// Wurzel der App: ein semantisches Projekt im React-State; jede Ansicht wird // daraus abgeleitet. Die Oberfläche läuft auf dem Panel-System (src/panels): // links (und optional rechts) je ein Dock mit Tabs, dazwischen die Hauptansicht. // Die beiden dockbaren Kern-Panels (Zeichnungsebenen, Ebenen) registrieren sich // über den Import von builtinPanels und beziehen Zustand + Handler aus dem // PanelHostContext — kein Prop-Drilling mehr. // // „Ressourcen" ist KEIN Dock-Panel: der ResourceManager ist ein schwebendes // Fenster (Overlay über allem), das die Oberleiste öffnet und über X / Klick auf // den abgedunkelten Hintergrund / Esc schließt. Ein leeres Dock wird nicht // gerendert, damit die Mitte (Grundriss/Perspektive) die Breite voll nutzt. // // Bearbeitung läuft ausschließlich immutabel über setProject. Sichtbare // Kategorie-Codes (collectVisibleCodes) steuern, was Grundriss und 3D zeigen. // Der Darstellungsmodus je Panel (DisplayMode) verfeinert das zusätzlich (die // fünf DOSSIER-Modi): das Zeichnungsebenen-Panel steuert das 3D, das // Ebenen-Panel die Kategorie-Darstellung in Grundriss UND 3D. import { useEffect, useMemo, useRef, useState } from "react"; import { collectVisibleCodes, flattenCategories, formatM, getWallType, wallTypeThickness, } from "./model/types"; import { wallCorners } from "./model/geometry"; import { leftNormal, normalize, scale, sub } from "./model/geometry"; import type { Drawing2D, DrawingLevel, EdgeGrip, LayerCategory, Project, Vec2, Wall, } from "./model/types"; import { generatePlan } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan"; import { parseDxf } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser"; import { setDxfImportTrigger } from "./io/dxfImportHook"; import { ImportDialog, buildDrawings } from "./ui/ImportDialog"; import type { ImportDecision } from "./ui/ImportDialog"; import { ExportPdfDialog } from "./ui/ExportPdfDialog"; import type { ExportPdfDecision } from "./ui/ExportPdfDialog"; import { savePlanPdf } from "./export/exportPdf"; import { ExportDxfDialog } from "./ui/ExportDxfDialog"; import type { ExportDxfDecision } from "./ui/ExportDxfDialog"; import { savePlanDxf } from "./export/exportDxf"; import { PlanView } from "./plan/PlanView"; import type { PlanViewHandle, PlanSelection, MarqueeSelection, GripHandlers, } from "./plan/PlanView"; import { Viewport3D } from "./viewport/Viewport3D"; import type { DisplayResolver, ViewportContextInfo } from "./viewport/Viewport3D"; import { ResourceManager } from "./ui/ResourceManager"; import type { ResourceManagerHandlers } from "./ui/ResourceManager"; import { TopBar } from "./ui/TopBar"; import { TextEditorDialog } from "./ui/TextEditorDialog"; import { RoomStampEditor } from "./panels/RoomStampEditor"; import { defaultRoomStamp } from "./model/roomStamp"; import type { DetailLevel, RenderMode, View3d, ViewType } from "./ui/TopBar"; import { StatusBar } from "./ui/StatusBar"; import { getTool } from "./tools/tools"; import { applyAngleConstraint, computeSnap } from "./tools/snapping"; import { requiredPoints, transformPreview, } from "./tools/transform"; import type { CopyMode, TransformOp, TransformSelection } from "./tools/transform"; import { DEFAULT_SNAP } from "./tools/types"; import type { SnapResult, SnapSettings, ToolContext, ToolDraft, ToolHandlers, ToolId, ToolMods, ToolPointer, ToolState, } from "./tools/types"; import { ContextMenu, MenuIcon } from "./ui/ContextMenu"; import type { ContextMenuItem } from "./ui/ContextMenu"; import { CommandLine } from "./ui/CommandLine"; import type { CommandLineHandle, CommandLineField, CommandLineOption } from "./ui/CommandLine"; import { CommandEngine } from "./commands/engine"; import type { EngineHost } from "./commands/engine"; import type { CommandContext } from "./commands/types"; import { t, useT } from "./i18n"; import { cutSegment, nearestEdgeIndex, splitDrawing, toPolyForm, } from "./editors/splitJoin"; import type { PolyForm } from "./editors/splitJoin"; import { countClosedAreas, differenceDrawings, intersectionDrawings, unionDrawings, } from "./editors/booleanOps"; import type { BooleanResult } from "./editors/booleanOps"; // Globaler Store (Slices: project/selection/view/layout). import { useStore } from "./state/appStore"; import { categoryLabel, drawingVertices, findCategory, } from "./state/projectSlice"; import { reconcileLayout } from "./state/layoutSlice"; // Panel-System. import { Dock } from "./panels/Dock"; import { FloatingPanel } from "./panels/FloatingPanel"; import { DragOverlay, usePanelDrag } from "./panels/panelDrag"; import { itemDisplay } from "./panels/displayMode"; import { PanelHostContext } from "./panels/types"; import type { DisplayMode, DockId, LayoutState, PanelHost, } from "./panels/types"; import type { PanelHostValue } from "./panels/host"; import { deriveSelection } from "./state/selectionInfo"; import { centroid as roomCentroid } from "./geometry/roomArea"; import { docFromText } from "./text/richText"; import type { RichTextDoc } from "./text/richText"; import { deleteNamedLayout, listNamedLayouts, loadNamedLayout, saveLayout, saveNamedLayout, } from "./panels/layout"; import { deleteDrawingCombo, deleteLayerCombo, listDrawingCombos, listLayerCombos, loadDrawingCombo, loadLayerCombo, saveDrawingCombo, saveLayerCombo, } from "./state/visibilitySets"; // Seiteneffekt: registriert die zwei dockbaren Kern-Panels in der Registry. import { BUILTIN_PANEL_IDS } from "./panels/builtinPanels"; /** * Offenes Kontextmenü — genau eine Quelle. `x/y` sind Viewport-Pixel (clientX/Y). * • "layer" — Rechtsklick auf eine Ebenen-Zeile (Kategorie-Code). * • "level" — Rechtsklick auf eine Zeichnungsebenen-Zeile (Ebenen-ID). * • "plan" — Rechtsklick im Grundriss (optional getroffene Wand). * • "viewport" — Rechtsklick im 3D (optional getroffene Wand). */ type MenuState = | { kind: "layer"; code: string; x: number; y: number } | { kind: "level"; id: string; x: number; y: number } | { kind: "plan"; wallId: string | null; x: number; y: number } | { kind: "viewport"; wallId: string | null; x: number; y: number } | null; /** Offener Inline-Editor (aus einem Menüpunkt). `x/y` = Anker in Viewport-Px. */ type EditorState = | { kind: "layer"; code: string; x: number; y: number } | { kind: "level"; id: string; x: number; y: number } | null; /** * GUI-Werkzeug → Befehlsname der Engine. Die Zeichen-Werkzeuge der Palette sind * in Wahrheit Engine-Befehle: ein Klick auf das Werkzeug startet diesen Befehl * (statt das Legacy-Tool zu fahren). „select" hat (noch) keinen Befehl und bleibt * der Auswahl-Modus. „wall" ist jetzt ebenfalls ein Befehl (`wall`) — Klick und * getipptes „wall"/„wand" münden in denselben Befehl; der Legacy-Wand-Pfad ist * damit deaktiviert (siehe toolHandlers/onSelectTool). */ const TOOL_COMMAND: Partial> = { wall: "wall", ceiling: "ceiling", window: "fenster", door: "tuer", stair: "stair", room: "room", line: "line", polyline: "polyline", rect: "rect", }; /** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */ function coupledCommandFor(id: ToolId): string | null { return TOOL_COMMAND[id] ?? null; } /** Fragt die Kopienanzahl (≥1) für die Modi O/P ab; null bei Abbruch/ungültig. */ function promptCount(): number | null { const s = window.prompt(t("transform.countPrompt"), "3"); if (s == null) return null; const n = Math.round(Number(s)); return Number.isFinite(n) && n >= 1 ? n : null; } export default function App() { // Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache // umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t). useT(); // ── Globaler Zustand aus dem Store (Slices: project/selection/view/layout) ─ // Pro useStore-Aufruf EIN Feld selektieren (Referenz-Gleichheit, sonst // Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil. const project = useStore((s) => s.project); const setProject = useStore((s) => s.setProject); const activeLevelId = useStore((s) => s.activeLevelId); const setActiveLevelId = useStore((s) => s.setActiveLevelId); const viewType = useStore((s) => s.viewType); const setViewType = useStore((s) => s.setViewType); // Schwebendes Ressourcen-Fenster (Overlay über allem). Transienter UI-State, // bleibt lokal. Wird über die Oberleiste geöffnet und über X / Hintergrund- // Klick / Esc geschlossen. const [resourcesOpen, setResourcesOpen] = useState(false); // Modaler Import-Dialog (DXF/DWG). Offen, sobald eine Datei gewählt/fallen- // gelassen wurde; hält Dateiname, geparstes Ergebnis (null bei DWG) und das // DWG-Flag (kein Parser). null = kein Dialog offen. Transienter UI-State. const [importState, setImportState] = useState<{ fileName: string; isDwg: boolean; parsed: DxfImportResult | null; // true, solange DWG noch geparst wird (WASM lädt/läuft). loading?: boolean; } | null>(null); // Modaler PDF-Export-Dialog (Grundriss → Vektor-PDF). true = offen. const [exportOpen, setExportOpen] = useState(false); const [exportDxfOpen, setExportDxfOpen] = useState(false); // Visuelles Drop-Feedback (dezenter Rahmen), solange eine Datei über die App // gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen // zu vermeiden. const [dropActive, setDropActive] = useState(false); // ── Oberleisten-/Status-Zustand (View-State, kein Modell) ───────────────── // detail wirkt im Grundriss (Symbole + Schraffuren/Linien), renderMode in der // 3D-Ansicht (3 Modi), referenceLines schaltet die Wand-Achslinien. const detail = useStore((s) => s.detail); const setDetail = useStore((s) => s.setDetail); const renderMode = useStore((s) => s.renderMode); const setRenderMode = useStore((s) => s.setRenderMode); const view3d = useStore((s) => s.view3d); const setView3d = useStore((s) => s.setView3d); const fov = useStore((s) => s.fov); const setFov = useStore((s) => s.setFov); const planColorMode = useStore((s) => s.planColorMode); const setPlanColorMode = useStore((s) => s.setPlanColorMode); const referenceLines = useStore((s) => s.referenceLines); const setReferenceLines = useStore((s) => s.setReferenceLines); const lineMode = useStore((s) => s.lineMode); const setLineMode = useStore((s) => s.setLineMode); const scaleDenominator = useStore((s) => s.scaleDenominator); const setScaleDenominator = useStore((s) => s.setScaleDenominator); // LIVE Papier-Massstab-Nenner aus der View-Transform (PlanView meldet ihn); // null = keine Plan-Ansicht aktiv. Transienter UI-State, bleibt lokal. const [liveScale, setLiveScale] = useState(null); // Cursor in Modell-Metern (aus der Plan-Ansicht) bzw. null (nicht über Plan). const [cursor, setCursor] = useState(null); // Live-Zoom der Plan-Ansicht in Prozent (eingepasst = 100 %). const [zoomPercent, setZoomPercent] = useState(100); // ── Zeichenwerkzeuge (transienter View-State; bleibt lokal) ─────────────── // Aktives Werkzeug, Werkzeug-Parameter (Wandtyp/Kategorie/Linienstil), Snap- // Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je // Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView). const [activeTool, setActiveTool] = useState("select"); const [activeWallTypeId, setActiveWallTypeId] = useState( project.wallTypes[0].id, ); const activeCategoryCode = useStore((s) => s.activeCategoryCode); const setActiveCategoryCode = useStore((s) => s.setActiveCategoryCode); const [activeLineStyleId] = useState(project.lineStyles[0].id); const [snap, setSnap] = useState(DEFAULT_SNAP); const toolStateRef = useRef(getTool("select").init()); const [draft, setDraft] = useState(null); // Aktive Transformation (Bewegen/Spiegeln/Drehen) auf der Auswahl — Vectorworks- // Stil mit U/I/O/P-Modusleiste. null = keine Transformation läuft. const [activeTransform, setActiveTransform] = useState<{ op: TransformOp; copyMode: CopyMode; count: number; points: Vec2[]; } | null>(null); // Letzter gesnappter Cursor der laufenden Transformation — für das Live-Feld // (Winkel/Distanz) der Befehlszeile (analog gripDragInfoRef beim Vertex-Drag). const transformDragInfoRef = useRef<{ cursor: Vec2 } | null>(null); // ── Auswahl (Store-Slice) ───────────────────────────────────────────────── const selectedWallIds = useStore((s) => s.selectedWallIds); const setSelectedWallIds = useStore((s) => s.setSelectedWallIds); // 2D-Auswahl ist jetzt eine MENGE (Marquee/Mehrfach). Für die bestehende // Einzel-Element-Maschinerie (Grips, Edit, Attribute, Object-Info) wird lokal // der „eine" gewählte Zeichnungs-ID abgeleitet (nur bei genau einem Element); // `setSelectedDrawingId` bleibt als bequeme Einzel-Setter-Hülle erhalten. const selectedDrawingIds = useStore((s) => s.selectedDrawingIds); const setSelectedDrawingIds = useStore((s) => s.setSelectedDrawingIds); const selectedDrawingId = selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null; const setSelectedDrawingId = (id: string | null) => setSelectedDrawingIds(id ? [id] : []); // Bequemer Einzel-Selektor (erste gewählte Wand). const selectedWallId = selectedWallIds[0] ?? null; // Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine" // gewählte (bei genau einem Element). const selectedCeilingIds = useStore((s) => s.selectedCeilingIds); const setSelectedCeilingIds = useStore((s) => s.setSelectedCeilingIds); const selectedCeilingId = selectedCeilingIds.length === 1 ? selectedCeilingIds[0] : null; const selectedOpeningIds = useStore((s) => s.selectedOpeningIds); const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds); const selectedOpeningId = selectedOpeningIds.length === 1 ? selectedOpeningIds[0] : null; const selectedStairIds = useStore((s) => s.selectedStairIds); const setSelectedStairIds = useStore((s) => s.setSelectedStairIds); const selectedStairId = selectedStairIds.length === 1 ? selectedStairIds[0] : null; const selectedRoomIds = useStore((s) => s.selectedRoomIds); const setSelectedRoomIds = useStore((s) => s.setSelectedRoomIds); const selectedRoomId = selectedRoomIds.length === 1 ? selectedRoomIds[0] : null; // ── Kontextmenüs (transienter UI-State, bleibt lokal) ───────────────────── // Eigenschaften-Zwischenablage des Ebenen-Kontextmenüs (Farbe + Linienstärke). const [layerClipboard, setLayerClipboard] = useState<{ color: string; lw: number; } | null>(null); // Offenes Kontextmenü (genau eines) — Quelle bestimmt die Einträge. const [menu, setMenu] = useState(null); // Offener Inline-Editor (Ebene bzw. Geschoss) — aus einem Menüpunkt geöffnet. const [editor, setEditor] = useState(null); // Offener Raum-Stempel-Editor (Rich-Text) — per Doppelklick/Button geöffnet. const [stampEditorRoomId, setStampEditorRoomId] = useState(null); // Beim Geschosswechsel die Wand-Auswahl + offene Overlays zurücksetzen (die // Wände eines anderen Geschosses sind eine andere Menge). Der Ansichtswechsel // (Grundriss ↔ 3D) lässt die Auswahl bestehen — dieselbe wallId gilt in // beiden Ansichten; nur die offenen Overlays werden geschlossen. useEffect(() => { setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); setMenu(null); setEditor(null); }, [activeLevelId]); useEffect(() => { setMenu(null); setEditor(null); }, [viewType]); // Imperatives Handle der Plan-Ansicht (für die Zoom-Buttons der Oberleiste). // useRef(null) → RefObject (current: T) — die für ein ref-Prop passende // Form; .current bleibt zur Laufzeit null, solange PlanView nicht montiert ist // (Optional-Chaining unten fängt das ab). const planViewRef = useRef(null); // Verstecktes Datei-Input für den DXF-Import (Site-Panel/„import"-Befehl). // Der Klick auf „DXF importieren" triggert .click() dieses Inputs; onChange // liest die Datei als Text und reicht sie durch parseDxf → addContextObjects. const dxfInputRef = useRef(null); // Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt // über den Modul-Hook). Beim Unmount wieder abmelden. useEffect(() => { setDxfImportTrigger(() => dxfInputRef.current?.click()); return () => setDxfImportTrigger(null); }, []); // Panel-Layout (Docks/Tabs/Größen) — aus dem Store-Slice (Init lädt aus // localStorage + reconcile). Bei jeder Änderung zurückspeichern. const layout = useStore((s) => s.layout); const setLayout = useStore((s) => s.setLayout); // Auf die Modus-Map abonnieren, damit App bei einer Moduswahl re-rendert und // die abgeleiteten Resolver (floorDisplay/categoryDisplay) neu berechnet. const displayModes = useStore((s) => s.displayModes); const modeFor = useStore((s) => s.modeFor); const setModeFor = useStore((s) => s.setModeFor); useEffect(() => { saveLayout(layout); }, [layout]); // Esc schließt das schwebende Ressourcen-Fenster (nur registriert, solange es // offen ist). useEffect(() => { if (!resourcesOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setResourcesOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [resourcesOpen]); const activeLevel = project.drawingLevels.find((z) => z.id === activeLevelId) ?? project.drawingLevels[0]; // Sichtbare Kategorie-Codes — Basisfilter für Grundriss & 3D. const visibleCodes = useMemo( () => collectVisibleCodes(project.layers), [project.layers], ); // ── ToolController (Zeichenwerkzeuge) ───────────────────────────────────── // PlanView meldet rohe Modellpunkte + Pixel/Meter hierher; wir snappen, fahren // das aktive Werkzeug (Zustand in toolStateRef, kein Re-Render je Bewegung) und // halten den Draft (Vorschau) im State. Commit läuft immutabel über setProject. const toolCtx: ToolContext = useMemo( () => ({ project, level: activeLevel, defaultCategoryCode: activeCategoryCode, activeWallTypeId, activeLineStyleId, }), [project, activeLevel, activeCategoryCode, activeWallTypeId, activeLineStyleId], ); // Beim Werkzeugwechsel / Geschoss-/Ansichtswechsel: laufenden Entwurf + // laufende Transformation verwerfen. useEffect(() => { toolStateRef.current = getTool(activeTool).init(); setDraft(null); setActiveTransform(null); }, [activeTool, activeLevelId, viewType]); // Snap-Kandidaten brauchen die bereits gesetzten Punkte + den letzten Punkt aus // dem laufenden Werkzeug-Zustand (Wand: points[], Linie: a). const draftPointsFromState = (st: ToolState): Vec2[] => { const s = st as { points?: Vec2[]; a?: Vec2 }; if (s.points) return s.points; if (s.a) return [s.a]; return []; }; const snapFor = (raw: Vec2, pxPerMeter: number, mods: ToolMods) => { const pts = draftPointsFromState(toolStateRef.current); return computeSnap({ raw, project, levelId: activeLevel.id, visibleCodes, settings: snap, draftPoints: pts, lastPoint: pts.length ? pts[pts.length - 1] : null, pxPerMeter, shift: mods.shift, ctrl: mods.ctrl, }); }; // Wendet ein ToolResult an: Draft (+ Snap-Marker) zeigen, ggf. committen/abschließen. const applyToolResult = ( res: { draft: ToolDraft | null; commit?: (p: Project) => Project; done?: boolean }, snapResult: ReturnType, ) => { if (res.draft) setDraft({ ...res.draft, snap: snapResult }); else if (snapResult) setDraft({ preview: [], vertices: [], snap: snapResult }); else setDraft(null); if (res.commit) setProject(res.commit); if (res.done) toolStateRef.current = getTool(activeTool).init(); }; const runToolStep = ( kind: "move" | "click", raw: Vec2, pxPerMeter: number, mods: ToolMods, ) => { const tool = getTool(activeTool); const snapResult = snapFor(raw, pxPerMeter, mods); const p: ToolPointer = { raw, snap: snapResult, point: snapResult?.point ?? raw, shift: mods.shift, ctrl: mods.ctrl, alt: mods.alt, button: 0, }; const [st, res] = kind === "click" ? tool.onClick(toolStateRef.current, p, toolCtx) : tool.onMove(toolStateRef.current, p, toolCtx); toolStateRef.current = st; applyToolResult(res, snapResult); }; // ── Transformation (Bewegen/Spiegeln/Drehen) auf der Auswahl ────────────── const transformSel: TransformSelection = { wallIds: selectedWallIds, drawingId: selectedDrawingId, }; const snapForTransform = (raw: Vec2, pxPerMeter: number, mods: ToolMods, points: Vec2[]) => computeSnap({ raw, project, levelId: activeLevel.id, visibleCodes, settings: snap, draftPoints: points, lastPoint: points.length ? points[points.length - 1] : null, pxPerMeter, shift: mods.shift, ctrl: mods.ctrl, }); const transformMove = (raw: Vec2, pxPerMeter: number, mods: ToolMods) => { const tr = activeTransform!; const snapResult = snapForTransform(raw, pxPerMeter, mods, tr.points); const cursor = snapResult?.point ?? raw; // Letzter gesnappter Cursor — speist das Live-Feld (Winkel/Distanz) der // Befehlszeile, analog zu gripDragInfoRef beim Vertex-Drag. transformDragInfoRef.current = { cursor }; const need = requiredPoints(tr.op); const eff = [...tr.points, cursor].slice(0, need); const preview = eff.length === need ? transformPreview(project, transformSel, tr.op, eff, tr.copyMode, tr.count) : []; setDraft({ preview, vertices: tr.points, snap: snapResult }); }; const transformClick = (raw: Vec2, pxPerMeter: number, mods: ToolMods) => { const tr = activeTransform!; const snapResult = snapForTransform(raw, pxPerMeter, mods, tr.points); const pt = snapResult?.point ?? raw; const points = [...tr.points, pt]; if (points.length >= requiredPoints(tr.op)) { commitTransformOn(transformSel, tr.op, points, tr.copyMode, tr.count); setActiveTransform(null); setDraft(null); transformDragInfoRef.current = null; } else { setActiveTransform({ ...tr, points }); } }; const toolHandlers: ToolHandlers = { onToolMove: (raw, pxPerMeter, mods) => { // Läuft ein Befehl, übernimmt die Engine die Plan-Eingabe (ein Eingabepfad). // Modifikatoren (Shift=Ortho, Ctrl=Snap aus) mitgeben, damit auch der ERSTE // Punkt sauber snappt bzw. unterdrückt werden kann. if (engineRef.current?.isActive()) { engineRef.current.move(raw, { shift: mods.shift, ctrl: mods.ctrl }); return; } // Gekoppeltes Werkzeug (Wand/Linie/Rechteck/Polylinie) ohne laufenden // Befehl: KEIN Legacy-Zeichnen — nur Hover (die Engine startet beim Klick neu). if (coupledCommandFor(activeTool)) return; activeTransform ? transformMove(raw, pxPerMeter, mods) : runToolStep("move", raw, pxPerMeter, mods); }, onToolClick: (raw, pxPerMeter, mods) => { const em = { shift: mods.shift, ctrl: mods.ctrl }; if (engineRef.current?.isActive()) { engineRef.current.pick(raw, em); return; } // Gekoppeltes Werkzeug, aber der Befehl ist (nach Commit) beendet: denselben // Befehl neu starten und den Klick als ersten Punkt durchreichen — so bleibt // das Werkzeug wie in einem CAD armiert, ohne dass der Legacy-Pfad zeichnet. // Der erste Punkt snappt so ebenfalls (mods durchgereicht). const cmd = coupledCommandFor(activeTool); if (cmd) { engineRef.current?.start(cmd); engineRef.current?.pick(raw, em); return; } activeTransform ? transformClick(raw, pxPerMeter, mods) : runToolStep("click", raw, pxPerMeter, mods); }, onToolCommit: () => { // Rechtsklick im Viewport = Enter an die Engine (§2.3), wenn ein Befehl läuft. if (engineRef.current?.isActive()) { engineRef.current.confirm(); return; } if (activeTransform) { // Rechts-/Doppelklick während einer Transformation = abbrechen. setActiveTransform(null); setDraft(null); return; } const tool = getTool(activeTool); const [st, res] = tool.onCommitGesture(toolStateRef.current, toolCtx); toolStateRef.current = st; if (res.commit) setProject(res.commit); setDraft(null); if (res.done) toolStateRef.current = tool.init(); }, draft, }; // ── Command-Engine (Rhino-artiges Befehlssystem) ────────────────────────── // Eine stabile Engine-Instanz; alle Live-Werte (Kontext/Snap/visibleCodes) // liest der Host über einen jeweils aktualisierten `latest`-Ref, damit die // Instanz nicht je Render neu gebaut werden muss. Die Engine speist Picks + // getippte Eingabe in DENSELBEN Schritt (ein Eingabepfad). Koexistenz: läuft // ein Befehl, übernimmt er die linke Maustaste der Plan-Ansicht über denselben // toolHandlers-Pfad wie die Alt-Tools (siehe unten). const cmdLatest = useRef({ project, activeLevel, activeCategoryCode, activeLineStyleId, activeWallTypeId, visibleCodes, snap, selectedWallIds, selectedDrawingId, selectedDrawingIds, setProject, setDraft, }); cmdLatest.current = { project, activeLevel, activeCategoryCode, activeLineStyleId, activeWallTypeId, visibleCodes, snap, selectedWallIds, selectedDrawingId, selectedDrawingIds, setProject, setDraft, }; const commandLineRef = useRef(null); const engineRef = useRef(null); if (engineRef.current === null) { const host: EngineHost = { context: (lastPoint): CommandContext => { const c = cmdLatest.current; return { project: c.project, level: c.activeLevel, defaultCategoryCode: c.activeCategoryCode, activeLineStyleId: c.activeLineStyleId, activeWallTypeId: c.activeWallTypeId, lastPoint, // Vorab gesetzte Auswahl aus dem Store (Rhino: erst selektieren, dann // Befehl). Move/Copy/Offset operieren darauf. selection: { wallIds: c.selectedWallIds, drawingId: c.selectedDrawingId, drawingIds: c.selectedDrawingIds, }, }; }, snap: (raw, draftPoints, mods) => { const c = cmdLatest.current; return computeSnap({ raw, project: c.project, levelId: c.activeLevel.id, visibleCodes: c.visibleCodes, settings: c.snap, draftPoints, lastPoint: draftPoints.length ? draftPoints[draftPoints.length - 1] : null, pxPerMeter: planViewRef.current?.pxPerMeter() ?? 90, // Shift = Ortho, Ctrl = Snap aus — auch für den ERSTEN Punkt (ohne // Draft): so fängt der Startpunkt einer neuen Linie/Polylinie/Wand an // vorhandene Endpunkte/Mitten, statt erst ab dem zweiten Punkt. shift: mods?.shift ?? false, ctrl: mods?.ctrl ?? false, }); }, pxPerMeter: () => planViewRef.current?.pxPerMeter() ?? 90, setDraft: (d) => cmdLatest.current.setDraft(d), commit: (mutate) => cmdLatest.current.setProject(mutate), isFloor: () => cmdLatest.current.activeLevel.kind === "floor", }; engineRef.current = new CommandEngine(host); } const engine = engineRef.current; // Auf Engine-Zustandsänderungen re-rendern (Prompt/Optionen aktualisieren). const [engineTick, setEngineTick] = useState(0); useEffect( () => engine.subscribe(() => setEngineTick((n) => n + 1)), [engine], ); // engineTick wird nur als Re-Render-Trigger gelesen (kein direkter Konsum). void engineTick; const engineView = engine.view(); const commandActive = engineView.active; // ── GUI-Werkzeug ↔ Befehls-Engine (ein Eingabe-Hub) ─────────────────────── // Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie) // STARTET den passenden Befehl in der Engine und fokussiert das Befehlsfeld // („Dialog drin"). „Auswahl" beendet einen laufenden Befehl (Auswahl-Modus). const onSelectTool = (id: ToolId) => { const cmd = coupledCommandFor(id); if (cmd) { // Den gekoppelten Befehl in DERSELBEN Engine starten wie das Tippen von // „line" — Klick und Tippen münden in denselben Befehl. Das aktive-Tool- // Highlight spiegelt den laufenden Befehl (activeTool = id). Der Legacy- // Zeichenpfad ist deaktiviert, solange ein Befehl läuft (toolHandlers // leiten Move/Pick/Confirm an die Engine, siehe oben). engine.start(cmd); setActiveTool(id); // Befehlsfeld fokussieren: getippte Präzisionseingabe steht sofort bereit. // Nach dem Render des aktiven Prompts fokussieren (nächster Tick). requestAnimationFrame(() => commandLineRef.current?.focus()); return; } // „Auswahl" oder ein Legacy-Werkzeug (Wand): laufenden Befehl beenden. if (engine.isActive()) engine.cancel(); setActiveTool(id); }; // 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf // die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern // ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick). // Snapping/Tab-Felder/Commit laufen unverändert über den EngineHost; die neuen // Elemente landen auf dem aktiven Geschoss (host.context().level). const onWorkplanePoint = (kind: "move" | "pick", model: Vec2) => { const eng = engineRef.current; if (!eng?.isActive()) return; if (kind === "move") eng.move(model); else eng.pick(model); }; const onWorkplaneConfirm = () => { const eng = engineRef.current; if (eng?.isActive()) eng.confirm(); }; // Globaler Tab-Handler: läuft ein Befehl MIT Feldern (§2.7), zyklt Tab das // aktive Feld (Präzisionseingabe), ohne den Fokus zu stehlen — so kann man bei // der Maus bleiben. Sonst fokussiert/öffnet Tab die Command-Line. Gleicher // INPUT/TEXTAREA/contentEditable-Guard wie die bestehenden keydown-Handler // (das CommandLine-Input selbst behandelt Tab eigenständig). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== "Tab") return; const el = e.target as HTMLElement | null; if ( el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable) ) { return; } e.preventDefault(); const eng = engineRef.current; if (eng?.isActive() && eng.hasFields()) { eng.cycleField(); return; } // (c) Läuft ein Vertex-Griff-Drag, öffnet/zyklt Tab das Editier-Feld // (Länge/Winkel relativ zur Nachbarecke). Beim ersten Tab wird zusätzlich // das Befehlsfeld fokussiert, damit man den Zahlenwert direkt tippen kann. if (gripDragInfoRef.current) { const wasOpen = gripEditRef.current !== null; cycleGripEditFieldRef.current?.(); if (!wasOpen) commandLineRef.current?.focus(); return; } commandLineRef.current?.focus(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); // Tastatur für aktive Zeichenwerkzeuge: Esc verwirft den Entwurf (bzw. schaltet // im Ruhezustand zurück auf Auswahl), Enter schließt mehrteilige Werkzeuge ab, // Backspace nimmt den letzten Punkt zurück. In echten Eingaben inaktiv. useEffect(() => { if (activeTool === "select") return; const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { return; } // Läuft ein gekoppelter Befehl (Linie/Rechteck/Polylinie über die Engine), // gehören Esc/Enter/Backspace der Engine bzw. dem Befehlsfeld — der Legacy- // Werkzeug-Pfad bleibt stumm (keine doppelte Mechanik). if (engineRef.current?.isActive()) return; const tool = getTool(activeTool); const drawing = toolStateRef.current.phase !== "idle"; if (e.key === "Escape") { if (drawing) { const [st] = tool.onCancel(toolStateRef.current); toolStateRef.current = st; setDraft(null); } else { setActiveTool("select"); } e.preventDefault(); } else if (e.key === "Enter") { const [st, res] = tool.onCommitGesture(toolStateRef.current, toolCtx); toolStateRef.current = st; if (res.commit) setProject(res.commit); setDraft(null); if (res.done) toolStateRef.current = tool.init(); e.preventDefault(); } else if (e.key === "Backspace" && tool.onUndoPoint) { const [st, res] = tool.onUndoPoint(toolStateRef.current, toolCtx); toolStateRef.current = st; setDraft(res.draft); e.preventDefault(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [activeTool, toolCtx]); // ── Editier-Griffe (Grips) des einzeln selektierten Elements ────────────── // Genau eine Wand ODER ein 2D-Element → dessen Eckpunkte als ziehbare Griffe. const selectedDrawing = useMemo( () => project.drawings2d.find((d) => d.id === selectedDrawingId) ?? null, [project.drawings2d, selectedDrawingId], ); const selectedCeiling = useMemo( () => (project.ceilings ?? []).find((c) => c.id === selectedCeilingId) ?? null, [project.ceilings, selectedCeilingId], ); const selectedRoom = useMemo( () => (project.rooms ?? []).find((r) => r.id === selectedRoomId) ?? null, [project.rooms, selectedRoomId], ); // Stempel-Griff des (einzeln) gewählten Raums: sein fixer Anker (Meter) + ID. const roomStamp = useMemo( () => selectedRoom ? { roomId: selectedRoom.id, anchor: selectedRoom.stampAnchor ?? roomCentroid(selectedRoom.boundary), } : null, [selectedRoom], ); const grips: Vec2[] = useMemo(() => { if (selectedDrawing) return drawingVertices(selectedDrawing); if (selectedWallIds.length === 1 && !selectedDrawingId) { const w = project.walls.find((x) => x.id === selectedWallIds[0]); if (w) return [w.start, w.end]; } // Decke: die Umriss-Vertices als Griffe (nur bei genau einer gewählten Decke). if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) { return [...selectedCeiling.outline]; } // Raum: die Umriss-Vertices als Griffe (nur bei genau einem gewählten Raum). if ( selectedRoom && !selectedDrawingId && selectedWallIds.length === 0 && !selectedCeiling ) { return [...selectedRoom.boundary]; } return []; }, [ selectedDrawing, selectedCeiling, selectedRoom, selectedWallIds, selectedDrawingId, project.walls, ]); // ── Kanten-/Seiten-Griffe (Edge Grips) ─────────────────────────────────── // Pro Seite des selektierten Elements ein Griff: Kanten-Mittelpunkt + nach // AUSSEN zeigende Einheitsnormale + die beiden Vertex-Indizes (passend zur // `grips`-/`drawingVertices`-Reihenfolge). Die Außenrichtung wird über das // Vorzeichen des Skalarprodukts der Kanten-Normale mit (mid − centroid) // gewählt (vom Polygon-Schwerpunkt weg). // • rect: 4 Kanten (geschlossen). // • polyline mit `closed`: Kanten zwischen aufeinanderfolgenden Vertices // inkl. Schlusskante (letzter → erster). // • Wand: eine Kante [0,1]; Außennormale = leftNormal der Achse. Ziehen // verschiebt beide Enden (ganze Wand senkrecht). // • offene Polylinie/Linie: je Segment ein Kanten-Griff. Ziehen verschiebt DAS // Segment (seine beiden Endpunkte) um das volle Drag-Delta — die geteilten // Ecken wandern mit, die Nachbar-Segmente dehnen sich nach (Parallel- // Verschieben EINER Kante einer komplexen Polylinie, §Task B). const edgeGrips: EdgeGrip[] = useMemo(() => { // Geschlossener Ring aus Vertices → Kanten-Griffe mit Außennormale. const ringEdges = (verts: Vec2[]): EdgeGrip[] => { const n = verts.length; if (n < 3) return []; // Schwerpunkt (Mittel der Eckpunkte) als Innenbezug. let cx = 0, cy = 0; for (const v of verts) { cx += v.x; cy += v.y; } const centroid: Vec2 = { x: cx / n, y: cy / n }; const out: EdgeGrip[] = []; for (let i = 0; i < n; i++) { const a = verts[i]; const b = verts[(i + 1) % n]; const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const edge = sub(b, a); let nrm = normalize(leftNormal(edge)); // Nach außen orientieren: Normale soll von centroid → mid weisen. const outward = sub(mid, centroid); if (nrm.x * outward.x + nrm.y * outward.y < 0) nrm = scale(nrm, -1); out.push({ mid, normal: nrm, aIndex: i, bIndex: (i + 1) % n }); } return out; }; // Offene Kette (Linie/offene Polylinie) → ein Griff je Segment, OHNE // Schlusskante. `normal` dient nur der Griff-Darstellung (leftNormal der // Kante); die Bewegung ist frei (volles Delta), nicht auf die Normale // beschränkt — daher `free: true`. const openEdges = (verts: Vec2[]): EdgeGrip[] => { const n = verts.length; if (n < 2) return []; const out: EdgeGrip[] = []; for (let i = 0; i < n - 1; i++) { const a = verts[i]; const b = verts[i + 1]; const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const nrm = normalize(leftNormal(sub(b, a))); out.push({ mid, normal: nrm, aIndex: i, bIndex: i + 1, free: true }); } return out; }; if (selectedDrawing) { const g = selectedDrawing.geom; if (g.shape === "rect") return ringEdges(grips); if (g.shape === "polyline" && g.closed) return ringEdges(grips); if (g.shape === "polyline" && !g.closed) return openEdges(grips); if (g.shape === "line") return openEdges(grips); return []; } if (selectedWallIds.length === 1 && !selectedDrawingId) { const w = project.walls.find((x) => x.id === selectedWallIds[0]); if (w) { const mid: Vec2 = { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 }; const nrm = normalize(leftNormal(sub(w.end, w.start))); return [{ mid, normal: nrm, aIndex: 0, bIndex: 1 }]; } } // Decke: geschlossener Ring aus dem Umriss. if (selectedCeiling && !selectedDrawingId && selectedWallIds.length === 0) { return ringEdges(grips); } // Raum: geschlossener Ring aus dem Umriss. if ( selectedRoom && !selectedDrawingId && selectedWallIds.length === 0 && !selectedCeiling ) { return ringEdges(grips); } return []; }, [ selectedDrawing, selectedCeiling, selectedRoom, grips, selectedWallIds, selectedDrawingId, project.walls, ]); // Snapping beim Griff-Ziehen: das gezogene Element selbst aus den Quellen // nehmen (sonst fängt der Punkt an sich selbst). Live-Mutation über setProject. // Zuletzt angewandter Kanten-Offset während eines Edge-Grip-Drags (Anker). // null = kein laufender Drag; der erste Move-Event verankert nur (kein Sprung). const edgeDragOffsetRef = useRef(null); // Freies Kanten-Ziehen (offene Polylinie/Linie): letzter roher Cursor, gegen den // das inkrementelle 2D-Delta gebildet wird. null = Drag noch nicht verankert. const edgeFreeLastRef = useRef(null); // Körper-Verschieben (2D): koinzidente Endpunkte ANDERER gewählter Elemente, die // an einer Ecke des verschobenen Elements hängen, werden um dasselbe Delta // mitgeführt (gemeinsame Ecke bleibt verbunden). Lazy beim ersten Move-Event // bestimmt (Snapshot der Selektion), je Drag in onGripEnd zurückgesetzt. null = // noch nicht bestimmt. const moveCoincidentRef = useRef< | { drawingId: string | null; wallId: string | null; index: number; pos: Vec2 }[] | null >(null); // Eckpunkte eines selektierten Elements (Wand: start/end; 2D: drawingVertices). const elementCorners = (drawingId: string | null, wallId: string | null): Vec2[] => { if (drawingId) { const d = project.drawings2d.find((x) => x.id === drawingId); return d ? drawingVertices(d) : []; } if (wallId) { const w = project.walls.find((x) => x.id === wallId); return w ? [w.start, w.end] : []; } return []; }; // Koinzidente Sibling-Vertices (unter den ÜBRIGEN gewählten Elementen) an JEDER // Ecke des verschobenen Elements. Toleranz wie beim 3D-Drag (gemeinsame Ecke). const COINCIDENT_TOL_2D = 0.05; // m const findMoveCoincident2D = ( selfDrawingId: string | null, selfWallId: string | null, ): { drawingId: string | null; wallId: string | null; index: number; pos: Vec2 }[] => { const corners = elementCorners(selfDrawingId, selfWallId); if (corners.length === 0) return []; const near = (p: Vec2, q: Vec2) => Math.hypot(p.x - q.x, p.y - q.y) <= COINCIDENT_TOL_2D; const seen = new Set(); const out: { drawingId: string | null; wallId: string | null; index: number; pos: Vec2 }[] = []; const add = (drawingId: string | null, wallId: string | null, index: number, pos: Vec2) => { const key = `${drawingId ?? ""}|${wallId ?? ""}|${index}`; if (seen.has(key)) return; seen.add(key); out.push({ drawingId, wallId, index, pos: { ...pos } }); }; for (const corner of corners) { for (const id of selectedWallIds) { if (id === selfWallId) continue; const w = project.walls.find((x) => x.id === id); if (!w) continue; if (near(corner, w.start)) add(null, id, 0, w.start); if (near(corner, w.end)) add(null, id, 1, w.end); } for (const id of selectedDrawingIds) { if (id === selfDrawingId) continue; const d = project.drawings2d.find((x) => x.id === id); if (!d) continue; drawingVertices(d).forEach((v, i) => { if (near(corner, v)) add(id, null, i, v); }); } } return out; }; // ── (c) Präzise Werteingabe beim Editieren (Tab im Vertex-Griff-Drag) ─────── // Editieren läuft AD-HOC (gripHandlers → moveGripOf), NICHT durch die Engine. // Damit „Tab → Länge/Winkel" wie beim Zeichnen funktioniert, ohne die Engine // umzubauen, hält App einen kleinen FELD-Controller, der NUR während eines // Vertex-Griff-Drags aktiv ist: er belegt das Befehlsfeld (Prompt/Felder/ // Eingabe) und liefert gelockte Länge/Winkel relativ zum Anker (Nachbarecke). // `onGripMove` liest diese Locks und zwingt den Zielpunkt darauf — so setzt ein // getippter Wert den exakten Endpunkt. Foundation: heute 2D-Vertex; dieselbe // Lock-Auflösung (polar um den Anker) ist auf 3D-Vertex/Körper erweiterbar. const [gripEdit, setGripEdit] = useState<{ /** Fixer Bezug (Nachbarecke), relativ zu dem Länge/Winkel gelten. */ anchor: Vec2; /** Aktives Tab-Feld (zyklt length ↔ angle). */ active: "length" | "angle"; /** Gelockte Werte (getippt). length in m, angle in Grad. */ locks: { length?: number; angle?: number }; } | null>(null); // Live-Spiegel für Handler ohne Re-Render-Abhängigkeit (Drag liest synchron). const gripEditRef = useRef(null); gripEditRef.current = gripEdit; // Letzter roher Cursor des laufenden Vertex-Drags (für die Lock-Auflösung beim // Tippen, wenn gerade kein Move-Event kommt). Plus der gezogene Griff-Index. const gripDragInfoRef = useRef<{ index: number; raw: Vec2 } | null>(null); // Endpunkt aus Anker + (gelockten/Live-)Länge/Winkel — wie lineEndFromFields, // hier für den Vertex-Drag. Ungelockte Größen folgen dem rohen Cursor `raw`. const gripEditPoint = ( anchor: Vec2, locks: { length?: number; angle?: number }, raw: Vec2, ): Vec2 => { const DEG = Math.PI / 180; const dx = raw.x - anchor.x; const dy = raw.y - anchor.y; const length = locks.length != null ? locks.length : Math.hypot(dx, dy); const angleDeg = locks.angle != null ? locks.angle : (Math.atan2(dy, dx) * 180) / Math.PI; const a = angleDeg * DEG; return { x: anchor.x + Math.cos(a) * length, y: anchor.y + Math.sin(a) * length }; }; // Felder fürs Befehlsfeld während des Vertex-Drags (Live ODER gelockt). const gripEditFields = (): { id: string; labelKey: string; value: number | null; locked: boolean; active: boolean; }[] => { const ge = gripEdit; const info = gripDragInfoRef.current; if (!ge || !info) return []; const dx = info.raw.x - ge.anchor.x; const dy = info.raw.y - ge.anchor.y; const liveLen = Math.hypot(dx, dy); const liveAng = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 360) + 360) % 360; return [ { id: "length", labelKey: "cmd.field.length", value: ge.locks.length ?? liveLen, locked: ge.locks.length != null, active: ge.active === "length", }, { id: "angle", labelKey: "cmd.field.angle", value: ge.locks.angle ?? liveAng, locked: ge.locks.angle != null, active: ge.active === "angle", }, ]; }; // Stabiler Ref auf cycleGripEditField (der globale Tab-Handler hat leere Deps). const cycleGripEditFieldRef = useRef<() => void>(() => {}); // Tab im Vertex-Drag: Feld-Controller öffnen/zyklen (length ↔ angle). const cycleGripEditField = () => { const info = gripDragInfoRef.current; if (!info) return; const refIndex = info.index > 0 ? info.index - 1 : grips.length > 1 ? 1 : -1; const anchor = refIndex >= 0 ? grips[refIndex] : null; if (!anchor) return; // ohne Anker keine polare Eingabe möglich setGripEdit((prev) => prev ? { ...prev, active: prev.active === "length" ? "angle" : "length" } : { anchor, active: "length", locks: {} }, ); }; cycleGripEditFieldRef.current = cycleGripEditField; // Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und // den Zielpunkt sofort mit dem gelockten Wert neu setzen. const submitGripEditValue = (value: number) => { const ge = gripEditRef.current; const info = gripDragInfoRef.current; if (!ge || !info) return; const locks = { ...ge.locks, [ge.active]: value }; const next = ge.active === "length" ? "angle" : "length"; setGripEdit({ ...ge, locks, active: next }); // Zielpunkt mit dem frischen Lock anwenden (live, ohne Maus-Bewegung). const pt = gripEditPoint(ge.anchor, locks, info.raw); applyGrip(info.index, pt); }; // Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst // auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad. const applyGrip = (index: number, pt: Vec2) => { if (selectedRoomId) moveRoomGrip(selectedRoomId, index, pt); else if (selectedCeilingId) moveCeilingGrip(selectedCeilingId, index, pt); else moveGripOf(selectedDrawingId, selectedWallId, index, pt); }; const applyEdge = (aIndex: number, bIndex: number, delta: Vec2) => { if (selectedRoomId) moveRoomEdge(selectedRoomId, aIndex, bIndex, delta); else if (selectedCeilingId) moveCeilingEdge(selectedCeilingId, aIndex, bIndex, delta); else moveEdgeOf(selectedDrawingId, selectedWallId, aIndex, bIndex, delta); }; const gripHandlers = { onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => { // (c) Vertex-Drag mit aktivem Feld-Controller + Locks: den Zielpunkt polar // aus Anker + gelockter Länge/Winkel bilden (ungelockt folgt dem Cursor). const ge = gripEditRef.current; gripDragInfoRef.current = { index, raw }; if (ge && (ge.locks.length != null || ge.locks.angle != null)) { const pt = gripEditPoint(ge.anchor, ge.locks, raw); applyGrip(index, pt); setDraft({ preview: [], vertices: [], snap: null }); return; } const snapProject: Project = selectedRoomId ? { ...project, rooms: (project.rooms ?? []).filter((r) => r.id !== selectedRoomId) } : selectedCeilingId ? { ...project, ceilings: (project.ceilings ?? []).filter((c) => c.id !== selectedCeilingId) } : selectedDrawingId ? { ...project, drawings2d: project.drawings2d.filter((d) => d.id !== selectedDrawingId) } : { ...project, walls: project.walls.filter((w) => w.id !== selectedWallId) }; // Bezugspunkt für Shift-Ortho: ein Nachbar-Griff (festes Ende), relativ zu // dem die Bewegung auf H/V eingerastet wird. const refIndex = index > 0 ? index - 1 : grips.length > 1 ? 1 : -1; const anchor = refIndex >= 0 ? grips[refIndex] : null; // Shift = Ortho-Lock: den gezogenen Punkt auf H/V gegenüber dem FIXEN Anker // zwingen (Achse = die nähere; applyAngleConstraint mit 90°, wie Ortho beim // Zeichnen). Der Lock dominiert (kein Snap), damit die Endkoordinate // garantiert eine Achse mit dem Anker teilt — identisch zum 3D-Vertex-Drag. if (mods.shift && anchor) { const pt = applyAngleConstraint(anchor, raw, 90); applyGrip(index, pt); setDraft({ preview: [], vertices: [], snap: null }); return; } const snapResult = computeSnap({ raw, project: snapProject, levelId: activeLevel.id, visibleCodes, settings: snap, draftPoints: [], lastPoint: anchor, pxPerMeter, shift: mods.shift, ctrl: mods.ctrl, }); const pt = snapResult?.point ?? raw; applyGrip(index, pt); setDraft({ preview: [], vertices: [], snap: snapResult }); }, onMoveBody: (delta: Vec2, target?: { wallId?: string; drawingId?: string }) => { // Decke: ganzen Umriss verschieben (kein Multi-Koinzidenz-Handling nötig). if (selectedCeilingId && !target) { moveCeilingBy(selectedCeilingId, delta); return; } // Treppe: ganzen Antritt (+ Zentrum) verschieben. if (selectedStairId && !target) { moveStairBy(selectedStairId, delta); return; } // Raum: ganzen Umriss (+ Stempel-Anker) verschieben. if (selectedRoomId && !target) { moveRoomBy(selectedRoomId, delta); return; } // Gegriffenes Element: bei Mehrfachauswahl das unter dem Cursor (target), // sonst das einzeln selektierte. Wand UND 2D schließen sich aus. const moveDrawingId = target ? target.drawingId ?? null : selectedDrawingId; const moveWallId = target ? target.wallId ?? null : selectedWallId; moveElementByOf(moveDrawingId, moveWallId, delta); // Beim ersten Move-Event die Koinzidenz-Gruppe einmalig bestimmen (Snapshot // VOR der ersten Mutation der Siblings). Danach inkrementell mitführen. if (moveCoincidentRef.current === null) { moveCoincidentRef.current = findMoveCoincident2D(moveDrawingId, moveWallId); } for (const c of moveCoincidentRef.current) { c.pos = { x: c.pos.x + delta.x, y: c.pos.y + delta.y }; moveGripOf(c.drawingId, c.wallId, c.index, c.pos); } }, // Kanten-Griff ziehen. Zwei Verhalten: // • free (offene Polylinie/Linie, §Task B): das Segment folgt dem VOLLEN // Cursor-Delta — beide Endpunkte wandern gleich, die geteilten Ecken ziehen // die Nachbar-Segmente nach. Shift = Ortho (H/V) auf das Gesamt-Delta. // • sonst (rect/geschlossene Polylinie/Wand): NUR die Komponente senkrecht // zur Kante (Seite verschiebt sich parallel nach außen/innen), aufs Raster // gerundet — wie bisher. onEdgeMove: (edge: EdgeGrip, raw: Vec2, _pxPerMeter: number, mods: ToolMods) => { if (edge.free) { // Inkrementelles 2D-Delta gegen den letzten rohen Cursor. Erster Event // verankert nur (kein Sprung, da der Griff am Kanten-Mittelpunkt sitzt). const last = edgeFreeLastRef.current; edgeFreeLastRef.current = raw; if (last === null) return; let dx = raw.x - last.x; let dy = raw.y - last.y; // Raster-Schritt auf das Delta (Snap aktiv, nicht per Ctrl unterdrückt). const gs = snap.gridSize; if (!mods.ctrl && snap.enabled && snap.grid && gs > 0) { dx = Math.round(dx / gs) * gs; dy = Math.round(dy / gs) * gs; if (dx === 0 && dy === 0) { edgeFreeLastRef.current = last; // noch kein voller Rasterschritt → warten return; } } // Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen. if (mods.shift) { if (Math.abs(dx) >= Math.abs(dy)) dy = 0; else dx = 0; } if (dx === 0 && dy === 0) return; applyEdge(edge.aIndex, edge.bIndex, { x: dx, y: dy }); setDraft({ preview: [], vertices: [], snap: null }); return; } // Senkrechte Projektion von (raw − Grab-Mittelpunkt) auf die Außennormale. const rel = sub(raw, edge.mid); let offset = rel.x * edge.normal.x + rel.y * edge.normal.y; // Raster-Schritt (Snap) anwenden, sofern Snap+Raster aktiv und nicht per // Ctrl unterdrückt sind. const step = snap.gridSize; if (!mods.ctrl && snap.enabled && snap.grid && step > 0) { offset = Math.round(offset / step) * step; } // INKREMENTELL + Grab-Anker: moveEdge verschiebt additiv, daher nur die // Änderung seit dem letzten Event anwenden. Erster Event verankert nur // (kein Sprung beim Greifen, da das Dreieck am Mittelpunkt sitzt). const prev = edgeDragOffsetRef.current; edgeDragOffsetRef.current = offset; if (prev === null) return; const inc = offset - prev; if (inc === 0) return; applyEdge(edge.aIndex, edge.bIndex, scale(edge.normal, inc)); setDraft({ preview: [], vertices: [], snap: null }); }, onGripEnd: () => { setDraft(null); edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen edgeFreeLastRef.current = null; // freien Kanten-Drag-Anker zurücksetzen moveCoincidentRef.current = null; // Körper-Koinzidenz-Gruppe verwerfen gripDragInfoRef.current = null; // (c) Vertex-Drag-Info verwerfen setGripEdit(null); // (c) Feld-Controller schließen }, }; // Tastatur im Auswahl-Werkzeug: Ctrl/Cmd+A = alles auf der aktiven Ebene // wählen; Entf/Backspace = ALLE Selektierten (Wände + 2D-Elemente) löschen. useEffect(() => { if (activeTool !== "select") return; const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { return; } // Alles auswählen (Wände + Zeichnungen des aktiven Geschosses/der Ebene). if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) { const wallIds = project.walls .filter((w) => w.floorId === activeLevel.id) .map((w) => w.id); const drawingIds = project.drawings2d .filter((d) => d.levelId === activeLevel.id) .map((d) => d.id); const ceilingIds = (project.ceilings ?? []) .filter((c) => c.floorId === activeLevel.id) .map((c) => c.id); const stairIds = (project.stairs ?? []) .filter((s) => s.floorId === activeLevel.id) .map((s) => s.id); const roomIds = (project.rooms ?? []) .filter((r) => r.floorId === activeLevel.id) .map((r) => r.id); if ( wallIds.length || drawingIds.length || ceilingIds.length || stairIds.length || roomIds.length ) { setSelectedWallIds(wallIds); setSelectedDrawingIds(drawingIds); setSelectedCeilingIds(ceilingIds); setSelectedStairIds(stairIds); setSelectedRoomIds(roomIds); e.preventDefault(); } return; } if (e.key !== "Delete" && e.key !== "Backspace") return; if (activeTransform) return; // während einer Transformation nicht löschen if ( selectedDrawingIds.length || selectedWallIds.length || selectedCeilingIds.length || selectedOpeningIds.length || selectedStairIds.length || selectedRoomIds.length ) { const dids = new Set(selectedDrawingIds); const wids = new Set(selectedWallIds); const cids = new Set(selectedCeilingIds); const oids = new Set(selectedOpeningIds); const stids = new Set(selectedStairIds); const rmids = new Set(selectedRoomIds); 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)), stairs: (p.stairs ?? []).filter((s) => !stids.has(s.id)), rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)), doors: p.doors.filter((dr) => !wids.has(dr.hostWallId)), // Öffnungen: eigene Auswahl löschen UND alle, deren Host-Wand entfällt. openings: (p.openings ?? []).filter( (o) => !oids.has(o.id) && !wids.has(o.hostWallId), ), })); setSelectedDrawingIds([]); setSelectedWallIds([]); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); e.preventDefault(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [ activeTool, activeTransform, selectedDrawingIds, selectedWallIds, selectedCeilingIds, selectedOpeningIds, selectedStairIds, project.walls, project.drawings2d, project.ceilings, project.stairs, activeLevel.id, ]); // Split der gewählten 2D-Elemente an einem Punkt in mehrere Stücke (shared von // Ctrl+S und einem etwaigen Menü-Eintrag). Ersetzt jedes Quell-Drawing durch ≥1 // Ergebnis; neue Elemente werden ausgewählt. Wände sind ausgenommen (no-op). const runSplit = (ids: string[]) => { if (ids.length === 0) return; const idSet = new Set(ids); const newIds: string[] = []; setProject((p) => { const out: typeof p.drawings2d = []; for (const d of p.drawings2d) { if (!idSet.has(d.id)) { out.push(d); continue; } const others: PolyForm[] = []; for (const o of p.drawings2d) { if (o.id === d.id || o.levelId !== d.levelId) continue; const f = toPolyForm(o.geom); if (f) others.push(f); } const pieces = splitDrawing(d, others); for (const piece of pieces) newIds.push(piece.id); out.push(...pieces); } return { ...p, drawings2d: out }; }); setSelectedDrawingIds(newIds); setSelectedWallIds([]); }; // Ctrl/Cmd+S = Split, Ctrl/Cmd+J = Join der selektierten Drawing2D-Elemente. // Join läuft über DENSELBEN Engine-Befehl wie getipptes „join" (§Task C, ein // Pfad). Split bleibt vorerst die direkte Aktion (kein Split-Engine-Befehl // gefordert). Beide rufen preventDefault (sonst Browser-Speichern/-Lesezeichen). // Wände sind ausgenommen (no-op). Tool-unabhängig, solange eine 2D-Auswahl steht. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey)) return; const k = e.key.toLowerCase(); if (k !== "s" && k !== "j") return; const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { return; } const ids = selectedDrawingIds; if (ids.length === 0) return; // nur 2D-Auswahl bedient; Wände → no-op e.preventDefault(); if (k === "s") { runSplit(ids); return; } // Join: exakt derselbe Befehl wie „join" tippen (Sofort-Befehl, autoRun). // Nach dem Verbinden zeigt die alte Auswahl auf ersetzte IDs → aufräumen. engine.start("join"); setSelectedDrawingIds([]); setSelectedWallIds([]); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDrawingIds, engine]); // Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten // geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2 // geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; const k = e.key.toLowerCase(); if (k !== "u" && k !== "d" && k !== "i") return; const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { return; } if (selectedDrawingIds.length < 2) return; e.preventDefault(); if (k === "u") applyBoolean(unionDrawings); else if (k === "d") applyBoolean(differenceDrawings); else applyBoolean(intersectionDrawings); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDrawingIds, project.drawings2d]); // 2D-Boolean-Operation (Union / Subtract / Intersect) auf die gewählten // geschlossenen Drawing2D anwenden. Reihenfolge der Auswahl = Reihenfolge in // `selectedDrawingIds` (für Differenz: erstes Element = Basis). Ersetzt die // beteiligten Quellen durch das Ergebnis und selektiert die neuen Elemente. // Braucht ≥2 geschlossene Flächen; sonst No-op. const applyBoolean = ( op: (srcs: Drawing2D[]) => BooleanResult, ) => { const order = selectedDrawingIds; if (order.length < 2) return; const byId = new Map(project.drawings2d.map((d) => [d.id, d])); // In Auswahl-Reihenfolge (Basis zuerst) auflösen. const srcs = order .map((id) => byId.get(id)) .filter((d): d is Drawing2D => d !== undefined); const result = op(srcs); if (result.replacedIds.length === 0) return; // <2 geschlossene → No-op const replaced = new Set(result.replacedIds); setProject((p) => ({ ...p, drawings2d: [ ...p.drawings2d.filter((d) => !replaced.has(d.id)), ...result.created, ], })); setSelectedDrawingIds(result.created.map((d) => d.id)); setSelectedWallIds([]); }; // Alt+Klick im Grundriss = Segment löschen (Schere): PlanView meldet das // getroffene Drawing2D + den Modellpunkt; App ermittelt daraus die nächst- // gelegene Kante (nearestEdgeIndex) und entfernt sie. Geschlossene Form → // offene Polylinie; bleiben <2 Punkte übrig, wird das Element gelöscht. const onSegmentCut = (drawingId: string, model: Vec2) => { const src = project.drawings2d.find((d) => d.id === drawingId); if (!src) return; const edgeIndex = nearestEdgeIndex(src.geom, model); if (edgeIndex === null) return; let removed = false; setProject((p) => ({ ...p, drawings2d: p.drawings2d.flatMap((d) => { if (d.id !== drawingId) return [d]; const next = cutSegment(d, edgeIndex); if (next === null) { removed = true; return []; } return [next]; }), })); if (removed) setSelectedDrawingIds([]); }; // Transformations-Shortcuts (nur im Auswahl-Werkzeug): // m = Bewegen, s = Spiegeln, d = Drehen (startet auf der Auswahl); // während einer Transformation: u/i/o/p = Modus (move/copy/array/distribute), // Esc = abbrechen. O/P fragen die Kopienanzahl ab. useEffect(() => { if (activeTool !== "select") return; const onKey = (e: KeyboardEvent) => { const el = e.target as HTMLElement | null; if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { return; } if (e.ctrlKey || e.metaKey || e.altKey) return; if (activeTransform) { const k = e.key.toLowerCase(); if (k === "u") setActiveTransform((t) => (t ? { ...t, copyMode: "move" } : t)); else if (k === "i") setActiveTransform((t) => (t ? { ...t, copyMode: "copy" } : t)); else if (k === "o") { const n = promptCount(); if (n) setActiveTransform((t) => (t ? { ...t, copyMode: "array", count: n } : t)); } else if (k === "p") { const n = promptCount(); if (n) setActiveTransform((t) => (t ? { ...t, copyMode: "distribute", count: n } : t)); } else if (e.key === "Escape") { setActiveTransform(null); setDraft(null); } else return; e.preventDefault(); return; } const hasSel = selectedWallIds.length > 0 || !!selectedDrawingId; if (!hasSel) return; const k = e.key.toLowerCase(); // Bewegen/Spiegeln/Drehen laufen ALLE über die interaktive Transformation // (`activeTransform`) mit den U/I/O/P-Kopiermodi (Bewegen/Kopie/Reihe/ // Verteilen). Prompt + Optionen + Live-Feld speisen die Command-Line; die // Kürzel U/I/O/P wirken dort auch bei fokussiertem Eingabefeld (options.key). const op: TransformOp | null = k === "m" ? "move" : k === "s" ? "mirror" : k === "d" ? "rotate" : null; if (op) { setActiveTransform({ op, copyMode: "move", count: 1, points: [] }); setDraft(null); requestAnimationFrame(() => commandLineRef.current?.focus()); e.preventDefault(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [activeTool, activeTransform, selectedWallIds, selectedDrawingId, engine]); // ── Projekt-Mutationen (aus dem Store-Slice) ───────────────────────────── // Sämtliche immutablen Projekt-Mutationen liegen jetzt in projectSlice. App // liest sie als (referenzstabile) Store-Actions und reicht sie unverändert an // Panels/Menüs/Editoren weiter. const toggleLevelVisible = useStore((s) => s.toggleLevelVisible); const toggleCategoryVisible = useStore((s) => s.toggleCategoryVisible); const patchActiveFloor = useStore((s) => s.patchActiveFloor); const addFloor = useStore((s) => s.addFloor); const addDrawing = useStore((s) => s.addDrawing); const addDrawingLevel = useStore((s) => s.addDrawingLevel); const importDrawings = useStore((s) => s.importDrawings); const addCategory = useStore((s) => s.addCategory); const patchCategory = useStore((s) => s.patchCategory); const addSubCategory = useStore((s) => s.addSubCategory); const duplicateCategory = useStore((s) => s.duplicateCategory); const deleteCategory = useStore((s) => s.deleteCategory); const assignWallCategory = useStore((s) => s.assignWallCategory); const snapshotLayerVisibility = useStore((s) => s.snapshotLayerVisibility); const applyLayerVisibility = useStore((s) => s.applyLayerVisibility); const snapshotDrawingVisibility = useStore((s) => s.snapshotDrawingVisibility); const applyDrawingVisibility = useStore((s) => s.applyDrawingVisibility); const patchLevel = useStore((s) => s.patchLevel); const duplicateLevel = useStore((s) => s.duplicateLevel); const deleteLevel = useStore((s) => s.deleteLevel); const patchComponent = useStore((s) => s.patchComponent); const addComponent = useStore((s) => s.addComponent); const deleteComponent = useStore((s) => s.deleteComponent); const patchHatch = useStore((s) => s.patchHatch); const addHatch = useStore((s) => s.addHatch); const deleteHatch = useStore((s) => s.deleteHatch); const patchLineStyle = useStore((s) => s.patchLineStyle); const addLineStyle = useStore((s) => s.addLineStyle); const deleteLineStyle = useStore((s) => s.deleteLineStyle); const importLineStyles = useStore((s) => s.importLineStyles); const importHatches = useStore((s) => s.importHatches); // Site-/Kontext-Schicht (siteSlice): Import/Entfernen/Gelände. const addContextObjects = useStore((s) => s.addContextObjects); const removeContextObject = useStore((s) => s.removeContextObject); const generateTerrain = useStore((s) => s.generateTerrain); const moveGripOf = useStore((s) => s.moveGripOf); const moveElementByOf = useStore((s) => s.moveElementByOf); const moveEdgeOf = useStore((s) => s.moveEdgeOf); const commitTransformOn = useStore((s) => s.commitTransformOn); const setElementColor = useStore((s) => s.setElementColor); const setElementWeight = useStore((s) => s.setElementWeight); const setElementFill = useStore((s) => s.setElementFill); const setElementFillColor = useStore((s) => s.setElementFillColor); const resizeElement = useStore((s) => s.resizeElement); const updateWall = useStore((s) => s.updateWall); const setWallThickness = useStore((s) => s.setWallThickness); const updateOpening = useStore((s) => s.updateOpening); const moveCeilingGrip = useStore((s) => s.moveCeilingGrip); const moveCeilingBy = useStore((s) => s.moveCeilingBy); const moveCeilingEdge = useStore((s) => s.moveCeilingEdge); const updateCeiling = useStore((s) => s.updateCeiling); const setCeilingThickness = useStore((s) => s.setCeilingThickness); const updateStair = useStore((s) => s.updateStair); const moveStairBy = useStore((s) => s.moveStairBy); const updateRoom = useStore((s) => s.updateRoom); const moveRoomBy = useStore((s) => s.moveRoomBy); const moveRoomGrip = useStore((s) => s.moveRoomGrip); const moveRoomEdge = useStore((s) => s.moveRoomEdge); const moveRoomStamp = useStore((s) => s.moveRoomStamp); const setRoomStampDoc = useStore((s) => s.setRoomStampDoc); const setRoomStamp = useStore((s) => s.setRoomStamp); // Text-Ziel der Oberleisten-Text-Gruppe: der Stempel-Text des (einzeln) // gewählten Raums. Ohne aktive Editor-Auswahl wirkt Formatierung auf das ganze // Doc (range: null). Ist kein Raum gewählt → null (Controls setzen nur // Defaults). Der Setter schreibt live in den Store. const textTarget = useMemo( () => selectedRoom ? { doc: selectedRoom.stampDoc ?? docFromText(selectedRoom.name), range: null, apply: (doc: RichTextDoc) => setRoomStampDoc(selectedRoom.id, doc), } : null, [selectedRoom, setRoomStampDoc], ); // ── 3D-Editier-Griffe (direkt im Viewport3D) ────────────────────────────── // Genau EINE gewählte Wand (kein 2D-Element) bzw. genau EIN 2D-Element liefert // die Quelle der 3D-Griffe. Dieselben Store-Actions wie bei den Plan-Griffen // (moveGripOf/moveElementByOf), damit Plan + 3D identisch mutieren. Der Höhen- // Griff bindet die OK absolut (top=custom z) — gleicher Pfad wie das Attribut- // Panel (updateWall top). // Alle gewählten Wände/2D-Elemente als Quelle der 3D-Griffe. Wände UND 2D- // Elemente schließen sich gegenseitig aus (entweder-oder), damit der einzelne // Element-Editierpfad (moveGripOf je ID) eindeutig bleibt. Bei Mehrfachauswahl // erscheinen Griffe für ALLE — Basis für das gemeinsame Ziehen koinzidenter Ecken. const edit3dWalls = useMemo( () => selectedDrawingIds.length === 0 ? selectedWallIds .map((id) => project.walls.find((w) => w.id === id)) .filter((w): w is Wall => !!w) : [], [selectedWallIds, selectedDrawingIds, project.walls], ); const edit3dDrawings = useMemo( () => selectedWallIds.length === 0 ? selectedDrawingIds .map((id) => project.drawings2d.find((d) => d.id === id)) .filter((d): d is Drawing2D => !!d) : [], [selectedWallIds, selectedDrawingIds, project.drawings2d], ); // Snap beim 3D-Vertex-Drag: dieselbe computeSnap-Logik wie die Plan-Griffe. // Das/die gerade GEZOGENEN Element(e) werden aus den Snap-Quellen genommen // (sonst fängt der Punkt an sich selbst). `excludeWallIds`/`excludeDrawingIds` // sind die Mitglieder der Koinzidenz-Gruppe (alle, die mitgeführt werden). const snapFor3dVertex = ( raw: Vec2, excludeWallIds: Set, excludeDrawingIds: Set, ): SnapResult | null => { const snapProject: Project = { ...project, walls: project.walls.filter((w) => !excludeWallIds.has(w.id)), drawings2d: project.drawings2d.filter((d) => !excludeDrawingIds.has(d.id)), }; return computeSnap({ raw, project: snapProject, levelId: activeLevel.id, visibleCodes, settings: snap, draftPoints: [], lastPoint: null, // 3D hat kein echtes pxPerMeter; ein vernünftiger Fallback hält die // Snap-Toleranz (tolerancePx/pxPerMeter) in einem brauchbaren Meter-Bereich. pxPerMeter: 90, shift: false, ctrl: false, }); }; // Ein 3D-Vertex-Griff wurde gezogen. `target` benennt das konkrete Element // (Wand-ID ODER Drawing-ID), `index` den Vertex. Liefert den effektiv // angewandten (gesnappten) Punkt zurück — der 3D-Viewport nutzt ihn, um // koinzidente Endpunkte anderer gewählter Elemente mitzuführen und den // Snap-Marker zu zeigen. `snapped=false` (für mitgeführte Geschwister) wendet // den Punkt direkt an, ohne erneut zu snappen. const onEdit3dVertex = ( target: { wallId?: string; drawingId?: string }, index: number, model: Vec2, excludeWallIds?: Set, excludeDrawingIds?: Set, ): Vec2 => { let pt = model; if (excludeWallIds || excludeDrawingIds) { const snapResult = snapFor3dVertex( model, excludeWallIds ?? new Set(), excludeDrawingIds ?? new Set(), ); pt = snapResult?.point ?? model; } moveGripOf(target.drawingId ?? null, target.wallId ?? null, index, pt); return pt; }; const onEdit3dBody = (target: { wallId?: string; drawingId?: string }, delta: Vec2) => { moveElementByOf(target.drawingId ?? null, target.wallId ?? null, delta); }; const onEdit3dWallTop = (wallId: string, topZ: number) => { updateWall(wallId, { top: { mode: "custom", z: topZ } }); }; const onEdit3dEnd = () => { setDraft(null); }; // ── Selektions-Attribut-Kontrakt (Attributes-/Object-Info-Paletten) ─────── // Normalisierte, effektiv aufgelöste Sicht auf das erste selektierte Element. const selection = useMemo( () => deriveSelection( project, selectedWallIds, selectedDrawingId, selectedCeilingId, selectedOpeningId, selectedStairId, selectedRoomId, ), [project, selectedWallIds, selectedDrawingId, selectedCeilingId, selectedOpeningId, selectedStairId, selectedRoomId], ); // ── 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 // münden in dasselbe {meshes, contours}-Format und denselben Dialog-Fluss. // Parse-Fehler werden ruhig abgefangen: DWG fällt auf den ODA-Hinweis zurück. const openImportForFile = (file: File) => { const isDwg = /\.dwg$/i.test(file.name); if (isDwg) { // Async-Zustand: Dialog öffnet sofort und zeigt „DWG wird gelesen …", // während WASM lädt/parst. Danach echte Zusammenfassung ODER ODA-Hinweis. setImportState({ fileName: file.name, isDwg: true, parsed: null, loading: true, }); const r = new FileReader(); r.onload = () => { const buf = r.result; if (!(buf instanceof ArrayBuffer)) { setImportState({ fileName: file.name, isDwg: true, parsed: null, loading: false, }); return; } // Dynamisch importieren, damit LibreDWG-WASM nicht im Initial-Bundle ist. import("./io/dwgParser") .then(({ parseDwg }) => parseDwg(buf)) .then((parsed) => { setImportState({ fileName: file.name, isDwg: false, parsed, loading: false, }); }) .catch((err) => { console.error("DWG-Import fehlgeschlagen:", err); // Fallback: weiterhin DWG-Modus → Dialog zeigt den ODA-Hinweis. setImportState({ fileName: file.name, isDwg: true, parsed: null, loading: false, }); }); }; r.readAsArrayBuffer(file); return; } const r = new FileReader(); r.onload = () => { let parsed: DxfImportResult | null = null; try { parsed = parseDxf(String(r.result ?? "")); } catch (err) { console.error("DXF-Import fehlgeschlagen:", err); parsed = { meshes: [], contours: [] }; } setImportState({ fileName: file.name, isDwg: false, parsed }); }; r.readAsText(file); }; // Bestätigte Import-Entscheidung ausführen: ggf. neue Zeichnung anlegen, die // 2D-Konturen als Drawing2D anhängen, optional die Meshes als Kontext // übernehmen. Reihenfolge: erst Ebene (echte ID), dann Drawings/Kontext. const runImport = (decision: ImportDecision) => { const levelId = decision.target.kind === "new" ? addDrawingLevel(decision.target.name) : decision.target.levelId; const drawings = buildDrawings( decision, levelId, project, activeCategoryCode, ); if (drawings.length) importDrawings(drawings); if (decision.importMeshes && decision.parsed.meshes.length) { addContextObjects(decision.parsed.meshes); } // Auf die Ziel-Ebene wechseln, damit die importierte Geometrie sichtbar ist. setActiveLevelId(levelId); setImportState(null); }; // ── Host-Context für die Panels ─────────────────────────────────────────── // Alles außer dem (dock-spezifischen) Darstellungsmodus ist gemeinsam; den // Modus hängt jedes Dock weiter unten passend zu seinem aktiven Tab an. const baseHost = useMemo(() => { const value: Omit = { project, activeLevelId, activeTool, onSelectTool, // Werkzeuge auch in der Perspektive: der Cursor wird per Raycast auf die // Geschoss-Arbeitsebene (OKFF) projiziert und über onWorkplanePoint in // dieselbe Engine wie im Grundriss gespeist (Viewport3D). toolsEnabled: activeLevel.kind === "floor", activeWallTypeId, onActiveWallTypeId: setActiveWallTypeId, snap, onSnapChange: setSnap, onSelectLevel: setActiveLevelId, onToggleLevel: toggleLevelVisible, onAddFloor: addFloor, onAddDrawing: addDrawing, onLevelContextMenu: (id, x, y) => setMenu({ kind: "level", id, x, y }), activeCategoryCode, onSelectCategory: setActiveCategoryCode, onToggleCategory: toggleCategoryVisible, onAddCategory: addCategory, onLayerContextMenu: (code, x, y) => setMenu({ kind: "layer", code, x, y }), onPatchComponent: patchComponent, onAddComponent: addComponent, onDeleteComponent: deleteComponent, onPatchHatch: patchHatch, onAddHatch: addHatch, onDeleteHatch: deleteHatch, onPatchLineStyle: patchLineStyle, onAddLineStyle: addLineStyle, onDeleteLineStyle: deleteLineStyle, onImportLineStyles: importLineStyles, onImportHatches: importHatches, selection, onSetSelectionColor: (color) => { if (!selection) return; if (selection.kind === "wall" || selection.kind === "drawing2d") setElementColor(selection.kind, selection.id, color); else if (selection.kind === "ceiling") updateCeiling(selection.id, { color }); else if (selection.kind === "opening") updateOpening(selection.id, { color }); else if (selection.kind === "stair") updateStair(selection.id, { color }); else if (selection.kind === "room") updateRoom(selection.id, { color }); }, onSetSelectionWeight: (weightMm) => { // Strichstärke gibt es als direktes Feld nur bei Drawing2D. if (selection?.kind === "drawing2d") setElementWeight(selection.id, weightMm); }, onSetSelectionFill: (hatchId) => { // Füllschraffur nur bei Drawing2D (geschlossene Formen). if (selection?.kind === "drawing2d") setElementFill(selection.id, hatchId); }, onSetSelectionFillColor: (color) => { // Vollton-Füllfarbe nur bei Drawing2D (geschlossene Formen). if (selection?.kind === "drawing2d") setElementFillColor(selection.id, color); }, onResizeSelection: (w, h, anchor) => { if (selection?.kind === "wall" || selection?.kind === "drawing2d") resizeElement(selection.kind, selection.id, w, h, anchor); }, // ── Wand-Attribute (nur bei selektierter Wand) ───────────────────────── onSetWallReferenceLine: (ref) => { if (selection?.kind === "wall") updateWall(selection.id, { referenceLine: ref }); }, onSetWallType: (wallTypeId) => { if (selection?.kind === "wall") updateWall(selection.id, { wallTypeId }); }, onSetWallThickness: (thickness) => { if (selection?.kind === "wall") setWallThickness(selection.id, thickness); }, onSetWallBottom: (anchor) => { if (selection?.kind === "wall") updateWall(selection.id, { bottom: anchor ?? undefined }); }, onSetWallTop: (anchor) => { if (selection?.kind === "wall") updateWall(selection.id, { top: anchor ?? undefined }); }, // ── Decken-Attribute (nur bei selektierter Decke) ────────────────────── onSetCeilingType: (wallTypeId) => { if (selection?.kind === "ceiling") updateCeiling(selection.id, { wallTypeId, thickness: undefined }); }, onSetCeilingThickness: (thickness) => { if (selection?.kind === "ceiling") setCeilingThickness(selection.id, thickness); }, onSetCeilingTop: (anchor) => { if (selection?.kind === "ceiling") updateCeiling(selection.id, { top: anchor ?? undefined }); }, // ── Öffnungs-Attribute (nur bei selektierter Öffnung) ────────────────── onSetOpeningKind: (kind) => { if (selection?.kind === "opening") updateOpening( selection.id, kind === "door" ? { kind, sillHeight: 0, hinge: "start", swing: "left", openingDir: "in", swingAngle: 90 } : { kind }, ); }, onSetOpeningWidth: (width) => { if (selection?.kind === "opening" && width > 0) updateOpening(selection.id, { width }); }, onSetOpeningHeight: (height) => { if (selection?.kind === "opening" && height > 0) updateOpening(selection.id, { height }); }, onSetOpeningSill: (sillHeight) => { if (selection?.kind === "opening" && sillHeight >= 0) updateOpening(selection.id, { sillHeight }); }, onSetOpeningPosition: (position) => { if (selection?.kind === "opening" && position >= 0) updateOpening(selection.id, { position }); }, onSetOpeningSwingAngle: (swingAngle) => { if (selection?.kind === "opening" && swingAngle > 0) updateOpening(selection.id, { swingAngle }); }, onSetOpeningHinge: (hinge) => { if (selection?.kind === "opening") updateOpening(selection.id, { hinge }); }, onSetOpeningSwing: (swing) => { if (selection?.kind === "opening") updateOpening(selection.id, { swing }); }, onSetOpeningDir: (openingDir) => { if (selection?.kind === "opening") updateOpening(selection.id, { openingDir }); }, // ── Treppen-Attribute (nur bei selektierter Treppe) ──────────────────── onSetStairShape: (shape) => { if (selection?.kind === "stair") updateStair(selection.id, { shape }); }, onSetStairWidth: (width) => { if (selection?.kind === "stair" && width > 0) updateStair(selection.id, { width }); }, onSetStairSteps: (stepCount) => { if (selection?.kind === "stair" && stepCount >= 2) updateStair(selection.id, { stepCount: Math.round(stepCount) }); }, onSetStairRise: (totalRise) => { if (selection?.kind === "stair" && totalRise > 0) updateStair(selection.id, { totalRise }); }, onSetStairUp: (up) => { if (selection?.kind === "stair") updateStair(selection.id, { up }); }, // ── Raum-Attribute (nur bei selektiertem Raum) ───────────────────────── onSetRoomName: (name) => { if (selection?.kind === "room") updateRoom(selection.id, { name }); }, onSetRoomSia: (category) => { if (selection?.kind === "room") updateRoom(selection.id, { siaCategory: category }); }, onEditRoomStamp: (roomId) => onOpenStampEditor(roomId), // ── Site-/Kontext-Schicht ────────────────────────────────────────────── contextObjects: project.context ?? [], onAddContextObjects: addContextObjects, onRemoveContextObject: removeContextObject, onGenerateTerrain: generateTerrain, onImportDxf: () => dxfInputRef.current?.click(), }; return value; // setProject-Closures sind stabil genug; Neuberechnung bei project/active. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ project, activeLevelId, activeCategoryCode, activeTool, activeWallTypeId, snap, viewType, selection, ]); // ── Dock-/Float-Handler (aus dem Layout-Slice) ──────────────────────────── // Die Layout-Mutationen (Resize/Reorder/Stapeln/Lösen/Float) liegen jetzt im // layoutSlice. App liest sie als Store-Actions und verdrahtet sie unverändert // mit dem (transienten) Drag-Controller und den Panels. const resizeDock = useStore((s) => s.resizeDock); const resizeGroups = useStore((s) => s.resizeGroups); const activateTab = useStore((s) => s.activateTab); const joinGroup = useStore((s) => s.joinGroup); const newGroup = useStore((s) => s.newGroup); const dropFloat = useStore((s) => s.dropFloat); const focusFloat = useStore((s) => s.focusFloat); const moveFloat = useStore((s) => s.moveFloat); const resizeFloat = useStore((s) => s.resizeFloat); const closeFloat = useStore((s) => s.closeFloat); const redockFloat = useStore((s) => s.redockFloat); const dockFloatTo = useStore((s) => s.dockFloatTo); // ── Tab-Drag-Steuerung (Reorder / Einreihen / Stapeln / Lösen) ──────────── // Der Controller (usePanelDrag) klärt anhand der Bewegung Klick vs. Ziehen // und löst beim Loslassen das Ziel auf; die Auflösung selbst macht der Slice. const { drag, startTabDrag } = usePanelDrag({ onActivate: activateTab, onJoinGroup: joinGroup, onNewGroup: newGroup, onDropFloat: dropFloat, }); // Zustand für die Rand-Zonen-Hervorhebung beim Titel-Drag eines Fensters. // `floatDragging` schaltet die Zonen ein (sobald die Titelleiste gezogen // wird), `floatDragHover` markiert die gerade überfahrene Zone. const [floatDragging, setFloatDragging] = useState(false); const [floatDragHover, setFloatDragHover] = useState(null); // Titel-Drag eines schwebenden Fensters über eine Rand-Zone: liefert true, // wenn das Fenster angedockt wurde (dann unterbleibt das reine Verschieben). // Trefferprüfung über die Datenattribute der Rand-Zonen (data-drop-zone). const onFloatTitleDrop = (panelId: string, clientX: number, clientY: number): boolean => { setFloatDragging(false); setFloatDragHover(null); const zone = document .elementsFromPoint(clientX, clientY) .find((el) => (el as HTMLElement).dataset?.dropZone !== undefined) as | HTMLElement | undefined; if (!zone) return false; const dock = zone.dataset.dropZone as DockId; dockFloatTo(panelId, dock); return true; }; const onFloatTitleDrag = (_panelId: string, clientX: number, clientY: number) => { if (!floatDragging) setFloatDragging(true); const zone = document .elementsFromPoint(clientX, clientY) .find((el) => (el as HTMLElement).dataset?.dropZone !== undefined) as | HTMLElement | undefined; setFloatDragHover(zone ? (zone.dataset.dropZone as DockId) : null); }; // Ein Dock wird nur gerendert, wenn es Tabs hat — ein leeres Dock soll die // Mitte nicht stauchen. (Rechtes Dock ist standardmäßig leer: Ressourcen ist // ein schwebendes Fenster.) const leftHasTabs = layout.left.groups.length > 0; const rightHasTabs = layout.right.groups.length > 0; // Rand-Andock-Zonen sind aktiv, solange irgendeine Drag-Geste läuft (Tab-Drag // ODER Titel-Drag eines schwebenden Fensters). Die gerade überfahrene Zone // (für die Hervorhebung) kommt aus dem Tab-Drag-Hover bzw. dem Float-Hover. const dragActive = drag !== null || floatDragging; const activeZone: DockId | null = drag && drag.hover.kind === "zone" ? drag.hover.dock : floatDragHover; // Handler-Bündel für das schwebende Ressourcen-Fenster (ResourceManager). const resourceHandlers: ResourceManagerHandlers = { onPatchComponent: patchComponent, onAddComponent: addComponent, onDeleteComponent: deleteComponent, onPatchHatch: patchHatch, onAddHatch: addHatch, onDeleteHatch: deleteHatch, onPatchLineStyle: patchLineStyle, onAddLineStyle: addLineStyle, onDeleteLineStyle: deleteLineStyle, onImportLineStyles: importLineStyles, onImportHatches: importHatches, }; // ── Effektive Darstellung für die Hauptansicht ──────────────────────────── // 3D: Geschosse nach dem Modus des Zeichnungsebenen-Panels, Kategorien nach // dem Modus des Ebenen-Panels. Grundriss: Kategorien nach dem Ebenen-Panel. // Aus der abonnierten Map lesen (nicht über modeFor), damit ein Moduswechsel // garantiert ein Re-Render auslöst und die Resolver neu rechnen. const drawingLevelsMode = displayModes[BUILTIN_PANEL_IDS.drawingLevels] ?? "all"; const layersMode = displayModes[BUILTIN_PANEL_IDS.layers] ?? "all"; // Sichtbarkeit je Kategorie-Code (für categoryDisplay), Baum berücksichtigt. const codeVisible = useMemo(() => { const map = new Map(); for (const c of flattenCategories(project.layers)) map.set(c.code, c.visible); return map; }, [project.layers]); // Resolver: je Geschoss-ID die Render-Entscheidung (aktiv = Arbeitsfokus). // Memoisiert, damit Plan/3D nicht bei jedem Render neu rechnen. const floorDisplay: DisplayResolver = useMemo(() => { const visible = new Map( project.drawingLevels.map((z) => [z.id, z.visible]), ); return (id) => itemDisplay(drawingLevelsMode, id === activeLevelId, visible.get(id) ?? false); }, [project.drawingLevels, drawingLevelsMode, activeLevelId]); // Resolver: je Kategorie-Code die Render-Entscheidung (Kategorien sind nie // „aktiv"). Wirkt in Grundriss UND 3D. const categoryDisplay: CategoryDisplayResolver & DisplayResolver = useMemo( () => (code) => itemDisplay(layersMode, false, codeVisible.get(code) ?? false), [layersMode, codeVisible], ); // Stabiler Schlüssel der 3D-Display-Entscheidungen (Resolver sind nicht // referenzstabil → triggert sonst keinen Neuaufbau). const displayKey = `${drawingLevelsMode}|${layersMode}|${activeLevelId}`; // ── Ableitungen für Oberleiste + Status-Leiste ──────────────────────────── // Eine zoom-/cursor-fähige Plan-Ansicht ist nur im Grundriss eines Geschosses // aktiv. Sonst sind Cursor/Zoom bedeutungslos (PlanView ist nicht montiert → // das Handle ist null, Aktionen sind No-Ops). const planActive = activeLevel.kind === "floor" && viewType === "grundriss"; // Übernimmt ein Zeichenwerkzeug ODER eine laufende Transformation die linke // Maustaste der Plan-Ansicht? Dann liegt der Pointer-Input beim Tool/Transform // (statt Auswahl/Marquee), und die Editier-Griffe sind ausgeblendet. // Ein laufender Befehl übernimmt ebenfalls die linke Maustaste der Plan-Ansicht // (über denselben toolHandlers-Pfad) — daher hier mit einbeziehen. const toolInputActive = activeTool !== "select" || !!activeTransform || commandActive; // Tatsächlich (noch) existierende gewählte Wände — IDs, die es nicht mehr gibt // (z. B. nach Löschen/Geschosswechsel), fallen heraus. const selectedWalls = useMemo(() => { const ids = new Set(selectedWallIds); return project.walls.filter((w) => ids.has(w.id)); }, [project.walls, selectedWallIds]); const selectionCount = selectedWalls.length; // Erste gewählte Wand (für Status-Leiste / Kontextmenü-Aktionen mit Einzelbezug). const selectedWall = selectedWalls[0] ?? null; // Aktive Ebene (Kategorie) der Status-Leiste: die Kategorie der (ersten) // gewählten Wand, sonst „—" (es gibt keine eigenständige Kategorie-Selektion). const activeLayerName = selectionCount === 1 && selectedWall ? categoryLabel(project.layers, selectedWall.categoryCode) : categoryLabel(project.layers, activeCategoryCode); // Kontextueller Werkzeug-Hinweis (links). Zeigt die Anzahl gewählter Wände: // 0 → „Auswahl", 1 → „Auswahl: 1 Wand", n → „Auswahl: n Wände". const toolHint = selectionCount === 0 ? t("status.selection") : t("status.selectionN", { n: selectionCount, noun: selectionCount === 1 ? t("status.wall") : t("status.walls"), }); // Solange keine Plan-Ansicht montiert ist, gibt es keinen Live-Massstab. // (PlanView meldet beim Montieren/Zoomen sofort wieder einen Wert.) useEffect(() => { if (!planActive) setLiveScale(null); }, [planActive]); // Zoom-Buttons der Oberleiste an die montierte Plan-Ansicht weiterreichen. const fitPlan = () => planViewRef.current?.fit(); const fitSelection = () => planViewRef.current?.fitSelection(); // Papier-Massstab anwenden: gewählten Nenner merken UND den Plan-Zoom exakt // auf 1:N setzen (PlanView rechnet die viewBox aus N + Canvas + dpi). const applyScale = (n: number) => { setScaleDenominator(n); planViewRef.current?.applyScale(n); }; // „100 %" = Zoom exakt auf den gewählten Massstab setzen. const zoomToApplied = () => planViewRef.current?.applyScale(scaleDenominator); // Massstäbliche Inhalts-Maße (Meter) des aktuellen Plans — nur berechnet, wenn // der Export-Dialog offen ist (für die „passt aufs Blatt?"-Warnung). const exportContentMeters = useMemo(() => { if (!exportOpen) return undefined; const b = generatePlan( project, activeLevel.id, visibleCodes, categoryDisplay, detail, referenceLines, planColorMode === "mono", ).bounds; return { w: b.maxX - b.minX, h: b.maxY - b.minY }; }, [ exportOpen, project, activeLevel.id, visibleCodes, categoryDisplay, detail, referenceLines, planColorMode, ]); // PDF-Export: erzeugt den Plan mit EXAKT denselben Eingaben wie die Ansicht // (Geschoss, sichtbare Codes, Darstellungsmodus, Detailgrad, Referenzlinien, // Farb-/Mono-Modus) und schreibt ihn massstäblich als Vektor-PDF. Reines // Modell → SVG → PDF, unabhängig vom Bildschirm-DOM. const onExportPdf = async (decision: ExportPdfDecision) => { const plan = generatePlan( project, activeLevel.id, visibleCodes, categoryDisplay, detail, referenceLines, planColorMode === "mono", ); await savePlanPdf(plan, { scaleDenominator: decision.scaleDenominator, paper: decision.paper, orientation: decision.orientation, title: activeLevel.name, subtitle: project.name, fileName: `${project.name}-${activeLevel.name}`, }); setExportOpen(false); }; // DXF-Export: erzeugt den Plan mit EXAKT denselben Eingaben wie die Ansicht und // schreibt ihn als Modell-Space-DXF in echten Metern (1:1). Layer aus den // Kategorie-Ebenen; jede Entity auf ihrem Kategorie-Layer. const onExportDxf = (decision: ExportDxfDecision) => { const plan = generatePlan( project, activeLevel.id, visibleCodes, categoryDisplay, detail, referenceLines, planColorMode === "mono", ); savePlanDxf(plan, project, { title: activeLevel.name, includeHatches: decision.includeHatches, fileName: `${project.name}-${activeLevel.name}`, }); setExportDxfOpen(false); }; // Modusleiste-Wechsel: array/distribute fragen die Kopienanzahl ab. const onTransformMode = (mode: CopyMode) => { if (mode === "array" || mode === "distribute") { const n = promptCount(); if (n) setActiveTransform((tr) => (tr ? { ...tr, copyMode: mode, count: n } : tr)); } else { setActiveTransform((tr) => (tr ? { ...tr, copyMode: mode } : tr)); } }; // ── Transformation in der Befehlszeile ──────────────────────────────────── // Die laufende Transformation speist Prompt + U/I/O/P-Optionen + ein Live-Feld // (Winkel beim Drehen, Distanz beim Bewegen) in die Command-Line — statt einer // eigenen schwebenden Leiste. Rein die UI-Fläche; Geometrie unverändert. const TRANSFORM_MODES: { id: CopyMode; labelKey: string; key: string }[] = [ { id: "move", labelKey: "transform.mode.move", key: "U" }, { id: "copy", labelKey: "transform.mode.copy", key: "I" }, { id: "array", labelKey: "transform.mode.array", key: "O" }, { id: "distribute", labelKey: "transform.mode.distribute", key: "P" }, ]; const transformOptions = (): CommandLineOption[] => { const tr = activeTransform; if (!tr) return []; return TRANSFORM_MODES.map((m) => ({ id: m.id, labelKey: m.labelKey, key: m.key, active: tr.copyMode === m.id, // ×count nur beim aktiven Reihen-/Verteilungs-Modus. value: (m.id === "array" || m.id === "distribute") && tr.copyMode === m.id ? `×${tr.count}` : undefined, })); }; // Live-/lockbares Feld: Winkel (Drehen, ab Zentrum+Bezug) bzw. Distanz // (Bewegen, ab Basispunkt). Ohne Bezugspunkt kein sinnvolles Feld → []. const transformFields = (): CommandLineField[] => { const tr = activeTransform; const info = transformDragInfoRef.current; if (!tr || !info) return []; const cursor = info.cursor; if (tr.op === "rotate") { if (tr.points.length !== 2) return []; const c = tr.points[0], refP = tr.points[1]; const a0 = Math.atan2(refP.y - c.y, refP.x - c.x); const a1 = Math.atan2(cursor.y - c.y, cursor.x - c.x); const deg = ((((a1 - a0) * 180) / Math.PI) % 360 + 360) % 360; return [ { id: "angle", labelKey: "cmd.field.angle", value: deg, locked: false, active: true }, ]; } // Bewegen: Distanz ab Basispunkt. Spiegeln hat kein sinnvolles Zahlenfeld // (freie Achse) → keine Felder. if (tr.op !== "move") return []; if (tr.points.length !== 1) return []; const base = tr.points[0]; const dist = Math.hypot(cursor.x - base.x, cursor.y - base.y); return [ { id: "distance", labelKey: "cmd.field.distance", value: dist, locked: false, active: true }, ]; }; // Getippte Zahl in der Transformation: als Wert des aktiven Feldes deuten und // über denselben Pfad wie transformClick committen. const submitTransformValue = (value: number) => { const tr = activeTransform; const info = transformDragInfoRef.current; if (!tr || !info) return; if (tr.op === "rotate") { if (tr.points.length !== 2) return; const c = tr.points[0], refP = tr.points[1]; const a0 = Math.atan2(refP.y - c.y, refP.x - c.x); const a = a0 + (value * Math.PI) / 180; const r = Math.hypot(refP.x - c.x, refP.y - c.y) || 1; const target = { x: c.x + Math.cos(a) * r, y: c.y + Math.sin(a) * r }; const points = [...tr.points, target]; commitTransformOn(transformSel, tr.op, points, tr.copyMode, tr.count); } else if (tr.op === "move") { if (tr.points.length !== 1) return; // Distanz entlang der aktuellen Live-Richtung locken. const base = tr.points[0]; const dx = info.cursor.x - base.x, dy = info.cursor.y - base.y; const len = Math.hypot(dx, dy) || 1; const target = { x: base.x + (dx / len) * value, y: base.y + (dy / len) * value }; const points = [...tr.points, target]; commitTransformOn(transformSel, tr.op, points, tr.copyMode, tr.count); } setActiveTransform(null); setDraft(null); transformDragInfoRef.current = null; }; const cancelTransform = () => { setActiveTransform(null); setDraft(null); transformDragInfoRef.current = null; }; // ── Maus-Schema: Auswahl + Kontextmenü aus Grundriss / 3D ───────────────── // Links-Klick im Grundriss: genau eine Wand wählen (Menge ersetzen) bzw. // Leerraum → Auswahl löschen. // Doppelklick auf einen Raum-Stempel: den Raum wählen + den Stempel-Editor // öffnen (Rich-Text-Popup). const onOpenStampEditor = (roomId: string) => { setSelectedRoomIds([roomId]); setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setStampEditorRoomId(roomId); }; const onPlanSelect = (sel: PlanSelection) => { // Shift+Klick: Mehrfach-Auswahl — getroffenes Element zu-/abschalten, den // 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) { if (sel.wallId) { const id = sel.wallId; setSelectedWallIds( selectedWallIds.includes(id) ? selectedWallIds.filter((w) => w !== id) : [...selectedWallIds, id], ); } else if (sel.drawingId) { const id = sel.drawingId; setSelectedDrawingIds( selectedDrawingIds.includes(id) ? selectedDrawingIds.filter((d) => d !== id) : [...selectedDrawingIds, id], ); } else if (sel.ceilingId) { const id = sel.ceilingId; setSelectedCeilingIds( selectedCeilingIds.includes(id) ? selectedCeilingIds.filter((c) => c !== id) : [...selectedCeilingIds, id], ); } else if (sel.openingId) { const id = sel.openingId; setSelectedOpeningIds( selectedOpeningIds.includes(id) ? selectedOpeningIds.filter((o) => o !== id) : [...selectedOpeningIds, id], ); } else if (sel.stairId) { const id = sel.stairId; setSelectedStairIds( selectedStairIds.includes(id) ? selectedStairIds.filter((s) => s !== id) : [...selectedStairIds, id], ); } else if (sel.roomId) { const id = sel.roomId; setSelectedRoomIds( selectedRoomIds.includes(id) ? selectedRoomIds.filter((r) => r !== id) : [...selectedRoomIds, 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. if (sel.openingId) { setSelectedOpeningIds([sel.openingId]); setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } else if (sel.wallId) { setSelectedWallIds([sel.wallId]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } else if (sel.drawingId) { setSelectedDrawingId(sel.drawingId); setSelectedWallIds([]); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } else if (sel.ceilingId) { setSelectedCeilingIds([sel.ceilingId]); setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } else if (sel.stairId) { setSelectedStairIds([sel.stairId]); setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedRoomIds([]); } else if (sel.roomId) { setSelectedRoomIds([sel.roomId]); setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); } else { setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } }; // Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen // (nach CAD-Konvention von PlanView bestimmt). Ersetzt die bisherige Auswahl. const onPlanMarquee = (sel: MarqueeSelection) => { setSelectedWallIds(sel.wallIds); setSelectedDrawingIds(sel.drawingIds ?? []); // Decken/Öffnungen/Treppen/Räume sind (noch) nicht Teil des Marquee → beim Aufziehen abwählen. setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); }; // 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 // Aktionen einen Bezug haben); innerhalb einer bestehenden Mehrfachauswahl // bleibt die Menge erhalten. const onPlanContextMenu = (info: { clientX: number; clientY: number; model: Vec2; }) => { const wallId = hitTestWall(project, activeLevel.id, visibleCodes, info.model); if (wallId && !selectedWallIds.includes(wallId)) setSelectedWallIds([wallId]); setMenu({ kind: "plan", x: info.clientX, y: info.clientY, wallId }); }; // Links-Klick im 3D: per Raycast getroffene Wand wählen (Einzelauswahl). const onViewportSelectWall = (wallId: string | null) => { setSelectedWallIds(wallId ? [wallId] : []); if (wallId) { setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } }; // Links-Klick im 3D: per Raycast getroffene Treppe wählen (Einzelauswahl). const onViewportSelectStair = (stairId: string | null) => { setSelectedStairIds(stairId ? [stairId] : []); if (stairId) { setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedRoomIds([]); } }; // Links-Klick im 3D: per Raycast getroffene Decke wählen (Einzelauswahl). const onViewportSelectCeiling = (ceilingId: string | null) => { setSelectedCeilingIds(ceilingId ? [ceilingId] : []); if (ceilingId) { setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedStairIds([]); setSelectedOpeningIds([]); setSelectedRoomIds([]); } }; // Links-Klick im 3D: per Raycast getroffene Öffnung wählen (Einzelauswahl). const onViewportSelectOpening = (openingId: string | null) => { setSelectedOpeningIds(openingId ? [openingId] : []); if (openingId) { setSelectedWallIds([]); setSelectedDrawingId(null); setSelectedCeilingIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); } }; // Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition. const onViewportContextMenu = (info: ViewportContextInfo) => { if (info.wallId && !selectedWallIds.includes(info.wallId)) { setSelectedWallIds([info.wallId]); } setMenu({ kind: "viewport", x: info.clientX, y: info.clientY, wallId: info.wallId, }); }; // ── App-weites Drag & Drop einer DXF/DWG-Datei ──────────────────────────── // dragover muss preventDefault rufen, sonst lässt der Browser nicht fallen. // dragenter/leave wird ausgezählt (Kind-Elemente lösen sonst Flackern aus). const dragDepthRef = useRef(0); const hasFiles = (e: React.DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files"); const onAppDragEnter = (e: React.DragEvent) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepthRef.current += 1; setDropActive(true); }; const onAppDragOver = (e: React.DragEvent) => { if (!hasFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }; const onAppDragLeave = (e: React.DragEvent) => { if (!hasFiles(e)) return; dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setDropActive(false); }; const onAppDrop = (e: React.DragEvent) => { if (!hasFiles(e)) return; e.preventDefault(); dragDepthRef.current = 0; setDropActive(false); const f = e.dataTransfer.files?.[0]; if (f) openImportForFile(f); }; return (
{/* Verstecktes DXF-Datei-Input (Site-Panel/„import"-Befehl triggern es). */} { const f = e.target.files?.[0]; if (f) openImportForFile(f); e.target.value = ""; }} /> setExportOpen(true)} onExportDxf={() => setExportDxfOpen(true)} renderMode={renderMode} onRenderMode={setRenderMode} renderModeEnabled={viewType === "perspektive"} planColorMode={planColorMode} onPlanColorMode={setPlanColorMode} referenceLines={referenceLines} onReferenceLines={setReferenceLines} referenceLinesEnabled={planActive} lineMode={lineMode} onLineMode={setLineMode} lineModeEnabled={planActive} combos={{ // Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums → // localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden. listLayerCombos, saveLayerCombo: (name) => saveLayerCombo(name, snapshotLayerVisibility()), loadLayerCombo: (name) => { const codes = loadLayerCombo(name); if (codes) applyLayerVisibility(codes); }, deleteLayerCombo, // Zeichnungs-Kombinationen: analog über drawingLevels[].visible. listDrawingCombos, saveDrawingCombo: (name) => saveDrawingCombo(name, snapshotDrawingVisibility()), loadDrawingCombo: (name) => { const ids = loadDrawingCombo(name); if (ids) applyDrawingVisibility(ids); }, deleteDrawingCombo, }} resourcesOpen={resourcesOpen} onToggleResources={() => setResourcesOpen((v) => !v)} layoutMenu={ setLayout(reconcileLayout(l))} /> } textTarget={textTarget} onAddText={null} />
{/* Linkes Dock: gestapelte Gruppen (Werkzeuge oben, Zeichnungsebenen/ Ebenen unten). Jede Gruppe baut über buildHost ihren eigenen Host-Provider, damit der Darstellungsmodus dem aktiven Tab DIESER Gruppe folgt. Nur gerendert, wenn das Dock Gruppen hat. */} {leftHasTabs && ( resizeDock("left", size)} onResizeGroups={(w) => resizeGroups("left", w)} buildHost={(activeTab) => hostWithMode(baseHost, activeTab, modeFor, setModeFor) } displayModeFor={(activeTab) => modeFor(activeTab)} onDisplayModeChange={(activeTab, m) => setModeFor(activeTab, m)} /> )} {/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan- Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt das Zoom-Handle entgegen. */} {/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes Fenster). Nur gerendert, wenn es Gruppen hat — sonst nutzt die Mitte den freigewordenen Platz. */} {rightHasTabs && ( resizeDock("right", size)} onResizeGroups={(w) => resizeGroups("right", w)} buildHost={(activeTab) => hostWithMode(baseHost, activeTab, modeFor, setModeFor) } displayModeFor={(activeTab) => modeFor(activeTab)} onDisplayModeChange={(activeTab, m) => setModeFor(activeTab, m)} /> )} {/* Rand-Andock-Zonen (links/rechts) — nur während einer Drag-Geste (Tab-Ziehen ODER Titel-Drag eines Fensters). Sie sind die Trefferflächen für „hierher andocken"; data-drop-zone wird von der Drag-Auflösung (panelDrag/onFloatTitleDrop) gelesen. */} {dragActive && ( <>
)} {/* Schwebende Panels ÜBER der Mitte. Jedes bekommt einen eigenen Host-Provider, damit sein Darstellungsmodus-Umschalter dem eigenen Panel folgt (wie bei den Docks). Verschieben/Größe/Andocken/Schließen schreiben das Layout fort (persistiert via useEffect). */} {layout.floating.map((fp) => ( setModeFor(fp.panelId, m)} onFocus={focusFloat} onMove={moveFloat} onResize={resizeFloat} onRedock={redockFloat} onClose={closeFloat} onTitleDrag={onFloatTitleDrag} onTitleDrop={onFloatTitleDrop} /> ))}
{/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */} {/* Command-Line (Rhino-artig) ÜBER der Statusleiste: Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. Tab fokussiert sie. */} { if (gripEdit) { const v = parseFloat(text.replace(",", ".")); if (isFinite(v)) submitGripEditValue(v); return; } if (activeTransform) { const v = parseFloat(text.replace(",", ".")); if (isFinite(v)) submitTransformValue(v); return; } engine.submitText(text); }} onOption={(id) => activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id) } onCycleField={() => gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField() } onCancel={() => gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel() } suggest={(prefix) => engine.suggest(prefix)} /> {/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State. Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */} {/* Schwebendes Ressourcen-Fenster ÜBER allem (hoher z-index, abgedunkelter Hintergrund). Schließt über X / Klick auf den Hintergrund / Esc. */} {resourcesOpen && ( setResourcesOpen(false)} /> )} {/* Modaler Import-Dialog (DXF/DWG) — offen, sobald eine Datei gewählt/ fallengelassen wurde. Liefert eine strukturierte Entscheidung an runImport (Ebene anlegen / Drawings anhängen / Meshes als Kontext). */} {importState && ( setImportState(null)} /> )} {/* PDF-Export-Dialog (Grundriss → Vektor-PDF). Nur im Grundriss eines Geschosses sinnvoll; der Button ist sonst deaktiviert. */} {exportOpen && ( setExportOpen(false)} /> )} {/* DXF-Export-Dialog (Grundriss → Modell-Space-DXF in Metern). */} {exportDxfOpen && ( setExportDxfOpen(false)} /> )} {/* Text-Editor (schwebendes Fenster): Doppelklick auf einen Raumstempel bzw. Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das bearbeitete Rich-Text-Dokument über setRoomStampDoc. Die Formatier- Leiste in der Oberleiste wirkt ebenfalls live auf den gewählten Raum. */} {stampEditorRoomId && (() => { const room = (project.rooms ?? []).find((r) => r.id === stampEditorRoomId); if (!room) return null; // Strukturierter Stempel für neue Räume (kein Alt-Rich-Text) bzw. sobald // einer gesetzt ist; freier Text nur noch als Fallback für Alt-Räume, // die einen stampDoc, aber keinen strukturierten Stempel tragen. const useStructured = Boolean(room.stamp) || !room.stampDoc; if (useStructured) { return ( { setRoomStamp(room.id, edited); setStampEditorRoomId(null); }} onCancel={() => setStampEditorRoomId(null)} /> ); } const doc = room.stampDoc ?? docFromText(room.name); return ( { setRoomStampDoc(room.id, edited); setStampEditorRoomId(null); }} onCancel={() => setStampEditorRoomId(null)} /> ); })()} {/* Dezenter Drop-Rahmen, solange eine Datei über die App gezogen wird. */} {dropActive && ( )} {/* Aktives Kontextmenü (genau eines). Die Einträge baut buildMenuItems je Quelle (Ebene / Zeichnungsebene / Grundriss / 3D) — alle verdrahtet bzw. klar deaktiviert mit Tooltip. */} {menu && ( setEditor({ kind: "layer", code, x: menu.x, y: menu.y }), openLevelEditor: (id) => setEditor({ kind: "level", id, x: menu.x, y: menu.y }), addSubCategory, assignWallCategory, duplicateCategory, deleteCategory, setLayerClipboard, patchCategory, duplicateLevel, deleteLevel, clearSelection: () => setSelectedWallIds([]), fit: () => planViewRef.current?.fit(), fitSelection: () => planViewRef.current?.fitSelection(), closedSelectionCount: countClosedAreas( project.drawings2d.filter((d) => selectedDrawingIds.includes(d.id), ), ), union: () => applyBoolean(unionDrawings), difference: () => applyBoolean(differenceDrawings), intersect: () => applyBoolean(intersectionDrawings), })} onClose={() => setMenu(null)} /> )} {/* Inline-Editor (Ebene bzw. Geschoss) — minimaler echter Editor, der live auf den State wirkt. Aus einem Menüpunkt geöffnet. */} {editor?.kind === "layer" && (() => { const cat = findCategory(project.layers, editor.code); if (!cat) return null; return ( setEditor(null)} > patchCategory(cat.code, { name: e.target.value }) } /> patchCategory(cat.code, { color: e.target.value }) } /> patchCategory(cat.code, { lw: Number(e.target.value) }) } /> {t("editor.unit.mm")} ); })()} {editor?.kind === "level" && (() => { const level = project.drawingLevels.find((z) => z.id === editor.id); if (!level) return null; const isFloor = level.kind === "floor"; return ( setEditor(null)} > patchLevel(level.id, { name: e.target.value })} /> {isFloor && ( <> patchLevel(level.id, { floorHeight: Number(e.target.value), }) } /> {t("editor.unit.m")} patchLevel(level.id, { cutHeight: Number(e.target.value), }) } /> {t("editor.unit.m")} )} {!isFloor && (
{level.kind === "drawing" ? t("editor.noHeightDrawing") : t("editor.noHeightSection")}
)}
); })()}
); } // ── Host-Helfer ────────────────────────────────────────────────────────────── /** * Baut den vollständigen Host-Wert für den Context, indem an den gemeinsamen * `base` der zum aktiven Tab des Docks gehörende Darstellungsmodus angehängt * wird. So liest das gerade gerenderte Panel über `usePanelHost().displayMode` * genau seinen Modus. Rückgabe als `PanelHost` (Record), das der Context * erwartet — die Verengung auf `PanelHostValue` macht `usePanelHost`. */ function hostWithMode( base: Omit, activeTab: string | null, modeFor: (id: string | null) => DisplayMode, setModeFor: (id: string | null, mode: DisplayMode) => void, ): PanelHost { const value: PanelHostValue = { ...base, displayMode: { mode: modeFor(activeTab), setMode: (mode) => setModeFor(activeTab, mode), }, }; return value as unknown as PanelHost; } // ── Wand-Trefferprüfung im Modellraum (für das Grundriss-Kontextmenü) ──────── /** * Liefert die ID der obersten Wand, deren (gehrtes) Band den Modellpunkt * enthält — gefiltert auf das aktive Geschoss + sichtbare Kategorien, in * umgekehrter Wand-Reihenfolge (zuletzt gezeichnet zuerst). Spiegelt die * Trefferlogik der PlanView, arbeitet aber direkt auf den Wand-Polygonen, da * das Grundriss-Kontextmenü nur den Modellpunkt kennt. `null` bei Leerraum. */ function hitTestWall( project: Project, floorId: string, visibleCodes: Set, m: Vec2, ): string | null { const walls = project.walls.filter( (w) => w.floorId === floorId && visibleCodes.has(w.categoryCode), ); for (let i = walls.length - 1; i >= 0; i--) { const w = walls[i]; const thickness = wallTypeThickness(getWallType(project, w)); const corners = wallCorners(w.start, w.end, thickness); if (pointInPoly(m, corners)) return w.id; } return null; } /** Punkt-in-Polygon (Ray-Casting) im Modellraum — wie in der PlanView. */ function pointInPoly(pt: Vec2, poly: Vec2[]): boolean { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i].x; const yi = poly[i].y; const xj = poly[j].x; const yj = poly[j].y; const intersect = yi > pt.y !== yj > pt.y && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } // ── Kontextmenü-Aufbau (Einträge je Quelle) ────────────────────────────────── /** Titel des Kontextmenüs je Quelle (Ebenenname/-code, Geschossname, „Wand"). */ function menuTitle(menu: NonNullable, project: Project): string { switch (menu.kind) { case "layer": { const c = findCategory(project.layers, menu.code); return c ? `${c.code} · ${c.name}` : menu.code; } case "level": { const l = project.drawingLevels.find((z) => z.id === menu.id); return l ? l.name : t("ctx.title.level"); } case "plan": case "viewport": return menu.wallId ? t("ctx.title.wall") : t("ctx.title.view"); } } /** Aktionen, die der Kontextmenü-Builder von App braucht (alle verdrahtet). */ interface MenuActions { project: Project; selectedWallId: string | null; layerClipboard: { color: string; lw: number } | null; openLayerEditor: (code: string) => void; openLevelEditor: (id: string) => void; addSubCategory: (parentCode: string) => void; assignWallCategory: (wallId: string, code: string) => void; duplicateCategory: (code: string) => void; deleteCategory: (code: string) => void; setLayerClipboard: (clip: { color: string; lw: number } | null) => void; patchCategory: (code: string, patch: Partial) => void; duplicateLevel: (id: string) => void; deleteLevel: (id: string) => void; clearSelection: () => void; fit: () => void; fitSelection: () => void; /** Anzahl geschlossener Flächen in der aktuellen 2D-Auswahl (Boolean-Aktivierung). */ closedSelectionCount: number; union: () => void; difference: () => void; intersect: () => void; } /** * Baut die Kontextmenü-Einträge je Quelle — 1:1 nach docs/design/context-menu.md. * JEDER Eintrag ist verdrahtet ODER klar deaktiviert mit Tooltip (`title`, * nennt den Grund) — kein stiller No-Op. */ function buildMenuItems( menu: NonNullable, a: MenuActions, ): ContextMenuItem[] { switch (menu.kind) { case "layer": return layerMenuItems(menu.code, a); case "level": return levelMenuItems(menu.id, a); case "plan": return planMenuItems(menu.wallId, a); case "viewport": return viewportMenuItems(menu.wallId, a); } } /** Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile). */ function layerMenuItems(code: string, a: MenuActions): ContextMenuItem[] { const total = flattenCategories(a.project.layers).length; const hasSelection = a.selectedWallId !== null; const hasClipboard = a.layerClipboard !== null; return [ { label: t("ctx.layerSettings"), icon: , onClick: () => a.openLayerEditor(code), }, { divider: true }, { label: t("ctx.addSubLayer"), icon: , onClick: () => a.addSubCategory(code), }, { label: t("ctx.assignSelection"), icon: , disabled: !hasSelection, title: hasSelection ? undefined : t("ctx.assignSelection.disabled"), onClick: () => { if (a.selectedWallId) a.assignWallCategory(a.selectedWallId, code); }, }, { divider: true }, { label: t("ctx.duplicate"), icon: , onClick: () => a.duplicateCategory(code), }, { label: t("ctx.copyProps"), icon: , onClick: () => { const c = findCategory(a.project.layers, code); if (c) a.setLayerClipboard({ color: c.color, lw: c.lw }); }, }, { label: t("ctx.pasteProps"), icon: , disabled: !hasClipboard, title: hasClipboard ? undefined : t("ctx.pasteProps.disabled"), onClick: () => { if (a.layerClipboard) a.patchCategory(code, { color: a.layerClipboard.color, lw: a.layerClipboard.lw, }); }, }, { divider: true }, { label: t("ctx.delete"), icon: , danger: true, disabled: total <= 1, title: total <= 1 ? t("ctx.delete.lastLayer") : undefined, onClick: () => a.deleteCategory(code), }, ]; } /** Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung). */ function levelMenuItems(id: string, a: MenuActions): ContextMenuItem[] { const total = a.project.drawingLevels.length; return [ { label: t("ctx.levelSettings"), icon: , onClick: () => a.openLevelEditor(id), }, { divider: true }, { label: t("ctx.duplicate"), icon: , onClick: () => a.duplicateLevel(id), }, { divider: true }, { label: t("ctx.delete"), icon: , danger: true, disabled: total <= 1, title: total <= 1 ? t("ctx.delete.lastLevel") : undefined, onClick: () => a.deleteLevel(id), }, ]; } /** * Grundriss-Kontextmenü. Bezug ist die getroffene/gewählte Wand. Einträge sind * verdrahtet: „Auf Auswahl einpassen" (PlanView-Handle) und „Auswahl aufheben". * Ohne getroffene Wand bleibt nur „Einpassen". (Eigenschaften-Panel/Bemaßung * folgen später — daher hier KEIN Schein-Eintrag.) */ function planMenuItems( wallId: string | null, a: MenuActions, ): ContextMenuItem[] { // Boolean-Einträge (Vereinigen/Subtrahieren/Schneiden) auf die 2D-Auswahl. // Aktiv ab 2 geschlossenen Flächen; sonst deaktiviert mit erklärendem Tooltip. const boolEnabled = a.closedSelectionCount >= 2; const boolTitle = boolEnabled ? undefined : t("ctx.boolean.disabled"); const booleanItems: ContextMenuItem[] = [ { label: t("ctx.union"), icon: , disabled: !boolEnabled, title: boolTitle, onClick: a.union, }, { label: t("ctx.subtract"), icon: , disabled: !boolEnabled, title: boolTitle, onClick: a.difference, }, { label: t("ctx.intersect"), icon: , disabled: !boolEnabled, title: boolTitle, onClick: a.intersect, }, ]; if (wallId) { return [ { label: t("ctx.fitSelection"), icon: , onClick: a.fitSelection, }, { label: t("ctx.clearSelection"), icon: , onClick: a.clearSelection, }, { divider: true }, ...booleanItems, { divider: true }, { label: t("ctx.fit"), icon: , onClick: a.fit, }, ]; } return [ ...booleanItems, { divider: true }, { label: t("ctx.fit"), icon: , onClick: a.fit, }, ]; } /** * 3D-Kontextmenü. Im 3D gibt es (noch) kein Einpassen-Handle wie im Grundriss; * verdrahtet ist „Auswahl aufheben" (mit getroffener Wand). Ohne Wand ist das * Menü leer-sinnvoll: ein deaktivierter Hinweis, kein stiller No-Op. */ function viewportMenuItems( wallId: string | null, a: MenuActions, ): ContextMenuItem[] { if (wallId) { return [ { label: t("ctx.clearSelection"), icon: , onClick: a.clearSelection, }, ]; } return [ { label: t("ctx.noWallHit"), disabled: true, title: t("ctx.noWallHit.hint"), }, ]; } // ── Inline-Editor (Ebene / Geschoss) ───────────────────────────────────────── /** * Kleiner schwebender Editor (dunkel, wie das Kontextmenü) am Anker. Schließt * über Außenklick / Esc. Rein darstellend: die Felder rufen die übergebenen * Patch-Handler, die live auf den State wirken (kein Bestätigen nötig). */ function InlineEditor({ x, y, title, onClose, children, }: { x: number; y: number; title: string; onClose: () => void; children: React.ReactNode; }) { const ref = useRef(null); // An die Viewport-Ränder klemmen (wie das Kontextmenü). const [pos, setPos] = useState({ x, y }); useEffect(() => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); let nx = x; let ny = y; if (nx + r.width > window.innerWidth - 4) nx = window.innerWidth - r.width - 4; if (ny + r.height > window.innerHeight - 4) ny = window.innerHeight - r.height - 4; setPos({ x: Math.max(4, nx), y: Math.max(4, ny) }); }, [x, y]); // Schließen bei Außenklick / Esc. useEffect(() => { const onDown = (e: PointerEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("pointerdown", onDown, true); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("pointerdown", onDown, true); window.removeEventListener("keydown", onKey); }; }, [onClose]); return (
{title}
{children}
); } /** Eine Editor-Zeile: Beschriftung links, Steuerelement rechts. */ function EditorField({ label, children, }: { label: string; children: React.ReactNode; }) { return ( ); } // ── Layout-Menü (Oberleiste) ───────────────────────────────────────────────── /** * Kleines Layout-Menü: aktuelles Layout unter einem Namen sichern und ein * gespeichertes Layout wählen (oder löschen). Liest die Namen frisch aus dem * localStorage, sobald das Menü geöffnet wird. */ function LayoutMenu({ layout, onApply, }: { layout: LayoutState; onApply: (layout: LayoutState) => void; }) { const [names, setNames] = useState([]); const refresh = () => setNames(listNamedLayouts()); const onSave = () => { const name = window.prompt(t("layout.savePrompt")); if (!name) return; saveNamedLayout(name.trim(), layout); refresh(); }; const onPick = (value: string) => { if (value === "__save__") { onSave(); return; } if (value.startsWith("__del__:")) { const name = value.slice("__del__:".length); deleteNamedLayout(name); refresh(); return; } const loaded = loadNamedLayout(value); if (loaded) onApply(loaded); }; return ( ); } // ── Inhalt (Mitte) ─────────────────────────────────────────────────────────── /** Hauptinhalt — abhängig von der aktiven Zeichnungsebene und dem Ansichtstyp. */ function Content({ project, level, viewType, visibleCodes, detail, referenceLines, hairline, renderMode, view3d, fov, mono, onPatchFloor, floorDisplay, categoryDisplay, displayKey, planViewRef, onCursor, onZoom, onScale, selectedWallIds, selectedCeilingIds, selectedOpeningIds, selectedStairIds, selectedRoomIds, roomStamp, onStampMove, onStampEdit, onPlanSelect, onPlanMarquee, onSegmentCut, onPlanContextMenu, onViewportSelectWall, onViewportSelectCeiling, onViewportSelectOpening, onViewportSelectStair, onViewportContextMenu, commandActive, draft, onWorkplanePoint, onWorkplaneConfirm, activeTool, toolInputActive, toolHandlers, grips, edgeGrips, selectedDrawingId, selectedDrawingIds, gripHandlers, edit3dWalls, edit3dDrawings, onEdit3dVertex, onEdit3dBody, onEdit3dWallTop, onEdit3dEnd, }: { project: Project; level: DrawingLevel; viewType: ViewType; visibleCodes: Set; detail: DetailLevel; referenceLines: boolean; hairline: boolean; renderMode: RenderMode; /** Kanonischer 3D-Blickwinkel (Preset) der Perspektive. */ view3d: View3d; /** Sichtwinkel der 3D-Perspektivkamera in Grad. */ fov: number; /** Grundriss in Schwarz-Weiss (mono) statt mit Kategorie-/Element-Farben. */ mono: boolean; onPatchFloor: (patch: Partial) => void; floorDisplay: DisplayResolver; categoryDisplay: CategoryDisplayResolver & DisplayResolver; displayKey: string; planViewRef: React.RefObject; onCursor: (model: Vec2 | null) => void; onZoom: (percent: number) => void; onScale: (n: number) => void; selectedWallIds: string[]; selectedCeilingIds: string[]; selectedOpeningIds: string[]; selectedStairIds: string[]; selectedRoomIds: string[]; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2; }) => void; onViewportSelectWall: (wallId: string | null) => void; onViewportSelectCeiling: (ceilingId: string | null) => void; onViewportSelectOpening: (openingId: string | null) => void; onViewportSelectStair: (stairId: string | null) => void; onViewportContextMenu: (info: ViewportContextInfo) => void; /** Läuft ein zeichnender Befehl? Macht den 3D-Viewport zur Erstellungs-Fläche. */ commandActive: boolean; /** Aktuelle Werkzeug-Vorschau (Rubber-Band) — auch in 3D gespiegelt. */ draft: ToolDraft | null; /** Auf die Arbeitsebene projizierter Punkt → CommandEngine (move/pick). */ onWorkplanePoint: (kind: "move" | "pick", model: Vec2) => void; /** Befehl bestätigen/beenden aus dem 3D-Viewport. */ onWorkplaneConfirm: () => void; activeTool: ToolId; toolInputActive: boolean; toolHandlers: ToolHandlers; grips: Vec2[]; edgeGrips: EdgeGrip[]; selectedDrawingId: string | null; selectedDrawingIds?: string[]; gripHandlers: GripHandlers; /** Alle gewählten Wände — Quelle der 3D-Editier-Griffe (Mehrfachauswahl ⇒ alle). */ edit3dWalls: Wall[]; /** Alle gewählten 2D-Elemente — 3D-Vertex-Griffe (Mehrfachauswahl ⇒ alle). */ edit3dDrawings: Drawing2D[]; onEdit3dVertex: ( target: { wallId?: string; drawingId?: string }, index: number, model: Vec2, excludeWallIds?: Set, excludeDrawingIds?: Set, ) => Vec2; onEdit3dBody: (target: { wallId?: string; drawingId?: string }, delta: Vec2) => void; onEdit3dWallTop: (wallId: string, topZ: number) => void; onEdit3dEnd: () => void; }) { if (level.kind === "section" || level.kind === "elevation") { return ; } // Freie Zeichnung: dieselbe Plan-Ansicht wie ein Geschoss (generatePlan filtert // `drawings2d` nach `level.id`), nur OHNE Geschoss-Eigenschaftsleiste und ohne // 3D-Umschaltung — eine Zeichnung hat keine Höhe/3D. So sind importierte // DXF-Polylinien hier sofort sichtbar und mit den 2D-Werkzeugen bearbeitbar. if (level.kind === "drawing") { return (
); } // Geschoss: Eigenschaften-Leiste + gewählte Ansicht. return (
{viewType === "perspektive" ? (
{t("content.perspektiveTitle", { name: level.name })}
) : ( )}
); } /** Kompakte Eigenschaften-Leiste eines Geschosses. */ function FloorProps({ level, onPatch, }: { level: DrawingLevel; onPatch: (patch: Partial) => void; }) { return (
{t("props.okff")} {t("props.okffValue", { value: (level.baseElevation ?? 0).toFixed(2) })}
); } /** Abgeleiteter Grundriss eines Geschosses. */ function LevelPlanView({ project, level, visibleCodes, categoryDisplay, detail, referenceLines, hairline, mono, planViewRef, onCursor, onZoom, onScale, selectedWallIds, selectedCeilingIds, selectedOpeningIds, selectedStairIds, selectedRoomIds, roomStamp, onStampMove, onStampEdit, onPlanSelect, onPlanMarquee, onSegmentCut, onPlanContextMenu, activeTool, toolInputActive, toolHandlers, grips, edgeGrips, selectedDrawingId, selectedDrawingIds, gripHandlers, }: { project: Project; level: DrawingLevel; visibleCodes: Set; categoryDisplay: CategoryDisplayResolver; detail: DetailLevel; referenceLines: boolean; hairline: boolean; mono: boolean; planViewRef: React.RefObject; onCursor: (model: Vec2 | null) => void; onZoom: (percent: number) => void; onScale: (n: number) => void; selectedWallIds: string[]; selectedCeilingIds: string[]; selectedOpeningIds: string[]; selectedStairIds: string[]; selectedRoomIds: string[]; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; onPlanContextMenu: (info: { clientX: number; clientY: number; model: Vec2; }) => void; activeTool: ToolId; toolInputActive: boolean; toolHandlers: ToolHandlers; grips: Vec2[]; edgeGrips: EdgeGrip[]; selectedDrawingId: string | null; selectedDrawingIds?: string[]; gripHandlers: GripHandlers; }) { // Detailgrad + Referenzlinien fließen in die Plan-Erzeugung ein (Symbole, // Schraffuren/Linienfeinheit bzw. Wand-Achslinien). const plan = useMemo( () => generatePlan( project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono, ), [project, level.id, visibleCodes, categoryDisplay, detail, referenceLines, mono], ); // Bei einer freien Zeichnung gibt es keine Schnitthöhe → eigene Legende/Titel. const isDrawing = level.kind === "drawing"; const caption = isDrawing ? t("content.drawingLegend") : t("content.grundrissLegend", { name: level.name, cutHeight: formatM(level.cutHeight ?? 0), }); return (
{isDrawing ? t("content.drawingTitle", { name: level.name }) : t("content.grundrissTitle", { name: level.name })}
{caption}
); } /** Platzhalter für Schnitt/Ansicht (kommt in einer späteren Phase). */ function StubView({ level }: { level: DrawingLevel }) { return (
{level.name} {t("stub.note", { kind: level.kind === "section" ? t("stub.section") : t("stub.elevation"), })}
); }