diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 4041cf5..a941963 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -1254,13 +1254,24 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => { expect(roofMeshes.length).toBe(1); const m = roofMeshes[0]; expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); - // Walm hat KEINE Giebel (gables: []); 2 Quader (je 2 Dreiecke) + 2 Dreiecke - // (je 1 Dreieck) = 6 Dreiecke = 18 Indizes. - expect(m.indices.length).toBe(18); + // Walm hat KEINE Giebel (gables: []). Jede Fläche ist jetzt ein SOLIDER Slab + // (Oberseite + Unterseite + Seitenwände): Quader-Fläche (4 Ecken) = 2+2+8 = + // 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; 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)", () => { // Explizit leeres roofs-Feld -> keine Roof-Meshes. const project: Project = { ...sampleProject, roofs: [] }; diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index ef1ce49..84e819b 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -1667,15 +1667,75 @@ function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[ * render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also * 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[] { const out: RMesh[] = []; for (const roof of project.roofs ?? []) { const eavesZ = roofBaseElevation(project, roof); const g = roofGeometry(roof, eavesZ); const color = roof.color ? hexToRgb(roof.color, ROOF_RGB) : ROOF_RGB; + const thickness = Math.max(0, roof.thickness); const positions: 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); if (indices.length === 0) continue; out.push({ positions, indices, kind: "extrusion", color });