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