35299307d6
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.
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
// 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);
|
||
}
|
||
});
|
||
});
|