Dächer anwählbar + editierbar + löschbar

Platzierte Dächer sind jetzt vollwertige Auswahl-Elemente (analog Decke):
- 2D-Auswahl per Klick: unsichtbares Traufe-Pick-Polygon (roofId) +
  pickRoof (Punkt-in-Polygon, niedrigste Priorität) in PlanView.
- Auswahl-Kanal selectedRoofIds (selectionSlice), updateRoof (projectSlice),
  RoofInfo + roofSelection + deriveSelection-Dispatch (selectionInfo).
- Attribut-Panel „Dach": Form (Sattel/Walm/Pult/Mansarde/Zelt/Flach),
  Firstrichtung X/Y, Neigung(en), Überstand, Dachdicke — live editierbar über
  host.onSetRoofPatch; dazu abgeleitete Firsthöhe + Grundfläche (read-only).
- Löschen (Delete) + Abwählen bei Geschosswechsel/Marquee/anderer Auswahl.
+2 Tests (Roof-Selection). tsc + vitest 642 + vite build grün.
This commit is contained in:
2026-07-09 22:18:59 +02:00
parent 89112eff9b
commit 80121a3f9a
12 changed files with 385 additions and 70 deletions
+54 -67
View File
@@ -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
+19
View File
@@ -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",
+19
View File
@@ -284,6 +284,25 @@ export const en: Record<TranslationKey, string> = {
"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",
+2
View File
@@ -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 && <WallSection wall={sel.wall} host={host} />}
{sel.ceiling && <CeilingSection ceiling={sel.ceiling} host={host} />}
{sel.roof && <RoofSection roof={sel.roof} host={host} />}
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
+104
View File
@@ -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<typeof usePanelHost>;
}) {
const numField = (
labelKey: TranslationKey,
value: number,
step: number,
apply: (v: number) => void,
) => (
<div className="objinfo-field">
<span className="objinfo-flabel">{t(labelKey)}</span>
<input
type="number"
className="objinfo-text"
step={step}
value={Number(value.toFixed(2))}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) apply(v);
}}
title={t(labelKey)}
/>
</div>
);
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.roof.section")}</div>
{/* Dachform. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.roof.shape")}</span>
<Dropdown
value={roof.shape}
onChange={(v) => 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}
/>
</div>
{/* Firstrichtung (X/Y) — nur bei Formen mit First relevant. */}
{roof.shape !== "flach" && roof.shape !== "zelt" && (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.roof.ridgeAxis")}</span>
<Dropdown
value={roof.ridgeAxis}
onChange={(v) => 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}
/>
</div>
)}
{/* 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). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.roof.ridgeHeight")}</span>
<span className="objinfo-fval">{formatM(roof.ridgeHeight)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.roof.area")}</span>
<span className="objinfo-fval">{roof.footprintArea.toFixed(2)} m²</span>
</div>
</>
);
}
export function StairSection({
stair,
host,
+5
View File
@@ -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<import("../model/types").Roof>) => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */
+27 -1
View File
@@ -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<PlanViewHandle, PlanViewProps>(
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<PlanViewHandle, PlanViewProps>(
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<PlanViewHandle, PlanViewProps>(
roomId,
extrudedSolidId,
columnId,
roofId,
shift: e.shiftKey,
});
}
+19
View File
@@ -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({
+13
View File
@@ -235,6 +235,12 @@ export interface ProjectSlice {
patch: Partial<import("../model/types").Opening>,
) => void;
/** Immutable Änderung eines Dachs (Form/Neigung/Überstand/Firstrichtung/Dicke). */
updateRoof: (
id: string,
patch: Partial<import("../model/types").Roof>,
) => 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) =>
+54
View File
@@ -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();
});
});
+62 -1
View File
@@ -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;
+7 -1
View File
@@ -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: [],
}),
};
}