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
+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([]);
});
});