Dach 3D: echte Materialdicke (solide Slabs) statt papierdünner Flächen

roof.thickness war im 3D bislang ein totes Feld — Dächer waren einseitige
Flächen. emitRoofs baut jede Dachfläche jetzt als soliden Slab: Oberseite +
um thickness entlang der Flächennormalen (Newell) nach unten versetzte
Unterseite + umlaufende Seitenwände. Giebel bleiben dünne Endfüllung.
Basis für die kommende Dach-Schichtlogik. +1 Test (Unterseite unter der
Oberfläche); Walm-Zähltest auf Slab-Dreiecke aktualisiert. 667/667 grün.
Visuelle 3D-Abnahme in der Tauri-App durch den Nutzer.
This commit is contained in:
2026-07-10 01:58:15 +02:00
parent bfb80b363b
commit e99bb2413f
2 changed files with 75 additions and 4 deletions
+14 -3
View File
@@ -1254,13 +1254,24 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
expect(roofMeshes.length).toBe(1); expect(roofMeshes.length).toBe(1);
const m = roofMeshes[0]; const m = roofMeshes[0];
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
// Walm hat KEINE Giebel (gables: []); 2 Quader (je 2 Dreiecke) + 2 Dreiecke // Walm hat KEINE Giebel (gables: []). Jede Fläche ist jetzt ein SOLIDER Slab
// (je 1 Dreieck) = 6 Dreiecke = 18 Indizes. // (Oberseite + Unterseite + Seitenwände): Quader-Fläche (4 Ecken) = 2+2+8 =
expect(m.indices.length).toBe(18); // 12 Dreiecke, Dreiecks-Fläche (3 Ecken) = 1+1+6 = 8 Dreiecke.
// 2 Quader × 12 + 2 Dreiecke × 8 = 40 Dreiecke = 120 Indizes.
expect(m.indices.length).toBe(120);
const vcount = m.positions.length / 3; const vcount = m.positions.length / 3;
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
}); });
it("Dach-Dicke: Slab erzeugt eine Unterseite unter der Oberfläche (thickness > 0)", () => {
const project: Project = { ...sampleProject, roofs: [baseRoof] };
const m = projectToModel3d(project).meshes.filter(isRoofMesh)[0];
const heights = m.positions.filter((_, i) => i % 3 === 2);
// Sattel 6×4, 30°, Traufe 3: Traufe-OK bei z=3, First höher. Die Unterseite
// liegt um ~thickness (0.3, senkrecht) tiefer -> min height klar unter 3.
expect(Math.min(...heights)).toBeLessThan(3 - 0.2);
});
it("Projekt ohne Dächer emittiert keine Roof-Meshes (Rückwärtskompatibilität)", () => { it("Projekt ohne Dächer emittiert keine Roof-Meshes (Rückwärtskompatibilität)", () => {
// Explizit leeres roofs-Feld -> keine Roof-Meshes. // Explizit leeres roofs-Feld -> keine Roof-Meshes.
const project: Project = { ...sampleProject, roofs: [] }; const project: Project = { ...sampleProject, roofs: [] };
+61 -1
View File
@@ -1667,15 +1667,75 @@ function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[
* render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also * render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also
* unerheblich. * unerheblich.
*/ */
/**
* Robuste Flächennormale (Newell) einer Polygon-Ebene in MODELL-Koordinaten
* ([x, y, z=Höhe]), nach OBEN orientiert (positive Höhen-Komponente) für die
* Dach-Dickenversetzung (Unterseite = Oberseite Normale·Dicke).
*/
function planeNormalUp(pts: RoofVec3[]): [number, number, number] {
let nx = 0;
let ny = 0;
let nz = 0;
for (let i = 0; i < pts.length; i++) {
const c = pts[i];
const d = pts[(i + 1) % pts.length];
nx += (c[1] - d[1]) * (c[2] + d[2]);
ny += (c[2] - d[2]) * (c[0] + d[0]);
nz += (c[0] - d[0]) * (c[1] + d[1]);
}
const len = Math.hypot(nx, ny, nz) || 1;
const u: [number, number, number] = [nx / len, ny / len, nz / len];
return u[2] < 0 ? [-u[0], -u[1], -u[2]] : u;
}
/**
* Eine Dachfläche als SOLIDER Slab (Oberseite + um `thickness` entlang der
* Normalen nach unten versetzte Unterseite + umlaufende Seitenwände) gibt dem
* Dach eine echte Materialdicke statt einer papierdünnen Fläche. render3d
* rendert doppelseitig, Winding ist unerheblich.
*/
function pushRoofSlab(
positions: number[],
indices: number[],
pts: RoofVec3[],
thickness: number,
): void {
if (pts.length < 3 || thickness <= EPS) {
pushFanTriangles(positions, indices, pts);
return;
}
const nrm = planeNormalUp(pts);
const bottom: RoofVec3[] = pts.map((p) => [
p[0] - nrm[0] * thickness,
p[1] - nrm[1] * thickness,
p[2] - nrm[2] * thickness,
]);
pushFanTriangles(positions, indices, pts); // Oberseite
pushFanTriangles(positions, indices, bottom); // Unterseite
// Seitenwände: je Kante ein Quad (zwei Dreiecke) zwischen Ober- und Unterseite.
for (let i = 0; i < pts.length; i++) {
const j = (i + 1) % pts.length;
const base = positions.length / 3;
positions.push(pts[i][0], pts[i][1], pts[i][2]);
positions.push(pts[j][0], pts[j][1], pts[j][2]);
positions.push(bottom[j][0], bottom[j][1], bottom[j][2]);
positions.push(bottom[i][0], bottom[i][1], bottom[i][2]);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
}
function emitRoofs(project: Project): RMesh[] { function emitRoofs(project: Project): RMesh[] {
const out: RMesh[] = []; const out: RMesh[] = [];
for (const roof of project.roofs ?? []) { for (const roof of project.roofs ?? []) {
const eavesZ = roofBaseElevation(project, roof); const eavesZ = roofBaseElevation(project, roof);
const g = roofGeometry(roof, eavesZ); const g = roofGeometry(roof, eavesZ);
const color = roof.color ? hexToRgb(roof.color, ROOF_RGB) : ROOF_RGB; const color = roof.color ? hexToRgb(roof.color, ROOF_RGB) : ROOF_RGB;
const thickness = Math.max(0, roof.thickness);
const positions: number[] = []; const positions: number[] = [];
const indices: number[] = []; const indices: number[] = [];
for (const plane of g.planes) pushFanTriangles(positions, indices, plane.pts); // Dachflächen als solide Slabs (echte Dicke); Giebel bleiben dünne Füllung
// (schliessen die offenen Enden zur Attika hin).
for (const plane of g.planes) pushRoofSlab(positions, indices, plane.pts, thickness);
for (const gable of g.gables) pushFanTriangles(positions, indices, gable); for (const gable of g.gables) pushFanTriangles(positions, indices, gable);
if (indices.length === 0) continue; if (indices.length === 0) continue;
out.push({ positions, indices, kind: "extrusion", color }); out.push({ positions, indices, kind: "extrusion", color });