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:
+54
-67
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user