From 31f2d63362f34fba1b05832751b65b55cfb76147 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 9 Jul 2026 21:13:11 +0200 Subject: [PATCH] =?UTF-8?q?D=C3=A4cher:=20Platzier-Werkzeug=20+=202D-Plan?= =?UTF-8?q?=20+=203D-Fl=C3=A4chen=20+=20Demo-Dach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dach-Feature end-to-end nutzbar: - Befehl „Dach" (Alias dach/rf, BIM-Ribbon, Satteldach-Icon): Grundriss als Rechteck aufziehen; die Dachform (Sattel/Walm/Pult/Mansarde/Zelt/Flach) wählt man als Inline-Option der Befehlszeile vor/während des Aufziehens. First entlang der längeren Seite (Standard). - 2D-Grundriss (generatePlan): Traufe/First/Grat (Walm/Zelt) + gestrichelte Knicklinien (Mansarde), Farbe aus roof.color/Kategorie „31 Dächer". - 3D (toWalls3d emitRoofs): Dachflächen + Giebel als terrakottafarbene Meshes (Fan-Triangulierung, Mansarde-Fünfeck sauber). - Seed: Demo-Satteldach RF1 über dem Baukörper (OG, 5×4, Überstand 0.4). - i18n de/en für Befehl + Formen. +10 Tests (2D 7, 3D 3). tsc + vitest 640 grün. Auswahl/Attribut-Editieren placierter Dächer folgt separat. --- src/commands/cmds/roof.ts | 173 +++++++++++++++++++++++++++++ src/commands/registry.ts | 4 + src/i18n/de.ts | 9 ++ src/i18n/en.ts | 9 ++ src/model/sampleProject.ts | 21 ++++ src/plan/generatePlan.roof.test.ts | 147 ++++++++++++++++++++++++ src/plan/generatePlan.ts | 83 +++++++++++++- src/plan/toWalls3d.test.ts | 78 ++++++++++++- src/plan/toWalls3d.ts | 54 ++++++++- src/ui/ribbon/CommandIcon.tsx | 9 ++ src/ui/ribbon/ribbonItems.ts | 1 + 11 files changed, 583 insertions(+), 5 deletions(-) create mode 100644 src/commands/cmds/roof.ts create mode 100644 src/plan/generatePlan.roof.test.ts diff --git a/src/commands/cmds/roof.ts b/src/commands/cmds/roof.ts new file mode 100644 index 0000000..b2666a8 --- /dev/null +++ b/src/commands/cmds/roof.ts @@ -0,0 +1,173 @@ +// Roof — Dach als Engine-Befehl: der Grundriss wird als RECHTECK aufgezogen +// (zwei Klicks: erste Ecke → Gegenecke), committet ein `Roof`-Bauteil. Die +// Dachform/Neigung wählt man nach dem Platzieren im Attribut-Panel; Default ist +// ein Satteldach mit First entlang der LÄNGEREN Rechteckseite. +// +// Schritte: +// 1) „Erste Dachecke:" → Punkt +// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet das Dach. +// +// Herkunft der Dach-Felder (CommandContext): +// • floorId ← ctx.level.id (aktives Geschoss) +// • categoryCode← "35" (Ebene „Dächer"; Fallback ctx.defaultCategoryCode) + +import type { Roof, RoofShape } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + CmdOption, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +/** Default-Ebene (Kategorie) für Dächer („31 Dächer"). */ +const ROOF_CATEGORY = "31"; + +/** Wählbare Dachformen (Inline-Optionen der Befehlszeile). */ +const SHAPE_OPTIONS: CmdOption[] = [ + { id: "sattel", labelKey: "cmd.roof.shape.sattel" }, + { id: "walm", labelKey: "cmd.roof.shape.walm" }, + { id: "pult", labelKey: "cmd.roof.shape.pult" }, + { id: "mansarde", labelKey: "cmd.roof.shape.mansarde" }, + { id: "zelt", labelKey: "cmd.roof.shape.zelt" }, + { id: "flach", labelKey: "cmd.roof.shape.flach" }, +]; +const SHAPE_IDS = new Set(SHAPE_OPTIONS.map((o) => o.id)); + +/** Existiert die Dach-Kategorie („35")? Sonst Fallback auf die aktive Kategorie. */ +function roofCategory(ctx: CommandContext): string { + const flat: string[] = []; + const walk = (list: { code: string; children?: unknown[] }[]) => { + for (const c of list) { + flat.push(c.code); + if (c.children) walk(c.children as { code: string; children?: unknown[] }[]); + } + }; + walk(ctx.project.layers as { code: string; children?: unknown[] }[]); + return flat.includes(ROOF_CATEGORY) ? ROOF_CATEGORY : ctx.defaultCategoryCode; +} + +/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */ +function rectCorners(a: Vec2, b: Vec2): Vec2[] { + const x0 = Math.min(a.x, b.x); + const x1 = Math.max(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const y1 = Math.max(a.y, b.y); + return [ + { x: x0, y: y0 }, + { x: x1, y: y0 }, + { x: x1, y: y1 }, + { x: x0, y: y1 }, + ]; +} + +interface RoofIdle extends CommandState { + phase: "start"; + shape: RoofShape; +} +interface RoofRect extends CommandState { + phase: "corner"; + a: Vec2; + cursor: Vec2 | null; + shape: RoofShape; +} +type RoofState = RoofIdle | RoofRect; + +/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */ +function roofDraft(a: Vec2, cursor: Vec2 | null): ToolDraft { + const pts = cursor ? rectCorners(a, cursor) : [a]; + const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }]; + const draft: ToolDraft = { preview, vertices: [a] }; + if (cursor) { + draft.hud = { + at: cursor, + text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`, + }; + } + return draft; +} + +/** Hängt ein Dach ans Projekt (immutabel). Entartete Rechtecke werden verworfen. */ +function appendRoof(p: Project, a: Vec2, b: Vec2, shape: RoofShape, ctx: CommandContext): Project { + const w = Math.abs(b.x - a.x); + const d = Math.abs(b.y - a.y); + if (w < 0.1 || d < 0.1) return p; + const roof: Roof = { + id: uniqueId("RF"), + type: "roof", + floorId: ctx.level.id, + categoryCode: roofCategory(ctx), + outline: rectCorners(a, b), + shape, + // Flach/Pult brauchen keine „Firsthöhe"; Sattel/Walm/Mansarde/Zelt ~30°. + pitchDeg: shape === "flach" ? 0 : 30, + overhang: 0.4, + // First entlang der längeren Seite (Standard). + ridgeAxis: w >= d ? "x" : "y", + thickness: 0.2, + }; + const roofs = p.roofs ? [...p.roofs, roof] : [roof]; + return { ...p, roofs }; +} + +const idle = (shape: RoofShape = "sattel"): [CommandState, CommandResult] => [ + { phase: "start", shape, lastPoint: null } as RoofIdle, + { draft: null, done: true }, +]; + +export const roofCommand: Command = { + name: "roof", + labelKey: "cmd.roof.label", + floorOnly: true, + prompt: (s) => ((s as RoofState).phase === "corner" ? "cmd.roof.corner" : "cmd.roof.start"), + accepts: () => ["point", "option"], + // Dachform als Inline-Optionen — vor/während des Aufziehens wählbar. + options: () => SHAPE_OPTIONS, + init: (): RoofIdle => ({ phase: "start", shape: "sattel", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as RoofState; + // Dachform-Option: aktualisiert die Form, Phase/Rechteck bleiben. + if (input.kind === "option" && SHAPE_IDS.has(input.id)) { + const ns = { ...s, shape: input.id as RoofShape } as RoofState; + const draft = ns.phase === "corner" ? roofDraft(ns.a, ns.cursor) : null; + return [ns, { draft }]; + } + if (input.kind !== "point") { + return [s, { draft: s.phase === "corner" ? roofDraft(s.a, s.cursor) : null }]; + } + if (s.phase !== "corner") { + const ns: RoofRect = { + phase: "corner", + a: input.point, + cursor: input.point, + shape: s.shape, + lastPoint: input.point, + }; + return [ns, { draft: roofDraft(input.point, input.point) }]; + } + // Zweite Ecke → committen. + const a = s.a; + const b = input.point; + const shape = s.shape; + return [ + { phase: "start", shape, lastPoint: null } as RoofIdle, + { draft: null, done: true, commit: (p) => appendRoof(p, a, b, shape, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as RoofState; + if (s.phase !== "corner") return [s, { draft: null }]; + const ns: RoofRect = { ...s, cursor: point }; + return [ns, { draft: roofDraft(s.a, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 1cfbc41..9b4c366 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -11,6 +11,7 @@ import { ceilingCommand } from "./cmds/ceiling"; import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening"; import { stairCommand } from "./cmds/stair"; import { columnCommand } from "./cmds/column"; +import { roofCommand } from "./cmds/roof"; import { roomCommand } from "./cmds/room"; import { rectCommand } from "./cmds/rect"; import { circleCommand } from "./cmds/circle"; @@ -37,6 +38,7 @@ export const COMMANDS: Record = { tuer: tuerCommand, stair: stairCommand, column: columnCommand, + roof: roofCommand, room: roomCommand, line: lineCommand, polyline: polylineCommand, @@ -75,6 +77,8 @@ export const ALIASES: Record = { tp: "stair", stuetze: "column", stütze: "column", + dach: "roof", + rf: "roof", raum: "room", rm: "room", l: "line", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 2232612..7a0a92e 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -856,6 +856,15 @@ export const de = { "cmd.ceiling.label": "Decke", "cmd.ceiling.start": "Erster Umrisspunkt der Decke:", "cmd.ceiling.next": "Nächster Punkt ( Schliessen ):", + "cmd.roof.label": "Dach", + "cmd.roof.start": "Erste Dachecke:", + "cmd.roof.corner": "Gegenecke:", + "cmd.roof.shape.sattel": "Satteldach", + "cmd.roof.shape.walm": "Walmdach", + "cmd.roof.shape.pult": "Pultdach", + "cmd.roof.shape.mansarde": "Mansarddach", + "cmd.roof.shape.zelt": "Zeltdach", + "cmd.roof.shape.flach": "Flachdach", "cmd.opening.label": "Öffnung", "cmd.opening.windowLabel": "Fenster", "cmd.opening.doorLabel": "Tür", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 8014719..64be6bc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -846,6 +846,15 @@ export const en: Record = { "cmd.ceiling.label": "Ceiling", "cmd.ceiling.start": "First outline point of ceiling:", "cmd.ceiling.next": "Next point ( Close ):", + "cmd.roof.label": "Roof", + "cmd.roof.start": "First roof corner:", + "cmd.roof.corner": "Opposite corner:", + "cmd.roof.shape.sattel": "Gable roof", + "cmd.roof.shape.walm": "Hip roof", + "cmd.roof.shape.pult": "Mono-pitch roof", + "cmd.roof.shape.mansarde": "Mansard roof", + "cmd.roof.shape.zelt": "Pyramid roof", + "cmd.roof.shape.flach": "Flat roof", "cmd.opening.label": "Opening", "cmd.opening.windowLabel": "Window", "cmd.opening.doorLabel": "Door", diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index 2e3b5ba..19e8122 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -451,6 +451,27 @@ export const sampleProject: Project = { ceilingTypeId: "dg-massiv", }, ], + // Dach — Satteldach über dem Baukörper (5×4), First entlang X (längere Seite), + // 0.4 m Traufüberstand. Sitzt auf der OG-Oberkante (roofBaseElevation-Default). + roofs: [ + { + id: "RF1", + type: "roof", + floorId: "og", + categoryCode: "31", + outline: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 5, y: 4 }, + { x: 0, y: 4 }, + ], + shape: "sattel", + pitchDeg: 30, + overhang: 0.4, + ridgeAxis: "x", + thickness: 0.2, + }, + ], // Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG // (totalRise = Geschosshöhe). Kategorie „40 Treppen". stairs: [ diff --git a/src/plan/generatePlan.roof.test.ts b/src/plan/generatePlan.roof.test.ts new file mode 100644 index 0000000..f05f1a0 --- /dev/null +++ b/src/plan/generatePlan.roof.test.ts @@ -0,0 +1,147 @@ +/** + * Unit-Tests für die Dach-Darstellung im Grundriss (generatePlan → addRoof): + * `project.roofs` wird nach Geschoss + sichtbarer Kategorie gefiltert (analog + * Decken) und pro Dach in Grundriss-Linien übersetzt (Traufe/First/Grat/Knick, + * aus `roofGeometry`). Reine Linien-Darstellung ohne Poché — die Form (flach/ + * sattel/walm/mansarde) entscheidet, welche der vier Linienarten auftauchen. + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { Project, Roof } from "../model/types"; + +/** Minimalprojekt ohne Wände: nur das Geschoss + die Dach-Kategorie „35". */ +function baseProject(roofs?: Roof[]): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [], + wallTypes: [], + drawingLevels: [ + { + id: "eg", + name: "EG", + kind: "floor", + visible: true, + locked: false, + floorHeight: 2.6, + cutHeight: 1.0, + baseElevation: 0, + }, + ], + layers: [{ code: "35", name: "Dächer", color: "#8a4b32", lw: 0.25, visible: true, locked: false }], + walls: [], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + ...(roofs !== undefined ? { roofs } : {}), + }; +} + +const rectOutline: Roof["outline"] = [ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 4 }, + { x: 0, y: 4 }, +]; + +const roof = (id: string, shape: Roof["shape"], extra: Partial = {}): Roof => ({ + id, + type: "roof", + floorId: "eg", + categoryCode: "35", + outline: rectOutline, + shape, + pitchDeg: 30, + overhang: 0.5, + ridgeAxis: "x", + thickness: 0.3, + ...extra, +}); + +const visible = new Set(["35"]); + +/** Filtert die Grundriss-Primitive nach `kind:"line"` + gegebener Klasse. */ +function linesOf(primitives: ReturnType["primitives"], cls: string) { + return primitives.filter( + (p): p is Extract => p.kind === "line" && p.cls === cls, + ); +} + +describe("generatePlan — Dächer (Dachaufsicht)", () => { + it("Satteldach: Traufe + First, aber kein Grat/Knick", () => { + const plan = generatePlan(baseProject([roof("R1", "sattel")]), "eg", visible); + expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0); + expect(linesOf(plan.primitives, "roof-ridge").length).toBeGreaterThan(0); + expect(linesOf(plan.primitives, "roof-hip").length).toBe(0); + expect(linesOf(plan.primitives, "roof-break").length).toBe(0); + }); + + it("Walmdach: Traufe + First + Grat", () => { + const plan = generatePlan(baseProject([roof("R2", "walm")]), "eg", visible); + expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0); + expect(linesOf(plan.primitives, "roof-ridge").length).toBeGreaterThan(0); + expect(linesOf(plan.primitives, "roof-hip").length).toBeGreaterThan(0); + }); + + it("Mansarddach: Knicklinien gestrichelt", () => { + const plan = generatePlan(baseProject([roof("R3", "mansarde")]), "eg", visible); + const breaks = linesOf(plan.primitives, "roof-break"); + expect(breaks.length).toBeGreaterThan(0); + for (const b of breaks) { + expect(b.dash).toBeTruthy(); + expect(b.dash?.length).toBeGreaterThan(0); + } + }); + + it("Flachdach: nur Traufe, weder First noch Grat noch Knick", () => { + const plan = generatePlan(baseProject([roof("R4", "flach")]), "eg", visible); + expect(linesOf(plan.primitives, "roof-eaves").length).toBeGreaterThan(0); + expect(linesOf(plan.primitives, "roof-ridge").length).toBe(0); + expect(linesOf(plan.primitives, "roof-hip").length).toBe(0); + expect(linesOf(plan.primitives, "roof-break").length).toBe(0); + }); + + it("First-Linie ist kräftiger als die Traufe (First > Traufe > Knick)", () => { + const plan = generatePlan(baseProject([roof("R5", "mansarde")]), "eg", visible); + const eaves = linesOf(plan.primitives, "roof-eaves")[0]; + const ridge = linesOf(plan.primitives, "roof-ridge")[0]; + const brk = linesOf(plan.primitives, "roof-break")[0]; + expect(ridge.weightMm).toBeGreaterThan(eaves.weightMm); + expect(eaves.weightMm).toBeGreaterThan(brk.weightMm); + }); + + it("löst die Strichfarbe über roof.color, sonst die Kategorie-Farbe auf", () => { + const withColor = generatePlan( + baseProject([roof("R6", "sattel", { color: "#ff0000" })]), + "eg", + visible, + ); + expect(linesOf(withColor.primitives, "roof-eaves")[0].color).toBe("#ff0000"); + + const withoutColor = generatePlan(baseProject([roof("R7", "sattel")]), "eg", visible); + expect(linesOf(withoutColor.primitives, "roof-eaves")[0].color).toBe("#8a4b32"); + }); + + it("Rückwärtskompatibilität: ohne Dächer (bzw. Dach auf anderem Geschoss) bleiben die Primitive unverändert", () => { + const planNoField = generatePlan(baseProject(undefined), "eg", visible); + const planEmpty = generatePlan(baseProject([]), "eg", visible); + const planOtherFloor = generatePlan( + baseProject([roof("R8", "sattel", { floorId: "og" })]), + "eg", + visible, + ); + + expect(planEmpty.primitives).toEqual(planNoField.primitives); + expect(planOtherFloor.primitives).toEqual(planNoField.primitives); + expect(planNoField.primitives.some((p) => p.kind === "line" && p.cls.startsWith("roof-"))).toBe( + false, + ); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 237613f..f495d4a 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -17,6 +17,7 @@ import type { Opening, OverrideActions, Project, + Roof, Room, Stair, Vec2, @@ -42,6 +43,7 @@ import { wallTypeThickness, } from "../model/types"; import { columnFootprint } from "../geometry/column"; +import { roofGeometry } from "../geometry/roof"; import { effectiveOverrides, hasOverrides } from "../overrides/engine"; import { evaluateRoom, siaLabel } from "../geometry/roomArea"; import type { Marks, RichTextDoc } from "../text/richText"; @@ -114,6 +116,15 @@ const SYMBOL_HAIRLINE_MM = 0.02; * Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach * BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */ const OVERHEAD_DASH: number[] = [0.4, 0.25]; +/** Strichstärken der Dach-Grundrisslinien (mm Papier): Traufe = mittlere + * Haarlinie, First etwas kräftiger als die Traufe, Grat dazwischen, Knick dünn + * + gestrichelt (vgl. addRoofs). */ +const ROOF_EAVES_MM = 0.13; +const ROOF_RIDGE_MM = 0.25; +const ROOF_HIP_MM = 0.18; +const ROOF_BREAK_MM = 0.09; +/** Strichmuster (mm Papier) der Dach-Knicklinie (Mansarde). */ +const ROOF_BREAK_DASH: number[] = [0.12, 0.08]; /** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */ const OUTLINE_DETAIL_FACTOR: Record = { grob: 1.6, // dickere Sammellinie @@ -735,6 +746,20 @@ export function generatePlan( addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints); } + // Dächer dieses Geschosses (Dachaufsicht): reine Grundriss-Linien (Traufe/ + // First/Grat/Knick), kein Poché. Über der Wand-Poché gezeichnet. + const roofs = (project.roofs ?? []).filter( + (r) => + r.floorId === floorId && + visibleCodes.has(r.categoryCode) && + categoryDisplay(r.categoryCode).render, + ); + for (const roof of roofs) { + const greyed = categoryDisplay(roof.categoryCode).greyed; + const category = catByCode.get(roof.categoryCode); + addRoof(primitives, roof, greyed, category); + } + // Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel // (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände // lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob". @@ -867,7 +892,7 @@ export function generatePlan( return { primitives, - bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms), + bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs), }; } @@ -2414,6 +2439,58 @@ function addCeilingPoche( } } +/** + * Dach im Grundriss (Dachaufsicht): reine Linien-Darstellung ohne Poché — die + * 2D-Geometrie aus `roofGeometry` (First/Grat/Knick hängen NICHT von der + * Traufhöhe ab, daher `eavesZ=0`): + * • Traufe (`eaves`): geschlossener Umriss-Ring als mittlere Haarlinie. + * • First (`ridges`): kräftige Volllinie, etwas stärker als die Traufe. + * • Grat (`hips`, Walm/Zelt): Volllinie zwischen Traufe- und First-Stärke. + * • Knick (`breaks`, Mansarde): dünne gestrichelte Linie. + * Farbe: `roof.color` übersteuert sonst die Kategorie-Farbe (analog Stützen- + * Poché), Default die neutrale Poché-Tinte. + */ +function addRoof( + out: Primitive[], + roof: Roof, + greyed: boolean, + category: LayerCategory | undefined, +): void { + const stroke = roof.color ?? category?.color ?? POCHE_STROKE; + const geo = roofGeometry(roof, 0); + + const n = geo.eaves.length; + for (let i = 0; i < n; i++) { + out.push({ + kind: "line", + a: geo.eaves[i], + b: geo.eaves[(i + 1) % n], + cls: "roof-eaves", + weightMm: ROOF_EAVES_MM, + color: stroke, + greyed, + }); + } + for (const [a, b] of geo.ridges) { + out.push({ kind: "line", a, b, cls: "roof-ridge", weightMm: ROOF_RIDGE_MM, color: stroke, greyed }); + } + for (const [a, b] of geo.hips) { + out.push({ kind: "line", a, b, cls: "roof-hip", weightMm: ROOF_HIP_MM, color: stroke, greyed }); + } + for (const [a, b] of geo.breaks) { + out.push({ + kind: "line", + a, + b, + cls: "roof-break", + weightMm: ROOF_BREAK_MM, + dash: ROOF_BREAK_DASH, + color: stroke, + greyed, + }); + } +} + /** * Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente * Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren @@ -2671,6 +2748,7 @@ function computeBounds( ceilings: Ceiling[] = [], stairs: Stair[] = [], rooms: Room[] = [], + roofs: Roof[] = [], ) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; const acc = (p: Vec2) => { @@ -2683,6 +2761,9 @@ function computeBounds( } for (const c of ceilings) for (const p of c.outline) acc(p); for (const r of rooms) for (const p of r.boundary) acc(p); + // Traufe (inkl. Überstand) statt reinem Umriss, damit ein weit auskragendes + // Dach das „Einpassen" korrekt mit einrahmt. + for (const roof of roofs) for (const p of roofGeometry(roof, 0).eaves) acc(p); for (const s of stairs) { const { zBottom, zTop } = stairVerticalExtent(project, s); const geo = stairGeometry(s, zTop - zBottom); diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 52ce2ec..a067b26 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -6,10 +6,11 @@ */ import { describe, it, expect } from "vitest"; -import type { Project } from "../model/types"; +import type { Project, Roof } from "../model/types"; import { sampleProject } from "../model/sampleProject"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d"; import { resolveHatch } from "./generatePlan"; +import { roofGeometry, roofBaseElevation } from "../geometry/roof"; const RGB: [number, number, number] = [1, 0.5, 0.1]; const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b] @@ -1047,3 +1048,78 @@ describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => { expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); }); }); + +describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => { + // Dieselbe Terrakotta-Farbe wie ROOF_RGB in toWalls3d.ts — dient hier nur der + // Isolation der Dach-Meshes von Kontext-/Öffnungs-/Treppen-Meshes im Test. + const ROOF_RGB: [number, number, number] = [0.72, 0.45, 0.36]; + const isRoofMesh = (m: { color: readonly number[] }) => + m.color[0] === ROOF_RGB[0] && m.color[1] === ROOF_RGB[1] && m.color[2] === ROOF_RGB[2]; + + const baseRoof: Roof = { + id: "R1", + type: "roof", + floorId: "eg", + categoryCode: "35", + outline: [ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 4 }, + { x: 0, y: 4 }, + ], + shape: "sattel", + pitchDeg: 30, + overhang: 0, + ridgeAxis: "x", + baseElevation: 3, // fix, unabhängig von Geschoss-Stapelung + thickness: 0.3, + }; + + it("Satteldach: Flächen + Giebel als EIN Roof-Mesh, First-Höhe passt zur Traufhöhe + ridgeHeight", () => { + 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. + 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); + }); + + it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => { + const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" }; + const project: Project = { ...sampleProject, roofs: [walmRoof] }; + const { meshes } = projectToModel3d(project); + const roofMeshes = meshes.filter(isRoofMesh); + 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); + const vcount = m.positions.length / 3; + expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); + }); + + 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: [] }; + expect(projectToModel3d(project).meshes.filter(isRoofMesh).length).toBe(0); + // Fehlt das Feld ganz (undefined), ebenfalls keine. + 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); + }); +}); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 9cde0fa..64fb8ba 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -51,6 +51,8 @@ import { pointInOutline } from "../geometry/ceiling"; import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening"; import type { DetailLevel } from "../ui/TopBar"; import { stairGeometry, stairBBox } from "../geometry/stair"; +import { roofGeometry, roofBaseElevation } from "../geometry/roof"; +import type { Vec3 as RoofVec3 } from "../geometry/roof"; import { extrudePolygon, extrudeCircle } from "../engine/truckSolid"; import { computeJoins } from "../model/joins"; import type { WallCuts } from "../model/joins"; @@ -393,6 +395,8 @@ const GLASS_HALF_THICK = 0.01; const FRAME_RGB: RRgb = [0.85, 0.85, 0.83]; /** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */ const DEFAULT_FRAME_WIDTH = 0.06; +/** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */ +const ROOF_RGB: RRgb = [0.72, 0.45, 0.36]; /** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */ const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255]; /** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */ @@ -1633,6 +1637,49 @@ function emitColumns(project: Project): RMesh[] { return out; } +/** + * Trianguliert ein KONVEXES 3D-Polygon per Dreiecks-Fächer ab Vertex 0 und hängt + * die Dreiecke an `positions`/`indices` an. `roofGeometry` (Vertrag in + * `geometry/roof.ts`) liefert für `planes`/`gables` ausschliesslich konvexe + * Polygone (Dreieck/Quader bei den meisten Formen, ein Fünfeck-Giebel bei + * Mansarde) — ein Fächer trianguliert diese korrekt, ohne Ear-Clipping. + */ +function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[]): void { + if (pts.length < 3) return; + const base = positions.length / 3; + for (const p of pts) positions.push(p[0], p[1], p[2]); + for (let i = 1; i < pts.length - 1; i++) indices.push(base, base + i, base + i + 1); +} + +/** + * Emittiert Dächer (`project.roofs`) als geschlossene Dreiecks-Meshes: sowohl die + * geneigten Dachflächen (`RoofGeometry.planes`) als auch die Giebelflächen + * (`RoofGeometry.gables`) — erst zusammen bilden sie einen von aussen + * geschlossenen Körper (die Giebel schliessen die offenen Enden bei Sattel/ + * Mansarde, die First-/Grat-/Knicklinien sind reine Grundriss-Hilfslinien und + * werden hier nicht gebraucht). Traufhöhe über {@link roofBaseElevation} + * (Override oder Geschoss-Oberkante), Flächen über {@link roofGeometry}. Farbe: + * `roof.color` (falls gültiges Hex), sonst {@link ROOF_RGB}. `positions` in + * MODELL-Metern (x, y, z=Höhe) wie {@link emitOpeningGlass}/{@link emitMeshes}; + * render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also + * unerheblich. + */ +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 positions: number[] = []; + const indices: number[] = []; + for (const plane of g.planes) pushFanTriangles(positions, indices, plane.pts); + for (const gable of g.gables) pushFanTriangles(positions, indices, gable); + if (indices.length === 0) continue; + out.push({ positions, indices, kind: "extrusion", color }); + } + return out; +} + /** * Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`) * und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet` @@ -2110,14 +2157,15 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R // Kontext-Meshes (Terrain/Import) + Fenster-/Tür-Glasscheiben (eigenes Mesh // je Scheibe) + Rahmen-/Sprossen-Riegel typisierter Öffnungen (Zarge/ // Blendrahmen/Kämpfer/Mittelpfosten, s. emitOpeningFrames) + glatte Beton- - // Laufplatten (gerade Betontreppen als Mesh) — pickGeometry/Highlight nutzen - // `meshes` bewusst NICHT (man wählt Wand/Öffnung/Treppe; Treppen-Pick läuft - // über die pickGeometry-Boxen). + // Laufplatten (gerade Betontreppen als Mesh) + Dachflächen/-giebel + // (s. emitRoofs) — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man + // wählt Wand/Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen). meshes: [ ...emitMeshes(project), ...emitOpeningGlass(project), ...emitOpeningFrames(project, detail), ...emitStairMeshes(project), + ...emitRoofs(project), ], }; } diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx index 72dfe08..7e253e5 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -99,6 +99,15 @@ export function CommandIcon({ name }: { name: string }) { ); + case "roof": + // Satteldach: Giebeldreieck + Traufe. + return ( + + + + + + ); default: return null; } diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts index 9ffd921..b05191b 100644 --- a/src/ui/ribbon/ribbonItems.ts +++ b/src/ui/ribbon/ribbonItems.ts @@ -105,6 +105,7 @@ export const RIBBON_TABS: RibbonTab[] = [ t("stair"), c("column"), t("ceiling"), + c("roof"), t("room"), ], },