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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user