diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 63736af..fc83917 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2af4358..59a8aaa 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -299,6 +299,8 @@ export const en: Record = { "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", diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index 19e8122..570e97b 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -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: [ diff --git a/src/model/types.ts b/src/model/types.ts index fb3d060..49eb836 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -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 diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index f1148c2..ac7652f 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -497,6 +497,21 @@ export function RoofSection({ /> + {/* Dachtyp (mehrschichtiger Aufbau) — leer = einschichtig (Dicke unten). */} +
+ {t("objinfo.roof.type")} + 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} + /> +
+ {/* Firstrichtung (X/Y) — nur bei Formen mit First relevant. */} {roof.shape !== "flach" && roof.shape !== "zelt" && (
diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index a941963..66ac864 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -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); }); }); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 84e819b..e47ef92 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -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; } diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 56e65ba..6096a6a 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -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,