Dach-Schichtlogik: RoofType/Layer[] wie Decken (3D mehrschichtig)
Kernthema der BIM-Tiefe (Designdoc docs/design/bim-elements-depth-study.md, P0):
Dächer bekommen einen mehrschichtigen Aufbau analog WallType/CeilingType.
- Modell: RoofType { layers: Layer[] }, Project.roofTypes, Roof.roofTypeId
(Roof.thickness bleibt einschichtiger Fallback). Resolver roofLayers()/
roofTotalThickness() — wiederverwendet den bestehenden Layer-Typ.
- 3D (emitRoofs): je Schicht ein eigenes Mesh mit Bauteilfarbe, entlang der
Flächennormalen unter die vorige gestapelt (Eindeckung aussen → Verkleidung
innen). Ohne roofTypeId einschichtiger thickness-Slab (Default-Dachfarbe).
- UI: Dachtyp-Dropdown im Dach-Panel (leer = einschichtig). Default-Dachtyp
'Steildach gedämmt' in sampleProject.
- +2 Tests (Schicht-Meshes/Stapelung; einschichtig = Slab+Giebel).
Offen (Designdoc): 2D-Schnitt-Poché der Dachschichten, RoofType-ResourceManager-
Tab, Traufe/Ortgang getrennt. 668/668 grün; 3D-Optik vom Nutzer in Tauri prüfen.
This commit is contained in:
+72
-15
@@ -1229,21 +1229,22 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
||||
const project: Project = { ...sampleProject, roofs: [baseRoof] };
|
||||
const { meshes } = projectToModel3d(project);
|
||||
const roofMeshes = meshes.filter(isRoofMesh);
|
||||
// Zwei Dachflächen (je ein Quad -> 2 Dreiecke) + zwei Giebel (je ein Dreieck)
|
||||
// landen in EINEM kombinierten Mesh (Flächen und Giebel bilden zusammen den
|
||||
// geschlossenen Dachkörper).
|
||||
expect(roofMeshes.length).toBe(1);
|
||||
const m = roofMeshes[0];
|
||||
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||
expect(m.indices.length % 3).toBe(0);
|
||||
expect(m.indices.length).toBeGreaterThan(0);
|
||||
const vcount = m.positions.length / 3;
|
||||
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
|
||||
// Firsthöhe: eavesZ (baseElevation) + ridgeHeight aus roofGeometry.
|
||||
// Einschichtiges Dach (thickness-Fallback): 1 Schicht-Slab-Mesh (Flächen) +
|
||||
// 1 Giebel-Mesh (Endfüllung) = 2 Roof-Meshes (beide ROOF_RGB).
|
||||
expect(roofMeshes.length).toBe(2);
|
||||
for (const m of roofMeshes) {
|
||||
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
|
||||
expect(m.indices.length % 3).toBe(0);
|
||||
expect(m.indices.length).toBeGreaterThan(0);
|
||||
const vcount = m.positions.length / 3;
|
||||
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
|
||||
}
|
||||
// Firsthöhe: eavesZ (baseElevation) + ridgeHeight aus roofGeometry — über ALLE
|
||||
// Roof-Meshes (Oberseite der Flächen bzw. Giebelspitze).
|
||||
const g = roofGeometry(baseRoof, roofBaseElevation(project, baseRoof));
|
||||
const expectedRidgeZ = roofBaseElevation(project, baseRoof) + g.ridgeHeight;
|
||||
const heights = m.positions.filter((_, i) => i % 3 === 2);
|
||||
expect(Math.max(...heights)).toBeCloseTo(expectedRidgeZ, 6);
|
||||
const allHeights = roofMeshes.flatMap((m) => m.positions.filter((_, i) => i % 3 === 2));
|
||||
expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6);
|
||||
});
|
||||
|
||||
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
|
||||
@@ -1272,6 +1273,61 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
||||
expect(Math.min(...heights)).toBeLessThan(3 - 0.2);
|
||||
});
|
||||
|
||||
it("Dach-Schichtaufbau (roofTypeId): je Schicht ein eigenes Mesh mit Bauteilfarbe, entlang der Normalen gestapelt", () => {
|
||||
// Dachtyp mit zwei Schichten (Eindeckung + Sparren/Dämmung), eigene Farben.
|
||||
const flatRoof: Roof = {
|
||||
...baseRoof,
|
||||
shape: "flach", // Flachdach: eine Fläche, keine Giebel -> reine Schicht-Meshes
|
||||
roofTypeId: "rt-warm",
|
||||
};
|
||||
const project: Project = {
|
||||
...sampleProject,
|
||||
components: [
|
||||
...sampleProject.components,
|
||||
{
|
||||
id: "comp-tile",
|
||||
name: "Ziegel",
|
||||
color: "#a03a2a",
|
||||
hatchId: "",
|
||||
joinPriority: 1,
|
||||
},
|
||||
{
|
||||
id: "comp-rafter",
|
||||
name: "Sparren/Dämmung",
|
||||
color: "#caa15a",
|
||||
hatchId: "",
|
||||
joinPriority: 1,
|
||||
},
|
||||
],
|
||||
roofTypes: [
|
||||
{
|
||||
id: "rt-warm",
|
||||
name: "Warmdach",
|
||||
layers: [
|
||||
{ componentId: "comp-tile", thickness: 0.05 },
|
||||
{ componentId: "comp-rafter", thickness: 0.24 },
|
||||
],
|
||||
},
|
||||
],
|
||||
roofs: [flatRoof],
|
||||
};
|
||||
const meshes = projectToModel3d(project).meshes;
|
||||
const tile = meshes.filter(
|
||||
(m) => Math.abs(m.color[0] - 0xa0 / 255) < 1e-6 && Math.abs(m.color[1] - 0x3a / 255) < 1e-6,
|
||||
);
|
||||
const rafter = meshes.filter(
|
||||
(m) => Math.abs(m.color[0] - 0xca / 255) < 1e-6 && Math.abs(m.color[1] - 0xa1 / 255) < 1e-6,
|
||||
);
|
||||
expect(tile.length).toBe(1); // Eindeckungs-Schicht als eigenes Mesh
|
||||
expect(rafter.length).toBe(1); // Sparren-Schicht als eigenes Mesh
|
||||
// Die Sparren-Schicht liegt UNTER der Eindeckung: ihre Oberkante = Traufe −
|
||||
// Eindeckungsdicke. Flachdach-Oberseite bei baseElevation 3.
|
||||
const topOf = (m: { positions: number[] }) =>
|
||||
Math.max(...m.positions.filter((_, i) => i % 3 === 2));
|
||||
expect(topOf(tile[0])).toBeCloseTo(3, 6);
|
||||
expect(topOf(rafter[0])).toBeCloseTo(3 - 0.05, 6);
|
||||
});
|
||||
|
||||
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: [] };
|
||||
@@ -1280,8 +1336,9 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
||||
const noField: Project = { ...sampleProject };
|
||||
delete (noField as { roofs?: unknown }).roofs;
|
||||
expect(projectToModel3d(noField).meshes.filter(isRoofMesh).length).toBe(0);
|
||||
// sampleProject enthält jetzt ein Demo-Satteldach (RF1) -> genau ein Roof-Mesh.
|
||||
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(1);
|
||||
// sampleProject enthält ein Demo-Satteldach (RF1, einschichtig) -> 2 Roof-
|
||||
// Meshes: Schicht-Slab (Flächen) + Giebel-Endfüllung.
|
||||
expect(projectToModel3d(sampleProject).meshes.filter(isRoofMesh).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+42
-10
@@ -45,6 +45,7 @@ import {
|
||||
getDoorType,
|
||||
getWindowType,
|
||||
glazingPanesOf,
|
||||
roofLayers,
|
||||
} from "../model/types";
|
||||
import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall";
|
||||
import { columnFootprint } from "../geometry/column";
|
||||
@@ -1729,16 +1730,47 @@ function emitRoofs(project: Project): 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[] = [];
|
||||
// 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 });
|
||||
const overrideColor = roof.color ? hexToRgb(roof.color, ROOF_RGB) : null;
|
||||
// Schichtaufbau (aussen/Eindeckung → innen). Jede Schicht wird zu einem
|
||||
// eigenen RMesh mit ihrer Bauteilfarbe, entlang der Flächennormalen unter die
|
||||
// vorige gestapelt. Leere componentId (einschichtige thickness-Fallback) ⇒
|
||||
// Default-Dachfarbe. Eine Roof.color-Übersteuerung gilt für alle Schichten.
|
||||
const layers = roofLayers(project, roof);
|
||||
layers.reduce((offsetAbove, layer) => {
|
||||
const t = Math.max(0, layer.thickness);
|
||||
if (t <= EPS) return offsetAbove;
|
||||
const color =
|
||||
overrideColor ??
|
||||
(layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB);
|
||||
const positions: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const plane of g.planes) {
|
||||
// Oberseite dieser Schicht = Dachfläche um `offsetAbove` entlang der
|
||||
// Normalen nach innen versetzt; Slab-Dicke = Schichtdicke.
|
||||
const nrm = planeNormalUp(plane.pts);
|
||||
const top: RoofVec3[] = plane.pts.map((p) => [
|
||||
p[0] - nrm[0] * offsetAbove,
|
||||
p[1] - nrm[1] * offsetAbove,
|
||||
p[2] - nrm[2] * offsetAbove,
|
||||
]);
|
||||
pushRoofSlab(positions, indices, top, t);
|
||||
}
|
||||
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color });
|
||||
return offsetAbove + t;
|
||||
}, 0);
|
||||
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
|
||||
if (g.gables.length > 0) {
|
||||
const gp: number[] = [];
|
||||
const gi: number[] = [];
|
||||
for (const gable of g.gables) pushFanTriangles(gp, gi, gable);
|
||||
if (gi.length > 0) {
|
||||
const first = layers[0];
|
||||
const gc =
|
||||
overrideColor ??
|
||||
(first?.componentId ? hexToRgb(getComponent(project, first.componentId).color, ROOF_RGB) : ROOF_RGB);
|
||||
out.push({ positions: gp, indices: gi, kind: "extrusion", color: gc });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user