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
+169
View File
@@ -0,0 +1,169 @@
// Unit-Tests für den gemeinsamen Wand-Loch-Ausschnitt-Generator (wallMeshCut.ts,
// TS-Port von render3d/src/mesh.rs::extrude_layer_segment_with_holes). Prüft die
// geometrische Zerlegung (gleiche Rechteck-Anzahl wie die Rust-Referenz), nicht
// nur „läuft".
import { describe, it, expect } from "vitest";
import { isWatertight, solidSubrects, wallCutMesh } from "./wallMeshCut";
import type { CutMesh } from "./wallMeshCut";
import type { RWall } from "./toWalls3d";
/**
* Geschlossene-Fläche-Kennzahlen: Vektorfläche ∮ n dA (Σ (v1v0)×(v2v0)) und
* 6× signiertes Volumen (Σ v0·(v1×v2)). Dienen den Watertight-Tests direkt (statt
* eines naiven Kanten-Manifold-Zählers, der an den legitimen T-Stössen der vollen
* Deckel/Boden-Streifen scheitern würde — siehe isWatertight-Doc in wallMeshCut.ts).
*/
function surfaceIntegrals(m: CutMesh): { areaLen: number; vol6: number } {
const p = m.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < m.indices.length; t += 3) {
const i0 = m.indices[t] * 3;
const i1 = m.indices[t + 1] * 3;
const i2 = m.indices[t + 2] * 3;
const e1 = [p[i1] - p[i0], p[i1 + 1] - p[i0 + 1], p[i1 + 2] - p[i0 + 2]];
const e2 = [p[i2] - p[i0], p[i2 + 1] - p[i0 + 1], p[i2 + 2] - p[i0 + 2]];
const cx = e1[1] * e2[2] - e1[2] * e2[1];
const cy = e1[2] * e2[0] - e1[0] * e2[2];
const cz = e1[0] * e2[1] - e1[1] * e2[0];
ax += cx;
ay += cy;
az += cz;
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return { areaLen: Math.hypot(ax, ay, az), vol6 };
}
/** Ein RWall-Band mit optionalen Löchern (frei stehend, Achse entlang +x). */
function band(holes?: RWall["holes"]): RWall {
return {
start: [0, 0],
end: [5, 0],
thickness: 0.4,
height: 2.6,
baseElevation: 0,
color: [0.8, 0.8, 0.8],
layers: [],
wallId: "W1",
...(holes ? { holes } : {}),
};
}
describe("solidSubrects — Koordinaten-Kompression (Rust-Parität)", () => {
it("ohne Loch: genau ein Vollrechteck", () => {
expect(solidSubrects(0, 5, 0, 2.6, [])).toEqual([[0, 5, 0, 2.6]]);
});
it("ein voll im Inneren liegendes Loch → 3×3-Gitter minus 1 = 8 solide Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.6, [[2, 3, 0.9, 2.4]]);
expect(rects).toHaveLength(8);
// Keiner der Teilrechteck-Mittelpunkte liegt im Loch.
for (const [ua, ub, za, zb] of rects) {
const cu = (ua + ub) / 2;
const cz = (za + zb) / 2;
expect(cu > 2 && cu < 3 && cz > 0.9 && cz < 2.4).toBe(false);
}
});
it("randständiges Loch (Tür am Boden) → 3×2-Gitter minus 1 = 5 Teilrechtecke", () => {
expect(solidSubrects(0, 5, 0, 2.6, [[2, 3, 0, 2.1]])).toHaveLength(5);
});
it("zwei versetzt übereinanderliegende Löcher: keine überlappenden/entarteten Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.8, [
[1, 2, 0.9, 1.4],
[3, 4, 1.6, 2.2],
]);
// Alle Teilrechtecke haben echte Ausdehnung und liegen ausserhalb beider Löcher.
for (const [ua, ub, za, zb] of rects) {
expect(ub - ua).toBeGreaterThan(0);
expect(zb - za).toBeGreaterThan(0);
}
expect(rects.length).toBeGreaterThan(0);
});
});
describe("wallCutMesh — Loch-Ausschnitt-Mesh", () => {
it("ohne Loch: 12 Dreiecke (volle Box-Äquivalenz)", () => {
const m = wallCutMesh(band());
expect(m.indices.length / 3).toBe(12);
});
it("Fenster im Inneren: 48 Dreiecke, 4 Laibungen, Loch-Rand-Vertices vorhanden", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
expect(m.indices.length / 3).toBe(48);
// Vertices an allen vier Loch-Rändern (Brüstung/Sturz/links/rechts).
const has = (axis: number, val: number) => {
for (let i = 0; i < m.positions.length; i += 3) {
if (Math.abs(m.positions[i + axis] - val) < 1e-6) return true;
}
return false;
};
expect(has(1, 0.9)).toBe(true); // y = Brüstungs-OK
expect(has(1, 2.4)).toBe(true); // y = Sturz-UK
expect(has(0, 2)).toBe(true); // x = linke Kante
expect(has(0, 3)).toBe(true); // x = rechte Kante
// KEIN Vertex liegt strikt im offenen Loch-Rechteck (die Fläche ist wirklich weg).
for (let i = 0; i < m.positions.length; i += 3) {
const x = m.positions[i];
const y = m.positions[i + 1];
const strictlyInside = x > 2 + 1e-6 && x < 3 - 1e-6 && y > 0.9 + 1e-6 && y < 2.4 - 1e-6;
expect(strictlyInside).toBe(false);
}
});
it("Tür am Boden: 36 Dreiecke (untere Laibung entfällt, Boden bekommt die Lücke)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
expect(m.indices.length / 3).toBe(36);
});
it("alle Dreiecks-Indizes referenzieren existierende Vertices", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const vcount = m.positions.length / 3;
for (const idx of m.indices) {
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(vcount);
}
});
});
describe("wallCutMesh — Wasserdichtigkeit + Aussen-Orientierung (der hohl/offen-Bug)", () => {
it("volle Wand OHNE Loch: geschlossen (∮ n dA = 0), Normalen aussen (Volumen = L·T·H)", () => {
const m = wallCutMesh(band());
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6); // ∮ n dA = 0 ⇒ kein Loch, kein invertiertes Dreieck
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6, 6); // > 0 ⇒ aussen orientiert
expect(isWatertight(m)).toBe(true);
});
it("Wand mit Fenster: trotz T-Stösse geschlossen + aussen orientiert (Volumen = Wand Fensterquader)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * (2.4 - 0.9), 6);
expect(isWatertight(m)).toBe(true);
});
it("eine EINZELNE invertierte Kappe würde erkannt (Regressionswächter gegen falsche Wicklung)", () => {
// Bau die volle Wand, kehre die Wicklung EINES Dreiecks um → nicht mehr dicht.
const m = wallCutMesh(band());
const broken: CutMesh = { positions: m.positions.slice(), indices: m.indices.slice() };
[broken.indices[1], broken.indices[2]] = [broken.indices[2], broken.indices[1]];
expect(isWatertight(broken)).toBe(false);
});
it("Türwand ist ein GESCHLOSSENER Prisma-Körper (Querschnitt = П-Polygon, Türkerbe umlaufend)", () => {
// Die Türkerbe ist eine Einbuchtung der UNTEREN Umriss-Kante (kein Loch): der
// Querschnitt bleibt EIN einfaches, geschlossenes Polygon (П-Form), das über
// die Dicke extrudiert wieder einen geschlossenen Körper ergibt. Die fehlende
// untere Laibung und die Boden-Lücke heben sich in ∮ n dA exakt auf.
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * 2.1, 6);
expect(isWatertight(m)).toBe(true);
});
});