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 setSelectedCeilingIds = useStore((s) => s.setSelectedCeilingIds);
const selectedCeilingId = const selectedCeilingId =
selectedCeilingIds.length === 1 ? selectedCeilingIds[0] : null; 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 selectedOpeningIds = useStore((s) => s.selectedOpeningIds);
const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds); const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds);
const selectedOpeningId = const selectedOpeningId =
@@ -618,6 +623,7 @@ export default function App() {
setSelectedRoomIds([]); setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]); setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]); setSelectedColumnIds([]);
setSelectedRoofIds([]);
setMenu(null); setMenu(null);
setEditor(null); setEditor(null);
}, [activeLevelId]); }, [activeLevelId]);
@@ -1844,7 +1850,8 @@ export default function App() {
selectedStairIds.length || selectedStairIds.length ||
selectedRoomIds.length || selectedRoomIds.length ||
selectedExtrudedSolidIds.length || selectedExtrudedSolidIds.length ||
selectedColumnIds.length selectedColumnIds.length ||
selectedRoofIds.length
) { ) {
const dids = new Set(selectedDrawingIds); const dids = new Set(selectedDrawingIds);
const wids = new Set(selectedWallIds); const wids = new Set(selectedWallIds);
@@ -1854,11 +1861,13 @@ export default function App() {
const rmids = new Set(selectedRoomIds); const rmids = new Set(selectedRoomIds);
const exids = new Set(selectedExtrudedSolidIds); const exids = new Set(selectedExtrudedSolidIds);
const colids = new Set(selectedColumnIds); const colids = new Set(selectedColumnIds);
const rfids = new Set(selectedRoofIds);
setProject((p) => ({ setProject((p) => ({
...p, ...p,
drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)), drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)),
walls: p.walls.filter((w) => !wids.has(w.id)), walls: p.walls.filter((w) => !wids.has(w.id)),
ceilings: (p.ceilings ?? []).filter((c) => !cids.has(c.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)), stairs: (p.stairs ?? []).filter((s) => !stids.has(s.id)),
rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)), rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)),
extrudedSolids: (p.extrudedSolids ?? []).filter((s) => !exids.has(s.id)), extrudedSolids: (p.extrudedSolids ?? []).filter((s) => !exids.has(s.id)),
@@ -1877,6 +1886,7 @@ export default function App() {
setSelectedRoomIds([]); setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]); setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]); setSelectedColumnIds([]);
setSelectedRoofIds([]);
e.preventDefault(); e.preventDefault();
} }
}; };
@@ -1892,9 +1902,11 @@ export default function App() {
selectedStairIds, selectedStairIds,
selectedExtrudedSolidIds, selectedExtrudedSolidIds,
selectedColumnIds, selectedColumnIds,
selectedRoofIds,
project.walls, project.walls,
project.drawings2d, project.drawings2d,
project.ceilings, project.ceilings,
project.roofs,
project.stairs, project.stairs,
project.extrudedSolids, project.extrudedSolids,
project.columns, project.columns,
@@ -2559,6 +2571,7 @@ export default function App() {
selectedRoomId, selectedRoomId,
selectedExtrudedSolidId, selectedExtrudedSolidId,
selectedColumnId, selectedColumnId,
selectedRoofId,
), ),
[ [
project, project,
@@ -2570,6 +2583,7 @@ export default function App() {
selectedRoomId, selectedRoomId,
selectedExtrudedSolidId, selectedExtrudedSolidId,
selectedColumnId, selectedColumnId,
selectedRoofId,
], ],
); );
@@ -3116,6 +3130,10 @@ export default function App() {
setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles"); setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles");
setResourcesOpen(true); setResourcesOpen(true);
}, },
// ── Dach-Attribute (nur bei selektiertem Dach) ─────────────────────────
onSetRoofPatch: (patch) => {
if (selection?.kind === "roof") updateRoof(selection.id, patch);
},
// ── Treppen-Attribute (nur bei selektierter Treppe) ──────────────────── // ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
onSetStairShape: (shape) => { onSetStairShape: (shape) => {
if (selection?.kind === "stair") updateStair(selection.id, { shape }); if (selection?.kind === "stair") updateStair(selection.id, { shape });
@@ -4220,93 +4238,61 @@ export default function App() {
? selectedColumnIds.filter((x) => x !== id) ? selectedColumnIds.filter((x) => x !== id)
: [...selectedColumnIds, id], : [...selectedColumnIds, id],
); );
} else if (sel.roofId) {
const id = sel.roofId;
setSelectedRoofIds(
selectedRoofIds.includes(id)
? selectedRoofIds.filter((x) => x !== id)
: [...selectedRoofIds, id],
);
} }
return; return;
} }
// Ohne Shift: ersetzen. Öffnung hat Vorrang (ihr Symbol liegt in der Lücke), // 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 // dann Wand, dann 2D-Element, dann Decke; sonst leeren. Die Kanäle schließen
// sich gegenseitig aus. // 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) { if (sel.openingId) {
setSelectedOpeningIds([sel.openingId]); setSelectedOpeningIds([sel.openingId]);
setSelectedWallIds([]); clearOthers("opening");
setSelectedDrawingId(null);
setSelectedCeilingIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} else if (sel.wallId) { } else if (sel.wallId) {
setSelectedWallIds([sel.wallId]); setSelectedWallIds([sel.wallId]);
setSelectedDrawingId(null); clearOthers("wall");
setSelectedCeilingIds([]);
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} else if (sel.columnId) { } else if (sel.columnId) {
setSelectedColumnIds([sel.columnId]); setSelectedColumnIds([sel.columnId]);
setSelectedWallIds([]); clearOthers("column");
setSelectedDrawingId(null);
setSelectedCeilingIds([]);
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
} else if (sel.drawingId) { } else if (sel.drawingId) {
setSelectedDrawingId(sel.drawingId); setSelectedDrawingId(sel.drawingId);
setSelectedWallIds([]); clearOthers("drawing");
setSelectedCeilingIds([]);
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} else if (sel.extrudedSolidId) { } else if (sel.extrudedSolidId) {
setSelectedExtrudedSolidIds([sel.extrudedSolidId]); setSelectedExtrudedSolidIds([sel.extrudedSolidId]);
setSelectedWallIds([]); clearOthers("extrudedSolid");
setSelectedDrawingId(null);
setSelectedCeilingIds([]);
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedColumnIds([]);
} else if (sel.ceilingId) { } else if (sel.ceilingId) {
setSelectedCeilingIds([sel.ceilingId]); setSelectedCeilingIds([sel.ceilingId]);
setSelectedWallIds([]); clearOthers("ceiling");
setSelectedDrawingId(null);
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} else if (sel.stairId) { } else if (sel.stairId) {
setSelectedStairIds([sel.stairId]); setSelectedStairIds([sel.stairId]);
setSelectedWallIds([]); clearOthers("stair");
setSelectedDrawingId(null);
setSelectedCeilingIds([]);
setSelectedOpeningIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} else if (sel.roomId) { } else if (sel.roomId) {
setSelectedRoomIds([sel.roomId]); setSelectedRoomIds([sel.roomId]);
setSelectedWallIds([]); clearOthers("room");
setSelectedDrawingId(null); } else if (sel.roofId) {
setSelectedCeilingIds([]); setSelectedRoofIds([sel.roofId]);
setSelectedOpeningIds([]); clearOthers("roof");
setSelectedStairIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} else { } else {
setSelectedWallIds([]); clearOthers("");
setSelectedDrawingId(null);
setSelectedCeilingIds([]);
setSelectedOpeningIds([]);
setSelectedStairIds([]);
setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]);
} }
}; };
// Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen // Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen
@@ -4314,14 +4300,15 @@ export default function App() {
const onPlanMarquee = (sel: MarqueeSelection) => { const onPlanMarquee = (sel: MarqueeSelection) => {
setSelectedWallIds(sel.wallIds); setSelectedWallIds(sel.wallIds);
setSelectedDrawingIds(sel.drawingIds ?? []); setSelectedDrawingIds(sel.drawingIds ?? []);
// Decken/Öffnungen/Treppen/Räume/Extrusionen sind (noch) nicht Teil des // Decken/Öffnungen/Treppen/Räume/Extrusionen/Dächer sind (noch) nicht Teil
// Marquee → beim Aufziehen abwählen. // des Marquee → beim Aufziehen abwählen.
setSelectedCeilingIds([]); setSelectedCeilingIds([]);
setSelectedOpeningIds([]); setSelectedOpeningIds([]);
setSelectedStairIds([]); setSelectedStairIds([]);
setSelectedRoomIds([]); setSelectedRoomIds([]);
setSelectedExtrudedSolidIds([]); setSelectedExtrudedSolidIds([]);
setSelectedColumnIds([]); setSelectedColumnIds([]);
setSelectedRoofIds([]);
}; };
// Rechts-Klick im Grundriss: Wand-Kontextmenü. Trifft der Klick eine Wand, die // 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 // 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.room": "Raum",
"objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Stütze", "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) ─────────────────────────────── // ── Stützen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.column.section": "Stütze", "objinfo.column.section": "Stütze",
"objinfo.column.profile": "Profil", "objinfo.column.profile": "Profil",
+19
View File
@@ -284,6 +284,25 @@ export const en: Record<TranslationKey, string> = {
"objinfo.kind.room": "Room", "objinfo.kind.room": "Room",
"objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Column", "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) ──────────────────────────────── // ── Column attributes (object info panel) ────────────────────────────────
"objinfo.column.section": "Column", "objinfo.column.section": "Column",
"objinfo.column.profile": "Profile", "objinfo.column.profile": "Profile",
+2
View File
@@ -36,6 +36,7 @@ import { ColorHexField } from "../ui/ColorHexField";
import { import {
WallSection, WallSection,
CeilingSection, CeilingSection,
RoofSection,
OpeningSection, OpeningSection,
StairSection, StairSection,
RoomSection, RoomSection,
@@ -314,6 +315,7 @@ export function AttributesPanel() {
Darstellung + die Host-Handler. */} Darstellung + die Host-Handler. */}
{sel.wall && <WallSection wall={sel.wall} host={host} />} {sel.wall && <WallSection wall={sel.wall} host={host} />}
{sel.ceiling && <CeilingSection ceiling={sel.ceiling} 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.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />} {sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} 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 { useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import { formatM } from "../model/types"; import { formatM } from "../model/types";
import type { import type {
RoofShape,
SiaCategory, SiaCategory,
SliceTermination, SliceTermination,
StairShape, StairShape,
@@ -32,6 +34,7 @@ import type {
ColumnInfo, ColumnInfo,
ExtrudedSolidInfo, ExtrudedSolidInfo,
OpeningInfo, OpeningInfo,
RoofInfo,
RoomInfo, RoomInfo,
StairInfo, StairInfo,
WallInfo, WallInfo,
@@ -431,6 +434,107 @@ export function ColumnSection({
// ── Treppen-Attribut-Abschnitt ─────────────────────────────────────────────── // ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete // Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only. // 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({ export function StairSection({
stair, stair,
host, host,
+5
View File
@@ -299,6 +299,11 @@ export interface PanelHostValue {
* (Discoverability: von der gewählten Öffnung in den Typeditor springen). * (Discoverability: von der gewählten Öffnung in den Typeditor springen).
*/ */
onEditOpeningType: (kind: "door" | "window") => void; 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) ─── // ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */ /** 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`. * traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`.
*/ */
columnId: string | 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 * 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 * 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; 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 // Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon // ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie). // 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 openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
? null ? null
: pickStair(e.clientX, e.clientY); : 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 = const roomId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
? null ? null
: pickRoom(e.clientX, e.clientY); : 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({ onSelect({
model: viewToModel(v.x, v.y), model: viewToModel(v.x, v.y),
hitIndex, hitIndex,
@@ -1552,6 +1577,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
roomId, roomId,
extrudedSolidId, extrudedSolidId,
columnId, columnId,
roofId,
shift: e.shiftKey, 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). * (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
*/ */
columnId?: string; columnId?: string;
/** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */
roofId?: string;
} }
| { | {
kind: "line"; kind: "line";
@@ -2466,6 +2468,23 @@ function addRoof(
const stroke = roof.color ?? category?.color ?? POCHE_STROKE; const stroke = roof.color ?? category?.color ?? POCHE_STROKE;
const geo = roofGeometry(roof, 0); 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; const n = geo.eaves.length;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
out.push({ out.push({
+13
View File
@@ -235,6 +235,12 @@ export interface ProjectSlice {
patch: Partial<import("../model/types").Opening>, patch: Partial<import("../model/types").Opening>,
) => void; ) => void;
/** Immutable Änderung eines Dachs (Form/Neigung/Überstand/Firstrichtung/Dicke). */
updateRoof: (
id: string,
patch: Partial<import("../model/types").Roof>,
) => void;
// ── Treppen-Editieren ────────────────────────────────────────────────────── // ── Treppen-Editieren ──────────────────────────────────────────────────────
/** Verschiebt eine Treppe (Antritt) um `delta`. */ /** Verschiebt eine Treppe (Antritt) um `delta`. */
moveStairBy: (stairId: string, delta: Vec2) => void; moveStairBy: (stairId: string, delta: Vec2) => void;
@@ -913,6 +919,13 @@ export function createProjectSlice(
openings: (p.openings ?? []).map((o) => (o.id === id ? { ...o, ...patch } : o)), 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 ────────────────────────────────────────────────── // ── Treppen-Editieren ──────────────────────────────────────────────────
// (coalesceKey: siehe Kommentar bei moveGripOf.) // (coalesceKey: siehe Kommentar bei moveGripOf.)
moveStairBy: (stairId, delta) => 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, LayerCategory,
Opening, Opening,
Project, Project,
Roof,
RoofShape,
Room, Room,
SiaCategory, SiaCategory,
SliceTermination, SliceTermination,
@@ -49,6 +51,7 @@ import {
wallVerticalExtent, wallVerticalExtent,
} from "../model/wall"; } from "../model/wall";
import { ceilingArea, outlineBBox } from "../geometry/ceiling"; import { ceilingArea, outlineBBox } from "../geometry/ceiling";
import { roofBBox, roofBaseElevation, roofGeometry } from "../geometry/roof";
import { openingGapQuad, openingVerticalExtent } from "../geometry/opening"; import { openingGapQuad, openingVerticalExtent } from "../geometry/opening";
import { stairGeometry, stairBBox } from "../geometry/stair"; import { stairGeometry, stairBBox } from "../geometry/stair";
@@ -70,6 +73,25 @@ export interface FloorChoice {
name: string; 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 * Eine wählbare Schichttrennlinie (Fuge zwischen zwei Schichten) als
* Referenzlinie einer mehrschichtigen Wand. `offset` ist der fertige * Referenzlinie einer mehrschichtigen Wand. `offset` ist der fertige
@@ -299,7 +321,8 @@ export interface Selection {
| "drawing2d" | "drawing2d"
| "door" | "door"
| "extrudedSolid" | "extrudedSolid"
| "column"; | "column"
| "roof";
id: string; id: string;
/** Grafik-Kategorie (Ebene) des Elements. */ /** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string; categoryCode: string;
@@ -357,6 +380,8 @@ export interface Selection {
wall?: WallInfo; wall?: WallInfo;
/** Decken-Attribute (nur bei `kind === "ceiling"`). */ /** Decken-Attribute (nur bei `kind === "ceiling"`). */
ceiling?: CeilingInfo; ceiling?: CeilingInfo;
/** Dach-Attribute (nur bei `kind === "roof"`). */
roof?: RoofInfo;
/** Öffnungs-Attribute (nur bei `kind === "opening"`). */ /** Öffnungs-Attribute (nur bei `kind === "opening"`). */
opening?: OpeningInfo; opening?: OpeningInfo;
/** Treppen-Attribute (nur bei `kind === "stair"`). */ /** 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). */ /** Selektion für eine Öffnung ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
function openingSelection(project: Project, o: Opening): Selection { function openingSelection(project: Project, o: Opening): Selection {
const cat = findCategory(project.layers, o.categoryCode); const cat = findCategory(project.layers, o.categoryCode);
@@ -847,6 +903,7 @@ export function deriveSelection(
selectedRoomId: string | null = null, selectedRoomId: string | null = null,
selectedExtrudedSolidId: string | null = null, selectedExtrudedSolidId: string | null = null,
selectedColumnId: string | null = null, selectedColumnId: string | null = null,
selectedRoofId: string | null = null,
): Selection | null { ): Selection | null {
if (selectedDrawingId) { if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === 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); const c = (project.ceilings ?? []).find((x) => x.id === selectedCeilingId);
return c ? ceilingSelection(project, c) : null; 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) { if (selectedStairId) {
const s = (project.stairs ?? []).find((x) => x.id === selectedStairId); const s = (project.stairs ?? []).find((x) => x.id === selectedStairId);
return s ? stairSelection(project, s) : null; return s ? stairSelection(project, s) : null;
+7 -1
View File
@@ -23,6 +23,8 @@ export interface SelectionSlice {
selectedExtrudedSolidIds: string[]; selectedExtrudedSolidIds: string[];
// Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen. // Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedColumnIds: string[]; selectedColumnIds: string[];
// Gewählte Dächer als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedRoofIds: string[];
setSelectedWallIds: (ids: string[]) => void; setSelectedWallIds: (ids: string[]) => void;
setSelectedDrawingIds: (ids: string[]) => void; setSelectedDrawingIds: (ids: string[]) => void;
@@ -32,7 +34,8 @@ export interface SelectionSlice {
setSelectedRoomIds: (ids: string[]) => void; setSelectedRoomIds: (ids: string[]) => void;
setSelectedExtrudedSolidIds: (ids: string[]) => void; setSelectedExtrudedSolidIds: (ids: string[]) => void;
setSelectedColumnIds: (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; clearSelection: () => void;
} }
@@ -49,6 +52,7 @@ export function createSelectionSlice(
selectedRoomIds: [], selectedRoomIds: [],
selectedExtrudedSolidIds: [], selectedExtrudedSolidIds: [],
selectedColumnIds: [], selectedColumnIds: [],
selectedRoofIds: [],
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }), setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
@@ -57,6 +61,7 @@ export function createSelectionSlice(
setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }), setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }),
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }), setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }), setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }),
clearSelection: () => clearSelection: () =>
set({ set({
selectedWallIds: [], selectedWallIds: [],
@@ -67,6 +72,7 @@ export function createSelectionSlice(
selectedRoomIds: [], selectedRoomIds: [],
selectedExtrudedSolidIds: [], selectedExtrudedSolidIds: [],
selectedColumnIds: [], selectedColumnIds: [],
selectedRoofIds: [],
}), }),
}; };
} }