diff --git a/src/App.tsx b/src/App.tsx index 609cbb3..85bacf6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -582,6 +582,9 @@ export default function App() { const selectedRoofIds = useStore((s) => s.selectedRoofIds); const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds); const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null; + // Schnitt-/Ansichtslinien-Auswahl (Einzel-DrawingLevel-Id oder null). + const selectedSectionLineId = useStore((s) => s.selectedSectionLineId); + const setSelectedSectionLineId = useStore((s) => s.setSelectedSectionLineId); const updateRoof = useStore((s) => s.updateRoof); const moveRoofGrip = useStore((s) => s.moveRoofGrip); const moveRoofBy = useStore((s) => s.moveRoofBy); @@ -635,6 +638,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); setMenu(null); setEditor(null); }, [activeLevelId]); @@ -1853,6 +1857,13 @@ export default function App() { } if (e.key !== "Delete" && e.key !== "Backspace") return; if (activeTransform) return; // während einer Transformation nicht löschen + // Schnittlinie: Entf löscht NUR ihre Linie (linePoints), die Ebene bleibt. + if (selectedSectionLineId) { + patchLevel(selectedSectionLineId, { linePoints: undefined }); + setSelectedSectionLineId(null); + e.preventDefault(); + return; + } if ( selectedDrawingIds.length || selectedWallIds.length || @@ -1898,6 +1909,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); e.preventDefault(); } }; @@ -1914,6 +1926,7 @@ export default function App() { selectedExtrudedSolidIds, selectedColumnIds, selectedRoofIds, + selectedSectionLineId, project.walls, project.drawings2d, project.ceilings, @@ -2707,6 +2720,21 @@ export default function App() { ], ); + // Gewählte Schnitt-/Ansichtslinie (DrawingLevel) — separat von `selection`, da + // eine Schnittlinie KEIN Projekt-Bauteil ist. Speist die Schnittlinien-Sektion + // des Object-Info-Panels. + const sectionLine = useMemo( + () => + selectedSectionLineId + ? project.drawingLevels.find( + (z) => + z.id === selectedSectionLineId && + (z.kind === "section" || z.kind === "elevation"), + ) ?? null + : null, + [project.drawingLevels, selectedSectionLineId], + ); + // ── DXF/DWG-Import (Dialog) ─────────────────────────────────────────────── // Eine Datei (Button ODER Drop) öffnet den modalen Import-Dialog. DXF wird als // Text geparst, DWG als ArrayBuffer über LibreDWG-WASM (lazy, asynchron); beide @@ -3319,6 +3347,14 @@ export default function App() { onSetRoofPatch: (patch) => { if (selection?.kind === "roof") updateRoof(selection.id, patch); }, + // ── Schnitt-/Ansichtslinie (nur bei selektierter Linie) ──────────────── + sectionLine, + onSetSectionLinePatch: (patch) => { + if (selectedSectionLineId) patchLevel(selectedSectionLineId, patch); + }, + onDeleteSectionLine: () => { + if (selectedSectionLineId) patchLevel(selectedSectionLineId, { linePoints: undefined }); + }, // ── Treppen-Attribute (nur bei selektierter Treppe) ──────────────────── onSetStairShape: (shape) => { if (selection?.kind === "stair") updateStair(selection.id, { shape }); @@ -3475,6 +3511,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); switch (kind) { case "Wand": setSelectedWallIds([id]); @@ -3848,6 +3885,8 @@ export default function App() { detail, scaleDenominator, selection, + sectionLine, + selectedSectionLineId, selectedViewSnapshotId, ]); @@ -4334,6 +4373,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); setStampEditorRoomId(roomId); }; // Doppelklick auf ein Text-2D-Element: selektieren + Inhalts-Editor öffnen. @@ -4349,6 +4389,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); setEditTextId(drawingId); }; // Bearbeiteten Text-Inhalt übernehmen (immutabel; nur bei Text-Geometrie). @@ -4373,6 +4414,13 @@ export default function App() { // Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl // erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert. if (sel.shift) { + // Schnittlinie ist Einzelauswahl — Shift+Klick togglet sie an/aus. + if (sel.sectionLineId) { + setSelectedSectionLineId( + selectedSectionLineId === sel.sectionLineId ? null : sel.sectionLineId, + ); + return; + } if (sel.wallId) { const id = sel.wallId; setSelectedWallIds( @@ -4454,8 +4502,12 @@ export default function App() { if (keep !== "extrudedSolid") setSelectedExtrudedSolidIds([]); if (keep !== "column") setSelectedColumnIds([]); if (keep !== "roof") setSelectedRoofIds([]); + if (keep !== "sectionLine") setSelectedSectionLineId(null); }; - if (sel.openingId) { + if (sel.sectionLineId) { + setSelectedSectionLineId(sel.sectionLineId); + clearOthers("sectionLine"); + } else if (sel.openingId) { setSelectedOpeningIds([sel.openingId]); clearOthers("opening"); } else if (sel.wallId) { @@ -4500,6 +4552,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); }; // 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 @@ -4526,6 +4579,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); } }; // Links-Klick im 3D: per Raycast getroffene Treppe wählen (Einzelauswahl). @@ -4540,6 +4594,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); } }; // Links-Klick im 3D: per Raycast getroffene Decke wählen (Einzelauswahl). @@ -4554,6 +4609,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); } }; // Links-Klick im 3D: per Raycast getroffene Öffnung wählen (Einzelauswahl). @@ -4568,6 +4624,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); } }; // Links-KLICK im Nordstern-3D-Viewport (WASM, TS-Raycast): getroffenes Bauteil @@ -4587,6 +4644,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); return; } // Öffnung (Fenster/Tür) und Treppe sind jetzt auch im WASM-Viewport pickbar @@ -4602,6 +4660,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); return; } if (hit.kind === "stair") { @@ -4614,6 +4673,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); return; } if (hit.kind === "roof") { @@ -4646,6 +4706,7 @@ export default function App() { setSelectedExtrudedSolidIds([]); setSelectedColumnIds([]); setSelectedRoofIds([]); + setSelectedSectionLineId(null); }; // Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition. const onViewportContextMenu = (info: ViewportContextInfo) => { @@ -4941,6 +5002,7 @@ export default function App() { selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedSectionLineId={selectedSectionLineId} roomStamp={roomStamp} onStampMove={moveRoomStamp} onStampEdit={onOpenStampEditor} @@ -6074,6 +6136,7 @@ function Content({ selectedColumnIds, selectedRoomIds, selectedRoofIds, + selectedSectionLineId, roomStamp, onStampMove, onStampEdit, @@ -6152,6 +6215,7 @@ function Content({ selectedColumnIds: string[]; selectedRoomIds: string[]; selectedRoofIds: string[]; + selectedSectionLineId: string | null; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; @@ -6269,6 +6333,7 @@ function Content({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedSectionLineId={selectedSectionLineId} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} @@ -6324,6 +6389,7 @@ function Content({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedSectionLineId={selectedSectionLineId} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} @@ -6419,6 +6485,7 @@ function Content({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedSectionLineId={selectedSectionLineId} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} @@ -6464,6 +6531,7 @@ function LevelPlanView({ selectedColumnIds, selectedRoomIds, selectedRoofIds, + selectedSectionLineId, roomStamp, onStampMove, onStampEdit, @@ -6502,6 +6570,7 @@ function LevelPlanView({ selectedColumnIds: string[]; selectedRoomIds: string[]; selectedRoofIds: string[]; + selectedSectionLineId: string | null; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; @@ -6579,6 +6648,7 @@ function LevelPlanView({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} @@ -6632,6 +6702,7 @@ function SectionPlanView({ selectedColumnIds, selectedRoomIds, selectedRoofIds, + selectedSectionLineId, roomStamp, onStampMove, onStampEdit, @@ -6666,6 +6737,7 @@ function SectionPlanView({ selectedColumnIds: string[]; selectedRoomIds: string[]; selectedRoofIds: string[]; + selectedSectionLineId: string | null; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; @@ -6767,6 +6839,7 @@ function SectionPlanView({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} diff --git a/src/commands/cmds/sectionline.test.ts b/src/commands/cmds/sectionline.test.ts new file mode 100644 index 0000000..23b9c3b --- /dev/null +++ b/src/commands/cmds/sectionline.test.ts @@ -0,0 +1,100 @@ +// Schnittlinien-Befehl: der reine Datenpfad (commitLine/nextLevelName) — setzt +// die Grundriss-Schnittlinie auf die richtige Ziel-Ebene bzw. legt eine neue an. + +import { describe, it, expect } from "vitest"; +import { _test } from "./sectionline"; +import type { DrawingLevel, Project } from "../../model/types"; + +const { commitLine, nextLevelName } = _test; + +function projectWith(levels: DrawingLevel[]): Project { + return { + id: "p", + name: "T", + layers: [], + drawingLevels: levels, + walls: [], + drawings2d: [], + } as unknown as Project; +} + +const a = { x: 0, y: 0 }; +const b = { x: 5, y: 0 }; + +describe("sectionline — commitLine", () => { + it("belegt die EINE Platzhalter-Schnittebene ohne Linie", () => { + const p = projectWith([ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false }, + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + const out = commitLine(p, a, b, "section"); + const s = out.drawingLevels.find((z) => z.id === "s1")!; + expect(s.linePoints).toEqual([a, b]); + expect(s.directionSign).toBe(1); + // Keine neue Ebene angelegt. + expect(out.drawingLevels).toHaveLength(2); + }); + + it("legt eine NEUE Schnittebene an, wenn keine Platzhalter-Ebene existiert", () => { + const p = projectWith([ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false }, + { + id: "s1", + name: "Schnitt A", + kind: "section", + visible: true, + locked: false, + linePoints: [{ x: 1, y: 1 }, { x: 2, y: 2 }], + }, + ]); + const out = commitLine(p, a, b, "section"); + expect(out.drawingLevels).toHaveLength(3); + const created = out.drawingLevels[out.drawingLevels.length - 1]; + expect(created.kind).toBe("section"); + expect(created.linePoints).toEqual([a, b]); + // Zweite Ebene → Buchstabe B. + expect(created.name).toContain("B"); + }); + + it("legt bei ZWEI Platzhaltern (mehrdeutig) eine neue Ebene an", () => { + const p = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + { id: "s2", name: "Schnitt B", kind: "section", visible: true, locked: false }, + ]); + const out = commitLine(p, a, b, "section"); + expect(out.drawingLevels).toHaveLength(3); + // Die bestehenden Platzhalter bleiben unangetastet. + expect(out.drawingLevels[0].linePoints).toBeUndefined(); + expect(out.drawingLevels[1].linePoints).toBeUndefined(); + }); + + it("verwirft entartete (zu kurze) Linien", () => { + const p = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + const out = commitLine(p, a, { x: 0.02, y: 0 }, "section"); + expect(out).toBe(p); + }); + + it("trennt Schnitt- und Ansichts-Ebenen (kind)", () => { + const p = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + // Ansichtslinie darf die Schnitt-Platzhalter-Ebene NICHT belegen. + const out = commitLine(p, a, b, "elevation"); + expect(out.drawingLevels).toHaveLength(2); + expect(out.drawingLevels[0].linePoints).toBeUndefined(); + expect(out.drawingLevels[1].kind).toBe("elevation"); + }); +}); + +describe("sectionline — nextLevelName", () => { + it("zählt Buchstaben je Art hoch (A, B, C …)", () => { + const p0 = projectWith([]); + expect(nextLevelName(p0, "section")).toContain("A"); + const p1 = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + expect(nextLevelName(p1, "section")).toContain("B"); + }); +}); diff --git a/src/commands/cmds/sectionline.ts b/src/commands/cmds/sectionline.ts new file mode 100644 index 0000000..d085ea6 --- /dev/null +++ b/src/commands/cmds/sectionline.ts @@ -0,0 +1,213 @@ +// Schnittlinie — setzt die Grundriss-Schnitt-/Ansichtslinie einer Zeichnungsebene +// (DrawingLevel, kind "section"/"elevation") per zwei Klicks (Start → Ende). Die +// Linie steuert `sectionPlaneFromLevel` (plan/toSection.ts): senkrecht auf ihr +// steht die Blickrichtung (directionSign, Voreinstellung +1). +// +// Schritte: +// 1) „Schnitt-Startpunkt:" → Punkt +// 2) „Schnitt-Endpunkt:" → Live-Linie + Richtungspfeil; Klick committet. +// +// Ziel-Ebene (im Commit bestimmt): existiert GENAU EINE Ebene der gewählten Art +// OHNE gesetzte Linie (frisch angelegter Platzhalter), wird deren Linie gesetzt; +// sonst wird eine NEUE Ebene angelegt (Name „Schnitt B/C…" bzw. „Ansicht …"). +// +// Die Art (Schnitt/Ansicht) ist als Inline-Option umschaltbar — dieselbe Mechanik +// für beide, nur `kind` unterscheidet sich (Alias `viewline` startet mit +// „elevation"). Bezeichner englisch, sichtbarer Text via t(). + +import { t } from "../../i18n"; +import type { DrawingLevel, DrawingLevelKind } from "../../model/types"; +import { leftNormal, normalize, sub } from "../../model/geometry"; +import type { + Command, + CommandResult, + CommandState, + CmdOption, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +/** Nur Schnitt/Ansicht tragen eine Linie. */ +type LineKind = "section" | "elevation"; + +/** Wählbare Art (Inline-Optionen der Befehlszeile). */ +const KIND_OPTIONS: CmdOption[] = [ + { id: "section", labelKey: "cmd.sectionline.kind.section" }, + { id: "elevation", labelKey: "cmd.sectionline.kind.elevation" }, +]; +const KIND_IDS = new Set(KIND_OPTIONS.map((o) => o.id)); + +/** + * Richtungspfeil-Vorschau: kurze Linie vom Mittelpunkt der Schnittlinie entlang + * der Blicknormalen (leftNormal der Linie, per directionSign gedreht) plus zwei + * Flügel-Segmente — zeigt, wohin der Schnitt „blickt". + */ +function directionArrow(a: Vec2, b: Vec2, sign: 1 | -1): DraftShape[] { + const dir = sub(b, a); + const len = Math.hypot(dir.x, dir.y); + if (len < 1e-6) return []; + const u = normalize(dir); + const n = leftNormal(u); // (-uy, ux) + const nx = n.x * sign; + const ny = n.y * sign; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + // Pfeillänge ~ min(1 m, Viertel der Linienlänge), damit die Vorschau lesbar bleibt. + const arrowLen = Math.min(1, len / 4); + const tip = { x: mid.x + nx * arrowLen, y: mid.y + ny * arrowLen }; + const wing = arrowLen * 0.35; + // Flügel: vom Spitzenpunkt zurück, je halb entlang −u und +u geneigt. + const back = { x: tip.x - nx * wing * 2, y: tip.y - ny * wing * 2 }; + const w1 = { x: back.x + u.x * wing, y: back.y + u.y * wing }; + const w2 = { x: back.x - u.x * wing, y: back.y - u.y * wing }; + return [ + { kind: "line", a: mid, b: tip }, + { kind: "line", a: tip, b: w1 }, + { kind: "line", a: tip, b: w2 }, + ]; +} + +/** Vorschau: Schnittlinie + Richtungspfeil + Länge im HUD. */ +function lineDraft(a: Vec2, cursor: Vec2 | null, sign: 1 | -1): ToolDraft { + if (!cursor) return { preview: [{ kind: "poly", pts: [a] }], vertices: [a] }; + const preview: DraftShape[] = [ + { kind: "line", a, b: cursor }, + ...directionArrow(a, cursor, sign), + ]; + const draft: ToolDraft = { preview, vertices: [a] }; + draft.hud = { + at: cursor, + text: `${Math.hypot(cursor.x - a.x, cursor.y - a.y).toFixed(2)} m`, + }; + return draft; +} + +/** Nächster freier Ebenen-Name der Art (A, B, C … über die Anzahl bestehender). */ +function nextLevelName(p: Project, kind: LineKind): string { + const count = p.drawingLevels.filter((z) => z.kind === kind).length; + // Buchstaben-Suffix (A, B, …, Z, dann AA…) — klassische Schnitt-Benennung. + let n = count; + let letters = ""; + do { + letters = String.fromCharCode(65 + (n % 26)) + letters; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + const key = kind === "section" ? "cmd.sectionline.newSection" : "cmd.sectionline.newElevation"; + return t(key, { letter: letters }); +} + +/** + * Setzt die Schnittlinie [a, b] auf die Ziel-Ebene (immutabel). Existiert GENAU + * EINE Ebene der Art ohne Linie, wird sie belegt; sonst wird eine neue angelegt. + * Entartete (zu kurze) Linien werden verworfen. + */ +function commitLine(p: Project, a: Vec2, b: Vec2, kind: LineKind): Project { + if (Math.hypot(b.x - a.x, b.y - a.y) < 0.1) return p; + const line: [Vec2, Vec2] = [a, b]; + const orphans = p.drawingLevels.filter((z) => z.kind === kind && !z.linePoints); + if (orphans.length === 1) { + const targetId = orphans[0].id; + return { + ...p, + drawingLevels: p.drawingLevels.map((z) => + z.id === targetId ? { ...z, linePoints: line, directionSign: z.directionSign ?? 1 } : z, + ), + }; + } + const level: DrawingLevel = { + id: `${kind}-${Date.now()}`, + name: nextLevelName(p, kind), + kind: kind as DrawingLevelKind, + visible: true, + locked: false, + linePoints: line, + directionSign: 1, + }; + return { ...p, drawingLevels: [...p.drawingLevels, level] }; +} + +interface LineIdle extends CommandState { + phase: "start"; + kind: LineKind; +} +interface LineEnd extends CommandState { + phase: "end"; + a: Vec2; + cursor: Vec2 | null; + kind: LineKind; +} +type LineState = LineIdle | LineEnd; + +const DIRECTION_SIGN: 1 = 1; + +function makeCommand(name: string, startKind: LineKind): Command { + const idle = (kind: LineKind): [CommandState, CommandResult] => [ + { phase: "start", kind, lastPoint: null } as LineIdle, + { draft: null, done: true }, + ]; + return { + name, + labelKey: "cmd.sectionline.label", + // Nicht floorOnly: eine Schnittlinie zieht man IM Grundriss, wo Bauteile + // liegen — die aktive Ebene ist dann ein Geschoss. Aber der Befehl soll auch + // auf freien Zeichnungsebenen nutzbar bleiben; die Prüfung übernimmt der + // Grundriss-Kontext (keine harte Sperre nötig). + floorOnly: true, + prompt: (s) => ((s as LineState).phase === "end" ? "cmd.sectionline.end" : "cmd.sectionline.start"), + accepts: () => ["point", "option"], + options: () => KIND_OPTIONS, + init: (): LineIdle => ({ phase: "start", kind: startKind, lastPoint: null }), + + onInput: (state, input): [CommandState, CommandResult] => { + const s = state as LineState; + if (input.kind === "option" && KIND_IDS.has(input.id)) { + const ns = { ...s, kind: input.id as LineKind } as LineState; + const draft = + ns.phase === "end" ? lineDraft(ns.a, ns.cursor, DIRECTION_SIGN) : null; + return [ns, { draft }]; + } + if (input.kind !== "point") { + return [ + s, + { draft: s.phase === "end" ? lineDraft(s.a, s.cursor, DIRECTION_SIGN) : null }, + ]; + } + if (s.phase !== "end") { + const ns: LineEnd = { + phase: "end", + a: input.point, + cursor: input.point, + kind: s.kind, + lastPoint: input.point, + }; + return [ns, { draft: lineDraft(input.point, input.point, DIRECTION_SIGN) }]; + } + const a = s.a; + const b = input.point; + const kind = s.kind; + return [ + { phase: "start", kind, lastPoint: null } as LineIdle, + { draft: null, done: true, commit: (p) => commitLine(p, a, b, kind) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as LineState; + if (s.phase !== "end") return [s, { draft: null }]; + const ns: LineEnd = { ...s, cursor: point }; + return [ns, { draft: lineDraft(s.a, point, DIRECTION_SIGN) }]; + }, + + onConfirm: (state): [CommandState, CommandResult] => idle((state as LineState).kind), + onCancel: (state): [CommandState, CommandResult] => idle((state as LineState).kind), + }; +} + +/** Schnittlinie (startet als „section"). */ +export const sectionLineCommand: Command = makeCommand("sectionline", "section"); +/** Ansichtslinie (startet als „elevation"; identische Mechanik). */ +export const viewLineCommand: Command = makeCommand("viewline", "elevation"); + +// Für Tests: die reinen Datenpfad-Helfer. +export const _test = { commitLine, nextLevelName, directionArrow }; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 581dd03..8ffcf80 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -29,6 +29,7 @@ import { trimCommand } from "./cmds/trim"; import { importCommand } from "./cmds/import"; import { terrainCommand } from "./cmds/terrain"; import { measureCommand } from "./cmds/measure"; +import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline"; /** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */ export const COMMANDS: Record = { @@ -59,6 +60,8 @@ export const COMMANDS: Record = { import: importCommand, terrain: terrainCommand, measure: measureCommand, + sectionline: sectionLineCommand, + viewline: viewLineCommand, }; /** @@ -105,6 +108,8 @@ export const ALIASES: Record = { tr: "trim", imp: "import", ter: "terrain", + schnittlinie: "sectionline", + ansichtslinie: "viewline", }; /** Liefert den Befehl zu einem exakten Namen (oder undefined). */ diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 5b69d63..5b7dc40 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -145,6 +145,7 @@ export const de = { "ribbon.group.draw": "Zeichnen", "ribbon.group.modify": "Ändern", "ribbon.group.components": "Bauteile", + "ribbon.group.sections": "Schnitte", "ribbon.group.custom": "Eigene Leiste", "ribbon.custom.add": "+ Hinzufügen", "ribbon.custom.empty": "Elemente über + Hinzufügen wählen", @@ -504,6 +505,18 @@ export const de = { "objinfo.room.refFloor": "Referenzgeschoss", "objinfo.room.editStamp": "Stempel bearbeiten …", // ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ───────── + "objinfo.sectionLine.section": "Schnittlinie", + "objinfo.sectionLine.kind.section": "Schnitt", + "objinfo.sectionLine.kind.elevation": "Ansicht", + "objinfo.sectionLine.name": "Name", + "objinfo.sectionLine.flip": "Blickrichtung umkehren", + "objinfo.sectionLine.startX": "Start X", + "objinfo.sectionLine.startY": "Start Y", + "objinfo.sectionLine.endX": "Ende X", + "objinfo.sectionLine.endY": "Ende Y", + "objinfo.sectionLine.depth": "Schnitt-Tiefe (m)", + "objinfo.sectionLine.depthHint": "leer = unbegrenzt", + "objinfo.sectionLine.delete": "Schnittlinie löschen", "objinfo.extrudedSolid.section": "Extrusion", "objinfo.extrudedSolid.height": "Höhe", "objinfo.extrudedSolid.taper": "Verjüngung", @@ -978,6 +991,13 @@ export const de = { "cmd.roof.shape.mansarde": "Mansarddach", "cmd.roof.shape.zelt": "Zeltdach", "cmd.roof.shape.flach": "Flachdach", + "cmd.sectionline.label": "Schnittlinie", + "cmd.sectionline.start": "Schnitt-Startpunkt:", + "cmd.sectionline.end": "Schnitt-Endpunkt:", + "cmd.sectionline.kind.section": "Schnitt", + "cmd.sectionline.kind.elevation": "Ansicht", + "cmd.sectionline.newSection": "Schnitt {letter}", + "cmd.sectionline.newElevation": "Ansicht {letter}", "cmd.opening.label": "Öffnung", "cmd.opening.windowLabel": "Fenster", "cmd.opening.doorLabel": "Tür", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e5fe664..7617b60 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -144,6 +144,7 @@ export const en: Record = { "ribbon.group.draw": "Draw", "ribbon.group.modify": "Modify", "ribbon.group.components": "Components", + "ribbon.group.sections": "Sections", "ribbon.group.custom": "Custom bar", "ribbon.custom.add": "+ Add", "ribbon.custom.empty": "Pick elements via “+ Add”", @@ -501,6 +502,18 @@ export const en: Record = { "objinfo.room.refFloor": "Reference floor", "objinfo.room.editStamp": "Edit stamp …", // ── Extruded solid attributes (truck integration, object info panel) ──── + "objinfo.sectionLine.section": "Section line", + "objinfo.sectionLine.kind.section": "Section", + "objinfo.sectionLine.kind.elevation": "Elevation", + "objinfo.sectionLine.name": "Name", + "objinfo.sectionLine.flip": "Flip view direction", + "objinfo.sectionLine.startX": "Start X", + "objinfo.sectionLine.startY": "Start Y", + "objinfo.sectionLine.endX": "End X", + "objinfo.sectionLine.endY": "End Y", + "objinfo.sectionLine.depth": "Section depth (m)", + "objinfo.sectionLine.depthHint": "empty = unlimited", + "objinfo.sectionLine.delete": "Delete section line", "objinfo.extrudedSolid.section": "Extrusion", "objinfo.extrudedSolid.height": "Height", "objinfo.extrudedSolid.taper": "Taper", @@ -968,6 +981,13 @@ export const en: Record = { "cmd.roof.shape.mansarde": "Mansard roof", "cmd.roof.shape.zelt": "Pyramid roof", "cmd.roof.shape.flach": "Flat roof", + "cmd.sectionline.label": "Section line", + "cmd.sectionline.start": "Section start point:", + "cmd.sectionline.end": "Section end point:", + "cmd.sectionline.kind.section": "Section", + "cmd.sectionline.kind.elevation": "Elevation", + "cmd.sectionline.newSection": "Section {letter}", + "cmd.sectionline.newElevation": "Elevation {letter}", "cmd.opening.label": "Opening", "cmd.opening.windowLabel": "Window", "cmd.opening.doorLabel": "Door", diff --git a/src/model/types.ts b/src/model/types.ts index 8688be7..8d8173b 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -770,6 +770,13 @@ export interface DrawingLevel { * die dahinterliegende Fassade. */ shadows?: boolean; + /** + * Schnitt-Tiefe in Metern (nur Schnitt/Ansicht): Bauteile weiter als `depth` + * HINTER der Schnittebene (in Blickrichtung) werden ausgeblendet. Fehlt der + * Wert, ist die Tiefe unbegrenzt (das gesamte Modell wird projiziert — heutiges + * Verhalten). + */ + depth?: number; } /** diff --git a/src/panels/AttributesPanel.tsx b/src/panels/AttributesPanel.tsx index be63416..8215c19 100644 --- a/src/panels/AttributesPanel.tsx +++ b/src/panels/AttributesPanel.tsx @@ -42,6 +42,7 @@ import { RoomSection, ExtrudedSolidSection, ColumnSection, + SectionLineSection, } from "./ObjectInfoPanel"; /** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld: @@ -92,10 +93,15 @@ export function AttributesPanel() { // Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein // kompakter Hinweis. if (sel === null) { + // Schnitt-/Ansichtslinie ist kein Projekt-Bauteil (kein `selection`) — sie + // kommt über host.sectionLine und wird hier im „Leerzustand" bearbeitet. return (
{typeSection} - {!typeSection &&
{t("attr.empty")}
} + {host.sectionLine && } + {!typeSection && !host.sectionLine && ( +
{t("attr.empty")}
+ )}
); } diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index 5ffacd0..0f6dd99 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -19,10 +19,12 @@ import { t } from "../i18n"; import type { TranslationKey } from "../i18n"; import { flattenCategories, formatM } from "../model/types"; import type { + DrawingLevel, RoofShape, SiaCategory, SliceTermination, StairShape, + Vec2, VerticalAnchor, WallReferenceLine, } from "../model/types"; @@ -314,6 +316,118 @@ export function RoomSection({ ); } +// ── Schnitt-/Ansichtslinien-Abschnitt (Object-Info; nur die gewählte Linie) ── +// Name (editierbar), Blickrichtung umkehren (directionSign-Flip), Endpunkt- +// Koordinaten (editierbar) + Schnitt-Tiefe; Löschen setzt linePoints zurück +// (die Ebene bleibt bestehen). Eine Schnittlinie ist KEIN Projekt-Bauteil, +// darum kommt sie über `host.sectionLine` (nicht über `selection`). +export function SectionLineSection({ + level, + host, +}: { + level: DrawingLevel; + host: ReturnType; +}) { + const line = level.linePoints; + const kindLabel = + level.kind === "elevation" + ? t("objinfo.sectionLine.kind.elevation") + : t("objinfo.sectionLine.kind.section"); + const patchPoint = (which: 0 | 1, axis: "x" | "y", value: number) => { + if (!line) return; + const next: [Vec2, Vec2] = [{ ...line[0] }, { ...line[1] }]; + next[which] = { ...next[which], [axis]: value }; + host.onSetSectionLinePatch({ linePoints: next }); + }; + const coordField = (labelKey: TranslationKey, value: number, on: (v: number) => void) => ( +
+ {t(labelKey)} + { + const v = Number(e.target.value); + if (Number.isFinite(v)) on(v); + }} + title={t(labelKey)} + /> +
+ ); + return ( + <> +
+
+ {t("objinfo.sectionLine.section")} · {kindLabel} +
+ + {/* Name (editierbar). */} +
+ {t("objinfo.sectionLine.name")} + host.onSetSectionLinePatch({ name: e.target.value })} + title={t("objinfo.sectionLine.name")} + /> +
+ + {/* Blickrichtung umkehren (directionSign-Flip). */} + + + {/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */} + {line && ( + <> + {coordField("objinfo.sectionLine.startX", line[0].x, (v) => patchPoint(0, "x", v))} + {coordField("objinfo.sectionLine.startY", line[0].y, (v) => patchPoint(0, "y", v))} + {coordField("objinfo.sectionLine.endX", line[1].x, (v) => patchPoint(1, "x", v))} + {coordField("objinfo.sectionLine.endY", line[1].y, (v) => patchPoint(1, "y", v))} + + )} + + {/* Schnitt-Tiefe (leer = unbegrenzt). */} +
+ {t("objinfo.sectionLine.depth")} + { + const raw = e.target.value.trim(); + if (raw === "") { + host.onSetSectionLinePatch({ depth: undefined }); + return; + } + const v = Number(raw); + if (Number.isFinite(v) && v > 0) host.onSetSectionLinePatch({ depth: v }); + }} + title={t("objinfo.sectionLine.depth")} + /> +
+ + {/* Löschen: linePoints zurücksetzen (die Ebene bleibt bestehen). */} + + + ); +} + // ── Extrusions-Attribut-Abschnitt (truck-Integration) ─────────────────────── // Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only. export function ExtrudedSolidSection({ diff --git a/src/panels/host.ts b/src/panels/host.ts index e5b046b..6f6f539 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -330,6 +330,25 @@ export interface PanelHostValue { */ onSetRoofPatch: (patch: Partial) => void; + // ── Schnitt-/Ansichtslinie (Object-Info-Panel; nur die selektierte Linie) ── + /** + * Die aktuell gewählte Schnitt-/Ansichtsebene (DrawingLevel, kind + * "section"/"elevation"), oder `null`. Das Object-Info-Panel zeigt ihre + * Attribute (Name, Blickrichtung, Endpunkte, Tiefe), auch wenn `selection` + * null ist (eine Schnittlinie ist KEIN Projekt-Bauteil). + */ + sectionLine: DrawingLevel | null; + /** + * Immutable Änderung einer Schnitt-/Ansichtslinie (Name/directionSign/ + * linePoints/depth) — wirkt NUR auf die selektierte Linie. + */ + onSetSectionLinePatch: (patch: Partial) => void; + /** + * Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die + * Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis). + */ + onDeleteSectionLine: () => void; + // ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ─── /** Setzt die Grundform (gerade/L/Wendel). */ onSetStairShape: (shape: StairShape) => void; diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index aca475f..6e7c621 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -197,6 +197,12 @@ export interface PlanSelection { * Element höherer Priorität (alle anderen gehen vor); sonst `null`. */ roofId: string | null; + /** + * ID der getroffenen Schnitt-/Ansichtslinie (DrawingLevel), wenn der Klick ihr + * dünnes Pick-Band traf; sonst `null`. Höchste Priorität (das schmale Band liegt + * über allem — ein Klick genau darauf meint die Linie). + */ + sectionLineId: 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 @@ -287,6 +293,11 @@ export interface PlanViewProps { selectedRoomIds?: string[]; /** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */ selectedRoofIds?: string[]; + /** + * Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die + * Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur. + */ + selectedSectionLineIds?: string[]; /** * Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert * von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit @@ -416,6 +427,7 @@ export const PlanView = forwardRef( selectedStairIds, selectedRoomIds, selectedRoofIds, + selectedSectionLineIds, selectedExtrudedSolidIds, selectedColumnIds, roomStamp, @@ -1007,6 +1019,20 @@ export const PlanView = forwardRef( return null; }; + // Getroffene Schnitt-/Ansichtslinie: ihr schmales, unsichtbares Pick-Band + // (Polygon mit sectionLineId) per Punkt-in-Polygon. + const pickSectionLine = (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.sectionLineId && pointInPolygon(m, p.pts)) { + return p.sectionLineId; + } + } + 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). @@ -1518,6 +1544,28 @@ export const PlanView = forwardRef( const hit = hitIndex !== null ? plan.primitives[hitIndex] : undefined; // Öffnung (Fenster/Tür) zuerst: ihr Symbol sitzt in der ausgesparten Lücke // bzw. schlägt über die Wand — ein Klick nahe am Symbol wählt die Öffnung. + // Schnitt-/Ansichtslinie zuerst: ihr schmales Band liegt über allem, ein + // Klick genau darauf meint die Linie (nicht die darunterliegende Wand) — + // dann sofort melden (kein anderes Element konkurriert). + const sectionLineId = pickSectionLine(e.clientX, e.clientY); + if (sectionLineId) { + onSelect({ + model: viewToModel(v.x, v.y), + hitIndex, + wallId: null, + drawingId: null, + ceilingId: null, + openingId: null, + stairId: null, + roomId: null, + extrudedSolidId: null, + columnId: null, + roofId: null, + sectionLineId, + shift: e.shiftKey, + }); + return; + } const openingId = pickOpening(e.clientX, e.clientY); const wallId = openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null; @@ -1581,6 +1629,7 @@ export const PlanView = forwardRef( extrudedSolidId, columnId, roofId, + sectionLineId: null, shift: e.shiftKey, }); } @@ -1780,6 +1829,20 @@ export const PlanView = forwardRef( ); }, [plan, selectedRoofIds]); + // Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare) + // Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen. + const highlightSectionLinePolys = useMemo(() => { + const ids = + selectedSectionLineIds && selectedSectionLineIds.length + ? new Set(selectedSectionLineIds) + : null; + if (!ids) return []; + return plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.sectionLineId != null && ids.has(p.sectionLineId), + ); + }, [plan, selectedSectionLineIds]); + // Hervorhebung der selektierten extrudierten Körper (truck-Integration): der // Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen // — analog zur Raum-Auswahl. @@ -2024,6 +2087,18 @@ export const PlanView = forwardRef( pointerEvents="none" /> ))} + {/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */} + {highlightSectionLinePolys.map((poly, i) => ( + `${s.x},${s.y}`) + .join(" ")} + pointerEvents="none" + /> + ))} {/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */} {highlightExtrudedSolidPolys.map((poly, i) => ( { + it("zeichnet Linie + Pick-Band für eine Schnittebene mit linePoints", () => { + const p = baseProject([ + floor, + { + id: "s-a", + name: "Schnitt A", + kind: "section", + visible: true, + locked: false, + linePoints: [{ x: 0, y: 2 }, { x: 6, y: 2 }], + directionSign: 1, + }, + ]); + const prims = generatePlan(p, "eg", visible).primitives; + // Strichpunkt-Hauptlinie vorhanden. + const lines = prims.filter((pr) => pr.kind === "line" && pr.cls === "section-line"); + expect(lines.length).toBeGreaterThan(0); + // Pick-Band trägt die Level-Id. + const band = prims.find((pr) => pr.kind === "polygon" && pr.sectionLineId === "s-a"); + expect(band).toBeTruthy(); + // Label-Text (Kurz-Tag „A") an den Enden. + const labels = prims.filter((pr) => pr.kind === "drawingText" && pr.text === "A"); + expect(labels.length).toBe(2); + }); + + it("zeichnet NICHTS für eine Schnittebene ohne linePoints", () => { + const p = baseProject([ + floor, + { id: "s-b", name: "Schnitt B", kind: "section", visible: true, locked: false }, + ]); + const prims = generatePlan(p, "eg", visible).primitives; + expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId != null)).toBe(false); + expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false); + }); + + it("behandelt Ansichtsebenen (elevation) gleich", () => { + const p = baseProject([ + floor, + { + id: "e-a", + name: "Ansicht Süd", + kind: "elevation", + visible: true, + locked: false, + linePoints: [{ x: 0, y: 0 }, { x: 4, y: 0 }], + directionSign: -1, + }, + ]); + const prims = generatePlan(p, "eg", visible).primitives; + expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId === "e-a")).toBe(true); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 92d3ad3..0839430 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -265,6 +265,12 @@ export type Primitive = columnId?: string; /** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */ roofId?: string; + /** + * ID der Schnitt-/Ansichtslinie (DrawingLevel), falls dieses (unsichtbare) + * Pick-Band das Schnittführungs-Symbol umhüllt (für die Links-Klick-Auswahl + * der Schnittlinie im Grundriss). + */ + sectionLineId?: string; } | { kind: "line"; @@ -892,6 +898,10 @@ export function generatePlan( // die Reihenfolge unkritisch; wir hängen sie als Referenz an. addContextContours(primitives, project); + // Schnittführungs-Symbole (klassische Schnittlinien) aller Schnitt-/Ansichts- + // ebenen mit gesetzter Linie — projektglobale Referenzgeometrie über dem Plan. + addSectionLines(primitives, project); + // Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen. // Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss, // Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan. @@ -985,6 +995,12 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan { const monoFill = solid ? HATCH_INK : HATCH_PAPER; const monoHatch = solid ? hatch : hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch; + // Auswahl im Schnitt: das QUELL-Element (via cp.sourceId + component.kind) als + // wallId/ceilingId/roofId ans Polygon hängen — so wählt ein Klick auf das + // geschnittene Bauteil im Schnitt exakt jene Wand/Decke/Dach im Modell, und + // die bestehende PlanView-Pick-/Highlight-Logik greift ohne Sonderfall. + const kind = cp.component.kind; + const sourceId = cp.sourceId; primitives.push({ kind: "polygon", pts, @@ -992,6 +1008,9 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan { stroke: SECTION_INK, strokeWidthMm: SECTION_CUT_OUTLINE_MM, hatch: mono ? monoHatch : hatch, + wallId: kind === "wall" ? sourceId : undefined, + ceilingId: kind === "slab" ? sourceId : undefined, + roofId: kind === "roof" ? sourceId : undefined, }); } @@ -2641,6 +2660,139 @@ function addRoof( } } +// ── Schnittführungs-Symbol (klassische Schnittlinie im Grundriss) ─────────── +/** Tinte der Schnittführungs-Linie/Marken (kräftiges Grau). */ +const SECTION_LINE_INK = "#1f242c"; +/** Haarlinie der Strichpunkt-Schnittlinie (mm Papier). */ +const SECTION_LINE_MM = 0.18; +/** Kräftige Endmarken (Querstrich + Pfeil) (mm Papier). */ +const SECTION_LINE_MARK_MM = 0.5; +/** Strichpunkt-Muster der Schnittlinie (lange Striche + Punkt, mm Papier). */ +const SECTION_LINE_DASH: number[] = [6, 1.5, 0.4, 1.5]; +/** Länge der Endmarken (Querstrich/Pfeil) in Modell-Metern. */ +const SECTION_MARK_LEN = 0.6; +/** Halbe Breite des unsichtbaren Pick-Bands über der Linie (Modell-Meter). */ +const SECTION_PICK_HALF = 0.3; +/** Schrifthöhe des Schnitt-Labels (Modell-Meter). */ +const SECTION_LABEL_H = 0.5; + +/** + * Kurz-Tag eines Schnitt-Labels: der letzte „Wort"-Teil des Ebenennamens (z. B. + * „Schnitt A" → „A"); enthält der Name kein Leerzeichen, der ganze Name. Klassisch + * wird der Schnitt an beiden Enden mit diesem Tag beschriftet (A–A). + */ +function sectionLabelTag(name: string): string { + const parts = name.trim().split(/\s+/); + return parts.length > 1 ? parts[parts.length - 1] : name.trim(); +} + +/** + * Zeichnet für jede Schnitt-/Ansichtsebene MIT gesetzter `linePoints` das + * klassische Schnittführungs-Symbol in den Grundriss: eine Strichpunkt-Haarlinie + * über die volle Länge, an beiden Enden einen kräftigen Querstrich + Richtungspfeil + * auf der Blickseite (`directionSign`) und das Schnitt-Label (Kurz-Tag) daneben. + * Zusätzlich ein unsichtbares Pick-Band (Polygon mit `sectionLineId`), damit die + * Linie im Grundriss anklickbar ist (analog dem Dach-Traufe-Pick-Polygon). + */ +function addSectionLines(out: Primitive[], project: Project): void { + for (const level of project.drawingLevels) { + if (level.kind !== "section" && level.kind !== "elevation") continue; + const line = level.linePoints; + if (!line) continue; + const [p0, p1] = line; + const axis = sub(p1, p0); + const len = Math.hypot(axis.x, axis.y); + if (len < 1e-6) continue; + const u = normalize(axis); + const n = leftNormal(u); // (-uy, ux) + const sign = level.directionSign ?? 1; + const bn = { x: n.x * sign, y: n.y * sign }; // Blicknormale + + // Hauptlinie (Strichpunkt-Haarlinie). + out.push({ + kind: "line", + a: p0, + b: p1, + cls: "section-line", + weightMm: SECTION_LINE_MM, + dash: SECTION_LINE_DASH, + color: SECTION_LINE_INK, + }); + + // Endmarken (Querstrich + Richtungspfeil + Label) an beiden Enden. + const half = SECTION_MARK_LEN / 2; + for (const end of [p0, p1]) { + // Querstrich senkrecht zur Linie, mittig am Endpunkt. + const t0 = { x: end.x - n.x * half, y: end.y - n.y * half }; + const t1 = { x: end.x + n.x * half, y: end.y + n.y * half }; + out.push({ + kind: "line", + a: t0, + b: t1, + cls: "section-line", + weightMm: SECTION_LINE_MARK_MM, + color: SECTION_LINE_INK, + }); + // Richtungspfeil: vom Querstrich-Ende auf der Blickseite entlang bn. + const base = { x: end.x + bn.x * half, y: end.y + bn.y * half }; + const tip = { x: base.x + bn.x * SECTION_MARK_LEN, y: base.y + bn.y * SECTION_MARK_LEN }; + const wingBack = { + x: tip.x - bn.x * SECTION_MARK_LEN * 0.4, + y: tip.y - bn.y * SECTION_MARK_LEN * 0.4, + }; + const w = SECTION_MARK_LEN * 0.25; + out.push({ kind: "line", a: base, b: tip, cls: "section-line", weightMm: SECTION_LINE_MARK_MM, color: SECTION_LINE_INK }); + out.push({ + kind: "line", + a: tip, + b: { x: wingBack.x + u.x * w, y: wingBack.y + u.y * w }, + cls: "section-line", + weightMm: SECTION_LINE_MARK_MM, + color: SECTION_LINE_INK, + }); + out.push({ + kind: "line", + a: tip, + b: { x: wingBack.x - u.x * w, y: wingBack.y - u.y * w }, + cls: "section-line", + weightMm: SECTION_LINE_MARK_MM, + color: SECTION_LINE_INK, + }); + // Label (Kurz-Tag) etwas hinter dem Pfeil, horizontal. + const labelAt = { + x: tip.x + bn.x * SECTION_MARK_LEN * 0.4 - SECTION_LABEL_H * 0.3, + y: tip.y + bn.y * SECTION_MARK_LEN * 0.4 + SECTION_LABEL_H * 0.5, + }; + out.push({ + kind: "drawingText", + at: labelAt, + text: sectionLabelTag(level.name), + heightM: SECTION_LABEL_H, + angle: 0, + color: SECTION_LINE_INK, + }); + } + + // Unsichtbares Pick-Band (Rechteck entlang der Linie) — klickbar per + // Punkt-in-Polygon in PlanView, trägt die DrawingLevel-Id. + const off = { x: n.x * SECTION_PICK_HALF, y: n.y * SECTION_PICK_HALF }; + out.push({ + kind: "polygon", + pts: [ + { x: p0.x + off.x, y: p0.y + off.y }, + { x: p1.x + off.x, y: p1.y + off.y }, + { x: p1.x - off.x, y: p1.y - off.y }, + { x: p0.x - off.x, y: p0.y - off.y }, + ], + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + sectionLineId: level.id, + }); + } +} + /** * Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente * Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren diff --git a/src/plan/generateSectionPlan.select.test.ts b/src/plan/generateSectionPlan.select.test.ts new file mode 100644 index 0000000..5077813 --- /dev/null +++ b/src/plan/generateSectionPlan.select.test.ts @@ -0,0 +1,58 @@ +// Auswahl im Schnitt-View: generateSectionPlan hängt jedem Cut-Polygon die +// Quell-Element-Id (cp.sourceId) je nach component.kind als wallId/ceilingId/ +// roofId ans Plan-Polygon — damit ein Klick im Schnitt das Bauteil im Modell +// wählt (die bestehende PlanView-Pick-Logik greift dann ohne Sonderfall). + +import { describe, it, expect } from "vitest"; +import { generateSectionPlan } from "./generatePlan"; +import type { SectionCutPolygon, SectionOutput } from "./toSection"; + +function cut(kind: "wall" | "slab" | "roof", sourceId: string): SectionCutPolygon { + return { + component: { kind, index: 0 }, + color: [0.5, 0.5, 0.5], + pts: [ + [0, 0], + [1, 0], + [1, 2], + [0, 2], + ], + sourceId, + }; +} + +const output: SectionOutput = { + cutPolygons: [cut("wall", "W1"), cut("slab", "C1"), cut("roof", "R1")], + visibleEdges: [], + hiddenEdges: [], +}; + +describe("generateSectionPlan — Auswahl-Ids am Cut-Polygon", () => { + it("schreibt sourceId je component.kind als wallId/ceilingId/roofId", () => { + const polys = generateSectionPlan(output).primitives.filter( + (p): p is Extract => p.kind === "polygon", + ); + const wall = polys.find((p) => p.wallId === "W1"); + const ceiling = polys.find((p) => p.ceilingId === "C1"); + const roof = polys.find((p) => p.roofId === "R1"); + expect(wall).toBeTruthy(); + expect(ceiling).toBeTruthy(); + expect(roof).toBeTruthy(); + // Kanäle schließen sich je Polygon aus (eine Wand trägt keine ceilingId). + expect(wall?.ceilingId).toBeUndefined(); + expect(wall?.roofId).toBeUndefined(); + expect(ceiling?.wallId).toBeUndefined(); + }); + + it("lässt die Ids weg, wenn cp.sourceId fehlt (unauflösbares Bauteil)", () => { + const noId: SectionOutput = { + cutPolygons: [{ ...cut("wall", "W1"), sourceId: undefined }], + visibleEdges: [], + hiddenEdges: [], + }; + const poly = generateSectionPlan(noId).primitives.find( + (p): p is Extract => p.kind === "polygon", + ); + expect(poly?.wallId).toBeUndefined(); + }); +}); diff --git a/src/plan/toSection.depth.test.ts b/src/plan/toSection.depth.test.ts new file mode 100644 index 0000000..2aed8e0 --- /dev/null +++ b/src/plan/toSection.depth.test.ts @@ -0,0 +1,72 @@ +// Schnitt-Tiefe (DrawingLevel.depth): filterByDepth blendet Ansichtskanten aus, +// deren Quell-Wand weiter als `depth` hinter der Schnittebene (Blickrichtung) +// liegt. Owner-Distanz-Näherung (Wand-Mitte) — reiner TS-Datenpfad, ohne WASM. + +import { describe, it, expect } from "vitest"; +import { filterByDepth } from "./toSection"; +import type { SectionOutput, SectionPlaneSpec } from "./toSection"; +import type { Project, Wall } from "../model/types"; + +// Zwei Wände auf verschiedenen Tiefen: W_near bei y=1, W_far bei y=8. +function makeWall(id: string, y: number): Wall { + return { + id, + type: "wall", + floorId: "eg", + categoryCode: "20", + wallTypeId: "wt", + start: { x: 0, y }, + end: { x: 4, y }, + height: 2.6, + } as unknown as Wall; +} + +const project = { + id: "p", + name: "T", + walls: [makeWall("W_near", 1), makeWall("W_far", 8)], + ceilings: [], + roofs: [], + openings: [], + doors: [], + components: [], + wallTypes: [], + drawingLevels: [{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false }], +} as unknown as Project; + +// Schnittebene bei y=0, Blickrichtung +y (in die Szene). wallSegmentOwners +// nummeriert die Wände in project.walls-Reihenfolge: index 0 = W_near, 1 = W_far. +const plane: SectionPlaneSpec = { point: [0, 0, 0], normal: [0, 0, 1] }; + +function edge(index: number) { + return { component: { kind: "wall" as const, index }, a: [0, 0] as [number, number], b: [1, 1] as [number, number] }; +} + +describe("filterByDepth", () => { + it("behält nahe Kanten, verwirft Kanten hinter der Tiefengrenze", () => { + const out: SectionOutput = { + cutPolygons: [], + visibleEdges: [edge(0), edge(1)], + hiddenEdges: [edge(1)], + }; + filterByDepth(out, project, plane, 4); // Tiefe 4 m: W_near (1) bleibt, W_far (8) fällt weg. + expect(out.visibleEdges.map((e) => e.component.index)).toEqual([0]); + expect(out.hiddenEdges).toHaveLength(0); + }); + + it("behält alles, wenn die Tiefe alle Bauteile umfasst", () => { + const out: SectionOutput = { + cutPolygons: [], + visibleEdges: [edge(0), edge(1)], + hiddenEdges: [], + }; + filterByDepth(out, project, plane, 20); + expect(out.visibleEdges).toHaveLength(2); + }); + + it("depth <= 0 ist ein No-op", () => { + const out: SectionOutput = { cutPolygons: [], visibleEdges: [edge(0), edge(1)], hiddenEdges: [] }; + filterByDepth(out, project, plane, 0); + expect(out.visibleEdges).toHaveLength(2); + }); +}); diff --git a/src/plan/toSection.orientation.test.ts b/src/plan/toSection.orientation.test.ts index 9b74ed0..f5948d9 100644 --- a/src/plan/toSection.orientation.test.ts +++ b/src/plan/toSection.orientation.test.ts @@ -112,6 +112,13 @@ describe("splitWallLayers: Aussenputz auf der Aussenseite, Wände spiegelverkehr expect(bands[3].width).toBeCloseTo(RENDER_EXT_T, 6); }); + it("propagiert sourceId (Quell-Element) auf ALLE Schicht-Bänder", () => { + const cpWithId: SectionCutPolygon = { ...cp, sourceId: "W2" }; + const bands = splitWallLayers(cpWithId, sampleProject, aw, wall("W2"), uWorld); + expect(bands.length).toBeGreaterThan(1); + expect(bands.every((b) => b.sourceId === "W2")).toBe(true); + }); + it("W2 und W4 liegen spiegelverkehrt (nicht identisch)", () => { const b2 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld)); const b4 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld)); diff --git a/src/plan/toSection.ts b/src/plan/toSection.ts index 82fb48d..7699247 100644 --- a/src/plan/toSection.ts +++ b/src/plan/toSection.ts @@ -76,6 +76,15 @@ export interface SectionCutPolygon { * Wert (unauflösbares Bauteil), nimmt das Band an keiner Verschmelzung teil. */ componentId?: string; + /** + * ID des QUELL-Elements dieses Cut-Bands im Dokumentmodell (Wand/Decke/Dach) — + * aufgelöst in `attachCutStyles` über die Besitzer-Listen. Trägt die Auswahl im + * Schnitt-View: `generateSectionPlan` schreibt sie je nach `component.kind` als + * `wallId`/`ceilingId`/`roofId` ins Plan-Polygon, sodass ein Klick auf das + * geschnittene Bauteil im Schnitt exakt jenes Element im Grundriss-Modell wählt. + * Fehlt (unauflösbares Bauteil), bleibt das Band ohne Auswahlbezug. + */ + sourceId?: string; } /** Ein projiziertes Liniensegment in (u, v)-Metern. */ @@ -294,11 +303,13 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section // aufgelöst; keine Boolean-Dominanz (joinPriority undefined) — unverändert // übernehmen. WICHTIG vor dem Slab-Zweig (sonst falscher owner-Lookup). if (ref.kind === "roof") { + cp.sourceId = (project.roofs ?? [])[ref.index]?.id; bands.push(cp); continue; } if (ref.kind === "wall") { const owner = walls[ref.index]; + cp.sourceId = owner?.id; // Wand: bei mehrschichtigem Wandtyp wird das EINE geschnittene Rechteck in // stehende Teil-Rechtecke je Schicht quer zur Dicke (u) aufgeteilt — das // vertikale Gegenstück zur Decken-Zerlegung (dort quer zur Höhe v), siehe @@ -327,6 +338,7 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section // Schnitt sichtbar (im Grundriss bleibt die Decke bewusst flächig, siehe // `addCeilingPoche`). const owner = slabs[ref.index]; + cp.sourceId = owner?.id; if (owner) { const wt = getCeilingType(project, owner); if (wt.layers.length > 1) { @@ -727,6 +739,7 @@ function bandFromRect(src: SectionCutPolygon, r: Rect): SectionCutPolygon { hatch: src.hatch, joinPriority: src.joinPriority, componentId: src.componentId, + sourceId: src.sourceId, }; } @@ -779,6 +792,7 @@ function splitSlabLayers( hatch, joinPriority: comp.joinPriority, componentId: comp.id, + sourceId: cp.sourceId, }); } return out; @@ -865,6 +879,7 @@ export function splitWallLayers( hatch, joinPriority: comp.joinPriority, componentId: comp.id, + sourceId: cp.sourceId, }); } return out; @@ -1033,6 +1048,72 @@ export function appendRoofSections( }); } +/** Mittelpunkt (Modell-2D) einer Punktliste (Outline-Schwerpunkt, naiv). */ +function centroid2d(pts: Vec2[]): Vec2 | null { + if (pts.length === 0) return null; + let x = 0; + let y = 0; + for (const p of pts) { + x += p.x; + y += p.y; + } + return { x: x / pts.length, y: y / pts.length }; +} + +/** + * Blendet Ansichtskanten (sichtbar/verdeckt) aus, deren QUELL-Bauteil weiter als + * `depth` Meter HINTER der Schnittebene (in Blickrichtung) liegt — die + * Schnitt-Tiefe (`DrawingLevel.depth`). Die Distanz wird über den REPRÄSENTATIVEN + * Bauteilpunkt (Wand-Mitte bzw. Outline-Schwerpunkt) gegen die Ebenennormale + * gemessen (in-place). + * + * NÄHERUNG (bewusst, kein Rust-Hack): der Rust-Extraktor liefert die Kanten nur in + * (u, v) OHNE echte Tiefe je Kante — die exakte Lösung bräuchte eine Tiefen-Achse + * aus `section.rs`. Bis dahin filtert dieser Owner-Distanz-Test grob nach dem + * Bauteil-Mittelpunkt: ein Bauteil wird GANZ ein- oder ausgeblendet (kein + * Teil-Clipping an der Tiefengrenze). Cut-Polygone liegen auf der Ebene (Distanz + * ~0) und bleiben immer erhalten. TODO: exakte per-Kante-Tiefe aus render3d + * (section.rs) zurückgeben, dann hier hart nach Kanten-Tiefe clippen. + */ +export function filterByDepth( + output: SectionOutput, + project: Project, + plane: SectionPlaneSpec, + depth: number, +): void { + if (!(depth > 0)) return; + // Blickrichtung + Ebenenaufpunkt in Modell-2D (x = world.x, y = world.z). + const blick = normalize({ x: plane.normal[0], y: plane.normal[2] }); + const origin = { x: plane.point[0], y: plane.point[2] }; + const walls = wallSegmentOwners(project); + const slabs = slabSegmentOwners(project); + const roofs = project.roofs ?? []; + + const ownerPoint = (ref: SectionComponentRef): Vec2 | null => { + if (ref.kind === "wall") { + const w = walls[ref.index]; + return w ? { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 } : null; + } + if (ref.kind === "slab") { + const c = slabs[ref.index]; + return c ? centroid2d(c.outline) : null; + } + const r = roofs[ref.index]; + return r ? centroid2d(r.outline) : null; + }; + + const keep = (e: SectionEdge): boolean => { + const p = ownerPoint(e.component); + if (!p) return true; // Unauflösbar: konservativ behalten. + // Distanz HINTER der Ebene entlang der Blickrichtung; ≤ depth ⇒ behalten. + const d = dot(sub(p, origin), blick); + return d <= depth + 1e-6; + }; + + output.visibleEdges = output.visibleEdges.filter(keep); + output.hiddenEdges = output.hiddenEdges.filter(keep); +} + export async function computeSection( project: Project, level: DrawingLevel, @@ -1063,5 +1144,7 @@ export async function computeSection( // Dächer TS-seitig ergänzen (der Rust-Extraktor kennt nur Wände + Decken). appendRoofSections(output, project, plane); attachCutStyles(output, project, plane); + // Schnitt-Tiefe: Kanten weiter als `level.depth` hinter der Ebene ausblenden. + if (level.depth !== undefined) filterByDepth(output, project, plane, level.depth); return output; } diff --git a/src/state/selectionSlice.ts b/src/state/selectionSlice.ts index b7bfa4a..3da70a9 100644 --- a/src/state/selectionSlice.ts +++ b/src/state/selectionSlice.ts @@ -25,6 +25,9 @@ export interface SelectionSlice { selectedColumnIds: string[]; // Gewählte Dächer als MENGE von IDs — getrennt von den übrigen Kanälen. selectedRoofIds: string[]; + // Gewählte Schnitt-/Ansichtslinie (DrawingLevel-Id) — EINZELauswahl (eine Linie + // zur Zeit), getrennt von den übrigen Kanälen. `null` = keine Linie gewählt. + selectedSectionLineId: string | null; setSelectedWallIds: (ids: string[]) => void; setSelectedDrawingIds: (ids: string[]) => void; @@ -35,6 +38,7 @@ export interface SelectionSlice { setSelectedExtrudedSolidIds: (ids: string[]) => void; setSelectedColumnIds: (ids: string[]) => void; setSelectedRoofIds: (ids: string[]) => void; + setSelectedSectionLineId: (id: string | null) => void; /** Auswahl (Wände + Decken + Öffnungen + Treppen + Dächer + 2D-Elemente) leeren. */ clearSelection: () => void; } @@ -53,6 +57,7 @@ export function createSelectionSlice( selectedExtrudedSolidIds: [], selectedColumnIds: [], selectedRoofIds: [], + selectedSectionLineId: null, setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }), @@ -62,6 +67,7 @@ export function createSelectionSlice( setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }), setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }), setSelectedRoofIds: (ids) => set({ selectedRoofIds: ids }), + setSelectedSectionLineId: (id) => set({ selectedSectionLineId: id }), clearSelection: () => set({ selectedWallIds: [], @@ -73,6 +79,7 @@ export function createSelectionSlice( selectedExtrudedSolidIds: [], selectedColumnIds: [], selectedRoofIds: [], + selectedSectionLineId: null, }), }; } diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts index 5fefe0a..5fe61d4 100644 --- a/src/ui/ribbon/ribbonItems.ts +++ b/src/ui/ribbon/ribbonItems.ts @@ -110,6 +110,10 @@ export const RIBBON_TABS: RibbonTab[] = [ t("room"), ], }, + { + titleKey: "ribbon.group.sections", + items: [c("sectionline"), c("viewline")], + }, ], }, {