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
+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: [],
}),
};
}