Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+10
View File
@@ -28,6 +28,10 @@ const LAYER_HATCH = "HATCH";
const LAYER_SYMBOLS = "SYMBOLS";
const LAYER_CONTEXT = "CONTEXT";
const LAYER_DEFAULT = "PLAN";
/** Sammel-Layer für Extrusions-Footprints (truck-Integration) — die Quell-
* Drawing2D wird beim Extrudieren entfernt, es gibt also keine categoryCode
* mehr nachzuschlagen; eigener Layer statt Absturz in LAYER_DEFAULT. */
const LAYER_EXTRUSION = "EXTRUSION";
/**
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
@@ -60,6 +64,8 @@ export function buildPlanDxf(
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
// Warmes Orange, identisch zum 3D-/Grundriss-Footprint der Extrusionen (EXTRUSION_STROKE/EXTRUSION_RGB).
dxf.addLayer({ name: LAYER_EXTRUSION, color: aciFromHex("#d98c40"), trueColor: rgbFromHex("#d98c40"), lineWeight: 13 });
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
@@ -68,11 +74,15 @@ export function buildPlanDxf(
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
const codeByDrawing = new Map<string, string>();
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
const codeByColumn = new Map<string, string>();
for (const c of project.columns ?? []) codeByColumn.set(c.id, c.categoryCode);
const layerFor = (p: Primitive): string => {
if (p.kind === "polygon") {
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
if (p.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT;
if (p.extrudedSolidId) return LAYER_EXTRUSION;
return LAYER_DEFAULT;
}
if (p.kind === "line") {
+327
View File
@@ -0,0 +1,327 @@
// Unit-Tests für den IFC4-Export (exportIfcSpf) — erste vollständige Scheibe.
// • Strukturelle Validität: KEINE dangling references (jede #N-Referenz ist
// definiert), KEINE doppelten Entity-IDs — der wichtigste Test.
// • Header/FILE_SCHEMA('IFC4') vorhanden.
// • Je "floor"-Geschoss genau ein IfcBuildingStorey.
// • Wandanzahl → IfcWall-Anzahl.
// • Eine Öffnung ⇒ IfcOpeningElement + IfcRelVoidsElement (+ IfcDoor/
// IfcWindow + IfcRelFillsElement).
// • GUID-Format (22 Zeichen, gültiger Zeichensatz), deterministisch.
// • Leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash).
//
// Fixture-Muster gespiegelt von exportSchedule.test.ts.
import { describe, it, expect } from "vitest";
import { exportIfcSpf, ifcGuid } from "./exportIfc";
import type {
Ceiling,
ExtrudedSolid,
Opening,
Project,
Stair,
Wall,
} from "../model/types";
/**
* Minimalprojekt: 2 Geschosse (EG + OG, EG auch ein "section"-Level, das
* NICHT zu einem Storey werden darf) + 2 Wände (Wandtyp T=0.4) + 1 Decke
* (Deckentyp T=0.2) + 1 Tür + 1 Fenster (an W1 gehostet) + 1 Treppe +
* 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const openings: Opening[] = [
{
id: "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "door",
position: 1,
width: 0.9,
height: 2.1,
sillHeight: 0,
},
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5,
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3,
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
return {
id: "t",
name: "Testprojekt",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 2.6 },
{ id: "schnitt-a", name: "Schnitt A", kind: "section", visible: true, locked: false, linePoints: [{ x: 0, y: 0 }, { x: 1, y: 0 }], directionSign: 1 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings,
ceilings,
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Sammelt alle definierten Entity-IDs (`#N=`) und alle referenzierten `#N`. */
function collectIds(spf: string): { defined: Set<number>; referenced: Set<number>; duplicates: number[] } {
const defined = new Set<number>();
const duplicates: number[] = [];
const referenced = new Set<number>();
for (const line of spf.split("\n")) {
const defMatch = /^#(\d+)=/.exec(line);
if (defMatch) {
const id = Number(defMatch[1]);
if (defined.has(id)) duplicates.push(id);
defined.add(id);
}
const refs = line.matchAll(/#(\d+)/g);
for (const r of refs) {
// Der erste Treffer je Zeile ist ggf. die Definition selbst — trotzdem
// harmlos mitgezählt, da sie ja in `defined` steht (Selbstreferenz-Check
// unten prüft nur: JEDE referenzierte ID muss iRGENDWO definiert sein).
referenced.add(Number(r[1]));
}
}
return { defined, referenced, duplicates };
}
describe("exportIfcSpf — IFC4-Export", () => {
it("erzeugt keine dangling references und keine doppelten Entity-IDs", () => {
const spf = exportIfcSpf(fixtureProject());
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
const dangling = [...referenced].filter((id) => !defined.has(id));
expect(dangling).toEqual([]);
expect(defined.size).toBeGreaterThan(0);
});
it("trägt einen gültigen IFC4-Header", () => {
const spf = exportIfcSpf(fixtureProject());
expect(spf).toContain("ISO-10303-21;");
expect(spf).toContain("FILE_SCHEMA(('IFC4'));");
expect(spf).toContain("END-ISO-10303-21;");
});
it("erzeugt je 'floor'-Geschoss genau ein IfcBuildingStorey (Schnitte NICHT)", () => {
const spf = exportIfcSpf(fixtureProject());
const storeyLines = spf.split("\n").filter((l) => l.includes("=IFCBUILDINGSTOREY("));
expect(storeyLines).toHaveLength(2); // EG + OG, NICHT "Schnitt A"
expect(storeyLines.some((l) => l.includes("'EG'"))).toBe(true);
expect(storeyLines.some((l) => l.includes("'OG'"))).toBe(true);
});
it("bildet jede Wand auf genau ein IfcWall ab", () => {
const spf = exportIfcSpf(fixtureProject());
const wallLines = spf.split("\n").filter((l) => l.includes("=IFCWALL("));
expect(wallLines).toHaveLength(2);
});
it("Tür/Fenster bleiben eigene Objekte (IfcDoor/IfcWindow); das Loch steckt im Wand-Mesh (kein IfcOpeningElement/Void/Fill)", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Tür + Fenster als eigene Objekte erhalten.
expect(lines.filter((l) => l.includes("=IFCDOOR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCWINDOW("))).toHaveLength(1);
// Bewusste Abwägung: Wand ist jetzt ein Face-Set mit ausgeschnittenem Loch —
// die frühere IfcOpeningElement-Void/Fill-Semantik entfällt (siehe Dateikopf).
expect(lines.filter((l) => l.includes("=IFCOPENINGELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELVOIDSELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELFILLSELEMENT("))).toHaveLength(0);
});
it("bildet Wände als IfcTriangulatedFaceSet (IfcCartesianPointList3D + CoordIndex) statt Profil-Extrusion ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Genau ein Face-Set + eine Punktliste je Wand (2 Wände).
expect(lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("))).toHaveLength(2);
expect(lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("))).toHaveLength(2);
// Wand-Shape ist als Tessellation deklariert (nicht mehr SweptSolid).
expect(spf).toContain("'Tessellation'");
// Face-Set-CoordIndex referenziert 1-basierte Punkt-Indizes (Tripel-Listen).
const fs = lines.find((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(fs).toMatch(/\(\(\d+,\d+,\d+\)/);
});
it("bildet Decke, Treppe und Extrusion auf die erwarteten Entity-Typen ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
expect(lines.filter((l) => l.includes("=IFCSLAB("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCSTAIR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCBUILDINGELEMENTPROXY("))).toHaveLength(1);
});
it("das Wand-Face-Set enthält das ausgeschnittene Fenster-Loch (Loch-Vertices + volle Dreieckszahl, keine dangling refs)", () => {
// W2 hat das Fenster F1 (position 1, width 1.2, sill 0.9, height 1.5) → das
// Loch liegt voll im Wand-Inneren; das Face-Set der Wand hat exakt die
// Rust-Zerlegung: 8 Langseiten-Teilrechtecke ×2×2 + 8 (Deckel/Boden/Kappen)
// + 8 (4 Laibungen) = 48 Dreiecke, 144 Punkte.
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
// Das Loch-Face-Set hat 48 CoordIndex-Tripel.
const triCounts = faceSets.map((l) => (l.match(/\(\d+,\d+,\d+\)/g) ?? []).length);
expect(triCounts).toContain(48);
// Zugehörige Punktliste hat 144 Punkte (3er-Koordinaten-Tupel).
const pointLists = lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("));
const ptCounts = pointLists.map((l) => (l.match(/\([^()]*,[^()]*,[^()]*\)/g) ?? []).length);
expect(ptCounts).toContain(144);
// Keine dangling refs (der Kern-Invarianten-Check gilt auch mit Face-Sets).
const { defined, referenced } = collectIds(spf);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
it("Wand-Face-Sets sind NACH AUSSEN orientiert (positives Volumen) — Regression gegen die Reflexions-Wicklung, die die Wand hohl machte", () => {
// Der IFC-Achsen-Swap (x,y,z)→(x,z,y) ist eine Reflexion und kehrte die
// Dreiecks-Wicklung um → Normalen zeigten nach INNEN → Viewer cullten die
// Vorderseiten → Wand wirkte oben/unten offen. Nach dem Wicklungs-Ausgleich
// muss das signierte Volumen jedes Wandkörpers POSITIV sein (aussen orientiert).
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const byId = new Map<number, string>();
for (const l of lines) {
const m = /^#(\d+)=/.exec(l);
if (m) byId.set(Number(m[1]), l);
}
const parsePoints = (line: string): number[][] =>
[...line.matchAll(/\(([-\d.]+),([-\d.]+),([-\d.]+)\)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
Number(m[3]),
]);
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(faceSets.length).toBeGreaterThan(0);
let closedCount = 0;
for (const fs of faceSets) {
const ptListId = Number(/=IFCTRIANGULATEDFACESET\(#(\d+),/.exec(fs)![1]);
const closed = /=IFCTRIANGULATEDFACESET\(#\d+,\$,([^,]+),/.exec(fs)![1];
if (closed === ".T.") closedCount++;
const pts = parsePoints(byId.get(ptListId)!);
const tris = [...fs.matchAll(/\((\d+),(\d+),(\d+)\)/g)].map((m) => [
Number(m[1]) - 1,
Number(m[2]) - 1,
Number(m[3]) - 1,
]);
// 6× signiertes Volumen Σ v0·(v1×v2): > 0 ⇒ Normalen zeigen nach aussen.
let vol6 = 0;
for (const [a, b, c] of tris) {
const [ax, ay, az] = pts[a];
const [bx, by, bz] = pts[b];
const [cx, cy, cz] = pts[c];
vol6 += ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx);
}
expect(vol6).toBeGreaterThan(0);
}
// Beide Wände sind geschlossene Prisma-Körper (Fenster = Durchgangsloch,
// Tür = umlaufende П-Kerbe) → Closed=.T. bei beiden.
expect(closedCount).toBe(2);
});
it("GUIDs sind 22 Zeichen lang, nutzen den gültigen IFC-Zeichensatz und sind deterministisch", () => {
const guid = ifcGuid("W1");
expect(guid).toHaveLength(22);
expect(guid).toMatch(/^[0-9A-Za-z_$]{22}$/);
expect(ifcGuid("W1")).toBe(guid); // stabil über Re-Export
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
});
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
proj.drawingLevels = [];
const spf = exportIfcSpf(proj);
expect(spf).toContain("=IFCPROJECT(");
expect(spf).toContain("=IFCSITE(");
expect(spf).toContain("=IFCBUILDING(");
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
});
+687
View File
@@ -0,0 +1,687 @@
// IFC4-Export (STEP Physical File / ISO-10303-21) des semantischen Modells.
// Reiner Rechen-/Serialisierungskern: keine UI, kein Datei-IO, kein WASM, keine
// neue Dependency — IFC wird direkt als Text geschrieben (analog exportDxf.ts/
// exportSchedule.ts). Der Download (Blob+Anchor) passiert im App-Layer.
//
// Abbildung (erste vollständige Scheibe):
// Project → IfcProject → IfcSite → IfcBuilding → je "floor"-Geschoss ein
// IfcBuildingStorey (IfcRelAggregates-Kette). Bauteile hängen über
// IfcRelContainedInSpatialStructure am jeweiligen Geschoss (verwaiste
// Geschossreferenzen fallen defensiv auf IfcBuilding zurück).
//
// Decken/Treppen/Extrusionen als IfcExtrudedAreaSolid (unser Modell IST
// Extrusion): Profil = IfcArbitraryClosedProfileDef(IfcPolyline) in der
// XY-Ebene, extrudiert entlang +Z. Die horizontale Objekt-Platzierungskette
// (Site/Building/Storey/Element) trägt bewusst NUR die Z-Verschiebung
// (Geschoss-Elevation); die Profilpunkte tragen direkt die Welt-X/Y-Koordinaten.
//
// • Wand → IfcWall mit ÖFFNUNGSGENAUEM Dreiecks-Mesh (IfcTriangulatedFace
// Set, IFC4: IfcCartesianPointList3D + CoordIndex) statt einer Profil-
// Extrusion. Gespeist aus DEMSELBEN Loch-Ausschnitt-Mesh wie STL/OBJ
// (`pickGeometry` → `plan/wallMeshCut.ts`): Joins/Gehrungen UND ausgeschnittene
// Fenster/Türen (inkl. Laibungen) sind im Körper enthalten. ABWÄGUNG (bewusst,
// Nutzer-Priorität "so wie im 3D"): dadurch verliert die Wand die parametrische
// IfcWall-Profil-Extrusion + IfcOpeningElement-Void-Semantik zugunsten
// VISUELLER PARITÄT in JEDEM Viewer (der Loch schon im Mesh sieht, ohne eine
// Boolean-Subtraktion ausführen zu müssen — genau der Bug des Nutzers: "das
// Fenster ist als Objekt da im IFC, aber die Löcher sind nicht da").
// • Decke → IfcSlab (outline-Polygon, PredefinedType FLOOR).
// • Öffnung → KEIN IfcOpeningElement/Void mehr (das Loch steckt im Wand-Mesh);
// Tür/Fenster bleiben als eigenes Objekt IfcDoor/IfcWindow mit eigener Box-
// Geometrie erhalten (füllt das ausgeschnittene Loch, "sieht aus wie 3D").
// • Extrusion → IfcBuildingElementProxy aus points+height.
// • Treppe → IfcStair, GEOMETRISCH bewusst vereinfacht auf einen
// extrudierten Bounding-Footprint (Lauf-Rechteck bei "straight"; Achsen-
// ausgerichtete Bounding-Box der Kontrollpunkte bei "L"/"spiral") — die
// echte Stufengeometrie ist ausgelassen (siehe stairFootprint()).
//
// Material-Layer (IfcMaterialLayerSet/-Usage) sind NICHT enthalten — die
// korrekte Direction/Offset-Semantik von IfcMaterialLayerSetUsage ließ sich
// ohne Gegenprüfung an einem echten Viewer nicht mit ausreichender Sicherheit
// umsetzen; Geometrie/Hierarchie hatten Vorrang (siehe Bericht/PENDENZEN).
//
// GUIDs: deterministisch aus der Element-ID über einen 128-Bit-Hash (zwei
// FNV-1a-64-Läufe) + Standard-IFC-GUID-Kompression (Base64-Variante,
// Zeichensatz 0-9,A-Z,a-z,_,$) — stabil über Re-Exporte hinweg.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
import type {
Opening,
Project,
Stair,
Vec2,
Wall,
} from "../model/types";
import {
getCeilingType,
getWallType,
openingLabel,
wallTypeThickness,
} from "../model/types";
import {
ceilingVerticalExtent,
stairVerticalExtent,
wallReferenceOffset,
wallVerticalExtent,
} from "../model/wall";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { isWatertight, wallCutMesh } from "../plan/wallMeshCut";
// ── IFC-GUID (Base64-Kompression, 22 Zeichen) ───────────────────────────────
// Standard-Kompressionsalgorithmus (IfcOpenShell guid.compress): das erste
// Byte des 128-Bit-Werts wird auf 2 Zeichen abgebildet, die restlichen 15
// Byte in 5 Dreiergruppen zu je 4 Zeichen — macht 2 + 5×4 = 22 Zeichen.
const IFC_GUID_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
/** Kodiert `v` big-endian in `len` IFC-GUID-Zeichen (Basis 64). */
function ifcGuidB64(v: number, len: number): string {
let out = "";
for (let i = len - 1; i >= 0; i--) {
out += IFC_GUID_CHARS[Math.floor(v / 64 ** i) % 64];
}
return out;
}
/** Komprimiert einen 128-Bit-Wert (32 Hex-Zeichen) zur 22-stelligen IFC-GUID. */
function compressGuidHex(hex32: string): string {
const bytes: number[] = [];
for (let i = 0; i < 32; i += 2) bytes.push(parseInt(hex32.slice(i, i + 2), 16));
let out = ifcGuidB64(bytes[0], 2);
for (let i = 1; i < 16; i += 3) {
const v = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += ifcGuidB64(v, 4);
}
return out;
}
/** FNV-1a-64 (BigInt) — reines Determinismus-/Streuungs-Werkzeug, keine Kryptografie. */
function fnv1a64(str: string, seed: bigint): bigint {
const prime = 0x100000001b3n;
const mask = 0xffffffffffffffffn;
let hash = seed & mask;
for (let i = 0; i < str.length; i++) {
hash ^= BigInt(str.charCodeAt(i));
hash = (hash * prime) & mask;
}
return hash;
}
/** Leitet aus einer stabilen Element-ID einen deterministischen 128-Bit-Hex-Wert ab. */
function idToHex32(id: string): string {
const h1 = fnv1a64(id, 0xcbf29ce484222325n);
const h2 = fnv1a64(`${id}salt`, 0x9e3779b97f4a7c15n);
return h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0");
}
/** Deterministische 22-stellige IFC-GUID aus einer beliebigen Element-ID. */
export function ifcGuid(id: string): string {
return compressGuidHex(idToHex32(id));
}
// ── STEP-Formatierung ───────────────────────────────────────────────────────
/** STEP-String-Literal ('…', Apostroph verdoppelt, Backslash verdoppelt). */
function S(s: string): string {
const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "''");
return `'${escaped}'`;
}
/** STEP-REAL-Literal — immer mit Dezimalpunkt, ohne unnötige Nachkommastellen. */
function R(x: number): string {
const v = Object.is(x, -0) ? 0 : x;
let s = v.toFixed(6);
s = s.replace(/0+$/, "");
if (s.endsWith(".")) s += "0";
if (!s.includes(".")) s += ".0";
return s;
}
/** STEP-Enumerationswert `.WERT.`. */
function ENUM(v: string): string {
return `.${v}.`;
}
/** STEP-Liste `(a,b,c)`. */
function LIST(items: string[]): string {
return `(${items.join(",")})`;
}
// ── STEP-Writer ──────────────────────────────────────────────────────────────
class StepWriter {
private lines: string[] = [];
private nextId = 1;
/** Schreibt eine neue Entity-Zeile und liefert ihre `#id`. */
add(type: string, params: string): number {
const id = this.nextId++;
this.lines.push(`#${id}=${type}(${params});`);
return id;
}
get entityLines(): readonly string[] {
return this.lines;
}
}
// ── Geometrie-Helfer (reines 2D-Vec2-Rechnen, Welt-Meter) ───────────────────
function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
function normalize(v: Vec2): Vec2 {
const len = Math.hypot(v.x, v.y) || 1;
return { x: v.x / len, y: v.y / len };
}
function leftNormal(u: Vec2): Vec2 {
return { x: -u.y, y: u.x };
}
function addScaled(p: Vec2, d: Vec2, s: number): Vec2 {
return { x: p.x + d.x * s, y: p.y + d.y * s };
}
/** Rechteck-Footprint einer Öffnung im Wandloch (volle Wanddicke tief), CCW. */
function openingFootprint(project: Project, wall: Wall, opening: Opening): Vec2[] {
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u);
const t = wallTypeThickness(getWallType(project, wall));
const off = wallReferenceOffset(wall, t);
const inner = -t / 2 + off;
const outer = t / 2 + off;
const a = addScaled(wall.start, u, opening.position);
const b = addScaled(wall.start, u, opening.position + opening.width);
return [
addScaled(a, n, inner),
addScaled(b, n, inner),
addScaled(b, n, outer),
addScaled(a, n, outer),
];
}
/**
* Vereinfachter Bounding-Footprint einer Treppe (bewusst NICHT die echte
* Stufen-/Podestkontur, siehe Dateikopf):
* • "straight" — echtes, ausgerichtetes Lauf-Rechteck (Länge × Breite).
* • "L"/"spiral" — achsenausgerichtete Bounding-Box der Kontrollpunkte
* (Start/Eckpunkt/Ende bzw. Wendel-Zentrum±Radius), um die halbe
* Laufbreite erweitert.
*/
function stairFootprint(stair: Stair): Vec2[] {
const halfW = Math.max(stair.width, 0) / 2;
if (stair.shape === "straight") {
const u = normalize(stair.dir);
const n = leftNormal(u);
const end = addScaled(stair.start, u, stair.runLength);
return [
addScaled(stair.start, n, -halfW),
addScaled(end, n, -halfW),
addScaled(end, n, halfW),
addScaled(stair.start, n, halfW),
];
}
const pts: Vec2[] = [stair.start];
const u = normalize(stair.dir);
const corner = addScaled(stair.start, u, stair.runLength);
pts.push(corner);
if (stair.shape === "L" && stair.run2Length && stair.turn) {
const n = leftNormal(u);
const turnDir: Vec2 = { x: n.x * stair.turn, y: n.y * stair.turn };
pts.push(addScaled(corner, turnDir, stair.run2Length));
}
if (stair.shape === "spiral" && stair.center) {
const r = (stair.radius ?? 0) + halfW;
const c = stair.center;
return [
{ x: c.x - r, y: c.y - r },
{ x: c.x + r, y: c.y - r },
{ x: c.x + r, y: c.y + r },
{ x: c.x - r, y: c.y + r },
];
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
minX -= halfW;
minY -= halfW;
maxX += halfW;
maxY += halfW;
return [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
}
// ── IFC4-Export ──────────────────────────────────────────────────────────────
interface StoreyRef {
entityId: number;
placementId: number;
baseElevation: number;
}
interface Structure {
entityId: number;
placementId: number;
baseElevation: number;
}
/**
* Baut aus einem Projekt einen vollständigen IFC4-SPF-String (STEP Physical
* File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal-
* IFC (Project/Site/Building, kein Crash).
*/
export function exportIfcSpf(project: Project): string {
const w = new StepWriter();
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
// Placement (Position aller ExtrudedAreaSolid — Profile tragen direkt
// Welt-X/Y, siehe Dateikopf).
const originPoint = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const extrudeDir = w.add("IFCDIRECTION", LIST([R(0), R(0), R(1)]));
const identityAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
// Owner-History (minimal, aber vorhanden — manche Importer verlangen sie).
const org = w.add("IFCORGANIZATION", `$,${S("dossier")},$,$,$`);
const person = w.add(
"IFCPERSON",
`${S("dossier")},$,$,$,$,$,$,$`,
);
const personOrg = w.add("IFCPERSONANDORGANIZATION", `#${person},#${org},$`);
const app = w.add(
"IFCAPPLICATION",
`#${org},${S("1.0")},${S("dossier")},${S("dossier")}`,
);
const ownerHistory = w.add(
"IFCOWNERHISTORY",
`#${personOrg},#${app},$,${ENUM("ADDED")},$,$,$,${Math.floor(Date.now() / 1000)}`,
);
// Einheiten (Meter, Radiant, m², m³).
const lenUnit = w.add("IFCSIUNIT", `*,${ENUM("LENGTHUNIT")},$,${ENUM("METRE")}`);
const areaUnit = w.add("IFCSIUNIT", `*,${ENUM("AREAUNIT")},$,${ENUM("SQUARE_METRE")}`);
const volUnit = w.add("IFCSIUNIT", `*,${ENUM("VOLUMEUNIT")},$,${ENUM("CUBIC_METRE")}`);
const angleUnit = w.add(
"IFCSIUNIT",
`*,${ENUM("PLANEANGLEUNIT")},$,${ENUM("RADIAN")}`,
);
const unitAssignment = w.add(
"IFCUNITASSIGNMENT",
LIST([`#${lenUnit}`, `#${areaUnit}`, `#${volUnit}`, `#${angleUnit}`]),
);
// Geometrischer Kontext (3D, Precision 1e-5).
const context = w.add(
"IFCGEOMETRICREPRESENTATIONCONTEXT",
`$,${S("Model")},3,${R(0.00001)},#${identityAxis},$`,
);
// Räumliche Hierarchie: Project → Site → Building → Storeys.
const siteAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const sitePlacement = w.add("IFCLOCALPLACEMENT", `$,#${siteAxis}`);
const buildingAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const buildingPlacement = w.add(
"IFCLOCALPLACEMENT",
`#${sitePlacement},#${buildingAxis}`,
);
const projectId = w.add(
"IFCPROJECT",
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
);
const siteId = w.add(
"IFCSITE",
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,$,$,$`,
);
const buildingId = w.add(
"IFCBUILDING",
`${S(ifcGuid(`${project.id}:building`))},#${ownerHistory},${S(project.name || "Gebäude")},$,$,#${buildingPlacement},$,$,${ENUM("ELEMENT")},$,$,$`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-site`))},#${ownerHistory},$,$,#${projectId},${LIST([`#${siteId}`])}`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-building`))},#${ownerHistory},$,$,#${siteId},${LIST([`#${buildingId}`])}`,
);
// Je "floor"-Geschoss ein IfcBuildingStorey (Elevation = baseElevation).
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
const storeyByFloorId = new Map<string, StoreyRef>();
const storeyEntityIds: number[] = [];
for (const floor of floors) {
const base = floor.baseElevation ?? 0;
const pt = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(base)]));
const axis = w.add("IFCAXIS2PLACEMENT3D", `#${pt},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${buildingPlacement},#${axis}`);
const entityId = w.add(
"IFCBUILDINGSTOREY",
`${S(ifcGuid(`${floor.id}:storey`))},#${ownerHistory},${S(floor.name)},$,$,#${placementId},$,$,${ENUM("ELEMENT")},${R(base)}`,
);
storeyByFloorId.set(floor.id, { entityId, placementId, baseElevation: base });
storeyEntityIds.push(entityId);
}
if (storeyEntityIds.length > 0) {
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-storeys`))},#${ownerHistory},$,$,#${buildingId},${LIST(storeyEntityIds.map((id) => `#${id}`))}`,
);
}
/** Geschoss → Trägerstruktur (Storey), oder defensiv das Gebäude (verwaiste floorId). */
const resolveStructure = (floorId: string): Structure => {
const s = storeyByFloorId.get(floorId);
if (s) return { entityId: s.entityId, placementId: s.placementId, baseElevation: s.baseElevation };
return { entityId: buildingId, placementId: buildingPlacement, baseElevation: 0 };
};
// Räumliche Eingliederung sammelt sich je Trägerstruktur (Storey/Building)
// und wird am Ende in EINE IfcRelContainedInSpatialStructure je Struktur
// gebündelt (Öffnungen NICHT — die hängen nur über RelVoidsElement an ihrer
// Wand, wie in IFC üblich).
const containment = new Map<number, number[]>();
const addToContainment = (structureId: number, elementId: number): void => {
const arr = containment.get(structureId);
if (arr) arr.push(elementId);
else containment.set(structureId, [elementId]);
};
/** Baut Profil+Extrusion+Shape+Placement für einen geschlossenen Footprint. */
const emitBoxProduct = (
footprint: Vec2[],
zBottomRel: number,
depth: number,
placementRelTo: number,
): { placementId: number; shapeId: number } => {
const closed = [...footprint, footprint[0]];
const ptIds = closed.map((p) => w.add("IFCCARTESIANPOINT", LIST([R(p.x), R(p.y)])));
const polylineId = w.add("IFCPOLYLINE", LIST(ptIds.map((id) => `#${id}`)));
const profileId = w.add(
"IFCARBITRARYCLOSEDPROFILEDEF",
`${ENUM("AREA")},$,#${polylineId}`,
);
const solidId = w.add(
"IFCEXTRUDEDAREASOLID",
`#${profileId},#${identityAxis},#${extrudeDir},${R(Math.max(depth, 0.001))}`,
);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("SweptSolid")},${LIST([`#${solidId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(zBottomRel)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/**
* Baut aus einem Dreiecks-Mesh (`positions` flach x,y,z in IFC-Koordinaten —
* bereits Z-up und relativ zur `placementRelTo`-Herkunft, `indices` je 3 =
* 1-basiert-1 CoordIndex-Tripel) ein IfcTriangulatedFaceSet + Shape + Placement.
* IFC4-Tessellierung: IfcCartesianPointList3D (CoordList) + IfcTriangulatedFace
* Set (CoordIndex, 1-basiert). Das Element-Placement sitzt im Ursprung der
* Trägerstruktur (die Punkte tragen die Geometrie bereits absolut in deren Frame).
*/
const emitTriangulatedProduct = (
positions: number[],
indices: number[],
placementRelTo: number,
closed: boolean,
): { placementId: number; shapeId: number } => {
const coords: string[] = [];
for (let i = 0; i < positions.length; i += 3) {
coords.push(`(${R(positions[i])},${R(positions[i + 1])},${R(positions[i + 2])})`);
}
const pointListId = w.add("IFCCARTESIANPOINTLIST3D", `(${coords.join(",")})`);
const tris: string[] = [];
for (let i = 0; i < indices.length; i += 3) {
tris.push(`(${indices[i] + 1},${indices[i + 1] + 1},${indices[i + 2] + 1})`);
}
// Closed=.T. NUR wenn das Mesh nachweislich ein dichtes, aussen orientiertes
// Volumen ist (siehe isWatertight): Wände sind extrudierte Querschnitts-
// Polygone (Fenster = Durchgangsloch, Tür = umlaufende П-Kerbe) → geschlossene
// Körper → `.T.` (Viewer rendern sie als Solid statt als offene Fläche). Der
// Guard fängt echte Defekte ab (invertierte Wicklung → Volumen < 0 → `$`).
// Normals=$ (Viewer leitet sie aus der — jetzt aussen orientierten — Wicklung ab).
const closedFlag = closed ? ".T." : "$";
const faceSetId = w.add("IFCTRIANGULATEDFACESET", `#${pointListId},$,${closedFlag},(${tris.join(",")}),$`);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("Tessellation")},${LIST([`#${faceSetId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/** Baut ein IfcBuildingElement-Subtyp mit dem üblichen 9-Attribut-Flatten. */
const emitBuildingElement = (
type: string,
guid: string,
name: string,
placementId: number,
shapeId: number,
predefinedType: string | null,
): number =>
w.add(
type,
`${S(guid)},#${ownerHistory},${S(name)},$,$,#${placementId},#${shapeId},$,${predefinedType ?? "$"}`,
);
// ── Wände (öffnungsgenaues Dreiecks-Mesh statt Profil-Extrusion) ────────
// Die Wand-Schicht-Bänder kommen aus DEMSELBEN geflachten Modell wie STL/OBJ
// (`pickGeometry`, Joins/Gehrungen + Öffnungs-`holes` bereits aufgelöst). Alle
// Bänder einer Wand-Id werden zu EINEM Face-Set vereint. IFC-Koordinaten: das
// Mesh liegt in Welt (Modell-x, Höhe, Modell-y) mit Y-up → IFC (x, y, z=Höhe)
// mit Z-up, also (mx, mz, my); Z relativ zur Geschoss-UK, damit die Storey-
// Placement-Elevation nicht doppelt zählt.
//
// WICHTIG — WICKLUNG: der Achsen-Swap (x,y,z)→(x,z,y) ist eine REFLEXION
// (Determinante 1) und KEHRT die Dreiecks-Wicklung UM → aus aussen orientierten
// würden innen orientierte Normalen, der Viewer cullt dann die Vorderseiten und
// die Wand wirkt HOHL/offen (genau der gemeldete Bug). Deshalb wird beim Swap
// die Wicklung jedes Dreiecks umgedreht (i0,i2,i1), damit die Aussen-Normalen
// aussen bleiben. Watertightness (isWatertight) misst das anschliessend am
// fertigen IFC-Mesh → treibt das Closed-Flag des Face-Sets.
const bandsByWallId = new Map<string, RWall[]>();
for (const band of pickGeometry(project).walls) {
const list = bandsByWallId.get(band.wallId);
if (list) list.push(band);
else bandsByWallId.set(band.wallId, [band]);
}
const wallEntityIdByWallId = new Map<string, number>();
for (const wall of project.walls ?? []) {
const bands = bandsByWallId.get(wall.id);
if (!bands || bands.length === 0) continue; // degenerierte Wand / keine Geometrie
const structure = resolveStructure(wall.floorId);
const positions: number[] = [];
const indices: number[] = [];
for (const band of bands) {
const cut = wallCutMesh(band);
const base = positions.length / 3;
for (let i = 0; i < cut.positions.length; i += 3) {
positions.push(
cut.positions[i],
cut.positions[i + 2],
cut.positions[i + 1] - structure.baseElevation,
);
}
// Wicklung umkehren (Reflexions-Ausgleich, s. o.): (a,b,c) → (a,c,b).
for (let i = 0; i < cut.indices.length; i += 3) {
indices.push(base + cut.indices[i], base + cut.indices[i + 2], base + cut.indices[i + 1]);
}
}
if (indices.length === 0) continue;
const closed = isWatertight({ positions, indices });
const { placementId, shapeId } = emitTriangulatedProduct(positions, indices, structure.placementId, closed);
let name = wall.id;
try {
name = getWallType(project, wall).name;
} catch {
/* verwaister Wandtyp — Roh-ID als Name */
}
const entityId = emitBuildingElement("IFCWALL", ifcGuid(wall.id), name, placementId, shapeId, null);
wallEntityIdByWallId.set(wall.id, entityId);
addToContainment(structure.entityId, entityId);
}
// ── Decken ─────────────────────────────────────────────────────────────
for (const ceiling of project.ceilings ?? []) {
if (ceiling.outline.length < 3) continue;
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const structure = resolveStructure(ceiling.floorId);
const { placementId, shapeId } = emitBoxProduct(
ceiling.outline,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
let name: string = ceiling.id;
try {
name = getCeilingType(project, ceiling).name;
} catch {
/* verwaister Deckentyp — Roh-ID als Name */
}
const entityId = emitBuildingElement(
"IFCSLAB",
ifcGuid(ceiling.id),
name,
placementId,
shapeId,
ENUM("FLOOR"),
);
addToContainment(structure.entityId, entityId);
}
// ── Fenster/Türen als eigene Objekte (IfcDoor/IfcWindow) ────────────────
// KEIN IfcOpeningElement/IfcRelVoidsElement/IfcRelFillsElement mehr: das Loch
// steckt bereits im Wand-Face-Set (s. o.). Eine Void-Relation beschriebe eine
// Boolean-Subtraktion gegen einen Swept-Solid, den es nicht mehr gibt — sie
// brächte in den Viewern nur Verwirrung (der Nutzer-Bug war genau, dass die
// Void nicht subtrahiert wurde). Tür/Fenster bleiben als EIGENES Objekt mit
// eigener Box-Geometrie erhalten, die das ausgeschnittene Loch füllt ("sieht
// aus wie 3D"): kein Blatt-/Rahmendetail (bewusste Vereinfachung).
for (const opening of project.openings ?? []) {
const wall = (project.walls ?? []).find((wl) => wl.id === opening.hostWallId);
if (!wall) continue; // verwaiste Wirtswand — keine Geometrie ableitbar
if (!wallEntityIdByWallId.has(wall.id)) continue; // Wirtswand übersprungen (degeneriert)
const wallExtent = wallVerticalExtent(project, wall);
const structure = resolveStructure(wall.floorId);
const footprint = openingFootprint(project, wall, opening);
const zSillAbs = wallExtent.zBottom + opening.sillHeight;
const { placementId: fillPlacement, shapeId: fillShape } = emitBoxProduct(
footprint,
zSillAbs - structure.baseElevation,
opening.height,
structure.placementId,
);
const fillGuid = ifcGuid(`${opening.id}:fill`);
const fillName = openingLabel(opening);
const fillEntityId =
opening.kind === "door"
? w.add(
"IFCDOOR",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("DOOR")},$,$`,
)
: w.add(
"IFCWINDOW",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("WINDOW")},$,$`,
);
addToContainment(structure.entityId, fillEntityId);
}
// ── Treppen (vereinfachter Bounding-Footprint, siehe Dateikopf) ────────
for (const stair of project.stairs ?? []) {
const footprint = stairFootprint(stair);
if (footprint.length < 3) continue;
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const structure = resolveStructure(stair.floorId);
const { placementId, shapeId } = emitBoxProduct(
footprint,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
const predefinedType =
stair.shape === "straight"
? ENUM("STRAIGHT_RUN_STAIR")
: stair.shape === "spiral"
? ENUM("SPIRAL_STAIR")
: ENUM("QUARTER_TURN_STAIR");
const entityId = emitBuildingElement(
"IFCSTAIR",
ifcGuid(stair.id),
"Treppe",
placementId,
shapeId,
predefinedType,
);
addToContainment(structure.entityId, entityId);
}
// ── Extrudierte Körper (truck-Integration) → IfcBuildingElementProxy ───
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3) continue;
const floor = floors.find((f) => f.id === solid.levelId);
const base = floor?.baseElevation ?? 0;
const structure = resolveStructure(solid.levelId);
const { placementId, shapeId } = emitBoxProduct(
solid.points,
base - structure.baseElevation,
solid.height,
structure.placementId,
);
const entityId = emitBuildingElement(
"IFCBUILDINGELEMENTPROXY",
ifcGuid(solid.id),
"Extrusion",
placementId,
shapeId,
null,
);
addToContainment(structure.entityId, entityId);
}
// ── Räumliche Eingliederung (gebündelt je Trägerstruktur) ──────────────
for (const [structureId, elementIds] of containment) {
w.add(
"IFCRELCONTAINEDINSPATIALSTRUCTURE",
`${S(ifcGuid(`contain:${structureId}`))},#${ownerHistory},$,$,${LIST(elementIds.map((id) => `#${id}`))},#${structureId}`,
);
}
// ── Kopf + Zusammenbau ───────────────────────────────────────────────────
const iso = new Date().toISOString().replace(/\.\d+Z$/, "");
const fileName = `${project.name || "modell"}.ifc`;
const header = [
"ISO-10303-21;",
"HEADER;",
`FILE_DESCRIPTION(${LIST([S("")])},${S("2;1")});`,
`FILE_NAME(${S(fileName)},${S(iso)},${LIST([S("dossier")])},${LIST([S("dossier")])},${S("dossier")},${S("dossier")},${S("")});`,
"FILE_SCHEMA(('IFC4'));",
"ENDSEC;",
"",
"DATA;",
];
const footer = ["ENDSEC;", "END-ISO-10303-21;"];
return [...header, ...w.entityLines, ...footer].join("\n") + "\n";
}
+306
View File
@@ -0,0 +1,306 @@
// Unit-Tests für den STL-/OBJ-Mesh-Export (reines Modul, siehe exportMesh.ts).
// • OBJ: ≥1 v/f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich.
// • STL: solid/endsolid-Rahmen, Facettenzahl = Dreieckszahl, je Facette genau 3 vertex-Zeilen.
// • Dreieckszahl-Plausibilität: isolierte Wand-Box (12) und isoliertes N-Eck-Prisma (4N-4).
// • leeres Projekt ⇒ gültige leere Datei (kein Crash).
import { describe, it, expect } from "vitest";
import { exportObj, exportStl } from "./exportMesh";
import type { Project, Wall, Ceiling, ExtrudedSolid, Opening } from "../model/types";
/** Gemeinsame Ressourcen-Basis (Komponente/Wandtyp/Deckentyp/Geschoss/Ebene). */
function baseProject(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
extrudedSolids: [],
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Eine einzelne, frei stehende Wand (kein Nachbar ⇒ kein Gehrungs-/Anschlussschnitt). */
function projectWithOneWall(): Project {
const p = baseProject();
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
p.walls = [wall];
return p;
}
/**
* Eine frei stehende Wand mit EINER Öffnung (Fenster ODER Tür). Das Fenster
* (sillHeight>0) liegt vollständig im Wand-Inneren (vier Laibungen); die Tür
* (sillHeight 0) berührt die Wand-UK (keine untere Laibung).
*/
function projectWithOpening(kind: "window" | "door"): Project {
const p = projectWithOneWall();
const opening: Opening = {
id: kind === "window" ? "F1" : "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind,
position: 2,
width: 1,
height: kind === "window" ? 1.5 : 2.1,
sillHeight: kind === "window" ? 0.9 : 0,
};
p.openings = [opening];
return p;
}
/** Eine einzelne Extrusion mit N-Eck-Profil (kein Wand-/Deckenkontext). */
function projectWithPolygonExtrusion(pts: { x: number; y: number }[], height = 2.5): Project {
const p = baseProject();
const solid: ExtrudedSolid = {
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: pts,
height,
};
p.extrudedSolids = [solid];
return p;
}
/** Kombiniertes Fixture-Projekt: 2 Wände (Eckstoss), 1 Decke, 1 Extrusion. */
function fixtureProject(): Project {
const p = baseProject();
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
p.walls = walls;
p.ceilings = ceilings;
p.extrudedSolids = extrudedSolids;
return p;
}
/** Parst die `v`-Zeilen eines OBJ-Strings zu Koordinaten-Tripeln. */
function parseObjVertices(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("v "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
/** Parst die `f`-Zeilen eines OBJ-Strings zu 1-basierten Index-Tripeln. */
function parseObjFaces(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("f "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
describe("exportObj — Wavefront-OBJ-Export", () => {
it("liefert ≥1 v und ≥1 f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich", () => {
const obj = exportObj(fixtureProject());
const verts = parseObjVertices(obj);
const faces = parseObjFaces(obj);
expect(verts.length).toBeGreaterThan(0);
expect(faces.length).toBeGreaterThan(0);
for (const v of verts) {
expect(v).toHaveLength(3);
for (const c of v) expect(Number.isFinite(c)).toBe(true);
}
for (const f of faces) {
expect(f).toHaveLength(3);
for (const idx of f) {
expect(idx).toBeGreaterThanOrEqual(1);
expect(idx).toBeLessThanOrEqual(verts.length);
}
}
});
it("gruppiert Bauteil-Vorkommen als o-Objekte (Wand/Decke/Extrusion)", () => {
const obj = exportObj(fixtureProject());
expect(obj).toContain("o Wand_W1");
expect(obj).toContain("o Wand_W2");
expect(obj).toContain("o Decke_D1");
expect(obj).toContain("o Extrusion_E1");
});
it("leeres Projekt ⇒ gültige Datei ohne v/f (kein Crash)", () => {
const obj = exportObj(baseProject());
expect(obj.split("\n").some((l) => l.startsWith("v "))).toBe(false);
expect(obj.split("\n").some((l) => l.startsWith("f "))).toBe(false);
expect(obj.length).toBeGreaterThan(0);
});
});
describe("exportStl — ASCII-STL-Export", () => {
it("hat solid/endsolid-Rahmen, je Facette genau 3 vertex-Zeilen, Facettenzahl = Dreieckszahl", () => {
const stl = exportStl(fixtureProject());
const lines = stl.split("\n").filter((l) => l.length > 0);
expect(lines[0]).toBe("solid dossier");
expect(lines[lines.length - 1]).toBe("endsolid dossier");
const facetCount = lines.filter((l) => l.startsWith("facet normal")).length;
const loopCount = lines.filter((l) => l === "outer loop").length;
const endloopCount = lines.filter((l) => l === "endloop").length;
const endfacetCount = lines.filter((l) => l === "endfacet").length;
const vertexCount = lines.filter((l) => l.startsWith("vertex ")).length;
expect(facetCount).toBeGreaterThan(0);
expect(loopCount).toBe(facetCount);
expect(endloopCount).toBe(facetCount);
expect(endfacetCount).toBe(facetCount);
expect(vertexCount).toBe(facetCount * 3);
});
it("leeres Projekt ⇒ gültiges leeres solid-Gerüst (kein Crash)", () => {
const stl = exportStl(baseProject());
expect(stl).toBe("solid dossier\nendsolid dossier\n");
});
it("Dreieckszahl-Plausibilität: eine frei stehende Wand ergibt genau 1 Box (12 Dreiecke, 8 Ecken)", () => {
const stl = exportStl(projectWithOneWall());
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(12);
const obj = exportObj(projectWithOneWall());
expect(parseObjVertices(obj)).toHaveLength(8);
});
it("Dreieckszahl-Plausibilität: ein N-Eck-Profil ergibt ein Prisma mit 4N-4 Dreiecken (2N Ecken)", () => {
// Rechteck (N=4): 2 Kappen à 2 Dreiecke + 4 Seitenquads à 2 Dreiecke = 12 = 4·44.
const rectStl = exportStl(
projectWithPolygonExtrusion([
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 1 },
]),
);
expect(rectStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(12);
// Konvexes Fünfeck (N=5): 4·54 = 16 Dreiecke, 10 Ecken.
const pentagon = [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2.5, y: 1.5 },
{ x: 1, y: 2.5 },
{ x: -0.5, y: 1.5 },
];
const pentaStl = exportStl(projectWithPolygonExtrusion(pentagon));
expect(pentaStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(16);
const pentaObj = exportObj(projectWithPolygonExtrusion(pentagon));
expect(parseObjVertices(pentaObj)).toHaveLength(10);
});
it("Fenster wird als echtes Loch ausgeschnitten (Gitter-Zerlegung + 4 Laibungen, 48 Dreiecke statt 12)", () => {
// Fenster [x 2..3] × [y 0.9..2.4] vollständig im Wand-Inneren. Erwartung
// (= Rust-Zerlegung `extrude_layer_segment_with_holes`): 8 solide Langseiten-
// Teilrechtecke (3×3-Gitter minus Loch) × 2 Seiten × 2 Dreiecke = 32,
// + Deckel/Boden/2 Stirnkappen = 8, + 4 Laibungen × 2 = 8 → 48 Dreiecke.
const stl = exportStl(projectWithOpening("window"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(48);
// Die volle Box hätte NUR Ecken bei y∈{0,2.6}, x∈{0,5}. Der Ausschnitt
// führt echte Vertices an den Loch-Rändern ein — Beweis, dass das Loch
// wirklich in der Fläche steckt (nicht bloss eine übergelegte Scheibe).
const verts = parseObjVertices(exportObj(projectWithOpening("window")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
expect(verts.some((v) => near(v[1], 0.9))).toBe(true); // Brüstungs-OK
expect(verts.some((v) => near(v[1], 2.4))).toBe(true); // Sturz-UK
expect(verts.some((v) => near(v[0], 2))).toBe(true); // linke Loch-Kante
expect(verts.some((v) => near(v[0], 3))).toBe(true); // rechte Loch-Kante
// Deutlich mehr Vertices als die 8-Ecken-Vollbox.
expect(verts.length).toBe(144);
});
it("Tür berührt die Wand-UK: keine untere Laibung (36 Dreiecke, keine Brüstungs-Zerlegung)", () => {
// Tür [x 2..3] × [y 0..2.1]: Boden bekommt die Lücke [2,3] (statt einer
// unteren Laibung) → 5 Langseiten-Teilrechtecke × 2 × 2 = 20, Deckel(1)=2,
// Boden(2 Streifen)=4, 2 Stirnkappen=4, 3 Laibungen (links/rechts/oben)=6 → 36.
const stl = exportStl(projectWithOpening("door"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(36);
const verts = parseObjVertices(exportObj(projectWithOpening("door")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
// Sturz-UK bei y=2.1 vorhanden, aber KEIN Brüstungsvertex (Tür sitzt am Boden).
expect(verts.some((v) => near(v[1], 2.1))).toBe(true);
expect(verts.every((v) => v[1] >= -1e-6 && v[1] <= 2.6 + 1e-6)).toBe(true);
});
it("facet-Normalen sind Einheitsvektoren aus dem Kreuzprodukt der Dreiecksecken", () => {
const stl = exportStl(projectWithOneWall());
const facetLines = stl.split("\n").filter((l) => l.startsWith("facet normal"));
for (const line of facetLines) {
const [nx, ny, nz] = line.slice("facet normal ".length).trim().split(/\s+/).map(Number);
const len = Math.hypot(nx, ny, nz);
expect(len).toBeGreaterThan(0.99);
expect(len).toBeLessThan(1.01);
}
});
});
+353
View File
@@ -0,0 +1,353 @@
// OBJ-/STL-Export des 3D-Gebäudemodells als reines Dreiecks-Mesh. Pures Modul
// (kein Datei-IO, kein WASM) — Architektur wie exportDxf.ts/exportSchedule.ts:
// buildGroups() liest das Projekt und liefert reine Zahlen-Arrays, exportObj()/
// exportStl() serialisieren sie synchron zu Text.
//
// TRIANGEL-QUELLE (bewusste v1-Entscheidung, siehe PENDENZEN):
// • Wände: pickGeometry() aus toWalls3d.ts liefert dieselben Schicht-Bänder,
// die auch der 3D-Viewer zeichnet (EIN RWall je Materiallage/Achsen-
// Teilstück, bereits mit End-Cuts/Gehrungen/Decken-Dominanz aufgelöst).
// Bänder mit Öffnungs-`holes` werden über den gemeinsamen Loch-Ausschnitt-
// Generator (`plan/wallMeshCut.ts`, TS-Port von render3ds Gitter-Zerlegung
// `extrude_layer_segment_with_holes`) ÖFFNUNGSGENAU trianguliert — die
// Fenster/Türen sind sichtbar ausgestanzt inkl. Laibungsflächen, genau wie
// im 3D-Viewer. Lochlose Bänder bleiben die klassische geteilte 8-Ecken-Box.
// • Decken: pickGeometry() liefert den Umriss + zBottom/zTop je Decke;
// der Umriss wird mit dem vorhandenen Ear-Clipping-Triangulator
// (`triangulate` aus plan/glPlan/glPlanCompile.ts, bereits getestet und
// konkav-fähig) zu Boden-/Deckenkappen trianguliert, dazwischen ein
// Seitenmantel — ein einfaches Umriss-Prisma.
// • ExtrudedSolids (truck-Profile): `src/engine/truckSolid.ts` liefert zwar
// "echte" truck-Dreiecke, ABER nur asynchron über WASM
// (`extrudePolygon`/`extrudeCircle` geben Promises zurück). Das würde die
// geforderte SYNCHRONE, WASM-freie Signatur `exportObj(project): string`
// brechen. Deshalb nutzt dieser Export stattdessen DIESELBE Prisma-
// Triangulierung wie die Decken (Umriss aus `solid.points` — bei
// Kreisprofilen bereits eine 48-Eck-Tessellierung, siehe
// `ExtrudedSolid.points`-Doc) inkl. `taper`-Verjüngung (linear zum
// Schwerpunkt skaliert, wie die truck-Extrusion es tut). Das ist eine
// bewusste Abweichung vom ursprünglichen Plan ("truckSolid-Dreiecke") zu-
// gunsten der pure-Modul-Architektur — ehrlich dokumentiert, kein
// Stillschweigen.
//
// ACHSEN-KONVENTION: identisch zur bestehenden 3D-Pick-/Highlight-Geometrie
// (siehe `selectionHighlightLines` in toWalls3d.ts): Modell (x, y) → Welt
// (x, Höhe, y), Y-UP, rechtshändig. Ein Weltvertex (vx,vy,vz) entspricht also
// (Modell-x, Meter-Höhe, Modell-y) — exakt wie im 3D-Viewer.
import type { Project, Vec2 } from "../model/types";
import { getFloor } from "../model/types";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { wallCutMesh } from "../plan/wallMeshCut";
import { triangulate } from "../plan/glPlan/glPlanCompile";
/**
* Eine Mesh-Gruppe (ein Bauteil-Vorkommen: Wand/Decke/Extrusion) mit LOKALEN
* (0-basierten) Vertex-/Dreiecks-Indizes. `exportObj` verschiebt die Indizes
* beim Serialisieren auf die globale, 1-basierte OBJ-Nummerierung; `exportStl`
* braucht gar keine Indizes über Gruppen hinweg (STL ist unindiziert).
*/
interface MeshGroup {
name: string;
/** Flaches Weltkoordinaten-Array (x,y,z, x,y,z, …), Meter, Y-up. */
positions: number[];
/** Dreiecks-Indizes (3 je Dreieck), 0-basiert relativ zu `positions`. */
indices: number[];
}
function newGroup(name: string): MeshGroup {
return { name, positions: [], indices: [] };
}
/** OBJ-Gruppennamen dürfen keine Whitespaces/Sonderzeichen enthalten. */
function sanitizeName(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, "_") || "x";
}
/**
* Die 6 Quader-Seiten als Eckpunkt-Quadrupel (Index-Bits: bit0=Achse /+,
* bit1=Höhe /+, bit2=Dicke /+ — dieselbe Konvention wie `emitOpeningGlass`/
* `selectionHighlightLines` in toWalls3d.ts), in nach AUSSEN gewundener
* Reihenfolge (für ein rechtshändiges Dreibein Achse×Höhe=Dicke-Normale, wie
* es die Wandbox-Konstruktion unten aufspannt). Jedes Quadrupel wird zu 2
* Dreiecken (0,1,2)+(0,2,3).
*/
const BOX_QUADS: Array<[number, number, number, number]> = [
[4, 6, 7, 5], // Dicke +
[1, 3, 2, 0], // Dicke
[2, 3, 7, 6], // Höhe + (Wandkopf)
[4, 5, 1, 0], // Höhe (Wand-UK)
[1, 5, 7, 3], // Achse + (Wandende)
[2, 6, 4, 0], // Achse (Wandanfang)
];
/**
* Hängt EIN Wand-Schicht-Band (`RWall`, siehe toWalls3d.ts) an eine Mesh-Gruppe
* an: hat das Band echte Öffnungs-`holes`, wird das öffnungsgenaue Loch-Ausschnitt-
* Mesh (siehe {@link wallCutMesh}) verwendet — die Fenster/Türen sind sichtbar
* ausgestanzt inkl. Laibungen, exakt wie im 3D-Viewer. Ohne Löcher bleibt es die
* klassische, geteilte 8-Ecken-Box ({@link pushWallBox}) — bitgleich zum
* bisherigen Verhalten (Joins/Gehrungen stecken schon in `start`/`end`).
*/
function pushWall(group: MeshGroup, w: RWall): void {
if (w.holes && w.holes.length > 0) {
const cut = wallCutMesh(w);
const base = group.positions.length / 3;
for (const p of cut.positions) group.positions.push(p);
for (const idx of cut.indices) group.indices.push(base + idx);
return;
}
pushWallBox(group, w);
}
/**
* Hängt EINEN vollen Wand-Schicht-Quader (`RWall`) an eine Mesh-Gruppe an — Box
* über Achse×Höhe×Dicke, OHNE Öffnungs-Ausschnitt (nur für lochlose Bänder, siehe
* {@link pushWall}). Degenerierte Bänder (Länge/Höhe/Dicke ≤ 0) werden übersprungen.
*/
function pushWallBox(group: MeshGroup, w: RWall): void {
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dz = ey - sy;
const len = Math.hypot(dx, dz);
if (len < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return;
const ax = dx / len;
const az = dz / len;
// Normale = Welt-Hoch × Achse (rechtshändig), dieselbe Konvention wie
// `selectionHighlightLines`: normal = (az, 0, ax).
const nx = az;
const nz = -ax;
const halfLen = len / 2;
const halfHeight = w.height / 2;
const halfThick = w.thickness / 2;
const cx = (sx + ex) / 2;
const cz = (sy + ey) / 2;
const cy = w.baseElevation + w.height / 2;
const base = group.positions.length / 3;
for (let i = 0; i < 8; i++) {
const sL = i & 1 ? 1 : -1;
const sH = i & 2 ? 1 : -1;
const sT = i & 4 ? 1 : -1;
group.positions.push(
cx + sL * halfLen * ax + sT * halfThick * nx,
cy + sH * halfHeight,
cz + sL * halfLen * az + sT * halfThick * nz,
);
}
for (const [a, b, c, d] of BOX_QUADS) {
group.indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
}
}
/** Signierte Fläche eines Modell-XY-Umrisses (Shoelace); ≥0 = CCW. */
function signedAreaXY(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
}
return a / 2;
}
/**
* Hängt ein extrudiertes Umriss-Prisma (Decke ODER truck-Profil-Extrusion,
* siehe Moduldoc) an eine Mesh-Gruppe an: Boden-/Deckkappe (Ear-Clipping-
* Triangulierung via `triangulate`) + Seitenmantel. `taper` (0..1) verjüngt
* den OBEREN Ring linear zum Flächenschwerpunkt — dieselbe Semantik wie
* `ExtrudedSolid.taper`/die truck-Extrusion (0 = Prisma, 1 = Spitze/Pyramide).
* Der Umriss wird intern auf CCW (Modell-XY) normalisiert, damit die
* Seitenmantel-Normalen unabhängig von der Eingabe-Wicklung nach außen zeigen.
*/
function pushPrism(
group: MeshGroup,
outline: Vec2[],
zBottom: number,
zTop: number,
taper = 0,
): void {
if (outline.length < 3 || zTop - zBottom <= 1e-9) return;
const ccw = signedAreaXY(outline) >= 0 ? outline : [...outline].reverse();
const n = ccw.length;
let cx = 0;
let cyModel = 0;
for (const p of ccw) {
cx += p.x;
cyModel += p.y;
}
cx /= n;
cyModel /= n;
const t = Math.min(1, Math.max(0, taper));
const base = group.positions.length / 3;
// Unterer Ring (base + 0..n-1), oberer Ring (base + n..2n-1).
for (const p of ccw) group.positions.push(p.x, zBottom, p.y);
for (const p of ccw) {
const tx = cx + (p.x - cx) * (1 - t);
const ty = cyModel + (p.y - cyModel) * (1 - t);
group.positions.push(tx, zTop, ty);
}
// Kappen: `triangulate` liefert bei CCW-Eingabe stets CCW-in-Modell-XY-
// Dreiecke — eingebettet in die Weltebene (x, const, y) entspricht das einer
// nach UNTEN (Y) zeigenden Flächennormale (Kreuzprodukt-Herleitung siehe
// Moduldoc-Kommentar oben). Die Bodenkappe nutzt diese Wicklung direkt
// (Normale zeigt nach unten = nach außen), die Deckkappe braucht die
// umgekehrte Wicklung (Normale nach oben).
const capTris = triangulate(ccw);
for (let i = 0; i < capTris.length; i += 3) {
const a = capTris[i];
const b = capTris[i + 1];
const c = capTris[i + 2];
group.indices.push(base + a, base + b, base + c);
group.indices.push(base + n + a, base + n + c, base + n + b);
}
// Seitenmantel: je Umrisskante ein Quad (2 Dreiecke), Wicklung so, dass die
// Normale nach außen zeigt (siehe Moduldoc-Herleitung).
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const bi = base + i;
const bj = base + j;
const ti = base + n + i;
const tj = base + n + j;
group.indices.push(bi, ti, tj);
group.indices.push(bi, tj, bj);
}
}
/** Baut alle Mesh-Gruppen (Wände, Decken, Extrusionen) des Projekts. */
function buildGroups(project: Project): MeshGroup[] {
const groups: MeshGroup[] = [];
const { walls, slabs } = pickGeometry(project);
// Wände: alle Schicht-Bänder DERSELBEN Wand-Id in EINE Gruppe (mehrere
// Materiallagen ergeben mehrere Boxen im selben Bauteil-Vorkommen).
const wallGroups = new Map<string, MeshGroup>();
for (const w of walls) {
let g = wallGroups.get(w.wallId);
if (!g) {
g = newGroup(`Wand_${sanitizeName(w.wallId)}`);
wallGroups.set(w.wallId, g);
groups.push(g);
}
pushWall(g, w);
}
// Decken: ein Prisma je Decke.
for (const s of slabs) {
const g = newGroup(`Decke_${sanitizeName(s.ceilingId)}`);
const outline: Vec2[] = s.outline.map(([x, y]) => ({ x, y }));
pushPrism(g, outline, s.zBottom, s.zTop);
groups.push(g);
}
// Extrudierte Profile (truck-Integration): eigenes Prisma je Körper (siehe
// Moduldoc — bewusst NICHT die truck-WASM-Dreiecke, aus Sync-/Pure-Gründen).
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3 || solid.height <= 0) continue;
let baseZ = 0;
try {
baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback, wie toWalls3d).
}
const g = newGroup(`Extrusion_${sanitizeName(solid.id)}`);
pushPrism(g, solid.points, baseZ, baseZ + solid.height, solid.taper ?? 0);
groups.push(g);
}
return groups.filter((g) => g.indices.length > 0);
}
/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */
function fmt(v: number): string {
if (!Number.isFinite(v)) return "0.0";
const s = v.toFixed(6);
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
}
/**
* Baut einen vollständigen Wavefront-OBJ-String des 3D-Gebäudemodells (Meter,
* rechtshändig, Y-up — siehe Moduldoc). Ein `o`-Objekt je Bauteil-Vorkommen
* (Wand/Decke/Extrusion). Leeres Projekt ⇒ gültige Datei nur mit Kopf-
* Kommentaren, kein `v`/`f` (kein Crash).
*/
export function exportObj(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = [
"# DOSSIER OBJ-Export - Dreiecks-Mesh des 3D-Gebaeudemodells",
"# Achsen: rechtshaendig, Y-up (Y = Hoehe ueber OKFF), wie der 3D-Viewer.",
"# Waende: Schicht-Baender mit oeffnungsgenau ausgeschnittenen Loechern (inkl. Laibungen).",
"# Decken/Extrusionen: Umriss-Prisma (Ear-Clipping-Triangulierung).",
];
let offset = 0;
for (const g of groups) {
const vertCount = g.positions.length / 3;
if (vertCount === 0) continue;
lines.push(`o ${g.name}`);
for (let i = 0; i < g.positions.length; i += 3) {
lines.push(`v ${fmt(g.positions[i])} ${fmt(g.positions[i + 1])} ${fmt(g.positions[i + 2])}`);
}
for (let i = 0; i < g.indices.length; i += 3) {
const a = g.indices[i] + 1 + offset;
const b = g.indices[i + 1] + 1 + offset;
const c = g.indices[i + 2] + 1 + offset;
lines.push(`f ${a} ${b} ${c}`);
}
offset += vertCount;
}
return lines.join("\n") + "\n";
}
/**
* Baut einen vollständigen ASCII-STL-String des 3D-Gebäudemodells (Meter,
* dieselbe Achsen-Konvention wie {@link exportObj}). Jedes Dreieck trägt eine
* aus dem Kreuzprodukt berechnete Facettennormale (konsistente Winding, siehe
* `pushWallBox`/`pushPrism`). Leeres Projekt ⇒ gültiges leeres `solid`-Gerüst.
*/
export function exportStl(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = ["solid dossier"];
for (const g of groups) {
for (let i = 0; i < g.indices.length; i += 3) {
const ia = g.indices[i] * 3;
const ib = g.indices[i + 1] * 3;
const ic = g.indices[i + 2] * 3;
const ax = g.positions[ia];
const ay = g.positions[ia + 1];
const az = g.positions[ia + 2];
const bx = g.positions[ib];
const by = g.positions[ib + 1];
const bz = g.positions[ib + 2];
const cx = g.positions[ic];
const cy = g.positions[ic + 1];
const cz = g.positions[ic + 2];
const ux = bx - ax;
const uy = by - ay;
const uz = bz - az;
const vx = cx - ax;
const vy = cy - ay;
const vz = cz - az;
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const len = Math.hypot(nx, ny, nz);
if (len > 1e-12) {
nx /= len;
ny /= len;
nz /= len;
} else {
nx = 0;
ny = 0;
nz = 0;
}
lines.push(`facet normal ${fmt(nx)} ${fmt(ny)} ${fmt(nz)}`);
lines.push("outer loop");
lines.push(`vertex ${fmt(ax)} ${fmt(ay)} ${fmt(az)}`);
lines.push(`vertex ${fmt(bx)} ${fmt(by)} ${fmt(bz)}`);
lines.push(`vertex ${fmt(cx)} ${fmt(cy)} ${fmt(cz)}`);
lines.push("endloop");
lines.push("endfacet");
}
}
lines.push("endsolid dossier");
return lines.join("\n") + "\n";
}
+95 -15
View File
@@ -1,14 +1,18 @@
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände, 1 Decke, 1 Tür,
// 1 Fenster, 1 Treppe, 1 Extrusion).
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
import { describe, it, expect } from "vitest";
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
import type { Project, Wall, Ceiling } from "../model/types";
import type { Project, Wall, Ceiling, Door, Opening, Stair, ExtrudedSolid } from "../model/types";
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */
/**
* Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2) +
* 1 Tür + 1 Fenster (je an W1 gehostet) + 1 Treppe + 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
@@ -48,6 +52,61 @@ function fixtureProject(): Project {
ceilingTypeId: "dt",
},
];
const doors: Door[] = [
{
id: "T1",
type: "door",
hostWallId: "W1",
categoryCode: "21",
position: 1,
width: 0.9,
height: 2.1, // Fläche 1.89
swing: "left",
hinge: "start",
},
];
const openings: Opening[] = [
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5, // Fläche 1.80
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3, // Länge 3 (kein 2. Lauf)
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
], // 2×3 = 6 m²
height: 2.5,
},
];
return {
id: "t",
name: "T",
@@ -61,10 +120,11 @@ function fixtureProject(): Project {
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings: [],
doors,
openings,
ceilings,
stairs: [],
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
@@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
const lines = csv.split("\r\n");
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
expect(lines).toHaveLength(4);
// Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion).
expect(lines).toHaveLength(8);
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
@@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => {
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
// Tür T1: Breite 0.9, Höhe 2.1, keine Dicke, Fläche 1.89.
expect(lines[4]).toBe("Tür;T1;Tür;EG;0.90;2.10;;1.89");
// Fenster F1: Breite 1.2, Höhe 1.5, keine Dicke, Fläche 1.80.
expect(lines[5]).toBe("Fenster;F1;Fenster;EG;1.20;1.50;;1.80");
// Treppe S1: Lauflänge 3 (kein 2. Lauf), Steighöhe 2.6, keine Dicke/Fläche.
expect(lines[6]).toBe("Treppe;S1;Treppe;EG;3.00;2.60;;");
// Extrusion E1: keine Länge, Höhe 2.5, keine Dicke, Grundrissfläche 6.
expect(lines[7]).toBe("Extrusion;E1;Extrusion;EG;;2.50;;6.00");
});
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
const csv = exportScheduleCsv(fixtureProject());
const lines = csv.split("\r\n");
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
expect(lines).toHaveLength(8);
expect(lines[4]).toBe("");
expect(lines[5]).toContain("Zusammenfassung");
// Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16.
expect(lines).toHaveLength(16);
expect(lines[8]).toBe("");
expect(lines[9]).toContain("Zusammenfassung");
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
expect(lines[10]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
expect(lines[11]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
// Tür-Aggregat: 1 Stk, Gesamtbreite 0.9, Fläche 1.89.
expect(lines[12]).toBe("Tür;;Tür;1 Stk;0.90;;;1.89");
// Fenster-Aggregat: 1 Stk, Gesamtbreite 1.2, Fläche 1.80.
expect(lines[13]).toBe("Fenster;;Fenster;1 Stk;1.20;;;1.80");
// Treppen-Aggregat: 1 Stk, Gesamtlänge 3, keine bekannte Fläche (leer statt 0.00).
expect(lines[14]).toBe("Treppe;;Treppe;1 Stk;3.00;;;");
// Extrusions-Aggregat: 1 Stk, keine Länge, Fläche 6.
expect(lines[15]).toBe("Extrusion;;Extrusion;1 Stk;;;;6.00");
});
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
@@ -114,6 +190,10 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.doors = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
const csv = exportScheduleCsv(proj);
expect(csv.split("\r\n")).toHaveLength(1);
expect(scheduleRows(proj)).toHaveLength(0);
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
// Ordner → Mehrseiten-PDF (DOSSIER A3): exportiert alle Layouts eines Ordners
// als EIN gemeinsames PDF, jedes Layout = eine Seite. Das Rendering je Seite
// spiegelt exakt `ui/LayoutSheet.tsx` (Master-Rahmen + Titelblock + echte
// Viewport-Pläne via `generatePlan`→`planToPrintSvg`) und komponiert die Seiten
// per jsPDF (`addPage` je Layout) analog `export/exportPdf.ts` (buildPlanPdf).
//
// Trennung: die Seiten-Sammlung/-Reihenfolge + effektive Blattgrösse ist reine,
// testbare Logik (`folderPdfPages`, layoutModel); das SVG/jsPDF-Rendering
// (`buildFolderPdf`) braucht ein DOM und läuft nur im Browser/Tauri.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { jsPDF } from "jspdf";
import "svg2pdf.js";
import type { MasterLayout, Project } from "../model/types";
import { generatePlan } from "../plan/generatePlan";
import { planToPrintSvg } from "./planToPrintSvg";
import { saveBinaryFile } from "../io/saveFile";
import {
findMaster,
findSnapshot,
folderPdfPages,
resolveTitleBlock,
resolveViewportScale,
visibleCodesFromSnapshot,
type FolderPdfPage,
type ResolvedTitleBlock,
} from "../panels/layoutModel";
export { folderPdfPages, type FolderPdfPage };
/** Titelblock (mm-Vektor) auf die aktuelle jsPDF-Seite — Anmutung wie LayoutSheet. */
function drawTitleBlock(
doc: jsPDF,
sheetW: number,
sheetH: number,
tb: ResolvedTitleBlock,
paper: string,
orientation: string,
): void {
const boxW = 70;
const boxH = 30;
const pad = 6;
const x = sheetW - boxW - pad;
const y = sheetH - boxH - pad;
const fmt = `${paper.toUpperCase()} ${orientation === "landscape" ? "quer" : "hoch"}`;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(x, y, boxW, boxH, "FD");
doc.setLineWidth(0.2);
doc.line(x, y + 8, x + boxW, y + 8);
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
doc.setTextColor(17, 17, 17);
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.text(clip(tb.projectName, 34), x + 3, y + 5.6);
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.text(clip(tb.sheetName, 36), x + 3, y + 13);
if (tb.author) {
doc.setFontSize(7);
doc.setTextColor(68, 68, 68);
doc.text(clip(tb.author, 40), x + 3, y + 18);
doc.setTextColor(17, 17, 17);
}
doc.setFontSize(8);
doc.text(tb.scale, x + 3, y + boxH - 2.4);
doc.setFontSize(7);
doc.text(`${tb.date} · ${fmt}`, x + boxW - 3, y + boxH - 2.4, { align: "right" });
}
function clip(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
/** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */
async function renderPage(
doc: jsPDF,
project: Project,
page: FolderPdfPage,
master: MasterLayout | undefined,
): Promise<void> {
const { layout, widthMm, heightMm } = page;
// Echte Viewport-Pläne (wie ViewportPlan in LayoutSheet.tsx).
for (const vp of layout.viewports) {
const snap = findSnapshot(project, vp.snapshotId);
if (!snap) continue;
try {
const codes = visibleCodesFromSnapshot(snap);
const plan = generatePlan(
project,
snap.activeLevelId,
codes,
undefined,
snap.detail,
false,
false,
);
const scaleN = resolveViewportScale(vp, snap);
const print = planToPrintSvg(plan, {
scaleDenominator: scaleN,
pageWidthMm: vp.widthMm,
pageHeightMm: vp.heightMm,
});
await doc.svg(print.svg, {
x: vp.xMm,
y: vp.yMm,
width: vp.widthMm,
height: vp.heightMm,
});
} catch {
// Einzelner Viewport, der nicht rendert, darf die Seite nicht killen.
}
}
// Master: Rahmen + Titelblock (read-only, wie im In-Viewport-Editor).
if (master) {
if (master.border ?? true) {
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(5, 5, widthMm - 10, heightMm - 10);
}
const tb = resolveTitleBlock(project, layout, master);
drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation);
}
}
/**
* Baut das Mehrseiten-PDF eines Ordners und liefert das jsPDF-Dokument. Jede
* Seite = ein Layout in Baum-Reihenfolge. Gibt `null`, wenn der Ordner keine
* Layouts enthält (der Aufrufer meldet das dann in der UI).
*/
export async function buildFolderPdf(
project: Project,
folderId: string,
): Promise<jsPDF | null> {
const pages = folderPdfPages(project, folderId);
if (pages.length === 0) return null;
const orientOf = (p: FolderPdfPage) =>
p.widthMm > p.heightMm ? "landscape" : "portrait";
const first = pages[0];
const doc = new jsPDF({
unit: "mm",
format: [first.widthMm, first.heightMm],
orientation: orientOf(first),
compress: true,
});
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (i > 0) doc.addPage([page.widthMm, page.heightMm], orientOf(page));
const master = findMaster(project, page.layout);
await renderPage(doc, project, page, master);
}
return doc;
}
/**
* Baut das Ordner-PDF und speichert es (nativer Tauri-Dialog bzw.
* Browser-Download). Liefert `false`, wenn nichts zu exportieren war oder der
* Speichern-Dialog abgebrochen wurde.
*/
export async function saveFolderPdf(
project: Project,
folderId: string,
fileName: string,
): Promise<boolean> {
const doc = await buildFolderPdf(project, folderId);
if (!doc) return false;
const bytes = doc.output("arraybuffer");
const name = fileName.replace(/\.pdf$/i, "");
return saveBinaryFile(new Uint8Array(bytes), `${name}.pdf`, "application/pdf");
}