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:
@@ -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") {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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·4−4.
|
||||
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·5−4 = 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user