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);
|
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
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user