diff --git a/src/App.tsx b/src/App.tsx index daa22cb..f4615c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -569,6 +569,11 @@ export default function App() { const setSelectedCeilingIds = useStore((s) => s.setSelectedCeilingIds); const selectedCeilingId = selectedCeilingIds.length === 1 ? selectedCeilingIds[0] : null; + // Dach-Auswahl (MENGE); Einzel-Element bei genau einem gewählten Dach. + const selectedRoofIds = useStore((s) => s.selectedRoofIds); + const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds); + const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null; + const updateRoof = useStore((s) => s.updateRoof); const selectedOpeningIds = useStore((s) => s.selectedOpeningIds); const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds); const selectedOpeningId = @@ -618,6 +623,7 @@ export default function App() { setSelectedRoomIds([]); setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); + setSelectedRoofIds([]); setMenu(null); setEditor(null); }, [activeLevelId]); @@ -1844,7 +1850,8 @@ export default function App() { selectedStairIds.length || selectedRoomIds.length || selectedExtrudedSolidIds.length || - selectedColumnIds.length + selectedColumnIds.length || + selectedRoofIds.length ) { const dids = new Set(selectedDrawingIds); const wids = new Set(selectedWallIds); @@ -1854,11 +1861,13 @@ export default function App() { const rmids = new Set(selectedRoomIds); const exids = new Set(selectedExtrudedSolidIds); const colids = new Set(selectedColumnIds); + const rfids = new Set(selectedRoofIds); setProject((p) => ({ ...p, drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)), walls: p.walls.filter((w) => !wids.has(w.id)), ceilings: (p.ceilings ?? []).filter((c) => !cids.has(c.id)), + roofs: (p.roofs ?? []).filter((r) => !rfids.has(r.id)), stairs: (p.stairs ?? []).filter((s) => !stids.has(s.id)), rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)), extrudedSolids: (p.extrudedSolids ?? []).filter((s) => !exids.has(s.id)), @@ -1877,6 +1886,7 @@ export default function App() { setSelectedRoomIds([]); setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); + setSelectedRoofIds([]); e.preventDefault(); } }; @@ -1892,9 +1902,11 @@ export default function App() { selectedStairIds, selectedExtrudedSolidIds, selectedColumnIds, + selectedRoofIds, project.walls, project.drawings2d, project.ceilings, + project.roofs, project.stairs, project.extrudedSolids, project.columns, @@ -2559,6 +2571,7 @@ export default function App() { selectedRoomId, selectedExtrudedSolidId, selectedColumnId, + selectedRoofId, ), [ project, @@ -2570,6 +2583,7 @@ export default function App() { selectedRoomId, selectedExtrudedSolidId, selectedColumnId, + selectedRoofId, ], ); @@ -3116,6 +3130,10 @@ export default function App() { setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles"); setResourcesOpen(true); }, + // ── Dach-Attribute (nur bei selektiertem Dach) ───────────────────────── + onSetRoofPatch: (patch) => { + if (selection?.kind === "roof") updateRoof(selection.id, patch); + }, // ── Treppen-Attribute (nur bei selektierter Treppe) ──────────────────── onSetStairShape: (shape) => { if (selection?.kind === "stair") updateStair(selection.id, { shape }); @@ -4220,93 +4238,61 @@ export default function App() { ? selectedColumnIds.filter((x) => x !== id) : [...selectedColumnIds, id], ); + } else if (sel.roofId) { + const id = sel.roofId; + setSelectedRoofIds( + selectedRoofIds.includes(id) + ? selectedRoofIds.filter((x) => x !== id) + : [...selectedRoofIds, id], + ); } return; } // Ohne Shift: ersetzen. Öffnung hat Vorrang (ihr Symbol liegt in der Lücke), // dann Wand, dann 2D-Element, dann Decke; sonst leeren. Die Kanäle schließen // sich gegenseitig aus. + // Alle Kanäle ausser dem gewählten leeren — als Helfer, damit jeder Zweig + // (inkl. des neuen Dach-Kanals) konsistent bleibt. + const clearOthers = (keep: string) => { + if (keep !== "wall") setSelectedWallIds([]); + if (keep !== "drawing") setSelectedDrawingId(null); + if (keep !== "ceiling") setSelectedCeilingIds([]); + if (keep !== "opening") setSelectedOpeningIds([]); + if (keep !== "stair") setSelectedStairIds([]); + if (keep !== "room") setSelectedRoomIds([]); + if (keep !== "extrudedSolid") setSelectedExtrudedSolidIds([]); + if (keep !== "column") setSelectedColumnIds([]); + if (keep !== "roof") setSelectedRoofIds([]); + }; if (sel.openingId) { setSelectedOpeningIds([sel.openingId]); - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers("opening"); } else if (sel.wallId) { setSelectedWallIds([sel.wallId]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers("wall"); } else if (sel.columnId) { setSelectedColumnIds([sel.columnId]); - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); + clearOthers("column"); } else if (sel.drawingId) { setSelectedDrawingId(sel.drawingId); - setSelectedWallIds([]); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers("drawing"); } else if (sel.extrudedSolidId) { setSelectedExtrudedSolidIds([sel.extrudedSolidId]); - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedColumnIds([]); + clearOthers("extrudedSolid"); } else if (sel.ceilingId) { setSelectedCeilingIds([sel.ceilingId]); - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers("ceiling"); } else if (sel.stairId) { setSelectedStairIds([sel.stairId]); - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers("stair"); } else if (sel.roomId) { setSelectedRoomIds([sel.roomId]); - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers("room"); + } else if (sel.roofId) { + setSelectedRoofIds([sel.roofId]); + clearOthers("roof"); } else { - setSelectedWallIds([]); - setSelectedDrawingId(null); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); + clearOthers(""); } }; // Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen @@ -4314,14 +4300,15 @@ export default function App() { const onPlanMarquee = (sel: MarqueeSelection) => { setSelectedWallIds(sel.wallIds); setSelectedDrawingIds(sel.drawingIds ?? []); - // Decken/Öffnungen/Treppen/Räume/Extrusionen sind (noch) nicht Teil des - // Marquee → beim Aufziehen abwählen. + // Decken/Öffnungen/Treppen/Räume/Extrusionen/Dächer sind (noch) nicht Teil + // des Marquee → beim Aufziehen abwählen. setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); + setSelectedRoofIds([]); }; // Rechts-Klick im Grundriss: Wand-Kontextmenü. Trifft der Klick eine Wand, die // NICHT bereits in der Auswahl ist, wird die Auswahl auf sie gesetzt (damit die diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 7a0a92e..4ff6864 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -285,6 +285,25 @@ export const de = { "objinfo.kind.room": "Raum", "objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.column": "Stütze", + "objinfo.kind.roof": "Dach", + // ── Dach-Attribute (Object-Info-Panel) ────────────────────────────────── + "objinfo.roof.section": "Dach", + "objinfo.roof.shape": "Dachform", + "objinfo.roof.shape.flach": "Flachdach", + "objinfo.roof.shape.pult": "Pultdach", + "objinfo.roof.shape.sattel": "Satteldach", + "objinfo.roof.shape.walm": "Walmdach", + "objinfo.roof.shape.mansarde": "Mansarddach", + "objinfo.roof.shape.zelt": "Zeltdach", + "objinfo.roof.ridgeAxis": "Firstrichtung", + "objinfo.roof.ridgeAxis.x": "entlang X", + "objinfo.roof.ridgeAxis.y": "entlang Y", + "objinfo.roof.pitch": "Neigung (°)", + "objinfo.roof.pitchUpper": "Obere Neigung (°)", + "objinfo.roof.overhang": "Überstand (m)", + "objinfo.roof.thickness": "Dachdicke (m)", + "objinfo.roof.ridgeHeight": "Firsthöhe", + "objinfo.roof.area": "Grundfläche", // ── Stützen-Attribute (Object-Info-Panel) ─────────────────────────────── "objinfo.column.section": "Stütze", "objinfo.column.profile": "Profil", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 64be6bc..8f5005e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -284,6 +284,25 @@ export const en: Record = { "objinfo.kind.room": "Room", "objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.column": "Column", + "objinfo.kind.roof": "Roof", + // ── Roof attributes (object info panel) ───────────────────────────────── + "objinfo.roof.section": "Roof", + "objinfo.roof.shape": "Roof shape", + "objinfo.roof.shape.flach": "Flat roof", + "objinfo.roof.shape.pult": "Mono-pitch roof", + "objinfo.roof.shape.sattel": "Gable roof", + "objinfo.roof.shape.walm": "Hip roof", + "objinfo.roof.shape.mansarde": "Mansard roof", + "objinfo.roof.shape.zelt": "Pyramid roof", + "objinfo.roof.ridgeAxis": "Ridge direction", + "objinfo.roof.ridgeAxis.x": "along X", + "objinfo.roof.ridgeAxis.y": "along Y", + "objinfo.roof.pitch": "Pitch (°)", + "objinfo.roof.pitchUpper": "Upper pitch (°)", + "objinfo.roof.overhang": "Overhang (m)", + "objinfo.roof.thickness": "Roof thickness (m)", + "objinfo.roof.ridgeHeight": "Ridge height", + "objinfo.roof.area": "Footprint area", // ── Column attributes (object info panel) ──────────────────────────────── "objinfo.column.section": "Column", "objinfo.column.profile": "Profile", diff --git a/src/panels/AttributesPanel.tsx b/src/panels/AttributesPanel.tsx index c5868b7..be63416 100644 --- a/src/panels/AttributesPanel.tsx +++ b/src/panels/AttributesPanel.tsx @@ -36,6 +36,7 @@ import { ColorHexField } from "../ui/ColorHexField"; import { WallSection, CeilingSection, + RoofSection, OpeningSection, StairSection, RoomSection, @@ -314,6 +315,7 @@ export function AttributesPanel() { Darstellung + die Host-Handler. */} {sel.wall && } {sel.ceiling && } + {sel.roof && } {sel.opening && } {sel.stair && } {sel.room && } diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index 8b96e15..86c1672 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -16,8 +16,10 @@ import { useState } from "react"; import { t } from "../i18n"; +import type { TranslationKey } from "../i18n"; import { formatM } from "../model/types"; import type { + RoofShape, SiaCategory, SliceTermination, StairShape, @@ -32,6 +34,7 @@ import type { ColumnInfo, ExtrudedSolidInfo, OpeningInfo, + RoofInfo, RoomInfo, StairInfo, WallInfo, @@ -431,6 +434,107 @@ export function ColumnSection({ // ── Treppen-Attribut-Abschnitt ─────────────────────────────────────────────── // Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete // Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only. +/** Dach-Abschnitt: Form, Neigung(en), Überstand, Firstrichtung, Dicke. */ +export function RoofSection({ + roof, + host, +}: { + roof: RoofInfo; + host: ReturnType; +}) { + const numField = ( + labelKey: TranslationKey, + value: number, + step: number, + apply: (v: number) => void, + ) => ( +
+ {t(labelKey)} + { + const v = Number(e.target.value); + if (Number.isFinite(v)) apply(v); + }} + title={t(labelKey)} + /> +
+ ); + return ( + <> +
+
{t("objinfo.roof.section")}
+ + {/* Dachform. */} +
+ {t("objinfo.roof.shape")} + host.onSetRoofPatch({ shape: v as RoofShape })} + options={[ + { value: "flach", label: t("objinfo.roof.shape.flach") }, + { value: "pult", label: t("objinfo.roof.shape.pult") }, + { value: "sattel", label: t("objinfo.roof.shape.sattel") }, + { value: "walm", label: t("objinfo.roof.shape.walm") }, + { value: "mansarde", label: t("objinfo.roof.shape.mansarde") }, + { value: "zelt", label: t("objinfo.roof.shape.zelt") }, + ]} + title={t("objinfo.roof.shape")} + width={130} + /> +
+ + {/* Firstrichtung (X/Y) — nur bei Formen mit First relevant. */} + {roof.shape !== "flach" && roof.shape !== "zelt" && ( +
+ {t("objinfo.roof.ridgeAxis")} + host.onSetRoofPatch({ ridgeAxis: v as "x" | "y" })} + options={[ + { value: "x", label: t("objinfo.roof.ridgeAxis.x") }, + { value: "y", label: t("objinfo.roof.ridgeAxis.y") }, + ]} + title={t("objinfo.roof.ridgeAxis")} + width={130} + /> +
+ )} + + {/* Neigung(en) — Flachdach hat keine. */} + {roof.shape !== "flach" && + numField("objinfo.roof.pitch", roof.pitchDeg, 1, (v) => + host.onSetRoofPatch({ pitchDeg: Math.max(0, Math.min(85, v)) }), + )} + {roof.shape === "mansarde" && + numField("objinfo.roof.pitchUpper", roof.pitchUpperDeg ?? roof.pitchDeg / 2, 1, (v) => + host.onSetRoofPatch({ pitchUpperDeg: Math.max(0, Math.min(85, v)) }), + )} + + {/* Überstand + Dicke (Meter). */} + {numField("objinfo.roof.overhang", roof.overhang, 0.05, (v) => + host.onSetRoofPatch({ overhang: Math.max(0, v) }), + )} + {numField("objinfo.roof.thickness", roof.thickness, 0.02, (v) => + host.onSetRoofPatch({ thickness: Math.max(0.01, v) }), + )} + + {/* Abgeleitete Werte (read-only). */} +
+ {t("objinfo.roof.ridgeHeight")} + {formatM(roof.ridgeHeight)} +
+
+ {t("objinfo.roof.area")} + {roof.footprintArea.toFixed(2)} m² +
+ + ); +} + export function StairSection({ stair, host, diff --git a/src/panels/host.ts b/src/panels/host.ts index 556cdfc..63f8d0c 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -299,6 +299,11 @@ export interface PanelHostValue { * (Discoverability: von der gewählten Öffnung in den Typeditor springen). */ onEditOpeningType: (kind: "door" | "window") => void; + /** + * Immutable Änderung von Dach-Attributen (Form/Neigung/Überstand/Firstrichtung/ + * Dicke) — wirkt NUR auf das selektierte Dach. + */ + onSetRoofPatch: (patch: Partial) => void; // ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ─── /** Setzt die Grundform (gerade/L/Wendel). */ diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 27ad285..8c3a12e 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -192,6 +192,11 @@ export interface PlanSelection { * traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`. */ columnId: string | null; + /** + * ID des getroffenen Dachs (Traufe-Fläche), wenn der Klick es traf und KEIN + * Element höherer Priorität (alle anderen gehen vor); sonst `null`. + */ + roofId: string | null; /** * War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die * Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu @@ -985,6 +990,20 @@ export const PlanView = forwardRef( return null; }; + // Getroffenes Dach: das unsichtbare Traufe-Pick-Polygon per Punkt-in-Polygon + // (die sichtbaren Dachlinien allein wären schwer exakt zu treffen). + const pickRoof = (clientX: number, clientY: number): string | null => { + const v = clientToView(clientX, clientY, view); + const m = viewToModel(v.x, v.y); + for (let i = plan.primitives.length - 1; i >= 0; i--) { + const p = plan.primitives[i]; + if (p.kind === "polygon" && p.roofId && pointInPolygon(m, p.pts)) { + return p.roofId; + } + } + return null; + }; + // Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss // ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon // trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie). @@ -1536,11 +1555,17 @@ export const PlanView = forwardRef( openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId ? null : pickStair(e.clientX, e.clientY); - // Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen. + // Raum als vorletzte Priorität: die Raum-Fläche liegt UNTER allem anderen. const roomId = openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId ? null : pickRoom(e.clientX, e.clientY); + // Dach als LETZTE Priorität: die Traufe-Fläche deckt den ganzen Grundriss + // ab, daher erst wählen, wenn wirklich nichts anderes getroffen wurde. + const roofId = + openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId + ? null + : pickRoof(e.clientX, e.clientY); onSelect({ model: viewToModel(v.x, v.y), hitIndex, @@ -1552,6 +1577,7 @@ export const PlanView = forwardRef( roomId, extrudedSolidId, columnId, + roofId, shift: e.shiftKey, }); } diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 09a17a1..bf16853 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -258,6 +258,8 @@ export type Primitive = * (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche). */ columnId?: string; + /** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */ + roofId?: string; } | { kind: "line"; @@ -2466,6 +2468,23 @@ function addRoof( const stroke = roof.color ?? category?.color ?? POCHE_STROKE; const geo = roofGeometry(roof, 0); + // Unsichtbares Pick-Polygon über der Traufe-Fläche — macht das Dach über die + // ganze Fläche per Links-Klick selektierbar (die Dachlinien allein wären + // schwer exakt zu treffen). fill/stroke "none" ⇒ zeichnet nichts; PlanView + // testet nur die `pts` (Punkt-in-Polygon), analog Öffnung/Decke. + if (geo.eaves.length >= 3) { + out.push({ + kind: "polygon", + pts: geo.eaves, + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + greyed, + roofId: roof.id, + }); + } + const n = geo.eaves.length; for (let i = 0; i < n; i++) { out.push({ diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 7227aa6..3e703a8 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -235,6 +235,12 @@ export interface ProjectSlice { patch: Partial, ) => void; + /** Immutable Änderung eines Dachs (Form/Neigung/Überstand/Firstrichtung/Dicke). */ + updateRoof: ( + id: string, + patch: Partial, + ) => void; + // ── Treppen-Editieren ────────────────────────────────────────────────────── /** Verschiebt eine Treppe (Antritt) um `delta`. */ moveStairBy: (stairId: string, delta: Vec2) => void; @@ -913,6 +919,13 @@ export function createProjectSlice( openings: (p.openings ?? []).map((o) => (o.id === id ? { ...o, ...patch } : o)), })), + // ── Dach-Editieren ───────────────────────────────────────────────────── + updateRoof: (id, patch) => + setProject((p) => ({ + ...p, + roofs: (p.roofs ?? []).map((r) => (r.id === id ? { ...r, ...patch } : r)), + })), + // ── Treppen-Editieren ────────────────────────────────────────────────── // (coalesceKey: siehe Kommentar bei moveGripOf.) moveStairBy: (stairId, delta) => diff --git a/src/state/roofSelection.test.ts b/src/state/roofSelection.test.ts new file mode 100644 index 0000000..5c52284 --- /dev/null +++ b/src/state/roofSelection.test.ts @@ -0,0 +1,54 @@ +// Dach-Auswahl: deriveSelection liefert für ein selektiertes Dach eine +// Roof-Selection mit den erwarteten Attributen (Form/Neigung/Firsthöhe/Fläche). + +import { describe, it, expect } from "vitest"; +import { deriveSelection } from "./selectionInfo"; +import type { Project, Roof } from "../model/types"; + +const roof: Roof = { + id: "RF1", + type: "roof", + floorId: "eg", + categoryCode: "31", + outline: [ + { x: 0, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 4 }, + { x: 0, y: 4 }, + ], + shape: "sattel", + pitchDeg: 45, + overhang: 0, + ridgeAxis: "x", + thickness: 0.2, +}; + +const project = { + id: "p", + name: "T", + layers: [{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false }], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, baseElevation: 0 }, + ], + walls: [], + drawings2d: [], + roofs: [roof], +} as unknown as Project; + +describe("deriveSelection — Dach", () => { + it("liefert eine Roof-Selection mit Form + abgeleiteten Werten", () => { + const sel = deriveSelection(project, [], null, null, null, null, null, null, null, "RF1"); + expect(sel?.kind).toBe("roof"); + expect(sel?.roof?.shape).toBe("sattel"); + expect(sel?.roof?.pitchDeg).toBe(45); + // Firsthöhe = halbe Tiefe (2) · tan45 (1) = 2. + expect(sel?.roof?.ridgeHeight).toBeCloseTo(2, 6); + // Grundfläche 6×4 = 24. + expect(sel?.roof?.footprintArea).toBeCloseTo(24, 6); + expect(sel?.id).toBe("RF1"); + }); + + it("ohne Dach-Auswahl kein Roof (null bei leerer Auswahl)", () => { + expect(deriveSelection(project, [], null)).toBeNull(); + }); +}); diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 71667d3..a90731b 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -30,6 +30,8 @@ import type { LayerCategory, Opening, Project, + Roof, + RoofShape, Room, SiaCategory, SliceTermination, @@ -49,6 +51,7 @@ import { wallVerticalExtent, } from "../model/wall"; import { ceilingArea, outlineBBox } from "../geometry/ceiling"; +import { roofBBox, roofBaseElevation, roofGeometry } from "../geometry/roof"; import { openingGapQuad, openingVerticalExtent } from "../geometry/opening"; import { stairGeometry, stairBBox } from "../geometry/stair"; @@ -70,6 +73,25 @@ export interface FloorChoice { name: string; } +/** Dach-Attribute fürs Objekt-Info-Panel (nur bei Auswahl genau eines Dachs). */ +export interface RoofInfo { + shape: RoofShape; + /** Hauptneigung (Grad). */ + pitchDeg: number; + /** Obere Neigung (Grad) — nur Mansarde relevant. */ + pitchUpperDeg?: number; + /** Dachüberstand an der Traufe (Meter). */ + overhang: number; + /** Firstrichtung entlang X oder Y. */ + ridgeAxis: "x" | "y"; + /** Dachdicke (Meter). */ + thickness: number; + /** Firsthöhe über der Traufe (Meter, abgeleitet). */ + ridgeHeight: number; + /** Grundfläche des Umriss-Rechtecks (m², ohne Überstand). */ + footprintArea: number; +} + /** * Eine wählbare Schichttrennlinie (Fuge zwischen zwei Schichten) als * Referenzlinie einer mehrschichtigen Wand. `offset` ist der fertige @@ -299,7 +321,8 @@ export interface Selection { | "drawing2d" | "door" | "extrudedSolid" - | "column"; + | "column" + | "roof"; id: string; /** Grafik-Kategorie (Ebene) des Elements. */ categoryCode: string; @@ -357,6 +380,8 @@ export interface Selection { wall?: WallInfo; /** Decken-Attribute (nur bei `kind === "ceiling"`). */ ceiling?: CeilingInfo; + /** Dach-Attribute (nur bei `kind === "roof"`). */ + roof?: RoofInfo; /** Öffnungs-Attribute (nur bei `kind === "opening"`). */ opening?: OpeningInfo; /** Treppen-Attribute (nur bei `kind === "stair"`). */ @@ -577,6 +602,37 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection { }; } +/** Selektion für ein Dach ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */ +function roofSelection(project: Project, roof: Roof): Selection { + const cat = findCategory(project.layers, roof.categoryCode); + const color = roof.color ?? cat?.color ?? WALL_FALLBACK_COLOR; + const weightMm = cat?.lw ?? WALL_FALLBACK_MM; + const bb = roofBBox(roof.outline); + const eavesZ = roofBaseElevation(project, roof); + const g = roofGeometry(roof, eavesZ); + const footprintArea = Math.abs(bb.x1 - bb.x0) * Math.abs(bb.y1 - bb.y0); + const roofInfo: RoofInfo = { + shape: roof.shape, + pitchDeg: roof.pitchDeg, + pitchUpperDeg: roof.pitchUpperDeg, + overhang: roof.overhang, + ridgeAxis: roof.ridgeAxis, + thickness: roof.thickness, + ridgeHeight: g.ridgeHeight, + footprintArea, + }; + return { + kind: "roof", + id: roof.id, + categoryCode: roof.categoryCode, + color, + weightMm, + closed: true, + bbox: { minX: bb.x0, minY: bb.y0, maxX: bb.x1, maxY: bb.y1 }, + roof: roofInfo, + }; +} + /** Selektion für eine Öffnung ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */ function openingSelection(project: Project, o: Opening): Selection { const cat = findCategory(project.layers, o.categoryCode); @@ -847,6 +903,7 @@ export function deriveSelection( selectedRoomId: string | null = null, selectedExtrudedSolidId: string | null = null, selectedColumnId: string | null = null, + selectedRoofId: string | null = null, ): Selection | null { if (selectedDrawingId) { const d = project.drawings2d.find((x) => x.id === selectedDrawingId); @@ -873,6 +930,10 @@ export function deriveSelection( const c = (project.ceilings ?? []).find((x) => x.id === selectedCeilingId); return c ? ceilingSelection(project, c) : null; } + if (selectedRoofId) { + const r = (project.roofs ?? []).find((x) => x.id === selectedRoofId); + return r ? roofSelection(project, r) : null; + } if (selectedStairId) { const s = (project.stairs ?? []).find((x) => x.id === selectedStairId); return s ? stairSelection(project, s) : null; diff --git a/src/state/selectionSlice.ts b/src/state/selectionSlice.ts index 425da39..b7bfa4a 100644 --- a/src/state/selectionSlice.ts +++ b/src/state/selectionSlice.ts @@ -23,6 +23,8 @@ export interface SelectionSlice { selectedExtrudedSolidIds: string[]; // Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen. selectedColumnIds: string[]; + // Gewählte Dächer als MENGE von IDs — getrennt von den übrigen Kanälen. + selectedRoofIds: string[]; setSelectedWallIds: (ids: string[]) => void; setSelectedDrawingIds: (ids: string[]) => void; @@ -32,7 +34,8 @@ export interface SelectionSlice { setSelectedRoomIds: (ids: string[]) => void; setSelectedExtrudedSolidIds: (ids: string[]) => void; setSelectedColumnIds: (ids: string[]) => void; - /** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */ + setSelectedRoofIds: (ids: string[]) => void; + /** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */ clearSelection: () => void; } @@ -49,6 +52,7 @@ export function createSelectionSlice( selectedRoomIds: [], selectedExtrudedSolidIds: [], selectedColumnIds: [], + selectedRoofIds: [], setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }), @@ -57,6 +61,7 @@ export function createSelectionSlice( setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }), setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }), setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }), + setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }), clearSelection: () => set({ selectedWallIds: [], @@ -67,6 +72,7 @@ export function createSelectionSlice( selectedRoomIds: [], selectedExtrudedSolidIds: [], selectedColumnIds: [], + selectedRoofIds: [], }), }; }