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:
2026-07-10 02:03:55 +02:00
parent e99bb2413f
commit 3a986ecd2f
8 changed files with 197 additions and 26 deletions
+2
View File
@@ -300,6 +300,8 @@ export const de = {
"objinfo.roof.ridgeAxis.y": "entlang Y",
"objinfo.roof.pitch": "Neigung (°)",
"objinfo.roof.pitchUpper": "Obere Neigung (°)",
"objinfo.roof.type": "Dachtyp",
"objinfo.roof.type.single": "Einschichtig (Dicke)",
"objinfo.roof.mansardType": "Mansard-Art",
"objinfo.roof.mansard.giebel": "Giebel-Mansarde",
"objinfo.roof.mansard.walm": "Walm-Mansarde",
+2
View File
@@ -299,6 +299,8 @@ export const en: Record<TranslationKey, string> = {
"objinfo.roof.ridgeAxis.y": "along Y",
"objinfo.roof.pitch": "Pitch (°)",
"objinfo.roof.pitchUpper": "Upper pitch (°)",
"objinfo.roof.type": "Roof type",
"objinfo.roof.type.single": "Single layer (thickness)",
"objinfo.roof.mansardType": "Mansard type",
"objinfo.roof.mansard.giebel": "Gable mansard",
"objinfo.roof.mansard.walm": "Hip mansard",
+12
View File
@@ -202,6 +202,18 @@ export const sampleProject: Project = {
],
},
],
roofTypes: [
{
id: "roof-warm",
name: "Steildach gedämmt",
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
layers: [
{ componentId: "brick", thickness: 0.04 }, // Eindeckung (Ziegel, Platzhalter)
{ componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung
{ componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK)
],
},
],
// Bauteil-Typen (Bibliothek) — Tür/Fenster/Treppe, analog Wand-/Deckenstile.
// Elemente referenzieren sie per `typeId`; ohne Referenz gilt Inline-Verhalten.
doorTypes: [
+49 -1
View File
@@ -276,6 +276,21 @@ export interface CeilingType {
layers: Layer[];
}
/**
* Ein Dachtyp (Dachaufbau) = geordneter Schichtaufbau EINES Daches, senkrecht
* zur Dachfläche gestapelt (aussen/Eindeckung → innen/Verkleidung) — das
* geneigte Gegenstück zu `WallType`/`CeilingType`. Nutzt denselben `Layer`-Typ.
* Typische Folge: Eindeckung (Ziegel/Blech) → Konterlattung/Lattung → Unterdach
* → Sparren/Dämmung → Dampfbremse → Innenverkleidung. Ein Dach ohne `roofTypeId`
* fällt auf die einschichtige `Roof.thickness` zurück (rückwärtskompatibel).
*/
export interface RoofType {
id: string;
name: string;
/** Aussen (Eindeckung) → innen (Verkleidung), analog `WallType.layers`. */
layers: Layer[];
}
// ── Bauteil-Typen: Tür / Fenster / Treppe ──────────────────────────────────
// Wiederverwendbare Stile (Presets) für Türen, Fenster und Treppen — analog
// `WallType`/`CeilingType` (Bibliothek im Projekt, referenziert per `typeId`).
@@ -1053,8 +1068,18 @@ export interface Roof {
ridgeAxis: "x" | "y";
/** Traufhöhe (absolutes Z, Meter); fehlt ⇒ Oberkante des Geschosses. */
baseElevation?: number;
/** Dachdicke (Meter, senkrecht zur Fläche gemessen). */
/**
* Dachdicke (Meter, senkrecht zur Fläche) — Fallback/Override. Ohne
* {@link roofTypeId} ist es die einschichtige Gesamtdicke; mit `roofTypeId`
* bleibt es als reiner Fallback bestehen (die Schichtsumme des Typs gilt).
*/
thickness: number;
/**
* Verweis auf einen Dachtyp (Dachaufbau) aus `Project.roofTypes` — mehrschichtig
* (Eindeckung/Lattung/Dämmung/Verkleidung), analog `Ceiling.ceilingTypeId`.
* Fehlt er, gilt die einschichtige {@link thickness}.
*/
roofTypeId?: string;
/** Optionale Übersteuerung der Strich-/Umrissfarbe; sonst Kategorie-Farbe. */
color?: string;
}
@@ -1950,6 +1975,11 @@ export interface Project {
* `Ceiling.wallTypeId` gegen `wallTypes` auf (siehe `getCeilingType`).
*/
ceilingTypes?: CeilingType[];
/**
* Dachtypen (Dachaufbauten) — mehrschichtig, analog `ceilingTypes`. Optional;
* Dächer ohne `roofTypeId` lösen über die einschichtige `Roof.thickness` auf.
*/
roofTypes?: RoofType[];
/**
* Türtypen-Bibliothek (Türstile), analog `wallTypes`. Optional, damit
* bestehende Projekte/Tests ohne `doorTypes` gültig bleiben; Türen ohne
@@ -2110,6 +2140,24 @@ export const ceilingThickness = (project: Project, ceiling: Ceiling): number =>
}
};
/**
* Schichtaufbau eines Daches (aussen/Eindeckung → innen/Verkleidung). Mit
* {@link Roof.roofTypeId} die Schichten des Dachtyps; sonst eine EINSCHICHTIGE
* synthetische Schicht aus {@link Roof.thickness} (leere `componentId` ⇒ der
* Renderer nutzt seine Default-Dachfarbe).
*/
export const roofLayers = (project: Project, roof: Roof): Layer[] => {
if (roof.roofTypeId) {
const rt = (project.roofTypes ?? []).find((t) => t.id === roof.roofTypeId);
if (rt && rt.layers.length > 0) return rt.layers;
}
return [{ componentId: "", thickness: Math.max(0, roof.thickness) }];
};
/** Gesamtdicke eines Daches (Meter, senkrecht zur Fläche) = Summe der Schichten. */
export const roofTotalThickness = (project: Project, roof: Roof): number =>
roofLayers(project, roof).reduce((s, l) => s + Math.max(0, l.thickness), 0);
/**
* Löst den {@link DoorType} einer Tür-Öffnung auf, oder `undefined` (keine
* `typeId` bzw. unbekannt ⇒ Inline-Verhalten). Bewusst NICHT werfend — der
+15
View File
@@ -497,6 +497,21 @@ export function RoofSection({
/>
</div>
{/* Dachtyp (mehrschichtiger Aufbau) — leer = einschichtig (Dicke unten). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.roof.type")}</span>
<Dropdown
value={roof.roofTypeId ?? ""}
onChange={(v) => host.onSetRoofPatch({ roofTypeId: v || undefined })}
options={[
{ value: "", label: t("objinfo.roof.type.single") },
...(host.project.roofTypes ?? []).map((rt) => ({ value: rt.id, label: rt.name })),
]}
title={t("objinfo.roof.type")}
width={130}
/>
</div>
{/* Firstrichtung (X/Y) — nur bei Formen mit First relevant. */}
{roof.shape !== "flach" && roof.shape !== "zelt" && (
<div className="objinfo-field">
+72 -15
View File
@@ -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
View File
@@ -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;
}
+3
View File
@@ -80,6 +80,8 @@ export interface RoofInfo {
pitchDeg: number;
/** Obere Neigung (Grad) — nur Mansarde relevant. */
pitchUpperDeg?: number;
/** Dachtyp-Verweis (mehrschichtiger Aufbau) oder undefined (einschichtig). */
roofTypeId?: string;
/** Mansarde-Untertyp (Giebel/Walm/Zelt) — nur Mansarde relevant. */
mansardType?: "giebel" | "walm" | "zelt";
/** Mansarde-Knicklage (0..0.5 der halben Spannweite) — nur Mansarde relevant. */
@@ -630,6 +632,7 @@ function roofSelection(project: Project, roof: Roof): Selection {
shape: roof.shape,
pitchDeg: roof.pitchDeg,
pitchUpperDeg: roof.pitchUpperDeg,
roofTypeId: roof.roofTypeId,
mansardType: roof.mansardType,
mansardKneeRatio: roof.mansardKneeRatio,
overhang: roof.overhang,