From ca859c4aa4c726aedd5ea64ae8e2c6c387f869b5 Mon Sep 17 00:00:00 2001 From: Karim Date: Tue, 30 Jun 2026 20:52:27 +0200 Subject: [PATCH] Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem, dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en) sowie Projektdokumentation und Probe-Harness. --- .gitignore | 13 + ARCHITECTURE.md | 452 ++++ CONVENTIONS.md | 64 + HANDOVER.md | 422 ++++ README.md | 122 + ROADMAP.md | 382 +++ docs/README.md | 152 ++ docs/backend.md | 43 + docs/design/context-menu.md | 54 + docs/design/drawing-tools.md | 760 ++++++ docs/design/elements.md | 424 ++++ docs/design/layer-display-settings.md | 498 ++++ docs/design/plans-output.md | 338 +++ docs/design/resources-graphics.md | 293 +++ docs/design/rhino-command-system.md | 370 +++ docs/design/state-architecture.md | 52 + docs/design/topbar-footer.md | 43 + docs/design/wall-joins-priority.md | 636 +++++ docs/research/swisstopo-sia.md | 566 +++++ docs/research/tech-selection.md | 197 ++ docs/research/ux-patterns.md | 590 +++++ index.html | 24 + package-lock.json | 2260 +++++++++++++++++ package.json | 29 + scripts/_debug.mjs | 21 + scripts/_peek.mjs | 10 + scripts/_terrain-entry.ts | 4 + scripts/_verify3.mjs | 21 + scripts/check-terrain.mjs | 88 + scripts/probe-3d2d.mjs | 51 + scripts/probe-array.mjs | 30 + scripts/probe-command-line.mjs | 93 + scripts/probe-ctx.mjs | 11 + scripts/probe-dd-size.mjs | 11 + scripts/probe-display-style.mjs | 67 + scripts/probe-display.mjs | 15 + scripts/probe-doc.mjs | 48 + scripts/probe-dossier.mjs | 134 + scripts/probe-draw-commands.mjs | 36 + scripts/probe-dropdown.mjs | 50 + scripts/probe-dwg-import.mjs | 68 + scripts/probe-dwg.mjs | 95 + scripts/probe-edge-grips.mjs | 46 + scripts/probe-edge2.mjs | 43 + scripts/probe-edit-commands.mjs | 248 ++ scripts/probe-edit2.mjs | 69 + scripts/probe-ff-nowebgl.mjs | 14 + scripts/probe-ff-verify.mjs | 21 + scripts/probe-ff.mjs | 31 + scripts/probe-grips.mjs | 65 + scripts/probe-import-dialog.mjs | 132 + scripts/probe-import.mjs | 20 + scripts/probe-iso.mjs | 12 + scripts/probe-layer.mjs | 30 + scripts/probe-line.mjs | 28 + scripts/probe-offset2.mjs | 30 + scripts/probe-phase2.mjs | 58 + scripts/probe-refine.mjs | 42 + scripts/probe-resources.mjs | 204 ++ scripts/probe-sel.mjs | 27 + scripts/probe-shell.mjs | 300 +++ scripts/probe-shiftsel.mjs | 23 + scripts/probe-splitjoin.mjs | 166 ++ scripts/probe-tab-fields.mjs | 161 ++ scripts/probe-terrain-ui.mjs | 136 + scripts/probe-terrain.mjs | 66 + scripts/probe-tools-panel.mjs | 18 + scripts/probe-tools.mjs | 80 + scripts/probe-topbar.mjs | 16 + scripts/probe-transform.mjs | 62 + scripts/probe-verify2.mjs | 54 + scripts/probe-view3d.mjs | 84 + scripts/probe-wall-sel.mjs | 13 + scripts/probe-wallattr.mjs | 186 ++ scripts/probe.mjs | 47 + scripts/test-kernel2d.ts | 192 ++ src/App.tsx | 2783 +++++++++++++++++++++ src/commands/cmds/circle.ts | 141 ++ src/commands/cmds/copy.ts | 165 ++ src/commands/cmds/import.ts | 37 + src/commands/cmds/line.ts | 140 ++ src/commands/cmds/move.ts | 181 ++ src/commands/cmds/offset.ts | 339 +++ src/commands/cmds/polyline.ts | 181 ++ src/commands/cmds/rect.ts | 143 ++ src/commands/cmds/terrain.ts | 60 + src/commands/engine.ts | 439 ++++ src/commands/parseInput.ts | 166 ++ src/commands/registry.ts | 79 + src/commands/types.ts | 183 ++ src/editors/splitJoin.ts | 277 ++ src/geometry/kernel2d.ts | 735 ++++++ src/i18n/de.ts | 500 ++++ src/i18n/en.ts | 490 ++++ src/i18n/index.ts | 94 + src/io/dwgParser.ts | 353 +++ src/io/dxfImportHook.ts | 22 + src/io/dxfParser.ts | 295 +++ src/io/dxfToDrawings.ts | 89 + src/io/libredwg-web.d.ts | 19 + src/io/linParser.ts | 88 + src/io/patParser.ts | 75 + src/main.tsx | 60 + src/model/geometry.ts | 110 + src/model/joins.ts | 141 ++ src/model/sampleProject.ts | 182 ++ src/model/terrain.ts | 100 + src/model/types.ts | 469 ++++ src/model/wall.ts | 87 + src/panels/AttributesPanel.tsx | 177 ++ src/panels/DisplayModeSelect.tsx | 58 + src/panels/Dock.tsx | 265 ++ src/panels/DrawingLevelsPanel.tsx | 157 ++ src/panels/FloatingPanel.tsx | 214 ++ src/panels/LayersPanel.tsx | 155 ++ src/panels/ObjectInfoPanel.tsx | 440 ++++ src/panels/PanelFrame.tsx | 67 + src/panels/ResourcesPanel.tsx | 91 + src/panels/SitePanel.tsx | 125 + src/panels/TabStrip.tsx | 78 + src/panels/ToolsPanel.tsx | 215 ++ src/panels/builtinPanels.tsx | 81 + src/panels/displayMode.ts | 69 + src/panels/host.ts | 197 ++ src/panels/layout.ts | 551 ++++ src/panels/panelDrag.tsx | 304 +++ src/panels/registry.ts | 51 + src/panels/types.ts | 164 ++ src/plan/PlanView.tsx | 1752 +++++++++++++ src/plan/generatePlan.ts | 692 +++++ src/state/appStore.ts | 51 + src/state/layoutSlice.ts | 202 ++ src/state/projectSlice.ts | 1137 +++++++++ src/state/selectionInfo.ts | 268 ++ src/state/selectionSlice.ts | 33 + src/state/siteSlice.ts | 81 + src/state/store.ts | 87 + src/state/viewSlice.ts | 97 + src/state/visibilitySets.ts | 176 ++ src/styles.css | 3339 +++++++++++++++++++++++++ src/tools/snapping.ts | 177 ++ src/tools/tools.ts | 389 +++ src/tools/transform.ts | 251 ++ src/tools/types.ts | 191 ++ src/ui/CommandLine.tsx | 175 ++ src/ui/ContextMenu.tsx | 164 ++ src/ui/Dropdown.tsx | 330 +++ src/ui/ErrorBoundary.tsx | 36 + src/ui/EyeIcon.tsx | 39 + src/ui/ImportDialog.tsx | 333 +++ src/ui/ResourceManager.tsx | 840 +++++++ src/ui/StatusBar.tsx | 92 + src/ui/TopBar.tsx | 753 ++++++ src/ui/TransformBar.tsx | 65 + src/viewport/Viewport3D.tsx | 1072 ++++++++ tsconfig.json | 20 + vite.config.ts | 36 + 157 files changed, 37921 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CONVENTIONS.md create mode 100644 HANDOVER.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 docs/README.md create mode 100644 docs/backend.md create mode 100644 docs/design/context-menu.md create mode 100644 docs/design/drawing-tools.md create mode 100644 docs/design/elements.md create mode 100644 docs/design/layer-display-settings.md create mode 100644 docs/design/plans-output.md create mode 100644 docs/design/resources-graphics.md create mode 100644 docs/design/rhino-command-system.md create mode 100644 docs/design/state-architecture.md create mode 100644 docs/design/topbar-footer.md create mode 100644 docs/design/wall-joins-priority.md create mode 100644 docs/research/swisstopo-sia.md create mode 100644 docs/research/tech-selection.md create mode 100644 docs/research/ux-patterns.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/_debug.mjs create mode 100644 scripts/_peek.mjs create mode 100644 scripts/_terrain-entry.ts create mode 100644 scripts/_verify3.mjs create mode 100644 scripts/check-terrain.mjs create mode 100644 scripts/probe-3d2d.mjs create mode 100644 scripts/probe-array.mjs create mode 100644 scripts/probe-command-line.mjs create mode 100644 scripts/probe-ctx.mjs create mode 100644 scripts/probe-dd-size.mjs create mode 100644 scripts/probe-display-style.mjs create mode 100644 scripts/probe-display.mjs create mode 100644 scripts/probe-doc.mjs create mode 100644 scripts/probe-dossier.mjs create mode 100644 scripts/probe-draw-commands.mjs create mode 100644 scripts/probe-dropdown.mjs create mode 100644 scripts/probe-dwg-import.mjs create mode 100644 scripts/probe-dwg.mjs create mode 100644 scripts/probe-edge-grips.mjs create mode 100644 scripts/probe-edge2.mjs create mode 100644 scripts/probe-edit-commands.mjs create mode 100644 scripts/probe-edit2.mjs create mode 100644 scripts/probe-ff-nowebgl.mjs create mode 100644 scripts/probe-ff-verify.mjs create mode 100644 scripts/probe-ff.mjs create mode 100644 scripts/probe-grips.mjs create mode 100644 scripts/probe-import-dialog.mjs create mode 100644 scripts/probe-import.mjs create mode 100644 scripts/probe-iso.mjs create mode 100644 scripts/probe-layer.mjs create mode 100644 scripts/probe-line.mjs create mode 100644 scripts/probe-offset2.mjs create mode 100644 scripts/probe-phase2.mjs create mode 100644 scripts/probe-refine.mjs create mode 100644 scripts/probe-resources.mjs create mode 100644 scripts/probe-sel.mjs create mode 100644 scripts/probe-shell.mjs create mode 100644 scripts/probe-shiftsel.mjs create mode 100644 scripts/probe-splitjoin.mjs create mode 100644 scripts/probe-tab-fields.mjs create mode 100644 scripts/probe-terrain-ui.mjs create mode 100644 scripts/probe-terrain.mjs create mode 100644 scripts/probe-tools-panel.mjs create mode 100644 scripts/probe-tools.mjs create mode 100644 scripts/probe-topbar.mjs create mode 100644 scripts/probe-transform.mjs create mode 100644 scripts/probe-verify2.mjs create mode 100644 scripts/probe-view3d.mjs create mode 100644 scripts/probe-wall-sel.mjs create mode 100644 scripts/probe-wallattr.mjs create mode 100644 scripts/probe.mjs create mode 100644 scripts/test-kernel2d.ts create mode 100644 src/App.tsx create mode 100644 src/commands/cmds/circle.ts create mode 100644 src/commands/cmds/copy.ts create mode 100644 src/commands/cmds/import.ts create mode 100644 src/commands/cmds/line.ts create mode 100644 src/commands/cmds/move.ts create mode 100644 src/commands/cmds/offset.ts create mode 100644 src/commands/cmds/polyline.ts create mode 100644 src/commands/cmds/rect.ts create mode 100644 src/commands/cmds/terrain.ts create mode 100644 src/commands/engine.ts create mode 100644 src/commands/parseInput.ts create mode 100644 src/commands/registry.ts create mode 100644 src/commands/types.ts create mode 100644 src/editors/splitJoin.ts create mode 100644 src/geometry/kernel2d.ts create mode 100644 src/i18n/de.ts create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/index.ts create mode 100644 src/io/dwgParser.ts create mode 100644 src/io/dxfImportHook.ts create mode 100644 src/io/dxfParser.ts create mode 100644 src/io/dxfToDrawings.ts create mode 100644 src/io/libredwg-web.d.ts create mode 100644 src/io/linParser.ts create mode 100644 src/io/patParser.ts create mode 100644 src/main.tsx create mode 100644 src/model/geometry.ts create mode 100644 src/model/joins.ts create mode 100644 src/model/sampleProject.ts create mode 100644 src/model/terrain.ts create mode 100644 src/model/types.ts create mode 100644 src/model/wall.ts create mode 100644 src/panels/AttributesPanel.tsx create mode 100644 src/panels/DisplayModeSelect.tsx create mode 100644 src/panels/Dock.tsx create mode 100644 src/panels/DrawingLevelsPanel.tsx create mode 100644 src/panels/FloatingPanel.tsx create mode 100644 src/panels/LayersPanel.tsx create mode 100644 src/panels/ObjectInfoPanel.tsx create mode 100644 src/panels/PanelFrame.tsx create mode 100644 src/panels/ResourcesPanel.tsx create mode 100644 src/panels/SitePanel.tsx create mode 100644 src/panels/TabStrip.tsx create mode 100644 src/panels/ToolsPanel.tsx create mode 100644 src/panels/builtinPanels.tsx create mode 100644 src/panels/displayMode.ts create mode 100644 src/panels/host.ts create mode 100644 src/panels/layout.ts create mode 100644 src/panels/panelDrag.tsx create mode 100644 src/panels/registry.ts create mode 100644 src/panels/types.ts create mode 100644 src/plan/PlanView.tsx create mode 100644 src/plan/generatePlan.ts create mode 100644 src/state/appStore.ts create mode 100644 src/state/layoutSlice.ts create mode 100644 src/state/projectSlice.ts create mode 100644 src/state/selectionInfo.ts create mode 100644 src/state/selectionSlice.ts create mode 100644 src/state/siteSlice.ts create mode 100644 src/state/store.ts create mode 100644 src/state/viewSlice.ts create mode 100644 src/state/visibilitySets.ts create mode 100644 src/styles.css create mode 100644 src/tools/snapping.ts create mode 100644 src/tools/tools.ts create mode 100644 src/tools/transform.ts create mode 100644 src/tools/types.ts create mode 100644 src/ui/CommandLine.tsx create mode 100644 src/ui/ContextMenu.tsx create mode 100644 src/ui/Dropdown.tsx create mode 100644 src/ui/ErrorBoundary.tsx create mode 100644 src/ui/EyeIcon.tsx create mode 100644 src/ui/ImportDialog.tsx create mode 100644 src/ui/ResourceManager.tsx create mode 100644 src/ui/StatusBar.tsx create mode 100644 src/ui/TopBar.tsx create mode 100644 src/ui/TransformBar.tsx create mode 100644 src/viewport/Viewport3D.tsx create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d4ef86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +*.tsbuildinfo + +# Probe / screenshot artifacts (regenerated) +scripts/*.png + +# Editor / OS +.DS_Store +*.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2dc2b32 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,452 @@ +# Architektur — Standalone Browser-BIM (cad) + +> Stand: 2026-06-29 +> Vision, Phasen und Backlog: [ROADMAP.md](ROADMAP.md). Konventionen: [CONVENTIONS.md](CONVENTIONS.md). +> Detail-Designs: [docs/design/elements.md](docs/design/elements.md) · +> [docs/design/plans-output.md](docs/design/plans-output.md) · +> [docs/design/resources-graphics.md](docs/design/resources-graphics.md). + +Dieses Dokument beschreibt, **wie** die Standalone-Browser-App gebaut wird und +wie sie die Konzepte des DOSSIER-Rhino-Plugins in Browser-Äquivalente übersetzt. +DOSSIER ist ein Rhino-8-Plugin (Python + React-WebView); `cad` ist die *eigen­ +ständige* Browser-Variante: kein Rhino-Document, kein `doc.Strings`, kein +IronPython — stattdessen ein **eigenes typisiertes Datenmodell in TypeScript**, +gerendert über **Three.js** (3D) und **SVG** (Plan), gespeichert als **JSON-Datei**. + +Alle Bezeichner im Code sind **englisch** (Vectorworks-Terminologie); Prosa und +UI-Texte sind deutsch. Einheiten intern in **Metern**. + +--- + +## 0. Leitprinzip — ein Modell, viele Darstellungen + +Das semantische Gebäudemodell ist die **einzige Wahrheit**. Jede Sicht (3D, +Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** (`derive`) daraus. +Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern** +angewandt, nie in die Geometrie eingebacken. + +``` + ┌──────────────────────────────────────────┐ + │ Project (semantisches Modell, JSON) │ ← einzige Wahrheit + │ resources · types · designLevels · │ + │ layers · elements │ + └──────────────┬───────────────────────────┘ + │ pure derive() + ┌───────────────────────┼────────────────────────────┐ + ▼ ▼ ▼ + Scene3D (Three.js) PlanModel → SVG SectionModel → SVG + buildScene() generatePlan() generateSection() (HLR) + │ │ │ + └────────── beide lesen dieselben joins/components/styles ──────────┘ +``` + +Das steht im Spike bereits: `generatePlan.ts` und `Viewport3D.tsx` extrudieren +**dasselbe** gehrte Band-Polygon (`clippedBand`). Diese Symmetrie ist der Kern +und wird beim Ausbau strikt gehalten. + +--- + +## 1. Repo-Struktur (Ziel) + +Wächst aus dem heutigen `src/` (model/plan/viewport/ui). Module sind **klein und +fachlich geschnitten** — wir vermeiden bewusst den `elemente.py`-Monolithen +(7244 LOC) aus DOSSIER (dort als Schwachstelle #4.1 dokumentiert). + +``` +src/ + model/ + types.ts // Project, DesignLevel, LayerCategory, Element-Union (existiert) + geometry.ts // 2D/3D-Mathe ohne Kernel (existiert) + joins.ts // Wand-Verschneidung (Gehrung; später Prio-T/X) (existiert) + sampleProject.ts // Demo-Haus (existiert) + project.ts // Factory, Defaults, Migrationen + selectors.ts // abgeleitete Reads (visibleCodes, elementsOnLevel…) + ids.ts // newId(prefix) — UUID-Erzeugung + elements/ // pro Bauteil ein Modul (Daten + Generierung) + wall.ts opening.ts slab.ts stair.ts roof.ts structure.ts space.ts + resources/ + componentManager.ts hatchManager.ts lineManager.ts symbolLibrary.ts + overrides.ts // regelbasierte Engine (Condition → Action) + store/ + store.ts // Zustand-Store: { project, ui }, Aktionen, Undo/Redo + persistence.ts // Datei save/load (JSON), Autosave (IndexedDB) + history.ts // Undo/Redo-Ring + plan/ + generatePlan.ts // Grundriss aus Footprint + Symbolik (existiert) + generateSection.ts // Schnitt/Ansicht aus 3D-Projektion (HLR-Worker) + PlanView.tsx // SVG-Renderer + Pan/Zoom/Grips (existiert) + primitives.ts // Primitive-Union, SVG-Serializer, DXF/PDF-Export + viewport/ + Viewport3D.tsx // Three.js-Szene aus dem Modell (existiert) + scene.ts // buildScene(project) → THREE.Group (Layer-Spiegel) + clip.ts // Plan-/Schnitt-Clipping über THREE.Plane + camera.ts // Kamera-Presets, Norden-Rotation + sheets/ + layout.ts // Sheet/Viewport-Datenmodell + SheetEditor.tsx // Plansatz-Editor + exportPdf.ts // Vektor-PDF (svg → pdf-lib / jsPDF) + workers/ + geometry.worker.ts // Booleans (Öffnungen) + HLR via Comlink + ui/ + App.tsx Navigator panels… // React-Oberfläche (heute in App.tsx, wird gesplittet) +``` + +--- + +## 2. Datenmodell + +### 2.1 Zwei unabhängige Achsen (DOSSIER-Modell, im Spike umgesetzt) + +DOSSIER trennt zwei orthogonale Konzepte, persistiert als zwei getrennte +JSON-Bäume (`dossier_zeichnungsebenen`, `dossier_ebenen`). Wir übernehmen das +1:1 in `types.ts` (existiert bereits als `drawingLevels` + `layers`): + +1. **Zeichnungsebenen** (`DesignLevel` / `DrawingLevel`) — die obersten + Dokument-Abschnitte. Zwei produktive Arten: + - **Geschoss** (`kind:"floor"`): `floorHeight`, `cutHeight`, + `baseElevation` (akkumuliert via `recomputeFloorElevations`), `visible`/`locked`. + - **Schnitt/Ansicht** (`kind:"section"|"elevation"`): `linePoints`, + `directionSign`, Höhenbereich, Tiefe. + - **Zeichnung** (`kind:"drawing"`): freie 2D-Ebene ohne Geschossbezug. +2. **Ebenen** (`LayerCategory`) — das **Grafik-Kategorie-Schema**, das in *jedem* + Geschoss gilt. Baum mit `{code, name, color, lw, visible, locked, hatch?, children}`. + Codes 1:1 wie DOSSIER (`DEFAULT_LAYER_SCHEMA` aus `launcher/src/App.jsx`): + `00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster, 22 Möbel, 25 Stützen) · + 30 Decken · 31 Dächer · 35 Träger · 50 Text · 60 Plangrafik …` + +Ein **Element** kennt sein **Geschoss** (`floorId`) *und* seine **Ebene** +(`categoryCode`). Sichtbarkeit ergibt sich aus dem Schnitt (Geschoss `geschoss × code` +Matrix), genau wie DOSSIERs `apply_visibility(z_mode, e_mode)` in `layer_builder.py`. + +> **Begriffsnotiz:** Heute heißt der Typ im Code `DrawingLevel`. ROADMAP §5 nennt +> die Modell-Eingabe (Geschosse) Vectorworks-konform **Design Layer** und die +> abgeleitete Ausgabe **Drawing Layer / Sheet**. Wir behalten `DrawingLevel` als +> Union (kind=floor ≙ Design Layer, kind=section/elevation/drawing ≙ Drawing +> Layer) und führen `Sheet` erst separat ein (§2.5, plans-output.md). Kein Rename +> ohne expliziten Auftrag. + +### 2.2 Ressourcen-Bibliotheken (Vectorworks-Stil) + +Neuer Block `Project.resources` (heute provisorisch als flache `materials[]`). +Alles verweist **per id** — zentral änderbar. Details: resources-graphics.md. + +```ts +interface Resources { + lineStyles: LineStyle[]; // Line Manager: { id, name, weight(mm), color, dash:number[] } + hatches: Hatch[]; // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId } + components: Component[]; // Component Manager (= DOSSIER-Material erweitert): + // { id, name, hatchId, color3d, texture3d?, joinPriority } +} +``` + +`joinPriority` ist DOSSIERs `_MATERIAL_PRIO` (Beton 800 … Putz 100) als +**Daten** statt Hardcode — steuert die Prioritäts-T-Verschneidung (Risiko #1). + +### 2.3 Aufbauten (mehrschichtige Typen) + +```ts +interface WallType { id; name; layers: { componentId; thickness }[]; } +interface SlabType { id; name; layers: { componentId; thickness }[]; } +``` + +Schichtdicke liegt am Layer, **Priorität am Component** (so muss man Prio nur +einmal pflegen). 3D und Plan lesen dieselben `layers[]`. + +### 2.4 Elemente + +Diskriminierte Union `Element` (heute `Wall | Door`), erweitert um +`Window | Slab | Stair | Roof | Column | Beam | Space | Draw2d`. Jedes Element: +`{ id, type, floorId, categoryCode, ... }`. Gehostete Elemente (Tür/Fenster) +tragen `hostWallId` statt `floorId` (Geschoss ergibt sich aus der Wand). Volle +Felder: elements.md. + +### 2.5 Pläne / Output (eigene Achse) + +```ts +interface Sheet { // Plansatz-Blatt (≙ DOSSIER Layout/PageView) + id; name; paper: "A0".."A4"|"Letter"; landscape: boolean; + viewports: SheetViewport[]; // platzierte Ableitungen + Titelblock + folder?: string; +} +interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt + id; rect: Rect; sourceViewId: string; // referenziert DrawingLevel oder ViewSnapshot + scale: number; // 1:N +} +interface ViewSnapshot { // ≙ DOSSIER Ausschnitt (View-Snapshot) + id; name; folder?; + camera: CameraState; scale: number; detailLevel: DetailLevel; + visibility: VisibilityState; // pro Geschoss/Ebene + overrides?: { presetId?; enabled }; +} +``` + +--- + +## 3. State, Persistenz, Undo/Redo + +### 3.1 Store — Zustand + +Heute: `useState` in `App.tsx`, immutabel via `setProject`. Das skaliert +nicht für Werkzeuge + Undo. Migration auf **Zustand** (ROADMAP §4): + +```ts +interface AppState { + project: Project; // das Dokument + ui: { // flüchtig, NICHT persistiert/undobar + activeLevelId; activeViewType; selection: string[]; + activeTool: ToolId; hover; cameraByView; planTransform; + }; + // Aktionen mutieren via Immer-Producer; jede Modell-Mutation pusht History. + apply(mutator: (p: Project) => void): void; + undo(): void; redo(): void; +} +``` + +**Trennung Modell ↔ UI** ist hart: nur `project` ist undobar und wird gespeichert. +`ui` (Auswahl, Kamera, aktives Werkzeug) lebt separat. Das ersetzt DOSSIERs +`sc.sticky`-Bus — wo DOSSIER cross-modul über Sticky-Keys kommuniziert, lesen bei +uns einfach alle Komponenten denselben Store (reaktiv). **Kein Sticky, kein +Bridge-Polling, keine `is not None`-Lücken** (DOSSIER-Schwachstelle #4.4). + +### 3.2 Persistenz — JSON-Datei statt `doc.Strings` + +DOSSIER speichert *alles* in `doc.Strings` (Key-Value am Rhino-Document) + +globale Presets als JSON-Dateien im User-Home. Browser-Äquivalent: + +| DOSSIER | Browser (cad) | +|---|---| +| `doc.Strings.SetString(key, json)` | Feld im `Project`-Objekt (ein JSON-Baum) | +| `.3dm`-Datei mit eingebetteten Strings | **`.cad.json`-Datei** via File System Access API | +| Globale Presets (`~/Library/.../*.json`) | **LocalStorage** (`cad.presets.*`) + Export/Import | +| Launcher schreibt Settings-Datei | **IndexedDB** Autosave (Phase 5) | + +```ts +// persistence.ts +const FORMAT_VERSION = 1; +function serialize(p: Project): string // JSON.stringify({ version, project }) +function deserialize(text: string): Project // + migrate(version) bis aktuell +async function saveToFile(p: Project): Promise // showSaveFilePicker → .cad.json +async function loadFromFile(): Promise // showOpenFilePicker; Fallback +async function autosave(p: Project) // IndexedDB, debounced 2 s +``` + +- **Save/Load:** [File System Access API](https://developer.mozilla.org/docs/Web/API/File_System_Access_API) + (`showSaveFilePicker`/`showOpenFilePicker`); Fallback Download-Blob + `` + für Firefox/Safari. +- **Autosave/Recovery:** IndexedDB (via `idb`), entkoppelt vom expliziten Speichern. +- **Cross-Projekt-Presets:** LocalStorage mit Namespacing (`cad.presets.overrides`, + `cad.presets.wallTypes`, …) + JSON-Export/Import — ersetzt DOSSIERs + `~/Library/.../override_presets.json` (overrides.py). +- **Migrationen:** `migrate(data, fromVersion)`-Kette, additiv. DOSSIER macht das + per Sticky-Prefix (`traite_`→`pause_`→`dossier_`); wir machen es versioniert im + Datei-Schema. + +### 3.3 Undo/Redo + +DOSSIER überlässt Undo Rhino (und hat dadurch Cache-Stale-Bugs, Schwachstelle +#4.3). Wir besitzen das Dokument selbst → **eigener History-Ring**: + +```ts +// history.ts — Snapshots des project-Teilbaums (strukturelles Sharing via Immer-Patches) +interface History { undo: Patch[][]; redo: Patch[][]; } +``` + +Jede `apply()`-Mutation erzeugt Immer-Patches → Push auf `undo`. Da abgeleitete +Sichten **pure** sind, ist nach `undo()` kein Cache zu invalidieren — der +ganze Cache-Stale-Komplex aus DOSSIER (`_JOINTS_CACHE_KEY`, Material-Cache) +entfällt strukturell. + +--- + +## 4. Rendering + +### 4.1 Three.js-Szene spiegelt den Ebenen-Baum + +DOSSIER baut in Rhino eine **Layer-Hierarchie** `Zeichnungsebene → CODE_Name-Sublayer` +(`layer_builder.build_layers`) und hängt jedes Objekt an den passenden Sublayer. +Browser-Spiegel: ein **`THREE.Group`-Baum** mit identischer Struktur, gebaut aus +dem Modell (nicht persistiert — reine Ableitung): + +``` +scene +└─ levelGroup[floorId] (y-Offset = baseElevation) ← Geschoss + └─ categoryGroup[categoryCode] (userData.code) ← Ebene + └─ element meshes (userData.elementId) +``` + +```ts +// scene.ts +function buildScene(project: Project, vis: VisibilityState): THREE.Group +function syncScene(root: THREE.Group, project, vis) // diff statt full rebuild (Perf) +``` + +- **Sichtbarkeit:** `categoryGroup.visible = vis.codes.has(code)` und + `levelGroup.visible = vis.floors.has(floorId)` — entspricht DOSSIERs + `apply_visibility`, aber als simples `.visible`-Toggle statt Layer-Table-Modify. +- **Farbe/Material:** Component → `MeshStandardMaterial` (PBR später), per + `componentId` gecacht (wie heute `layerMaterial`-Cache, aber projektweit). +- **„Grau/gesperrt"-Modi** (DOSSIER `grey`/`grey_locked`): graues Override-Material + auf der Gruppe statt Layer-Color-Tausch. + +### 4.2 Grundriss = Clipping-Ebene + symbolische Generierung + +DOSSIER zeigt den Grundriss eines Geschosses, indem es eine **Clipping-Plane** auf +`okff + schnitthöhe` legt, Normale −Z (`layer_builder.update_clipping_plane`). +Zwei Browser-Pfade — **beide nötig** (ROADMAP §3): + +1. **3D-Viewport im Plan-Modus:** `THREE.Plane(normal=(0,0,-1), constant=cutZ)` über + `renderer.clippingPlanes` + `material.clippingPlanes`. `clip.ts` setzt die Ebene + pro aktivem Geschoss (`cutZ = baseElevation + cutHeight`), Kamera orthografisch + von oben. So sieht man den **echten geschnittenen Volumenkörper**. +2. **Symbolischer SVG-Grundriss** (der Hauptweg, schnell + sauber): `generatePlan.ts` + erzeugt Vektor-Primitive **direkt aus Parametern** — Wand-Schnittbänder, + Öffnungs-Aussparungen, Tür-Schwenkbögen — *ohne* Mesh zu schneiden. Steht im + Spike. Ausbau: Schraffuren, Lauflinien, Bemaßung, LoD (plans-output.md). + +### 4.3 Schnitt/Ansicht = Kamera + Schnittebenen + HLR + +DOSSIER setzt 1–2 Clipping-Planes (Cut + Back), Parallel-Projektion senkrecht zur +Linie, zoomt auf die BBox (`schnitte.activate_schnitt`). Browser: + +- **3D-Vorschau:** zwei `THREE.Plane` (Cut auf der Linie, Back in `directionSign`- + Richtung um `depthBack` versetzt) + orthografische Kamera senkrecht zur Linie. +- **Vektor-Ergebnis (Risiko #4):** Hidden-Line-Removal durch das zusammengebaute + Gebäude → saubere Linien als SVG. Via **OpenCascade.js** (`HLRBRep`) im + **Web Worker** (Comlink), Ergebnis gecacht pro (Schnitt, sichtbare Ebenen, + Modell-Hash). Geschnittene Bauteile bekommen Component-Schraffur (Section-Style, + resources-graphics.md). Details: plans-output.md. + +### 4.4 SVG-Renderer + Geometrie-Booleans + +- **Plan-Output:** SVG (`PlanView.tsx`). `Primitive`-Union (polygon/line/arc/text/ + hatch) → SVG-Elemente; derselbe Serializer speist DXF- und PDF-Export. +- **Öffnungs-Booleans (Risiko #2):** Tür/Fenster schneidet Loch in Wand. Für 3D + reicht heute das Aussparen per Segment-Extrusion (im Spike). Für exakte B-Rep- + Verschneidung (und IFC) **OpenCascade.js** *oder* **Manifold** im Worker — + Entscheidung in Phase 0 (ROADMAP §8): OCC = exakt/B-Rep, Manifold = schnell/Mesh. + +--- + +## 5. React-Panel-Struktur + +DOSSIER ist eine Sammlung **getrennter WebView-Panels** (EBENEN, ELEMENTE, +GESTALTUNG, OBERLEISTE, MASSSTAB, AUSSCHNITTE, DIMENSIONEN, LAYOUTS, OVERRIDES), +die über die Python-Bridge + `sc.sticky` kommunizieren. Im Browser ist alles +**eine SPA** mit einem geteilten Store — die Panels werden zu Docking-Bereichen. + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ TopBar Werkzeuge · Ansichtstyp · Massstab · Snaps · Save/Load │ ≙ OBERLEISTE+MASSSTAB +├──────────────┬──────────────────────────────────────┬───────────────┤ +│ Navigator │ Viewport (Three.js oder SVG-Plan) │ Inspector │ +│ ┌──────────┐ │ ┌────────────────────────────────┐ │ ┌───────────┐ │ +│ │Zeichnungs│ │ │ 3D / Grundriss / Schnitt / │ │ │ aktives │ │ ≙ ELEMENTE/ +│ │-ebenen │ │ │ Ansicht — abgeleitet aus dem │ │ │ Element + │ │ GESTALTUNG- +│ ├──────────┤ │ │ Modell │ │ │ Stil │ │ Properties +│ │ Ebenen │ │ └────────────────────────────────┘ │ └───────────┘ │ +│ │ (Baum) │ │ │ Manager-Tabs: │ +│ │ │ │ │ Components · │ ≙ Resource- +│ └──────────┘ │ │ Hatch · Line │ Manager +├──────────────┴──────────────────────────────────────┴───────────────┤ +│ BottomBar Koordinaten · Snaps · Statusmeldungen │ +└─────────────────────────────────────────────────────────────────────┘ + Modal/Drawer: Ausschnitte · Sheets/Layouts · Overrides · Kamera-Presets · SIA-Bilanz +``` + +**Komponenten-Map** (heute alles in `App.tsx`; wird gesplittet): + +| Bereich | DOSSIER-Panel | cad-Komponente | +|---|---|---| +| Zeichnungsebenen-Liste | EBENEN (oben) | `Navigator/DrawingLevels.tsx` | +| Ebenen-Baum | EBENEN (Baum) | `Navigator/LayerTree.tsx` (Spike: `CategoryRow`) | +| Top-Bar (View/Snaps/Massstab) | OBERLEISTE, MASSSTAB | `TopBar.tsx` | +| Element-Eigenschaften | ELEMENTE, ELEMENT-PROPERTIES | `Inspector/ElementProps.tsx` | +| Stil/Attribute der Auswahl | GESTALTUNG | `Inspector/StylePanel.tsx` | +| Component/Hatch/Line | (Project-Settings, mass_style) | `managers/*Manager.tsx` | +| View-Snapshots | AUSSCHNITTE | `panels/Snapshots.tsx` | +| Plansätze | LAYOUTS | `sheets/SheetEditor.tsx` | +| Regelbasierte Overrides | OVERRIDES | `panels/Overrides.tsx` | +| Bemaßung | DIMENSIONEN | `panels/Dimensions.tsx` | +| Element-Übersicht (BIM-Tree) | ELEMENTE-ÜBERSICHT | `panels/ElementTree.tsx` | +| Kamera-Presets | KAMERA | `panels/CameraPresets.tsx` | + +**Werkzeug-System** (ersetzt DOSSIERs Rhino-Command-Aliases `cmd/wand.py` etc.): +ein `Tool`-Interface mit `onPointerDown/Move/Up`, das Vorschau-Primitive +zurückgibt und beim Bestätigen `store.apply()` ruft. Pointer-Events auf Canvas/SVG +mit Snap-Engine (Endpunkt/Mitte/Schnitt/Ortho/Raster). Tabelle der Tools: +elements.md §8. + +--- + +## 6. Rhino → Browser — Mapping-Tabelle + +| DOSSIER (Rhino-Plugin) | cad (Standalone-Browser) | Anmerkung | +|---|---|---| +| **Rhino `RhinoDoc`** | `Project` (TS-Objekt im Store) | einzige Wahrheit | +| **`doc.Strings[key]=json`** | Feld im `Project`-JSON | persistiert in `.cad.json` | +| **`.3dm`-Datei** | **`.cad.json`** via File System Access API | + IndexedDB-Autosave | +| **Globale Presets (~/Library/*.json)** | **LocalStorage** + Export/Import | cross-Projekt | +| **`sc.sticky` (cross-modul Bus)** | **Zustand-Store** (reaktiv) | kein Polling, kein None-Bug | +| **`panel_base.BaseBridge` / WebView-IPC** | direkte React-Props/Store | keine `document.title`-Hacks | +| **`document.title="RHINOMSG::"` Polling** | entfällt | SPA, kein WebView | +| **Eto.Forms Satelliten-Fenster** | React-Modal/Drawer | z.B. Ausschnitt-Settings | +| **Rhino Layer-Tabelle (Sublayer-Baum)** | `LayerCategory[]`-Baum + `THREE.Group`-Spiegel | `scene.ts` | +| **`layer_builder.build_layers`** | `buildScene` (Ableitung) | Gruppen statt Layer | +| **`layer.PlotWeight`** | `LineStyle.weight` (mm) → SVG `stroke-width` | massstabsabh. (§plans) | +| **`SectionStyle` (Layer)** | Component-Schraffur beim Schnitt-Rendern | resources-graphics.md | +| **Clipping-Plane (`AddClippingPlane`)** | `THREE.Plane` + `renderer.clippingPlanes` | `clip.ts` | +| **`vp.ChangeToParallelProjection`** | `THREE.OrthographicCamera` | `camera.ts` | +| **`vp.GetFrustum()` → Massstab** | Frustum-Breite (ortho) → 1:N | gleiche Mathe (§plans) | +| **CoreGraphics DPI-Auto-Detect** | `window.devicePixelRatio` + CSS-px | Browser kennt DPI nativ | +| **`Rhino.Geometry.Brep`-Booleans** | OpenCascade.js / Manifold (Worker) | Öffnungen, HLR | +| **`HLRBRep` (über Rhino-Display)** | OpenCascade.js `HLRBRep` (Worker) | Schnitt/Ansicht-Linien | +| **Rhino-Grips + DisplayConduit** | Pointer-Events auf SVG/Canvas + Overlay | grip-editing (elements.md) | +| **`Rhino.Input.Custom.GetPoint`** | `Tool`-Pointer-Handler + Snap | Werkzeug-System | +| **`MouseCallback` (Doppelklick Schnitt)** | `onDoubleClick` auf SVG-Symbol | plans-output.md | +| **Rhino Undo/Redo** | eigener History-Ring (Immer-Patches) | kein Cache-Stale | +| **`HatchPattern`-Tabelle** | `Hatch`-Ressourcen + SVG `` | resources-graphics.md | +| **`Linetype`-Tabelle** | `LineStyle.dash[]` → SVG `stroke-dasharray` | resources-graphics.md | +| **`TextEntity` / Rich-Text** | SVG `` / HTML-Annotation | plans-output.md | +| **`RhinoPageView` (Layout)** | `Sheet` + `SheetEditor` | plans-output.md | +| **`FilePdf` Multi-Page-Export** | svg → `pdf-lib`/`jsPDF` (Vektor) | plans-output.md | +| **Swisstopo/OSM via .NET HttpClient** | `fetch()` (CORS-fähige STAC/Overpass-APIs) | Phase 4 | +| **LV95↔WGS84 (Python-Formeln)** | dieselben Formeln in TS portiert | Phase 4 | +| **IronPython 2.7/3 Runtime-Risiken** | entfällt (TS/Browser) | — | + +--- + +## 7. Was wir von DOSSIER übernehmen — und was nicht + +**Übernehmen (bewährte Konzepte):** +- Zwei-Achsen-Dokumentmodell (Zeichnungsebenen × Ebenen). +- Layer-Codes/Farben/lw 1:1 (`DEFAULT_LAYER_SCHEMA`). +- Component/Hatch/Line-Manager mit id-Verweisen + `joinPriority`. +- Ansichtstypen = Kamera + optionaler Schnitt (vereinheitlicht). +- LoD (`darstellung`: einfach/standard/detail) mit Dokument-Override. +- Regelbasierte Overrides (Condition → Action, additive Priorität, reversibel). +- Ausschnitte (View-Snapshots), Layer-Kombinationen, Massstab-pro-Viewport. +- SIA-416-Räume, Norden-Rotation, Stil-Kataloge. + +**Bewusst anders (Browser-nativ, Schwachstellen vermeiden):** +- **Kein Sticky-Bus** → ein reaktiver Store (vermeidet DOSSIER #4.4/#4.5/#4.6). +- **Kein Monolith** → Bauteil-Module statt 7244-LOC-`elemente.py` (#4.1). +- **Pure Ableitungen** statt mutierter Doc-Objekte → kein Cache-Stale (#4.3), + kein Undo/Redo-Loch. +- **Versioniertes Datei-Schema** statt UserString-Sticky-Migration. +- **Persistenz im Project-JSON** statt verteilt über `doc.Strings`. + +**Anti-Over-Engineering (aus DOSSIER-CONVENTIONS.md übernommen):** keine Abstraktion +ohne konkretes Problem; kein `try/catch: {}` als Bug-Versteck; erst grep/lesen, +dann editieren; Wand-Geometrie nie „nebenbei" refactoren. + +--- + +## 8. Reihenfolge bei Code-Arbeit + +1. **Dieses Dokument + das relevante Detail-Design** (elements/plans-output/ + resources-graphics) lesen. +2. **Dann das betroffene Modul** lesen (nicht raten). +3. **Erst danach editieren**; `Project` immer immutabel via `store.apply()` ändern. +4. **Verifizieren:** `npx tsc -b`, `npm run build`, Screenshot via + `node scripts/probe.mjs` — Geometrie visuell prüfen. +5. **Dieses Dokument aktuell halten**, wenn sich Patterns/Mapping ändern. diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..2c2ebc0 --- /dev/null +++ b/CONVENTIONS.md @@ -0,0 +1,64 @@ +# Projekt-Konventionen — Browser-BIM (cad) + +Siehe [ROADMAP.md](ROADMAP.md) für Vision, Architektur und Phasen. + +## Code-Konventionen (verbindlich) + +- **Alle Bezeichner im Code sind ENGLISCH** — Funktionen, Variablen, Typen, Felder, + Datei-/Modulnamen. Keine deutschen Bezeichner. (Beispiel: `computeJoins`, nicht + `verschneidungBerechnen`.) +- **UI-Texte und Kommentare dürfen Deutsch sein** (Nutzeroberfläche ist deutsch). +- **Domänen-Begriffe** möglichst nach Vectorworks-Terminologie benennen (englisch): + Design Layer, Sheet/Drawing Layer, Component, Class, Hatch, Wall Style, Viewport. +- **Einheiten:** intern alles in **Metern** (number). Anzeige via `formatM`. +- **Geometrie-Konventionen:** Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`; bei + CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (−T/2) → innen (+T/2) + gestapelt. + +## Code-Struktur (kein God-Component) + +- **`App.tsx` bleibt ein dünner Shell** (Store-Provider, Oberleiste, Docks+View-Router, + Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine Geschäftslogik darin. +- **Globaler Zustand in einem Store** (`src/state/`, Slices: project/selection/view/layout). + Komponenten lesen Zustand über Store-Hooks statt Prop-Drilling. +- **Features als eigene Module:** `src/views/` (View-Router-Teile), `src/editors/` + (Inline-Editoren), `src/menus/` (Kontextmenü-Builder), `src/panels/`, `src/ui/`. +- Ziel: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei). + Siehe `docs/design/state-architecture.md`. + +## UI-Konventionen + +- **Listen-/Manager-Ansichten als saubere Tabellen:** eine Kopfzeile mit + Spaltentiteln (sticky), darunter kompakte Datenzeilen mit Inline-Edit pro Zelle. + KEINE wiederholten Feld-Beschriftungen pro Zeile. Gilt für Component-/Hatch-/ + Line-Manager und ähnliche Listen. +- Dunkler DOSSIER-Stil; kompakt, ruhig, viel Inhalt pro Fläche. +- **UI-Text immer übersetzbar (i18n):** KEINE hartcodierten sichtbaren Strings im + JSX. Alle Texte über eine Übersetzungsfunktion `t('key')` aus einem Wörterbuch + (Default-Sprache Deutsch). Keys wie bei DOSSIER (`common.delete`, `layers.settings`, + `topbar.resources`). Neue Komponenten gleich mit `t(...)` schreiben. Identifier/Keys + bleiben englisch; nur die Wörterbuch-Werte sind die übersetzbaren Texte. + +## Native-App-Verhalten (kein Browser-Standard) + +Die App soll sich wie ein natives Programm anfühlen, nicht wie eine Webseite: +- **Browser-Kontextmenü global unterdrücken** (`document` `contextmenu` → `preventDefault`). + Nur unser eigenes `ContextMenu` erscheint; auf Flächen ohne eigenes Menü passiert nichts. +- **Keine Textauswahl / „Alles markieren":** `user-select: none` global; `user-select: text` + NUR in echten Eingaben (`input`, `textarea`, `[contenteditable]`). Ctrl+A außerhalb von + Eingaben unterbinden. +- Bild-/Element-Drag aus (`draggable=false` wo nötig); keine Browser-Drag-Gesten. + +## Architektur-Prinzip + +Ein **semantisches Modell** ist die einzige Wahrheit; jede Ansicht (3D, Grundriss, +Schnitt) wird **abgeleitet**. Darstellung (Detailgrad, Stile, Schraffuren) wird beim +Rendern angewandt, nie in die Geometrie eingebacken. + +## Arbeitsweise (für Beiträge) + +- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich. +- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via + `node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für + Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen. +- Dev-Server läuft via `npm run dev` (Vite, Port 5173). diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..2372cfd --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,422 @@ +# HANDOVER — Browser-BIM (cad), Standalone-Port von DOSSIER + +> Für die nächste Instanz. Stand: 2026-06-29. Lies zuerst +> `CONVENTIONS.md`, `ROADMAP.md` und die Projektnotizen (siehe unten). + +## >>> COMMIT-REGEL (verbindlich, IMMER beachten) <<< + +Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — weder im +Code/Doku noch in der Git-Historie. + +- **Commit-Messages:** sachlich, in der Sprache des Projekts. **NIEMALS** + `Co-Authored-By:`-Trailer, „Generated with …"-Zeilen, Tool-/Modellnamen oder + sonstige Urheber-Hinweise auf einen Assistenten. +- **Dateien/Kommentare:** keine Erwähnung von Assistenten, Modellen oder Agenten. + Wer hier weiterarbeitet, schreibt so, als wäre es Handarbeit des Teams. +- Vor jedem Push kurz prüfen: `git log` und `git diff` frei von solchen Spuren. + +## >>> STAND 2026-06-30 (Fortsetzung, spät) — ZUERST lesen <<< + +Fortsetzungs-Lauf (UI + Import + Wand-Attribute + Selektion/Editieren). Alles +unten ist `npx tsc -b` + `npm run build` GRÜN und per Screenshot/Probe verifiziert, +außer „LÄUFT" markiert. Viel wurde an **Subagenten** delegiert (Dateien landen +auf der Platte, unabhängig von Benachrichtigungen). + +**Fertig & verifiziert (diese Fortsetzung):** +- **Zwei-Ton-Dark-Theme + KEIN Petrol-Grün mehr:** `src/styles.css` Dark-Tokens neutral + (`--bg #0e0e0e`, `--panel #1d1d1d`, `--accent #4d4d4d` …); Kontextmenü-Tokens + `--ctx-hover/-text` neutral; grüner Fokus-Ring entfernt. +- **Topbar DOSSIER-Stil:** Icon-Grid 4-oben/3-unten (Grundriss integriert), Kombis + gestapelt, kompakte Selects. `src/ui/TopBar.tsx`. +- **Themed Dropdowns** statt nativer `` lassen sich im Probe nicht per + `.value=` ändern (Fehlalarm) — Verdrahtung im Code prüfen. + +## Orientierung +`ROADMAP.md` (Vision/Phasen/§10–§11 Backlog) · `CONVENTIONS.md` (Konventionen) · +`docs/README.md` + `docs/design/*` (alle Specs) · `docs/backend.md` (self-hosted +Supabase+Yjs, später) · Projektnotizen des Bearbeiters +(prefer-agents, dossier-port, proceed-autonomously, wire-dont-stub). diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d7d124 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# DOSSIER Standalone + +Browser-BIM für Wohnbau. Ein Werkzeug, um ein Wohnhaus aus semantischen +Bauteilen zu modellieren und daraus saubere, normgerechte 2D-Pläne zu ziehen — +ohne Revit, ohne Installation, im Browser. + +Das ist die eigenständige Browser-Variante des Rhino-Plugins +[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse, +Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als +React/Three.js-App statt als Rhino-Aufsatz. + +## Grundgedanke + +Es gibt **ein semantisches Modell** als einzige Wahrheit. Jede Ansicht — 3D, +Grundriss, Schnitt, Ansicht — wird daraus **abgeleitet**. Darstellung +(Detailgrad, Linienstärken, Schraffuren) wird erst beim Rendern angewandt, nie +in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall. + +Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen +3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken → +Linien, Öffnungen → Lücken + Symbol). Schnitte und Ansichten brauchen später den +zweiten Weg — echte 3D-Projektion mit verdeckten Kanten. + +## Stand heute + +Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit +abgeleiteter 3D-Sicht** geworden. Der einfachere Teil steht und ist per +Screenshot verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen. + +**Funktioniert:** +- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer + **L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D. +- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/ + Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …). +- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis; + Snapping (Endpunkt/Mittelpunkt/Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/ + Offset, Spiegeln/Drehen/Array. +- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` · + `5<45`) und Tab-Feld-Zyklus (Länge → Winkel …). +- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert, + zentral änderbar. +- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste + (echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en). +- **Import:** DXF (Konturen/Mesh) → Terrain-TIN; `.lin`/`.pat` für Linien/Schraffuren. + +**Bewusst noch offen** (die eigentlich harten Teile): +- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke, + noch kein geschnittenes Volumen. +- **HLR** (verdeckte Kanten) für Schnitte und Ansichten — diese Views sind Stubs. +- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz + verbindet seitlich) — das berüchtigte Risiko #1. + +Details und die Begründungen stehen im +[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der +[ROADMAP](ROADMAP.md) (Vision, Phasen, Backlog). + +## Stack + +Bewusst leichtgewichtig — Three.js ist reiner Display-Layer, kein schwerer +Geometrie-Kernel verfrüht eingezogen. + +| | | +|---|---| +| Frontend | React + TypeScript + Vite | +| 3D | Three.js | +| 2D-Plan | eigener SVG-Renderer | +| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain | +| Import | `dxf-parser`, eigene `.lin`/`.pat`-Parser | + +Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC), +OpenCascade/Manifold (exakte Booleans + HLR). Siehe ROADMAP §4. + +## Entwicklung + +```bash +npm install +npm run dev # Vite, http://localhost:5173 +npx tsc -b # Typecheck +npm run build # tsc -b && vite build +``` + +Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und +schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Für +Firefox-Fälle gibt es `scripts/probe-ff*.mjs` (Playwright). + +## Aufbau + +``` +src/ + model/ semantisches Modell + Ableitungen (types, geometry, joins, terrain) + geometry/ 2D-Kernel (offset/trim/fillet/intersect) + commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/) + tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen + plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + viewport/ Viewport3D (Three.js) + panels/ dockbares Panel-System + die einzelnen Paletten + state/ Store + Slices (project/selection/view/layout) + ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line + io/ Import/Export (DXF, .lin, .pat) + i18n/ Wörterbücher de/en +``` + +## Konventionen + +- **Bezeichner im Code sind englisch**, an Vectorworks-Terminologie angelehnt + (Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer + über `t('key')` — keine hartcodierten Strings im JSX. +- Intern alles in **Metern**; Anzeige via `formatM`. +- `App.tsx` bleibt dünner Shell, Zustand lebt im Store. Verbindliches in + [CONVENTIONS.md](CONVENTIONS.md). + +## Weiterlesen + +- [ROADMAP.md](ROADMAP.md) — Produktvision, Architektur-Entscheidungen, Phasen 0–7, DOSSIER-Backlog +- [ARCHITECTURE.md](ARCHITECTURE.md) — Technische Architektur im Detail +- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte +- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …) + +--- + +Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, +Swisstopo-Geodaten) sind als Differenzierer eingeplant. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..2e1d5d3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,382 @@ +# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap + +> Arbeitstitel: **cad** (Name später) +> Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser** +> Stand: 2026-06-28 + +## 1. Produktvision + +Ein **browserbasiertes BIM-Werkzeug** für Wohnbau, das zwei Dinge verbindet: + +1. **Einfaches 3D-Gebäudemodell** — aus semantischen Bauteilen: Wände, Türen, Fenster, Treppen, Decken, Dächer, Räume. +2. **Schöne, normgerechte 2D-Pläne** — Grundrisse, Schnitte, Ansichten — automatisch aus dem Modell abgeleitet. + +**Kernversprechen:** *Das schönste und einfachste Werkzeug, um ein Wohnhaus zu modellieren und daraus perfekte Pläne zu ziehen.* Nicht Revit nachbauen — radikaler Fokus auf Wohnbau + Plan-Qualität. + +**Markt-Beleg:** Arcol, Snaptrude, TestFit zeigen, dass browserbasiertes BIM real ist und Nutzer schlanke, schöne Tools wollen statt der schwerfälligen Giganten (Revit/ArchiCAD). + +--- + +## 2. Das mentale Modell — warum BIM anders ist als CAD + +| | mechanisches CAD | **BIM (unser Weg)** | +|---|---|---| +| Bausteine | generische Volumenkörper | **semantische Bauteile** (Wand, Tür, Fenster…) | +| Beziehungen | keine | Tür *hostet* in Wand & schneidet Öffnung; Wände *verbinden* sich | +| Geschosse | — | **Stockwerke** als erste Klasse | +| 2D-Plan | Hidden-Line-Projektion | **symbolische Darstellung** (Schwenkbögen, Schraffuren, Lauflinien) | +| Standard | STEP | **IFC** | + +--- + +## 2b. Kern-Prinzip: ein Modell, viele Darstellungen + +Das **wichtigste Architektur-Prinzip**: Das semantische Modell ist die *eine +Wahrheit*; jede Ansicht (3D, Grundriss, Schnitt) ist eine **abgeleitete +Darstellung**. Im Spike steht das bereits. Daraus folgen direkt die Kern-Wünsche: + +- **Modelldarstellungen / Detailgrade** — derselbe Tür/Fenster wird je nach + `detailLevel` (grob / mittel / fein) unterschiedlich gezeichnet (≙ Revit + „Detailgrad", ArchiCAD „Modelldarstellung"). Grob: Öffnung + Linie. Fein: + Rahmen, Blatt, Schwenkbogen, Anschlag. +- **Editierbare Stile** — Wandfarben, Linienstärken, Türlinien, Schraffuren als + **Stil-Schicht**, erst beim Rendern angewandt (nicht in die Geometrie + eingebacken). Pro Kategorie *und* pro Element überschreibbar. +- **Mehrschichtige Bauteile** — Wände/Decken mit **Schichtaufbau** (`layers[]`: + Material + Dicke + Priorität). 3D und Plan lesen dieselben Schichten. +- **In 2D *und* 3D zeichnen** — beide sind editierbare Sichten auf *ein* Modell; + Werkzeuge mutieren das Modell, alle Sichten re-derivieren reaktiv. + +Schwierigkeit: Detailgrade/Stile/Schichten sind 🟢 gut machbar; 2D+3D-Editieren +🟡 mittel; **mehrschichtige Wand-Verschneidung** 🔴 der härteste Teil (Risiko #1). + +--- + +## 2c. Arbeitsweise & Dokumentmodell (DOSSIER-Modell) ⭐ + +Referenz: **DOSSIER** (Rhino-Plugin des Nutzers, https://git.kgva.ch/karim/DOSSIER). +Dieses Projekt ist die **Standalone-Browser-Variante** davon. Zwei *unabhängige* Achsen: + +- **Zeichnungsebenen** — die obersten Dokument-Abschnitte, zwei Arten: + - **Geschosse** (EG, 1OG …): `hoehe` (Geschosshöhe), `schnitthoehe` (Schnitthöhe), + `okff` (Niveau, akkumuliert), `visible`/`locked`. + - **Schnitte / Ansichten** (`type:"schnitt"`): Schnittlinie `linePts`, Richtung + `dirSign`, Höhenbereich, Tiefe. + - Ein **Geschoss** wird **im 3D-View ODER im Plan-View** betrachtet (Umschalter, + *keine* getrennten Daten). Plan-View = Clipping-Ebene auf `okff + schnitthoehe`. +- **Ebenen** — das **Grafik-Kategorien-Schema**, in *jedem* Geschoss vorhanden; + Baum-Knoten mit pro Ebene einstellbaren **Darstellungseinstellungen** (in den + „Ebeneneinstellungen…"): **Stift** = Typ/Linienstil + Farbe + Dicke (lw); + **Schraffur** = Typ + Skalierung + Rotation + **Stiftstärke der Schraffurlinien**. + Modell: `{code, name, visible, locked, lineStyleId|{type,color,lw}, hatchId|{type,scale,angle,lineWeight}, children}`. + Codes 1:1 wie DOSSIER: + `00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster) · 30 Decken · 31 Dächer + · 40 Treppen (└41 Treppen-2D) · 50 Tragwerk · 60 Räume · 80 Plangrafik …` +- **Elemente** (Wand, Decke, Treppe, Öffnung, Plangrafik, Text) liegen **auf den + Ebenen** und kennen ihr **Geschoss** *und* ihre **Ebene (Code)**. +- **2D-Zeichnen** (Linie, Polylinie, Rechteck, Kreis, Bogen, Text) findet auf der + Ebene `80 Plangrafik` (bzw. passender Kategorie) statt. + +**Ansichtstypen = Kamera-Projektion + optionaler Schnitt** (vereinheitlicht): + +| Typ | Projektion | Schnitt | +|---|---|---| +| **Grundriss** | Top-View (orthogonal) | horizontal auf `okff + schnitthöhe` | +| **Schnitt** | Front-View in eine Richtung (orthogonal) | vertikale Schnittebene (Geschnittenes + dahinter) | +| **Ansicht** | Front-View in eine Richtung (orthogonal) | kein Schnitt (Fassade außen) | +| **Perspektive** | 3D perspektivisch | — | + +Sichtbarkeit pro Ansicht über Ein-/Ausschalten von Ebenen & Zeichnungsebenen. + +Persistenz (DOSSIER): zwei getrennte JSON-Bäume `dossier_zeichnungsebenen` und +`dossier_ebenen`; Elemente tragen `geschoss`-id + Ebenen-`code`. Plan-View nutzt +eine Clipping-Ebene; weitere Konzepte: Overrides (regelbasiert), Ausschnitte +(View-Snapshots), Massstab (pro Viewport), Layer-Kombinationen, SIA-Räume. + +## 2d. Resource Manager & Prioritäts-Verschneidung 🔴 + +Verwaltete Ressourcen-Bibliotheken wie in Vectorworks, jeweils mit eigenem Manager: + +- **Line Manager** — Linienstile (Stärke, Farbe, Strichelung), wiederverwendbar. +- **Hatch Manager** — Schraffurstile (Muster, Maßstab, Winkel, Linienstil). +- **Component Manager** — Baustoffe mehrschichtiger Bauteile. Pro Component: + **Schraffur** (→ Hatch Manager), **3D-Textur**, Farbe und + **Verschneidungs-Priorität** (`joinPriority`). + +Alles verweist per id auf diese Bibliotheken (Components nutzen Hatches, Hatches +nutzen Linienstile, 2D-Objekte & Ebenen-Defaults nutzen Linienstile/Schraffuren) — +zentral änderbar. + +Regel: **höhere Priorität verschneidet sich zuerst / läuft durch.** Beispiel an +einer T-Ecke (Beton-Wand mit Innen- und Außenputz): + +- **Beton** (höchste Prio) läuft in der Mitte **durch** den Stoß. +- **Putze** (niedrige Prio) **verbinden** sich jeweils auf ihrer Seite mit dem + angrenzenden Putz, gehen aber **nirgends durch** den Beton. + +Das ist die anspruchsvollste Verschneidungs-Logik (Revit „Layer Priority / +Wrapping", Vectorworks „Component-Verschneidung"). Wir bauen sie stufenweise auf +der bereits funktionierenden L-Ecken-Gehrung auf. + +--- + +## 3. Zwei Wege zum 2D-Plan (zentrale Architektur-Erkenntnis) + +Architektur-Pläne entstehen auf **zwei verschiedenen Wegen** — das prägt die ganze Engine: + +**A) Grundriss = aus dem semantischen 2D-Footprint + Symbolik** +Ein Grundriss ist ein horizontaler Schnitt auf ~1 m. Statt ein 3D-Mesh zu zerschneiden, generieren wir ihn **direkt aus den Parametern**: Wand-Achsen + Dicken → Linien; Öffnungen → Lücken + Tür-/Fenstersymbol; Treppe → Lauflinie. Schnell, exakt, sauber, vektorbasiert. + +**B) Schnitt & Ansicht = aus 3D-Projektion (HLR)** +Vertikale Schnitte und Ansichten brauchen echte 3D-Projektion mit verdeckten Kanten (Hidden Line Removal) durch das zusammengebaute Gebäude. + +→ Wir brauchen **beides**: einen sauberen 2D-Symbol-Renderer *und* einen Projektions-Pfad. + +--- + +## 4. Tech-Stack + +| Schicht | Wahl | Begründung | +|---|---|---| +| BIM-Datenmodell | **eigenes parametrisches Gebäudemodell** (TS) | web-ifc ist stark beim *Lesen/Anzeigen* von IFC, schwächer beim *Authoring/Editieren*. Für ein Editier-Tool brauchen wir ein eigenes, editierbares Modell. | +| IFC-Interop | **web-ifc** (ThatOpen, WASM) | Import/Export nach IFC — Brücke zu Revit/ArchiCAD. | +| 3D-Rendering | **Three.js** | Standard; ThatOpen baut darauf auf, also kompatibel. | +| Geometrie-Booleans | **OpenCascade.js** (Öffnungen) *oder* Manifold | Tür/Fenster schneidet Loch in Wand. OCC = exakt (B-Rep), Manifold = schnell (Mesh). Entscheidung in Phase 0. | +| Projektion/HLR | **OpenCascade.js** (`HLRBRep`) | Saubere Linien für Schnitte/Ansichten. | +| 2D-Pläne | **SVG** + eigener Symbol-Renderer | Vektor, druckbar, exportierbar (DXF/PDF). | +| Frontend | **React + TypeScript + Vite** | Schnelles HMR, großes Ökosystem. | +| Worker-Bridge | **Comlink** | Schwere Geometrie im Web Worker, UI bleibt flüssig. | +| State | **Zustand** o.ä. | Passt zum komplexen Dokumentmodell. | +| Persistenz (später) | Postgres + Object Storage | Versionierbare Projekte. | + +--- + +## 5. Datenmodell (grob) + +Vectorworks-orientiert: **Design Layers** (Modell-Eingabe) und **Drawing Layers** +(abgeleitete Ausgabe). Bauteile beziehen ihren Aufbau aus **Components** (verwaltet +im Component Manager). + +``` +Project + ├─ Resources // verwaltete Bibliotheken (Vectorworks-Stil) + │ ├─ LineStyles[] // Line Manager: { id, name, weight, color, dash } + │ ├─ Hatches[] // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId } + │ └─ Components[] // Component Manager: wiederverwendbare Baustoffe + │ Component { id, name, hatchId, texture3d, color, joinPriority } + │ // joinPriority: höher = verschneidet sich zuerst (geht durch) + ├─ Grids (Achsraster) (optional) + ├─ Types // mehrschichtige Aufbauten + │ ├─ WallType { id, name, layers: Layer[] } + │ └─ SlabType { id, name, layers: Layer[] } + │ Layer = { componentId, thickness } // Priorität liegt am Component + │ + ├─ DesignLayers ("Ebenen") // hier wird modelliert & 2D gezeichnet + │ DesignLayer { id, name, elevation(z), height(Δz), + │ defaultLineStyle, defaultHatch, + │ elements: Wall | Door | Window | Slab | Stair | Roof | Space , + │ draw2d: Line | Polyline | Rect | Circle | Arc | Text } + │ Wall { axis, wallTypeId, height } + │ Door { hostWall, position, width, height, swing, symbolId } + │ Window { hostWall, position, width, height, sill } + │ Slab { boundary, slabTypeId } · Space { boundary, name } // Fläche auto + │ + └─ DrawingLayers ("Zeichnungsebenen") // abgeleitete Ausgabe + DrawingLayer { id, name, + type: plan | section | elevation | drawing, + cutHeight(z), // bei plan: Schnitthöhe + sectionLine, // bei section + sourceDesignLayers[], + detailLevel: coarse|medium|fine, + scale, styleOverrides, dims[], labels[], annotations[] } +``` +3D *und* jede Drawing Layer werden **aus den Design Layers abgeleitet**. `cutHeight`, +`detailLevel`, `Styles` und `Component`-Eigenschaften steuern, *wie* abgeleitet wird. + +--- + +## 6. Die harten Risiken (früh angehen) + +1. **Wand-Verbindungen / Cleanup** ⚠️ — Wo Wände aufeinandertreffen, müssen sie sauber verschneiden. **L-Ecken-Gehrung: ✅ erledigt.** Offen & berüchtigt schwer: **Prioritäts-basierte T-/X-Stöße bei mehrschichtigen Wänden** (Beton durch, Putz verbindet seitlich, geht nicht durch — siehe 2d). → stufenweise auf der Gehrung aufbauen. +2. **Gehostete Öffnungen** — Tür/Fenster muss synchron mit der Wand bleiben (verschieben, schneiden). → Saubere Host-Beziehung im Modell. +3. **Symbolischer Plan-Renderer** — normgerechte Darstellung (Schwenkbögen, Schraffuren der geschnittenen Bauteile, Lauflinien). → Eigenes Regelwerk; früh prototypen. +4. **Schnitt-/Ansichts-Projektion (HLR)** durch ganzes Gebäude — Performance. → Worker + Caching. +5. **IFC-Treue** — verlustarmer Round-Trip. → Früh mit echten IFC-Dateien testen. +6. **Geschoss-übergreifende Elemente** (Treppen, Lufträume). + +--- + +## 7. Phasen-Roadmap + +### Phase 0 — Spike: das größte Risiko zuerst +**Ziel:** Beweisen, dass der symbolische Plan-Pfad im Browser funktioniert. +- Eine Wand zeichnen, eine Tür platzieren → Öffnung wird geschnitten (3D). +- Daraus **Grundriss als SVG** generieren: Wand-Schnittlinien + Tür-**Schwenkbogen**. +- ✅ *Erfolg:* sauberer, schöner Grundriss-Ausschnitt aus einem semantischen Modell. + +### Phase 1 — MVP: durchgehende Wohnbau-Scheibe +- **Dokumentmodell (Vectorworks-Stil):** Design Layers ("Ebenen") + Drawing Layers + ("Zeichnungsebenen", Typ plan/section/elevation, mit `cutHeight`). +- **Resource Manager:** Line Manager, Hatch Manager, Component Manager (mit + `joinPriority`, Schraffur, 3D-Textur). +- **Wände** mehrschichtig (✅) mit Eck-Gehrung (✅); **Prioritäts-T-Stöße** (Risiko #1). +- **Türen & Fenster** gehostet in Wänden (Risiko #2). +- **2D-Zeichnen:** Linie, Polylinie, Rechteck, Kreis, Bogen mit Stilen. +- **Decken/Böden** (Slabs); 3D-Viewport + **live Grundriss**, Basis-Bemaßung. +- → aus DOSSIER (§11): Wand-Referenzlage (mid/left/right), Öffnungs-Detailgrad mit Dokument-Override, Decken-Aussparungen, Element-Übersicht (BIM-Tree). +- ✅ Ein einfaches Haus modellieren → saubere Pläne pro Geschoss. + +### Phase 2 — Vollständiger Bauteil-Satz Wohnbau +- **Treppen** (mit Lauflinie im Plan), **Dächer**, Geländer. +- **Räume/Spaces** mit automatischer Flächenberechnung & Raumstempel. +- Stützen/Unterzüge (falls nötig). +- Materialien & einfache Visualisierung. +- → aus DOSSIER (§11): Treppen-Typen (gerade/L/Wendel) + geschoss­übergreifend, Dach-Typen (Pult/Sattel/Walm/Mansarde), Stützen-Profile, **SIA-416-Räume** + CSV, Raumstempel-Builder, Stil-Kataloge (Wände/Öffnungen). + +### Phase 3 — Plan-/Dokumentations-Modul ⭐ (Differenzierung) +**Hier gewinnen wir. Maximale Politur.** +- **Grundrisse, Schnitte (HLR, Risiko #4), Ansichten.** +- **Automatische Bemaßung** (Außenketten, Achsen, Öffnungen) + manuelle. +- Schraffuren geschnittener Bauteile, Raumstempel, Beschriftungen, Symbole. +- **Plansätze/Sheets** mit Titelblock, Maßstäben, Layout. +- Schöne Typografie & Linienführung — genau das, was die Großen vermasseln. +- → aus DOSSIER (§11): **Massstab pro Viewport** (Auto-DPI, Plotweight-/Schraffur-Skalierung), Section-Style (3D-Schnittflächen), Ausschnitte (View-Snapshots) + Layer-Kombinationen, Kamera-Presets (Kardinal/Iso, **Norden-Rotation**), Detail-Bindung an Ausschnitt, Multi-Page-PDF @DPI, regelbasierte Overrides, Rich-Text-Annotationen. + +### Phase 4 — Interop (Import/Export) +- **Import:** **DWG/DXF** (2D-Pläne/Bestand), **IFC** (BIM-Bestand), **STL/OBJ** + (Mesh-Referenzmodelle), **XYZ** (Punktwolken aus Vermessung). +- **Export:** **IFC** (Brücke zu Revit/ArchiCAD), **DWG/DXF**, **glTF/OBJ**. +- Round-Trip-Tests mit echten Dateien (Risiko #5). +- → aus DOSSIER (§11): **Swisstopo-Import** (swissBUILDINGS3D / swissALTI3D / SWISSIMAGE, LV95↔WGS84) ⭐ CH, OSM-Overpass-Kontext, Terrain-Mesh-Generator. + +### Phase 5 — Persistenz & Konten +- Accounts, Projekte speichern/laden, Versionierung, Auto-Save. +- → aus DOSSIER (§11): Projekt-Persistierung auf Browser-Storage migrieren (IndexedDB statt doc.Strings); Presets/Favoriten cross-projekt (LocalStorage, Export/Import). + +### Phase 6 — Export & Kollaboration +- Export: **PDF**, **DXF** (Pläne); **IFC**, **glTF** (3D). +- Teilen per Link, Kommentare, später Echtzeit-Co-Editing. + +### Phase 7 — Produktisierung +- Performance-Härtung (große Modelle), Onboarding, Pricing, PWA/Offline. + +--- + +## 8. Offene Fragen +- Booleans: OpenCascade (exakt) vs. Manifold (schnell) — Entscheidung in Phase 0. +- Welche Normen für Plandarstellung (SIA / DIN / …)? → beeinflusst Symbolik. *(Hinweis: User ist in der Schweiz → SIA prüfen.)* +- Wie viel Statik/Bauphysik (gar nicht / später)? +- Pricing-Modell (Freemium, pro Seat?). + +--- + +## 9. Nächster konkreter Schritt +**Phase 0 starten:** Projekt scaffolden + Spike bauen — Wand + Tür mit geschnittener Öffnung → schöner Grundriss-Ausschnitt als SVG (mit Schwenkbogen). Das entschärft Risiko #1–#3 (Modell, Hosting, Symbolik) auf einmal. + +--- + +## 10. Leitentscheidungen & UI-Architektur + +### 10a. Leitentscheidungen aus der Recherche (Details in `docs/`, Index `docs/README.md`) +1. **Pure-Ableitungs-Architektur** als Fundament — ein semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern (Store + Undo früh). +2. **OCCT/replicad im Web Worker** früh als Spike — kritischer Pfad für Schnitt/Ansicht (HLR), exakte Wand-Booleans und IFC. WASM-Größe + HLR-Kosten (pro Ansicht cachen) validieren. +3. **Component-getriebene Prioritäts-Verschneidung** (`joinPriority` am Component): höchstes gemeinsames Material läuft durch, Rest mitert. 2D-Plan analytisch, exakte 3D-Booleans im Worker. +4. **SVG/Paper-Space-Maßstabsmodell** — Strichstärke/Text/Hatch in mm, `dpi=96·devicePixelRatio`, ein Serializer für Bildschirm + PDF + DXF. +5. **CH-Spezifika als Differenzierer** ohne Backend — SIA-416 (reine Logik) + serverloser Swisstopo-Flow (CORS-offen, Parzelle/EGRID, Norden-Rotation, Origin-Shift). + +### 10b. Panel-System (dockbar, Tabs, erweiterbar) — NEU +- **Docks links & rechts**; Panels in **Tab-Strips** gruppierbar (z. B. Zeichnungsebenen & Ebenen als Tabs eines Docks). +- **Panel-Registry** → eigene Panels und spätere **Plugins** registrieren sich und erscheinen als Panel. +- **Verschiebbar & floatend (am Tab gegriffen):** Ein Panel wird **am Tab selbst** (im Tab-Strip) gezogen → innerhalb des Docks **umsortieren**, ins andere Dock ziehen, oder aus dem Dock lösen. Andocken am **linken/rechten Rand** (Andock-Zonen beim Ziehen hervorheben); wird nicht angedockt, **schwebt** das Panel als freies (verschieb- und größenveränderbares) **Floating-Fenster** über dem Arbeitsbereich. Float-Position/Größe + Dock-Zustand werden gespeichert. +- **Fenster-Layouts speicherbar** (localStorage, benannte Layouts; Standard-Layout als Default). +- Der **Ressourcen-Manager** wird ebenfalls ein Panel (rechtes Dock). +- Pro Layer-Panel oben ein **Anzeige-Modus-Dropdown**: *nur aktive · alle anzeigen · aktive + andere grau* (DOSSIER/Vectorworks „Layer Options") — wirkt auf Plan & 3D. + +### 10c. Plan-Navigation +- Grundriss/Schnitt/Ansicht: **Pan** (ziehen), **Zoom** (Mausrad zum Cursor), **Einpassen** — analog zum 3D-Viewport. SVG-`viewBox`-Transform. + +### 10d. Backend & Kollaboration (Details: `docs/backend.md`) +- **Jetzt:** client-only (IndexedDB + Datei-Export/Import), kein Server. Modell JSON-serialisierbar + Edits als Operationen → **CRDT-fähig** halten. +- **Phase 5:** **Supabase self-hosted** (Docker Compose: Postgres + Auth + Storage) für Konten/Projekte/Dateien. +- **Phase 6:** **Yjs + Hocuspocus** (CRDT-Sync-Container, persistiert nach Postgres) für Echtzeit-Kollaboration. Alles self-hosted. +- Empfehlung: Stack **noch nicht** aufsetzen (würde den Modellierer ausbremsen); Weiche ist gestellt, Einführung additiv. + +### 10e. Top-Bar & Footer/Status-Leiste (Vectorworks-Stil) — NEU +- **Top-Bar (Oberleiste, wie DOSSIER `toolbar.py`/`ToolbarApp.jsx`):** Ansichts-Umschalter (Grundriss/Perspektive/Schnitt/Ansicht), Render-/Darstellungsmodus, aktives Geschoss + aktive Ebene, Snapping-Schalter, Massstab, Werkzeug-Kontext, Einstellungen/Ressourcen. +- **Footer/Status-Leiste (wie Vectorworks unten):** Cursor-Koordinaten **X/Y/Z**, Einheit, aktueller **Massstab** (1:N) + **Zoom %**, aktives Geschoss/Ebene, **Snap-Status**, kurzer Werkzeug-Hinweis links. +- Beide an das Panel-/Dock-Layout angedockt; Inhalte aus dem Modell abgeleitet. + +### 10f. Maus-Interaktion & Kontextmenü — NEU +- **Maus-Schema:** **Mitte = navigieren** (Plan: Pan · 3D: Orbit, `Shift`+Mitte: Pan) · **Links = Auswahl** (Einzelklick + **Markierrahmen/Aufziehrahmen** für Mehrfachauswahl im 2D) · **Rechts = Kontextmenü** · **Rad = Zoom** (zum Cursor). + - Marquee: Aufziehen von links→rechts = nur vollständig umschlossene Elemente; rechts→links = auch berührte (wie CAD-üblich). +- **Eigenes Kontextmenü-System** (gestylt, dunkel, wiederverwendbar) — kein Browser-Menü. +- **Ebenen-Kontextmenü 1:1 wie DOSSIER** (Einträge aus `layers_panel.py`/`DrawingLevelsApp.jsx` übernehmen) — auch auf Zeichnungsebenen. +- Kontextmenü generisch, damit Plan-Elemente, Panels & spätere Plugins eigene Einträge registrieren können. + +--- + +## 11. Aus DOSSIER übernehmen — Backlog + +Konkret im DOSSIER-Rhino-Plugin umgesetzte Features, die sich für den Standalone-Port lohnen. Bereits abgedeckt (Layer-Modell, mehrschichtige Wände, Prioritäts-Stöße, Component-/Line-/Hatch-Manager, Ansichtstypen, Detailgrade) ist hier **nicht** erneut gelistet — nur das Zusätzliche. Aufwand: S/M/L. Phase verweist auf §7. + +> **⭐ CH-Schätze (Schweiz-spezifisch, kaum woanders verfügbar):** +> - **Swisstopo-Geodaten** — swissBUILDINGS3D (3D-Bestand), swissALTI3D (präzises Höhenmodell), SWISSIMAGE (10-cm-Orthofoto), offene STAC-APIs ohne Auth, inkl. **LV95↔WGS84**-Transformation. Echter Standort-Kontext per Knopfdruck statt manuellem CAD-Import. +> - **SIA-416-Flächen** — Raum-Klassifikation (HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV/Excel-Export. Pflicht für CH-Energie-/Flächennachweise. +> - **Norden-Rotation** bei Kamera-Presets — Georeferenzierung passend zu Swisstopo/swissBUILDINGS. + +### Bauteile + +| Feature | Nutzen | Aufwand | Phase | +|---|---|---|---| +| Wand-Referenzlage (mid/left/right) | Achse intuitiv auf Aussenkante/Mitte legen; hilft beim Import fremder Dateien | S | 1 | +| Öffnungs-Detailgrad + Dokument-Override (`aktive_darstellung`) | LoD je Massstab (1:500 Rechteck → 1:50 Glas/Sims) global umschaltbar; kritisch für Mixed-Scale | M | 2 | +| Fenster/Tür mit Rahmen, Brüstung, Sims, Glas, Flügelzahl | Öffnung als vollwertiges Bauteil statt nur Loch; Render-Realismus | M | 2 | +| Tür-Schwenkbogen (Öffnungswinkel + Anschlagseite) | Öffnungsbahnen für Möblierung/Kollision; Standard-Plansymbol | M | 2 | +| Decken-Aussparungen (Treppenauge, Schächte, Kamin) | Konstruktiv echte Deckenöffnungen, nicht nur sichtbar | M | 1–2 | +| Decken UK/OK-Override | Abhängungen, schräge Brüstungen, abweichende Raumhöhen | S | 1 | +| Treppen-Typen gerade/L/Wendel + Stufen/Lauflinie/Podest | Volle Vertikalerschliessung, volumetrisch korrekt, Plan-Symbole | M | 2 | +| Treppe geschoss­übergreifend (`geschoss_end`, Höhen-Override) | Atrien, Rampen, Mehr-Geschoss-Läufe (Risiko #6) | S | 2 | +| Treppen-2D-Symbol mit Auf-/Abpfeil + Schnitt | Normgerechtes Plansymbol (Richtung, Stufenzahl, Lauflinie) | M | 2 | +| Dach-Typen Pult/Sattel/Walm/Mansarde + Neigung(en) | 3D-Volumen mit Gefälle, Kubatur, Material; Mansarde später (L) | M–L | 2–3 | +| Stützen-Profile (Quadrat/Rechteck/Rund/I/Rohr) + Drehung | Beton- und Stahltragwerk mit echtem Querschnitt | M | 2 | +| Träger achs-basiert, hängt unter Decken-OK | Unterzug folgt Deckenoberkante, weniger Fehler bei Updates | S | 2 | +| **SIA-416-Räume** (HNF/NNF/VF/FF/GF/AGF) + Bilanz-CSV ⭐ | CH-Flächennachweis, Excel-Export | M | 2 | +| Raum-Stempel-Builder (Drag-&-Drop-Felder) + Fläche-Rundung + Personen | Projekt-eigene Stempel-Layouts ohne Code; lesbare Listen; Brandschutz | M | 2–3 | +| Element-Übersicht (BIM-Tree Geschoss→Kind→Element, Suche, Zoom) | Inhaltsverzeichnis bei 100+ Elementen; Shift-Klick = Zoom | S | 1 | +| Stil-Kataloge Wände & Öffnungen (Presets) | Standard-Typen 1-Klick; globaler Stilwechsel | M | 2 | +| Grip-Editing (Wand-Endpunkte, Schnitt-Symbole im Plan) | Direktes Ziehen statt Dialog; 2D/3D-Sync; wichtig im Browser | L | 3–4 | + +### Darstellung / Ressourcen + +| Feature | Nutzen | Aufwand | Phase | +|---|---|---|---| +| Regelbasierte Overrides (Layer-/Tag-/Name-Regel, Priorität, Templates) | Automatische Farb-/Strich-/Linientyp-Anpassung; wiederverwendbar (vertieft §2c „Overrides") | M | 3 | +| Section-Style für 3D-Schnittflächen (Schraffur + Schnittkante/Silhouette) | 3D-Schnitt-Rendering im Viewport, nicht nur 2D-Plan | M | 4 | +| Massstabs-abhängige Linientyp-/Plotweight-Skalierung | Linientypen & Strichstärken bei 1:N korrekt sichtbar; PDF-Treue | M | 3–4 | +| Material-Bibliothek mit PBR (Rauheit/Reflexion/Transparenz) + Templates | Vertieft Component-Manager um Renderqualität; Seeds Beton/Holz/Dämmung | M | 3 | +| Rich-Text-Annotationen (Bold/Italic/Hoch-/Tiefstellung, Maskierung, Rahmen) | Bemaßungs-Indizes, formatierte Beschriftungen auf Canvas | M | 3 | +| LoD-bewusste Stil-UI (zeigt nur passende Controls je Geometrie-Typ) | Weniger kognitive Last (keine Füll-Optionen bei 3D-Auswahl) | S | 1 | + +### Pläne / Output + +| Feature | Nutzen | Aufwand | Phase | +|---|---|---|---| +| **Massstab pro Viewport** mit Auto-DPI + Schraffur-/Strich-Skalierung | Exakte Masse & lesbare Strichstärken ohne manuelle Kalibrierung | M | 3 | +| Ausschnitte / View-Snapshots (Kamera + Darstellung + Massstab) | Navigation über 50+ Ansichten; ersetzt Ordner-Wildwuchs (vertieft §2c) | M | 3 | +| Layer-Kombinationen als Presets (live oder eingefroren) | Bauphasen/Varianten/MEP per Klick statt manuellem Toggling (vertieft §2c) | S | 3 | +| Kamera-Presets (Kardinal N/O/S/W, Iso-Oktanten, **Norden-Rotation** ⭐) | Schnelle Ansichtswechsel; Georeferenzierung für Swisstopo | S | 3 | +| Detail↔Ausschnitt-Bindung + „Alle aktualisieren" | Titelblock/Detail synchron umbenennen; 1-Klick-Sync aller Schnitte | M | 3 | +| Multi-Page-PDF-Export @DPI (Vektor) | Druckfertige Plansätze — Kern-Output (ergänzt Phase-3-Sheets) | M | 3 | +| 9-Punkt-Bemaßung/Objekt-Info (lesen + verschieben/skalieren/rotieren) | Direktes Dimensionieren ohne Properties-Panel | M | 3 | + +### Kontext / Daten + +| Feature | Nutzen | Aufwand | Phase | +|---|---|---|---| +| **Swisstopo-Import** (swissBUILDINGS3D/ALTI3D/SWISSIMAGE) ⭐ CH | Authentischer Standort-Kontext, offene APIs, kein Auth | M | 4 | +| LV95↔WGS84-Transformation ⭐ CH | Karten-Anzeige + präzise CH-Koordinaten; Formeln direkt portierbar | S | 4 | +| OSM-Overpass-Import (Strassen/Gebäude/Wasser/Grün, 7 Kategorien) | Weltweiter, kostenloser Kontext; ergänzt Swisstopo | M | 4 | +| Terrain-Mesh-Generator (Mesh/TIN/NURBS-Patch/Höhenlinien, Volumen für Schnitt) | Geländemodell aus Höhendaten; Section-Cut-Füllung; portierbar zu Three.js | L | 4 | +| Auto-Zoom auf Import + Nullpunkt-Verschiebung (LV95→0/0/0) | Modellierungsgenauigkeit trotz Millionen-Koordinaten; UX-Standard | S | 4 | +| Projekt-Persistierung browser-nativ (IndexedDB statt doc.Strings) | Projekt kapselt seine Einstellungen lokal | M | 5 | +| Cross-Projekt-Presets (LocalStorage-Favoriten, Export/Import, Team-Sharing) | Einmal speichern, überall nutzen | S | 5 | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..042250a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,152 @@ +# Dokumentation — Standalone Browser-BIM (cad) + +> Stand: 2026-06-29 · Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen) · +> [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md). + +Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die +eigenständige Browser-Variante des DOSSIER-Rhino-Plugins (React + TypeScript + +Three.js + SVG, alles client-side). **Leitprinzip aller Dokumente:** ein +semantisches Modell ist die einzige Wahrheit; jede Sicht (3D, Grundriss, Schnitt) +wird **abgeleitet**, Darstellung erst beim Rendern angewandt. Bezeichner im Code +englisch (Vectorworks-Terminologie), Prosa deutsch, Einheiten intern in Metern. + +Die Dokumente sind in vier Gruppen geordnet: **Tech** (Bibliotheken/Kernel), +**Architektur/Design** (Aufbau & Bauteile), **UX** (Oberfläche & Interaktion), +**Swisstopo/SIA** (CH-Geodaten & Flächenstandards). + +--- + +## Tech — Technologie- & Bibliotheksauswahl + +### [research/tech-selection.md](research/tech-selection.md) +Evaluiert den kompletten Client-Stack für ein server­loses BIM-Werkzeug und +empfiehlt **`replicad`** (idiomatische TS-Schicht über `opencascade.js`/OCCT, MIT) +als primären B-Rep-Kernel im Web Worker, ergänzt durch **`Manifold`** (Apache-2.0) +für schnelle, robuste Mesh-Booleans auf Importgeometrie — denn nur ein echter +B-Rep-Kernel liefert exakte 2D-Ableitungen, und genau das löst replicads +`drawProjection` (OCC-HLR, `{visible, hidden}`-Kanten direkt im Browser). Weitere +Wahl: Import via **web-ifc + Fragments** (IFC), `dxf-parser` (DXF) und +`libredwg-web` (DWG, aber **GPL-3.0 → vorab klären/kapseln**); Vektor-Export über +**`svg2pdf.js` + `jsPDF`** (PDF) und **`@tarikjabiri/dxf`** (echte Hatch-Entities); +Schraffuren als SVG-`` mit `userSpaceOnUse` (maßstabskorrekt); Rendering +über **`three/webgpu`** mit automatischem WebGL2-Fallback. Top-Risiken: DWG-Lizenz, +OCCT-WASM-Größe, HLR-Kosten (pro Ansicht cachen), WebGPU vor Migration benchmarken. + +--- + +## Architektur/Design — Aufbau, Datenmodell & Bauteile + +### [../ARCHITECTURE.md](../ARCHITECTURE.md) +Die übergreifende Standalone-Architektur und die systematische Übersetzung jedes +DOSSIER-Konzepts in ein Browser-Äquivalent (30-zeilige **Rhino→Browser-Mapping- +Tabelle**). Kern: das semantische `Project` (JSON) als einzige Wahrheit mit pure +`derive()` zu Scene3D/Plan/Section; ein **Zwei-Achsen-Datenmodell** +(`drawingLevels` × `layers`) plus `Resources`/`WallType`/`Element`/`Sheet`; ein +**Zustand-Store** ersetzt DOSSIERs `sc.sticky`-Bus, **`.cad.json`** (File System +Access API) + IndexedDB-Autosave ersetzen `doc.Strings`, und ein **Immer-Patch- +Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit +**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering +über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt. + +### [design/elements.md](design/elements.md) +Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste +Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone- +Algorithmus, Port von `_t_junction_layer_overrides`) — das höchstpriorisierte +gemeinsame Material läuft durch, der Rest mitert an; Priorität sitzt am **Component** +(`joinPriority` als Daten, nicht Hardcode). Deckt zudem gehostete Öffnungen mit +LoD-Stufen (`_OEFF_PIECE_DEFS`), Decken mit Aussparungen, Treppen (gerade/L/Wendel, +geschossübergreifend, normgerechtes 2D-Symbol), Dächer, Tragwerk und **SIA-416-Räume** +(Shoelace-Fläche, Stempel, Färbung über Override-Preset) ab; das `Tool`-Interface + +Snap-Engine ersetzt DOSSIERs Rhino-Command-Aliases. + +### [design/plans-output.md](design/plans-output.md) +Der Weg zu **schönen, normgerechten, druckfertigen 2D-Plänen** (Vektor-PDF). Zentrale +Erkenntnis: Ansichten = Kamera + optionaler Schnitt, und es gibt **zwei Plan-Pfade** +(symbolischer Grundriss aus Parametern vs. Schnitt/Ansicht via **HLR im Worker**, +gecacht). Empfiehlt SVG/Paper-Space als Maßstabsmodell — Strichstärke/Schraffur sind +direkt in mm definiert (`dpi = 96·devicePixelRatio`, Hatch-Faktor `sqrt(N)/10`), was +DOSSIERs fragiles Plotweight-Rescaling überflüssig macht. Behandelt außerdem +Ausschnitte/View-Snapshots, Layer-Kombinationen, Kamera-Presets + Norden-Rotation, +Bemaßung sowie Sheets + Vektor-PDF-Export (`svg2pdf.js`/`jsPDF`, `PAPER_MM`). + +### [design/resources-graphics.md](design/resources-graphics.md) +Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Component-/Hatch-/Line- +Manager, alles per id referenziert), die `resolveStyle`-Kette +(ByLayer → Element-Style → Override) und die **regelbasierte Overrides-Engine**. +Schlüssel-Empfehlung: Overrides als **reine Render-Reads** modellieren (kein +Backup/Restore wie in DOSSIER, da nichts mutiert wird) — inklusive eines +**SIA-416-Presets** statt hartcodierter Färbung. Ergänzt Symbol-Bibliothek, +Rich-Text-Annotationen, den LoD-Resolver (`resolveDetail`) und den Section-Style für +geschnittene Bauteile; eine Tabelle zeigt, was der Browser hier gegenüber DOSSIER +vereinfacht. + +--- + +## UX — Oberfläche, Interaktion & gefühlte Geschwindigkeit + +### [research/ux-patterns.md](research/ux-patterns.md) +Untersucht UX-Muster moderner Browser-CAD/BIM-Tools (Arcol, Snaptrude, TestFit, +Onshape, Vectorworks, Figma) und leitet **priorisierte Leitplanken** ab. Empfehlung +für die Grundstruktur: eine feste, Figma-artige **3-Zonen-Shell** +(Navigator/Viewport/Inspector) — explizit gegen Paletten-Wildwuchs —, mit +Vectorworks-Navigation-Tabs für unsere zwei Achsen und einem zwei/drei-spaltigen +Resource-Manager als Vorbild. Größte Differenzierungs-Hebel laut Doku: +**Snapping/Inferencing** im Onshape-Stil (Vertex-Highlights, Achsenlinien, Shift +unterdrückt) und **Grip-Editing über Sicht-Grenzen** (Schnittlinie im Plan ziehen); +dazu perceived-performance-Muster (Skeletons, optimistic UI, 150-ms-Delay-then-show), +eine Command-Palette (Cmd/Ctrl-K) und learn-by-doing-Onboarding am Sample-Projekt. + +--- + +## Swisstopo/SIA — Schweizer Geodaten & Flächenstandards + +### [research/swisstopo-sia.md](research/swisstopo-sia.md) +Dokumentiert die **live getesteten** geo.admin.ch-Dienste und die SIA-Flächenlogik. +Überraschendster Befund: **alles ist ohne eigenen Backend-Proxy nutzbar** — alle vier +Hosts senden `access-control-allow-origin: *`, und der Height-Service antwortet +faktisch frei. Schlüssel fürs Browser-Gelände-Mesh ist **swissALTI3D als Cloud- +Optimized GeoTIFF** (Range-Requests via `geotiff.js`, kein Full-Download); die +**Parzelle** kommt direkt als LV95-Polygon + EGRID aus dem Identify-Service. Empfiehlt +einen konkreten Library-Satz (`proj4`, `geotiff`, `3DTilesRendererJS`/`loaders.gl`) +und ordnet die Umsetzung in ROADMAP-Phasen ein (Phase 2 SIA-Räume = reine Logik → +Phase 4a Koordinaten → 4b Gelände/Orthofoto → 4c Nachbargebäude). SIA-Teil: +verifizierte SIA-416-Formeln, DOSSIERs SIA-Logik 1:1 portierbar (Shoelace, +`compute_sia_bilanz`, CSV mit BOM); Origin-Shift (LV95 → 0/0/0) ist Pflicht wegen +float32-Jitter, Caching über IndexedDB. + +--- + +## Top-5 Querschnitts-Empfehlungen für die ROADMAP + +Diese fünf Punkte tauchen in mehreren Dokumenten auf und sollten die ROADMAP-Planung +und Priorisierung leiten: + +1. **Pure-Ableitungs-Architektur als unverhandelbares Fundament** — ein + semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern. + Trägt ARCHITECTURE.md, beide Plan-/Stil-Designs und die UX-Doku (billiger + Split-View, optimistic Edits, kein Cache-Stale-/Override-Restore-Aufwand). Muss + früh stehen (Store + Undo, Phase 0–1), weil sie alles Spätere prägt. + +2. **OCCT/replicad im Web Worker früh als Spike absichern** — der B-Rep-Kernel und + sein `drawProjection`-HLR sind der kritische Pfad für Schnitt/Ansicht (Risiko #4) + *und* für exakte Wand-Booleans (Risiko #1) *und* für IFC. WASM-Größe, HLR-Kosten + (pro Ansicht cachen) und das Worker-Pattern sollten vor Phase 3 mit einer echten + Szene validiert werden. + +3. **Component-getriebene Prioritäts-Verschneidung (Backbone-T/X) als zentrales + Geometrie-Risiko** — `joinPriority` als Daten am Component; höchstes gemeinsames + Material läuft durch, Rest mitert. Verbindet elements.md + resources-graphics.md; + 2D-Plan rein analytisch, exakte 3D-Booleans im Worker. Stufenweise umsetzen + (Risiko #1, Phase 1). + +4. **SVG/Paper-Space-Maßstabsmodell + maßstabskorrekte Schraffuren durchgängig** — + Strichstärke/Text/Hatch in mm, `dpi = 96·devicePixelRatio`, Hatch `sqrt(N)/10`, + SVG-`` mit `userSpaceOnUse`. Eliminiert DOSSIERs Plotweight-Rescaling und + speist denselben Serializer für Bildschirm, PDF und DXF (tech-selection + + plans-output + resources-graphics). + +5. **Schweiz-Spezifika als Differenzierer ohne Backend-Last** — SIA-416-Bilanz + (reine Logik, Phase 2, ⭐) und der serverlose Swisstopo-Flow (CORS-offen, + COG-Terrain, Parzelle/EGRID, Norden-Rotation, Origin-Shift). Klein im Aufwand, + groß im CH-Marktwert; SIA-Färbung läuft über das Override-Preset, nicht über + Sonderpfade. diff --git a/docs/backend.md b/docs/backend.md new file mode 100644 index 0000000..96eb67a --- /dev/null +++ b/docs/backend.md @@ -0,0 +1,43 @@ +# Backend & Kollaboration — Architekturentscheidung + +> Stand: 2026-06-29 · Ziel: komplett self-hosted, kollaborations-offen + +## Grundsatz +So lange wie möglich **client-only** bleiben; das Backend additiv einführen, ohne +den Kern umzubauen. Die Pure-Ableitungs-Architektur (ein serialisierbares Modell, +alle Sichten abgeleitet) ist bereits kollaborations-freundlich. + +## Phasen +| Phase | Persistenz / Backend | +|---|---| +| **0–3** (Modellierer) | **Client-only**: IndexedDB + Datei-Export/Import (JSON). Offline-fähig (PWA möglich). Kein Server. | +| **5** (Konten/Persistenz) | **Supabase self-hosted** (Docker Compose): Postgres + Auth + Storage. Projekte, Versionen, Dateien (IFC/Pläne/Assets). Row-Level-Security pro Nutzer/Projekt. | +| **6** (Kollaboration) | **Yjs (CRDT)** + **Hocuspocus** Sync-Server (Container), persistiert Snapshots nach Postgres. Presence/Cursors. Optional Supabase-Realtime nur für leichte Broadcasts. | + +## Warum Yjs/Hocuspocus statt reinem Supabase-Realtime +Gleichzeitiges Editieren eines strukturierten Dokuments braucht Konfliktauflösung +(CRDT). Yjs ist dafür Standard; Hocuspocus ist der self-hostbare Server dazu und +kann nach Postgres (Supabase) persistieren. Supabase-Realtime allein wäre nur +Pub/Sub ohne Merge-Semantik. + +## Was wir JETZT schon richtig machen (damit Collab nicht blockiert) +- Dokumentmodell rein **JSON-serialisierbar**, keine Zyklen, stabile IDs. +- Edits immutable über `setProject` → später leicht auf Yjs-Doc abbildbar + (`Y.Map`/`Y.Array` je Sammlung: drawingLevels, layers, components, walls …). +- Kein Wahrheits-Zustand im Three.js-Scene-Graph oder im DOM — alles ableitbar. +- Ressourcen (Components/Hatches/Lines) als referenzierte Bibliotheken (IDs) → + gut mergebar. + +## Self-hosted Stack (Skizze, Phase 5/6) +``` +docker-compose: + supabase (postgres, gotrue auth, storage, kong gateway, studio) + hocuspocus (yjs websocket sync, persist -> postgres) + web (vite build, statisch via nginx/caddy) +``` +Alles auf eigener Infrastruktur lauffähig; keine externe Cloud nötig. + +## Offene Punkte +- Granularität der CRDT-Struktur (pro Sammlung vs. pro Element). +- Datei-Storage (Supabase Storage vs. S3-kompatibel/MinIO im selben Stack). +- Auth-Modell (E-Mail, OIDC/SSO fürs Büro). diff --git a/docs/design/context-menu.md b/docs/design/context-menu.md new file mode 100644 index 0000000..2edc7c3 --- /dev/null +++ b/docs/design/context-menu.md @@ -0,0 +1,54 @@ +# Kontextmenü & Anzeige-Modi — 1:1 wie DOSSIER + +> Quelle: DOSSIER `src/components/ContextMenu.jsx` + `DrawingLevelsApp.jsx`/Ebenen-Panel. +> Maus-Schema (unsere Festlegung): **Mitte = navigieren** (Plan Pan / 3D Orbit, Shift+Mitte Pan) · +> **Links = Auswahl** · **Rechts = Kontextmenü** · **Rad = Zoom**. + +## ContextMenu-Komponente (generisch, wiederverwendbar) +`ContextMenu({ x, y, items, onClose, title })` +- **item**: `{ label, icon?, onClick, disabled?, danger?, shortcut?, divider? }` +- Fixed-Position mit Rand-Clamp (4px); min-width 200px; Radius 13px; weicher Schatten; + Mount-Animation `scale(.94) translateY(-5px)` 100ms; Item-Hover = `--accent-dim`; + `danger` = rote Schrift; `divider` = 1px Trenner; Titel oben (caps, 10px, muted). +- **Schließen:** Klick außerhalb · Escape · erneuter Rechtsklick · nach Item-Klick. +- z-index ~300 (unter Modals). + +## Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile) +1. **Ebeneneinstellungen…** (`settings`) — Ebenen-Dialog *(Rhino-spez. → vorerst Stub)* +2. — Trenner — +3. **Sub-Ebene hinzufügen…** (`add`) +4. **Selektion hierher übertragen** (`move_down`) *(braucht Auswahl → später)* +5. — Trenner — +6. **Duplizieren** (`content_copy`) — Klon mit Suffix „ KOPIE" +7. **Eigenschaften kopieren** (`colorize`) — Farbe + Linienstärke +8. **Eigenschaften einfügen** (`format_paint`) — disabled wenn Clipboard leer +9. — Trenner — +10. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene +Titel = Ebenenname/-code. + +## Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung) +1. **Einstellungen…** (`settings`) +2. — Trenner — +3. **Duplizieren** (`content_copy`) — Klon mit Suffix „ Kopie" +4. — Trenner — +5. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene +Titel = Name. + +## „+"-Menü (Zeichnungsebenen) +**Geschoss** (`layers`) · **Schnitt / Ansicht** (`content_cut`) · — · **Zeichnung** (`edit_note`) + +## Anzeige-Modi (Dropdown oben im Panel) — **5 Modi** (für Ebenen UND Zeichnungsebenen) +| Wert | Label | Verhalten | +|---|---|---| +| `all_force` | **Alle anzeigen** | alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge → wechselt zu „Ausgewählte" | +| `all` | **Ausgewählte** | sichtbar nach per-Zeile-Flag | +| `active` | **Nur aktive** | nur aktive sichtbar; andere stark gedimmt | +| `grey` | **Andere grau** | aktive normal, andere 45% (Sichtbarkeits-Flags gelten) | +| `grey_locked` | **Andere grau & gesperrt** | wie grey + andere gesperrt | +Regel: Klick aufs Auge in `all_force`/`active` schaltet automatisch auf `all`. +*(Hinweis: Panel-System-Workflow hatte vorerst nur 3 Modi — beim Kontextmenü-Build auf diese 5 angleichen.)* + +## Migration (was sofort geht / was Stub bleibt) +- Sofort: ContextMenu-Komponente 1:1; Duplizieren, Eigenschaften kopieren/einfügen, Löschen, + +Menü, 5 Anzeige-Modi (alles reine JSON-State-Operationen). +- Stub/später: „Ebeneneinstellungen…"/„Einstellungen…" (Dialog), „Sub-Ebene hinzufügen", „Selektion hierher übertragen". diff --git a/docs/design/drawing-tools.md b/docs/design/drawing-tools.md new file mode 100644 index 0000000..50fdfd4 --- /dev/null +++ b/docs/design/drawing-tools.md @@ -0,0 +1,760 @@ +# Aktive Zeichen- und Bearbeitungs-Werkzeuge + +Status: Entwurf. Dieses Dokument spezifiziert das **Tool-System** für das aktive +Erzeugen von Modell-Elementen durch Zeichnen im Grundriss: Wände (Achs-Polylinie +→ `Wall` eines `WallType`) sowie reine 2D-Geometrie (Linie, Polylinie, Rechteck, +Kreis, Bogen, Text). Es definiert die Werkzeug-Zustandsmaschine, die Live-Vorschau +(Rubber-Band), das **Snapping** mit Bildschirm-Markern, die Ebenen-/Kategorie-/ +Stil-Zuordnung neuer Elemente und das neue Element `Drawing2D` samt Ableitung in +`generatePlan`. + +Bezugsdokumente: [elements.md](elements.md) (Wand-/Tür-Modell), +[resources-graphics.md](resources-graphics.md) (Stil-Auflösung), +[plans-output.md](plans-output.md) (Papier-Maßstab, mm-Strichstärken), +[context-menu.md](context-menu.md) (Maus-Schema). + +## 0. Architektur-Prinzip (Bezug zum Repo) + +Die App folgt der Regel **ein semantisches Modell ist die einzige Wahrheit; jede +Ansicht ist abgeleitet** (CONVENTIONS.md, `App.tsx`). Werkzeuge greifen darum NUR über +`setProject` immutabel auf das `Project`-Modell zu; sie schreiben NIE Geometrie +direkt in den Plan. Der `PlanView` bleibt eine reine Darstellungs-/Eingabe- +Schicht. Das Tool-System setzt genau an der bestehenden Naht in `PlanView` an: + +- **Modell↔Screen.** `PlanView` rechnet bereits Cursor-Pixel → viewBox-Einheiten + (`clientToView`) → Modell-Meter (`viewToModel`). Diese Umrechnung ist die + Grundlage; Werkzeuge arbeiten ausschließlich in **Modell-Metern** (CONVENTIONS.md: + intern alles in Metern). Für Snap-Marker brauchen Werkzeuge zusätzlich die + Rückrichtung Modell → viewBox (`toScreen`, existiert bereits) bzw. Modell → + Client-Pixel. +- **Pointer-Handling.** `PlanView` besitzt heute drei Gesten an der linken Taste/ + Mitte/rechts: Auswahl/Marquee, Pan, Kontextmenü. Das Tool-System schiebt sich + VOR diese Logik: ist ein aktives Zeichenwerkzeug gewählt (≠ `select`), übernimmt + das Werkzeug `pointerdown/move/up`; das `select`-Werkzeug delegiert an die heute + schon vorhandene Auswahl-/Marquee-Logik (kein Verhaltensbruch). +- **Pan/Zoom bleiben immer aktiv.** Mittlere Maustaste (Pan) und Mausrad (Zoom) + laufen unverändert weiter, auch während ein Zeichenwerkzeug aktiv ist — sonst + kann man beim Zeichnen nicht navigieren. + +## 1. Datenfluss-Überblick + +``` +TopBar (Werkzeugleiste) --activeTool--> App-State + │ + ┌──── activeTool, wallTypeId, defaultCategoryCode ────┐ + ▼ ▼ + PlanView ── pointerdown/move/up (Modellpunkt) ──> ToolController + ▲ │ + Snap-Marker + Rubber-Band-Overlay <── DraftState (Vorschau) ──┘ + │ │ + └──────────────── commit ──> onToolCommit(Element) ──> setProject +``` + +`activeTool` und die Werkzeug-Parameter (aktiver `WallType`, Default-Kategorie) +liegen als **View-State** in `App.tsx` — wie `viewType`, `detail`, `selectedWallIds` +bereits dort liegen. Der `ToolController` ist **frameworkfrei** (reines TS, kein +React-State pro Mausbewegung — analog zu `drag`/`marquee` als `useRef` in +`PlanView`), damit die Live-Vorschau ohne Re-Render des ganzen Baums läuft. Nur +beim **Commit** wird `setProject` (Re-Render) ausgelöst. + +## 2. Koordinaten & Hilfsfunktionen + +`PlanView` exportiert künftig zwei reine Konverter (heute intern vorhanden), +plus die effektive Pixel-pro-Meter-Skala für die Snap-Toleranz: + +```ts +// PlanView-intern bereits da; wird als stabile Callbacks nach außen gereicht. +type ToModel = (clientX: number, clientY: number) => Vec2; // Pixel → Meter +type ToClient = (m: Vec2) => { x: number; y: number }; // Meter → Pixel +type PxPerMeter = () => number; // aktuelle meet-Skala * PX_PER_M (Snap-Toleranz) +``` + +`PxPerMeter` ergibt sich aus `meetScale(view) * PX_PER_M` (beides in `PlanView` +vorhanden). Snap-Toleranzen werden in **Bildschirm-Pixeln** definiert (z. B. 10 px) +und über `pxPerMeter` in Meter umgerechnet — so ist der Fangradius zoom-unabhängig +konstant am Bildschirm. + +## 3. Tool-System + +### 3.1 Werkzeug-Identität und Registry + +```ts +export type ToolId = + | "select" // Default: Auswahl/Marquee (heutiges Verhalten) + | "wall" // Wand-Achs-Polylinie → Wall je Segment + | "line" // einzelne 2D-Strecke + | "polyline" // offene 2D-Polylinie + | "rect" // 2D-Rechteck (zwei Ecken) + | "circle" // 2D-Kreis (Zentrum + Radius) + | "arc" // 2D-Bogen (3-Punkt oder Zentrum-Start-Ende) + | "text"; // 2D-Textmarke + +/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */ +export interface ToolContext { + project: Project; + /** Aktives Geschoss/Zeichnungsebene (Ziel der neuen Elemente). */ + level: DrawingLevel; + /** Default-Kategorie-Code für neue Elemente (siehe §6). */ + defaultCategoryCode: string; + /** Aktiver Wandtyp für das Wand-Werkzeug. */ + activeWallTypeId: string; + /** Aktiver Linienstil-Code für 2D-Primitive (Line Manager). */ + activeLineStyleId: string; + /** Snapping-Einstellungen (an/aus je Typ, ortho, grid). */ + snap: SnapSettings; + /** Pixel pro Meter (für Snap-Toleranz in Metern). */ + pxPerMeter: number; +} + +/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis. */ +export interface ToolPointer { + /** Roher Modellpunkt (vor Snapping), in Metern. */ + raw: Vec2; + /** Gesnappter Punkt + Marker-Info (siehe §5). null = kein Snap. */ + snap: SnapResult | null; + /** Effektiver Punkt = snap?.point ?? raw. */ + point: Vec2; + /** Modifikatoren (Shift = Ortho erzwingen, Ctrl = Snap aus, Alt = …). */ + shift: boolean; + ctrl: boolean; + alt: boolean; + button: number; // 0 links, 2 rechts +} + +/** Was ein Werkzeug-Schritt nach außen meldet. */ +export interface ToolResult { + /** Neuer Vorschau-Zustand (Rubber-Band-Geometrie); null = nichts zu zeigen. */ + draft: ToolDraft | null; + /** Bei Abschluss: Mutation, die App über setProject anwendet. */ + commit?: (p: Project) => Project; + /** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */ + done?: boolean; +} + +/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */ +export interface Tool { + id: ToolId; + /** UI-Label-Key (i18n), z. B. "tool.wall". */ + labelKey: string; + /** Material-Symbol-Name für die Werkzeugleiste. */ + icon: string; + /** Statuszeilen-Hinweis-Key je Phase (z. B. "tool.wall.firstPoint"). */ + hintKey: (state: ToolState) => string; + + /** Initialer Ruhezustand. */ + init(): ToolState; + /** Klick/Tap (pointerdown→up ohne Drag, bzw. „setze Punkt"). */ + onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult]; + /** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */ + onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult]; + /** Doppelklick/Enter: mehrteilige Werkzeuge abschließen (z. B. Polylinie). */ + onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult]; + /** Esc: aktuellen Entwurf verwerfen, zurück in den Ruhezustand. */ + onCancel(state: ToolState): [ToolState, ToolResult]; + /** Backspace: letzten gesetzten Punkt zurücknehmen (mehrteilig). */ + onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult]; +} +``` + +`ToolState` ist je Werkzeug ein Discriminated Union (Beispiel Wand in §4). Der +`ToolController` hält genau eine aktive `Tool`-Instanz + deren `ToolState` in +einem `useRef` und ist die einzige Stelle, die diese Methoden aufruft. + +### 3.2 Vorschau-Geometrie (Rubber-Band) + +```ts +/** Darstellbare Vorschau — dieselben Primitive wie der Plan, plus Marker. */ +export interface ToolDraft { + /** Vorschau-Primitive (gestrichelt/halbtransparent gezeichnet). */ + preview: Primitive[]; + /** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */ + vertices: Vec2[]; + /** Optionaler Maß-/Winkel-Text am Cursor (z. B. "3.20 m, 90°"). */ + hud?: { at: Vec2; text: string }; +} +``` + +Wichtig: Die Vorschau benutzt **dieselben `Primitive`-Typen** wie `generatePlan` +(`polygon | line | arc`). Damit kann der Vorschau-Layer mit derselben +`PrimitiveShape`-Renderlogik gezeichnet werden (DRY) — nur mit einer +Vorschau-CSS-Klasse (gestrichelt, Akzentfarbe). Für die Wand-Vorschau kann das +Werkzeug sogar `generatePlan` auf einem **temporären Projekt** (Original + die in +Bau befindliche Wand) aufrufen, um echte gehrte Poché live zu zeigen; in der +ersten Phase reicht eine einfache Bandvorschau (`wallCorners`). + +### 3.3 Zustandsmaschine (allgemein) + +Jedes Werkzeug ist eine kleine Maschine über `pointerdown → move → up`. Da +`PlanView` Pointer-Capture nutzt, kommen `move`/`up` zuverlässig an. Generisches +Muster: + +``` + ruht ──pointerdown──> (Werkzeug setzt 1. Punkt / startet Drag) + ▲ │ + │ ├──move──> Vorschau (rubber-band), kein Commit + │ │ + │ (mehrteilig) pointerdown──> Punkt anhängen, Vorschau weiter + │ │ + └──Esc/Cancel─────────────┤ + ▼ + Doppelklick/Enter/letzter Punkt ──> commit(project) ──> ruht +``` + +- **Klick-vs-Drag.** Wie heute in `PlanView` (`MARQUEE_THRESHOLD_PX`): unter der + Schwelle ist es ein „Punkt setzen" (Klick), darüber ein Drag. Rechteck/Kreis/ + Linie unterstützen BEIDE Bedienarten: Zwei-Klick (Punkt, Punkt) ODER Drücken- + Ziehen-Loslassen. Polyline/Wall sind reine Klickfolgen mit Abschluss per + Doppelklick/Enter. +- **Esc** verwirft den Entwurf (`onCancel`) und bleibt im selben Werkzeug. + Zweites Esc (im Ruhezustand) schaltet zurück auf `select`. +- **Rechtsklick** während eines aktiven Entwurfs = „abschließen/abbrechen" + (CAD-üblich), KEIN Kontextmenü; im Ruhezustand öffnet Rechtsklick wie bisher + das Plan-Kontextmenü. + +### 3.4 Einbettung in PlanView (Pointer-Routing) + +`PlanView` bekommt zwei neue Props: + +```ts +interface PlanViewProps { + // … bisherige Props … + /** Aktives Werkzeug; "select" = bisheriges Verhalten. */ + activeTool?: ToolId; + /** + * Werkzeug-Treiber. PlanView ruft diese Callbacks mit fertig gesnappten + * Modellpunkten auf und rendert den zurückgegebenen Draft als Overlay. + */ + toolHandlers?: { + onToolDown(p: ToolPointer): void; + onToolMove(p: ToolPointer): void; + onToolUp(p: ToolPointer): void; + onToolDoubleClick(): void; + /** liefert die zu zeichnende Vorschau (von App/Controller gehalten). */ + draft: ToolDraft | null; + }; +} +``` + +Routing in `onPointerDown` (Ergänzung der bestehenden Methode): + +``` +onPointerDown(e): + if e.button === 1: → bestehender Pan (unverändert) + if e.button === 0: + if activeTool === "select": → bestehende Auswahl-/Marquee-Geste + else: + setPointerCapture + p = makeToolPointer(e) // raw → snap → point (§5) + toolHandlers.onToolDown(p) + if e.button === 2 (rechts): + if activeTool !== "select" && entwurf aktiv: toolHandlers.onToolUp({button:2,…}) // abschließen + else: bestehendes Kontextmenü +``` + +`onPointerMove`/`onPointerUp` analog: bei aktivem Zeichenwerkzeug an +`onToolMove`/`onToolUp` routen statt an Pan/Marquee. Der Cursor wird auf +`crosshair` gesetzt. `makeToolPointer` führt das Snapping aus (§5) und liefert den +fertigen `ToolPointer`. + +Die **Snap-Marker** und der **Draft** werden als zusätzliche SVG-Gruppe NACH den +Plan-Primitiven, aber vor der Auswahl-Hervorhebung gerendert (immer obenauf, +`pointerEvents="none"`). Marker werden in viewBox-Einheiten über `toScreen` +positioniert (existiert bereits). + +## 4. Werkzeug: Wand (Wall) + +Das Wand-Werkzeug zeichnet eine **Achs-Polylinie**; jedes Segment wird zu einem +eigenständigen `Wall`-Element des aktiven `WallType` auf dem aktiven Geschoss. +Aufeinanderfolgende Segmente teilen sich einen Knoten → die bestehende +`computeJoins`-Verschneidung (in `generatePlan`) erzeugt automatisch saubere +Gehrungen an den Ecken. Kein zusätzlicher Join-Code nötig. + +### 4.1 Zustand + +```ts +type WallToolState = + | { phase: "idle" } + | { + phase: "drawing"; + /** Bisher gesetzte Achs-Knoten (in Metern). */ + points: Vec2[]; + /** Aktuelle Cursor-Position (gesnappt) für die Rubber-Band-Vorschau. */ + cursor: Vec2 | null; + }; +``` + +### 4.2 Pseudocode + +``` +WallTool.onClick(state, p, ctx): + if state.phase === "idle": + return [{phase:"drawing", points:[p.point], cursor:p.point}, {draft: draftFor([p.point], p.point, ctx)}] + else: // weiteren Knoten anhängen + pts = [...state.points, p.point] + # Ortho/Snap haben p.point bereits ausgerichtet (§5). + return [{phase:"drawing", points: pts, cursor: p.point}, {draft: draftFor(pts, p.point, ctx)}] + +WallTool.onMove(state, p, ctx): + if state.phase !== "drawing": return [state, {draft:null}] + return [{...state, cursor:p.point}, {draft: draftFor(state.points, p.point, ctx)}] + +WallTool.onCommitGesture(state, ctx): // Doppelklick / Enter / Rechtsklick + if state.phase !== "drawing" || state.points.length < 2: + return [{phase:"idle"}, {draft:null, done:true}] + pts = state.points + return [{phase:"idle"}, { + draft: null, done: true, + commit: (proj) => appendWalls(proj, pts, ctx) + }] + +WallTool.onCancel(state): + return [{phase:"idle"}, {draft:null, done:true}] + +WallTool.onUndoPoint(state): + if state.phase==="drawing" && state.points.length>1: + return [{...state, points: state.points.slice(0,-1)}, {draft: …}] + return [{phase:"idle"}, {draft:null}] +``` + +`draftFor` baut die Vorschau: feste Segmente zwischen `points` + ein „lebendes" +Segment `points[last] → cursor`. Pro Segment werden die vier Band-Eckpunkte über +`wallCorners(a, b, thickness)` (vorhanden) berechnet und als Vorschau-`polygon` +gezeichnet; zusätzlich ein HUD mit Länge `|b−a|` und Winkel. `thickness = +wallTypeThickness(getWallType(...))`. + +### 4.3 Commit ins Modell + +``` +appendWalls(project, pts, ctx): + newWalls = [] + for i in 0 .. pts.length-2: + a = pts[i]; b = pts[i+1] + if |b-a| < EPS: continue // Null-Segmente überspringen + newWalls.push({ + id: uniqueId("W"), // siehe §8 (ID-Vergabe) + type: "wall", + floorId: ctx.level.id, // aktives Geschoss + categoryCode: ctx.defaultCategoryCode, // §6 + start: a, end: b, + wallTypeId: ctx.activeWallTypeId, + height: ctx.level.floorHeight ?? 2.6, // Geschosshöhe als Default + }) + return { ...project, walls: [...project.walls, ...newWalls] } +``` + +Hinweise: +- **Höhe** erbt die lichte Geschosshöhe (`DrawingLevel.floorHeight`), Fallback 2.6 m. +- **Geschossbindung**: Das Wand-Werkzeug ist nur aktiv, wenn `level.kind === "floor"` + (sonst gibt es keine Wände). In `drawing`-Ebenen ist das Wand-Werkzeug + deaktiviert (nur 2D-Werkzeuge); siehe §6. +- Die Wicklung wird NICHT erzwungen — `leftNormal`-Konvention (CONVENTIONS.md) und + `computeJoins` arbeiten richtungsunabhängig pro Segment. + +## 5. Snapping + +Snapping läuft in `makeToolPointer` (PlanView) BEVOR der Punkt an das Werkzeug +geht. Es prüft mehrere Snap-Quellen, wählt die nächstgelegene innerhalb der +Toleranz und liefert sowohl den gefangenen Punkt als auch eine **Marker-Art** für +die Bildschirmdarstellung. + +### 5.1 Typen + +```ts +export type SnapKind = + | "endpoint" // Wand-Achsenende, Polylinien-Knoten, Primitiv-Endpunkt + | "midpoint" // Mitte einer Strecke/Wandachse + | "intersection" // Schnittpunkt zweier Achsen/Linien + | "center" // Kreis-/Bogenzentrum + | "quadrant" // Kreis-Quadrantenpunkte (0/90/180/270°) + | "onEdge" // nächster Punkt AUF einer Wandachse/Linie (Lot) + | "grid" // Rasterpunkt + | "ortho" // orthogonal/winkelrastriert zum vorigen Punkt + | "extension"; // Verlängerung einer Achse (gestrichelte Hilfslinie) + +export interface SnapResult { + point: Vec2; // gefangener Punkt (Meter) + kind: SnapKind; + /** Quell-Element (für Marker/Hilfslinien), optional. */ + refA?: Vec2; + refB?: Vec2; + /** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */ + distPx: number; +} + +export interface SnapSettings { + enabled: boolean; // Master-Schalter (Ctrl invertiert temporär) + endpoint: boolean; + midpoint: boolean; + intersection: boolean; + center: boolean; + onEdge: boolean; + grid: boolean; + gridSize: number; // Rasterweite in Metern, z. B. 0.10 + ortho: boolean; // Shift erzwingt zusätzlich + angleStep: number; // Winkelraster in Grad (z. B. 45) + tolerancePx: number; // Fangradius am Bildschirm, z. B. 10 +} +``` + +### 5.2 Snap-Kandidaten sammeln + +Quellen pro Geschoss (gefiltert auf sichtbare Kategorien, wie der Plan): + +| Snap | Quelle | +|------|--------| +| endpoint | `wall.start`, `wall.end` aller sichtbaren Wände; Knoten bereits gesetzter Draft-Punkte; `Drawing2D`-Vertices | +| midpoint | Mitte jeder Wandachse und jedes 2D-Segments | +| intersection | paarweise `lineIntersect` der Wandachsen (nur Paare, deren Boxen sich am Cursor nähern) | +| center/quadrant | Kreise/Bögen aus `Drawing2D` | +| onEdge | Lotfußpunkt des Cursors auf jede nahe Wandachse/2D-Linie | +| grid | Rundung des Cursors auf `gridSize` | +| ortho | Ausrichtung relativ zum letzten Draft-Punkt (§5.4) | + +Performance: Kandidaten werden je `move` neu erzeugt, aber **früh nach +Bildschirm-Distanz gefiltert** (nur Punkte innerhalb ~`2·tolerancePx`). Bei +großen Modellen kann eine grobe Bounding-Box-Vorauswahl je Wand vorgeschaltet +werden; in den ersten Phasen genügt lineares Scannen (Wandzahl ist klein). + +### 5.3 Auswahl-Pseudocode + +``` +computeSnap(rawModel, ctx, draftPoints, lastPoint): + if ctrl(): return null # Snap temporär aus + s = ctx.snap + tolM = s.tolerancePx / ctx.pxPerMeter # px-Toleranz → Meter + cands: SnapResult[] = [] + + if s.endpoint: cands += endpoints(...) filtered to within tolM + if s.midpoint: cands += midpoints(...) + if s.intersection: cands += intersections(...) + if s.center: cands += centers/quadrants(...) + if s.onEdge: cands += perpendicularFeet(...) # niedrigere Priorität + + # Punkt-Snaps haben Vorrang vor Linien-/Raster-Snaps: + pick = argmin(cands, by distPx within tolM, tie-break by priority) + if pick: rawModel = pick.point + + # Ortho/Winkelraster wirkt RELATIV zum letzten Punkt und ÜBERLAGERT: + if (s.ortho || shift()) && lastPoint: + rawModel = applyAngleConstraint(lastPoint, rawModel, s.angleStep) + # Wenn dabei auch ein Punkt-Snap nahe der Ortho-Linie liegt → bevorzugen. + + if !pick && s.grid: + g = snapToGrid(rawModel, s.gridSize) + if dist(g, rawModel) within tolM: return {point:g, kind:"grid", …} + + return pick ?? null +``` + +Prioritätsreihenfolge bei gleichem Abstand: `endpoint > intersection > midpoint > +center/quadrant > onEdge > grid`. Ortho/Winkelraster ist eine **Projektion**, kein +Punkt-Kandidat: es verschiebt den (ggf. schon gesnappten) Punkt auf die nächste +erlaubte Richtung vom letzten Knoten. + +### 5.4 Ortho / Winkelraster + +``` +applyAngleConstraint(from, to, stepDeg): + d = to - from + ang = atan2(d.y, d.x) + k = round(ang / rad(stepDeg)) * rad(stepDeg) + len = |d| + return from + (cos(k), sin(k)) * len +``` + +Mit `stepDeg = 90` ist das klassisches Ortho (H/V); `45` erlaubt Diagonalen. +`Shift` erzwingt Ortho temporär unabhängig von der Einstellung. + +### 5.5 Bildschirm-Marker + +Pro aktivem Snap zeichnet `PlanView` ein Marker-Glyph an `toScreen(snap.point)` +(`pointerEvents="none"`, eigene CSS-Klassen, papierkonstante Größe via +non-scaling): + +- `endpoint` → kleines Quadrat ▫ +- `midpoint` → Dreieck �△ +- `intersection` → ✕ +- `center` → ○, `quadrant` → ◇ +- `onEdge` → ⟂-Glyph +- `grid` → feiner Punkt +- `ortho`/`extension` → zusätzlich eine **gestrichelte Hilfslinie** von `refA` + (Bezugspunkt) zum Cursor + +Marker erscheinen NUR während ein Zeichenwerkzeug aktiv ist. i18n-Tooltips/Status +(„Endpunkt", „Mittelpunkt", …) über `t('snap.endpoint')` etc. + +## 6. Ebene, Kategorie und Stil neuer Elemente + +Neue Elemente brauchen eine **Zeichnungsebene** (DrawingLevel) und eine +**Kategorie** (LayerCategory `code`) sowie — bei 2D-Primitiven — einen Stift/ +Schraffur-Stil. + +### 6.1 Zeichnungsebene (Ziel) + +- Ziel ist **immer das aktive Geschoss/die aktive Zeichnungsebene** (`activeLevelId` + in `App.tsx`). Wände nur auf `kind === "floor"`. 2D-Primitive (`Drawing2D`) auf + jeder Ebene, also auch auf `kind === "drawing"` (freie 2D-Zeichnung). + +### 6.2 Kategorie (categoryCode) + +- Es gibt eine **aktive Kategorie** als View-State (`activeCategoryCode` in App, + neu). Default beim Start: der Code der gewählten Wand-Kategorie (im Sample „20" + Wände), bzw. die erste sichtbare Kategorie. Die Statusleiste zeigt heute schon + die „aktive Ebene" (`activeLayerName`); diese wird künftig von `activeCategoryCode` + gespeist statt nur aus der Auswahl abgeleitet. +- Neue Wände: `categoryCode = activeCategoryCode` (z. B. „20"). +- Neue 2D-Primitive: ebenfalls `activeCategoryCode`. Sinnvoll ist eine eigene + 2D-/Hilfslinien-Kategorie (z. B. „90 Zeichnung"); diese wird über die + Kategorie-Auswahl in der Statusleiste/Werkzeugleiste gesetzt. +- Die Kategorie liefert Farbe + Strichstärke (`LayerCategory.color`, `.lw`), genau + wie `generatePlan` es heute für Wände via `categoryLwMap` nutzt. + +### 6.3 Stift/Schraffur + +- **Wände** erhalten KEINEN eigenen Stift — ihr Erscheinungsbild kommt aus dem + `WallType` (Component → Hatch → LineStyle) und der Kategorie-`lw` (bestehender + Pfad in `generatePlan`). +- **2D-Primitive** referenzieren optional einen `LineStyle` aus dem Line Manager + (`activeLineStyleId`). Ohne expliziten Stil erben sie Farbe/Strichstärke aus der + Kategorie (`color`, `lw`). Flächige 2D-Primitive (geschlossenes Rechteck/Kreis/ + Polyline) können optional eine Schraffur (`hatchId`) tragen. + +## 7. Speicherung der 2D-Primitive: `Drawing2D` + +2D-Geometrie wird als neues Modell-Element `Drawing2D` gespeichert — analog zu +`Wall`/`Door` ein semantisches Element, das beim Rendern abgeleitet wird (KEINE +vorab erzeugten Primitive im Modell). Damit bleibt die Architektur „Modell → +abgeleitete Ansicht" intakt. + +### 7.1 Typ + +```ts +/** Geometrie-Form eines 2D-Zeichenelements. */ +export type Drawing2DGeom = + | { shape: "line"; a: Vec2; b: Vec2 } + | { shape: "polyline"; pts: Vec2[]; closed: boolean } + | { shape: "rect"; min: Vec2; max: Vec2 } // achsparallel + | { shape: "circle"; center: Vec2; r: number } + | { + shape: "arc"; + center: Vec2; + r: number; + /** Start-/Endwinkel in Radiant (math. Konvention, CCW positiv). */ + a0: number; + a1: number; + } + | { shape: "text"; at: Vec2; text: string; height: number; angle: number }; + +/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */ +export interface Drawing2D { + id: string; + type: "drawing2d"; + /** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */ + levelId: string; + /** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */ + categoryCode: string; + geom: Drawing2DGeom; + /** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */ + lineStyleId?: string; + /** Optionale Schraffur für geschlossene Formen (Hatch Manager). */ + hatchId?: string; + /** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */ + color?: string; +} +``` + +Ergänzung am `Project`: + +```ts +export interface Project { + // … bisher … + drawings2d: Drawing2D[]; // NEU +} +export type Element = Wall | Door | Drawing2D; // erweitert +``` + +`sampleProject` bekommt ein leeres `drawings2d: []`. Lösch-/Referenz-Regeln: +beim Löschen einer Zeichnungsebene werden auch deren `Drawing2D` entfernt (analog +zur bestehenden Wand-/Tür-Bereinigung in `deleteLevel`). + +### 7.2 Ableitung in `generatePlan` + +`generatePlan` rendert künftig zusätzlich die `Drawing2D` des Geschosses (gefiltert +wie Wände auf sichtbare Kategorien + `categoryDisplay`). Neue Funktion +`addDrawing2D(out, project, d, greyed, lwMm)`: + +``` +addDrawing2D(out, project, d): + color = d.color ?? categoryColor(d.categoryCode) + weight = lineStyle(d.lineStyleId)?.weight ?? categoryLw(d.categoryCode) + dash = lineStyle(d.lineStyleId)?.dash ?? null + switch d.geom.shape: + "line": out.push({kind:"line", a, b, cls:"draw2d", weightMm:weight, dash}) + "polyline": for each segment → line-Primitive (closed → Schluss-Segment) + "rect": vier Kanten als line-Primitive (oder polygon, falls hatchId) + "circle": → als zwei 180°-Bögen (arc-Primitive) ODER neues Primitiv (s. u.) + "arc": → arc-Primitive (center/from/to/r aus a0,a1) + "text": → neues text-Primitiv (s. u.) +``` + +Dabei wird, wo möglich, der **vorhandene** `Primitive`-Vorrat (`line`, `arc`, +`polygon`) wiederverwendet — die Strichstärke kommt in mm Papier (wie der Rest des +Plans), Farbe über eine CSS-Klasse oder ein neues optionales `color`-Feld am +`line`-Primitive. + +Zwei `Primitive`-Erweiterungen sind nötig: + +```ts +// kreisförmige Vollkurve (Kreis) — sonst muss man sie in zwei Bögen zerlegen: +| { kind: "circle"; center: Vec2; r: number; cls: string; weightMm: number; + dash?: number[] | null; fill?: string; greyed?: boolean } +// Textmarke: +| { kind: "text"; at: Vec2; text: string; heightMm: number; angle: number; + cls: string; color?: string; greyed?: boolean } +``` + +`PlanView.renderPrimitive` bekommt entsprechende `case`-Zweige (``, +``). Text wird in **Papier-Millimetern** dimensioniert (Höhe → `mmToPx`, +non-scaling), damit die Schrifthöhe beim Zoomen papierkonstant bleibt (analog zu +Strichstärken in `plans-output.md`). + +Das `arc`-Primitiv zeichnet heute nur Kurzbögen (≤180°, `large-arc=0`). Für +beliebige 2D-Bögen wird es um ein `largeArc`-Flag erweitert (aus `|a1−a0|` +berechnet); abwärtskompatibel (Default 0). + +## 8. ID-Vergabe & Immutabilität + +- Neue IDs über einen kleinen Helfer `uniqueId(prefix)` (z. B. + `\`${prefix}-${Date.now()}-${counter++}\``), konsistent mit der bestehenden + Praxis in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix + „dr2d". +- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention). + Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project → + Project`; App ruft `setProject(prev => result.commit(prev))`. + +## 9. App- und PlanView-Verdrahtung (konkret) + +Neuer View-State in `App.tsx`: + +```ts +const [activeTool, setActiveTool] = useState("select"); +const [activeCategoryCode, setActiveCategoryCode] = useState(/* erste Wand-Kat */); +const [activeWallTypeId, setActiveWallTypeId] = useState(project.wallTypes[0].id); +const [activeLineStyleId, setActiveLineStyleId] = useState(project.lineStyles[0].id); +const [snap, setSnap] = useState(DEFAULT_SNAP); +const toolStateRef = useRef(getTool(activeTool).init()); +const [draft, setDraft] = useState(null); +``` + +Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die +`PlanView.toolHandlers` implementiert: + +``` +onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx) + toolStateRef.current = st; setDraft(res.draft) + if res.commit: setProject(res.commit) + if res.done: toolStateRef.current = tool.init() +onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft) +onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null) +``` + +Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist): +`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim +Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog +zur bestehenden Auswahl-Bereinigung in den `useEffect`s). + +`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen +gebaut und an PlanView/Controller gereicht. + +### 9.1 Werkzeugleiste (TopBar) + +Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als +i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv- +Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der +aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines +Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D- +Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den +Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`). + +### 9.2 i18n-Keys (neu, Auszug) + +``` +tool.select / tool.wall / tool.line / tool.polyline / tool.rect / +tool.circle / tool.arc / tool.text +tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish +snap.endpoint / snap.midpoint / snap.intersection / snap.center / +snap.quadrant / snap.onEdge / snap.grid / snap.ortho +snap.settings / snap.gridSize / snap.angleStep +status.activeWallType / status.activeCategory / status.activeTool +``` + +Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch. + +## 10. Übrige Werkzeuge (Kurzspezifikation) + +| Werkzeug | Eingabe | Zustand | Commit | +|----------|---------|---------|--------| +| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` | +| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` | +| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) | +| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` | +| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) | +| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` | + +Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/ +Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe- +feld (an `toClient(at)` positioniert) und committet bei Enter/Blur. + +3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der +Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn +aus dem mittleren Punkt. + +## 11. Phasenplan + +**Phase 1 — Gerüst + Select + Wall + Line (MVP).** +1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`, + `SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen. +2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen; + Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering; + Crosshair-Cursor. +3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`, + `activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace). +4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD, + Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`. +5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`; + `Drawing2D`-Löschung beim Geschoss-Löschen. +6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern. +7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl; + i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie. +8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs` + (Wand zeichnen, Gehrung prüfen). + +**Phase 2 — Snapping vervollständigen + 2D-Grundformen.** +- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster + (45°), Snap-Einstellungsmenü in der TopBar. +- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene + Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen. + +**Phase 3 — Kurven + Text.** +- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView- + Renderzweige; Text papierkonstant. +- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant. + +**Phase 4 — Bearbeitung (Folge-Doku).** +- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben), + Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von + Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes + Design-Dokument; hier nur als Ausblick.) + +## 12. Architektur-Garantien (Checkliste) + +- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan- + Primitive. Ansichten (Plan/3D) leiten ab. +- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern, + Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling). +- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine + Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar. +- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping + und Commit-Pfad sind werkzeugübergreifend geteilt. + + diff --git a/docs/design/elements.md b/docs/design/elements.md new file mode 100644 index 0000000..9dd60b7 --- /dev/null +++ b/docs/design/elements.md @@ -0,0 +1,424 @@ +# Design — Bauteile (Elements) + +> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md). +> Output/Pläne: [plans-output.md](plans-output.md). Ressourcen/Stile: +> [resources-graphics.md](resources-graphics.md). + +Dieses Dokument legt die **Daten**, die **Generierung** (3D-Geometrie + Plan- +Symbolik) und das **Grip-Editing** je Bauteil fest und übersetzt DOSSIERs +`elemente.py` (7244 LOC, Monolith) in **kleine Bauteil-Module** (`src/model/ +elements/wall.ts`, `opening.ts`, …). Bezeichner englisch, Prosa deutsch, Meter. + +DOSSIERs Architektur dort: pro Element eine **Achse/Outline-Source** (editierbar) ++ ein **auto-generiertes Volumen** (`wand_axis`+`wand_volume`, Outline+Brep). +Browser-Äquivalent: das **semantische Element ist die Source**; Geometrie wird per +`generate*()` **abgeleitet** (nie persistiert). Das ist sauberer als DOSSIERs +zwei-Objekt-Modell und kennt kein Cache-Stale. + +--- + +## 0. Gemeinsames Fundament + +```ts +// src/model/elements/base.ts +interface ElementBase { + id: string; + type: ElementType; // "wall" | "window" | "door" | "slab" | "stair" | "roof" + // | "column" | "beam" | "space" | "draw2d" + floorId: string; // Zeichnungsebene (Geschoss); bei gehosteten via Host + categoryCode: string; // Ebene (Grafik-Kategorie), z.B. "20" + styleId?: string; // optionaler Element-Override-Stil (resources-graphics.md) + name?: string; +} +``` + +**Geometrie-Konvention** (aus CONVENTIONS.md, im Spike etabliert): Wand-Normale +`n = leftNormal(u) = (-u.y, u.x)`; bei CCW-Wicklung zeigt `+n` nach innen. +Schichten werden außen (`-T/2`) → innen (`+T/2`) gestapelt (`generatePlan.addWallPoche`, +`Viewport3D.addWallMeshes`). + +**Detailgrad** (LoD) — DOSSIERs `darstellung` (`auto|einfach|standard|detail`): +```ts +type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail +// Auflösung: Element-Wert "auto" → Dokument-/Snapshot-Wert; sonst Element-Wert. +function resolveDetail(el: ElementBase, doc: { detailLevel: DetailLevel }): DetailLevel +``` +≙ DOSSIER `_resolve_oeff_darstellung` + `get_aktive_darstellung`. Steuert, *wie +viel* Symbolik gezeichnet wird (1:500 Rechteck → 1:50 Glas/Sims/Schwenkbogen). + +**Generierungs-Signaturen** (jedes Modul exportiert beides): +```ts +function build3d(project, el, ctx): THREE.Object3D // Volumen (Schichten/Brep) +function generatePlan(project, el, ctx, lod): Primitive[] // Schnittflächen + Symbol +// ctx trägt baseElevation, joins, sichtbare Codes, resolver für Components/Styles +``` + +--- + +## 1. Wand (Wall) — mehrschichtig + +### 1.1 Daten +```ts +interface Wall extends ElementBase { + type: "wall"; + start: Vec2; end: Vec2; // Achse (Centerline) im Grundriss [im Spike] + wallTypeId: string; // → WallType.layers (außen→innen) + height: number; + reference: "mid" | "left" | "right"; // Referenzlage der Achse (DOSSIER _wand_referenz) + baseOffset?: number; // UK relativ zu OKFF (default 0) + topOffset?: number; // OK-Override (default = floorHeight) + jointRole?: "auto" | "through" | "butt"; // T-Stoss-Rolle (DOSSIER wand_joint_rolle) + // Mehrsegment-Wände (Polyline): optional axisPoints statt start/end + axisPoints?: Vec2[]; +} +``` +`reference` verschiebt die Achse auf Außenkante/Mitte (DOSSIER +`_wall_offsets_from_referenz`): hilft beim Modellieren *und* beim Import fremder +Pläne (ROADMAP §11). Offsets: `mid → [+T/2, -T/2]`, `left → [0, -T]`, `right → [+T, 0]`. + +### 1.2 Generierung — 3D + Plan (Status: im Spike, einschichtig→mehrschichtig ✅) +Beide Sichten extrudieren/füllen **dasselbe gehrte Band-Polygon** pro Schicht. +Heute schon vorhanden: +- `geometry.clippedBand(start, end, offA, offB, startCut, endCut)` — Band mit + Gehrungsschnitt. +- `generatePlan.addWallPoche` — pro Schicht ein gefülltes Polygon (Component-Fill + + Schraffur), Öffnungen ausgespart. +- `Viewport3D.addLayerPrism` — dasselbe Polygon via `ExtrudeGeometry`. + +### 1.3 Wand-Verschneidung (Joins) — Risiko #1 + +**Status: L-Ecken-Gehrung ✅** (`joins.computeJoins` → `miterLine`, robust gegen +Wicklung + ungleiche Dicken). **Offen: Prioritäts-T-/X-Stöße** bei mehrschichtigen +Wänden. + +DOSSIERs gelöste Logik (`elemente._t_junction_layer_overrides`, `_wand_should_apply_t_miter`), +die wir portieren: + +1. **Knoten finden:** Endpunkte auf Gitter runden (`roundKey`, existiert), + gruppieren. `==1` freies Ende, `==2` L-Ecke (Gehrung, ✅), `>2` T/X. +2. **Through-Wand bestimmen:** an einem T-Stoß läuft genau **eine** Wand durch. + Auswahl nach `jointRole` (DOSSIER-Regel), sonst nach Component-`joinPriority`: + ``` + my.role="through" → ich laufe durch (kein Miter) + my.role="butt" → ich stoße an (Miter) + beide "auto" → höhere joinPriority = Through-Wand + ``` +3. **Schicht-Durchdringung (Backbone):** **nur das Material mit der höchsten + gemeinsamen `joinPriority`** in *beiden* Wänden läuft durch und unioniert + (T-Form). Beispiel ROADMAP §2d: Beton (800) läuft mittig durch; Putze (100) + verbinden sich seitlich, gehen aber nirgends durch den Beton. Alle Nicht- + Backbone-Schichten der anstoßenden Wand mitern an der Through-Außenkante + (`standard_miter`). Ergebnis: gleichfarbige Außenlagen bilden automatisch + saubere L-Stöße. + +```ts +// joins.ts — Erweiterung der bestehenden API +interface WallCuts { startCut: Line|null; endCut: Line|null; + // neu: pro-Schicht Overrides am T-Stoss + layerExtensions?: number[]; // wie weit jede Schicht in Through-Body drillt + layerMiters?: (Line|null)[]; // pro-Schicht Mitre (null = Backbone, läuft durch) +} +function computeJoins(project, walls): Map // erweitert +``` + +**Implementierungsplan (stufenweise, Risiko #1):** +- (a) ✅ L-Gehrung bleibt. +- (b) T-Stoß ohne Schichten: Backbone = ganze Wand; Through union, Stem mitert. +- (c) T-Stoß mit Schichten: Backbone-Material-Logik wie oben (Port von + `_t_junction_layer_overrides`). +- (d) X-Stoß: paarweise als zwei T behandeln. +- **Booleans:** Union/Extension der Backbone-Säule via **OpenCascade.js/Manifold** + im Worker (`workers/geometry.worker.ts`), nur für 3D + exakten B-Rep-Export; der + 2D-Plan bleibt rein analytisch (Polygon-Clipping, kein Kernel) — schnell. +- **Validierung:** Screenshot-Probe der T-Ecke (Beton durch, Putz seitlich). + +### 1.4 Grip-Editing (Risiko #L, Phase 3–4) +DOSSIER: Display-Conduit zeichnet dicke Marker an Achs-Endpunkten, MouseCallback +fängt Klick → `GetPoint` mit Snap → `_replace_axis_vertex` → Volumen regeneriert +(`wand_grips.py`). Browser-Port: +- **Marker:** SVG-Kreise (r ≈ 7 px) an Endpunkten/Knicks der *selektierten* Wand, + als Overlay über dem Plan (unabhängig von Ebenen-Sichtbarkeit) — exakt DOSSIERs + Conduit-Idee. +- **Hit-Test:** Pointer-Distanz < 14 px (DOSSIER `_HIT_RADIUS_PX`). +- **Drag:** `pointerdown` auf Marker → Live-Preview-Linien zu Nachbar-Vertices → + Snap (Endpunkt/Ortho/Raster) → `pointerup` → `store.apply(p => wall.start = newPt)`. + Abgeleitete Sichten (Plan + 3D) re-derivieren reaktiv — kein manuelles Regen. +- Funktioniert für Line (2 Grips) und Polyline (jeder Knick ein Grip), wie DOSSIER. + +--- + +## 2. Öffnungen (Window / Door) — gehostet, LoD + +### 2.1 Daten +```ts +interface OpeningBase extends ElementBase { + hostWallId: string; // Host-Wand (Geschoss ergibt sich daraus) [im Spike] + position: number; // Abstand entlang Wandachse vom Wand-Start (m) + width: number; height: number; + reference: "mid" | "left" | "right"; // Lage des Klickpunkts in der Öffnung + detailLevel: DetailLevel | "auto"; + frame?: { width: number; depth: number; pos: "outer"|"mid"|"inner"; offset: number }; + outerSide: "left" | "right"; // welche Wandseite ist außen +} +interface Window extends OpeningBase { + type: "window"; + sill: number; // Brüstungshöhe + sashes: 1|2|3|4; // Flügelzahl + sillProfileOut?: "none"|"narrow"|"standard"|"wide"; // Sims außen (DOSSIER _OEFF_SIMS_STYLES) + sillProfileIn?: "none"|"narrow"|"standard"|"wide"; + glass: boolean; +} +interface Door extends OpeningBase { + type: "door"; + swing: "left" | "right"; // Anschlagseite [im Spike] + hinge: "start" | "end"; // Scharnierpfosten [im Spike] + openAngle: number; // Plan-Öffnungswinkel 0–180 (default 90) + doorType: "normal" | "wall-opening"; // Wandöffnung = ohne Blatt + frameType: "casing" | "block"; // Zarge | Blockrahmen + lintel?: "none"|"inner"|"outer"|"both";// Sturzlinien-Anzeige (DOSSIER _OEFF_STURZ) +} +``` +Felder 1:1 aus DOSSIERs `_OEFF_*`-Keys + `_OEFF_STYLE_FIELDS`. Presets (Fenster +Standard/Gross/Bandlage, Tür Innen/Eingang/Verglast, Wandöffnung) als +Style-Katalog (resources-graphics.md), seed wie `_OEFF_DEFAULT_STYLES`. + +### 2.2 Host-Beziehung (Risiko #2) +Die Öffnung kennt ihre Wand (`hostWallId`); ihre Geometrie wird **relativ zur +Wandachse** berechnet (`opening.axisFrame(wall, position)` → Punkt + Tangente + +Normale, ≙ DOSSIER `_oeff_axis_frame`). Verschiebt sich die Wand, folgt die +Öffnung automatisch (sie hält keinen absoluten Punkt). Beim Plan/3D wird die +Wand an `[position, position+width]` ausgespart — steht im Spike (`addWallPoche` +Segmentierung, `addWallMeshes` Sturz). + +### 2.3 Generierung nach LoD +| LoD | Plan-Symbol | 3D | +|---|---|---| +| **coarse** (1:200/500) | Öffnung als Lücke + dünne Linie | Aussparung, kein Rahmen | +| **medium** (1:100) | + Rahmenlinien, Tür-Schwenkbogen (`addDoorSymbol` ✅), Sturz gestrichelt | Aussparung + einfacher Rahmen-Quader | +| **fine** (1:50) | + Glas-Doppellinie, Sims, Flügel-Teilung, Anschlag | Rahmen + Blatt + Glas (transparent) + Sims (DOSSIER `_OEFF_PIECE_DEFS`) | + +- **Tür-Schwenkbogen:** im Spike (`generatePlan.addDoorSymbol` — Blatt + Arc). + Ausbau: `openAngle`, lichte vs. volle Breite je LoD (Port von + `_make_tuer_swing_curves`). +- **3D-Stücke** (Rahmen/Glas/Flügel/Sims/Sturz) ≙ DOSSIER `_make_oeffnung_pieces` + / `_OEFF_PIECE_DEFS` — jeweils eigene Component (Farbe + Transparenz: Glas + α≈0.88, IOR 1.5). Pieces landen auf Unter-Ebenen von `21 Türen/Fenster`. + +--- + +## 3. Decke / Boden (Slab) — mit Aussparungen + +### 3.1 Daten +```ts +interface Slab extends ElementBase { + type: "slab"; + boundary: Vec2[]; // geschlossener Umriss (CCW) + slabTypeId: string; // mehrschichtig (analog WallType) + openings?: Vec2[][]; // Aussparungen: Treppenauge, Schacht, Kamin (DOSSIER aussp) + ukOverride?: number; okOverride?: number; // UK/OK statt auto (Abhängung, schräge Brüstung) +} +``` + +### 3.2 Generierung +- **Z-Auflösung:** `okOverride ?? (baseElevation_oberes_Geschoss)`, + `ukOverride ?? (ok - thickness)` — Port von `_resolve_decke_z`. Decke sitzt + standardmäßig zwischen zwei Geschossen. +- **3D:** `boundary` als `THREE.Shape`, Aussparungen als `shape.holes` (`THREE.Path`), + `ExtrudeGeometry` über die Schichten (≙ `_make_decke_volume(outline, holes)`). +- **Plan:** im Schnitt unter `cutHeight` meist nur Kante; Aussparungs-Ränder als + Linien; geschnittene Decke (in Schnitt-Ansicht) bekommt Schraffur. +- **Aussparung↔Decke:** Aussparung als geschlossene Curve, die räumlich in der + Decke liegt (`_find_decke_containing_point` / `_find_aussparungen_for_decke`). + Bei uns: `Slab.openings` direkt im Slab — keine separate Source nötig (einfacher + als DOSSIERs Parent-Child). + +--- + +## 4. Treppe (Stair) — Typen, Lauflinie, geschossübergreifend + +### 4.1 Daten +```ts +interface Stair extends ElementBase { + type: "stair"; + kind: "straight" | "l-shaped" | "spiral"; // gerade | L | Wendel (DOSSIER _TREPPE_ARTEN) + run: Vec2[]; // Lauflinien-Stützpunkte (gerade: 2; L: 3; Wendel: Zentrum+Start) + width: number; + reference: "mid" | "left" | "right"; // Lage der Lauflinie zur Treppe + steps: number; // Anzahl Steigungen + mode: "solid" | "flat" | "slab-edge"; // massiv | flach | Plattenrand + runSlabThickness?: number; // Lauf-Plattendicke + floorEndId?: string; // Zielgeschoss (geschossübergreifend, Risiko #6) + heightOverride?: number; ukOverride?: number; + rules?: { riser:[lo,hi,on]; tread:[lo,hi,on]; stepGo:[lo,hi,on] }; // SIA-Komfortregeln + lockRiser?: { value: number }; // Schrittmass-Lock (S fix, N passt sich an) + // Plan-Symbol-Flags (DOSSIER _KEY_TREPPE_SHOW_*) + show?: { treads; runLine; outline; breakLine }; + upperDashed?: boolean; // obere Stufen gestrichelt (über Schnitthöhe) + arrowStyle?: "classic"|"filled"|"double"|"line"; +} +``` + +### 4.2 Generierung +- **Steigung/Auftritt:** `riser = height/steps`; `tread` aus Lauflinienlänge / + (steps−1). SIA-Komfort: `2·riser + tread ∈ [0.60, 0.65]` (DOSSIER + `_TREPPE_SOLL_DEFAULT`). Lock: ist `lockRiser` gesetzt, wird `steps` neu + berechnet statt `riser` zu ändern. +- **3D je `kind`:** gerade → Stapel von Tritt-Quadern oder massive Rampe; + L → zwei Läufe + Podest (`podestMin`); Wendel → um Zentrum rotierte Tritte + (Port `_make_treppe_*_preview` / Volume-Funktionen). `mode` steuert massiv vs. + Lauf-Platte. +- **Geschossübergreifend (Risiko #6):** Höhe = `(baseElevation[floorEndId] - + baseElevation[floorId])` falls `floorEndId` gesetzt; sonst Geschosshöhe. Treppe + taucht dann in beiden Geschoss-Grundrissen auf (mit Schnitt an `cutHeight`). +- **Plan-Symbol (normgerecht):** Lauflinie mit **Auf-/Abpfeil** (`arrowStyle`), + Stufenkanten, Bruchlinie an `cutHeight` (untere durchgezogen, obere gestrichelt + via `upperDashed`), Außenkante. ≙ DOSSIERs 2D-Treppensymbol; liegt auf Ebene + `40 Treppen`/`41 Treppen-2D`. + +### 4.3 Grip-Editing +Lauflinien-Stützpunkte als Grips (wie Wand-Vertices, §1.4); Ziehen ändert +Geometrie + Stufenzahl reaktiv. + +--- + +## 5. Dach (Roof) + +### 5.1 Daten +```ts +interface Roof extends ElementBase { + type: "roof"; + outline: Vec2[]; // Grundriss-Umriss + roofType: "mono" | "gable" | "hip" | "mansard"; // Pult|Sattel|Walm|Mansarde + thickness: number; + slope: number; // Grad (Hauptneigung) + eaveIndex?: number; // Index der Traufkante (Pult) + ridge?: "long" | "short"; // Firstrichtung (Sattel) + // Mansarde: + slopeLower?: number; kinkHeight?: number; + mansardVariant?: "hip" | "gable" | "hip-gable"; +} +``` + +### 5.2 Generierung +Port von DOSSIERs `_make_pultdach/_satteldach/_walmdach/_mansardendach*` + +`_thicken_roof_inward`. Aufwand M–L (Mansarde später). Reihenfolge: Pult → +Sattel → Walm → Mansarde. 3D als Brep/Mesh über OpenCascade.js (Worker), da +Schräg-Verschneidung Booleans braucht. Plan: Firstlinien + Traufe + ggf. +Höhenkoten. + +--- + +## 6. Tragwerk (Column / Beam) + +### 6.1 Daten +```ts +interface ProfileDef { + shape: "square"|"rect"|"round"|"i-beam"|"tube"; // DOSSIER _TRAG_PROFILE + b?: number; h?: number; d?: number; t?: number; // Breite/Höhe/Durchm./Wanddicke + angle: number; // Rotation um Z +} +interface Column extends ElementBase { type:"column"; point: Vec2; profile: ProfileDef; + uk?: number; ok?: number; } +interface Beam extends ElementBase { type:"beam"; axis:[Vec2,Vec2]; profile: ProfileDef; + zTop?: number; // hängt unter Decken-OK (zTop = ok der Decke) +} +``` + +### 6.2 Generierung +- **Querschnitt:** `profileCurve(shape, b,h,d,t, angle)` (Port `_trag_profile_curve`) + → für Stütze entlang Z extrudieren (`_make_stuetze_volume`), für Träger entlang + der Achse (`_make_traeger_volume`, Profil in der Schnitt-Ebene). +- **Träger achs-basiert unter Decke:** `zTop` default = OK der darüberliegenden + Decke → Unterzug folgt automatisch (weniger Update-Fehler, ROADMAP §11). +- Stützen liegen auf `25 Stützen`, Träger auf `35 Träger`. + +--- + +## 7. Raum (Space) — SIA-416 + Stempel + +### 7.1 Daten +```ts +interface Space extends ElementBase { + type: "space"; + boundary: Vec2[]; // geschlossener Umriss + number?: string; spaceName?: string; function?: string; + sia?: "" | "HNF"|"NNF"|"VF"|"FF"|"GF"|"AGF"; // SIA-416-Klasse + persons?: number; // Personenbelegung (Brandschutz) + areaRounding: "exact"|"0.01"|"0.1"|"0.5"|"1"; + stamp: StampConfig; // Raumstempel-Layout (s.u.) + fill?: string; // Füll-Hatch-Id (optional) +} +interface StampConfig { // ≙ DOSSIER Stempel-Builder + layout: FieldId[][]; // Zeilen × Felder, z.B. [["number","name"],["function"],["area"]] + font; bold; italic; textHeight; textMode:"fixed"|"scale"; align:"left"|"mid"|"right"; + offset: Vec2; // Stempel-Position relativ zum Centroid (User-Move) +} +type FieldId = "number"|"name"|"function"|"area"|"sia"; +``` + +### 7.2 Generierung & Bilanz +- **Fläche:** Shoelace-Formel über `boundary`, gerundet nach `areaRounding` + (`_resolve_raum_rundung`). Umfang analog. +- **Stempel:** als SVG-Text-Block aus `layout`-Zeilen am Centroid + `offset` + (User kann verschieben; Offset persistiert wie DOSSIER `stamp_dx/dy`). + `textMode:"scale"` → Texthöhe in Paper-mm × Massstab (plans-output.md). +- **SIA-Färbung:** über die **Overrides-Engine** (regelbasiert), nicht hartcodiert + — DOSSIER `_build_sia_preset_rules` erzeugt 4 Regeln `userString sia == hnf|nnf|vf|ff` + → Farbe + Solid-Hatch. Bei uns: ein Override-Preset „SIA-416" (resources-graphics.md), + das auf `space.sia` matcht. Toggle = Preset aktivieren. +- **SIA-Bilanz + CSV:** `panels/SiaBalance.tsx` summiert Flächen je Klasse je + Geschoss → Tabelle + CSV-Export (`HNF/NNF/VF/FF/GF/AGF`). Pflicht für + CH-Flächennachweis (ROADMAP ⭐). + +--- + +## 8. Werkzeuge (Tools) — ersetzt Rhino-Command-Aliases + +DOSSIER hat pro Bauteil ein Command-Alias (`rhino/aliases/cmd/wand.py`, `tuer.py`, +`treppe.py`, …) das `GetPoint`-Interaktionen fährt. Browser: ein **Tool-Interface** +mit Pointer-Handlern + Snap. + +```ts +interface Tool { + id: ToolId; + onPointerDown(pt: Vec2, snap: SnapResult, state): void; + onPointerMove(pt: Vec2, snap: SnapResult, state): Primitive[]; // Live-Preview + onPointerUp(pt: Vec2, snap: SnapResult, state): void; + commit(store): void; // ruft store.apply() +} +``` + +| Tool | DOSSIER-Alias | Kurzbeschrieb | +|---|---|---| +| `wall` | `cmd/wand` | Achse zeichnen (Linie/Polyline), Dicke/Referenz/Typ aus „last used" | +| `door`/`window` | `cmd/tuer`,`fenster` | Punkt auf Wandachse → hosten (Snap an Wand) | +| `slab` | `cmd/decke` | Umriss klicken; Aussparung als Loch | +| `stair` | `cmd/treppe` | Lauflinie + Breite + Stufen | +| `roof` | `cmd/dach` | Umriss + Typ + Neigung | +| `column`/`beam` | `cmd/stuetze`,`traeger` | Punkt / Achse + Profil | +| `space` | `cmd/raum` | Umriss → Fläche auto, Stempel | +| `draw2d` | `cmd/symbol`,`stempel` | Linie/Polyline/Rect/Kreis/Bogen/Text auf `60 Plangrafik` | +| `pipette` | `cmd/pipette` | Stil/Typ von Element übernehmen | + +**Snap-Engine** (`tools/snap.ts`): Endpunkt, Mitte, Schnitt, senkrecht, Raster, +Ortho — ersetzt Rhinos OSnap. T-Snap an andere Wandachsen (Port +`_t_snap_to_wand_axis`, `_snap_endpoint_to_other_wand_axis`) sorgt für saubere +Knoten. + +--- + +## 9. Element-Übersicht (BIM-Tree) +`panels/ElementTree.tsx`: Baum Geschoss → Bauteiltyp → Element, mit Suche und +Shift-Klick = Zoom (DOSSIER ELEMENTE-ÜBERSICHT). Inhaltsverzeichnis bei 100+ +Elementen — reine Ableitung aus `project.elements`. + +--- + +## 10. Reihenfolge der Umsetzung (verweist auf ROADMAP-Phasen) + +1. **Phase 1:** Wand mehrschichtig ✅ + L-Gehrung ✅ → **Prio-T-Stoß** (§1.3); + Tür/Fenster gehostet (§2); Decke + Aussparung (§3); Wand-Referenzlage (§1.1); + Element-Übersicht (§9). +2. **Phase 2:** Treppe (§4), Dach (§5), Tragwerk (§6), SIA-Räume + Stempel (§7), + Stil-Kataloge. +3. **Phase 3–4:** Grip-Editing (§1.4/§4.3), exakte B-Rep-Booleans im Worker. diff --git a/docs/design/layer-display-settings.md b/docs/design/layer-display-settings.md new file mode 100644 index 0000000..d3f2ada --- /dev/null +++ b/docs/design/layer-display-settings.md @@ -0,0 +1,498 @@ +# Design — Ebenen-Darstellung (Layer Display Settings) + +> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md). +> Ressourcen/Stile: [resources-graphics.md](resources-graphics.md). Output/Pläne: +> [plans-output.md](plans-output.md). Kontextmenü/Inline-Editor: +> [context-menu.md](context-menu.md). + +Dieses Dokument spezifiziert die **per-Ebene Darstellungseinstellungen** auf der +`LayerCategory` (Grafik-Kategorie) und den dazugehörigen Editor +„Ebeneneinstellungen…", der aus dem Ebenen-Kontextmenü geöffnet wird. + +Heute trägt jede `LayerCategory` nur eine flache Strichstärke (`lw`), eine `color` +und eine optionale `hatch` (ein freier String, der nirgends aufgelöst wird). Das +reicht nicht: Eine Ebene soll — wie in Vectorworks/DOSSIER — einen vollständigen +**Stift (PEN)** und eine vollständige **Standard-Schraffur (HATCH)** definieren, +die beim Rendern angewandt werden. Bezeichner englisch, Prosa/UI-Text deutsch +(CONVENTIONS.md). + +--- + +## 1. Zielbild + +Jede Ebene definiert zwei Darstellungs-Aspekte, die in den Grundriss-Generator +einfließen: + +- **PEN** — Linienstil der Ebene: `type` (durchgezogen / gestrichelt / …), + `color` und `lw` (Strichstärke in mm Papier). Steuert alle Umriss-/Symbol-Linien + der Elemente dieser Ebene (Wand-Umriss, Tür-Symbol, Referenzlinie). +- **HATCH** — Standard-Schraffur der Ebene: `pattern`, `scale`, `angle` und die + `lineWeight` der Musterlinien. Wird angewandt, wo ein Element keine eigene + Schraffur aus einem `Component` mitbringt (z. B. einschichtige/„grob"-Flächen, + reine 2D-Zeichnungsobjekte einer Ebene). + +Beides folgt dem Architektur-Prinzip: **Darstellung wird beim Rendern aufgelöst, +nie in die Geometrie eingebacken.** + +--- + +## 2. Reference vs. Inline — Entscheidung + +Es gibt drei Modelle, ein Datum für PEN/HATCH einer Ebene zu halten: + +1. **Pure inline** — die Ebene trägt `{type,color,lw}` und `{pattern,scale,angle, + lineWeight}` direkt. Einfach, aber: kein Wiederverwenden, kein zentrales + Ändern; widerspricht der Ressourcen-Architektur (resources-graphics.md §1: + „alles verweist per id, zentral änderbar"). +2. **Pure reference** — die Ebene trägt nur `lineStyleId` / `hatchId`. Konsistent, + zentral, aber unflexibel: Eine Ebene kann z. B. nicht „den Stil X, aber in + ihrer eigenen Farbe" wollen, ohne einen Klon-Stil anzulegen. +3. **Reference + optionale per-Ebene Overrides** (EMPFOHLEN) — die Ebene + **verweist** auf eine `LineStyle`- bzw. `HatchStyle`-Ressource und darf + **einzelne Felder lokal überschreiben**. Das ist exakt das DOSSIER/Vectorworks- + Muster: ein Stil als Basis, regelbasierte/lokale Overrides obendrauf + (resources-graphics.md, `overrides.py`). + +### Empfehlung: Reference + optionale Overrides + +Begründung: + +- **Zentrale Pflege bleibt erhalten:** Ändert man den Linienstil „Wand stark" im + Line Manager, ziehen alle Ebenen nach, die ihn referenzieren und das jeweilige + Feld nicht überschreiben. +- **Lokale Freiheit ohne Stil-Wildwuchs:** Eine Ebene kann punktuell `color` oder + `lw` anpassen (häufigster Fall: gleiche Strichart, andere Farbe), ohne einen + fast identischen Stil zu duplizieren. +- **Migrationsfähig:** Die heutige flache `{color, lw}` der Ebene wird zu reinen + Overrides über einem neutralen Basis-Stil — verlustfrei (siehe §6). +- **Konsistent mit der bestehenden Kette:** `Component → Hatch → LineStyle` + verweist bereits per id; Ebenen reihen sich nahtlos ein. + +Die Overrides sind **sparse**: nur gesetzte Felder überschreiben. Ein leeres +Override-Objekt (oder `undefined`) bedeutet „komplett dem Stil folgen". + +--- + +## 3. Datenmodell (TS) + +### 3.1 LineStyle erweitern um `type` + +`LineStyle` trägt heute schon `weight`, `color`, `dash`. Wir machen die +Strichart explizit benennbar (statt nur via `dash`-Array), damit der Editor ein +sauberes Dropdown anbietet und `dash` daraus ableiten kann. + +```ts +/** Benannte Strichart eines Stifts (für UI-Dropdown). */ +export type LineKind = "solid" | "dashed" | "dotted" | "dashdot"; + +/** mm-Strichmuster je Strichart (relativ zur Papier-mm). */ +export const LINE_DASH: Record = { + solid: null, + dashed: [0.6, 0.4], + dotted: [0.1, 0.25], + dashdot: [0.6, 0.25, 0.1, 0.25], +}; + +export interface LineStyle { + id: string; + name: string; + /** NEU: benannte Strichart; `dash` wird daraus abgeleitet, falls nicht gesetzt. */ + kind: LineKind; + /** Strichstärke in Millimetern (≙ Rhino PlotWeight). */ + weight: number; + color: string; + /** Strichmuster in mm; `null` = durchgezogen. Optional — sonst aus `kind`. */ + dash: number[] | null; +} +``` + +> Hinweis: `kind` ist additiv; bestehende `LineStyle`-Daten setzen es per Migration +> aus `dash` (§6). + +### 3.2 PEN- und HATCH-Override-Typen + +```ts +/** + * Per-Ebene Stift (PEN). Verweist auf einen LineStyle; einzelne Felder dürfen + * lokal überschrieben werden. Alle Override-Felder optional (sparse). + */ +export interface LayerPen { + /** Basis-Linienstil (Line Manager). */ + lineStyleId: string; + /** Lokale Overrides — nur gesetzte Felder gewinnen. */ + override?: { + kind?: LineKind; + color?: string; + /** Strichstärke in mm Papier. */ + lw?: number; + }; +} + +/** + * Per-Ebene Standard-Schraffur (HATCH). Verweist auf einen HatchStyle; einzelne + * Felder dürfen lokal überschrieben werden. `enabled=false` = Ebene hat keine + * Default-Schraffur (Umriss-only). + */ +export interface LayerHatch { + /** Aktiv? false = keine Default-Schraffur dieser Ebene. */ + enabled: boolean; + /** Basis-Schraffur (Hatch Manager). */ + hatchId: string; + /** Lokale Overrides — nur gesetzte Felder gewinnen. */ + override?: { + pattern?: HatchPattern; + scale?: number; + /** Drehung in Grad. */ + angle?: number; + color?: string; + /** Strichstärke der Musterlinien in mm Papier. */ + lineWeight?: number; + }; +} +``` + +### 3.3 LayerCategory erweitern + +```ts +export interface LayerCategory { + code: string; + name: string; + visible: boolean; + locked: boolean; + + // ── NEU: vollständige Darstellung ────────────────────────────────────────── + /** Stift der Ebene (PEN) — Linien aller Elemente dieser Ebene. */ + pen: LayerPen; + /** Standard-Schraffur der Ebene (HATCH). */ + hatch: LayerHatch; + + /** Unterkategorien (Baum). */ + children?: LayerCategory[]; + + // ── DEPRECATED (nur Übergang; siehe Migration §6) ────────────────────────── + /** @deprecated → pen.override.color. */ + color?: string; + /** @deprecated → pen.override.lw. */ + lw?: number; +} +``` + +`color` und `lw` bleiben als optionale, deprecatete Felder bestehen, bis alle +Lesepfade auf den Resolver (§4) umgestellt sind, und werden dann entfernt. Die +Panel-Swatch (`LayersPanel`) liest künftig die **aufgelöste** Stift-Farbe. + +--- + +## 4. Resolver — vom Modell zur Render-Entscheidung + +Der Resolver löst PEN/HATCH einer Ebene gegen die Ressourcen-Bibliotheken auf und +wendet die Overrides an. Er ist die **einzige** Stelle, an der „Stil + Override" +zusammenfließen; Generator und Panel rufen nur ihn. + +### 4.1 Aufgelöste Render-Typen + +`HatchRender` existiert bereits in `generatePlan.ts`. Wir ergänzen ein paralleles +`PenRender` und exportieren beide Resolver aus einem neuen Modul +`src/model/layerStyle.ts` (damit Panel und Generator teilen). + +```ts +/** Aufgelöster Stift einer Ebene — alles, was die Linie zu zeichnen braucht. */ +export interface PenRender { + color: string; + /** Strichstärke in mm Papier. */ + lw: number; + /** Strichmuster in mm Papier; null = durchgezogen. */ + dash: number[] | null; +} + +// HatchRender: bereits in generatePlan.ts definiert (pattern, scale, angle, +// color, lineWeight, dash). Wird nach layerStyle.ts gezogen und re-exportiert. +``` + +### 4.2 Resolver-Funktionen (Pseudocode) + +```ts +function resolvePen(project: Project, layer: LayerCategory): PenRender { + const ls = getLineStyle(project, layer.pen.lineStyleId); // wirft, falls fehlend + const o = layer.pen.override ?? {}; + const kind = o.kind ?? ls.kind; + return { + color: o.color ?? ls.color, + lw: o.lw ?? ls.weight, + // Override-kind setzt das dash neu; sonst Stil-dash bzw. aus kind abgeleitet. + dash: o.kind ? LINE_DASH[o.kind] : (ls.dash ?? LINE_DASH[ls.kind]), + }; +} + +function resolveLayerHatch(project: Project, layer: LayerCategory): HatchRender | null { + if (!layer.hatch.enabled) return null; // Ebene ohne Default-Schraffur + const h = getHatch(project, layer.hatch.hatchId); // wirft, falls fehlend + const o = layer.hatch.override ?? {}; + // Musterlinien-Stärke: Override > LineStyle der Schraffur > Default 0.13 mm. + const baseLs = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null; + return { + pattern: o.pattern ?? h.pattern, + scale: o.scale ?? h.scale, + angle: o.angle ?? h.angle, + color: o.color ?? h.color, + lineWeight: o.lineWeight ?? baseLs?.weight ?? 0.13, + dash: baseLs?.dash ?? null, + }; +} +``` + +Beide bauen eine `Map` über den ganzen Baum, analog zur heutigen +`categoryLwMap`: + +```ts +export function penMap(project: Project): Map { + const m = new Map(); + for (const c of flattenCategories(project.layers)) m.set(c.code, resolvePen(project, c)); + return m; +} +export function layerHatchMap(project: Project): Map { + const m = new Map(); + for (const c of flattenCategories(project.layers)) + m.set(c.code, resolveLayerHatch(project, c)); + return m; +} +``` + +--- + +## 5. Einfluss auf `generatePlan` + +Heute (generatePlan.ts): + +- `categoryLwMap(project.layers)` liefert nur `lw` je Code; die Umriss-Strichstärke + kommt daraus, **Farbe** der Umrisse ist fest `POCHE_STROKE`. +- Schraffur kommt ausschließlich aus dem `Component` der jeweiligen Schicht + (`resolveHatch(project, comp.hatchId)`); die Ebenen-`hatch` wird **nicht** genutzt. + +Änderungen (minimal-invasiv, additiv): + +### 5.1 Pens ersetzen `lwByCode` + +```ts +const pens = penMap(project); // statt categoryLwMap +const layerHatches = layerHatchMap(project); +… +const pen = pens.get(wall.categoryCode) ?? FALLBACK_PEN; // {color, lw, dash} +``` + +`addWallPoche` und `addDoorSymbol` bekommen statt `wallLwMm: number` / +`doorLwMm: number` jeweils das ganze `pen: PenRender`: + +- **Wand-Umrisslinie:** `stroke: pen.color` (statt fix `POCHE_STROKE`), + `strokeWidthMm: pen.lw * OUTLINE_DETAIL_FACTOR[detail]`, `dash: pen.dash`. + → Das `Primitive` „polygon" braucht ein optionales `dash?: number[] | null` + (Schichtfugen bleiben durchgezogen; nur die Umriss-Kontur nutzt `pen.dash`). +- **Schichtfugen:** behalten `POCHE_STROKE` und ihre dünne `LAYER_LINE_MM` + (interne Hilfslinien sind bewusst neutral, nicht stift-gefärbt). +- **Tür-Symbol / Referenzlinie:** `cls` bleibt, aber `weightMm` aus `pen.lw`, + und die PlanView darf die Stift-Farbe nutzen (`door-leaf` etc. erhalten optional + ein `stroke`-Feld am line/arc-Primitive; ansonsten greift die CSS-Klasse wie + bisher). + +### 5.2 Default-Schraffur der Ebene + +Die Ebenen-Schraffur greift dort, wo **keine Component-Schraffur** vorliegt: + +- **`detail === "grob"`** (eine Sammelfläche, heute `NO_HATCH`): statt `NO_HATCH` + nun `layerHatches.get(wall.categoryCode) ?? NO_HATCH`. So bekommt die grobe + Poché die Standard-Schraffur der Ebene (z. B. ein leichtes Diagonalmuster), + falls die Ebene eine definiert; sonst bleibt sie ungeschraffiert. +- **mittel/fein, mehrschichtig:** unverändert — die Component-Schraffur je Schicht + hat Vorrang (spezifischer als die Ebene). Die Ebenen-Schraffur ist der + *Fallback*, nicht der Default-Override. +- **Reine 2D-Zeichnungsobjekte** (künftige `drawing`-Ebenen-Elemente ohne + Component): nutzen direkt `resolveLayerHatch` als ihre Füllschraffur. + +Auflöse-Reihenfolge der Schraffur einer gezeichneten Fläche: + +``` +Component.hatch > LayerCategory.hatch (enabled) > keine Schraffur +``` + +### 5.3 Geänderte Signaturen (Zusammenfassung) + +```ts +// vorher: addWallPoche(out, project, wall, doors, cuts, greyed, detail, wallLwMm) +function addWallPoche(out, project, wall, doors, cuts, greyed, detail, + pen: PenRender, layerHatch: HatchRender | null): void + +// vorher: addDoorSymbol(out, wall, door, greyed, detail, doorLwMm) +function addDoorSymbol(out, wall, door, greyed, detail, pen: PenRender): void +``` + +`Primitive` (polygon) erhält optional `dash?: number[] | null`; line/arc erhalten +optional `stroke?: string`, damit Pen-Farbe durchschlagen kann (CSS-Klasse bleibt +Default). + +--- + +## 6. Editor „Ebeneneinstellungen…" + +Geöffnet wie heute über `layerMenuItems → openLayerEditor(code)` → +`setEditor({ kind: "layer", code, x, y })`. Der bestehende `InlineEditor`-Rahmen +(dunkel, am Anker, Esc/Außenklick schließt) und die `EditorField`-Zeilen bleiben; +der Inhalt wächst von 3 Feldern auf zwei kompakte Abschnitte **PEN** und **HATCH**. + +Da der Editor jetzt mehr Felder trägt, wird er als **kompakte Sektions-Form** +gestaltet (zwei Gruppen mit Trenn-Überschrift), gemäß CONVENTIONS.md UI-Konventionen +(saubere Form, keine wiederholten Beschriftungen, DOSSIER-Stil, alles via `t()`). + +### 6.1 Aufbau + +``` +┌ Ebene 20 ───────────────── × +│ Name [ Wände ] +│ +│ ── Stift (PEN) ────────────── +│ Linienstil [ Wand stark ▾ ] ← Dropdown über project.lineStyles +│ Strichart [ durchgezogen ▾ ] ← override.kind (leer = "vom Stil") +│ Farbe [■] [↺] ← override.color; ↺ = Override entfernen +│ Stärke [ 0.35 ] mm [↺] ← override.lw +│ +│ ── Schraffur (HATCH) ──────── +│ [✓] aktiv +│ Schraffur [ Beton ▾ ] ← Dropdown über project.hatches +│ Muster [ vom Stil ▾ ] ← override.pattern +│ Maßstab [ 1.00 ] [↺] +│ Drehung [ 45 ] ° [↺] +│ Farbe [■] [↺] +│ Linienst. [ 0.13 ] mm [↺] +└────────────────────────────── +``` + +- **Override-Semantik im UI:** Jedes Override-Feld zeigt entweder „vom Stil" + (Override leer → Platzhalter mit dem aufgelösten Stil-Wert als Hint) oder einen + konkreten Wert. Ein kleiner **Reset-Knopf `↺`** je Override-Feld löscht das + Override (setzt es zurück auf `undefined` → Feld folgt wieder dem Stil). +- **Live, kein Bestätigen:** wie der heutige Editor — jede Änderung ruft sofort + `patchCategory(code, patch)`. +- **i18n:** alle Labels über `t()`. Neue Keys (Beispiele): + `editor.pen`, `editor.lineStyle`, `editor.lineKind`, `editor.color`, + `editor.lineWeight`, `editor.hatch`, `editor.hatchEnabled`, `editor.pattern`, + `editor.scale`, `editor.rotation`, `editor.fromStyle`, `editor.resetOverride`. + Strichart-/Muster-Werte: `lineKind.solid`, `lineKind.dashed`, …, + `hatchPattern.solid`, `hatchPattern.diagonal`, … . Menü-Label bleibt + `ctx.layerSettings`. + +### 6.2 Patch-Helfer + +`patchCategory(code, patch: Partial)` bleibt die Schnittstelle. +Für die verschachtelten Overrides nutzt der Editor schmale Helfer (im App-Scope), +die sparse mergen und leere Overrides auf `undefined` kollabieren: + +```ts +function setPenOverride(cat: LayerCategory, patch: Partial) { + const next = pruneEmpty({ ...cat.pen.override, ...patch }); + patchCategory(cat.code, { pen: { ...cat.pen, override: next } }); +} +function setHatchOverride(cat, patch) { /* analog für cat.hatch.override */ } +// pruneEmpty: entfernt undefined-Felder; gibt undefined zurück, wenn leer. +``` + +`setLineStyleId` / `setHatchId` setzen nur die Referenz; `hatch.enabled` ist ein +Checkbox-Patch. + +### 6.3 „Eigenschaften kopieren / einfügen" + +Der bestehende `layerClipboard` (heute `{ color, lw }`) wird auf die volle +Darstellung erweitert: `{ pen, hatch }` (die Override-tragenden Strukturen, ohne +`code/name/visible/locked`). „Kopieren" liest `{ pen, hatch }` der Quelle, +„Einfügen" patcht sie auf das Ziel. So überträgt sich der komplette Stift + +Schraffur einer Ebene auf eine andere. + +--- + +## 7. Migration bestehender Beispieldaten + +Bestehende Projekte/Sample-Daten haben `LayerCategory { color, lw, hatch?: string }` +und `LineStyle { weight, color, dash }` (ohne `kind`). Eine reine Lese-Zeit- +Migration (`migrateProject(project)`), idempotent, beim Laden: + +1. **LineStyle.kind ableiten** — aus `dash`: + ``` + dash == null || dash.length === 0 → "solid" + sonst, wenn min(dash) sehr klein → "dotted" (heuristisch) + sonst → "dashed" + ``` + (Eine genaue Zuordnung ist nicht nötig; `dash` bleibt führend, `kind` ist nur + für das Dropdown.) + +2. **Neutralen Basis-Linienstil sicherstellen** — falls die Bibliothek noch keinen + generischen „Standard"-Stift hat, einen `lineStyle` mit + `{ id: "ls-default", name: "Standard", kind: "solid", weight: , color: "#000", dash: null }` + anlegen. (Pro Ebene wird der Stift referenziert; die Ebenen-spezifischen + `color`/`lw` wandern in das **Override**, nicht in den Stil — so bleibt der + Stil wiederverwendbar.) + +3. **Pro LayerCategory `pen` bauen:** + ```ts + pen = { + lineStyleId: "ls-default", + override: pruneEmpty({ color: cat.color, lw: cat.lw }), + } + ``` + Damit ist die Darstellung **pixelgenau wie vorher** (gleiche Farbe, gleiche lw), + nur jetzt über die Resolver-Kette. + +4. **Pro LayerCategory `hatch` bauen** — aus dem alten `hatch?: string`: + - War `hatch` ein gültiger `HatchStyle.id` → `{ enabled: true, hatchId: hatch }`. + - War es ein Pattern-Name oder leer/unbekannt → `{ enabled: false, hatchId: + }` (Referenz muss existieren, aber inaktiv). + So entsteht **keine** unbeabsichtigte Schraffur (Default heute: keine). + +5. **Deprecated-Felder belassen** für eine Übergangsphase; nach Umstellung aller + Lesepfade (`generatePlan`, `LayersPanel`-Swatch, Clipboard) in einem zweiten + Schritt `color`/`lw` aus `LayerCategory` und der alte `hatch: string` entfernen. + +Migration ist **idempotent**: Liegt `pen`/`hatch` bereits vor, wird die Ebene +unverändert durchgereicht. + +--- + +## 8. Build-Plan (phasiert) + +**Phase 1 — Datenmodell & Resolver (keine UI-Sichtbarkeit).** +- `LineKind` + `LINE_DASH`, `LineStyle.kind`, `LayerPen`, `LayerHatch`, + `LayerCategory.pen/hatch` in `types.ts`. +- `src/model/layerStyle.ts`: `PenRender`, `resolvePen`, `resolveLayerHatch`, + `penMap`, `layerHatchMap`; `HatchRender` hierher ziehen + re-exportieren. +- `migrateProject()` (Schritte §7) + Aufruf beim Laden/Seed. +- `npx tsc -b` grün. + +**Phase 2 — Generator umstellen.** +- `generatePlan` nutzt `penMap`/`layerHatchMap` statt `categoryLwMap`. +- `Primitive`-polygon `dash?`, line/arc `stroke?` ergänzen; `addWallPoche`/ + `addDoorSymbol`-Signaturen auf `PenRender` + `HatchRender|null`. +- Ebenen-Default-Schraffur in „grob" und für Schicht-lose Flächen verdrahten. +- Visuell prüfen via `node scripts/probe.mjs` (Geometrie unverändert, Farben/lw + identisch zur Migration). + +**Phase 3 — Panel.** +- `LayersPanel`-Swatch liest aufgelöste Stift-Farbe (`resolvePen(...).color`). + +**Phase 4 — Editor.** +- `InlineEditor`-Inhalt für `kind: "layer"` auf die PEN/HATCH-Sektionen erweitern + (§6), mit Dropdowns über `project.lineStyles` / `project.hatches`, Reset-Knöpfen, + neuen i18n-Keys. +- `layerClipboard` auf `{ pen, hatch }` erweitern; Kopieren/Einfügen anpassen. + +**Phase 5 — Aufräumen.** +- Deprecatete `color`/`lw`/`hatch: string` aus `LayerCategory` entfernen, sobald + kein Lesepfad sie mehr nutzt; Sample-Daten direkt im neuen Format ablegen. + +--- + +## 9. Offene Punkte / bewusst nicht jetzt + +- **Pro-Geschoss-Overrides der Ebene** (eine Ebene anders je `DrawingLevel`): + nicht in dieser Iteration; das Schema gilt geschossübergreifend (types.ts). + Falls später nötig, als zweite Override-Ebene über demselben Resolver. +- **Regelbasierte Overrides** (resources-graphics.md, `overrides.py`): orthogonal; + würden nach der Ebenen-Auflösung greifen. +- **Linienstil-Endkappen/Joins** und feinere Dash-Skalierung: bleiben in der + PlanView (Darstellung), nicht im Modell. diff --git a/docs/design/plans-output.md b/docs/design/plans-output.md new file mode 100644 index 0000000..e7765e1 --- /dev/null +++ b/docs/design/plans-output.md @@ -0,0 +1,338 @@ +# Design — Pläne & Output + +> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md). +> Bauteile: [elements.md](elements.md). Ressourcen/Stile: [resources-graphics.md](resources-graphics.md). + +Hier gewinnen wir (ROADMAP §3, Phase 3 ⭐): **schöne, normgerechte 2D-Pläne**, +automatisch aus dem Modell abgeleitet, druckfertig als Vektor-PDF. Dieses Dokument +übersetzt DOSSIERs `schnitte.py`, `massstab.py`, `ausschnitte.py`, `kamera.py`, +`dimensionen.py`, `layouts.py` in Browser-Module. Bezeichner englisch, Prosa +deutsch, Meter. + +--- + +## 1. Ansichtstypen = Kamera-Projektion + optionaler Schnitt + +Vereinheitlichtes Modell (ROADMAP §2c, im Spike als `DrawingLevelKind` angelegt): + +| Typ | Projektion | Schnitt | Erzeugung | +|---|---|---|---| +| **Grundriss** | Ortho Top | horizontal auf `okff + cutHeight` | symbolisch aus Footprint (Pfad A) | +| **Schnitt** | Ortho Front (Richtung) | vertikale Schnittebene + Tiefe | 3D-Projektion/HLR (Pfad B) | +| **Ansicht** | Ortho Front (Richtung) | kein Schnitt (Fassade außen) | 3D-Projektion/HLR (Pfad B) | +| **Perspektive** | 3D perspektivisch | — | Three.js direkt | + +```ts +type ViewType = "plan" | "section" | "elevation" | "perspective"; +interface DerivedView { // was der Viewport gerade zeigt + type: ViewType; + levelId?: string; // Geschoss (plan) bzw. Schnitt/Ansicht (DrawingLevel) + camera: CameraState; + cut?: CutSpec; // Clipping-Spezifikation (s.u.) + detailLevel: DetailLevel; +} +interface CutSpec { + planes: { point: Vec3; normal: Vec3 }[]; // 1 (plan/elevation) oder 2 (section: cut+back) +} +``` + +**Zwei Wege zum Plan** (zentrale Architektur-Erkenntnis, ROADMAP §3) — wir bauen +**beide**: +- **A) Grundriss = symbolisch** aus den Parametern (`plan/generatePlan.ts`, im + Spike). Schnell, exakt, vektorbasiert. Kein Mesh-Schnitt. +- **B) Schnitt & Ansicht = 3D-Projektion mit Hidden-Line-Removal** (`plan/ + generateSection.ts`, §4). Durch das zusammengebaute Gebäude. + +--- + +## 2. Schnitt & Ansicht — Datenmodell & Aktivierung + +DOSSIER speichert Schnitte als Zeichnungsebenen-Eintrag (`type:"schnitt"`) mit +`linePts/dirSign/depthBack/cutAtLine/heightMin/heightMax/projection` +(`schnitte.create_schnitt_entry`). Im Spike sind die Felder als `DrawingLevel` +(`kind:"section"|"elevation"`, `linePoints`, `directionSign`) angelegt — wir +ergänzen: + +```ts +interface SectionLevel extends DrawingLevel { // kind: "section" | "elevation" + linePoints: [Vec2, Vec2]; + directionSign: 1 | -1; // Blickrichtung (Pfeil im Plan) + depthBack: number; // Tiefe hinter der Schnittlinie (default 8) + cutAtLine: boolean; // true=Schnitt (cut+back), false=Ansicht (nur back) + heightMin: number; heightMax: number; + projection: "parallel" | "perspective"; +} +``` + +**Aktivierung** (Port `schnitte.activate_schnitt`): +1. `view_dir` = senkrecht zur Linie in XY, Richtung = `directionSign`. +2. **3D-Vorschau:** `THREE.Plane`s setzen — + - Cut (nur `cutAtLine`): auf der Linie, Normale `+view_dir`. + - Back (immer): um `depthBack` in `+view_dir` versetzt, Normale `−view_dir`. + - via `renderer.localClippingEnabled = true`, `material.clippingPlanes`. +3. **Kamera:** `OrthographicCamera`, Position `mid − view_dir·dist`, Target `mid`, + Up `+Z`; Zoom auf BBox (`linePoints` + Höhenbereich + `depthBack`). Bei + `perspective`: `PerspectiveCamera` + FOV. +4. **Vektor-Ergebnis:** HLR (§4). + +**2D-Plan-Symbol** (Schnittmarke im Grundriss, Port `make_schnitt_symbol`): Linie ++ Endpfeile in `view_dir`, Beschriftung. Bleibt im Grundriss sichtbar (liegt auf +einer eigenen Ebene, z.B. `18 Schnittlinien`). **Doppelklick** auf das Symbol +aktiviert den Schnitt (`onDoubleClick` auf das SVG-Symbol → `setActiveLevel(id)`, +≙ DOSSIER `_SchnittDoubleClickHandler`). + +**Grip-Editing der Schnittlinie:** Endpunkte als Grips im Grundriss; Ziehen +aktualisiert `linePoints` + Symbol + (falls aktiv) Clipping — ohne Re-Zoom der +3D-View (DOSSIER `skip_view`-Flag-Äquivalent: Drag aktualisiert nur die Clip- +Ebenen, nicht die Kamera). + +--- + +## 3. Massstab (Scale) — pro Viewport, Auto-DPI + +### 3.1 Mathematik (Port `massstab._compute_scale`, identisch im Browser) +``` +frustumWidth_world = ortho-Kamera-Breite in Modell-Einheiten (Meter) +frustumWidth_mm = frustumWidth_world * 1000 (Meter→mm) +screenWidth_mm = canvasWidthCssPx * 25.4 / dpi +N (1:N) = frustumWidth_mm / screenWidth_mm +``` +- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER). +- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS + definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection + komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den + Settings), persistiert pro Projekt. +- **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N / + 1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx / + (frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen. + +```ts +// plan/scale.ts +function computeScale(view: { frustumWidthWorld; canvasCssWidthPx; dpi }): number|null // 1:N +function applyScale(camera: THREE.OrthographicCamera, n: number, canvasCssWidthPx, dpi): void +const SCALE_PRESETS = [1,5,10,20,25,50,100,200,500,1000]; // 1:N Dropdown +``` + +### 3.2 Massstabs-abhängige Skalierung (DOSSIER-Stärke) +Bei 1:N müssen **Strichstärken** und **Schraffuren** lesbar bleiben: +- **Plotweight → SVG stroke-width:** `strokeWidthPx = lwMm / 25.4 · dpi` + (Welt-unabhängig; die Linie ist im Plan immer z.B. 0.25 mm dick). DOSSIER + skaliert dafür die PlotWeights (`_apply_scaled_lineweights`); im SVG-Modell + rechnen wir die mm-Strichstärke direkt in Pixel — **viel einfacher**, da SVG + von Natur aus papierbezogen ist. +- **Schraffur-Skalierung:** DOSSIER nutzt `factor = sqrt(N)/10` (1:100 ⇒ 1.0, + 1:50 ⇒ 0.71, 1:500 ⇒ 2.24; `apply_scaled_hatches`). Port: SVG ``- + `patternTransform="scale(factor)"` bzw. `patternUnits` so wählen, dass das Muster + die gewünschte Paper-Dichte hat. Formel 1:1 übernehmen. +- **Linetype-Dash:** `stroke-dasharray` in mm→px, ebenfalls papierbezogen. + +> **Kernvorteil gegenüber DOSSIER:** Weil der Plan **SVG/Paper-Space** ist, +> entfällt das fragile Welt↔Bildschirm-Plotweight-Rescaling (DOSSIER `write_plotweight`, +> `read_plotweight`, Print-Display-Toggle). Strichstärke und Maßlinien sind direkt +> in mm definiert und werden 1:1 gedruckt. + +--- + +## 4. Schnitt/Ansicht-Projektion (HLR) — Risiko #4 + +Vertikale Schnitte/Ansichten brauchen **echte 3D-Projektion mit verdeckten +Kanten** durch das zusammengebaute Gebäude. + +```ts +// plan/generateSection.ts (läuft im Web Worker via Comlink) +interface SectionRequest { meshes: SerializedBrep[]; cut: CutSpec; camera: CameraState; } +interface SectionResult { + cutLines: Primitive[]; // Schnittkanten (dick) — geschnittene Bauteile + cutFaces: Primitive[]; // Schnittflächen → Component-Schraffur (Poché) + visibleLines: Primitive[]; // sichtbare Projektion (dünn) + hiddenLines?: Primitive[]; // verdeckte (gestrichelt, optional) +} +function generateSection(req: SectionRequest): SectionResult +``` + +- **Kernel:** **OpenCascade.js** `HLRBRep_Algo` / `HLRBRep_HLRToShape` (B-Rep → + sichtbare/verdeckte Kanten). Eingabe = die Bauteil-Breps (Wände/Decken/Treppen…), + Projektionsrichtung aus `camera`. Alternativ Mesh-basiert (langsamer, weniger + sauber). +- **Schnittflächen-Schraffur (Section-Style):** wo die Cut-Plane ein Bauteil + durchschneidet, entsteht eine Fläche → mit der Component-Schraffur füllen + (resources-graphics.md). ≙ DOSSIER `SectionStyle` (Hatch + Schnittkante + + Silhouette), nur dass wir es als SVG-Fill rendern statt als Rhino-Layer-Property. +- **Performance:** schwer → **Worker + Cache**. Cache-Key = + hash(sichtbare Element-IDs + Geometrie-Hash + CutSpec + camera). Nur neu rechnen, + wenn sich relevante Eingaben ändern (ROADMAP Risiko #4). Geschnittene vs. dahinter + liegende Geometrie über die Back-Plane begrenzen (`depthBack`). +- **Stufenweise:** (a) Ansicht ohne Verdeckung (einfache Projektion) → (b) HLR + sichtbar → (c) verdeckte Kanten gestrichelt → (d) Schnittflächen-Poché. + +--- + +## 5. Ausschnitte (View-Snapshots) + +Navigation über 50+ Ansichten ohne Ordner-Wildwuchs (DOSSIER `ausschnitte.py`). +Ein Snapshot speichert **Kamera + Sichtbarkeit + Massstab + Darstellung + Overrides**. + +```ts +// in Project: viewSnapshots: ViewSnapshot[] +interface ViewSnapshot { + id; name; folder?: string; + camera: CameraState; // pos/target/up/parallel/fov + frustumWidth (Zoom!) + scale: number; // 1:N (DOSSIER speichert "1:50"-String) + detailLevel: DetailLevel; // LoD-Override (DOSSIER darstellung) + visibility: VisibilityState; // pro Geschoss + pro Ebene visible/locked + layerCombinationId?: string; // ODER Verweis auf Layer-Kombi (live) — s.u. + overrides?: { presetId?: string; enabled: boolean }; +} +interface CameraState { position; target; up; parallel; fov?; frustumWidth?; } +``` + +- **Save:** aktuellen `ui`-Zustand einfrieren (Port `_capture`: Kamera inkl. + Frustum-Breite für exakten Zoom-Restore, Layer-Sichtbarkeit, Massstab, LoD). +- **Restore:** Snapshot → `ui` + ggf. `project`-Sichtbarkeit anwenden (Port + `_restore`): Kamera, Sichtbarkeit (oder referenzierte Layer-Kombi), LoD, + optional Overrides-Preset. Da alles im Store liegt, ist das ein einfacher + State-Set — kein Multi-Panel-Force-Send-Tanz wie in DOSSIER. +- **Ordner, Umbenennen, Duplizieren, Settings-Drawer** wie DOSSIER (`_duplicate`, + `_set_field`, `_open_settings_window` → React-Drawer statt Eto-Form). + +### 5.1 Layer-Kombinationen (Presets) +```ts +interface LayerCombination { id; name; visibility: VisibilityState; } +``` +Bauphasen/Varianten/MEP per Klick (DOSSIER `_save_preset`/`apply_layer_preset_by_name`). +Snapshot kann **live** auf eine Kombi verweisen (folgt Änderungen) **oder** +eingefroren den `visibility`-Stand halten — genau DOSSIERs Wahl (`layerCombination` +vs. `layers`). + +--- + +## 6. Kamera-Presets & Norden-Rotation ⭐ + +Port `kamera.py`. Schnelle Ansichtswechsel + Georeferenzierung (Swisstopo, Phase 4). + +```ts +// viewport/camera.ts +function setCardinal(cam, dir: "N"|"E"|"S"|"W", northAngle: number): void +function setIso(cam, octant: "NE"|"SE"|"SW"|"NW"|..., northAngle: number): void +function setTop(cam, northAngle: number): void // Plan-Norden zeigt nach oben +// northAngle = Grad im Uhrzeigersinn von +Y (DOSSIER dossier_north_angle, default 0) +const north = (deg) => ({ x: Math.sin(rad(deg)), y: Math.cos(rad(deg)) }); +interface CameraPreset { id; name; camera: CameraState; } // benutzerdefiniert, gespeichert +``` +- **Norden-Rotation:** alle Kardinal-/Iso-Richtungen werden um `northAngle` + rotiert (Port `set_cardinal_view`, `_set_iso`, `set_top_view`). `northAngle` + liegt im `Project` (georeferenziert zu swissBUILDINGS). +- **Benutzer-Presets:** speichern/laden wie DOSSIER (`_load_presets`/`_save_presets`). + +--- + +## 7. Bemaßung (Dimensions) + +Port `dimensionen.py`. Maße werden **aus dem Modell abgeleitet** (Wand-Dicken, +Geschoss-Höhen, Öffnungen) + manuelle Maßketten. + +```ts +interface Dimension { + id; floorId; categoryCode; // liegt auf einer Ebene + kind: "linear" | "chain" | "aligned" | "level"; // Einzel|Kette|ausgerichtet|Höhenkote + refs: DimRef[]; // Bezugspunkte (frei ODER an Element gebunden) + offset: number; // Abstand der Maßlinie vom Objekt + style: DimStyleId; // Pfeile, Texthöhe, Einheiten +} +type DimRef = { point: Vec2 } | { elementId: string; anchor: "start"|"end"|"jamb"|... }; +``` +- **Auto-Bemaßung** (Phase 3): Außenketten (Gebäude-Hülle), Achsketten (Achsraster), + Öffnungs-Ketten — aus der Geometrie generiert, dann editierbar. +- **9-Punkt-Objekt-Info** (DOSSIER ROADMAP §11): Bounding-Box-Maße lesen + + Element via Greifen verschieben/skalieren/rotieren — direkt im Plan. +- **Rich-Text-Indizes** (Bold/Hoch-/Tiefstellung) für Maßzahlen — als SVG + `` mit `baseline-shift` (resources-graphics.md §Rich-Text). +- **Massstabsbezug:** Texthöhe/Pfeilgröße in **Paper-mm**, rendern × Massstab — + konsistent mit §3.2. + +--- + +## 8. Plansätze (Sheets) & PDF-Export + +DOSSIER nutzt Rhinos `RhinoPageView` + `Detail`-Viewports + `FilePdf` +(`layouts.py`). Browser-Äquivalent: eigenes Sheet-Modell + SVG → PDF. + +### 8.1 Datenmodell +```ts +interface Sheet { + id; name; folder?; + paper: "A0"|"A1"|"A2"|"A3"|"A4"|"Letter"; landscape: boolean; + viewports: SheetViewport[]; + titleBlock?: TitleBlock; // Titelblock (Projekt/Plan/Massstab/Datum) +} +interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt + id; rect: { x; y; w; h }; // Position auf dem Blatt (mm) + source: { kind: "level"; levelId } | { kind: "snapshot"; snapshotId }; + scale: number; // 1:N + clipToRect: boolean; +} +const PAPER_MM = { A0:[841,1189], A1:[594,841], A2:[420,594], A3:[297,420], + A4:[210,297], Letter:[216,279] }; // Port PAPER_SIZES_MM +``` + +### 8.2 Sheet-Editor +`sheets/SheetEditor.tsx`: Blatt als SVG in mm, Viewports per Drag platzieren/ +skalieren, Quelle (Geschoss/Snapshot) + Massstab zuweisen. Ein Viewport rendert +den abgeleiteten Plan/Schnitt **bei seinem Massstab** in sein `rect` (≙ DOSSIER +`apply_snapshot_to_detail`). Bei Änderung der Quelle re-derivieren (live), kein +manuelles Re-Sync nötig (DOSSIER war Snapshot-Mode). + +### 8.3 Detail↔Ausschnitt-Bindung +`SheetViewport.source.snapshotId` ist die Bindung (DOSSIER `_BIND_KEY`). „Alle +aktualisieren" = alle Viewports neu rendern; weil rein abgeleitet, ist das +automatisch. Umbenennen synchronisiert Titelblock + Schnitt-Symbol (DOSSIER +Detail↔Ausschnitt-Sync). + +### 8.4 PDF-Export (Vektor, Multi-Page, @DPI) +Port `layouts._export_pdf`, aber **vektorbasiert** (DOSSIER rasterte via +`ViewCaptureToFile` @DPI — wir bleiben Vektor → schärfer, kleiner): + +```ts +// sheets/exportPdf.ts +async function exportSheetsPdf(sheets: Sheet[], opts: { vector: boolean }): Promise +``` +- **Vektor-Pfad (bevorzugt):** jeder Sheet-Viewport rendert seinen Plan als SVG; + SVG → PDF via **`svg2pdf.js` + `jsPDF`** (oder `pdf-lib` mit eigenem Pfad- + Emit). Eine PDF-Seite pro Sheet, Größe = `PAPER_MM`. Strichstärken/Schraffuren + sind bereits in mm (§3.2) → 1:1 druckbar. +- **Raster-Fallback** (Perspektiven/3D-Inhalte): Three.js `renderer` → Canvas → + PNG @DPI → in PDF-Seite (`px = mm/25.4·dpi`, Port der DOSSIER-Pixelrechnung). +- **Speichern:** Blob → File System Access API (`showSaveFilePicker`) / Download. + +--- + +## 9. Primitive & SVG-Serializer (gemeinsame Basis) + +Alle Pläne (Grundriss, Schnitt, Ansicht, Sheet-Viewport) sprechen dieselbe +`Primitive`-Sprache (heute in `generatePlan.ts`), erweitert um Schraffur/Text: + +```ts +type Primitive = + | { kind:"polygon"; pts:Vec2[]; fill:string; stroke:string; strokeWidthMm:number; hatchId?:string } + | { kind:"line"; a:Vec2; b:Vec2; styleId:string } // styleId → LineStyle (mm, dash) + | { kind:"arc"; center:Vec2; from:Vec2; to:Vec2; r:number; styleId:string } + | { kind:"text"; at:Vec2; text:string; heightMm:number; align; font; rich?:RichRun[] } + | { kind:"symbol"; at:Vec2; symbolId:string; scale:number; angle:number }; // Symbol-Bibliothek +interface Plan { primitives: Primitive[]; bounds: Rect; } +``` +- **SVG-Serializer** (`plan/primitives.ts`): Primitive → SVG-Elemente. + `strokeWidthMm` → px via `mm·dpi/25.4`; `hatchId` → ``-Referenz; + `styleId` → `stroke`/`stroke-dasharray`. Derselbe Serializer für Bildschirm + *und* PDF. +- **DXF-Export** (Phase 4): dieselben Primitive → DXF-Entities (`dxf`-Writer-lib). + +--- + +## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen) + +1. **Phase 1 (MVP):** Grundriss-Generator ✅ ausbauen (Schraffuren, LoD), Live- + Grundriss neben 3D, Basis-Bemaßung; Massstab pro Viewport (§3). +2. **Phase 3 ⭐:** Schnitt/Ansicht via HLR (§4, Worker), Auto-Bemaßung (§7), + Ausschnitte + Layer-Kombinationen (§5), Kamera-Presets + Norden (§6), + Sheets + Vektor-PDF (§8). +3. **Phase 4:** DXF-Export (§9), Detail↔Ausschnitt-Sync-Politur. diff --git a/docs/design/resources-graphics.md b/docs/design/resources-graphics.md new file mode 100644 index 0000000..6960b40 --- /dev/null +++ b/docs/design/resources-graphics.md @@ -0,0 +1,293 @@ +# Design — Ressourcen & Grafik + +> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md). +> Bauteile: [elements.md](elements.md). Output/Pläne: [plans-output.md](plans-output.md). + +Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Vectorworks-Stil), +regelbasierte Overrides, Symbol-/Text-Bibliotheken und Detailgrad-Steuerung. Sie +wird **beim Rendern angewandt, nie in die Geometrie eingebacken** (ROADMAP §2b). +Dieses Dokument übersetzt DOSSIERs `styles.py`/`gestaltung.py`, `mass_style.py`, +`overrides.py`, `library.py`, `text_create.py`. Bezeichner englisch, Prosa +deutsch. + +--- + +## 1. Resource Manager — verwaltete Bibliotheken + +Drei Bibliotheken im `Project.resources`-Block; **alles verweist per id**, zentral +änderbar (ROADMAP §2d). Verweis-Kette: 2D-Objekte/Ebenen → LineStyle/Hatch; +Hatch → LineStyle; Component → Hatch (+3D-Material). + +```ts +interface Resources { + lineStyles: LineStyle[]; + hatches: Hatch[]; + components: Component[]; +} + +interface LineStyle { // Line Manager + id; name; + weight: number; // Strichstärke in mm (≙ Rhino PlotWeight) + color: string; // hex + dash: number[]; // Strichmuster in mm ([] = durchgezogen) +} + +interface Hatch { // Hatch Manager + id; name; + pattern: PatternId; // "solid" | "diagonal" | "insulation" | "concrete" | ... + scale: number; // Grundmaßstab des Musters + angle: number; // Grad + lineStyleId: string; // Linien der Schraffur → Line Manager +} + +interface Component { // Component Manager (= DOSSIER-Material, erweitert) + id; name; + hatchId: string; // Schnitt-Schraffur → Hatch Manager + color3d: string; // 3D-Diffusfarbe + texture3d?: TextureRef; // optionale PBR-Textur (Phase 3) + pbr?: { roughness; metalness; opacity; ior }; // Material-Bibliothek (ROADMAP §11) + joinPriority: number; // Verschneidungs-Rang (DOSSIER _MATERIAL_PRIO als Daten) +} +``` + +**Migration vom heutigen Stand:** Der Spike hat `Material { color, planFill, hatch }` +und `Layer { materialId, thickness, priority }`. Ziel: `Material → Component` +(`color→color3d`, `planFill→` Fill aus `hatch.pattern==solid`+Farbe, `hatch`-Enum +→ `hatchId`), `Layer.priority → Component.joinPriority` (Priorität wandert vom +Layer zum Component, damit man sie nur einmal pflegt — siehe elements.md §1.3). + +### 1.1 Manager-UI +`managers/ComponentManager.tsx`, `HatchManager.tsx`, `LineManager.tsx` — je eine +Liste mit CRUD + Vorschau (Three-Sphere für Component-3D, SVG-Swatch für +Hatch/Line). **Seeds** beim ersten Projekt (DOSSIER-Defaults): +- LineStyles: 0.13 / 0.18 / 0.25 / 0.35 / 0.50 mm (aus `DEFAULT_LAYER_SCHEMA`-lw). +- Hatches: `solid`, `diagonal`, `concrete`, `insulation` (Dämmung). +- Components: Stahlbeton (prio 800), Beton (800), Mauerwerk (600), Ziegel (550), + Holzständer (400), Dämmung (200), Putz (100) — exakt DOSSIER `_MATERIAL_PRIO` + (elements.md §1.3). Plus Glas (transparent), Holz-Türblatt (DOSSIER + `_OEFF_PIECE_DEFS`). + +### 1.2 Render-Anwendung +- **3D:** Component → `MeshStandardMaterial` (`color3d`/`pbr`), pro `componentId` + gecacht (`viewport/scene.ts`). +- **Plan/Schnitt:** geschnittene Schicht → Polygon mit `fill` (Component-Farbe) + + `` aus `hatchId`. Pattern als SVG `` mit `patternTransform` + für Massstab (plans-output.md §3.2). Der Hatch nutzt seinen `lineStyleId` für + die Musterlinien. + +--- + +## 2. Mehrschichtige Aufbauten ↔ Ressourcen +`WallType.layers[].componentId` / `SlabType.layers[].componentId` verweisen auf +Components. 3D und Plan lesen dieselben Schichten (elements.md §1). Die +**Prioritäts-Verschneidung** (Risiko #1) liest `Component.joinPriority`: +höhere Priorität läuft am Stoß durch (Backbone), niedrigere stößt seitlich an — +Algorithmus in elements.md §1.3 (Port DOSSIER `_t_junction_layer_overrides`). + +--- + +## 3. Stile & Element-Override (Selektions-Attribute) + +DOSSIERs GESTALTUNG-Panel (`styles.py`) setzt Farbe/Lineweight/Linetype/Hatch auf +die *Selektion*. Browser-Äquivalent — zwei Ebenen, in Render-Reihenfolge: + +``` +ByLayer (Ebenen-Default) → Element-Style (styleId) → Override-Regeln → gerendert +``` + +```ts +// Effektiver Stil eines Elements (resolve beim Rendern, nie persistiert) +interface EffectiveStyle { color; lineStyleId; hatchId?; } +function resolveStyle(project, el, doc): EffectiveStyle { + // 1) Default aus LayerCategory(categoryCode) + // 2) überschrieben durch el.styleId (Element-Override, optional) + // 3) überschrieben durch passende Override-Regeln (§4) +} +``` + +- **Wall-/Opening-Stil-Kataloge** (Presets, ROADMAP §11): benannte Sätze von + Default-Werten (Wandtyp + Farbe + lw; Öffnung mit Rahmen/Sims/…). 1-Klick- + Anwendung, globaler Stilwechsel. Speicherung: pro Projekt + cross-Projekt + (LocalStorage), Seed wie DOSSIER `_OEFF_DEFAULT_STYLES` (elements.md §2.1). +- **LoD-bewusste Stil-UI** (DOSSIER ROADMAP §11): das Stil-Panel zeigt nur + passende Controls je Geometrietyp (keine Füll-Optionen bei einer 3D-/Linien- + Auswahl). `panels/StylePanel.tsx` schaltet Felder nach `selection`-Typ. +- **Pipette:** Stil/Typ von einem Element auf ein anderes übernehmen (DOSSIER + `cmd/pipette`). + +--- + +## 4. Regelbasierte Overrides (Engine) + +Port `overrides.py` (ArchiCAD Graphical Overrides / Vectorworks +Datenvisualisierung). Im Browser **viel einfacher**, weil Overrides reine +**Render-Transformationen** sind — kein UserString-Backup/Restore nötig (DOSSIER +musste Originalwerte sichern, weil es echte Rhino-Objekte mutierte; wir mutieren +nichts). + +```ts +interface OverrideConfig { enabled: boolean; rules: OverrideRule[]; activePresetId?: string; } +interface OverrideRule { + id; name; enabled: boolean; + conditions: Condition[]; conditionsLogic: "and" | "or"; + actions: { color?: string; lineWeight?: number; lineStyleId?: string; + hatchId?: string; hatchScale?: number }; +} +interface Condition { + type: "category" | "userField" | "name" | "elementType"; // ≙ layer_name/user_string/object_name + operator: "equals"|"notEquals"|"contains"|"startsWith"|"endsWith"; + value: string; + key?: string; // nur für userField (z.B. "sia") +} +``` + +**Auswertung** (Port `_compose_overrides`): +```ts +function composeOverrides(el, project, cfg): Partial { + // additive: Actions aller matchenden, aktiven Regeln kombinieren; + // bei Konflikt für dieselbe Property gewinnt die Regel WEITER OBEN (kleinerer Index). +} +``` +- **Anwendung:** `resolveStyle` (§3) ruft `composeOverrides` — Override liegt + über Element-Style. Reines Read beim Rendern → kein `apply_all`/`restore_all`, + kein Backup, **keine reversibilität nötig**. Toggle `enabled` rendert neu. +- **Live:** Da abgeleitet, schlägt jede Modell-/Regel-Änderung sofort durch (kein + `install_listeners`/`AddRhinoObject`-Hook wie DOSSIER). +- **Presets & Templates** (cross-Projekt): Preset = Satz Regeln, Template = + einzelne Regel; LocalStorage statt `~/Library/.../override_presets.json` + (`save_preset`/`load_preset`/`list_rule_templates` → `resources/overridePresets.ts`). +- **SIA-416-Preset** (elements.md §7): vier Regeln `userField sia == HNF|NNF|VF|FF` + → Farbe + Solid-Hatch, Port `_build_sia_preset_rules`. Aktivieren = Preset + `activePresetId` setzen. + +```ts +// resources/overrides.ts +function composeOverrides(el, project, cfg): Partial +function setActivePreset(project, presetId): Project // immutabel +const PRESETS_NS = "cad.presets.overrides"; // LocalStorage +``` + +--- + +## 5. Symbol-Bibliothek + +Port `library.py` + `cmd/symbol`. Wiederverwendbare 2D-Symbole (Möbel, Sanitär, +Bäume, Nordpfeil, Pflanzen) für den Plan. + +```ts +interface Symbol { + id; name; category: string; // "furniture" | "sanitary" | "vegetation" | "annotation" + svgPath: string; // Pfad-/Gruppen-Markup im Symbol-Koordinatensystem (m) + defaultScale: number; +} +interface SymbolInstance extends ElementBase { // type:"draw2d", subtype:"symbol" + symbolId: string; at: Vec2; scale: number; angle: number; +} +``` +- **Speicherung:** mitgelieferte Symbole als statische Assets (SVG); Nutzer- + Symbole im Projekt + cross-Projekt (LocalStorage). DOSSIER nutzt Block- + Definitionen; bei uns SVG-Definition + Instanz-Transform (``-artig). +- **Picker:** `panels/SymbolPicker.tsx` (≙ DOSSIER `SymbolPicker.jsx`) — Grid mit + Vorschau, Drag in den Plan; Instanz auf Ebene `60 Plangrafik` (bzw. `22 Möbel`). +- **Render:** `Primitive{ kind:"symbol", ... }` → SVG `` mit dem + Symbol-Markup (plans-output.md §9). + +--- + +## 6. Text & Rich-Text-Annotationen + +Port `text_create.py` + `text_editor.py`. Formatierte Beschriftungen auf Canvas. + +```ts +interface TextElement extends ElementBase { // type:"draw2d", subtype:"text" + at: Vec2; runs: RichRun[]; + heightMm: number; // Paper-mm (rendern × Massstab) ODER Modell-m + heightMode: "paper" | "model"; // DOSSIER raum_txt_modus fix|masstab + font: string; align: "left"|"mid"|"right"; angle: number; + mask?: boolean; // Hintergrund-Maskierung (verdeckt Linien darunter) + frame?: boolean; // Rahmen um den Text +} +interface RichRun { + text: string; + bold?; italic?; super?; sub?; // Hoch-/Tiefstellung (Maß-Indizes) +} +interface TextStyle { id; name; font; heightMm; bold; italic; } // Text-Presets +``` +- **Render:** `` mit `` pro Run; `super/sub` via `baseline-shift` + + kleinerer `font-size`; `mask` via weißem `` darunter; `frame` via ``. +- **Editor:** Inline-Rich-Text-Editor (contentEditable oder leichter Custom-Editor) + → `RichRun[]`. Fonts aus einer kuratierten Web-Font-Liste + System-Fonts + (DOSSIER `_list_system_fonts` mit Preferred-Liste DM Mono/Krungthep/…); im + Browser via `document.fonts` / `queryLocalFonts()` (wo verfügbar) + gebündelte + Web-Fonts. +- **Massstabsbezug:** `heightMode:"paper"` → Texthöhe in mm, gerendert × Massstab + (plans-output.md §3) — Beschriftung bleibt bei jedem Massstab lesbar. + +--- + +## 7. Detailgrad (Level of Detail) + +Querschnittsthema (ROADMAP §2b „Modelldarstellungen"). Drei Stufen, Dokument-/ +Snapshot-weiter Override mit Per-Element-Ausnahme — DOSSIER +`darstellung`/`aktive_darstellung`. + +```ts +type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail +// Auflösung (Port _resolve_oeff_darstellung): +function resolveDetail(el, doc): DetailLevel { + const v = el.detailLevel ?? "auto"; + return v === "auto" ? doc.detailLevel : v; // doc-Level hat IMMER konkreten Wert +} +``` +- **Dokument-Ebene:** `Project.detailLevel` (Default `coarse`/einfach = 1:100, + DOSSIER `_DARSTELLUNG_DEFAULT_GLOBAL`). In der TopBar global umschaltbar; ein + ViewSnapshot kann ihn pro Ansicht überschreiben (plans-output.md §5). +- **Wirkung:** jedes Bauteil-`generatePlan`/`build3d` liest den aufgelösten LoD und + zeichnet entsprechend (elements.md: Tür coarse=Lücke, fine=Glas/Schwenkbogen/ + Sims). Kritisch für Mixed-Scale-Pläne (1:50 Detail neben 1:200 Übersicht). +- **Schnitt-Schraffur** koppelt an LoD: grob ggf. nur Umriss, fein voll schraffiert. + +--- + +## 8. Section-Style (3D-Schnittflächen) +Port DOSSIER `_apply_section_style` (`layer_builder.py`). Wo die Schnittebene ein +Bauteil durchschneidet: Schnittfläche bekommt die **Component-Schraffur**, die +Schnittkante einen dicken Rand, optional eine Silhouette. + +```ts +interface SectionStyle { // pro Component (oder Ebene) ableitbar + hatchId?: string; hatchScale; hatchAngle; + boundaryShow: boolean; boundaryLineStyleId; boundaryWidthScale; + fillBackground: boolean; +} +``` +- **3D-Viewport:** Three.js hat keinen nativen „Schnittflächen-Cap". Cap-Geometrie + selbst erzeugen: Schnittpolygon der Cut-Plane mit den Breps → Fläche mit + Hatch-Material (oder Stencil-Cap-Technik). Phase 4. +- **2D-Schnitt (SVG):** `generateSection` (plans-output.md §4) liefert `cutFaces` + → mit `SectionStyle.hatchId` füllen. Das ist der Hauptweg; der 3D-Cap ist Bonus. + +--- + +## 9. Was Browser hier einfacher macht (vs. DOSSIER) + +| DOSSIER-Aufwand | entfällt im Browser, weil … | +|---|---| +| UserString-Backup/Restore bei Overrides (`_backup_original`/`_restore_original`) | Overrides sind reine Render-Reads — nichts wird mutiert | +| Hatch-Curve-Link über Sticky (`gestaltung_curve_hatch`, Pending-TTL) | Schraffur ist eine Eigenschaft des Polygons, kein separates Objekt | +| Plotweight-Welt↔Bildschirm-Rescaling (`write/read_plotweight`) | Strichstärke ist in mm im SVG-Paper-Space (plans-output.md §3.2) | +| `install_listeners` für Live-Override-Reapply | reaktiver Store re-rendert automatisch | +| SectionStyle-API-Reflection über Rhino-Versionen | wir definieren das Rendering selbst (SVG/Three) | +| Cross-doc Presets als Dateien im User-Home | LocalStorage + Export/Import | + +--- + +## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen) + +1. **Phase 1:** Component/Hatch/Line-Manager (§1) + `resolveStyle` (§3) + + LoD-Grundgerüst (§7) + LoD-bewusste Stil-UI. +2. **Phase 2:** Stil-Kataloge (Wände/Öffnungen), Material-Seeds mit `joinPriority`. +3. **Phase 3:** Overrides-Engine + SIA-Preset (§4), Symbol-Bibliothek (§5), + Rich-Text (§6), PBR-Material-Bibliothek; massstabsabhängige Hatch-/Linetype- + Skalierung (plans-output.md §3.2). +4. **Phase 4:** Section-Style 3D-Cap (§8). diff --git a/docs/design/rhino-command-system.md b/docs/design/rhino-command-system.md new file mode 100644 index 0000000..143f152 --- /dev/null +++ b/docs/design/rhino-command-system.md @@ -0,0 +1,370 @@ +# Handoff — Rhino-artiges Befehlssystem + Modellier-Werkzeuge + +> Für die Instanz, die das Befehlssystem (Tab-getriggert) und die Rhino-artigen +> Modellierfunktionen baut. Stand: 2026-06-30. Zuerst lesen: `CONVENTIONS.md`, +> `ROADMAP.md`, `HANDOVER.md`, `docs/design/drawing-tools.md`. Volle Autonomie, +> selbst bestätigen (Memory `proceed-autonomously`, `wire-dont-stub`, `prefer-agents`). + +Dieses Dokument hat drei Teile: +1. **Was schon steht** (worauf du aufbaust — exakte Dateien/Typen/Actions). +2. **Rhino-Referenz** (Interaktionsmodell, das nachzubilden ist). +3. **Konkreter Bauplan für DIESE Codebase** (Architektur, Dateien, Reihenfolge). + +--- + +## TL;DR — die Kernidee + +Es gibt **kein** Befehlssystem, keine Command-Line, keinen Tab-Handler. Das baust du +greenfield. **Aber:** Das Werkzeug-System ist bereits eine saubere Pure-Function-Registry +(`src/tools/`) mit generischem Controller in `App.tsx`, und die Mutations-Schicht +(`projectSlice`) ist umfassend. Ein neues Modellier-Tool steckst du durch Hinzufügen eines +`Tool`-Objekts ein — **PlanView muss dafür nicht angefasst werden**. + +Das Befehlssystem ist im Kern eine **State-Machine-Engine über prompt → pick/type → +options**, plus eine **Command-Line-UI** (Statusleiste), plus ein **Koordinaten-Parser**. +Befehle dispatchen auf bestehende Store-Actions + `setActiveTool`/`setProject`. + +**Wichtigster konzeptioneller Sprung:** Die heutigen Tools haben je eine *eigene* +ad-hoc-Phasenlogik (`onClick`/`onMove`). Rhino-Feel verlangt eine **gemeinsame +Prompt/Option/Numerik-Engine**, die alle Befehle teilen. Plane das als Verallgemeinerung +des bestehenden `Tool`-Interfaces, nicht als Parallelwelt daneben (sonst zwei Eingabe-Pfade, +die divergieren). + +--- + +# TEIL 1 — Was schon steht (Baufundament) + +Stack: React 18 + TS + Vite, `three` 0.169 (nur 3D-Display). Einheiten intern **Meter**. +Eigener winziger Store (`useSyncExternalStore`, kein Redux/Zustand). Identifier englisch, +UI-Text deutsch via `t()`. Strict tsc (`noUnusedLocals` → ungenutzte Vars brechen den Build). + +## 1.1 Werkzeug-System — `src/tools/` +- **`Tool`-Interface** `src/tools/types.ts:139` — reine Funktionen über internen `ToolState` + (Discriminated Union je Tool). Handler geben `[nextState, ToolResult]` zurück. Tools + schreiben NIE Plan-Primitive; sie geben `commit(project) => project` zurück. +- `ToolId` `types.ts:11`: `"select" | "wall" | "line" | "polyline" | "rect"`. +- `ToolContext` `types.ts:72`: `{ project, level, defaultCategoryCode, activeWallTypeId, activeLineStyleId }`. +- `ToolPointer` `types.ts:85`: `{ raw, snap, point (=snap?.point ?? raw), shift, ctrl, alt, button }`. +- `ToolResult` `types.ts:121`: `{ draft, commit?, done? }`. `ToolDraft` `types.ts:109`: + `{ preview: DraftShape[], vertices: Vec2[], snap?, hud?: {at,text} }`. +- **Registry** `src/tools/tools.ts:375`: `TOOLS: Record`, `getTool(id)` `:384`, + `TOOL_ORDER` `:389`. Implementiert: select (Platzhalter), wall, line, polyline, rect. +- `uniqueId(prefix)` `types.ts:188` — ID-Generator. + +## 1.2 Controller / Verdrahtung — alles in `App.tsx` (NICHT in den Tool-Dateien) +- Aktives Tool: `const [activeTool,setActiveTool]=useState("select")` `App.tsx:197`. +- Laufender Zustand in **Ref** `toolStateRef` `App.tsx:205` (kein Re-Render je Mausschritt). + Live-Vorschau `const [draft,setDraft]` `App.tsx:206`. +- **Controller** `runToolStep(kind,raw,pxPerMeter,mods)` `App.tsx:350`: baut `ToolContext` + (`toolCtx` `:294`), snappt via `snapFor` `:322` (→ `computeSnap`), baut `ToolPointer`, + ruft `tool.onClick/onMove`, speichert State in Ref, `applyToolResult` `:339` wendet + draft/commit/done an. `toolHandlers` `App.tsx:419` verbindet PlanView↔Controller. +- Tool-Tasten `App.tsx:448`: Esc=Abbruch/zurück-zu-select, Enter=Commit-Geste, Backspace=Punkt zurück. + +## 1.3 Semantisches Modell — `src/model/types.ts` +- `type Element = Wall | Door | Drawing2D` `:251`. `Vec2={x,y}`. **Kein Slab/Stair-Typ.** +- `Project` `:254`: `{ ..., wallTypes[], drawingLevels[], layers[], walls[], doors[], drawings2d[] }`. +- `Wall` `:150`: `{ id,type:"wall", floorId, categoryCode, start, end, wallTypeId, height, color? }` + (Mittellinie + mehrschichtiger `WallType`). +- **Plan-Primitive `Drawing2DGeom`** `:200` — die Geom-Typen existieren bereits ALLE: + `line | polyline | rect | circle | arc | text`. Aber Tools erzeugen heute nur line/polyline/rect, + und `drawingVertices` (Grips) kennt nur diese drei. **circle/arc/text sind im Typ da, aber + nicht durchgängig gerendert/editierbar** — Lücke, kein Neubau nötig. +- `Drawing2D` `:209`: `{ id,type:"drawing2d", levelId, categoryCode, geom, lineStyleId?, hatchId?, color?, fillColor?, weightMm? }`. + +## 1.4 Geometrie — `src/model/geometry.ts` +`sub,add,scale,len,normalize`; `leftNormal(a)={x:-a.y,y:a.x}` `:17` (Wand-Normale-Konvention); +`cross`, `lineIntersect(a,da,b,db)`, `along`, `wallBand`, `wallCorners` `:70`, `clippedBand` `:87`. +`src/model/joins.ts`: `computeJoins(project,walls)` `:44` (nur L-Ecken gehrt; T/X eckig). +**Für Offset/Trim/Fillet (Rhino-Kern) gibt es NOCH KEIN 2D-Geometrie-Kernel** — Kurven/Kurven- +Schnitt, Polylinien-Offset usw. musst du ergänzen (siehe Bauplan §3.4). + +## 1.5 Store — `src/state/` +- `createStore` `store.ts:51` über `useSyncExternalStore`. **Actions leben IM State** + (`useStore(s=>s.action)`, referenzstabil). `RootState = Project & Selection & View & Layout` + `appStore.ts:21`. Exports `useStore`, `getState`, `setState`. +- **projectSlice**: `project` + `setProject(next|(p)=>p)`. Mutationen u.a. `addFloor`, + `addCategory`, `setElementColor/Weight/Fill`, `resizeElement`, `moveGripOf`, `moveElementByOf`, + `moveEdgeOf`, `commitTransformOn`. **Es gibt keine generische „addWall/addDrawing2d"-Action** — + Tools committen via `setProject`. (Beim Befehlssystem ggf. saubere Actions ergänzen.) +- **Aktive Zeichenebene + aktive Kategorie liegen im viewSlice**, NICHT in selection: + `activeLevelId` `viewSlice.ts:46`/`setActiveLevelId`, `activeCategoryCode` `:42`/`setActiveCategoryCode`. +- selectionSlice: `selectedWallIds[]`, `selectedDrawingId` + Setter/`clearSelection`. + +## 1.6 Views, Eingabe, Koordinaten — `src/plan/PlanView.tsx` (SVG-Vektor) +- Modell → `Plan`-Primitive via `generatePlan` `src/plan/generatePlan.ts:203`. `Primitive` = + `polygon|line|arc` (polygons tragen `wallId`/`drawingId` für Hit-Test). +- **Transform (entscheidend):** `PX_PER_M=90` `:20`; `toScreen(p)={x:p.x*90,y:-p.y*90}` `:31` + (fixer Welt-Ursprung 0,0; Y flippt). Invers `viewToModel` `:440`. SVG `viewBox`=State `view`; + Pan/Zoom ändern nur `view`, nie das Modell↔Screen-Mapping. +- **`rawModelAt(clientX,clientY)`** `:446` = aktuelle Mauswelt-Position in Meter (der Eine-Aufruf, + den ein Tool/Befehl braucht). `currentPxPerMeter()` `:488`. +- **Pointer-Events** alle am `` `:910`: down `:553`, move `:625`, up `:715`, wheel `:820`, + dblclick `:847`, contextmenu `:857`. Schema: Mitte=Pan, Links=Select/Marquee/Tool, Rechts=Menü. + Bei `toolActive` `:268` routen Links-Events zu `toolHandlers`. **PlanView meldet bereits + `(rawModelAt, currentPxPerMeter, toolMods)` nach oben** — neue Tools brauchen hier NICHTS. +- **Snapping** `src/tools/snapping.ts`: `computeSnap(input)` `:88` — endpoint/midpoint/intersection/ + onEdge/grid/ortho mit Prioritätstabelle. Wird in App (`snapFor`) konsumiert, nicht in PlanView. + `applyAngleConstraint` für Ortho. `SnapSettings`/`DEFAULT_SNAP` in `tools/types.ts:36/55`. +- 3D `src/viewport/Viewport3D.tsx` (three.js, Raycaster): nur Anzeige+Auswahl, **keine + Zeichenwerkzeuge**. 3D-Authoring = eigene spätere Phase (Raycast auf Arbeitsebene). + +## 1.7 Tastatur / globale Eingabe — **kein Dispatch-System** +- `main.tsx:38` globaler `contextmenu`→preventDefault; `:42` blockt Ctrl/Cmd+A außerhalb Inputs; + `isTextEntry(el)` `:24`. +- App-useEffects mit `window.addEventListener("keydown")`: Tool-Tasten `:448`, Delete `:604`, + Transform-Shortcuts m/s/d + u/i/o/p `:637`. **Jeder Guard wiederholt inline den + INPUT/TEXTAREA/contentEditable-Check** — es gibt keine geteilte Keymap. Dein Tab-Handler + + Command-Input kommt als neuer globaler `keydown` dazu (siehe §3.2). + +## 1.8 UI-Shell + i18n +- `App.tsx` (~2200 Z., enthält noch ToolController/Grips/Transform). JSX `:1043`: TopBar → body + (Dock links, Content-View-Router, TransformBar, Dock rechts, Floating) → StatusBar → + ResourceManager → ContextMenu → InlineEditor. Panel-Daten via `PanelHostContext` (`baseHost` + `App.tsx:729`, Typ `host.ts`). +- `StatusBar.tsx` — Footer: links `hint` (Tool-Hinweis), rechts X/Y, Einheit, Massstab 1:N, Zoom, + aktives Geschoss, aktive Ebene. **Bester Ort für die Command-Line** (Rhino hat sie klassisch unten). +- **i18n** `src/i18n/`: `t(key,params?)` `index.ts:67`, `useT()` `:84`. Flaches `as const`-Dict, + Punkt-Namespaces (`tool.*`,`snap.*`,`transform.*`,`status.*`…). `de.ts` (Quelle, ~309 Keys) + + `en.ts`; `TranslationKey=keyof typeof de` erzwingt Parität. **Neue Keys IMMER in beide Dateien.** + Keine hartcodierten JSX-Strings. + +## 1.9 Verifizieren +- `npx tsc -b` · `npm run build` · Dev `npm run dev` (Vite 5173, `host:true`). +- Screenshot `node scripts/probe.mjs` → `scripts/probe.png` (Puppeteer headless, `deviceScaleFactor:2`, + URL via `PROBE_URL`). Viele task-Probes existieren (`probe-tools.mjs`, `probe-line.mjs`, + `probe-transform.mjs` …) — gute Vorlagen, um Tools/Befehle programmatisch zu treiben. + **Screenshot ansehen + Geometrie prüfen**, nicht nur „kompiliert". + +--- + +# TEIL 2 — Rhino-Referenz (das Interaktionsmodell) + +## 2.1 Die Command-Line ist das Rückgrat +**Alles ist ein Befehl**, und die Command-Line **hört immer zu**: Tastenanschläge gehen an die +Command-Line, wenn sie nicht von einem Feld konsumiert werden. Kein „Tool aktiv vs. Eingabe aktiv". +Die Zeile hat gleichzeitig drei Rollen: **Eingabe** (Befehl/Wert tippen), **Prompt** +(„Start of line", „Next point"), **Optionen** (eckige, klickbare Inline-Optionen). + +## 2.2 Befehl aufrufen +- Namen tippen, z. B. `Line`. **Präfix-Autocomplete** (case-insensitiv): `L`→`Li`→`Lin` zeigt + Kandidatenliste mit Best-Match. **Tab/Pfeile** akzeptieren Vorschlag, **Enter/Leertaste** führt aus. +- **Aliase**: nutzerdefinierte Kürzel → Makro (z. B. `L`→`!_Line`, `cp`→`!_Copy`). Werden VOR + Autocomplete gematcht. (Minimal: Einzelbuchstabe→Befehl.) + +## 2.3 Enter / Leertaste / Rechtsklick (leicht falsch gemacht) +- **Enter = Leertaste** in der Command-Line. Beide: Befehl ausführen / Default akzeptieren / + mehrteiligen Befehl **beenden** / bei **leerer** Zeile **letzten Befehl wiederholen**. +- **Rechtsklick im Viewport = Enter.** Also: Rechtsklick beendet Polyline UND wiederholt bei + leerer Zeile den letzten Befehl. → `lastCommand` speichern, bei Leer-Enter/Rechtsklick neu starten. + +## 2.4 Inline-Optionen (klickbare Klammern) +``` +Start of line ( BothSides=No Chamfer Mode=Distance ): +``` +- Jede Option **klickbar UND tippbar** (genug Buchstaben zur Eindeutigkeit + Enter). +- **Toggle** `Name=Value` flippt beim Klick. **Value**-Option fragt Unterwert ab. **Action**-Option + (ohne `=`) verzweigt sofort. +- Optionen sind **innerhalb des Befehls persistent**, viele **über Aufrufe hinweg** (letzte + Offset-Distanz, Array-Anzahl, Fillet-Radius merken). **Zuletzt benutzte Optionswerte je Befehl + persistieren** — Nutzer erwarten das. + +## 2.5 Sub-Prompts = State-Machine +Befehle laufen Prompts ab. `Line`: „Start of line:" → Punkt → „End of line:" → Punkt → fertig. +`Polyline`: „Start" → „Next point ( Close Undo ):" → … → **Enter** beendet. Prompt-Text ist +sichtbar und lehrreich („Next point. Press Enter when done") — literal nachbilden. + +## 2.6 Transparente/verschachtelbare Befehle +Manche Befehle (Zoom/Pan, Osnap-Toggle, alles mit `'`-Präfix) laufen **innerhalb** eines anderen, +ohne ihn abzubrechen, und kehren zum Original-Prompt zurück. → Command-Runner braucht einen **Stack**. + +## 2.7 Koordinaten- & Numerik-Eingabe (Herz der Präzision) +| Eingabe | Bedeutung | +|---|---| +| `5,3` / `5,3,2` | absolut X,Y(,Z) | +| `r5,3` | **relativ** zum letzten Punkt (das `r`-Idiom) | +| `<45` | Winkel-Constraint auf 45°, dann Maus/Distanz | +| `5<45` | **polar**: Distanz 5 unter 45° vom letzten Punkt | +| Zahl tippen während Drag | **Distanz-Lock**: Richtung per Maus, Länge per Zahl+Enter (meistgenutzte Geste) | +| Zahl + **Tab** | Lock umschalten (Länge fix → Winkel folgt Maus, oder umgekehrt) | +Das Feld parst **kontextabhängig**: Befehlsname / Optionsbuchstabe / Koordinate / nackte Zahl — +je nach Befehlszustand. Das Live-Tool muss **einen primären Skalar** (Länge/Radius/Distanz) +exponieren, an den eine getippte Zahl bindet. + +## 2.8 Osnaps + Ortho + Gumball +- **Osnaps** (persistente Toggles): End, Mid, Cen, Int, Perp, Near, Quad, Tan, Point. Pro Mausschritt + gegen nahe Geometrie geprüft (Pixel-Toleranz), Marker+Label am Cursor; liefert **exakte + Modellkoordinate** (nie Roh-Maus, wenn Snap aktiv). One-Shot-Osnap überschreibt für den nächsten Pick. +- **Ortho** (F8): Winkelraster (90°/konfigurierbar), **Shift** togglet temporär. **Grid Snap** (F9). + **SmartTrack**: temporäre Hilfslinien aus zuletzt gehoverten Punkten. +- **Gumball**: On-Object-Widget (Pfeile=Move, Bögen=Rotate, Handles=Scale); Handle klicken → + Zahl tippen für exakten Transform. Direkt-Manipulations-Gegenstück zu getippten Befehlen. + +> Präzisionsmodell = **(Snap ODER getippte Koordinate) × (Ortho/Winkel-Constraint) × +> (Distanz-Constraint)**, in EINEM Pick komponierbar. + +## 2.9 Auswahl-Modell (links/rechts-Regel exakt) +- Klick = wählen; Shift+Klick add; Ctrl+Klick remove. +- **Links→rechts = Window** (nur voll umschlossene; **durchgezogenes** Rechteck). +- **Rechts→links = Crossing** (auch berührte; **gestricheltes** Rechteck). Richtung bestimmt + Modus — starke Konvention, exakt nachbilden. +- **SelLast** (zuletzt erzeugte/gewählte erneut wählen) ist enorm nützlich („erzeugen, dann sofort + bewegen"). Min. `SelLast`, `SelAll`, `SelNone`, `Invert`. + +## 2.10 Befehls-Prompt-Sequenzen (Kurz) +2D: **Line** (2 Pkt) · **Polyline** (Close/Undo, Enter beendet) · **Rectangle** (Ecke+Ecke, oder +Breite/Höhe tippen; 3Point/Center) · **Circle** (Center+Radius; 2P/3P/Tan) · **Arc** (Center-Start-End / +3Point) · **Offset** (Kurve wählen → Seite klicken/Distanz tippen; Distanz persistent) · +**Fillet/Chamfer** (Kurve1→Kurve2, Radius/Distances persistent) · **Trim** (Schneider wählen→Enter→ +wegzuschneidendes Stück klicken) · **Split** · **Extend** · **Join** · **Explode** · +**Move/Copy/Rotate/Scale/Mirror** (Auswahl→Basispunkt→Ziel; Copy-Option) · **ArrayRect/ArrayPolar** · +**Group/Ungroup**. +3D (braucht CSG, später): **ExtrudeCrv** (geschlossene Kurve→Solid, Cap) · **Box** · **Boolean +Union/Difference/Intersection** · **Cap** · **Gumball-Face-Drag = PushPull** · Loft/Sweep/Revolve. + +--- + +# TEIL 3 — Bauplan für DIESE Codebase + +> Ziel: nutzbarer 2D-Architektur-Drafter mit Rhino-Feel, dann einfaches Massing. Halte das +> ROADMAP-Prinzip: **ein semantisches Modell → Sichten abgeleitet**; Extrusionshöhe ist eine +> Eigenschaft, nie eingebackene Geometrie. + +## 3.0 Kuratierungs-Prinzip (WICHTIG — Nutzer-Vorgabe) +**NICHT den ganzen Rhino-Katalog stumpf portieren.** Wir bauen ein **Wohnbau-BIM**, keinen +NURBS-Allzweck-Modeller. Nimm nur, was dem Wohnbau-Workflow dient; lass den Rest weg, bis er +konkret gebraucht wird. Faustregel: *Brauche ich das, um ein Einfamilienhaus zu zeichnen und +daraus Pläne zu ziehen?* Wenn nein → weglassen. + +**Bewusst WEGLASSEN (vorerst):** Loft / Sweep1+2 / Revolve (Sonderformen, kaum Wohnbau) · +freie NURBS-Kurven (`Curve`/`InterpCrv` Grad>1, Deformable, FromFoci) · Ellipse · Tangent/ +Bisector/4Point-Linienvarianten · SmartTrack (nett, nicht kritisch) · der volle `Sel*`-Zoo +(nur SelLast/SelAll/SelNone/Invert) · Knot/Vertex/Tan-Osnaps. Alle leicht später additiv +nachrüstbar — kein Grund, sie jetzt mitzuschleppen. + +**Booleans sind KEIN „nice to have später"** — sie werden gebraucht, **sobald Tür/Fenster als +echte 3D-Öffnung** kommen (heute schneidet `Door` nur eine Plan-Lücke, kein 3D-Boolean, siehe +HANDOVER). Darum: CSG/Booleans an die **Tür/Fenster-Phase koppeln** und dann reinnehmen — nicht +ans Ende schieben. ABER (das ist der „nicht stumpf"-Teil): +> Für **rechteckige** Öffnungen in extrudierten Wänden braucht es **keinen allgemeinen +> Boolean-Kernel**. Eine analytische **Wand-minus-Box-Subtraktion** (Öffnung als parametrische +> Aussparung im Wand-Solid) ist einfacher, robuster und für 95 % Wohnbau ausreichend. Den +> allgemeinen CSG-Boolean (`rhino3dm`) erst ziehen, wenn schräge/runde/verschnittene Fälle +> wirklich auftreten. Also: **Öffnungen zuerst analytisch, allgemeine Booleans erst bei Bedarf.** + +## 3.1 Leitentscheidung: Engine verallgemeinern, nicht parallel bauen +Baue eine gemeinsame **Command-Engine**, die das bestehende `Tool`-Interface erweitert/ablöst, +sodass es **einen** Eingabepfad gibt (Maus + Tastatur + Command-Line speisen dieselbe Maschine). +Konkret: ein `Command`-Modell, das je Schritt einen **Prompt** (Text), erwartete **Eingabearten** +(Punkt | Zahl | Option | Auswahl) und **Optionen** beschreibt. Die heutigen Tools werden zu +Befehlen dieser Engine (wall/line/polyline/rect lassen sich 1:1 portieren — ihre Phasenlogik ist +schon eine Mini-State-Machine). + +**Warum nicht das alte Tool-Interface unangetastet lassen und Command-Line nur draufsetzen?** +Weil die Command-Line getippte Koordinaten/Optionen in denselben Schritt einspeisen muss, in dem +die Maus pickt. Zwei getrennte Pfade divergieren garantiert (Snapping, Constraints, HUD doppelt). + +## 3.2 Neue Dateien (Vorschlag) +- `src/commands/engine.ts` — Command-Runner: aktiver Befehl, Prompt-Stack (für transparente + Befehle §2.6), `lastCommand`-Wiederholung, Routing von Maus-Pick / getippter Eingabe / Option-Klick + in den aktuellen Schritt. Hält `CommandState`. +- `src/commands/types.ts` — `Command`-Interface (Verallgemeinerung von `Tool`): Schritte mit + `prompt: TranslationKey`, `accepts: ("point"|"number"|"option"|"selection")[]`, `options: CmdOption[]`, + `onInput(state,input,ctx): [state, CommandResult]`. `CommandResult` wie `ToolResult` (+`commit`). +- `src/commands/parseInput.ts` — Koordinaten-Parser (§2.7): `5,3` · `r5,3` · `5<45` · `<45` · + nackte Zahl (Distanz-Lock) · Optionsbuchstabe. Liefert eine Discriminated Union, die die Engine + in einen Modellpunkt/Constraint auflöst (mit `lastPoint` für `r`/polar). +- `src/commands/registry.ts` — `COMMANDS: Record` + Aliase + Autocomplete (Präfix). +- `src/ui/CommandLine.tsx` — die Command-Line-UI **in/über der Statusleiste** (`StatusBar.tsx`): + zeigt Prompt + klickbare Optionen + Texteingabe; Autocomplete-Dropdown. Tab fokussiert sie. +- (später) `src/geometry/kernel2d.ts` — 2D-Kernel für Offset/Trim/Fillet/Schnitt (§3.4). +- (viel später) `src/geometry/solid3d.ts` o. `rhino3dm`-Anbindung für Massing/Booleans (§3.5). + +## 3.3 Verdrahtung (minimal-invasiv) +- **Globaler Tab-Handler**: neuer `window.keydown` in App (gleicher Guard wie `App.tsx:448` — + INPUT/TEXTAREA/contentEditable überspringen). Tab → Command-Line fokussieren/öffnen. Jeder + getippte Buchstabe ohne aktives Tool startet den Befehlsmodus (Rhino „hört immer zu" — optional + in Phase 2; Phase 1 reicht Tab). +- **Command-Line → Engine → Store**: Befehle dispatchen auf `setActiveTool` (für tool-artige) bzw. + direkt auf Store-Actions / `setProject`. Nutze `getState()/setState()` (referenzstabil) aus + `appStore.ts`. +- **Pick-Eingabe**: die Engine konsumiert dieselben `(rawModelAt, currentPxPerMeter, toolMods)`, + die PlanView schon hochmeldet (`ToolHandlers`). `computeSnap` für Punktfang wiederverwenden. + → PlanView braucht im Idealfall **keine Änderung** (höchstens: Window/Crossing-Marquee-Visual + durchgezogen vs. gestrichelt nach Drag-Richtung, §2.9 — heute evtl. nur ein Modus). +- **Prompt/HUD**: Prompt-Text in die Statusleiste (`StatusBar` `hint` existiert schon). Distanz/ + Winkel-HUD am Cursor existiert in `ToolDraft.hud`. + +## 3.4 Reihenfolge (Tiers — strikt 2D zuerst) +**Tier 0 — Substrat (VOR jedem Befehl; das ist der „Feel"):** +1. Command-Runner + Command-Line-UI (Prompt → pick/type → Optionen; Enter/Space/Rechtsklick = + bestätigen/beenden/wiederholen; `lastCommand`). +2. Koordinaten-Parser (`x,y` · `rdx,dy` · `dist Ziel: `App.tsx` von „God-Component" zu dünnem Shell. Globaler Zustand in einen +> Store, Features in eigene Module → **wartbar + parallel bearbeitbar**. + +## Problem +`App.tsx` hält aktuell: Projekt-State, Auswahl, View-State (viewType/detailLevel/ +renderMode/referenceLines/scale/activeLevel), Layout, ALLE Mutations-Handler +(floors/layers/components/hatches/lineStyles/walls/doors), Kontextmenü-Builder, +Inline-Editoren, Layout-Menü, View-Routing. → Risiko + Flaschenhals (jedes Feature +fasst App.tsx an → kein paralleles Arbeiten). + +## Zielstruktur +``` +src/state/ + store.ts // Store (Zustand) — kombiniert die Slices, ein useStore-Hook + projectSlice.ts // project + alle Mutationen (floors, layers, components, + // hatches, lineStyles, walls, doors) inkl. recompute/refs-aware delete + selectionSlice.ts // selectedWallIds (+ marquee-Ergebnis) + viewSlice.ts // viewType, detailLevel, renderMode, referenceLines, activeLevelId, scale + layoutSlice.ts // wraps src/panels/layout.ts (docks + floating) +src/views/ // LevelPlanView, PerspectiveView, SectionStub, DrawingView (+ ViewRouter) +src/editors/ // FloorSettingsEditor, LayerSettingsEditor, … (heute inline in App) +src/menus/ // layerContextMenu(), levelContextMenu(), planContextMenu() — bauen ContextMenu-Items +src/ui/ // TopBar, StatusBar, ResourceManager, ContextMenu (bestehen) +src/panels/ // Docks/Panels (bestehen) +App.tsx // DÜNN: Store-Provider · TopBar · (Docks + ViewRouter) · StatusBar + // · Floating-Panels · Ressourcen-Overlay +``` + +## Store-Wahl: Zustand (empfohlen) +- Winzige Lib, kein Boilerplate, **Slices** gut teilbar, Selektoren verhindern + Re-Render-Sturm, kein Prop-Drilling. Passt zu „verschiedene Features = verschiedene + Slice-Dateien" → Parallelität. +- Alternative ohne Dependency: Context + useReducer oder `useSyncExternalStore` + (wie i18n). Mehr Boilerplate; bei der State-Menge ist Zustand ergonomischer. +- Komponenten: `const walls = useStore(s => s.walls)` / `useStore(s => s.addFloor)`. + PanelHostContext entfällt (Panels lesen direkt aus dem Store). + +## Vorgehen (reiner Refactor — Verhalten MUSS identisch bleiben) +1. Store + Slices anlegen, Projekt-State + Mutationen aus App.tsx hierher ziehen + (1:1, gleiche Logik inkl. recomputeFloorElevations, refs-aware delete). +2. View-/Selection-/Layout-State in ihre Slices. +3. Inline-Editoren, Kontextmenü-Builder, View-Routing in `src/editors/`,`src/menus/`,`src/views/` extrahieren; sie lesen den Store. +4. App.tsx auf den Shell reduzieren. +5. Verifizieren: tsc + build + Screenshots — **pixel-/funktionsgleich** zu vorher + (Auswahl, Massstab, Kontextmenü, Panels, 3D/Plan, i18n). Reiner Umbau, kein + Feature-Wechsel. + +## Auszahlung +- Danach editiert ein Wand-Feature `projectSlice`/`views`, ein Panel-Feature `panels`, + ein Editor `editors` — **disjunkte Dateien → mehrere Code-Workflows parallel** möglich. diff --git a/docs/design/topbar-footer.md b/docs/design/topbar-footer.md new file mode 100644 index 0000000..c3ef34c --- /dev/null +++ b/docs/design/topbar-footer.md @@ -0,0 +1,43 @@ +# Top-Bar (Oberleiste) & Footer/Status-Leiste — Design + +> Referenz: DOSSIER `rhino/toolbar.py` + `src/ToolbarApp.jsx`. Hier auf unseren +> Standalone-Stack (React+TS, eigenes Modell) übersetzt. DOSSIER hat KEINEN Footer +> (delegiert an Rhinos eigene Leiste) — den Footer ergänzen wir neu (Vectorworks-Stil). + +## Top-Bar — Gruppen (links → rechts) + +1. **Marke/Logo** + Settings-Icons (Projekt-Einstellungen, App-Einstellungen). +2. **Ansicht** — Umschalter: Grundriss · Perspektive · Schnitt · Ansicht. + Später: 3D-Views Top/Iso + Himmelsrichtungen N/O/S/W (mit Nordwinkel-Rotation). +3. **Darstellung** — Render-/Anzeigemodus (Wireframe/Shaded/…) + **Detailgrad** + (grob/mittel/fein, ≙ DOSSIER „Darstellung" Einfach/Standard/Detail). +4. **Massstab & Zoom** — Live-Anzeige „1:N" + Dropdown (1:1,1:5,…,1:1000, frei) + + **Plan-Ansicht-Toggle** (Linienstärken für Druck) + Zoom-Buttons: 100% · Einpassen · + Auswahl. Quelle: viewBox-Skala / `dpi = 96·devicePixelRatio` (siehe docs/design/plans-output.md). +5. **Overrides** (regelbasiert, Toggle + Preset) · **Masse** (Bemaßungs-Preset) — später. +6. **Anordnen (Z-Order)** — nach vorne/hinten (für 2D-Plangrafik) — später. +7. **Snapping** — Master-Osnap + Modi (End/Mitte/Schnittpunkt/Lot/Zentrum/Nah) + + **Raster** an/aus + **Referenzlinien** (Wandachsen) an/aus — wenn Zeichenwerkzeuge da sind. +8. **Text** — Stil/Font/Größe + B/I/U + Ausrichtung + „+" — mit den 2D-Werkzeugen. + +### MVP jetzt (zu vorhandenem Modell) +- Ansichts-Umschalter (haben wir, ausbauen) · Detailgrad-Dropdown · **Massstab 1:N + + Zoom: Einpassen/Auswahl/100%** · Render-Modus · Referenzlinien-Toggle · Ressourcen. + +## Footer / Status-Leiste (neu, unten, ~22 px) + +`[Werkzeug-Hinweis] … [Cursor X/Y/Z] · [Einheit] · [Massstab 1:N] · [Zoom %] · [Geschoss] · [Ebene] · [Snap] … [Auswahl: n]` + +- **Cursor X/Y/Z** — live aus der Plan-/3D-Position (Plan: aus viewBox-Inverse der Maus). +- **Einheit** — m (aus Projekt). **Massstab** 1:N + **Zoom %** — aus der View-Transform. +- **Aktives Geschoss** + **aktive Ebene** — aus Selection/State. +- **Snap-Status** — aktive Fänge (später). **Auswahl: n** — Anzahl selektierter Objekte. +- **Werkzeug-Hinweis** links — kontextueller Text des aktiven Werkzeugs. + +### MVP jetzt +- Cursor X/Y (im Grundriss), Einheit, Massstab 1:N, Zoom %, aktives Geschoss + Ebene. + Snap/Werkzeug/Auswahl kommen mit den Zeichenwerkzeugen. + +## Anbindung +Beide Leisten docken an das Panel-/Dock-Layout an (Top über den Docks, Footer darunter), +Inhalte rein aus dem Modell + View-State abgeleitet (keine Sonderzustände). diff --git a/docs/design/wall-joins-priority.md b/docs/design/wall-joins-priority.md new file mode 100644 index 0000000..4c9098a --- /dev/null +++ b/docs/design/wall-joins-priority.md @@ -0,0 +1,636 @@ +# Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X) + +> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md). +> Aufbauend auf der bestehenden **L-Ecken-Gehrung** in `src/model/joins.ts` +> (`computeJoins`, `miterLine`) und `src/model/geometry.ts` (`clippedBand`, +> `lineIntersect`). Plan-Verbraucher: `src/plan/generatePlan.ts` (`addWallPoche`). +> Bezeichner **englisch**, Prosa deutsch, Einheiten **Meter**. + +> **Hinweis Referenz:** Die im Auftrag genannte DOSSIER-Datei +> `rhino/wand_grips.py` ist in dieser Umgebung **nicht vorhanden** (das einzige +> auffindbare `dossier` ist ein Typst-Portfolio, kein Rhino-Plugin). Das Design +> stützt sich daher auf (a) den bestehenden Code, (b) die in CONVENTIONS.md/elements.md +> festgehaltenen Geometrie-Konventionen und (c) die etablierte Bau-Semantik der +> prioritätsbasierten Schichtverschneidung (Vectorworks „Komponenten-Verbindung", +> Revit „Layer Priority", ArchiCAD „Composite Priority"). Sobald `wand_grips.py` +> verfügbar ist, sollte Abschnitt 7 (Priorität/Backbone) gegen DOSSIERs konkrete +> Rangregel abgeglichen werden. + +--- + +## 0. Problem & Leitbild + +Heute (`computeJoins`) wird **nur die L-Ecke** behandelt: genau zwei Wandenden +treffen sich in einem Knoten, eine gemeinsame **Gehrungslinie** (`miterLine`) +schneidet beide Wände sauber. T-/X-Stöße (≥ 3 Enden) bleiben rechtwinklig +gekappt (`if (ends.length !== 2) continue;`) — die durchgehende Wand wird von der +ankommenden Wand nicht durchdrungen, und die Schichten überlappen sich oder +klaffen. + +**Ziel:** An jedem Knoten — L (2 Enden), T (3), X/Kreuz (4+) — soll **jede +Schicht jeder Wand** genau bis zu der Fläche reichen, die ihre Verschneidungs- +Semantik vorgibt: + +- Die **gemeinsame, höchstpriorisierte Schicht** (Backbone, z. B. Stahlbeton) + läuft **durch** den Stoß. +- **Niederpriorisierte Schichten** (z. B. Putz, Dämmung) **stoßen** an die nächst- + höhere Schicht des Nachbarn und **enden** dort. +- Putz **läuft auf jeder Seite** bis zur Betonfläche und endet dort; Putz + **überquert nie** den Beton (kanonisches Beispiel, siehe §7.1). + +**Architektur-Prinzip (CONVENTIONS.md):** Das semantische Modell ist die einzige +Wahrheit. Die Verschneidung ist eine **reine Ableitung** (`Project + Wall[]` → +Trimm-Linien je Schicht-Band), nichts wird in die Geometrie eingebacken. Sie wird +beim Generieren des Plans angewandt und ist **deterministisch** und **idempotent**. + +--- + +## 1. Geometrie-Konventionen (Wiederholung, verbindlich) + +Aus CONVENTIONS.md und `geometry.ts`: + +- Achsrichtung `u = normalize(end - start)`. +- Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`. +- Schichten werden **außen → innen** gestapelt: Offset entlang `n` läuft von + `-T/2` (linke/„außen"-Kante) nach `+T/2` (rechte/„innen"-Kante). Eine Schicht `k` + belegt das Intervall `[off_k, off_k + thickness_k]` mit `off_0 = -T/2`. +- Eine **unendliche Gerade** ist `Line { point, dir }`; Schnittpunkt zweier + Geraden via `lineIntersect(a, da, b, db)` (null bei parallel). +- Ein Schicht-**Band** zwischen Offsets `offA, offB` wird über `clippedBand(start, + end, offA, offB, startCut, endCut)` gebildet; jede Bandlängskante wird mit einer + optionalen Schnittlinie verschnitten statt rechtwinklig gekappt. + +--- + +## 2. Datenmodell — was schon da ist, was neu kommt + +### 2.1 Vorhanden (`types.ts`) + +```ts +interface Component { …; joinPriority: number; } // höher = läuft am Stoß durch +interface Layer { componentId: string; thickness: number; } +interface WallType { id; name; layers: Layer[]; } // layers: außen → innen +interface Wall { start: Vec2; end: Vec2; wallTypeId; height; … } +``` + +`joinPriority` ist bereits **pro Bauteil (Component)** definiert — genau richtig: +Priorität ist eine **Material-Eigenschaft**, nicht eine Schicht-Eigenschaft. Damit +verschneidet sich Beton mit Beton unabhängig vom Wandtyp. + +### 2.2 Neue, abgeleitete Strukturen (keine neuen persistenten Felder) + +Die heutige `WallCuts`-Struktur (eine Schnittlinie je **Wandende**) reicht für L +(Gehrung) — aber **nicht** für T/X, weil dort verschiedene Schichten **verschiedene** +Trimm-Linien brauchen (Beton läuft durch, Putz stoppt früher). Wir erweitern auf +**pro Schicht, pro Ende** eine eigene Trimm-Linie: + +```ts +// src/model/joins.ts (erweitert) + +/** Eine gerichtete Halbkante eines Wandendes an einem Knoten. */ +interface WallEnd { + wallId: string; + end: "start" | "end"; +} + +/** Trimm-Linie + Klassifikation für GENAU EIN Schicht-Band an EINEM Ende. */ +interface LayerCut { + /** Schnittgerade in Weltkoordinaten; null = rechtwinklig kappen (Default). */ + line: Line | null; + /** + * "miter" – Gehrung (L): geteilte Diagonale mit dem Nachbarn. + * "butt" – Band stößt stumpf an eine Nachbarfläche (niedrigere Priorität). + * "through" – Band läuft durch den Knoten (höchste Priorität / Backbone). + * "square" – freies Ende, rechtwinklig (line === null). + */ + kind: "miter" | "butt" | "through" | "square"; +} + +/** Pro Wandende: eine Trimm-Linie je Schicht-Index (parallel zu WallType.layers). */ +interface EndCuts { + /** layerCuts[k] gilt für layers[k]; Länge === wt.layers.length. */ + layerCuts: LayerCut[]; +} + +/** Ersetzt die alte WallCuts: jetzt schichtweise an beiden Enden. */ +interface WallJoin { + start: EndCuts; + end: EndCuts; +} + +export type JoinMap = Map; +``` + +**Abwärtskompatibilität:** Die alte L-Gehrung ist der Spezialfall „alle +`layerCuts[k].line` an einem Ende sind dieselbe `miter`-Linie". `clippedBand` +bleibt unverändert; der Plan-Generator ruft es jetzt **je Schicht mit der +schichtspezifischen Linie** auf (siehe §8). + +### 2.3 Knoten-Repräsentation + +```ts +/** Ein Verschneidungsknoten: alle Wandenden, die sich (gerundet) berühren. */ +interface Junction { + key: string; // roundKey(p) + p: Vec2; // Knotenposition (Mittel der Enden) + arms: Arm[]; // sortiert nach Außenwinkel (CCW) +} + +/** Ein „Arm" = ein Wandende, gesehen als Strahl, der vom Knoten WEGzeigt. */ +interface Arm { + we: WallEnd; + wall: Wall; + /** Richtung VOM Knoten weg in die Wand hinein (immer normiert). */ + dirOut: Vec2; // = end==="start" ? +u : -u + /** Außenwinkel atan2(dirOut.y, dirOut.x) für Sortierung. */ + angle: number; + total: number; // Gesamtdicke der Wand + /** Schicht-Profil, vom Knoten aus gesehen (siehe §3.2). */ + layers: ArmLayer[]; +} + +/** Eine Schicht eines Arms, mit ihren beiden Längs-Flächengeraden am Knoten. */ +interface ArmLayer { + index: number; // Index in wt.layers + componentId: string; + priority: number; // Component.joinPriority + /** Offsets entlang n der Wand: [innerEdge..outerEdge] des Bandes. */ + off0: number; off1: number; + /** Die zwei Längsflächen als Geraden (point am Knoten, dir = u der Wand). */ + faceA: Line; faceB: Line; +} +``` + +--- + +## 3. Knotenerkennung (Junction Detection) + +### 3.1 Knoten clustern + +Identisch zur heutigen Logik, nur ohne die `length !== 2`-Abbruchbedingung: + +``` +function buildJunctions(walls): Junction[] + map = Map + for w in walls: + map.push(roundKey(w.start), {wallId:w.id, end:"start"}) + map.push(roundKey(w.end), {wallId:w.id, end:"end"}) + out = [] + for (key, ends) in map: + if ends.length < 2: continue // freies Ende → alle Schichten "square" + arms = ends.map(buildArm) + arms.sort(by angle) // CCW um den Knoten + out.push({ key, p: avgEndPoint(ends), arms }) + return out +``` + +`roundKey` (vorhanden) gruppiert Endpunkte auf ein 0.1-mm-Gitter. **Wichtig für +T-Stöße:** Bei einem echten T endet die ankommende Wand **auf der Achse** der +durchgehenden Wand, nicht an deren Endpunkt. Solche Knoten werden über die +Endpunkt-Gruppierung **nicht** gefunden, wenn die durchgehende Wand dort kein Ende +hat. Daher zusätzlich (siehe §3.3) eine **Achs-Auf-Achs-Inzidenz**. + +### 3.2 Arm-Schichtprofil (kanonische Orientierung) + +Damit Schichten zweier Arme vergleichbar sind, muss jeder Arm seine Schichten in +**konsistenter Welt-Orientierung** kennen. Wir speichern je Schicht ihre beiden +**Längsflächen** als Geraden mit Stützpunkt am Knoten und Richtung `u`: + +``` +function buildArm(we): Arm + wall = byId(we.wallId); u = dirOf(wall); n = leftNormal(u) + dirOut = we.end==="start" ? u : negate(u) // vom Knoten in die Wand + j = we.end==="start" ? wall.start : wall.end + total = wallTypeThickness(wt) + off = -total/2 + layers = [] + for (k, layer) in wt.layers: + off0 = off; off1 = off + layer.thickness + faceA = { point: j + n*off0, dir: u } + faceB = { point: j + n*off1, dir: u } + layers.push({ index:k, componentId, priority, off0, off1, faceA, faceB }) + off = off1 + return { we, wall, dirOut, angle: atan2(dirOut), total, layers } +``` + +### 3.3 T-Stoß-Erkennung (Achs-auf-Achs) + +Zusätzlich zu Endpunkt-Clustern: ein Wandende `e` (Punkt `P`) bildet einen +**T-Stoß** mit Wand `B`, wenn `P` (innerhalb Toleranz) auf der **Strecke** `B.start +→ B.end` liegt, aber **nicht** auf deren Endpunkten: + +``` +function findTeeIncidences(walls): + for endpoint P of each wall A (as WallEnd e): + for each wall B != A: + if pointOnSegment(P, B.start, B.end, tol) and not nearEndpoint(P, B): + // virtueller Knoten: A endet, B läuft durch. + registerTee(P, armOf(e), passThroughWall=B) +``` + +`B` wird hier als **durchgehender Strang** behandelt (kein Ende am Knoten); im +Junction-Modell taucht `B` als zwei kollineare „Arme" auf (Richtung `+u` und +`−u`), die der Prioritätsalgorithmus (§5) automatisch als „durchlaufend" erkennt +(zwei kollineare Arme gleicher Wand → ihre Schichten enden nie gegeneinander). + +**MVP-Vereinfachung (Phase 1, §10):** T-Stöße zunächst NUR über koinzidente +Endpunkte (B hat dort tatsächlich einen Eckpunkt, z. B. weil die Wand dort geteilt +wurde). Echte Achs-auf-Achs-T-Stöße (B durchgehend) folgen in Phase 3. + +--- + +## 4. Winkel-Sektoren & Nachbar-Flächen + +Für die Prioritätsauflösung muss man wissen, **welche Fläche eines Arms welcher +Fläche des Nachbarn gegenübersteht**. Die Arme sind CCW nach `angle` sortiert. +Zwischen zwei aufeinanderfolgenden Armen `arms[i]` und `arms[i+1]` (zyklisch) liegt +ein **Sektor** (Keil). Jeder Sektor wird von **einer Längsfläche jedes der beiden +Arme** begrenzt: + +``` + arm[i+1] + \ Sektor S_i + \ / + faceR(i+1)\ ___ faceL(i) + X (Knoten) + / + / + arm[i] +``` + +Konvention: pro Arm hat die **äußere** Schicht (Index 0, Offset `-T/2`) die Fläche, +die in den **CCW-vorausgehenden** Sektor zeigt; die **innere** Schicht den +**nachfolgenden**. Konkret bestimmen wir die „dem Sektor zugewandte" Fläche jeder +Schicht über das Vorzeichen von `cross(dirOut, sektorrichtung)` — analog zur +`lbCloser`-Heuristik in `miterLine`, aber pro Sektor statt global. + +``` +function sectorFaces(armLeft, armRight): + // armRight ist CCW vor armLeft (Sektor liegt zwischen ihnen). + // Wähle für jeden Arm die Schicht-Flächen, die in den Sektor zeigen. + faceOf(arm, towards): pick faceA or faceB of each layer by sign of + cross(arm.dirOut, towards - knoten) +``` + +Diese Sektor-Sicht verallgemeinert die bestehende `miterLine`: bei genau zwei +Armen (L) gibt es zwei Sektoren (innen/außen), und die Mittel-Gehrungslinie ergibt +sich wie bisher aus dem Schnitt der gegenüberliegenden Außen- bzw. Innenflächen. + +--- + +## 5. Prioritätsauflösung — das Kernstück + +### 5.1 Idee + +An einem Knoten konkurrieren Schichten verschiedener Arme um denselben Raum. Regel: + +> Eine Schicht **läuft durch** (`through`), wenn sie zur **höchsten am Knoten +> präsenten Priorität** gehört **und** auf der „gegenüberliegenden" Seite eine +> Schicht **gleichen Materials** (oder ≥ gleicher Priorität) existiert, an die sie +> nahtlos anschließt. Andernfalls **stößt** sie (`butt`) an die nächsthöher- +> priorisierte Nachbarfläche und endet dort. + +Praktisch lösen wir das **fläche-gegen-fläche** je Schicht: Für jede Schicht `L` +eines Arms suchen wir die **Trimm-Fläche**, die ihr Band beendet. Das ist die +**erste** (vom Knoten aus, entlang `dirOut`) der gegenüberliegenden Flächen mit +**echt höherer Priorität**; existiert keine, läuft die Schicht bis zur +Knoten-Mittelachse durch. + +### 5.2 Pro Schicht: Trimm-Fläche finden + +``` +function resolveLayerCut(arm, layer, junction): LayerCut + // Kandidaten: alle Schichten ALLER ANDEREN Arme, deren Band den + // Halbraum dieses Layers überlappt (Offset-Überlapp im gemeinsamen + // Sektor) UND deren Priorität die Trimm-Entscheidung bestimmt. + candidates = [] + for other in junction.arms where other.wall.id-end != arm.id-end: + for ol in other.layers: + if overlapsInSector(layer, ol, arm, other): + candidates.push({ other, ol, face: facingFace(ol, towards arm) }) + + // Höchste am Knoten präsente Priorität (global, für "through"). + maxPrio = max over all candidate.ol.priority and layer.priority + + if layer.priority == maxPrio and hasCollinearSamePrio(arm, layer, junction): + // Backbone: läuft durch bis zur Mittel-/Gehrungslinie. + return { line: miterMidline(arm, layer, junction), kind: "through" } + + // Sonst: stoße an die NÄCHSTE Fläche höherer Priorität entlang dirOut. + blockers = candidates.filter(c => c.ol.priority > layer.priority) + if blockers.empty: + // niemand höher → Gehrung mit dem Nachbarn gleicher Stufe (L-Fall) + return { line: miterMidline(arm, layer, junction), kind: "miter" } + + nearest = argmin over blockers of distanceAlong(dirOut, face) + return { line: nearest.face, kind: "butt" } +``` + +Hilfsbegriffe: + +- `overlapsInSector(layer, ol, …)` — projiziert beide Bänder auf die **Normale des + Sektors** und prüft Intervall-Überlapp `[off0,off1] ∩ [off0',off1'] ≠ ∅`. Nur + überlappende Bänder können sich gegenseitig trimmen. +- `facingFace(ol, towards arm)` — die der `arm`-Seite zugewandte Längsfläche von + `ol` (die `faceA`/`faceB` von §3.2), als `Line`. +- `distanceAlong(dirOut, face)` — Abstand des Schnittpunkts `faceLine ∩ layerAxis` + vom Knoten, gemessen entlang `dirOut`. Negative bzw. hinter dem Knoten liegende + Treffer werden verworfen (eine Trimm-Fläche muss **vor** dem Band liegen). +- `miterMidline(...)` — die gemeinsame Diagonale für gleichrangige Begegnung; für + zwei Arme exakt die heutige `miterLine`. +- `hasCollinearSamePrio(...)` — true, wenn auf der „anderen Seite" des Knotens eine + Schicht **gleichen Materials/Priorität** existiert, in die das Band nahtlos + übergeht (Backbone-Kontinuität, §7). + +### 5.3 Resultat in `LayerCut.line` schreiben + +`resolveLayerCut` liefert für `layers[k]` eine `Line | null`. Diese wird in +`WallJoin[end].layerCuts[k]` abgelegt. `clippedBand` kappt damit **dieses eine +Band** an genau dieser Linie — alle anderen Schichten derselben Wand können andere +Linien (oder `null`) haben. Das ist die ganze Verkabelung zum Renderer. + +--- + +## 6. Master-Pseudocode `computeJoins` (neu) + +``` +function computeJoins(project, walls): JoinMap + result = init each wall → { start:{layerCuts:[square…]}, end:{layerCuts:[square…]} } + junctions = buildJunctions(walls) // §3.1 + // (Phase 3: junctions += findTeeIncidences(walls)) // §3.3 + + for J in junctions: + if J.arms.length == 1: continue // freies Ende: alles "square" + if J.arms.length == 2: + resolveL(J, project, result) // bestehende Gehrung, schichtweise + else: + resolveTorX(J, project, result) // §5, pro Arm pro Schicht + return result + +function resolveTorX(J, project, result): + for arm in J.arms: + cuts = [] + for layer in arm.layers: + cuts[layer.index] = resolveLayerCut(arm, layer, J) // §5.2 + writeEnd(result, arm.we, cuts) // start oder end + +function resolveL(J, project, result): + [a, b] = J.arms + // Wie heute: eine gemeinsame Gehrungslinie pro Sektor; aber wenn die + // Dicken/Schichten ungleich sind, kann pro Schicht über §5.2 verfeinert werden. + // MVP: eine miterLine für alle Schichten beider Arme (= heutiges Verhalten, + // schichtweise dupliziert). + line = miterLine(project, a.wall, a.we.end, b.wall) + for arm in [a,b]: + cuts = arm.layers.map(_ => ({ line, kind:"miter" })) + writeEnd(result, arm.we, cuts) +``` + +So bleibt die **L-Ecke bit-genau wie heute** (Regressionssicherheit), und die +neue Logik greift nur bei ≥ 3 Armen. + +--- + +## 7. Prioritäts-Semantik im Detail (das Putz/Beton-Beispiel) + +### 7.1 Kanonisches Beispiel + +Wandtyp „Aussenwand" (außen → innen): + +| k | Component | thickness | joinPriority | +|---|------------|-----------|--------------| +| 0 | Putz aussen| 0.02 | 1 | +| 1 | Stahlbeton | 0.18 | 9 | +| 2 | Putz innen | 0.015 | 1 | + +Drei solche Wände treffen in einem T-Knoten (zwei kollinear durchgehend „BB", +eine ankommend „A"): + +1. **Beton (prio 9)** der durchgehenden Wände `BB` läuft durch — beide + Beton-Bänder sind kollinear gleicher Priorität → `hasCollinearSamePrio` = + true → `through`. Der Beton von `A` (prio 9) **stößt** an die **Betonfläche** + von `BB` (gegenüberliegende Fläche, gleiche höchste Priorität, aber kein + kollinearer Partner für `A`) → `butt` an Betonaußenfläche von `BB`. +2. **Putz (prio 1)** jeder Wand stößt an die **erste höherpriorisierte Fläche** + entlang `dirOut`. Für die Putzschichten von `A` ist das die **Betonfläche von + `BB`**: Putz **läuft auf jeder Seite bis zum Beton** und endet dort (`butt`). +3. Putz **überquert nie** den Beton, weil eine `butt`-Trimm-Linie immer die + **nächste** höhere Fläche ist — sie liegt vor dem Beton-Durchlauf. + +Ergebnis exakt wie gefordert: *Beton durch, Putz schließt beidseitig an, Putz +kreuzt Beton nicht.* + +### 7.2 Warum Priorität pro Component (nicht pro Layer) + +Beton trifft Beton → gleiche Priorität → nahtlos. Würde Priorität pro Schicht +vergeben, müsste man sie pro Wandtyp neu pflegen. `joinPriority` am Component löst +das materialweise — Stahlbeton hat **immer** 9, egal in welchem Wandtyp. + +### 7.3 Backbone-Erkennung für „through" + +`hasCollinearSamePrio(arm, layer, junction)`: + +``` +for other in junction.arms where other != arm: + if collinear(arm.dirOut, other.dirOut) (antiparallel, gleiche Achse) and + other has a layer ol with ol.priority == layer.priority and + bandsAlign(layer, ol): // gleiche Offsets relativ zur gemeinsamen Achse + return true +return false +``` + +Nur wenn das Band auf der **gegenüberliegenden** Achse einen passenden Partner +gleicher Priorität und Lage hat, läuft es wirklich **durch**. Sonst (z. B. der +ankommende Beton von `A`) **stößt** es an — exakt das gewünschte Verhalten. + +--- + +## 8. Layer-Wrapping (Umschlagen niederpriorisierter Schichten) + +Ein subtiler, aber wichtiger Fall: An einem T-Stoß endet die **innere** Putzschicht +der ankommenden Wand `A` an der Betonfläche von `BB`. Damit der Putz **um die Ecke +herum sichtbar bleibt** (auf der Innenseite der durchgehenden Wand zieht der innere +Putz von `BB` durch), ist nichts Zusätzliches nötig — `BB`s innerer Putz läuft als +eigenes durchgehendes Band weiter. + +Wo Wrapping aktiv nötig ist: wenn eine **niederpriorisierte Außenschicht** an einer +Ecke **um eine höhere Schicht herumgeführt** werden soll (z. B. Dämmung, die außen +um die Stütze läuft). Modellierung: + +- Standard (MVP): kein Wrapping — jede Schicht endet an ihrer Trimm-Fläche + (`butt`). Das deckt T/X korrekt ab. +- Optional (Phase 4): Ein `wrap`-Flag pro Component (`Component.wrapAtEnds?: + boolean`). Ist es gesetzt, erzeugt der Generator an der Trimm-Fläche ein + **zusätzliches kurzes Stirnband** quer (Offset-Intervall der Schicht, Länge = + Tiefe bis zur nächsten Fläche), sodass die Schicht ihre eigene Stirnseite + „umschließt". Geometrisch ein weiteres `clippedBand` mit getauschten Achsen. + +Wrapping ist bewusst **nachgelagert**; es ändert die Trimm-Logik (§5) nicht, +sondern fügt nur Zusatzpolygone hinzu. + +--- + +## 9. Anbindung an den Plan-Generator (`generatePlan.addWallPoche`) + +Heute ruft `addWallPoche` `clippedBand(p1, p2, off, off+thickness, startCut, +endCut)` mit **einer** `WallCuts` je Ende. Neu: + +```ts +// joins: JoinMap (neu) +const join = joins.get(wall.id) ?? emptyJoin(wt.layers.length); +… +let off = -total / 2; +wt.layers.forEach((layer, k) => { + const startCut = (s <= 1e-6) ? join.start.layerCuts[k].line : null; + const endCut = (e >= axisLen - 1e-6) ? join.end.layerCuts[k].line : null; + out.push({ kind:"polygon", + pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut), + fill: comp.color, hatch: resolveHatch(...), … }); + off += layer.thickness; +}); +``` + +- Die **Umrisslinie** über die volle Dicke (`-T/2..+T/2`) nutzt für ihre beiden + Kanten die Cuts der **äußersten** bzw. **innersten** Schicht — oder wird, falls + Schichten unterschiedlich getrimmt sind, durch eine **abgeleitete Outline** + (Vereinigung der Schicht-Polygone, §11) ersetzt. MVP: weiterhin volle-Dicke-Band + mit den Cuts von Schicht 0 / letzter Schicht. +- `grob`-Detailgrad nutzt nur die **Backbone-Schicht** → ihr `through`/`miter`-Cut + für die ganze Sammelfläche (entspricht der bestehenden `backboneColor`-Logik). + +**Keine Signaturänderung an `clippedBand`** — es bleibt der zentrale Trimmer; wir +füttern es nur schichtweise mit verschiedenen Linien. + +--- + +## 10. 2D-analytisch jetzt vs. exakte 3D-Booleans später (OCCT) + +### 10.1 Jetzt — 2D-analytisch (dieses Design) + +- **Plan/Grundriss** ist eine reine 2D-Ableitung (vgl. `generatePlan.ts`-Kopf: + „nicht durch Zerschneiden eines 3D-Meshes, sondern direkt aus den Parametern"). +- Trimmen = **Geraden-Schnitt** (`lineIntersect`) + Polygon-Kappen (`clippedBand`). + Kein Polygon-Boolean nötig, solange Schichten als **konvexe Bänder** mit + schrägen Stirnflächen modelliert werden. Das ist exakt, schnell (O(Arme² · + Schichten) je Knoten), und deterministisch. +- **3D-Wand** im Viewport: Extrusion der getrimmten 2D-Schichtpolygone in `z` + (Höhe). Damit stimmen Plan und 3D ohne separaten Kernel überein. + +Grenzen der 2D-Analytik: nicht-konvexe Stirnprofile, echte Materialdurchdringung in +`z` (z. B. wenn Wände unterschiedlicher Höhe / Sturz / Brüstung interagieren), +Verschneidung Wand × Decke × Stütze. Das braucht echte Volumen-Booleans. + +### 10.2 Später — exakte 3D-Booleans (OCCT / opencascade.js) + +- **Bibliothek:** `opencascade.js` (WASM-Port von OCCT) — `BRepAlgoAPI_Cut/Common`, + `BRepPrimAPI_MakePrism` für Extrusionen. Alternativ `manifold-3d` (schneller, + robuster für reine Mesh-Booleans, aber ohne B-Rep/Fillets). +- **Modell bleibt gleich:** Priorität (`joinPriority`) bestimmt die **Cut- + Reihenfolge**. Algorithmus: baue je Schicht einen über-langen Solid (Band × + Höhe, über den Knoten hinaus verlängert); subtrahiere von jeder niederpriori- + sierten Schicht die Solids **aller höherpriorisierten** Schichten am Knoten + (`result = layerSolid − ⋃ higherPrioritySolids`). Gleiche Priorität: kein Cut + (Beton bleibt an Beton). Das ist die **3D-Verallgemeinerung exakt derselben + Prioritätsregel** — die 2D-`butt`/`through`-Klassifikation aus §5 ist die + 2D-Projektion dieser Boolean-Reihenfolge. +- Die Datenstrukturen (§2) bleiben unverändert; nur der **Trimmer** wird + ausgetauscht (`clippedBand` → OCCT-Cut). Deshalb ist das 2D-Design bereits + „OCCT-ready". + +--- + +## 11. Robuste Outline (optional, Phase 4) + +Wenn Schichten an einem Knoten unterschiedlich getrimmt sind, ist die „volle +Dicke"-Umrisslinie nicht mehr ein einfaches Band. Saubere Lösung: die +**Wand-Outline** als **Vereinigung aller Schicht-Polygone** berechnen +(Polygon-Boolean in 2D). Empfohlene Library: **`polygon-clipping`** (Martinez- +Rueda, robust, klein) oder **`@flatten-js/boolean-op`**. Damit wird der Umriss +exakt die Außenkontur der getrimmten Bänder — auch bei X-Knoten. MVP verzichtet +darauf und nutzt die Schicht-0/N-Cuts (visuell für die meisten Fälle ausreichend). + +--- + +## 12. Edge Cases + +1. **Gleiche Priorität, nicht kollinear** (z. B. zwei Betonwände treffen im rechten + Winkel im T): kein „through" (kein kollinearer Partner), aber auch kein + `butt`-Blocker höherer Priorität → Fallback **`miter`** (gemeinsame Gehrung wie + im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die + resultierenden Stirnflächen sind die Sektor-Halbierenden. +2. **Kollinear (180°)** — zwei Wände in einer Linie: `lineIntersect` liefert null + (parallel). Behandlung wie heute: **kein Schnitt**, Bänder laufen gerade durch + (bei gleichem Typ nahtlos). Bei ungleichem Typ: die schmalere Wand stößt an die + breitere; pro Schicht über §5 auflösbar. +3. **> 2 Schichten / asymmetrische Wandtypen**: Der Algorithmus ist in der Schicht- + anzahl generisch (`resolveLayerCut` läuft je Schicht-Index). Treffen Wände + verschiedener Typen (verschiedene Schichtzahl/-dicke), entscheidet **nur die + Priorität pro Fläche**, nicht der Index — deshalb arbeiten wir mit + `ArmLayer.faceA/faceB` (Welt-Flächen), nicht mit Schicht-Indizes über Wände + hinweg. +4. **Backbone fehlt** (alle Prioritäten gleich): degeneriert sauber zu reinen + Gehrungen (Fall 1). +5. **Sehr spitze Winkel**: Trimm-Schnittpunkte können weit vom Knoten wegwandern. + Begrenzung: `distanceAlong` auf `≤ maxReach` (z. B. `3 · total`) clampen; sonst + rechtwinklig kappen (`square`), um Artefakte zu vermeiden. +6. **Öffnungen am Wandende** (`addWallPoche`-Segmentierung): Cuts gelten nur für das + echte Wandende (`s≤ε` / `e≥len−ε`), wie heute. Türnahe Segmentenden bleiben + `square`. +7. **Numerische Knoten-Toleranz**: `roundKey`-Gitter (0.1 mm) und ε in + `pointOnSegment` müssen konsistent sein, sonst „flackernde" Knoten. Toleranz + zentral als Konstante (`JOIN_EPS = 1e-4` m). +8. **Mehr als 2 Wände gleicher Achse** (degenerierter X, alle kollinear): als ein + durchgehender Strang behandeln; Prioritätsregel pro Schicht greift normal. + +--- + +## 13. Staged Build-Plan + +**Phase 1 — Single-Layer T (Fundament).** +- `JoinMap`/`WallJoin`/`EndCuts`/`LayerCut` einführen; `WallCuts` darin als + Spezialfall abbilden. `clippedBand` unverändert. +- `buildJunctions` ohne `length!==2`-Abbruch; L-Fall ruft bestehende `miterLine` + (schichtweise dupliziert) → **Regressionsgleichheit** zu heute. +- T-Knoten **nur über koinzidente Endpunkte** (§3.3 MVP). Für **einschichtige** + Wände: die durchgehende Wand läuft durch, die ankommende stößt rechtwinklig an + deren nächste Fläche. Verifizieren: `npx tsc -b`, `npm run build`, + `node scripts/probe.mjs`, Screenshot prüfen. + +**Phase 2 — Prioritäts-Auflösung mehrschichtig (T).** +- `ArmLayer`-Profil (§3.2), Sektor-Flächen (§4), `resolveLayerCut` (§5) inkl. + `through`/`butt`/`miter`-Klassifikation und `hasCollinearSamePrio`. +- `addWallPoche` schichtweise verkabeln (§9). Putz/Beton-Testszene (§7.1) anlegen + und visuell prüfen. + +**Phase 3 — X-Knoten & echte Achs-T-Stöße.** +- `findTeeIncidences` (Achs-auf-Achs, §3.3): durchgehende Wand wird nicht geteilt. +- 4+-Arm-Sektorlogik vollständig; Edge Cases 1/5/8 absichern. + +**Phase 4 — Politur.** +- Robuste Outline via Polygon-Boolean (§11); optionales Layer-Wrapping (§8, + `Component.wrapAtEnds`). + +**Phase 5 — Exakte 3D-Booleans (OCCT).** +- `opencascade.js` integrieren; Trimmer-Interface so abstrahieren, dass 2D + (`clippedBand`) und 3D (OCCT-Cut) dieselbe Prioritäts-Reihenfolge nutzen (§10.2). + Plan bleibt 2D-analytisch; nur das 3D-Volumen nutzt Booleans. + +--- + +## 14. Trimmer-Abstraktion (für §10.2-Migration) + +Damit Phase 5 nicht den Aufrufer ändert, kapseln wir das Trimmen hinter einer +Schnittstelle. 2D nutzt `clippedBand`; 3D nutzt OCCT — beide konsumieren dieselbe +`JoinMap`. + +```ts +interface LayerTrimmer { + /** 2D: getrimmtes Bandpolygon. 3D-Variante liefert stattdessen einen Solid. */ + trimBand(p1: Vec2, p2: Vec2, offA: number, offB: number, + startCut: Line | null, endCut: Line | null): Vec2[]; +} +``` + +Die Prioritätslogik (`computeJoins`) bleibt der **gemeinsame, kernel-unabhängige** +Kopf; nur der `LayerTrimmer` wird ausgetauscht. Das hält das Design dem +Architektur-Prinzip treu: ein semantisches Modell, viele abgeleitete Ansichten. diff --git a/docs/research/swisstopo-sia.md b/docs/research/swisstopo-sia.md new file mode 100644 index 0000000..9a9ce5f --- /dev/null +++ b/docs/research/swisstopo-sia.md @@ -0,0 +1,566 @@ +# Swisstopo-Geodaten & SIA-Flächenstandards im Browser-BIM + +> Stand: 2026-06-29 · Recherche für das Standalone-Browser-BIM (React + TS + Three.js), +> Port von **DOSSIER** (Rhino-Plugin). Ziel: (A) Schweizer Geodaten (Höhenmodell, +> Orthofoto, 3D-Gebäude, Parzellen) direkt im Browser laden, (B) Standort-Kontext +> (Gelände + Parzelle + Nachbargebäude) für ein Projekt importieren, (C) SIA-416- +> Flächen/Volumen + Raumschemata berechnen wie in DOSSIER. +> +> Bezug zur ROADMAP: Swisstopo/Terrain/OSM = **Phase 4** (Kontext/Daten); SIA-416- +> Räume + Bilanz-CSV = **Phase 2**. Beide sind dort bereits als ⭐-Features gelistet. + +**Alle in diesem Dokument genannten geo.admin.ch-Endpunkte wurden am 2026-06-29 live +gegen die echte API getestet** (curl + CORS-Header-Check). Wo „verifiziert" steht, +liegt eine echte Antwort vor. + +--- + +## Teil A — Swisstopo-APIs & Dienste aus dem Browser + +### A.0 Das Wichtigste vorweg: CORS & Lizenz + +Zwei Fragen entscheiden, ob ein Dienst *ohne Backend-Proxy* aus einer reinen +Browser-App nutzbar ist: CORS und Lizenz. Beide sind hier günstig. + +**CORS (live verifiziert):** Alle relevanten Hosts senden `access-control-allow-origin: *`: + +| Host | Dienst | CORS | Range-Requests | +|---|---|---|---| +| `api3.geo.admin.ch` | REST (height, profile, identify, find, search) | ✅ `*` | — | +| `data.geo.admin.ch` | STAC-API + Daten-Assets (COG-GeoTIFF, XYZ.zip) | ✅ `*` | ✅ `206 Partial Content`, `accept-ranges`/`content-range` vorhanden | +| `wmts.geo.admin.ch` | WMTS-Kacheln | ✅ `*` | — | +| `3d.geo.admin.ch` | 3D-Tiles (`tileset.json` + glTF) | ✅ `*` | — | + +→ **Konsequenz:** Höhenabfrage, Geocoding, Parzellen-Identify, Karten-/Orthofoto- +Kacheln, **COG-GeoTIFF-Höhenmodell per Range-Request** und 3D-Tiles sind **direkt aus +dem Browser ohne eigenen Proxy** abrufbar. Das ist ein großer Vorteil gegenüber vielen +anderen nationalen Geodiensten. + +**Lizenz:** swisstopo/geo.admin.ch ist **Open Government Data**: „The acquisition and +use of data or services is free of charge, subject to the provisions on fair use." +Kommerzielle Nutzung ist erlaubt, Einbindung in (auch kommerzielle) Web-Apps explizit +gedeckt. Pflicht-Attribution: **`© swisstopo`** (bzw. „© Data: swisstopo"). „Fair use" += z.B. Web-App mit Ø 20'000 Nutzern/Tag ok; aggressives Bot-Scraping vermeiden. Haftung +ausgeschlossen, ~98% Verfügbarkeit. [Terms of use FSDI](https://www.geo.admin.ch/en/general-terms-of-use-fsdi) + +> ⚠️ **Korrektur zu DOSSIER & zur Doku:** Die offizielle REST-Doku notiert beim +> *Height*-Service „This service is not freely accessible (fee required)". Das ist +> **in der Praxis falsch / veraltet**: Der Endpunkt antwortet anonym, ohne Key, mit +> `200` und CORS `*` (verifiziert, siehe A.1). DOSSIERs Aussage „alle APIs offen, ohne +> Auth, ohne Key" deckt sich mit der gemessenen Realität. Wir verlassen uns aber nicht +> blind darauf, sondern behandeln 402/429 defensiv (Retry/Backoff, Cache). + +### A.1 Höhenabfrage — Height-Service (Einzelpunkt) + +Punkt-Höhe (DTM) aus swissALTI3D/DTM. **Verifiziert:** + +``` +GET https://api3.geo.admin.ch/rest/services/height?easting=2600000&northing=1200000&sr=2056 +→ {"height":"555.5"} +``` + +Parameter: +- `easting`, `northing` — LV95 (`sr=2056`) oder LV03 (`sr=21781`). **Pflicht.** +- `sr` — `2056` (LV95) angeben, sonst Default `21781`. +- `elevation_model` — `DTM2` (= swissALTI3D, 2 m), `DTM25` (Default), `COMB`. + (Im Tal lieferten DTM2/DTM25/COMB denselben Wert; im Steilgelände kann DTM2 genauer sein.) +- `callback` — JSONP (brauchen wir wegen CORS nicht). + +Nutzung im Tool: **Projekt-Nullpunkt-Z** bzw. „Gebäude auf Gelände setzen" — eine +einzelne Höhe an der Projekt-Koordinate. Antwortzeit ~50–150 ms. Quelle: +[GeoAdmin REST – Height](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html) + +### A.2 Höhenprofil — Profile-Service (Schnittlinie) + +Höhen entlang einer Polylinie — ideal für **Geländeschnitt** unter einem +Gebäude-Schnitt. **Verifiziert** (echte Werte zurück): + +``` +GET https://api3.geo.admin.ch/rest/services/profile.json + ?geom={"type":"LineString","coordinates":[[2600000,1200000],[2600200,1200000]]} + &sr=2056&nb_points=3 +→ [{"alts":{"COMB":555.5,"DTM2":555.5,"DTM25":555.5},"dist":0,"easting":2600000,"northing":1200000}, + {"alts":{...},"dist":100,...}, {"dist":200,...}] +``` + +Parameter: `geom` (GeoJSON-LineString, max 6'000 Punkte), `sr`, `nb_points` (Anzahl +Stützpunkte, Default 200), `elevation_models`, `offset` (Glättung). Auch als +`profile.csv`. → Für 2D-Geländeschnitte **ohne** Mesh-Download. Quelle: +[GeoAdmin REST – Profile](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html) + +### A.3 swissALTI3D — Höhenmodell als COG-GeoTIFF (Mesh-Quelle) ⭐ + +Das ist der **Schlüssel für das Gelände-Mesh im Browser**. swissALTI3D ist das präzise +DTM der Schweiz (ohne Vegetation/Bebauung), Auflösung 0.5 m / 2 m, alle 6 Jahre +aktualisiert ([swissALTI3D](https://www.swisstopo.admin.ch/en/height-model-swissalti3d)). +Bezug über die **STAC-API** (verifiziert — Tile `swissalti3d_2019_2599-1198`): + +``` +GET https://data.geo.admin.ch/api/stac/v1/collections/ch.swisstopo.swissalti3d/items + ?bbox=&limit=... +``` + +Jedes 1×1-km-Tile liefert pro Auflösung **zwei** Asset-Typen: + +| Asset | Typ | Browser-tauglich? | +|---|---|---| +| `..._0.5_2056_5728.tif` / `..._2_2056_5728.tif` | **Cloud-Optimized GeoTIFF**, **EPSG:2056** | ✅ **direkt** via `geotiff.js` + Range | +| `..._0.5_2056_5728.xyz.zip` / `..._2_..._xyz.zip` | ASCII-XYZ (E N Z) in ZIP | ✅ via `fflate` entpacken (so macht es DOSSIER) | + +**Verifiziert:** `data.geo.admin.ch` liefert auf das `.tif` ein `206 Partial Content` +mit `content-range` bei `Range:`-Header und CORS `*`. Das bedeutet: **`geotiff.js` +liest nur den benötigten Ausschnitt eines COG per HTTP-Range, ohne das ganze File zu +laden** — perfekt für eine Browser-App. Bbox in WGS84 für STAC, Tile-Daten dann in +LV95-Metern (kein Reprojizieren der Z-Werte nötig). Quelle: +[STAC tech docs](https://docs.geo.admin.ch/) · COG-Tile live geprüft. + +### A.4 SWISSIMAGE / Karten — WMTS-Kacheln + +Orthofoto (10 cm) und Landeskarten als Kacheln. RESTful-URL-Template: + +``` +https://wmts.geo.admin.ch/1.0.0//default/3011 Bern", lat:46.94677, lon:7.44419, + geom_st_box2d:"BOX(2600423.26 1199521.11, ...)", origin:"address", ... } +``` + +- `type=locations`, `origins` aus `{address, parcel, gg25, gazetteer, zipcode, district, + kantone}`, `sr=2056`. **Im LV95-Modus liefert die Geo-Admin-Konvention `y`=East, + `x`=North** (DOSSIER liest genau so: `e=attrs.y`, `n=attrs.x`). Labels enthalten + ``-Tags (strippen). +- `type=featuresearch` + `features=` durchsucht Attribute (z.B. Parzellennummer). + +Quelle: [Search](https://docs.geo.admin.ch/access-data/search.html) · Live-Antwort oben. + +### A.8 Koordinatensystem LV95 / EPSG:2056 & Transformationen + +Intern rechnet das BIM-Tool in **Metern** (ROADMAP-Konvention) und verschiebt den +Projekt-Ursprung nahe (0,0,0); LV95-Koordinaten sind ~2.6 Mio / 1.2 Mio Meter groß und +würden bei `float32` (Three.js) zu **Jitter** führen → **Origin-Shift Pflicht** (siehe B.3). +DOSSIER macht genau das (`origin_shift`/`shift_lv95`, typ. bbox-Center → 0/0/0). + +Transformations-Optionen: + +1. **`proj4` (npm `proj4@2.20.9`)** — universell, exakt. EPSG:2056-Definition: + ```js + proj4.defs("EPSG:2056", + "+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 "+ + "+x_0=2600000 +y_0=1200000 +ellps=bessel "+ + "+towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs"); + const [e,n] = proj4("EPSG:4326","EPSG:2056",[lon,lat]); // WGS84→LV95 + ``` + Genauigkeit mit dieser 3-Parameter-`towgs84` ~1 m (für Kontext-Import völlig + ausreichend). Types: `@types/proj4`. Quellen: + [epsg.io/2056](https://epsg.io/2056) · [proj4js](https://github.com/proj4js/proj4js). + +2. **Näherungsformeln (CH1903→WGS84, swisstopo)** — DOSSIERs Ansatz, ~1 m genau, + **0 Dependencies** (zwei kleine Funktionen `lv95_to_wgs84`/`wgs84_to_lv95`). + 1:1 nach TS portierbar; gut, wenn man `proj4` nicht ziehen will. Reicht, weil + STAC-Queries ohnehin nur eine grobe WGS84-Bbox brauchen und alle *Daten* schon in + LV95 kommen. + +3. **swisstopo REFRAME Web-API** — cm-genaue offizielle Umrechnung (LV95↔WGS84, + LN02↔Bessel). Nur nötig, wenn Vermessungs-Genauigkeit verlangt wird. REST, online. + [REFRAME Web](https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web) + +**Empfehlung:** `proj4` mit fester EPSG:2056-Def (eine Abhängigkeit, exakt genug, +wartungsarm) — oder, wenn Dependency-Geiz, DOSSIERs Formeln portieren. REFRAME nur bei +Bedarf nachrüsten. + +### A.9 Endpoint-Übersicht (Spickzettel) + +| Zweck | Endpoint | Frei/CORS | Format | +|---|---|---|---| +| Punkt-Höhe | `api3…/rest/services/height` | ✅ ✅ | JSON | +| Höhenprofil (Schnitt) | `api3…/rest/services/profile.json` | ✅ ✅ | JSON/CSV | +| Gelände-Mesh (COG) | STAC `…/swissalti3d/items` → `.tif` (COG, 2056) | ✅ ✅ Range | GeoTIFF | +| Gelände (ASCII) | STAC `…/swissalti3d` → `.xyz.zip` | ✅ ✅ | XYZ in ZIP | +| Orthofoto/Karte | `wmts…/1.0.0//…/{z}/{x}/{y}.jpeg` | ✅ ✅ | Kacheln | +| 3D-Nachbargebäude (stream) | `3d…/ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` | ✅ ✅ | 3D-Tiles/glTF | +| 3D-Gebäude (Datei) | STAC `…/swissbuildings3d_2` → `.obj/.ifc` | ✅ ✅ | OBJ/IFC | +| Parzelle/Kataster | `api3…/MapServer/identify` `layers=all:ch.kantone.cadastralwebmap-farbe` | ✅ ✅ | GeoJSON | +| Geocoding | `api3…/SearchServer?type=locations` | ✅ ✅ | JSON | + +--- + +## Teil B — Standort-Kontext importieren (Terrain + Parzelle + Nachbargebäude) + +So bekommt ein Projekt seinen realen Kontext „auf Knopfdruck". Der Ablauf folgt +DOSSIER (`rhino/swisstopo.py`), übersetzt auf Browser-Libs. + +### B.1 Pipeline (End-to-End) + +``` +Adresse/Parzelle ──SearchServer──▶ Zentrum (E,N) in LV95 + │ + ├─ radius r ──▶ bbox_LV95 (E±r, N±r) ──proj4/Formeln──▶ bbox_WGS84 + │ + ├─[Parzelle] identify(cadastralwebmap, point) ─▶ Polygon (LV95) ─▶ Grundstücksgrenze (Ebene 01 Vermessung) + │ + ├─[Gelände] STAC(swissalti3d, bbox_WGS84) ─▶ COG .tif(2056) + │ └─ geotiff.js readRasters(window) ─▶ Höhen-Grid (E,N,Z, m) + │ └─ Three.js BufferGeometry (Grid→Mesh) [optional: TIN, Höhenlinien, Volumen] + │ + ├─[Orthofoto] WMTS swissimage ─▶ Textur auf Gelände-Mesh ODER georef. Plane + │ + └─[Nachbarn] 3D-Tiles streamen (Anzeige) ODER STAC swissbuildings3d_2 .obj ─▶ Mesh-Import + (Weltweit/ausserhalb CH: OSM-Overpass als Fallback, siehe B.5) + ──▶ alle Geometrien um origin_shift (bbox-Center→0/0/0) verschoben, Z aus ALTI3D +``` + +### B.2 Gelände-Mesh aus swissALTI3D (Kern, Phase 4) + +**Empfohlener Browser-Weg (COG + geotiff.js):** + +1. STAC-Query mit `bbox_WGS84` → Liste der überlappenden Tiles; pro Tile das + gewünschte COG-Asset (`_2_2056_` für 2 m, `_0.5_2056_` für 0.5 m). +2. `geotiff.js`: `const tiff = await fromUrl(href)` → COG; `image.readRasters({window})` + liest **nur den Ausschnitt** (Range-Requests, da CORS+Range bestätigt). Ergebnis ist + ein reguläres Z-Raster mit bekanntem Origin/PixelScale (LV95-Meter) aus den + GeoKeys/`image.getOrigin()`/`image.getResolution()`. +3. Raster → **`THREE.BufferGeometry`**: ein Vertex pro Rasterpunkt + `(E−shiftE, N−shiftN, Z−shiftZ)`, Faces als zwei Dreiecke pro Zelle (DOSSIER: + `mesh_from_grid`, gleiche Logik), `computeVertexNormals()`. Bei 0.5 m wird das Mesh + groß → bei Bedarf raumräumlich sub-samplen (DOSSIER macht ganzzahliges Sub-Sampling + auf dem **globalen** LV95-Raster, damit Nachbar-Tiles nahtlos zusammenpassen). +4. Mehrere Tiles: erst zu **einem** Grid mergen (gemeinsamer Origin/Step), dann meshen — + sonst entstehen Nähte (DOSSIER: `merge_grids`). + +**Alternativweg (XYZ, exakt wie DOSSIER):** `.xyz.zip` laden → mit **`fflate`** +(npm, schnellster Inflate im Browser) entpacken → ASCII `E N Z` parsen → gleiches Grid. +Robust, aber überträgt mehr Bytes als der COG-Range-Weg. Für den Port empfehle ich +**COG primär, XYZ als Fallback**. + +Bibliotheken: `geotiff@3.0.5`, `fflate` (XYZ-Variante), `three@0.185.0`. +[geotiff.js](https://github.com/geotiffjs/geotiff.js/) · +[3D-Terrain aus GeoTIFF mit Three.js](https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/). + +**Ableitungen wie in DOSSIER** (alle aus dem Grid, in Phase 4 portierbar): +- **Höhenlinien** (Marching-Squares auf dem Grid; npm `d3-contour` oder + `marchingsquares`) — für 2D-Plan. +- **TIN / Patch** (Delaunay aus den Punkten; npm `delaunator`) — alternatives Mesh. +- **Geschlossenes Gelände-Volumen** (Boden N m unter tiefstem Punkt) → gefüllte + Querschnitte beim Schnitt-Cut (DOSSIER `terrainVolume`/`terrainVolumeDepth`). + +### B.3 Origin-Shift (Pflicht) + +LV95-Koordinaten (~2.6e6) sprengen `float32`. Beim Import einmal +`shift = (eCenter, nCenter, zRef)` festlegen, **alle** Geometrien `−shift` rechnen, und +`shift` am Projekt persistieren (für Re-Import / Geo-Referenz / Norden). DOSSIER: +`origin_shift`/`shift_lv95`, plus „Auto-Zoom auf Import" (ROADMAP §11). Damit bleibt das +Modell metergenau und der Rückweg in echte LV95-Koordinaten (Export, weitere +swisstopo-Abfragen) ist `+ shift`. + +### B.4 Parzelle + Orthofoto + +- **Parzelle:** `identify(...cadastralwebmap..., returnGeometry=true, geometryFormat=geojson)` + → Polygon (LV95) → `−shift` → als geschlossene Polylinie auf Ebene **`01 Vermessung`**. + Attribute `number`/`egris_egrid` am Objekt/Projekt speichern. +- **Orthofoto:** WMTS `swissimage`-Kacheln über die Modell-Bbox stitchen → eine Textur, + als Material auf das Gelände-Mesh **oder** auf eine georeferenzierte Plane (DOSSIER: + `add_ortho_plane`, mit UV-Shift gegen Tile-Nähte). Für 3D ist `3857` einfacher, für + exakte 2D-Lage `2056`. + +### B.5 OSM-Overpass als weltweiter Fallback (Phase 4) + +Ausserhalb der Schweiz (oder wenn nur 2D-Footprints/Straßen reichen): DOSSIER hat einen +**Overpass-Importer** (`https://overpass-api.de/api/interpreter`, POST) mit 7 Kategorien +(Straßen/Gebäude/Wasser/Wasserläufe/Grün/Wege). Liefert OSM-Ways → Polylinien. Im +Browser identisch nutzbar (`fetch` POST). Overpass koordiniert in WGS84 → mit +`proj4`→LV95→`−shift`. Hinweis: Overpass-CORS ist beim Haupt-Server meist offen, kann +aber je nach Mirror variieren; ggf. anderen Mirror wählen. +[Overpass API](https://overpass-api.de/). + +### B.6 Was sich von DOSSIER **nicht** 1:1 portieren lässt + +- **Rhino-`_-Import`** für DXF/DWG/OBJ und **`_-MeshPatch`/Delaunay**-Commands gibt es im + Browser nicht → ersetzen durch JS-Parser/Algorithmen (OBJ: `three`-`OBJLoader`; + Delaunay: `delaunator`; Contours: `d3-contour`). +- **Filesystem-Cache neben der `.3dm`** → Browser: **IndexedDB**-Cache (Cache-API für + Kacheln). Passt zur ROADMAP-Phase 5 (IndexedDB-Persistenz). +- IFC-Import von swissBUILDINGS3D 3.0 → über **web-ifc** (ohnehin im Stack, Phase 4). + +--- + +## Teil C — SIA 416 (Flächen/Volumen) & SIA 421 + DOSSIER-Logik + +### C.1 SIA 416 — Flächen- und Volumenhierarchie + +**SIA 416:2003** „Flächen und Volumen von Gebäuden" ist die in der CH gültige Norm und +Berechnungsbasis für Kostenplanung/Flächennachweise. Sie kennt vier Bereiche: +**GSF** (Grundstück), **GF** (Geschossflächen), **AGF** (Aussengeschossflächen), **GV** +(Volumen). Maßgeblich ist die effektive Geometrie (keine fiktiven Zuschläge mehr). +Quellen: [SIA 416 Übersicht (siworks/DBV)](https://diebauherrenvertretung.ch/sia146/) · +[SIA-Shop 416/2003](https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product) · +[Flächenkennzahlen SIA 416 (Ginesta, PDF)](https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf). + +**Hierarchie & Formeln (verifiziert):** + +``` +GSF Grundstücksfläche +GF Geschossfläche = KF + NGF + ├─ KF Konstruktionsfläche (Wände/Stützen; tragend KFT + nicht tragend KFN) + └─ NGF Nettogeschossfläche = NF + VF + FF + ├─ NF Nutzfläche = HNF + NNF + │ ├─ HNF Hauptnutzfläche (zweckbestimmte Hauptnutzung: Wohnen, Büro …) + │ └─ NNF Nebennutzfläche (Lager, Bad/WC, Abstell-, Nebenräume) + ├─ VF Verkehrsfläche (Erschließung: Flure, Treppen, Lifte) + └─ FF Funktionsfläche (Gebäudetechnik: Heizung, Lüftung, Technik) +AGF Aussengeschossfläche (Balkone, Terrassen, gedeckte Aussenflächen) +GV Gebäudevolumen [m³] +``` + +| Abk. | Deutsch | Inhalt (Kurz) | +|---|---|---| +| GSF | Grundstücksfläche | Parzellenfläche (aus Kataster, Teil A.6) | +| GF | Geschossfläche | allseits umschlossene + überdeckte Grundrissflächen, geschossweise | +| KF | Konstruktionsfläche | Bauteile (Wände/Stützen), nicht begehbar; KFT tragend / KFN nicht tragend | +| NGF | Nettogeschossfläche | begehbare Fläche innerhalb der Umschließung = NF+VF+FF | +| NF | Nutzfläche | tatsächlich nutzbar = HNF+NNF | +| HNF | Hauptnutzfläche | zweckbestimmte Hauptnutzung | +| NNF | Nebennutzfläche | dienende Nebenräume (Lager, Bad, WC) | +| VF | Verkehrsfläche | horizontale/vertikale Erschließung | +| FF | Funktionsfläche | Gebäudetechnik | +| AGF | Aussengeschossfläche | Balkone/Terrassen u.ä. | +| GV | Gebäudevolumen | umbauter Raum [m³] | + +> **Versionshinweis:** Es kursiert eine Revision **SIA 416:2017** mit präzisierten +> Begriffen, in der Praxis wird aber breit weiter **416:2003** referenziert. Für unser +> Tool reicht die Klassifikation **HNF/NNF/VF/FF/GF/AGF + Bilanz**; die genaue Auflage +> nur als Label dokumentieren. Verbindliche Definitionen stehen im kostenpflichtigen +> Normtext (SIA-Shop) — die hier zitierten freien Quellen stimmen in der Struktur überein. + +### C.2 SIA 421 — Flächengliederung für Bewirtschaftung/Vermietung + +**SIA 421:2006** „Flächengliederung und Mengenangaben" (Korrigenda C1:2014) baut auf der +SIA-416-Systematik auf und **gliedert Flächen für Immobilien-Bewirtschaftung und +Vermietung** (Mietflächen, Nutzungseinheiten, Zuordnung von VF/FF zu Mietern). Relevant, +sobald wir **Mietflächen-/Bewirtschaftungs-Auswertungen** wollen (über den reinen +Architektur-Nachweis hinaus). Für den ersten Wurf **nicht zwingend** — SIA 416 reicht +für Flächennachweis und Raumschema. SIA 421 ist die natürliche Erweiterung, wenn +Property-Management-Features kommen. Quellen: +[SIA 421:2006 (PDF Inhalt)](https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang) · +[Korrigenda C1:2014 (PDF)](https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf). + +### C.3 Wie DOSSIER SIA macht (Vorlage für den Port) + +Quellcode: `rhino/elemente.py` (Räume) + `rhino/elemente_uebersicht.py` (Bilanz/CSV). +Kernpunkte, die wir 1:1 übernehmen: + +**Datenmodell pro Raum.** Ein Raum ist eine **geschlossene Outline-Curve** + ein +**Text-Stempel**. Klassifikation über das Feld `dossier_raum_sia` mit Werten +`{"", hnf, nnf, vf, ff, gf, agf}` (`_RAUM_SIA_KINDS`). Weitere Felder: `name`, `nummer`, +`funktion` (`wohnen|schlafen|bad|kueche|essen|flur|…`), `personen` (für +Personenbelegung/Brandschutz), Rundung, Stempel-Layout. + +**Flächen-/Umfangsberechnung** (`_raum_amp`): Fläche aus `AreaMassProperties` (≙ in JS +**Shoelace-Formel** über das Polygon), Umfang = Kurvenlänge, plus Zentroid für den +Stempel. Im Browser: Polygon-Fläche selbst rechnen (Shoelace), kein Mesh nötig — exakt +und schnell. Rundungsstufen (`_format_area`): `exakt|0.01|0.1|0.5|1` (z.B. `0.5` = +`round(a*2)/2`). + +**SIA-Bilanz** (`compute_sia_bilanz`, `scope = total | geschoss:`): summiert +Raumflächen je Klasse, dann: +``` +NF = HNF + NNF +NGF = NF + VF + FF (GF/AGF separat aggregiert, zählen nicht in NGF) +``` +(genau die Formeln aus C.1). Ergebnis je Geschoss + Total. + +**Farb-/Darstellungs-Konvention** (`_SIA_COLORS_HEX`, Pastell): HNF rot `#e8a8a8`, +NNF orange `#e8c498`, VF gelb `#e8d878`, FF hellblau `#a8c8e0`, GF grau `#d0d0d0`, +AGF hellgrün `#c0d8c0`. Umgesetzt als **regelbasierte Overrides** (`_build_sia_preset_rules`, +Preset „SIA-Raeume"): Bedingung `user_string == code` → Outline-Farbe + Solid-Hatch. +→ Passt 1:1 zur geplanten **Overrides-Engine** (ROADMAP §2c/§11). + +**Export** (`_cmd_export_raeume`, `_export_bilanz`): CSV, **Semikolon + UTF-8-BOM** +(CH/DE-Excel), Dezimal-Komma. Raumliste: Nummer; Name; Geschoss; Funktion; SIA; Fläche; +Fläche gerundet; Umfang. Bilanz: eine Spalte je Geschoss + Total, Zeilen je Kategorie. +→ Im Browser: Blob + Download (kein SaveFileDialog), gleiche CSV-Struktur. Optional +direkt `.xlsx` via `sheetjs`/`exceljs`. + +**Layer-Routing:** GF→`61_GF`, AGF→`62_AGF`, Rest→`60_RAEUME` (`_layer_path_for_raum_sia`) +— damit Geschossflächen-Outlines getrennt schalt-/exportierbar sind. Übersetzt sich auf +unsere Ebenen-Codes (`60 Räume`). + +**Was wir im Port besser/anders machen:** +- Fläche per **Shoelace** statt Rhino-Mass-Props (0 Deps). +- Bilanz **reaktiv** aus dem semantischen Modell (Zustand-Store) statt Doc-Scan. +- **Space = Slab-/Raum-Polygon mit `siaClass`** im Datenmodell (ROADMAP: + `Space { boundary, name }`), Bilanz als abgeleitete Sicht. + +--- + +## Teil D — Umsetzungsplan (Endpunkte · Libs · Phase) + +Reihenfolge orientiert sich an der ROADMAP (SIA = Phase 2, Geo = Phase 4) und an „größter +Nutzen zuerst, geringste Abhängigkeit zuerst". + +### Phase 2 — SIA-Räume (kein Netz, reine Logik) ⭐ +**Endpunkte:** keine. **Libs:** keine (Shoelace selbst), optional `exceljs`/`sheetjs` für +.xlsx. +1. `Space`-Modell: `{ boundary[], geschossId, name, nummer, funktion, siaClass∈{hnf,nnf,vf,ff,gf,agf}, personen }`. +2. `computeArea` (Shoelace) + Umfang; Rundungsstufen (`exakt|0.01|0.1|0.5|1`) wie DOSSIER `_format_area`. +3. `computeSiaBilanz(scope)` → `{hnf,nnf,nf,vf,ff,ngf,gf,agf,count,personen}` mit + `nf=hnf+nnf`, `ngf=nf+vf+ff`. +4. SIA-Farbpalette + Stil-Override (Outline-Farbe/Solid-Fill) in der Overrides-Engine. +5. Raumstempel-Renderer (Felder-Layout) + **CSV-Export** (Semikolon, UTF-8-BOM, Komma) + für Raumliste **und** Bilanz. +*Ergebnis:* SIA-416-Flächennachweis + Raumschema, Excel-kompatibel — vor jeder Geo-Arbeit nutzbar. + +### Phase 4a — Geo-Grundlage: Koordinaten + Standortabfrage +**Endpunkte:** `SearchServer` (Geocoding), `height` (Punkt-Z), `identify` +(Parzelle, `cadastralwebmap-farbe`). **Libs:** `proj4@2.20.9` (+ `@types/proj4`). +1. `proj4`-EPSG:2056-Def + Helfer `lv95↔wgs84`, `bboxLv95→bboxWgs84` (DOSSIER-Logik). +2. **Origin-Shift**-Mechanik + Persistenz am Projekt (`shift = bbox-Center`), Auto-Zoom. +3. Adresssuche → Zentrum; „Gelände-Höhe holen" (height); „Parzelle holen" → Polygon auf + Ebene `01 Vermessung` (+ EGRID/Nummer am Projekt). +*Ergebnis:* Projekt ist georeferenziert; Parzelle + Adresse + Geländehöhe vorhanden. + +### Phase 4b — Gelände-Mesh + Orthofoto +**Endpunkte:** STAC `swissalti3d` (COG `.tif`, 2056) + `profile.json`; WMTS `swissimage`. +**Libs:** `geotiff@3.0.5`, `three@0.185.0`, optional `fflate` (XYZ-Fallback), +`d3-contour`/`marchingsquares` (Höhenlinien), `delaunator` (TIN). +1. STAC-Query (bbox) → COG-Tiles; `geotiff.js` Range-Read → Grid; Tiles mergen. +2. Grid → `THREE.BufferGeometry` (DOSSIER `mesh_from_grid`/`merge_grids`); Sub-Sampling + auf globalem LV95-Raster; Normalen. +3. Optional: Höhenlinien (2D-Plan), TIN, geschlossenes Volumen (Schnitt-Füllung). +4. WMTS-`swissimage`-Kacheln → Textur auf Mesh/Plane (DOSSIER `add_ortho_plane`). +5. Geländeschnitt im 2D-Plan via `profile.json` entlang der Schnittlinie. +*Ergebnis:* echtes Gelände mit Orthofoto unter dem Gebäude; Geländeschnitte. + +### Phase 4c — Nachbargebäude + weltweiter Fallback +**Endpunkte:** 3D-Tiles `ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` (Anzeige) +**oder** STAC `swissbuildings3d_2` `.obj/.ifc` (Import); OSM `overpass-api.de`. +**Libs:** `@loaders.gl/3d-tiles@4.4.3` **oder** `3DTilesRendererJS`; `three`-`OBJLoader`; +`web-ifc` (für 3.0-IFC); `proj4`. +1. Kontext-Anzeige: 3D-Tiles in den Three-Scenegraph streamen (kein Download). +2. Bedarf an echter Geometrie (Verschattung/Abstand): STAC-OBJ-Tile → Mesh-Import → `−shift`. +3. Ausserhalb CH / nur 2D: Overpass-POST → Ways → Polylinien (Ebene `70 OSM`). +*Ergebnis:* Nachbarschaftskontext (CH 3D, weltweit OSM). + +### Querschnitt (alle Geo-Phasen) +- **Caching:** IndexedDB für STAC-Antworten + COG-Bytes + Kacheln (ersetzt DOSSIERs + Disk-Cache); Cache-Schlüssel = Tile-ID/URL. +- **Defensive HTTP:** Timeouts, Retry/Backoff, 402/429 abfangen, Größen-Limit pro Tile + (DOSSIER: 200-MB-Guard). +- **Attribution:** „© swisstopo" sichtbar einblenden, „© OpenStreetMap-Mitwirkende" bei OSM. +- **Worker:** GeoTIFF-Parsing + Mesh-Bau im Web-Worker (Comlink, ROADMAP-Stack), UI bleibt flüssig. + +### Empfohlener Library-Satz (npm, aktuell) +`proj4@2.20.9` · `geotiff@3.0.5` · `three@0.185.0` · `fflate` (XYZ) · +`@loaders.gl/3d-tiles@4.4.3` *oder* `3DTilesRendererJS` · `delaunator` · +`d3-contour` · `web-ifc` (im Stack) · `exceljs`/`sheetjs` (optional, .xlsx). + +--- + +## Quellen + +- GeoAdmin REST (height/profile/identify/find/search): https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html +- GeoAdmin Tech-Docs (Hub): https://docs.geo.admin.ch/ +- Identify Features: https://docs.geo.admin.ch/access-data/identify-features.html +- Search: https://docs.geo.admin.ch/access-data/search.html +- WMTS: https://docs.geo.admin.ch/visualize-data/wmts.html · https://wmts.geo.admin.ch/ +- WMS: https://docs.geo.admin.ch/visualize-data/wms.html +- 3D-Tiles: https://docs.geo.admin.ch/visualize-data/3d-tiles.html +- swissALTI3D: https://www.swisstopo.admin.ch/en/height-model-swissalti3d +- Switzerland in 3D / swissBUILDINGS3D: https://www.swisstopo.admin.ch/en/switzerland-in-3d +- Terms of use (FSDI / OGD): https://www.geo.admin.ch/en/general-terms-of-use-fsdi +- REFRAME Web-API: https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web +- EPSG:2056 Definition: https://epsg.io/2056 +- proj4js: https://github.com/proj4js/proj4js +- geotiff.js: https://github.com/geotiffjs/geotiff.js/ +- Three.js-Terrain aus GeoTIFF: https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/ +- WMTS EPSG:2056 Beispiel: https://codepen.io/geoadmin/pen/GZKEam +- Overpass API: https://overpass-api.de/ +- SIA 416 (Übersicht): https://diebauherrenvertretung.ch/sia146/ · https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product +- SIA 416 Flächenkennzahlen (PDF): https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf +- SIA 421:2006 (PDF) + Korrigenda C1:2014: https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang · https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf +- DOSSIER-Quellcode (Vorlage): `rhino/swisstopo.py`, `rhino/osm.py`, `rhino/elemente.py` (Räume/SIA), `rhino/elemente_uebersicht.py` (Bilanz/CSV) diff --git a/docs/research/tech-selection.md b/docs/research/tech-selection.md new file mode 100644 index 0000000..778b141 --- /dev/null +++ b/docs/research/tech-selection.md @@ -0,0 +1,197 @@ +# Technologie-Auswahl: Browser-basiertes BIM-Tool (DOSSIER-Port) + +> **Kontext:** Standalone, browser-basiertes BIM-Werkzeug (React + TypeScript + Three.js) als Port des DOSSIER Rhino-Plugins. Keine Server-Abhaengigkeit gewuenscht (alles client-side). Recherchestand: Juni 2026. +> +> **Leitprinzip:** Wo immer moeglich auf einem echten B-Rep-Geometriekernel (OCCT) aufbauen, weil ein BIM-Werkzeug exakte 2D-Ableitungen (Schnitte, verdeckte Kanten, Bemassung) braucht — das ist mit reinen Dreiecksnetzen nicht sauber loesbar. Mesh-Booleans (Manifold) als schnelle Ergaenzung fuer Importgeometrie und Vorschau. + +--- + +## 1. Geometriekernel im Browser (Solids + Booleans) + +Das ist das schwierigste und zugleich wichtigste Problem. Drei ernsthafte Optionen, die alle im Browser (WASM) laufen. + +### Optionen + +**A) opencascade.js (OCCT als WASM)** +Port des vollstaendigen OpenCASCADE-Kernels (OCCT) nach WebAssembly via Emscripten. Voller B-Rep-Kernel: NURBS-Flaechen, exakte boolesche Operationen, Fillets/Chamfers, STEP/IGES-Import/-Export, Meshing. TypeScript-Bindings vorhanden. Die neueren Versionen (V3-Linie) zielen explizit auf moderne Bundler. +- Repo: +- Doku: +- npm: +- **Lizenz:** OCCT steht unter **LGPL-2.1 mit OCCT-Exception** (seit 6.7.0). Kommerzielle Nutzung ohne Lizenzgebuehren/Royalties erlaubt, sofern man (a) sichtbar darauf hinweist, dass die Software OCCT nutzt, und (b) eine Kopie der OCCT-Lizenz mitliefert. Die Exception entschaerft das statische-Linking-Problem fuer Header/Templates. Quellen: , +- **Trade-offs:** Sehr grosse WASM-Binaries (zweistelliger MB-Bereich je nach Custom-Build), steile Lern- und API-Kurve (rohe OCCT-C++-API durchgereicht), Build-Pflege aufwendig. + +**B) replicad (Abstraktion ueber opencascade.js)** — *empfohlene Basis* +replicad ist eine schlanke, idiomatische TypeScript-Schicht ueber opencascade.js. Es liefert genau die High-Level-Bausteine, die ein BIM-Tool braucht: Sketches/Blueprints, Extrude/Revolve/Loft, Booleans, Fillet/Chamfer — und entscheidend: **HLR-Projektionen** (`drawProjection`) und 2D-Drawings mit SVG-Export. Laeuft per Design im **Web Worker** und gibt Dreiecksnetze an den Main-Thread fuer Three.js zurueck. +- Doku/Library-Guide: +- API: +- **Lizenz:** **MIT** (eigene Schicht) — der OCCT/LGPL-Hinweis gilt weiterhin fuer das eingebettete WASM. Repo: +- **Trade-offs:** Erbt OCCT-WASM-Groesse und -Robustheitsgrenzen; kleineres Team/Bus-Faktor als OCCT selbst. Man kann jederzeit „unter die Haube" auf rohes opencascade.js durchgreifen, wenn die Abstraktion nicht ausreicht. + +**C) Manifold (manifold-3d, WASM)** — *empfohlen als schnelle Mesh-Ergaenzung* +Geometrie-Bibliothek fuer topologisch robuste **Dreiecksnetze**. Bietet den (laut Autor) ersten garantiert mannigfaltigen Mesh-Boolean-Algorithmus — extrem schnell und robust gegen Randfaelle. Stark parallelisiert. +- Repo: +- npm: (aktuell v3.5.x, Juni 2026) +- **Lizenz:** **Apache-2.0** (sehr permissiv, ideal). Bestaetigt: +- **Trade-offs:** **Nur Meshes, kein B-Rep** — keine exakten NURBS-Flaechen, keine echten Fillets auf Krümmungen, kein STEP. Fuer ein BIM-Tool, das exakte Plaene/Schnitte ableiten will, alleine nicht ausreichend, aber unschlagbar fuer schnelle Booleans auf importierter Mesh-Geometrie und Live-Vorschau. **Wichtig:** unterstuetzt `slice(z)` und `project()` (siehe Abschnitt 3). + +**D) three-bvh-csg (nur erwähnt, nicht empfohlen als Kernel)** +Sehr schnelle CSG direkt auf Three.js-BufferGeometry (auf three-mesh-bvh). >100x schneller als BSP-basierte Three.js-CSG-Libs. Aber: erklaert selbst, dass Resultate „aufgrund numerischer Praezision nicht garantiert 2-mannigfaltig" sind und verweist fuer CAD-Robustheit ausdruecklich auf Manifold. +- Repo: +- Forum: +- **Trade-offs:** Gut fuer Live-Vorschau/visuelles Schneiden, ungeeignet als verlaesslicher Modellierkernel. + +### Empfehlung (Kernel) +**replicad (= opencascade.js) als primaerer B-Rep-Kernel im Web Worker; Manifold als schneller Mesh-Boolean-Pfad fuer Import-/Vorschaugeometrie.** Diese Zweiteilung deckt sowohl „exakte BIM-Geometrie + 2D-Ableitung" (OCCT) als auch „schnell + robust auf beliebigen Meshes" (Manifold) ab. three-bvh-csg nur, falls man interaktives Echtzeit-Schneiden visuell braucht. + +--- + +## 2. 2D-Ableitung aus 3D: verdeckte Kanten (HLR) + Schnittgenerierung + +Kernfrage des DOSSIER-Ports: aus 3D-Solids saubere 2D-Zeichnungen (sichtbare/verdeckte Kanten, Schnitte) erzeugen — im Browser. + +### Optionen + +**A) OCC HLRBRep via replicad `drawProjection` — empfohlen** +OCCT enthaelt zwei HLR-Algorithmen: `HLRBRep_Algo` (exakt, auf der echten B-Rep) und `HLRBRep_PolyAlgo` (auf polyederisierter Naeherung, schneller, aber polygonal). Quellen: , + +replicad macht genau das im Browser nutzbar: `drawProjection(shape, camera)` liefert ein Objekt mit **`{ visible, hidden }`** — getrennte sichtbare und verdeckte Kantenzuege, die man unterschiedlich stylen kann (z.B. verdeckt = gestrichelt). Konkretes Beispiel aus der Doku: + +```js +const { drawProjection, ProjectionCamera } = replicad; +const camera = new ProjectionCamera(corner).lookAt(center); +const { visible, hidden } = drawProjection(shape, camera); +// visible/hidden sind Drawings -> .toSVG() +``` +- Beispiel: +- Verwandte API: `makeProjectedEdges`, `ProjectionCamera`, `Drawing.toSVG()` / `toSVGPaths()` () +- **Das ist der entscheidende Grund, replicad/OCCT zu nehmen:** exakte verdeckte-Kanten-Berechnung auf echtem B-Rep ist mit Mesh-Tools nicht serioes machbar. +- **Trade-offs:** HLR ist rechenintensiv (deshalb Worker + Caching pro Ansicht/Kamera); `HLRBRep_Algo` exakt aber langsam, `PolyAlgo` schneller aber genaehert. + +**B) Schnitte (Sections) via OCCT** +Echte Schnitte ueber Schnitt mit einer Ebene/Halbraum (`BRepAlgoAPI_Section` bzw. Boolean mit Schnittkoerper) ergeben exakte Schnittkanten als B-Rep-Edges, die wiederum nach SVG/DXF gehen. In replicad ueber Booleans + Projektion abbildbar. + +**C) Manifold `slice()` / `project()` — schnelle Mesh-Variante** +`Manifold.slice(z)` gibt den Querschnitt parallel zur X-Y-Ebene auf Hoehe `z` als `CrossSection` (2D-Polygone, intern Clipper2); `Manifold.project()` die projizierte Aussenkontur. Quellen: , +- **Trade-offs:** liefert **keine** verdeckte/sichtbare-Kanten-Trennung und keine Innenkanten-Semantik wie HLR — nur die geometrische Schnitt-/Projektionskontur des Meshes. Gut fuer Plan-Schnittkonturen (siehe Abschnitt 3), ungenuegend fuer vollwertige Ansichts-Zeichnungen mit verdeckten Kanten. + +### Empfehlung (2D-Ableitung) +**HLR und Ansichts-Zeichnungen ueber replicad `drawProjection` (OCC HLRBRep) im Worker, mit Caching pro Kamera/Ansicht. Echte Schnitte ueber OCCT-Section-Boolean. Manifold `slice()` als schneller Pfad nur fuer reine Schnittkonturen (z.B. Plan-Cut auf Mesh-Importen).** + +--- + +## 3. Plan-Ansicht: Clipping bei `cutHeight` + +Verhalten: horizontaler Schnitt auf einstellbarer Hoehe (Grundriss), darüber Abschneiden, Schnittflaechen markieren. + +### Optionen + +**A) Three.js Clipping Planes (`clippingPlanes` / `localClippingEnabled`)** +Three.js bietet globale (`renderer.clippingPlanes`) und material-lokale (`material.clippingPlanes`) Schnittebenen; `renderer.localClippingEnabled` ist standardmaessig aus (Null-Kosten, bis aktiviert). Quellen: , +- **Pro:** GPU-seitig, dynamisch (Slider auf `cutHeight` = `plane.constant` aendern, kein Geometrie-Rebuild), sehr fluessig. +- **Contra:** Clipping schneidet nur visuell — es entstehen **offene** Querschnitte (keine Deckflaeche). Fuer „Schnittflaeche fuellen/markieren" braucht man entweder einen Stencil-Cap-Trick oder eine echte Schnittkontur (Abschnitt 2). `clipIntersection = true` kann Material-Reinitialisierung pro Frame und FPS-Einbrueche verursachen — moeglichst vermeiden. Quelle: + +**B) Object Culling / Sichtbarkeit nach Hoehe** +Elemente oberhalb `cutHeight` per Bounding-Box/Etagen-Metadaten ausblenden (`object.visible = false`). +- **Pro:** trivial, keine Shader-Kosten, nutzt BIM-Etagensemantik. +- **Contra:** grobkoernig (ganze Objekte, kein praeziser Schnitt mitten durch ein Bauteil). + +**C) Echte Schnittgeometrie pro Etage (OCCT/Manifold) fuer den 2D-Plan** +Fuer die exportierbare 2D-Grundriss-Zeichnung den echten Schnitt auf `cutHeight` rechnen (OCCT-Section bzw. `Manifold.slice(z)`), Schnittflaechen schraffieren (Abschnitt 6). + +### Empfehlung (Plan-Clipping) +**Hybrid:** Im 3D-Viewport **Three.js Clipping Planes** fuer das interaktive Abschneiden (Slider direkt auf `plane.constant`), kombiniert mit **Object-Culling** ueber Etagen-Metadaten fuer Grob-Performance. Schnittflaechen-Caps via Stencil-Technik. Fuer die **exportierbare** 2D-Grundriss-Zeichnung die **echte** Schnittkontur ueber OCCT/Manifold berechnen statt nur GPU-Clipping. `clipIntersection` meiden. + +--- + +## 4. Import: DWG/DXF, IFC, STL, OBJ, XYZ-Punktwolken + +### DXF / DWG +- **DXF lesen:** `dxf-parser` (gdsestimating) — robust, weit verbreitet, parst DXF-Strings zu JS-Objekten. Repo: . Zum reinen 2D-Anzeigen: `dxf-viewer` (vagran). +- **DWG lesen (binaer!):** `@mlightcad/libredwg-web` — LibreDWG nach WASM, parst **DWG** (und DXF) direkt im Browser/Node ohne Backend. Aktuell v3.x. Repo: , npm: + - **Achtung Qualitaet/Limits:** DWG-Parsing ist speicherintensiv (kann >2 GB RAM ziehen); libredwg-web erzwingt WASM-Heap-Limits, sehr grosse DWGs koennen scheitern. Im Default-Build sind DXF-Parsing und DWG-Schreiben deaktiviert, um die WASM-Groesse zu senken — DXF also lieber mit `dxf-parser` lesen. Quelle: + - **Lizenz:** LibreDWG ist **GPL-3.0** — das ist fuer ein proprietaeres Produkt heikel. Wenn das Produkt nicht GPL sein soll, DWG-Import entweder ueber einen separaten Out-of-Process-Konverter kapseln oder Nutzer bitten, vorab nach DXF zu exportieren. **DWG-Lizenzfrage vor Integration klaeren.** +- **Referenz-Implementierung:** `cad-viewer` (mlightcad) zeigt vollstaendigen browser-only DXF/DWG-Viewer/Editor. + +### IFC (BIM-Kern) +- **web-ifc (ThatOpen/engine_web-ifc):** IFC lesen/schreiben in JS „at native speeds" via WASM. De-facto-Standard fuer Open-BIM im Browser. Repo: , Doku: . **Lizenz: MPL-2.0** (datei-basiertes Copyleft, fuer proprietaere Apps i.d.R. unkritisch). Quelle: +- **ThatOpen Components + Fragments:** Hoehere Ebene — `components` (Tools für BIM-Apps), `fragments` (kompaktes Binaerformat auf Google FlatBuffers). Typisch: ~100 MB IFC -> ~10 MB Fragments, >10x schnelleres Laden; Konvertierung lauft worker-basiert. Doku: , Repo: . IfcImporter setzt web-ifc (>=0.0.72) voraus. + - **Empfehlung:** IFC einmal mit web-ifc parsen, in **Fragments** cachen, danach aus Fragments laden. + +### STL / OBJ / XYZ-Punktwolken +- **Standard-Three.js-Loader** decken alles ab: `STLLoader` (ASCII+Binaer), `OBJLoader`, `XYZLoader` (XYZ/XYZRGB -> BufferGeometry), `PCDLoader`. Quellen: , . Three.js ist **MIT**. +- **Punktwolken-Performance:** Naive Darstellung skaliert nicht — ~17 Mio. Punkte ruckeln deutlich. Strategien: Downsampling, **LOD/Culling**, Hintergrund-/Streaming-Laden. Fuer sehr grosse Wolken Out-of-Core-Octree-Renderer (z.B. Potree-Ansatz) erwaegen statt eines einzelnen `Points`-Objekts. Quellen: , + +### Empfehlung (Import) +**IFC: web-ifc + Fragments (ThatOpen).** **DXF: dxf-parser.** **DWG: libredwg-web — aber GPL-Lizenz vorab klaeren / kapseln.** **STL/OBJ/XYZ/PCD: native Three.js-Loader, mit LOD/Downsampling fuer grosse Punktwolken (Potree-Pattern bei Bedarf).** + +--- + +## 5. Vektor-Export: SVG -> PDF (Print) und DXF + +### SVG -> PDF +- **svg2pdf.js (yWorks) + jsPDF — empfohlen.** Reine JS-Loesung, laeuft im Browser, erhaelt **echte Vektoren** (kein Rasterisieren via html2canvas!), was fuer druckfaehige Plaene entscheidend ist. Integriert sich ueber `doc.svg(element, ...)`. Kompatibel mit jsPDF v2/v3/v4. Repo: . Lizenz: svg2pdf.js **MIT**, jsPDF **MIT**. + - **Trade-off:** SVG-Feature-Abdeckung ist sehr gut, aber nicht 100% — exotische Filter/Pattern koennen abweichen; Schraffuren als explizite Linien (statt CSS-Filter) exportieren erhoeht Treffsicherheit. +- **Alternative/ergaenzend:** `pdf-lib` (MIT) fuer Seitenmontage, Mehrseitigkeit, Metadaten, Zusammenfuehren — kann mit jsPDF-Output kombiniert werden. (`html2canvas`+jsPDF bewusst **vermeiden**, da Raster statt Vektor.) + +### DXF-Export +- **@tarikjabiri/dxf (dxfjs/writer) — empfohlen.** Moderner, in TypeScript geschriebener DXF-Generator fuer Node + Browser. Unterstuetzt u.a. **Blocks, Hatches, Insert, Image** — d.h. Schraffuren lassen sich als echte DXF-Hatches exportieren (wichtig fuer CAD-Weiterverarbeitung). npm: , Doku: , Repo: . Lizenz: MIT. + - Einfachere Alternative: `dxf-writer` (Vorlaeufer, weniger Features). + +### Empfehlung (Export) +**SVG -> PDF: svg2pdf.js + jsPDF (Vektor, nicht Raster), optional pdf-lib fuer Seitenmontage. DXF-Export: @tarikjabiri/dxf mit echten Hatch-Entities.** Interner Zwischenschritt: 2D-Geometrie als SVG-Paths halten (replicad `Drawing.toSVGPaths()`), daraus sowohl PDF als auch DXF erzeugen. + +--- + +## 6. Schraffuren / Muster in SVG/Canvas bei Massstab + +### Optionen & Erkenntnisse +- **SVG `` mit `patternUnits="userSpaceOnUse"`** ist der richtige Weg fuer massstaebliche Schraffuren: das Muster skaliert NICHT mit der Form (im Gegensatz zu `objectBoundingBox`), sondern bleibt im Weltkoordinaten-Raster — exakt, was man fuer „mm pro Linie im Plan" braucht. Dichte/Winkel ueber `patternTransform`. Quellen: , +- **Performance:** Pattern-Layer werden bei jedem Layout-/Zoom-Schritt neu gerechnet; `userSpaceOnUse` vermeidet teures Rescaling und ist hier zugleich der schnellere und der korrekte Weg. Quelle: +- **SVG vs. Canvas:** Fuer technische Zeichnungen ist SVG qualitativ klar ueberlegen (Vektor, exporttauglich nach PDF/DXF). Canvas wird erst bei sehr vielen einfachen Elementen schneller. Quellen: , +- **Skalierungs-Strategie:** Bei sehr dichten Schraffuren ueber grosse Flaechen kann die Linienzahl explodieren -> entweder als Pattern-Kachel rendern (eine Definition, vielfach referenziert, statt tausende Einzellinien) **oder** beim Export Schraffuren in echte Linien/Hatch-Entities aufloesen (DXF-Hatch, Abschnitt 5). + +### Empfehlung (Schraffuren) +**SVG `` mit `patternUnits="userSpaceOnUse"` als primaerer Renderpfad** (massstabskorrekt, exportierbar, performant durch Kachel-Referenzierung). Bei extrem grossen/dichten Flaechen Canvas-Overlay nur fuer die reine Bildschirm-Vorschau erwaegen; fuer Export immer SVG -> svg2pdf.js bzw. echte DXF-Hatches. + +--- + +## 7. WebGPU vs. WebGL + Worker-Offloading der WASM-Geometrie + +### Rendering: WebGPU vs. WebGL +- **Reifegrad:** Seit Three.js r171 (Sept. 2025) ist der **WebGPURenderer produktionsreif** mit `import * as THREE from 'three/webgpu'` und **automatischem WebGL2-Fallback** — kein eigener Fallback-Code noetig. Quelle: +- **Browser-Abdeckung:** Chrome/Edge 113 (Mai 2023), Safari 26.0 (Sept. 2025), Firefox 141 (Juli 2025). ~95% der Nutzer WebGPU-faehig, restliche ~5% bekommen WebGL2-Fallback. Quelle: +- **Performance (nuanciert):** Bei draw-call-lastigen Szenen (viele Bauteile/Etagen) gewinnt WebGPU deutlich (bei ~10'000 Draw-Calls ~50 FPS WebGPU vs. ~30 FPS WebGL); bei wenigen grossen Meshes kann WebGL noch gleichauf oder schneller sein. Compute-Shader (Punktwolken, Culling) sind ein WebGPU-Alleinstellungsmerkmal. Quellen: , + - **Vorsicht:** Es gibt weiterhin Szenarien, in denen WebGPU langsamer ist als WebGL — daher messen, nicht blind migrieren. Quelle: + +### Worker-Offloading der WASM-Geometrie +- **Pflicht, nicht optional:** OCCT/replicad-Berechnungen (Booleans, HLR, Section) gehoeren in einen **Web Worker**, sonst blockiert die UI. replicad ist genau dafuer gebaut (WASM im Worker, Mesh zurueck an den Main-Thread). Quelle: +- **Muster:** Worker laedt das (grosse) WASM einmal; Kommunikation via Comlink o.ae.; Geometrie als Transferable (ArrayBuffer) zuruecksenden, um Kopierkosten zu sparen. Manifold (WASM) ebenso im Worker betreiben; Manifold ist intern stark parallelisiert. + +### Empfehlung (Performance) +**Three.js `three/webgpu`-Renderer mit automatischem WebGL2-Fallback** (gratis Abwaertskompatibilitaet, Vorteil bei vielen Draw-Calls/Etagen). **Alle WASM-Geometrie (OCCT/replicad + Manifold) konsequent in Web Worker(n)**, Ergebnis als Transferables. WebGPU-Compute fuer Punktwolken-/Culling-Beschleunigung als spaeteres Optimierungs-Upside. Vor groesserer WebGPU-Optimierung mit der echten Szene benchmarken. + +--- + +## Empfehlung (Zusammenfassung) + +| Thema | Wahl | Warum | +|---|---|---| +| **Geometriekernel (Solids/Booleans)** | **replicad** (= opencascade.js/OCCT) im Worker; **Manifold** als Mesh-Boolean-Ergaenzung | Echter B-Rep-Kernel noetig fuer exakte BIM-Geometrie + 2D-Ableitung; Manifold (Apache-2.0) schnell+robust fuer Mesh-Importe/Vorschau | +| **2D-Ableitung (HLR/Schnitt)** | **replicad `drawProjection`** (OCC HLRBRep, liefert `{visible, hidden}`); OCCT-Section fuer Schnitte | Einziger seriöser Weg fuer verdeckte/sichtbare Kanten auf echtem B-Rep im Browser; Manifold `slice()` nur fuer reine Konturen | +| **Plan-Clipping (`cutHeight`)** | **Three.js Clipping Planes** + **Object-Culling** (Etagen); echte Schnittkontur (OCCT/Manifold `slice`) fuer Export | GPU-Clipping fluessig & dynamisch fuer Viewport; Culling fuer Grob-Performance; exakte Kontur nur fuer druckbaren Plan | +| **Import IFC** | **web-ifc + Fragments** (ThatOpen) | De-facto Open-BIM-Standard, native Speed, ~10x kleineres/schnelleres Fragments-Caching; MPL-2.0 | +| **Import DXF / DWG** | **dxf-parser** (DXF) / **libredwg-web** (DWG) | Bewaehrt & browser-only; **DWG = GPL-3.0 -> Lizenz vorab klaeren/kapseln**, RAM-Limits bei grossen Dateien | +| **Import STL/OBJ/XYZ/PCD** | **Native Three.js-Loader** + LOD/Downsampling | Out of the box (MIT); grosse Punktwolken brauchen Octree/Potree-Pattern | +| **SVG -> PDF (Print)** | **svg2pdf.js + jsPDF** (+ pdf-lib optional) | Echte Vektoren statt Raster -> druckfaehig; MIT-Lizenzen | +| **DXF-Export** | **@tarikjabiri/dxf** | TS, Browser-faehig, echte Hatch-/Block-Entities fuer CAD-Weiterverarbeitung; MIT | +| **Schraffuren/Muster** | **SVG `` mit `userSpaceOnUse`** | Massstabskorrekt (skaliert nicht mit Form), exportierbar, performant via Kachel-Referenz | +| **Rendering** | **Three.js `three/webgpu`** mit WebGL2-Fallback | Produktionsreif seit r171, ~95% Abdeckung, Vorteil bei vielen Draw-Calls; gratis Fallback | +| **WASM-Offloading** | **Web Worker** fuer OCCT/replicad + Manifold, Transferables | UI bleibt reaktiv; replicad ist dafuer gebaut; spart Kopierkosten | + +--- + +### Wichtigste Risiken / offene Punkte +1. **DWG-Lizenz (LibreDWG = GPL-3.0):** Vor Integration klaeren — sonst proprietaeres Produkt gefaehrdet. Option: separater Konvertierungs-Service oder DXF-Pflicht beim Import. +2. **OCCT-WASM-Groesse & Build-Pflege:** zweistellige MB; Custom-Build/Tree-Shaking und Lazy-Loading im Worker einplanen. +3. **HLR-Kosten:** `drawProjection` pro Ansicht cachen; ggf. `PolyAlgo` fuer schnelle Vorschau, `HLRBRep_Algo` fuer den finalen Plan. +4. **WebGPU nicht blind:** mit echter Szene benchmarken — es gibt Faelle, in denen WebGL noch fuehrt. diff --git a/docs/research/ux-patterns.md b/docs/research/ux-patterns.md new file mode 100644 index 0000000..85b37e2 --- /dev/null +++ b/docs/research/ux-patterns.md @@ -0,0 +1,590 @@ +# UX/UI-Patterns moderner Browser-CAD/BIM-Tools — Research & Leitplanken + +> Recherche für unser Browser-BIM (React + TS + Three.js, DOSSIER-Port, Wohnbau, +> CH/SIA-Kontext). Ziel: konkrete, **übernehmbare** Interaktions- und UI-Muster. +> Stand: 2026-06-29. +> +> Untersucht: **Arcol**, **Snaptrude**, **TestFit**, **Onshape** (Browser-CAD), +> **Vectorworks** (Resource/Navigation-Modell), **Figma** (Canvas-Interaktion, +> Inspector). Querschnitt: Snapping/Inferencing, Grip-Editing, perceived +> performance, Command-Palette, Onboarding. +> +> Jeder externe Claim ist mit Quelle verlinkt. Am Ende: +> **„Leitplanken für unsere UI"** — priorisierte Empfehlungen. + +--- + +## 0. Kurzfazit (TL;DR) + +Die ganze Klasse moderner Browser-CAD/BIM-Tools konvergiert auf ein **gemeinsames +Set von Mustern**, das wir fast 1:1 übernehmen sollten: + +1. **Ein Modell, viele synchrone Sichten** (2D-Plan ⇄ 3D ⇄ Daten/Sheets), Änderung + in einer Sicht propagiert sofort in alle anderen. (Arcol, Snaptrude) +2. **Kontextuelle UI** statt voller Werkzeugleisten: Buttons/Felder erscheinen nur, + wenn die aktuelle Auswahl sie erlaubt. (Arcol, Onshape, Figma) +3. **Drei-Zonen-Layout**: links Navigator/Layer-Baum, Mitte Canvas + schwebende + Tool-Palette, rechts Inspector. (Figma, Vectorworks, Onshape) +4. **Aggressives Snapping/Inferencing** mit Live-Glyphen + Modifier zum + Unterdrücken. (Onshape) — für Maus-im-Browser unverzichtbar. +5. **Direkte Manipulation per Grips** statt Dialogen. (Figma, DOSSIER-Backlog) +6. **Perceived performance** über Skeletons, optimistic UI und gescopte + Ladezustände — im Browser-3D-Kontext ein Differenzierungs-Hebel. +7. **Command-Palette (Ctrl/Cmd-K)** als Discovery- und Speed-Layer. +8. **Onboarding via „learn by doing"** an einem mitgelieferten Sample-Projekt + + progressive disclosure. + +Unsere bestehende Architektur (semantisches Modell als Single Source of Truth, +abgeleitete Sichten, Vectorworks-Terminologie) ist exakt der richtige Unterbau für +diese Muster — die meiste Arbeit liegt in der **UI-Schicht**, nicht im Datenmodell. + +--- + +## 1. Gesamtlayout & Navigator-/Layer-Panels + +### 1.1 Was die Tools machen + +**Figma** strukturiert die Fläche in vier Zonen: eine Toolbar, **zwei Panels** und +einen scrollbaren Canvas. Links das **Navigation-Panel** mit Layern und Pages, +rechts das **Properties-Panel**; der Layer-Baum „enthält und organisiert alle +Elemente auf dem Canvas … und zeigt, wie Elemente verbunden sind" +([Figma: left sidebar](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar), +[Figma: Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)). + +**Vectorworks** trennt sauber zwei Konzepte, die für uns 1:1 relevant sind: +- Die **Navigation Palette** gibt Zugriff auf *Classes, Design Layers, Sheet + Layers, Viewports, Saved Views, References* — jeweils als eigener Tab mit Liste. + Sichtbarkeit wird per Klick in einer **Visibility-Spalte** gesetzt; Doppelklick + **aktiviert** einen Layer/eine Class. Die Zeichenfläche bleibt nutzbar, während + die Palette offen ist + ([VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)). +- Paletten sind **andockbar/ein-/ausblendbar pro Workspace** + ([VW Palettes & Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)). + +**Arcol** baut beim Modellieren einen **Model Tree** im Menü auf — pro +BIM-Komponente wächst der Baum mit +([AEC Magazine: Arcol BIM in browser](https://aecmag.com/bim/arcol-bim-cloud-browser/)). + +**Onshape** zeigt links den **Feature-/Assembly-Baum** (parametrische Historie: +Sketches, Features, Mates) — die Bauhistorie ist die primäre Navigation +([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)). + +### 1.2 Übernahme für uns + +Unser Dokumentmodell hat **zwei unabhängige Achsen** (siehe ROADMAP §2c): +**Zeichnungsebenen** (Geschosse + Schnitte/Ansichten) und **Ebenen** +(Grafik-Kategorien-Baum `00 Raster … 80 Plangrafik`). Das mappt fast wörtlich auf +das Vectorworks-Navigations-Modell: + +- **Linke Sidebar, getabbt** wie die VW-Navigation-Palette: + - Tab **„Geschosse / Ansichten"** (= unsere Zeichnungsebenen): EG/1OG/…, + Schnitte, Ansichten. Mit **Visibility-Toggle**, **Lock**, und **Doppelklick = + aktiv setzen** (welches Geschoss editiert wird). + - Tab **„Ebenen"** (= Grafik-Kategorien-Baum, in *jedem* Geschoss gleich): Baum + mit Code, Name, Farb-Swatch, Linienstärke, Visibility, Hatch. Pro Ansicht + schaltbar (das ist genau VWs „Sichtbarkeit pro Viewport/Saved View"). + - Tab **„BIM-Tree / Elemente"** (aus DOSSIER-Backlog §11: *Element-Übersicht + Geschoss→Kind→Element, Suche, Shift-Klick = Zoom*). Das ist unser Pendant zu + Arcols Model Tree + Onshapes Feature-Baum. +- **Visibility-Spalte als Erstklass-Interaktion** (VW-Muster): Auge-Icon je Zeile, + Klick togglet sofort, kein Dialog. (Wir haben bereits `EyeIcon.tsx` — das ist die + Keimzelle.) +- **Wichtig:** Geschoss wird **im 3D *oder* Plan** betrachtet, *keine* getrennten + Daten — der View-Umschalter (siehe §2) gehört in die Geschoss-Auswahl, nicht in + separate Dokumente. + +> **Anti-Pattern vermeiden:** Vectorworks selbst leidet unter +> **Paletten-Wildwuchs** (viele frei schwebende Fenster). Für ein fokussiertes +> Wohnbau-Tool: **feste 3-Zonen-Shell** (links Navigator, rechts Inspector), nicht +> N frei schwebende Palettenfenster. Figmas Striktheit schlägt VWs Flexibilität für +> unsere Zielgruppe (Architekt:innen, die schnell ein EFH zeichnen wollen). + +--- + +## 2. 3D ⇄ Plan-View-Umschaltung (das Herzstück) + +### 2.1 Was die Tools machen + +- **Snaptrude:** Nutzer arbeiten **in 2D *und* 3D gleichzeitig**; Änderung in einer + Sicht spiegelt sich automatisch in der anderen. Objekte sind **nach Geschossen + klassifiziert**, was es erlaubt, „3D-Geometrie zu zeichnen, während man in einer + 2D-Grundriss-Ansicht arbeitet". Push-an-einer-Fläche „fühlt sich an wie eine + Linie ziehen", berechnet aber sofort Flächen/BIM-Daten neu + ([ArchDaily: Snaptrude](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work), + [Snaptrude](https://www.snaptrude.com/)). +- **Arcol:** „Every view is 3D in Arcol" — und **Boards** (Präsentations-Layouts) + sind **live-synced**: ändert sich das Gebäude, aktualisieren sich die Layouts + automatisch (kein statischer PDF-Export) + ([AEC Magazine: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)). +- **TestFit:** explizites **Expand/Collapse von Optionen** — man klappt Varianten + auf zum Vergleichen und wieder zu, um sich aufs Detail-Editieren im Canvas zu + konzentrieren („smoother flow between setting up a site, reviewing options, and + refining a design") + ([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)). + +### 2.2 Übernahme für uns + +Das ist **genau unsere Kern-Architektur** (ROADMAP §2c: „Ansichtstyp = Kamera- +Projektion + optionaler Schnitt"). Konkrete UI-Muster: + +- **View-Switcher als segmented control** direkt am Canvas (oben links oder + oben mittig): `3D | Grundriss | Schnitt | Ansicht`. Plan-View = orthogonale + Top-Kamera + Clipping-Ebene auf `okff + schnitthöhe` (steht bereits so im Modell). +- **Kein** Moduswechsel der *Daten*, nur der **Kamera + Schnitt** — visuell als + weicher Übergang (Kamera-Animation) kommunizieren, damit Nutzer Orientierung + behalten (Snaptrude/Arcol-Gefühl: „dieselbe Sache aus anderem Winkel"). +- **Optional, stark differenzierend:** **Split-View** (3D links, Plan rechts) wie + Snaptrudes „2D + 3D simultan". Da unsere Sichten ohnehin reaktiv aus *einem* + Modell abgeleitet werden (Zustand-Store → SVG-Plan + Three-Scene), ist Split-View + technisch billig und ein Wow-Moment im Onboarding. +- **Kamera-Presets** (aus DOSSIER-Backlog): Kardinal N/O/S/W, Iso-Oktanten, + **Norden-Rotation** (CH/Swisstopo-Georeferenz) — als kleine Würfel-/Kompass-Gizmo + oben rechts im 3D (ViewCube-Muster, bekannt aus Onshape/CAD allgemein + ([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm))). +- **Drawing Layers (Schnitte/Ansichten) sind „Saved Views"**: Auswahl in der linken + Sidebar = Kamera + Schnitt + Maßstab + Layer-Kombination springt an (VW „Saved + Views" / DOSSIER „Ausschnitte"). Das ersetzt Ordner-Wildwuchs bei 50+ Ansichten. + +--- + +## 3. Zeichnen & Editieren: Snapping, Inferencing, Grips, Tool-Paletten + +### 3.1 Snapping / Inferencing — das wichtigste Detail im Browser + +**Onshape** ist hier die Referenz (sehr konkret dokumentiert, +[Onshape Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)): + +- **Inference-Typen:** horizontal, vertikal, **midpoint**, parallel, coincident, + Ausrichtung zum Origin/zu anderen Entities, Tangente, Perpendikular. +- **Visuelles Feedback:** + - **gelbe Highlights** auf Vertices/Mittelpunkten beim Hovern, + - **orange gestrichelte Linie** zeigt eine vorgeschlagene H/V-Ausrichtung, + - **orange Highlight** verwandter Geometrie beim Ziehen (relationales Feedback). +- **Steuerung:** Linksklick **akzeptiert** die vorgeschlagene Bedingung; **Shift + gedrückt halten unterdrückt** Inferencing temporär (loslassen = wieder an). +- **„Wake-up"-Inferences:** kurzes Verweilen über einer Geometrie „weckt" deren + Bezugslinien (z. B. erst Mittelpunkt antippen, dann woanders zeichnen → bekommt + Ausrichtung zu diesem Mittelpunkt). +- **Post-hoc:** vorhandene Geometrie ziehen triggert erneut Inferencing (Center + eines Kreises vertikal zum Origin ziehen → fügt automatisch vertikale Constraint). + +**Constraint-Sichtbarkeit:** Constraint-Icons sind farbcodiert (blau = +externe/Referenz, weiß = intern) und ein-/ausblendbar +([Onshape Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)). + +### 3.2 Übernahme für uns (Snapping) + +Für ein Maus-bedientes Browser-Tool ist **gutes Snapping der Unterschied zwischen +„Spielzeug" und „Werkzeug".** Konkret: + +- **Snap-Targets** (Wohnbau-relevant): Wand-Endpunkte/Achsen, Wand-Mittelpunkte, + Rechtwinklig/Parallel zu bestehender Wand, **Raster** (Achsraster `00 Raster`), + Öffnungs-Achsen, vorhandene 2D-Linien-Endpunkte, Schnittpunkte. +- **Feedback exakt wie Onshape übernehmen:** Snap-Glyph am Cursor (● + Endpunkt, △ Mitte, ⟂ rechtwinklig), **gestrichelte Hilfslinie** für + Achsen-Alignment, Hover-Highlight des Snap-Ziels. +- **Modifier:** **Shift unterdrückt Snapping** (Onshape-Konvention) — Nutzer + erwarten das bereits aus anderen Tools. Zusätzlich **Ortho-Modus** (z. B. Shift + für 0/45/90° beim Linienziehen — Figma-Konvention) sauber davon trennen oder per + Toggle. +- **Live-Maßeingabe beim Zeichnen** (CAD-Standard, auch Onshape): während des + Ziehens Länge/Winkel tippbar (Tab zwischen Feldern). Das ersetzt nachträgliches + Dimensionieren und ist für Architekt:innen Pflicht. +- Wir brauchen **kein** volles Constraint-Solver-System wie Onshape (mechanisches + parametrisches CAD). BIM-Wände sind achs-basiert; **Inferencing beim Setzen** + genügt, persistente geometrische Constraints sind Overkill für Wohnbau. + +### 3.3 Grip-Editing / direkte Manipulation + +- **Figma:** Auswahl zeigt Bounding-Box mit **Resize-Handles**; ziehen + manipuliert direkt; Smart-Guides/Maße erscheinen relativ zu Nachbarn beim Bewegen + ([Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)). +- **Snaptrude:** Push/Pull an Flächen als primäre 3D-Edit-Geste + ([ArchDaily](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)). +- **DOSSIER-Backlog (§11)** listet **Grip-Editing** (Wand-Endpunkte, + Schnitt-Symbole im Plan) explizit als Aufwand L, Phase 3–4 — und das **9-Punkt- + Objekt-Info** (lesen + verschieben/skalieren/rotieren direkt). + +**Übernahme:** Grips sind der wichtigste „pro feel"-Hebel. +- **Wand-Endpunkt-Grips** im Plan *und* 3D, mit Snapping (s. o.) und Live-Maß. +- **Öffnungs-Grips** (Position entlang Wand, Breite) — Host-Beziehung bleibt erhalten. +- **Schnittlinien-Grips im Plan** (Schnittlinie ziehen → Schnitt-Ansicht + re-deriviert) — das ist Grip-Editing über die Sicht-Grenze hinweg, ein starkes + Differenzierungsmerkmal. +- Selektion → **bounding handles** (Figma-Muster) für 2D-Plangrafik (Linien, + Rechtecke, Text). + +### 3.4 Tool-Palette & kontextuelle Werkzeuge + +**Kontextuelle UI ist das durchgehende Muster:** +- **Arcol:** „buttons appear only when they can be used" — Loft/Sweep erscheinen bei + Auswahl zweier Sketches, Boolean bei zwei Extrusions + ([AEC: Arcol sneak peek](https://aecmag.com/bim/arcol-a-sneak-peek/)). +- **Onshape:** Sketch-Toolbar erscheint beim Betreten des Sketch-Modus; Tools in + Gruppen (vertikale Trennlinien), Dropdown-Pfeile für Varianten; Dialoge mit + **blau hinterlegtem Feld**, das eine Auswahl im Graphics-Bereich verlangt + ([Onshape sketch toolbar](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm), + [Onshape Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)). +- **Figma:** schmale Bottom-/Top-Toolbar mit den Kern-Tools; alles Weitere + kontextuell rechts. + +**Übernahme:** +- **Schlanke Tool-Palette** (Figma-artig), gruppiert nach unserer Domäne: + *Wand · Tür/Fenster · Decke · Treppe · Dach · Raum* (BIM) und *Linie · Polylinie + · Rechteck · Kreis · Bogen · Text · Bemaßung* (2D auf `80 Plangrafik`). +- **Modus-bewusste Tools:** im Grundriss andere Defaults als im 3D; im + Schnitt/Ansicht nur Annotation/2D-Tools. +- **Contextual action bar bei Auswahl** (Arcol-Muster): selektiere eine Wand → + schwebende Mini-Toolbar „Tür einsetzen / Fenster / Wandtyp / verschneiden". + Selektiere zwei Wände → „verschneiden / verlängern". +- **Aktives Tool sticky + ESC bricht ab**, Leertaste = Pan, Scroll = Zoom + (Figma/CAD-Konventionen — Nutzer bringen Muskelgedächtnis mit). +- **LoD-bewusste Tool-/Stil-UI** (DOSSIER-Backlog: *zeigt nur passende Controls je + Geometrie-Typ*) — keine Füll-Optionen bei 3D-Auswahl. Deckt sich mit Figmas + „controls appear based on layer type". + +--- + +## 4. Property-/Inspector-Panel + +### 4.1 Was die Tools machen + +**Figma — rechte Sidebar** (für uns das Vorbild, +[Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)): +- Tabs **Design / Prototype** (bei Edit), **Inspect / Properties** (bei View-only). +- Kategorien: **Alignment/Rotation/Position → Dimensions → Constraints/Layout → + Appearance (Fill, Stroke, Effects) → Export**. +- **Controls erscheinen je Layer-Typ** (kontextuell). +- **Dev-Mode/Inspect** liefert konkrete Werte + Abstände zwischen Objekten + Code. + +**Arcol — rechtes Panel** zeigt **Gebäude-Metriken** kontextuell: Geschossfläche, +Anzahl Geschosse, GFZ/FAR, Unit-Count, Standortfläche, Kostenschätzung +([Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)). + +**TestFit — ein zentrales Parameter-Panel:** „alle Schlüsselparameter an einem +Ort" (Unit-Counts, Parkplatz-Ziele, Gebäudegrößen-Limits) → sofortige Wirkung auf +generierte Optionen +([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)). + +**Onshape — Feature-Dialoge:** Erstellen/Editieren über Dialoge mit Pflicht- +Selektionsfeldern (blau) +([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)). + +### 4.2 Übernahme für uns + +- **Rechter Inspector, kontextuell nach Element-Typ** (Figma-Muster): + - **Wand** → Wandtyp (→ WallType-Bibliothek), Referenzlage mid/left/right + (DOSSIER-Backlog), Höhe, Achs-Endpunkte (9-Punkt/Maße), Stil-Overrides. + - **Tür/Fenster** → Breite/Höhe, Brüstung, Schwenkbogen/Anschlag, Detailgrad, + Symbol. + - **Decke/Slab** → SlabType, UK/OK-Override, Aussparungen. + - **Raum** → SIA-416-Kategorie (HNF/NNF/VF/FF/GF/AGF), Fläche (read-only, + berechnet), Stempel-Felder. + - **2D-Element** → Linienstil (→ Line Manager), Hatch (→ Hatch Manager), Farbe. +- **Sektionen kollabierbar** (Figma) — Wohnbau-Inspector kann lang werden; + Default-Sektionen offen, Fortgeschrittenes (Overrides) zugeklappt + (= progressive disclosure, s. §7). +- **Mixed-value-Handling bei Mehrfachauswahl** (Figma): bei Mehrfachauswahl + abweichende Werte als „Mixed/—" zeigen, gemeinsames Editieren erlauben. Wichtig + z. B. „alle EG-Wände auf Wandtyp X". +- **Live-Metriken-Block** (Arcol-Muster) — selbst im Wohnbau wertvoll: + Bruttogeschossfläche, **SIA-416-Bilanz**, Raumzahl, Volumen. Im Inspector wenn + nichts selektiert ist = „Projekt-Übersicht" (vgl. Figmas Canvas-Level-Optionen + bei leerer Auswahl). +- **Read-only-Felder klar markieren** (berechnete Flächen/Volumen) vs. editierbar. + +--- + +## 5. Resource-Manager (Bibliotheken) + +### 5.1 Was Vectorworks macht — unser direktes Vorbild + +Der **Resource Manager** ist „ein zentraler Ort für Assets" (Symbole, Linientypen, +Texturen, Materialien …) +([VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)): +- **Zwei-/Drei-Pane-Modell:** **File-Browser** (offene Dateien, Favoriten, + VW-Libraries, User-/Workgroup-Libraries) → **Resource-Viewer** (Ressourcen der + gewählten Datei) → optional **Preview/Metadaten** + ([VW File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm), + [VW Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)). +- **Organisation mehrdimensional:** nach Quelle, **nach Typ** (Dropdown-Filter), + nach Ordnerstruktur. +- **Ansichten:** Thumbnails / List / Thumbnails-List; **Suchfeld** mit Filtern. +- **Resource Selector**: dieselbe Bibliothek erscheint **in Dialogen** und zeigt + dort nur **kontextuell passende** Ressourcen. + +### 5.2 Übernahme für uns + +Unsere ROADMAP §2d definiert bereits **Line Manager / Hatch Manager / Component +Manager**, mit Verweis-per-id-Architektur (Components → Hatches → LineStyles). Das +Vectorworks-Modell passt perfekt: + +- **Ein gemeinsames Resource-Browser-Pattern** für alle Bibliotheken (Linienstile, + Schraffuren, Components/Baustoffe, später Wand-/Öffnungs-Stil-Kataloge, + Material-PBR, Raumstempel-Layouts). Eine wiederverwendbare React-Komponente, + parametrisiert nach Ressourcentyp. +- **Zwei Erscheinungsformen** (wie VW): + 1. **Manager-Ansicht** (großes Panel/Modal) zum Anlegen/Editieren/Duplizieren. + 2. **Inline-Resource-Selector** im Inspector — beim Setzen eines Wandtyps/Hatch + öffnet sich ein kompakter Picker mit Thumbnails, gefiltert auf den passenden + Typ. (Figma macht das analog mit „Styles/Variables".) +- **Thumbnails sind im CAD-Kontext kritisch**: Hatch-Vorschau, Component- + Schichtaufbau, Linienstil-Strich als gerenderte Mini-Previews. +- **Zentrale Änderung propagiert** (unsere id-Referenz-Architektur): Component-Farbe + ändern → alle Wände mit diesem Component aktualisieren live. Das ist Arcols + „single source of truth" auf Ressourcen-Ebene. +- **Cross-Projekt-Presets/Favoriten** (DOSSIER-Backlog, LocalStorage; VW-Favoriten): + „einmal speichern, überall nutzen". + +--- + +## 6. Perceived Performance (gefühlte Geschwindigkeit) + +Browser-3D + WASM-Geometrie (web-ifc, OpenCascade) + HLR-Projektion = **echte +Latenz** an mehreren Stellen. Gefühlte Performance ist hier ein +Differenzierungs-Hebel gegenüber schwerfälligem Revit/ArchiCAD. + +### 6.1 Belegte Muster + +- **Skeleton-Screens** lassen Apps **20–30 % schneller** wirken als Spinner bei + identischer realer Ladezeit + ([LogRocket: skeleton screens](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/), + [UI Deploy](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)). +- **Indikator zur Situation passen:** Spinner für kurze Waits, Skeleton für + Content, Progress-Bar für messbare Operationen, **optimistic UI** für + „instant-feeling" Aktionen + ([Onething: skeleton vs spinner](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)). +- **Optimistic UI**: UI sofort aktualisieren, Server-Bestätigung abwarten, nur bei + Fehler zurückrollen — ideal für häufige, risikoarme Aktionen + ([Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)). +- **Verzögerung vor Indikator (100–200 ms):** schließt die Operation vorher ab, + gar kein Indikator → kein Flackern + ([Onething](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)). +- **Gescopte Ladezustände** (React Suspense / Next loading.tsx): nur der betroffene + Bereich lädt, der Rest bleibt interaktiv; `aria-busy`, Live-Regions, + reduced-motion respektieren + ([LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)). + +### 6.2 Übernahme für uns (konkret) + +- **Optimistic Model-Edits:** Geometrie-Mutation (Wand ziehen, Tür setzen) **sofort** + im Zustand-Store + 3D anzeigen; **schwere Booleans/HLR im Web Worker** (Comlink, + bereits geplant) nachziehen. Wand erscheint sofort, die *exakte* verschnittene + Öffnung/Schnittlinie „schärft nach". UI bleibt flüssig. +- **Progressive Plan-Generierung:** SVG-Grundriss zuerst grob (Achsen/Linien), + Schraffuren/Symbole nachladen — Skeleton/„low-detail first" statt Spinner. +- **HLR-Schnitte (Risiko #4):** Worker + Caching (ROADMAP §6). UI: **Skeleton der + Schnitt-Ansicht** + „berechne verdeckte Kanten…" mit Progress, restliche App + bleibt nutzbar (gescopter Ladezustand). +- **Delay-then-show** für alle Worker-Tasks (150 ms-Schwelle), sonst Flicker beim + schnellen Editieren. +- **Three.js-Disziplin:** stabile 60 fps beim Orbit/Pan ist selbst „perceived + performance" — instanziertes Rendering, Frustum-Culling, LoD für ferne Geometrie + (deckt sich mit ROADMAP-Phase-7-Performance-Härtung). Lieber 60 fps bei grober + Geometrie als ruckelnde Präzision. +- **Auto-Save-Status klar, unaufdringlich** kommunizieren („Gespeichert"/„Speichern…"), + optimistic — nie blockierend (Figma-Muster). + +--- + +## 7. Onboarding & Discoverability + +### 7.1 Belegte Muster + +- **Progressive Disclosure:** zuerst nur Essenzielles zeigen, Komplexität schrittweise + enthüllen — reduziert kognitive Last; drei Typen: step-by-step, conditional, + contextual + ([IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure), + [UXPin](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)). +- **„Learn by doing" an Sample-Dokument:** Grammarly startet Nutzer mit einem + Beispiel-Dokument mit Fehlern; Hotspots/Tooltips führen durch Features + ([Userpilot: onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)). +- **Stufenweises Aufdecken fortgeschrittener Features** (Asana: erst Projekt + anlegen, später Dependencies/Kanban/Gantt) + ([Userpilot: progressive disclosure](https://userpilot.com/blog/progressive-disclosure-examples/)). +- **Command-Palette als Discovery-Layer:** durchsuchbare Befehlsliste hilft, Features + zu entdecken — „incredible effect on exploration and feature discoverability", + besonders für neue/seltene Nutzer + ([Mobbin: command palette](https://mobbin.com/glossary/command-palette), + [Untitled UI: command menus](https://www.untitledui.com/components/command-menus)). +- **Arcol** wirbt explizit mit „low barrier to entry, gentle learning curve … clean, + intuitive, requires minimal training" + ([AEC: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)). + +### 7.2 Übernahme für uns + +- **Mitgeliefertes Sample-Projekt** (wir haben bereits `sampleProject.ts`!) als + Onboarding-Bühne: ein kleines EFH, fertig modelliert. Nutzer **manipuliert echtes + Modell** statt leerem Canvas (Grammarly-Muster). Erste Geste: „zieh diese Wand" + → sieht 3D + Plan live mitlaufen (unser Kern-Wow). +- **Progressive Disclosure im Inspector & Tool-Palette:** Default zeigt + Wohnbau-Essenz (Wand/Tür/Fenster/Decke/Raum). Fortgeschrittenes (Prioritäts- + Verschneidung, Overrides, Detailgrade, Section-Styles) **zugeklappt / hinter + „Erweitert"**. Das passt zu unserem radikalen Wohnbau-Fokus. +- **Contextual coachmarks** statt langem Tutorial: beim ersten Selektieren einer + Wand ein kleiner Tooltip „Endpunkt ziehen zum Verlängern, Doppelklick für Wandtyp". +- **Command-Palette (Ctrl/Cmd-K)** — siehe §8 — doppelt als Onboarding: alle + Werkzeuge/Befehle durchsuchbar = lebende Feature-Liste. +- **Tastatur-Kürzel sichtbar machen** (in Tooltips, in der Palette) — schult + beiläufig pro Workflows. + +--- + +## 8. Command-Palette & Tastatur + +### 8.1 Belegte Muster + +- **Ctrl/Cmd-K** ist die De-facto-Konvention (Linear, Figma [Cmd-P], Notion, + Vercel, Raycast, Slack, Superhuman); VS Code nutzt Cmd-Shift-P + ([Mobbin](https://mobbin.com/glossary/command-palette), + [Superhuman: command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)). +- Zwei Haupt-Use-Cases: **Navigation/Suche** und **Shortcuts/Quick Actions** + ([Outdraw Academy: command palette](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)). +- Trigger kann **sichtbar** (Button/Suchleiste) oder nur per Shortcut sein — + für Discovery besser **auch sichtbar** + ([Mobbin](https://mobbin.com/glossary/command-palette)). + +### 8.2 Übernahme für uns + +- **Cmd/Ctrl-K-Palette** für: Werkzeug aktivieren („Wand", „Tür"), Ansicht + springen („Grundriss EG", „Schnitt A-A"), Ressource öffnen („Hatch Manager"), + globale Aktionen („Norden rotieren", „PDF exportieren", „SIA-Bilanz"). +- **Sichtbarer Trigger** (Such-/Befehlsfeld in der Top-Bar) für Entdeckung + + Shortcut für Speed. +- **Konsistente, dokumentierte Shortcuts** (Figma-Disziplin): W=Wand, T=Tür, + L=Linie, Space=Pan, Scroll=Zoom, Shift=Snap aus, Esc=Abbrechen, G=Grundriss-Toggle. + In Tooltips + Palette anzeigen. + +--- + +## 9. Leitplanken für unsere UI (priorisierte Empfehlungen) + +> Sortiert nach **Hebel × Aufwand**. „P#" = grobe Phasen-Zuordnung zur ROADMAP. + +### A. Sofort / Fundament (Phase 0–1) — billig, prägt alles + +1. **Feste 3-Zonen-Shell.** Links **Navigator** (Tabs: *Geschosse/Ansichten · Ebenen + · BIM-Tree*, je mit Visibility-Toggle wie VW Navigation Palette). Mitte **Canvas** + mit schwebender Tool-Palette + View-Switcher. Rechts **Inspector** (kontextuell). + *Keine* frei schwebenden Palettenfenster (Anti-VW-Wildwuchs). +2. **View-Switcher als segmented control** `3D | Grundriss | Schnitt | Ansicht` + direkt am Canvas; weiche Kamera-Übergänge; *eine* Datenquelle (kein Daten- + Moduswechsel). Nutzt unsere bestehende „abgeleitete Sichten"-Architektur. +3. **Visibility/Lock pro Zeile** als Erstklass-Interaktion (1 Klick, kein Dialog) — + `EyeIcon.tsx` ausbauen. +4. **Kontextueller Inspector**: Controls nur für den selektierten Element-Typ + (Figma/Arcol); berechnete Felder read-only markiert; Sektionen kollabierbar. +5. **Tastatur-Grundlagen + Konventionen festnageln**: Space=Pan, Scroll=Zoom, + Esc=Abbrechen, Shift=Snap aus. Früh festlegen → Muskelgedächtnis. + +### B. Kern-„Pro-Feel" (Phase 1–2) — der eigentliche Wert + +6. **Snapping/Inferencing nach Onshape-Vorbild**: Snap-Glyphen am Cursor, + gestrichelte Achsen-Hilfslinien, Hover-Highlight, **Shift = unterdrücken**, + Wake-up-Inferences. Targets: Wand-Enden/Achsen/Mitten, Raster, Rechtwinklig/ + Parallel, Öffnungs-Achsen. **Höchste Priorität für „Werkzeug-Gefühl".** +7. **Live-Maßeingabe beim Zeichnen** (Länge/Winkel tippbar, Tab zwischen Feldern). +8. **Kontextuelle Action-Bar bei Auswahl** (Arcol): Wand selektiert → „Tür/Fenster + einsetzen / Wandtyp / verschneiden". Buttons erscheinen nur, wenn anwendbar. +9. **Schlanke domänen-gruppierte Tool-Palette** (BIM-Bauteile + 2D-Zeichnen), + modus-bewusst je Ansicht. +10. **Optimistic Edits + Worker-Nachzug**: Mutation sofort sichtbar, schwere + Booleans/HLR im Worker (Comlink); Delay-then-show (150 ms) statt Spinner-Flicker. + +### C. Differenzierung (Phase 3) — hier gewinnen wir + +11. **Grip-Editing** (Wand-Endpunkte, Öffnungs-Position/-Breite, **Schnittlinie im + Plan**) mit Snapping + Live-Maß, in 2D *und* 3D. (DOSSIER-Backlog, Aufwand L — + aber Kern-Differenzierer.) +12. **Saved Views / Ausschnitte** in der linken Sidebar = Kamera + Schnitt + Maßstab + + Layer-Kombination per Klick (VW „Saved Views" / DOSSIER). Skaliert auf 50+ + Ansichten. +13. **Wiederverwendbares Resource-Browser-Pattern** für Line/Hatch/Component-Manager: + Zwei-Pane (File-Browser → Viewer mit Thumbnails) als Manager *und* als inline + Picker im Inspector (VW-Modell). Zentrale Änderung propagiert (id-Referenzen). +14. **Perceived-performance-Politik festschreiben**: Skeletons für Plan-/Schnitt- + Generierung, gescopte Ladezustände (restliche App bleibt nutzbar), + reduced-motion/`aria-busy` respektieren. +15. **Split-View 3D|Plan** (Snaptrude-Muster) — billig dank reaktiver Sichten, + starker Wow-Effekt; auch fürs Onboarding. + +### D. Adoption & Politur (Phase 1 fortlaufend → 7) + +16. **Onboarding via Sample-Projekt** (`sampleProject.ts` als fertiges EFH); + „learn by doing", erste Geste zeigt 3D⇄Plan-Live-Sync. Progressive Disclosure: + Wohnbau-Essenz default, Fortgeschrittenes (Prioritäts-Verschneidung, Overrides, + Detailgrade) zugeklappt. +17. **Command-Palette (Cmd/Ctrl-K)** + sichtbarer Trigger: Werkzeuge/Ansichten/ + Ressourcen/Aktionen durchsuchbar; doppelt als Discovery-Layer. +18. **Live-Metriken-Block** im Inspector (Arcol): BGF, **SIA-416-Bilanz**, Räume, + Volumen — bei leerer Auswahl als Projekt-Übersicht. +19. **CH-Spezifika UI-seitig vorsehen**: **Norden-Rotation**-Gizmo (ViewCube/Kompass), + SIA-Raumkategorien im Raum-Inspector, später Swisstopo-Import-Flow mit Auto-Zoom + + Nullpunkt-Verschiebung. + +### Übergreifende Designprinzipien (gelten immer) + +- **Kontextualität vor Vollständigkeit** — zeige nur, was jetzt anwendbar ist + (Arcol/Onshape/Figma). Direkt verzahnt mit unserem „LoD-bewusste Stil-UI"-Backlog. +- **Direkte Manipulation vor Dialogen** — Grips/Drag/Inline-Edit schlägt + Properties-Dialog (Figma/Snaptrude/DOSSIER). +- **Eine Wahrheit, viele Sichten** — niemals Sicht-spezifische Daten; alles aus dem + semantischen Modell ableiten (deckt sich exakt mit unserem Architektur-Prinzip). +- **Konventionen respektieren** — Pan/Zoom/Snap/Esc/Cmd-K wie die etablierten Tools; + Nutzer bringen Muskelgedächtnis aus Figma/CAD mit. +- **Gefühlte > tatsächliche Geschwindigkeit** — optimistic + Skeletons + 60 fps; + im schweren Browser-3D-Geometrie-Kontext ein echter Wettbewerbsvorteil. + +--- + +## Quellen + +**Arcol** +- [Arcol unleashed – BIM 2.0 (AEC Magazine)](https://aecmag.com/bim/arcol-unleashed-bim-2-0/) +- [Arcol – BIM in a browser (AEC Magazine)](https://aecmag.com/bim/arcol-bim-cloud-browser/) +- [Arcol: a sneak peek (AEC Magazine)](https://aecmag.com/bim/arcol-a-sneak-peek/) +- [The Arcol Manifesto](https://arcol.io/blog/the-arcol-manifesto) + +**Snaptrude** +- [Snaptrude – browser-based BIM tool (ArchDaily)](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work) +- [Snaptrude (offiziell)](https://www.snaptrude.com/) + +**TestFit** +- [TestFit 5.19: A New Generative Design Workflow](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow) +- [TestFit (offiziell)](https://www.testfit.io/) + +**Onshape** +- [Onshape: User Interface Basics](https://cad.onshape.com/help/Content/ui-basics.htm) +- [Onshape: Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm) +- [Onshape: Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm) +- [Onshape: Sketch Basics](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm) + +**Vectorworks** +- [VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm) +- [VW Resource Manager: File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm) +- [VW Resource Manager: Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm) +- [VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm) +- [VW Palettes and Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm) + +**Figma** +- [Figma: right sidebar / layer properties](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar) +- [Figma: left sidebar (layers & pages)](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar) +- [Figma for Everyone: The Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface) + +**Perceived Performance** +- [LogRocket: Skeleton loading screen design](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/) +- [UI Deploy: Skeleton Screens vs. Spinners](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance) +- [Onething: Skeleton Screens vs Loading Spinners](https://www.onething.design/post/skeleton-screens-vs-loading-spinners) +- [Smart Interface Design Patterns: Loading & Progress UX](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/) + +**Command Palette** +- [Mobbin: Command Palette](https://mobbin.com/glossary/command-palette) +- [Untitled UI: Command menus (Cmd-K)](https://www.untitledui.com/components/command-menus) +- [Superhuman: How to build a remarkable command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/) +- [Outdraw Academy: Command Palette UX pattern](https://outdraw-academy.gitbook.io/ux-patterns/command-palette) + +**Onboarding / Progressive Disclosure** +- [IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure) +- [UXPin: What Is Progressive Disclosure](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/) +- [Userpilot: User onboarding examples](https://userpilot.com/blog/user-onboarding-examples/) +- [Userpilot: Progressive disclosure examples](https://userpilot.com/blog/progressive-disclosure-examples/) diff --git a/index.html b/index.html new file mode 100644 index 0000000..242f517 --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + cad — Phase 0 Spike + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f82445f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2260 @@ +{ + "name": "cad", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cad", + "version": "0.0.0", + "dependencies": { + "@mlightcad/libredwg-web": "^0.7.7", + "delaunator": "^5.1.0", + "dxf-parser": "^1.1.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.2", + "playwright": "^1.61.1", + "puppeteer": "^25.2.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mlightcad/libredwg-web": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@mlightcad/libredwg-web/-/libredwg-web-0.7.7.tgz", + "integrity": "sha512-JnEug2IVXafUecM9/WCQgH8mJRliaEq0Uxu3Mxt0Zk82n1MrSd25iA0Boo7e7t9qVwRPxQLGFxWoyPIrAeTy4w==", + "license": "GPL-3.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.5.tgz", + "integrity": "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz", + "integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.71", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", + "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1638949", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", + "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/dxf-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/dxf-parser/-/dxf-parser-1.1.2.tgz", + "integrity": "sha512-GPTumUvRkounlIazLIyJMmTWt+nlg+ksS0Hdm8jWvejmZKBTz6gvHTam76wRm4PQMma5sgKLThblQyeIJcH79Q==", + "license": "MIT", + "dependencies": { + "loglevel": "^1.7.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/puppeteer": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.2.1.tgz", + "integrity": "sha512-2D5RMkQH9FRhDU57a1/jV9xWoxqZvUjaZOYjAAPdRCEY8A01V5sxzyGOMs8XiKU9fPF91SOSwNYpHRu5SD958g==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.2.1", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.2.1.tgz", + "integrity": "sha512-MwEZ4FFGJ1ZLOmu/04eISxoEMKtCnHyJBRFfgpwPPSYNG6gT6Xw1laNziFSV7uwDcx3jK+ATYIo9SfOd8Uhc3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.5", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1638949", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT" + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..420fbcd --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "cad", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mlightcad/libredwg-web": "^0.7.7", + "delaunator": "^5.1.0", + "dxf-parser": "^1.1.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.169.0", + "@vitejs/plugin-react": "^4.3.2", + "playwright": "^1.61.1", + "puppeteer": "^25.2.1", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/scripts/_debug.mjs b/scripts/_debug.mjs new file mode 100644 index 0000000..c6e60f1 --- /dev/null +++ b/scripts/_debug.mjs @@ -0,0 +1,21 @@ +import puppeteer from "puppeteer"; +const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] }); +const page = await browser.newPage(); +await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 }); +const logs=[]; page.on("pageerror",e=>logs.push("[PAGEERROR] "+e.message)); page.on("console",m=>logs.push("[console] "+m.text())); +try { await page.goto("http://localhost:5173/", { waitUntil:"networkidle0", timeout:8000 }); } catch(e){ logs.push("[goto] "+e.message); } +await page.evaluate(()=>{ try{localStorage.removeItem("cad.layout");}catch{} }); +await page.reload({waitUntil:"domcontentloaded"}).catch(()=>{}); +await page.waitForSelector(".plan-svg polygon",{timeout:20000}); +await new Promise(r=>setTimeout(r,800)); +const dom = await page.evaluate(()=>({ + docks: [...document.querySelectorAll(".dock")].map(d=>d.className), + hasLeft: !!document.querySelector(".dock-left"), + hasRight: !!document.querySelector(".dock-right"), + resTriggerActive: document.querySelector(".res-trigger")?.classList.contains("active") ?? null, + layout: (()=>{try{return JSON.parse(localStorage.getItem("cad.layout"));}catch{return "ERR";}})(), + bodyChildren: [...(document.querySelector(".body")?.children||[])].map(c=>c.className), +})); +console.log(JSON.stringify(dom,null,2)); +console.log("LOGS:\n"+logs.join("\n")); +await browser.close(); diff --git a/scripts/_peek.mjs b/scripts/_peek.mjs new file mode 100644 index 0000000..1a9836f --- /dev/null +++ b/scripts/_peek.mjs @@ -0,0 +1,10 @@ +import puppeteer from "puppeteer"; +const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p = await b.newPage(); await p.setViewport({width:1500,height:950,deviceScaleFactor:2}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +try{ await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:15000}); }catch{} +await new Promise(r=>setTimeout(r,1000)); +const info = await p.evaluate(()=>({rootLen:document.getElementById("root")?.innerHTML.length??-1, viteOverlay: !!document.querySelector("vite-error-overlay"), bodyText: document.body.innerText.slice(0,80)})); +await p.screenshot({path:"scripts/probe-now.png"}); +console.log(JSON.stringify(info), "errs:", errs.slice(0,2).join(" | ")||"none"); +await b.close(); diff --git a/scripts/_terrain-entry.ts b/scripts/_terrain-entry.ts new file mode 100644 index 0000000..88ecddc --- /dev/null +++ b/scripts/_terrain-entry.ts @@ -0,0 +1,4 @@ +// Bündel-Einstieg für check-terrain.mjs — re-exportiert die zu testenden +// Funktionen, damit esbuild ein einzelnes ESM-Bundle bauen kann. +export { parseDxf } from "../src/io/dxfParser"; +export { generateTerrainFromContours } from "../src/model/terrain"; diff --git a/scripts/_verify3.mjs b/scripts/_verify3.mjs new file mode 100644 index 0000000..bc66ac8 --- /dev/null +++ b/scripts/_verify3.mjs @@ -0,0 +1,21 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1500,height:950,deviceScaleFactor:2}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +try{await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:15000});}catch{} +await new Promise(r=>setTimeout(r,1000)); +// Browser-Kontextmenü unterdrückt? +const ctxPrevented=await p.evaluate(()=>{const e=new MouseEvent("contextmenu",{bubbles:true,cancelable:true});return !document.body.dispatchEvent(e);}); +// Marquee: Links-Drag über den Plan +const svg=await p.$(".plan-svg"); const box=await svg.boundingBox(); +await p.mouse.move(box.x+box.width*0.25,box.y+box.height*0.25); +await p.mouse.down(); await p.mouse.move(box.x+box.width*0.85,box.y+box.height*0.85,{steps:8}); await p.mouse.up(); +await new Promise(r=>setTimeout(r,300)); +const sel=await p.evaluate(()=>{const m=document.body.innerText.match(/Auswahl:?\s*(\d+)?\s*W\w*/i);return m?m[0]:"(keine)";}); +await p.screenshot({path:"scripts/verify-de.png"}); +// Englisch +const langOk=await p.evaluate(()=>{if(window.__i18n){window.__i18n.setLanguage("en");return true;}return false;}); +await new Promise(r=>setTimeout(r,400)); +await p.screenshot({path:"scripts/verify-en.png"}); +console.log("ctxmenu unterdrückt:",ctxPrevented,"| Marquee-Auswahl:",sel,"| lang-switch:",langOk,"| errs:",errs.slice(0,2).join(" | ")||"keine"); +await b.close(); diff --git a/scripts/check-terrain.mjs b/scripts/check-terrain.mjs new file mode 100644 index 0000000..45138b8 --- /dev/null +++ b/scripts/check-terrain.mjs @@ -0,0 +1,88 @@ +// Node-seitige Geometrie-Prüfung (kein Browser): generiert eine minimale, +// gültige Test-DXF (Höhenlinien auf Z=400/401/402), schickt sie durch parseDxf +// → generateTerrainFromContours und bestätigt ein plausibles TIN. +// +// Die TS-Quellen werden mit esbuild on-the-fly zu einem ESM-Bundle gebaut und +// importiert (kein tsx nötig). + +import { build } from "esbuild"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const outdir = mkdtempSync(join(tmpdir(), "cad-terrain-")); +const outfile = join(outdir, "bundle.mjs"); + +await build({ + entryPoints: [join(process.cwd(), "scripts/_terrain-entry.ts")], + bundle: true, + format: "esm", + platform: "node", + outfile, + logLevel: "warning", +}); + +const mod = await import(pathToFileURL(outfile).href); +const { parseDxf, generateTerrainFromContours } = mod; + +// ── Minimal gültige Test-DXF: drei LWPOLYLINE-Höhenlinien (Z via Elevation 38) ── +// Jede Kontur ist ein leicht versetztes Dreieck/Quadrat auf einer eigenen Höhe. +function ring(z, pts, layer) { + // LWPOLYLINE mit Elevation-Gruppe (38) und 90=Vertex-Zahl, 70=closed(1). + let s = `0\nLWPOLYLINE\n8\n${layer}\n38\n${z}\n90\n${pts.length}\n70\n1\n`; + for (const [x, y] of pts) s += `10\n${x}\n20\n${y}\n`; + return s; +} + +const dxf = + `0\nSECTION\n2\nENTITIES\n` + + ring(400, [[0, 0], [10, 0], [10, 10], [0, 10]], "contours") + + ring(401, [[2, 2], [8, 2], [8, 8], [2, 8]], "contours") + + ring(402, [[4, 4], [6, 4], [6, 6], [4, 6]], "contours") + + `0\nENDSEC\n0\nEOF\n`; + +const parsed = parseDxf(dxf); +const contourSets = parsed.contours; +const allContours = contourSets.flatMap((cs) => cs.contours); + +console.log("=== DXF-Parse ==="); +console.log("ContourSets:", contourSets.length, "Konturen:", allContours.length); +console.log("Z-Werte:", allContours.map((c) => c.z).join(", ")); +console.log("Meshes (importiert):", parsed.meshes.length); + +const terrain = generateTerrainFromContours(allContours); +const vertCount = terrain.positions.length / 3; +const triCount = terrain.indices.length / 3; + +let minZ = Infinity, maxZ = -Infinity, nan = 0; +for (let i = 0; i < terrain.positions.length; i++) { + const v = terrain.positions[i]; + if (!Number.isFinite(v)) nan++; +} +for (let i = 2; i < terrain.positions.length; i += 3) { + minZ = Math.min(minZ, terrain.positions[i]); + maxZ = Math.max(maxZ, terrain.positions[i]); +} + +console.log("\n=== TIN ==="); +console.log("Vertices:", vertCount, "Dreiecke:", triCount); +console.log("Z-Range:", minZ, "..", maxZ); +console.log("NaN/Infinity-Werte:", nan); + +const ok = + contourSets.length === 1 && + allContours.length === 3 && + vertCount > 0 && + triCount > 0 && + nan === 0 && + Math.abs(minZ - 400) < 1e-6 && + Math.abs(maxZ - 402) < 1e-6; + +// Zusatz: leere Eingabe → leeres Mesh (kein Crash). +const empty = generateTerrainFromContours([]); +const emptyOk = empty.positions.length === 0 && empty.indices.length === 0; +console.log("\nLeere-Eingabe → leeres Mesh:", emptyOk ? "OK" : "FEHLER"); + +console.log("\n" + (ok && emptyOk ? "✅ TIN plausibel" : "❌ TIN NICHT plausibel")); +process.exit(ok && emptyOk ? 0 : 1); diff --git a/scripts/probe-3d2d.mjs b/scripts/probe-3d2d.mjs new file mode 100644 index 0000000..fb54283 --- /dev/null +++ b/scripts/probe-3d2d.mjs @@ -0,0 +1,51 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + page.on("console", (m) => { if (m.text().includes("DBG")) logs.push(m.text()); }); +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 600)); +const clickTool = (label) => page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label); +const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); }; +const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); +const cx = box.x + box.w / 2, cy = box.y + box.h / 2; + +// Ein kleines Linien-Raster in den Raum zeichnen (3 horizontale + 3 vertikale). +await clickTool("Linie"); +await new Promise((r) => setTimeout(r, 100)); +const L = box.x + box.w * 0.34, Rr = box.x + box.w * 0.66; +const T = box.y + box.h * 0.42, Bb = box.y + box.h * 0.72; +for (let i = 0; i <= 2; i++) { + const y = T + ((Bb - T) * i) / 2; + await click(L, y); await click(Rr, y); +} +for (let i = 0; i <= 2; i++) { + const x = L + ((Rr - L) * i) / 2; + await click(x, T); await click(x, Bb); +} +await clickTool("Auswahl"); +await new Promise((r) => setTimeout(r, 100)); +const draw2dPlan = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length); + +// Obergeschoss ausblenden (Augen-Icon), damit der EG-Boden mit dem Raster oben +// offen sichtbar ist. +await page.evaluate(() => { + let row = [...document.querySelectorAll("*")].find((e) => e.children.length === 0 && e.textContent.trim() === "Obergeschoss"); + while (row && !row.querySelector?.(".eye")) row = row.parentElement; + row?.querySelector(".eye")?.click(); +}); +await new Promise((r) => setTimeout(r, 200)); + +// In die Perspektive wechseln und rendern lassen. +await clickTool("Perspektive"); +await new Promise((r) => setTimeout(r, 1200)); +await page.screenshot({ path: "scripts/probe-3d2d.png" }); +console.log("draw2dPlan lines:", draw2dPlan); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-array.mjs b/scripts/probe-array.mjs new file mode 100644 index 0000000..dce66ee --- /dev/null +++ b/scripts/probe-array.mjs @@ -0,0 +1,30 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const b = await puppeteer.launch({ headless: "new", args: ["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] }); +const page = await b.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; page.on("pageerror", e => logs.push(e.message)); +page.on("dialog", async d => { await d.accept("3"); }); +await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(()=>{}); +await new Promise(r=>setTimeout(r,600)); +const clickTool = l => page.evaluate(x => [...document.querySelectorAll("button")].find(b=>b.textContent.trim()===x)?.click(), l); +const click = async (x,y)=>{ await page.mouse.move(x,y); await page.mouse.click(x,y); await new Promise(r=>setTimeout(r,70)); }; +const nLines = () => page.evaluate(()=>document.querySelectorAll(".plan-svg line.draw2d").length); +const box = await page.evaluate(()=>{const r=document.querySelector(".plan-svg").getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height};}); +const cx=box.x+box.w/2, cy=box.y+box.h/2; +await clickTool("Linie"); await new Promise(r=>setTimeout(r,80)); +const A=[cx-120,cy-140], B=[cx+20,cy-140]; +await click(...A); await click(...B); +await clickTool("Auswahl"); await new Promise(r=>setTimeout(r,60)); +await click((A[0]+B[0])/2, A[1]); // select midpoint +const sel = await page.evaluate(()=>document.querySelectorAll(".plan-svg .plan-grip").length); +await page.keyboard.press("m"); await new Promise(r=>setTimeout(r,80)); +await page.keyboard.press("o"); await new Promise(r=>setTimeout(r,200)); +const mode = await page.evaluate(()=>document.querySelector(".transform-bar .tf-mode.active .tf-mode-label")?.textContent); +await click(A[0], A[1]); // base +await click(A[0], A[1]+70); // dest (down by ~70px each step) +await new Promise(r=>setTimeout(r,150)); +const n = await nLines(); +await page.screenshot({ path:"scripts/probe-array.png" }); +console.log("selectedGrips:", sel, "activeMode:", mode, "lines(expect 4):", n, logs.length?logs.join("|"):"(no err)"); +await b.close(); diff --git a/scripts/probe-command-line.mjs b/scripts/probe-command-line.mjs new file mode 100644 index 0000000..b4e7647 --- /dev/null +++ b/scripts/probe-command-line.mjs @@ -0,0 +1,93 @@ +// Probe für das Rhino-artige Befehlssystem (Tier 0 + Line). +// Treibt: Tab → „line"⏎ → „0,0"⏎ → „r3,0"⏎ und screenshotet das Ergebnis. +// Erwartung: eine deutlich DUNKLE 3-m-Linie im Plan + Command-Line zeigt Prompts. +// +// Headless Puppeteer (swiftshader für WebGL). Zweiter Lauf mit „3<45" prüft die +// polare Eingabe. Vorlage: scripts/probe-tools.mjs. + +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} +await sleep(600); + +// Zählt 2D-Zeichenlinien + liefert die computed stroke der ersten (Tinte-Check). +async function inkInfo() { + return page.evaluate(() => { + const lines = [...document.querySelectorAll(".plan-svg line.draw2d")]; + const first = lines[0]; + const stroke = first ? getComputedStyle(first).stroke : null; + return { count: lines.length, stroke }; + }); +} + +// Tippt einen Befehl/Eine Zeile in die fokussierte Command-Line + Enter. +async function typeLine(text) { + await page.type(".cmdline-input", text, { delay: 12 }); + await page.keyboard.press("Enter"); + await sleep(150); +} + +// 1) Tab → Command-Line fokussieren (globaler Tab-Handler in App). +// Erst in den Body klicken, damit Tab nicht von einem anderen Feld geschluckt wird. +await page.mouse.click(600, 400); +await sleep(80); +await page.keyboard.press("Tab"); +await sleep(120); +const focused1 = await page.evaluate( + () => document.activeElement?.className || "", +); + +// 2) Befehl + zwei getippte Koordinaten. Startpunkt 1,7 liegt ÜBER dem Raum +// (freies Papier), damit die 3-m-Linie eindeutig sichtbar ist (nicht von einer +// Wandkante überdeckt). Verlangt: 1,7 ⏎ r3,0 ⏎ → 3-m-Linie nach rechts. +await typeLine("line"); +const promptAfterLine = await page.evaluate( + () => document.querySelector(".cmdline-prompt")?.textContent || "", +); +await typeLine("1,2"); +const promptAfterStart = await page.evaluate( + () => document.querySelector(".cmdline-prompt")?.textContent || "", +); +await typeLine("r3,0"); +await sleep(200); + +const ink1 = await inkInfo(); +await page.screenshot({ path: "scripts/probe-command-line.png" }); + +// 3) Zweiter Lauf: polare Eingabe 3<45 (neue Linie ab Ursprung). +await page.mouse.click(600, 400); +await sleep(80); +await page.keyboard.press("Tab"); +await sleep(120); +await typeLine("line"); +await typeLine("1,1"); +await typeLine("3<45"); +await sleep(200); +const ink2 = await inkInfo(); +await page.screenshot({ path: "scripts/probe-command-line-polar.png" }); + +console.log("focused after Tab:", focused1); +console.log("prompt after 'line':", JSON.stringify(promptAfterLine)); +console.log("prompt after '0,0':", JSON.stringify(promptAfterStart)); +console.log("ink (after 0,0 + r3,0):", JSON.stringify(ink1)); +console.log("ink (after polar 3<45):", JSON.stringify(ink2)); +console.log("=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-ctx.mjs b/scripts/probe-ctx.mjs new file mode 100644 index 0000000..5eea2fa --- /dev/null +++ b/scripts/probe-ctx.mjs @@ -0,0 +1,11 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1.5}); +await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,1200)); +const opened=await p.evaluate(()=>{const l=[...document.querySelectorAll(".nav-label")].find(e=>/Erdgeschoss|Obergeschoss|Detail|Ansicht|Schnitt/.test(e.textContent||""));if(!l)return "no-row";const row=l.parentElement;const r=row.getBoundingClientRect();const ev=new MouseEvent("contextmenu",{bubbles:true,cancelable:true,clientX:r.x+20,clientY:r.y+r.height/2,button:2});row.dispatchEvent(ev);return "dispatched";}); +await new Promise(r=>setTimeout(r,400)); +const info=await p.evaluate(()=>{const m=document.querySelector(".ctx-menu");if(!m)return{menu:false};const icons=[...m.querySelectorAll(".ctx-item-icon .material-symbols-outlined")];const cs=icons[0]?getComputedStyle(icons[0]):null;return{menu:true,nItems:m.querySelectorAll(".ctx-item").length,nIcons:icons.length,iconPx:cs?.fontSize,glyphFont:/Material Symbols/.test(cs?.fontFamily||""),ligature:icons[0]?.textContent,hoverBg:getComputedStyle(m.querySelector(".ctx-item")).getPropertyValue("--ctx-hover")};}); +await p.screenshot({path:"scripts/probe-ctx.png"}); +console.log("trigger:",opened,JSON.stringify(info)); +await b.close(); diff --git a/scripts/probe-dd-size.mjs b/scripts/probe-dd-size.mjs new file mode 100644 index 0000000..b64ce99 --- /dev/null +++ b/scripts/probe-dd-size.mjs @@ -0,0 +1,11 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1400,height:880,deviceScaleFactor:1.5}); +await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,1000)); +// ersten Dropdown-Trigger (Detailgrad) klicken +const tr=await p.$(".tb-dd-trigger"); if(tr){const r=await tr.boundingBox();await p.mouse.click(r.x+r.width/2,r.y+r.height/2);await new Promise(t=>setTimeout(t,350));} +const info=await p.evaluate(()=>{const menu=document.querySelector(".tb-dd-menu, .ctx-menu");if(!menu)return{open:false};const item=menu.querySelector(".ctx-item, .tb-dd-item");const cs=item?getComputedStyle(item):null;const ic=menu.querySelector(".ctx-item-icon, .material-symbols-outlined");return{open:true,itemFont:cs?.fontSize,itemPad:cs?.padding,menuFont:getComputedStyle(menu).fontSize,radius:getComputedStyle(menu).borderRadius,iconFont:ic?getComputedStyle(ic).fontSize:"(none)"};}); +await p.screenshot({path:"scripts/probe-dd-size.png"}); +console.log(JSON.stringify(info)); +await b.close(); diff --git a/scripts/probe-display-style.mjs b/scripts/probe-display-style.mjs new file mode 100644 index 0000000..90d1969 --- /dev/null +++ b/scripts/probe-display-style.mjs @@ -0,0 +1,67 @@ +// Probe: belegt den kontextabhängigen „Darstellung"-Dropdown. +// 1) Grundriss + „Schwarz-Weiss" (mono) → scripts/probe-plan-mono.png +// 2) Grundriss + „Farbig" (Referenz) → scripts/probe-plan-color.png +// 3) Perspektive + „Weiss" (Clay) → scripts/probe-3d-white.png +// 4) Perspektive + „Schattiert" (Ref.) → scripts/probe-3d-shaded.png +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); + +// Den Ansichts-Umschalter-Button per sichtbarem Text klicken. +const clickByText = async (sel, text) => { + const handle = await page.evaluateHandle( + (sel, text) => + [...document.querySelectorAll(sel)].find( + (el) => el.textContent.trim() === text, + ), + sel, + text, + ); + const el = handle.asElement(); + if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`); + await el.click(); +}; + +// Den „Darstellung"-Dropdown (per title) auf einen Wert setzen. +const setStyle = async (value) => { + const sel = await page.$('select.layout-menu[title*="Darstellungsart der"]'); + if (!sel) throw new Error("Darstellung-Dropdown nicht gefunden"); + await sel.select(value); + await new Promise((r) => setTimeout(r, 600)); +}; + +const shot = (name) => page.screenshot({ path: `scripts/${name}` }); + +// 1) Grundriss aktiv (Default), Schwarz-Weiss. +await clickByText("button.view-btn", "Grundriss"); +await new Promise((r) => setTimeout(r, 400)); +await setStyle("color"); +await shot("probe-plan-color.png"); +await setStyle("mono"); +await shot("probe-plan-mono.png"); + +// 2) Perspektive, Weiss vs. Schattiert. +await clickByText("button.view-btn", "Perspektive"); +await new Promise((r) => setTimeout(r, 800)); +await setStyle("shaded"); +await shot("probe-3d-shaded.png"); +await setStyle("white"); +await new Promise((r) => setTimeout(r, 600)); +await shot("probe-3d-white.png"); + +console.log("Screenshots geschrieben: probe-plan-color/mono, probe-3d-shaded/white"); +console.log("\n=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-display.mjs b/scripts/probe-display.mjs new file mode 100644 index 0000000..88693e3 --- /dev/null +++ b/scripts/probe-display.mjs @@ -0,0 +1,15 @@ +import puppeteer from "puppeteer"; +const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] }); +const p = await b.newPage(); await p.setViewport({width:1400,height:900,deviceScaleFactor:2}); +const logs=[]; p.on("pageerror",e=>logs.push(e.message)); +await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,800)); +// Default = Display (Haarlinien). Screenshot. +await p.screenshot({ path:"scripts/probe-display.png" }); +// Auf Print umschalten. +await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Print")?.click()); +await new Promise(r=>setTimeout(r,400)); +await p.screenshot({ path:"scripts/probe-print.png" }); +const dispActive = await p.evaluate(()=>!![...document.querySelectorAll(".view-btn.active")].find(b=>b.textContent.trim()==="Print")); +console.log("printActive:", dispActive, logs.length?logs.join("|"):"(no err)"); +await b.close(); diff --git a/scripts/probe-doc.mjs b/scripts/probe-doc.mjs new file mode 100644 index 0000000..9846a68 --- /dev/null +++ b/scripts/probe-doc.mjs @@ -0,0 +1,48 @@ +import puppeteer from "puppeteer"; + +// Wie probe.mjs, aber klickt zusätzlich die Zeichnungsebene "Grundriss OG" +// im Navigator und schießt scripts/probe-doc.png. +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} + +// Navigator-Zeile "Grundriss OG" anklicken. +const clicked = await page.evaluate(() => { + const rows = [...document.querySelectorAll(".nav-row")]; + const target = rows.find((r) => r.textContent.includes("Grundriss OG")); + if (target) { + target.click(); + return true; + } + return false; +}); +logs.push(clicked ? "[INFO] 'Grundriss OG' geklickt" : "[WARN] 'Grundriss OG' nicht gefunden"); + +await new Promise((r) => setTimeout(r, 800)); +await page.screenshot({ path: "scripts/probe-doc.png" }); + +const info = await page.evaluate(() => { + const title = document.querySelector(".content-single .pane-title")?.textContent ?? "none"; + const caption = document.querySelector(".content-single .plan-legend")?.textContent ?? "none"; + const polygons = document.querySelectorAll(".content-single .plan-svg polygon").length; + return { title, caption, polygons }; +}); + +console.log("=== Drawing-Layer-Ansicht ===", JSON.stringify(info)); +console.log("\n=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-dossier.mjs b/scripts/probe-dossier.mjs new file mode 100644 index 0000000..ec322d7 --- /dev/null +++ b/scripts/probe-dossier.mjs @@ -0,0 +1,134 @@ +// Probe für das DOSSIER-Dokumentmodell (Augen-Navigator + Mehrgeschoss-3D). +// Erfasst fünf Zustände in separate PNGs (deviceScaleFactor 2): +// (a) Standard: EG Grundriss mit Augen-Navigator (EG aktiv, andere gedimmt). +// (b) Perspektive mit EG + OG sichtbar → zwei Räume vertikal gestapelt. +// (c) OG-Auge ausgeschaltet → nur EG bleibt im 3D. +// (d) EG Geschosshöhe auf 3.5 → größerer Abstand zu OG; "+ Ebene" anklicken +// → neue Kategorie erscheint im Navigator. +// (e) Zeichnungsebene "Detail 1" → leerer Zeichnungs-Platzhalter. +// +// Aufruf: node scripts/probe-dossier.mjs +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const OUT = "scripts"; + +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1280, height: 820, deviceScaleFactor: 2 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Klick auf das Element, dessen Textinhalt `text` enthält (innerhalb selector). +async function clickByText(selector, text) { + const handle = await page.evaluateHandle( + (sel, t) => + [...document.querySelectorAll(sel)].find((el) => + el.textContent.includes(t), + ), + selector, + text, + ); + const el = handle.asElement(); + if (!el) throw new Error(`Element nicht gefunden: ${selector} ~ "${text}"`); + await el.click(); +} + +// Klick auf das Augen-Symbol einer Zeichnungsebene mit gegebenem Namen. +async function toggleLevelEyeByName(name) { + const ok = await page.evaluate((n) => { + const row = [...document.querySelectorAll(".nav-row")].find((r) => + r.querySelector(".nav-label")?.textContent.trim().includes(n), + ); + const eye = row?.querySelector(".eye"); + if (!eye) return false; + eye.click(); + return true; + }, name); + if (!ok) throw new Error(`Augen-Schalter nicht gefunden für Ebene: ${name}`); +} + +// Diagnose: aktueller Navigator-Zustand (für Querprüfung der Screenshots). +const navState = () => + page.evaluate(() => { + const levels = [...document.querySelectorAll(".nav-row")].map((r) => ({ + name: r.querySelector(".nav-label")?.textContent.trim(), + active: r.classList.contains("active"), + dimmed: r.classList.contains("hidden"), + elev: r.querySelector(".nav-elev")?.textContent.trim() ?? null, + })); + const cats = [...document.querySelectorAll(".cat-row")].map((r) => + r.querySelector(".cat-name")?.textContent.trim(), + ); + const canvas = document.querySelector(".viewport canvas"); + return { + levels, + catCount: cats.length, + catNames: cats, + hasCanvas: !!canvas, + egHeight: document.querySelector(".props-strip input")?.value ?? null, + }; + }); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} +await sleep(700); + +// ── (a) Standard: EG Grundriss mit Augen-Navigator ────────────────────────── +await page.screenshot({ path: `${OUT}/probe-a-eg-grundriss.png` }); +logs.push("(a) STATE " + JSON.stringify(await navState())); + +// ── (b) Perspektive mit EG + OG sichtbar ─────────────────────────────────── +await clickByText(".view-btn", "Perspektive"); +await sleep(1400); +await page.screenshot({ path: `${OUT}/probe-b-eg-og-perspektive.png` }); +logs.push("(b) STATE " + JSON.stringify(await navState())); + +// ── (c) OG-Auge ausschalten → nur EG bleibt im 3D ────────────────────────── +await toggleLevelEyeByName("Obergeschoss"); +await sleep(1400); +await page.screenshot({ path: `${OUT}/probe-c-only-eg-3d.png` }); +logs.push("(c) STATE " + JSON.stringify(await navState())); + +// OG wieder einblenden, damit (d) den Stapel-Abstand zeigt. +await toggleLevelEyeByName("Obergeschoss"); +await sleep(400); + +// ── (d) EG Geschosshöhe auf 3.5 → größerer Abstand zu OG ─────────────────── +// EG ist die Standardauswahl; die props-strip zeigt das aktive Geschoss. +await page.evaluate(() => { + const input = document.querySelector(".props-strip input"); + if (!input) throw new Error("Geschosshöhe-Feld nicht gefunden"); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + ).set; + setter.call(input, "3.5"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +}); +await sleep(1400); +// "+ Ebene" anklicken → neue Kategorie erscheint im Navigator. +await clickByText(".nav-add", "+ Ebene"); +await sleep(500); +await page.screenshot({ path: `${OUT}/probe-d-height-and-addlayer.png` }); +logs.push("(d) STATE " + JSON.stringify(await navState())); + +// ── (e) Zeichnungsebene "Detail 1" → leerer Zeichnungs-Platzhalter ───────── +await clickByText(".nav-label", "Detail 1"); +await sleep(500); +await page.screenshot({ path: `${OUT}/probe-e-detail-drawing.png` }); +logs.push("(e) STATE " + JSON.stringify(await navState())); + +console.log(logs.join("\n")); +await browser.close(); diff --git a/scripts/probe-draw-commands.mjs b/scripts/probe-draw-commands.mjs new file mode 100644 index 0000000..330c3c2 --- /dev/null +++ b/scripts/probe-draw-commands.mjs @@ -0,0 +1,36 @@ +import puppeteer from "puppeteer"; +const URL=process.env.PROBE_URL||"http://localhost:5173/"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto(URL,{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +const lineCount=()=>p.$$eval(".plan-svg line",ls=>ls.length).catch(()=>-1); +const focusCmd=async()=>{ await p.mouse.click(640,760); await p.keyboard.press("Tab"); await new Promise(r=>setTimeout(r,80)); }; +const typeLine=async t=>{ await p.type(".cmdline-input",t,{delay:8}); await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,90)); }; +const prompt=()=>p.evaluate(()=>document.querySelector(".cmdline-prompt")?.textContent||""); + +const l0=await lineCount(); +// Polyline (offener Zug, Enter beendet) +await focusCmd(); await typeLine("polyline"); +const pp=await prompt(); +await typeLine("1,1"); await typeLine("r2,0"); await typeLine("r0,1.5"); +await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,120)); // beenden +const l1=await lineCount(); +// Rectangle +await focusCmd(); await typeLine("rect"); +const rp=await prompt(); +await typeLine("0.5,3"); await typeLine("r3,1.5"); +const l2=await lineCount(); +// Circle (Radius getippt) +await focusCmd(); await typeLine("circle"); +const cp=await prompt(); +await typeLine("4,4"); await typeLine("0.8"); +const l3=await lineCount(); +await p.screenshot({path:"scripts/probe-draw-commands.png"}); +console.log(JSON.stringify({ + polyPrompt:pp, rectPrompt:rp, circPrompt:cp, + lines_start:l0, after_polyline:l1, after_rect:l2, after_circle:l3, + polyAdded:l1-l0, rectAdded:l2-l1, circleAdded:l3-l2, errs +},null,2)); +await b.close(); diff --git a/scripts/probe-dropdown.mjs b/scripts/probe-dropdown.mjs new file mode 100644 index 0000000..9ea874d --- /dev/null +++ b/scripts/probe-dropdown.mjs @@ -0,0 +1,50 @@ +import puppeteer from "puppeteer"; +const PORT = process.env.PORT || "5173"; +const b = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const p = await b.newPage(); +await p.setViewport({ width: 1400, height: 420, deviceScaleFactor: 2 }); +await p.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {}); +await new Promise((r) => setTimeout(r, 800)); + +// Detailgrad-Dropdown finden (Trigger mit aria-label = Detailgrad-Hint) und öffnen. +// Wir klicken den Trigger in der Detailgrad-Gruppe. +const opened = await p.evaluate(() => { + const groups = [...document.querySelectorAll(".tb-group")]; + const detailGroup = groups.find((g) => + (g.getAttribute("aria-label") || "").includes("Detailgrad"), + ); + const trig = detailGroup?.querySelector(".tb-dd-trigger"); + if (!trig) return { ok: false }; + trig.click(); + return { ok: true, label: trig.textContent }; +}); +await new Promise((r) => setTimeout(r, 250)); + +const info = await p.evaluate(() => { + const menu = document.querySelector(".tb-dd-menu"); + if (!menu) return { menu: false }; + const cs = getComputedStyle(menu); + const items = [...menu.querySelectorAll(".tb-dd-item")].map((it) => ({ + label: it.querySelector(".ctx-item-label")?.textContent, + active: it.getAttribute("aria-selected") === "true", + isActive: it.classList.contains("is-active"), + })); + return { + menu: true, + bg: cs.backgroundColor, + radius: cs.borderRadius, + items, + }; +}); +const stillOpen = await p.evaluate(() => !!document.querySelector(".tb-dd-menu")); +console.log(JSON.stringify({ opened, info, stillOpen }, null, 2)); + +// Mount-Animation (ctx-pop) für den Screenshot deaktivieren, damit das Popover +// im Headless-Capture sicher voll deckend gerendert ist. +await p.addStyleTag({ content: ".tb-dd-menu{animation:none !important;}" }); +await new Promise((r) => setTimeout(r, 120)); +await p.screenshot({ path: "scripts/probe-dropdown.png", clip: { x: 0, y: 0, width: 1400, height: 320 } }); +await b.close(); diff --git a/scripts/probe-dwg-import.mjs b/scripts/probe-dwg-import.mjs new file mode 100644 index 0000000..bd6b6dc --- /dev/null +++ b/scripts/probe-dwg-import.mjs @@ -0,0 +1,68 @@ +// Browser-Laufzeittest: echter DWG-Import über den App-Import-Pfad. +// Lädt eine echte DWG über das versteckte File-Input (uploadFile), wartet bis +// LibreDWG-WASM geparst hat, und screenshotet den Import-Dialog mit der ECHTEN +// Zusammenfassung (Konturen/Layer) statt des ODA-Hinweises. + +import puppeteer from "puppeteer"; + +const URL = process.env.URL || "http://localhost:5173/"; +const SAMPLE = + "/tmp/claude-1000/-home-karim-cad/65983130-c639-428b-bd68-85eafeb48d98/scratchpad/sample.dwg"; + +const b = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const p = await b.newPage(); +await p.setViewport({ width: 1300, height: 900, deviceScaleFactor: 2 }); +const logs = []; +p.on("pageerror", (e) => logs.push("ERR:" + e.message)); +p.on("console", (m) => { + if (m.type() === "error") logs.push("CONSOLE:" + m.text()); +}); + +await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {}); +await new Promise((r) => setTimeout(r, 700)); + +// File-Input mit accept=".dxf,.dwg" finden und die echte DWG hochladen. +const input = await p.$('input[type=file][accept*=".dwg"]'); +if (!input) { + console.log("FAIL: DWG-File-Input nicht gefunden."); + await b.close(); + process.exit(1); +} +await input.uploadFile(SAMPLE); + +// Auf Ladehinweis screenshoten (kann sehr kurz sein), dann auf Ergebnis warten. +await new Promise((r) => setTimeout(r, 150)); +await p.screenshot({ path: "scripts/probe-dwg-loading.png" }).catch(() => {}); + +// Warten, bis der Dialog die echte Zusammenfassung zeigt (oder bis Timeout). +let summaryText = ""; +for (let i = 0; i < 60; i++) { + await new Promise((r) => setTimeout(r, 250)); + summaryText = await p.evaluate(() => { + const sum = document.querySelector(".imp-summary"); + const hint = document.querySelector(".imp-dwg-hint"); + return JSON.stringify({ + summary: sum ? sum.textContent : null, + hint: hint ? hint.textContent : null, + }); + }); + const parsed = JSON.parse(summaryText); + if (parsed.summary) break; // echte Zusammenfassung erschienen +} + +await p.screenshot({ path: "scripts/probe-dwg-import.png" }); +const parsed = JSON.parse(summaryText); +console.log("Dialog-Zusammenfassung:", parsed.summary); +console.log("ODA-Hinweis (sollte null sein bei Erfolg):", parsed.hint); +console.log("Logs:", logs.length ? logs.join(" | ") : "(keine)"); + +if (!parsed.summary) { + console.log("FAIL: keine echte Zusammenfassung — DWG nicht geparst."); + await b.close(); + process.exit(1); +} +console.log("OK: DWG über den Import-Pfad geparst, echte Zusammenfassung sichtbar."); +await b.close(); diff --git a/scripts/probe-dwg.mjs b/scripts/probe-dwg.mjs new file mode 100644 index 0000000..86111e0 --- /dev/null +++ b/scripts/probe-dwg.mjs @@ -0,0 +1,95 @@ +// Node-Laufzeittest für den echten DWG-Import-Pfad (LibreDWG-WASM). +// +// Bündelt src/io/dwgParser.ts (samt Model-Typen) per esbuild zu einem ESM-Modul +// und ruft parseDwg() mit echten DWG-Beispieldateien auf. Loggt die erkannten +// Konturen/Meshes — spiegelt damit exakt das, was der Import-Dialog anzeigen +// würde (dieselbe parseDwg-Funktion, die auch der Browser-Import nutzt). +// +// Aufruf: node scripts/probe-dwg.mjs [pfad/zur.dwg ...] +// Ohne Argumente werden einige bekannte System-Beispiele probiert. + +import { build } from "esbuild"; +import { readFile, writeFile, unlink, access } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, ".."); + +// parseDwg + Abhängigkeiten zu einem temporären ESM-Bundle backen. WASM-Lib +// bleibt extern (wird zur Laufzeit aus node_modules geladen → echtes WASM). +const outFile = resolve(root, "scripts/_dwgparser.bundle.mjs"); +await build({ + entryPoints: [resolve(root, "src/io/dwgParser.ts")], + bundle: true, + format: "esm", + platform: "node", + outfile: outFile, + external: ["@mlightcad/libredwg-web"], + logLevel: "warning", +}); + +const { parseDwg } = await import(outFile + "?t=" + Date.now()); + +const candidates = + process.argv.slice(2).length > 0 + ? process.argv.slice(2) + : [ + "/home/karim/Downloads/02-15-cad-blocks-net-bushes-elevation.dwg", + "/home/karim/ssd/Nextcloud2 (Copy)/00.2 Situation Fluhmatt Bodengeschosse.dwg", + "/tmp/probe.dwg", + ]; + +let anyOk = false; +for (const path of candidates) { + try { + await access(path); + } catch { + console.log(`SKIP (nicht gefunden): ${path}`); + continue; + } + try { + const buf = await readFile(path); + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + const t0 = Date.now(); + const res = await parseDwg(ab); + const dt = Date.now() - t0; + const contourSets = res.contours; + const contourCount = contourSets.reduce( + (s, set) => s + set.contours.length, + 0, + ); + const layers = new Set(); + for (const set of contourSets) + for (const c of set.contours) if (c.layer) layers.add(c.layer); + const triCount = res.meshes.reduce((s, m) => s + m.indices.length / 3, 0); + console.log(`\nOK ${path} (${dt} ms)`); + console.log(` Konturen-Sätze: ${contourSets.length}`); + console.log(` Konturen: ${contourCount}`); + console.log(` Meshes: ${res.meshes.length} (${triCount} Dreiecke)`); + console.log( + ` Layer: ${ + layers.size ? [...layers].slice(0, 12).join(", ") : "(keine)" + }`, + ); + // Beispiel-Kontur (erste mit ≥2 Punkten) ausgeben. + const sample = contourSets + .flatMap((s) => s.contours) + .find((c) => c.pts.length >= 2); + if (sample) { + console.log( + ` Beispiel-Kontur: ${sample.pts.length} Pkt, closed=${sample.closed}, z=${sample.z}, layer=${sample.layer ?? "-"}`, + ); + } + anyOk = true; + } catch (err) { + console.log(`\nFEHLER ${path}:`, err?.message ?? err); + } +} + +await unlink(outFile).catch(() => {}); + +if (!anyOk) { + console.log("\nKeine DWG erfolgreich gelesen."); + process.exit(1); +} diff --git a/scripts/probe-edge-grips.mjs b/scripts/probe-edge-grips.mjs new file mode 100644 index 0000000..fe948aa --- /dev/null +++ b/scripts/probe-edge-grips.mjs @@ -0,0 +1,46 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 700)); + +const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 100)); }; +const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); + +// 1) Auswahl-Werkzeug ist Default: die OBERE Wand des Beispiel-Raums anklicken +// → Eckpunkt-Griffe (Quadrate) + 1 dreieckiger Kanten-Anfasser erscheinen. +const tx = box.x + box.w * 0.5; +const ty = box.y + box.h * 0.26; // mittig auf das obere Wandband +await click(tx, ty); +const c1 = await page.evaluate(() => ({ + grips: document.querySelectorAll(".plan-svg .plan-grip").length, + edges: document.querySelectorAll(".plan-svg .plan-edge-grip").length, +})); +await page.screenshot({ path: "scripts/probe-edge-grips-selected.png" }); + +// 2) Den Kanten-Anfasser (sitzt etwas OBERHALB der Kante, da Außennormale nach +// oben zeigt) nach OBEN ziehen → die ganze Wand verschiebt sich senkrecht. +const handleX = tx; +const handleY = ty - 13; // EDGE_GRIP_OFFSET_PX nach außen (oben) +await page.mouse.move(handleX, handleY); +await page.mouse.down(); +await page.mouse.move(handleX, handleY - 100, { steps: 10 }); +await page.mouse.up(); +await new Promise((r) => setTimeout(r, 200)); +const c2 = await page.evaluate(() => ({ + grips: document.querySelectorAll(".plan-svg .plan-grip").length, + edges: document.querySelectorAll(".plan-svg .plan-edge-grip").length, +})); +await page.screenshot({ path: "scripts/probe-edge-grips-dragged.png" }); + +console.log("after select:", JSON.stringify(c1)); +console.log("after drag:", JSON.stringify(c2)); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-edge2.mjs b/scripts/probe-edge2.mjs new file mode 100644 index 0000000..6a3ab8c --- /dev/null +++ b/scripts/probe-edge2.mjs @@ -0,0 +1,43 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +// Screen-bbox aller Wand-Polygone bestimmen, dann auf die obere Bandkante klicken. +const bb=await p.evaluate(()=>{ + const polys=[...document.querySelectorAll(".plan-svg polygon")]; + if(!polys.length) return null; + let minX=1e9,minY=1e9,maxX=-1e9,maxY=-1e9; + for(const pl of polys){ const r=pl.getBoundingClientRect(); minX=Math.min(minX,r.left);minY=Math.min(minY,r.top);maxX=Math.max(maxX,r.right);maxY=Math.max(maxY,r.bottom);} + return {minX,minY,maxX,maxY}; +}); +let sel=false; +if(bb){ + const cx=(bb.minX+bb.maxX)/2; + // ein paar Punkte auf der oberen Bandkante probieren + for(const dy of [4,8,12,16]){ + await p.mouse.click(cx, bb.minY+dy); await new Promise(r=>setTimeout(r,120)); + const k=await p.evaluate(()=>document.querySelector(".objinfo-kind")?.textContent||""); + if(/Wand/.test(k)){ sel=true; break; } + } +} +const dims=()=>p.evaluate(()=>[...document.querySelectorAll(".objinfo-input")].map(i=>i.value)); +const before=await dims(); +const tri=await p.evaluate(()=>{ + const el=document.querySelector(".plan-edge-grip"); if(!el) return null; + const pts=el.getAttribute("points").trim().split(/\s+/).map(s=>s.split(",").map(Number)); + const svg=el.ownerSVGElement; const ctm=svg.getScreenCTM(); + const cs=pts.map(([x,y])=>{const pt=svg.createSVGPoint();pt.x=x;pt.y=y;const sp=pt.matrixTransform(ctm);return [sp.x,sp.y];}); + return {cx:cs.reduce((a,c)=>a+c[0],0)/3, cy:cs.reduce((a,c)=>a+c[1],0)/3, count:document.querySelectorAll(".plan-edge-grip").length}; +}); +let after=before, dragged=false; +if(tri){ + await p.mouse.move(tri.cx,tri.cy); await p.mouse.down(); + for(let i=1;i<=6;i++){ await p.mouse.move(tri.cx, tri.cy - i*8); await new Promise(r=>setTimeout(r,20)); } + await p.mouse.up(); await new Promise(r=>setTimeout(r,200)); + after=await dims(); dragged=true; +} +await p.screenshot({path:"scripts/probe-edge2.png"}); +console.log(JSON.stringify({wallSelected:sel, edgeGripCount:tri&&tri.count, dims_before:before, dims_after:after, errs}, null, 2)); +await b.close(); diff --git a/scripts/probe-edit-commands.mjs b/scripts/probe-edit-commands.mjs new file mode 100644 index 0000000..fb50382 --- /dev/null +++ b/scripts/probe-edit-commands.mjs @@ -0,0 +1,248 @@ +// Probe für die Tier-1-Editierbefehle: Move / Copy / Offset. +// +// Treibt das Rhino-artige Befehlssystem headless und prüft die EXAKTE Geometrie +// aus dem gerenderten SVG (Drawing2D → `line.draw2d`, Screen→Modell via +// model = {x: x/90, y: -y/90}; PX_PER_M=90, Y-flip). Schritte: +// 1) line „0,0"→„r4,0" → eine waagerechte 4-m-Linie bei y=0 +// 2) Linie anklicken (selektieren) → move „0,0"→„0,2" → Linie liegt bei y=2 +// 3) copy „0,0"→„r2,0" → zwei Linien (Original + Kopie 2 m rechts) +// 4) eine Polylinie zeichnen + selektieren → offset Distanz 0.5 + Seite +// → zweite, parallel um 0.5 versetzte Kurve +// +// Vorlage: scripts/probe-command-line.mjs / probe-draw-commands.mjs. + +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 }); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} +await sleep(600); + +const PX = 90; // PX_PER_M + +// Liest alle Drawing2D-Liniensegmente als Modell-Koordinaten (Screen→Modell). +async function segs() { + return page.evaluate((PX) => { + const out = []; + for (const ln of document.querySelectorAll(".plan-svg line.draw2d")) { + // Bildschirm-CTM: SVG-User → Screen. Wir wollen Modell, daher rechnen wir + // über die Element-Endpunkte im SVG-User-Raum (x1/y1/x2/y2) zurück. + const x1 = parseFloat(ln.getAttribute("x1")); + const y1 = parseFloat(ln.getAttribute("y1")); + const x2 = parseFloat(ln.getAttribute("x2")); + const y2 = parseFloat(ln.getAttribute("y2")); + out.push({ + a: { x: x1 / PX, y: -y1 / PX }, + b: { x: x2 / PX, y: -y2 / PX }, + }); + } + return out; + }, PX); +} + +// Klick-Zentrum eines .draw2d-Segments in Client-Pixeln (über BoundingClientRect), +// damit die Auswahl pan/zoom-unabhängig trifft. `idx` = welches Segment. +async function clickSegment(idx) { + const box = await page.evaluate((idx) => { + const lines = document.querySelectorAll(".plan-svg line.draw2d"); + const ln = lines[idx]; + if (!ln) return null; + const r = ln.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, idx); + if (!box) { + logs.push(`[WARN] Segment ${idx} nicht gefunden (übersprungen)`); + return false; + } + await page.mouse.click(box.x, box.y); + await sleep(120); + return true; +} + +// Fokussiert die Command-Line DIREKT (ohne Plan-Klick — ein Plan-Klick würde die +// Auswahl löschen, die Move/Copy/Offset gerade brauchen). Das Input ist immer im +// DOM; ein Fokus genügt, danach landet getippter Text in der Engine. +const focusCmd = async () => { + await page.focus(".cmdline-input"); + await sleep(60); +}; +const typeLine = async (t) => { + await page.type(".cmdline-input", t, { delay: 8 }); + await page.keyboard.press("Enter"); + await sleep(110); +}; +const prompt = () => + page.evaluate(() => document.querySelector(".cmdline-prompt")?.textContent || ""); + +// Hilfsfunktion: nahe Gleichheit. +const near = (a, b, eps = 0.02) => Math.abs(a - b) <= eps; + +// ── 1) Linie zeichnen: 0,0 → r4,0 (waagerecht, y=0) ───────────────────────── +await focusCmd(); +await typeLine("line"); +await typeLine("0,0"); +await typeLine("r4,0"); +await sleep(150); +const afterLine = await segs(); + +// ── 2) Linie selektieren + Move 0,0 → 0,2 ─────────────────────────────────── +await clickSegment(0); // die eine Linie wählen +const gripsAfterClick = await page.evaluate( + () => document.querySelectorAll(".plan-svg .plan-grip").length, +); +await focusCmd(); +const gripsAfterFocus = await page.evaluate( + () => document.querySelectorAll(".plan-svg .plan-grip").length, +); +const moveSelInfo = gripsAfterClick; +logs.push(`[DBG] grips afterClick=${gripsAfterClick} afterFocus=${gripsAfterFocus}`); +await typeLine("move"); +const movePrompt = await prompt(); +await typeLine("0,0"); +const moveTargetPrompt = await prompt(); +await typeLine("0,2"); +await sleep(150); +const afterMove = await segs(); +await page.screenshot({ path: "scripts/probe-edit-move.png" }); + +// ── 3) Copy: 0,0 → r2,0 (Auswahl bleibt nach Move bestehen) ───────────────── +// Move hält die Auswahl (selectedDrawingId unverändert). Falls nicht, neu wählen. +await clickSegment(0); +await focusCmd(); +await typeLine("copy"); +const copyPrompt = await prompt(); +await typeLine("0,0"); +logs.push(`[DBG] copy after base: segs=${(await segs()).length}`); +await typeLine("r2,0"); +logs.push(`[DBG] copy after r2,0: segs=${(await segs()).length}`); +await page.keyboard.press("Enter"); // Copy-Serie beenden +await sleep(150); +const afterCopy = await segs(); +logs.push(`[DBG] copy after Enter: segs=${afterCopy.length}`); +await page.screenshot({ path: "scripts/probe-edit-copy.png" }); + +// ── 4) Offset einer Polylinie um 0.5 ──────────────────────────────────────── +// Frische Polylinie: ein offener L-Zug (3,-3)→(7,-3)→(7,-1). Tief unten (y<0), +// damit sie sich nicht mit den obigen Linien überlagert. +await focusCmd(); +await typeLine("polyline"); +await typeLine("3,-3"); +await typeLine("7,-3"); +await typeLine("7,-1"); +await page.keyboard.press("Enter"); // offenen Zug beenden +await sleep(150); +const afterPoly = await segs(); +logs.push(`[DBG] afterPoly segs=${JSON.stringify(afterPoly.map((s) => ({ a: { x: +s.a.x.toFixed(2), y: +s.a.y.toFixed(2) }, b: { x: +s.b.x.toFixed(2), y: +s.b.y.toFixed(2) } })))}`); + +// Eines der Polylinien-Segmente selektieren (das letzte gezeichnete Segment). +// Wir suchen ein Segment mit y≈-3 (die untere waagerechte Kante). +const polyIdx = await page.evaluate((PX) => { + const lines = [...document.querySelectorAll(".plan-svg line.draw2d")]; + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + const y1 = -parseFloat(ln.getAttribute("y1")) / PX; + const y2 = -parseFloat(ln.getAttribute("y2")) / PX; + if (Math.abs(y1 + 3) < 0.05 && Math.abs(y2 + 3) < 0.05) return i; + } + return -1; +}, PX); +await clickSegment(polyIdx); +const offsetSelGrips = await page.evaluate( + () => document.querySelectorAll(".plan-svg .plan-grip").length, +); + +await focusCmd(); +await typeLine("offset"); +const offsetPrompt = await prompt(); +await typeLine("0.5"); // Distanz tippen (Seite via Vorzeichen/Default links) +await sleep(150); +const afterOffset = await segs(); +await page.screenshot({ path: "scripts/probe-edit-offset.png" }); + +// ── Auswertung ─────────────────────────────────────────────────────────────── +function summarize(list) { + return list.map((s) => ({ + a: { x: +s.a.x.toFixed(3), y: +s.a.y.toFixed(3) }, + b: { x: +s.b.x.toFixed(3), y: +s.b.y.toFixed(3) }, + })); +} + +// Move-Check: nach Move soll genau eine Linie existieren, beide Endpunkte y≈2. +const moveLine = afterMove.find( + (s) => near(s.a.y, 2) && near(s.b.y, 2) && (near(s.a.x, 0) || near(s.b.x, 0)), +); +const moveOk = + afterLine.length === afterMove.length && // gleiche Anzahl (kein neues Element) + !!moveLine && + near(Math.abs(moveLine.b.x - moveLine.a.x), 4); // Länge bleibt 4 m + +// Copy-Check: nach Copy zwei waagerechte Linien bei y≈2; eine bei x∈[0,4], eine +// um 2 m nach rechts (x∈[2,6]). +const horizAtY2 = afterCopy.filter((s) => near(s.a.y, 2) && near(s.b.y, 2)); +const xsMin = horizAtY2.map((s) => Math.min(s.a.x, s.b.x)).sort((p, q) => p - q); +const copyOk = + afterCopy.length === afterMove.length + 1 && // genau ein Segment dazu + horizAtY2.length === 2 && + near(xsMin[0], 0) && + near(xsMin[1], 2); + +// Offset-Check: das Offset fügt eine versetzte Polylinie hinzu. Wir prüfen, dass +// eine NEUE untere Kante bei y≈-3±0.5 existiert (parallel, Abstand 0.5). Die +// Seite (oben/unten) ergibt sich aus der Default-Klickseite; wir akzeptieren +// beide Vorzeichen, prüfen aber den Abstand = 0.5. +const addedByOffset = afterOffset.length - afterPoly.length; +// Untere Original-Kante: y=-3, von x=3..7. Versetzte Kante: y=-3±0.5. +const bottomEdges = afterOffset.filter( + (s) => near(s.a.y, s.b.y) && Math.abs(s.a.x - s.b.x) > 1.0, // waagerechtes Segment +); +const ysBottom = [...new Set(bottomEdges.map((s) => +s.a.y.toFixed(3)))].sort( + (p, q) => p - q, +); +// Erwartung: y=-3 (Original) + eine parallele Kante 0.5 entfernt. +const hasParallel = ysBottom.some( + (y) => near(Math.abs(y - -3), 0.5) && !near(y, -3), +); +const offsetOk = addedByOffset >= 2 && hasParallel; + +console.log( + JSON.stringify( + { + prompts: { movePrompt, moveTargetPrompt, copyPrompt, offsetPrompt }, + counts: { + afterLine: afterLine.length, + afterMove: afterMove.length, + afterCopy: afterCopy.length, + afterPoly: afterPoly.length, + afterOffset: afterOffset.length, + addedByOffset, + }, + grips: { moveSelInfo, offsetSelGrips }, + moveLine: moveLine ? summarize([moveLine])[0] : null, + horizAtY2: summarize(horizAtY2), + ysBottom, + checks: { moveOk, copyOk, offsetOk }, + verdict: moveOk && copyOk && offsetOk ? "PASS" : "FAIL", + }, + null, + 2, + ), +); +if (logs.length) { + console.log("=== Logs ==="); + console.log(logs.join("\n")); +} +await browser.close(); diff --git a/scripts/probe-edit2.mjs b/scripts/probe-edit2.mjs new file mode 100644 index 0000000..0aedb33 --- /dev/null +++ b/scripts/probe-edit2.mjs @@ -0,0 +1,69 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 600)); +const clickTool = (label) => page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label); +const click = async (x, y, opts) => { await page.mouse.move(x, y); await page.mouse.click(x, y, opts); await new Promise((r) => setTimeout(r, 60)); }; +const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); +const cx = box.x + box.w / 2, cy = box.y + box.h / 2; + +// ── 1) Aktive Ebene auf "60 Plangrafik" (#c0a040) setzen, Linie zeichnen ── +await page.evaluate(() => { + const sel = [...document.querySelectorAll("select")].find((s) => [...s.options].some((o) => o.value === "60")); + if (sel) sel.id = "ebene-test"; +}); +await page.select("#ebene-test", "60"); +await new Promise((r) => setTimeout(r, 100)); +await clickTool("Linie"); +await new Promise((r) => setTimeout(r, 80)); +await click(cx - 150, cy - 120); await click(cx + 120, cy - 60); +await clickTool("Auswahl"); +await new Promise((r) => setTimeout(r, 80)); +const lineColor = await page.evaluate(() => document.querySelector(".plan-svg line.draw2d")?.getAttribute("stroke")); + +// ── 2) Parallel verschieben: Linie selektieren, Körper greifen + ziehen ── +const mid = [(cx - 150 + cx + 120) / 2, (cy - 120 + cy - 60) / 2]; +await click(mid[0], mid[1]); // selektieren +const before = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1") }; }); +await page.mouse.move(mid[0], mid[1]); await page.mouse.down(); +await page.mouse.move(mid[0] + 60, mid[1] + 120, { steps: 6 }); await page.mouse.up(); +await new Promise((r) => setTimeout(r, 100)); +const after = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1") }; }); +const moved = Math.hypot(after.x1 - before.x1, after.y1 - before.y1) > 5; + +// ── 3) Shift-Ortho: Endpunkt-Griff mit Shift ziehen → achsparallel ── +// Neue Linie zeichnen. +await clickTool("Linie"); +await new Promise((r) => setTimeout(r, 60)); +const a = [cx - 180, cy + 90], b = [cx - 40, cy + 140]; +await click(...a); await click(...b); +await clickTool("Auswahl"); +await new Promise((r) => setTimeout(r, 60)); +await click((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); // selektieren +// Endpunkt b mit Shift senkrecht-ish wegziehen → sollte exakt H oder V werden. +await page.keyboard.down("Shift"); +await page.mouse.move(b[0], b[1]); await page.mouse.down(); +await page.mouse.move(b[0] + 140, b[1] + 20, { steps: 8 }); // fast horizontal +await page.mouse.up(); +await page.keyboard.up("Shift"); +await new Promise((r) => setTimeout(r, 100)); +// Die gerade bearbeitete Linie ist die ZWEITE draw2d-Linie. +const orthoLine = await page.evaluate(() => { + const ls = document.querySelectorAll(".plan-svg line.draw2d"); + const l = ls[ls.length - 1]; + return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1"), x2: +l.getAttribute("x2"), y2: +l.getAttribute("y2") }; +}); +const dx = Math.abs(orthoLine.x2 - orthoLine.x1), dy = Math.abs(orthoLine.y2 - orthoLine.y1); +const isOrtho = dx < 1 || dy < 1; // exakt H oder V + +await page.screenshot({ path: "scripts/probe-edit2.png" }); +console.log("lineColor:", lineColor, "(expect #c0a040)"); +console.log("parallelMoved:", moved); +console.log("shiftOrtho dx,dy:", dx.toFixed(2), dy.toFixed(2), "isOrtho:", isOrtho); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-ff-nowebgl.mjs b/scripts/probe-ff-nowebgl.mjs new file mode 100644 index 0000000..e3ee556 --- /dev/null +++ b/scripts/probe-ff-nowebgl.mjs @@ -0,0 +1,14 @@ +import { firefox } from "playwright"; +const browser = await firefox.launch({ firefoxUserPrefs: { "webgl.disabled": true } }); +const page = await browser.newPage(); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle", timeout: 20000 }); } +catch (e) { logs.push(`[GOTO-ERROR] ${e.message}`); } +await page.waitForTimeout(1500); +const root = await page.evaluate(() => { const r = document.getElementById("root"); return r ? r.innerHTML.length : -1; }); +console.log("=== mit WebGL DEAKTIVIERT ==="); +console.log("#root innerHTML Länge:", root, root === 0 ? "→ KOMPLETT WEISS (reproduziert!)" : ""); +console.log(logs.join("\n")); +await browser.close(); diff --git a/scripts/probe-ff-verify.mjs b/scripts/probe-ff-verify.mjs new file mode 100644 index 0000000..9d9324b --- /dev/null +++ b/scripts/probe-ff-verify.mjs @@ -0,0 +1,21 @@ +import { firefox } from "playwright"; +const browser = await firefox.launch({ firefoxUserPrefs: { "webgl.disabled": true } }); +const page = await browser.newPage({ viewport: { width: 1200, height: 760 } }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +await page.goto("http://localhost:5173/", { waitUntil: "networkidle", timeout: 20000 }); +await page.waitForTimeout(1200); +const info = await page.evaluate(() => ({ + rootLen: document.getElementById("root")?.innerHTML.length ?? -1, + hasFallback: !!document.querySelector(".viewport-fallback"), + hasPlanSvg: !!document.querySelector(".plan-svg polygon"), + header: document.querySelector(".brand")?.textContent?.trim().slice(0, 20), +})); +await page.screenshot({ path: "scripts/probe-ff-verify.png" }); +console.log("=== Firefox OHNE WebGL — nach Fix ==="); +console.log("root innerHTML Länge:", info.rootLen, info.rootLen > 0 ? "✅ nicht weiß" : "❌ weiß"); +console.log("Fallback-Meldung sichtbar:", info.hasFallback ? "✅" : "❌"); +console.log("Grundriss-SVG gerendert:", info.hasPlanSvg ? "✅" : "❌"); +console.log("Header:", JSON.stringify(info.header)); +console.log("PageErrors:", logs.length ? logs.join("\n") : "✅ keine"); +await browser.close(); diff --git a/scripts/probe-ff.mjs b/scripts/probe-ff.mjs new file mode 100644 index 0000000..b7c5e07 --- /dev/null +++ b/scripts/probe-ff.mjs @@ -0,0 +1,31 @@ +import { firefox } from "playwright"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await firefox.launch(); +const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: 2 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}\n${e.stack || ""}`)); +page.on("requestfailed", (r) => + logs.push(`[REQFAIL] ${r.url()} :: ${r.failure()?.errorText}`), +); + +try { + await page.goto(URL, { waitUntil: "networkidle", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} +await page.waitForTimeout(1500); + +const rootHtml = await page.evaluate(() => { + const r = document.getElementById("root"); + return r ? r.innerHTML.slice(0, 300) : "NO #root"; +}); + +await page.screenshot({ path: "scripts/probe-ff.png" }); +console.log("=== FIREFOX CONSOLE / ERRORS ==="); +console.log(logs.length ? logs.join("\n") : "(keine Logs)"); +console.log("\n=== #root innerHTML (erste 300 Zeichen) ==="); +console.log(rootHtml); +await browser.close(); diff --git a/scripts/probe-grips.mjs b/scripts/probe-grips.mjs new file mode 100644 index 0000000..8d843e6 --- /dev/null +++ b/scripts/probe-grips.mjs @@ -0,0 +1,65 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 600)); + +const clickTool = (label) => + page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label); +const click = async (x, y, opts) => { await page.mouse.move(x, y); await page.mouse.click(x, y, opts); await new Promise((r) => setTimeout(r, 80)); }; +const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); +const cx = box.x + box.w / 2, cy = box.y + box.h / 2; + +// 1) Eine Wand zeichnen (A→B), Rechtsklick beendet. +await clickTool("Wand"); +await new Promise((r) => setTimeout(r, 120)); +const A = [cx - 160, cy - 150], B = [cx + 160, cy - 150]; +await click(...A); await click(...B); +await page.mouse.click(B[0], B[1], { button: "right" }); +await new Promise((r) => setTimeout(r, 150)); + +// 2) Auswahl-Werkzeug, Wand in der Mitte anklicken → Griffe erscheinen. +await clickTool("Auswahl"); +await new Promise((r) => setTimeout(r, 120)); +await click(cx, cy - 150); +const gripsAfterSelect = await page.evaluate(() => document.querySelectorAll(".plan-svg .plan-grip").length); +await page.screenshot({ path: "scripts/probe-grips-selected.png" }); + +// 3) Den End-Griff bei B nach unten ziehen. +await page.mouse.move(B[0], B[1]); +await page.mouse.down(); +await page.mouse.move(B[0], B[1] + 90, { steps: 6 }); +await page.mouse.move(B[0] - 30, B[1] + 130, { steps: 6 }); +await page.mouse.up(); +await new Promise((r) => setTimeout(r, 150)); +await page.screenshot({ path: "scripts/probe-grips-dragged.png" }); + +// 4) Eine Linie zeichnen, auswählen, Endpunkt-Griff prüfen. +await clickTool("Linie"); +await new Promise((r) => setTimeout(r, 100)); +const L1 = [cx - 180, cy + 120], L2 = [cx + 150, cy + 160]; +await click(...L1); await click(...L2); +await clickTool("Auswahl"); +await new Promise((r) => setTimeout(r, 100)); +// Mittig auf die Linie klicken (Mittelpunkt der beiden Endpunkte). +await click((L1[0] + L2[0]) / 2, (L1[1] + L2[1]) / 2); +const gripsLine = await page.evaluate(() => document.querySelectorAll(".plan-svg .plan-grip").length); +await page.screenshot({ path: "scripts/probe-grips-line.png" }); + +// 5) Linie löschen (Entf). +await page.keyboard.press("Delete"); +await new Promise((r) => setTimeout(r, 120)); +const draw2dAfterDelete = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length); + +console.log("gripsAfterSelect:", gripsAfterSelect); +console.log("gripsLine:", gripsLine); +console.log("draw2dAfterDelete:", draw2dAfterDelete); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-import-dialog.mjs b/scripts/probe-import-dialog.mjs new file mode 100644 index 0000000..b07afa6 --- /dev/null +++ b/scripts/probe-import-dialog.mjs @@ -0,0 +1,132 @@ +// Probe: DXF-Import-Dialog. Erzeugt eine kleine Test-DXF (LWPOLYLINE auf 2 +// Layern), füttert sie in das versteckte Datei-Input, screenshotet den offenen +// Dialog, wählt „Neue Zeichnung" + Importieren und screenshotet den Grundriss. +import puppeteer from "puppeteer"; +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const URL = process.env.PROBE_URL || "http://localhost:5174/"; + +// ── Minimale Test-DXF: 2 LWPOLYLINE auf Layern „Waende" und „Moeblierung". ── +const dxf = `0 +SECTION +2 +ENTITIES +0 +LWPOLYLINE +8 +Waende +90 +5 +70 +1 +10 +0 +20 +0 +10 +8 +20 +0 +10 +8 +20 +5 +10 +0 +20 +5 +10 +0 +20 +0 +0 +LWPOLYLINE +8 +Moeblierung +90 +4 +70 +0 +10 +2 +20 +1 +10 +4 +20 +1 +10 +4 +20 +3 +10 +2 +20 +3 +0 +ENDSEC +0 +EOF +`; +const dxfPath = join(tmpdir(), "probe-import.dxf"); +writeFileSync(dxfPath, dxf); + +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 }); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); + +// Datei in das versteckte DXF-Input legen → onChange öffnet den Dialog. +const input = await page.$('input[type="file"][accept*=".dxf"]'); +if (!input) throw new Error("DXF-Datei-Input nicht gefunden"); +await input.uploadFile(dxfPath); + +await page.waitForSelector(".imp-dialog", { timeout: 5000 }); +await new Promise((r) => setTimeout(r, 300)); +await page.screenshot({ path: "scripts/probe-import-dialog.png" }); +console.log("Dialog-Screenshot: scripts/probe-import-dialog.png"); + +// Zusammenfassung auslesen (Konturen/Layer sichtbar?). +const summary = await page.$$eval(".imp-summary li", (lis) => + lis.map((l) => l.textContent), +); +console.log("Zusammenfassung:", JSON.stringify(summary)); + +// Sicherstellen, dass „Neue Zeichnung" gewählt ist (Default), dann Importieren. +const importBtn = await page.$(".imp-btn.primary"); +const disabled = await page.evaluate((b) => b.disabled, importBtn); +console.log("Import-Button disabled?", disabled); +await importBtn.click(); + +await new Promise((r) => setTimeout(r, 600)); + +// Dialog sollte geschlossen sein; Grundriss/Drawing-Ansicht der neuen Ebene. +const dialogGone = (await page.$(".imp-dialog")) === null; +console.log("Dialog geschlossen?", dialogGone); + +// Anzahl Zeichnungsebenen-Zeilen + neuer „Zeichnung"-Eintrag prüfen (best effort). +const drawingCount = await page.evaluate(() => { + const project = window.__store?.getState?.()?.project; + if (!project) return null; + return { + drawingLevels: project.drawingLevels.map((z) => `${z.name}/${z.kind}`), + drawings2d: project.drawings2d.length, + }; +}); +console.log("Projekt:", JSON.stringify(drawingCount)); + +await page.screenshot({ path: "scripts/probe-import-plan.png" }); +console.log("Plan-Screenshot: scripts/probe-import-plan.png"); + +console.log("\n=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-import.mjs b/scripts/probe-import.mjs new file mode 100644 index 0000000..fdfcba5 --- /dev/null +++ b/scripts/probe-import.mjs @@ -0,0 +1,20 @@ +import puppeteer from "puppeteer"; +const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] }); +const p = await b.newPage(); await p.setViewport({width:1300,height:850,deviceScaleFactor:2}); +const logs=[]; p.on("pageerror",e=>logs.push(e.message)); +await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +// Ressourcen öffnen. +await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim().toLowerCase().includes("ressourc"))?.click()); +await new Promise(r=>setTimeout(r,300)); +// Tab "Linien". +await p.evaluate(()=>[...document.querySelectorAll(".res-tab-btn")].find(x=>/Linien|Lines/.test(x.textContent))?.click()); +await new Promise(r=>setTimeout(r,200)); +const before = await p.evaluate(()=>document.querySelectorAll(".res-line-preview").length); +const input = await p.$('.res-tab input[type=file]'); +await input.uploadFile("/tmp/sample.lin"); +await new Promise(r=>setTimeout(r,400)); +const after = await p.evaluate(()=>document.querySelectorAll(".res-line-preview").length); +await p.screenshot({ path:"scripts/probe-import.png" }); +console.log("lineStyles before:", before, "after:", after, "(expect +3)", logs.length?logs.join("|"):"(no err)"); +await b.close(); diff --git a/scripts/probe-iso.mjs b/scripts/probe-iso.mjs new file mode 100644 index 0000000..09ac192 --- /dev/null +++ b/scripts/probe-iso.mjs @@ -0,0 +1,12 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +const clickText=async t=>{const h=await p.evaluateHandle(t=>[...document.querySelectorAll("button")].find(b=>b.textContent.trim()===t),t);const el=h.asElement();if(el){await el.click();return true;}return false;}; +await clickText("Perspektive"); await new Promise(r=>setTimeout(r,500)); +await clickText("Isometrie"); await new Promise(r=>setTimeout(r,800)); +await p.screenshot({path:"scripts/probe-iso.png"}); +console.log("iso done, errs:", errs.join("|")||"none"); +await b.close(); diff --git a/scripts/probe-layer.mjs b/scripts/probe-layer.mjs new file mode 100644 index 0000000..6e52162 --- /dev/null +++ b/scripts/probe-layer.mjs @@ -0,0 +1,30 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] }); +const p = await b.newPage(); +await p.setViewport({ width:1200, height:800, deviceScaleFactor:2 }); +await p.emulateMediaFeatures([{ name:"prefers-color-scheme", value:"dark" }]); +const logs=[]; p.on("pageerror",e=>logs.push(e.message)); +await p.goto(URL,{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,600)); +// Auf den "Ebenen"-Tab wechseln (TabStrip braucht Pointer-Events). +await p.evaluate(()=>{ const t=[...document.querySelectorAll(".tab")].find(e=>e.textContent.trim()==="Ebenen"); ["pointerdown","pointerup","click"].forEach(ev=>t.dispatchEvent(new MouseEvent(ev,{bubbles:true}))); }); +await new Promise(r=>setTimeout(r,250)); +const initialActive = await p.evaluate(()=>document.querySelector(".cat-row.active .cat-name")?.textContent ?? null); +// "Türen/Fenster" anklicken. +await p.evaluate(()=>{ const r=[...document.querySelectorAll(".cat-name")].find(e=>e.textContent.includes("Türen"))?.closest(".cat-row"); r?.click(); }); +await new Promise(r=>setTimeout(r,250)); +const afterActive = await p.evaluate(()=>document.querySelector(".cat-row.active .cat-name")?.textContent ?? null); +const statusLayer = await p.evaluate(()=>{ const f=[...document.querySelectorAll(".sb-field")].find(x=>x.textContent.trim().startsWith("EBENE")||x.textContent.includes("Ebene")); return f?.querySelector(".sb-val")?.textContent; }); +// Jetzt eine Linie zeichnen → sollte auf Kategorie 21 (blau #5080c8) landen. +await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Linie")?.click()); +await new Promise(r=>setTimeout(r,80)); +const box = await p.evaluate(()=>{const r=document.querySelector(".plan-svg").getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height};}); +const cx=box.x+box.w/2, cy=box.y+box.h/2; +for(const [x,y] of [[cx-120,cy-30],[cx+120,cy+30]]){ await p.mouse.move(x,y); await p.mouse.click(x,y); await new Promise(r=>setTimeout(r,60)); } +await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Auswahl")?.click()); +await new Promise(r=>setTimeout(r,80)); +const lineColor = await p.evaluate(()=>document.querySelector(".plan-svg line.draw2d")?.getAttribute("stroke")); +await p.screenshot({ path:"scripts/probe-layer.png" }); +console.log("initialActive:", initialActive, "afterActive:", afterActive, "statusLayer:", statusLayer, "lineColor(expect #5080c8):", lineColor, logs.length?logs.join("|"):"(no err)"); +await b.close(); diff --git a/scripts/probe-line.mjs b/scripts/probe-line.mjs new file mode 100644 index 0000000..b54d5de --- /dev/null +++ b/scripts/probe-line.mjs @@ -0,0 +1,28 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 600)); +await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "Linie")?.click()); +await new Promise((r) => setTimeout(r, 150)); +const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); +const cx = box.x + box.w / 2, cy = box.y + box.h / 2; +// Zwei-Klick-Strecke quer durch den Raum. +for (const [x, y] of [[cx - 200, cy - 40], [cx + 200, cy + 90]]) { + await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up(); + await new Promise((r) => setTimeout(r, 100)); +} +await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "Auswahl")?.click()); +await new Promise((r) => setTimeout(r, 200)); +const draw2d = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length); +await page.screenshot({ path: "scripts/probe-line.png" }); +console.log("draw2d lines:", draw2d); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-offset2.mjs b/scripts/probe-offset2.mjs new file mode 100644 index 0000000..c17cac0 --- /dev/null +++ b/scripts/probe-offset2.mjs @@ -0,0 +1,30 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +const focusCmd=async()=>{ const el=await p.$(".cmdline-input"); const bx=await el.boundingBox(); await p.mouse.click(bx.x+bx.width/2, bx.y+bx.height/2); await new Promise(r=>setTimeout(r,60)); }; +const typeLine=async t=>{ await p.type(".cmdline-input",t,{delay:8}); await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,90)); }; +// 1) Linie zeichnen (1,4)->(4,4) +await focusCmd(); await typeLine("line"); await typeLine("1,2.5"); await typeLine("r3,0"); +// 2) gerenderte draw2d-Linie finden, Mittelpunkt klicken (selektieren) +const mid=await p.evaluate(()=>{ + const l=[...document.querySelectorAll(".plan-svg line.draw2d")][0]; if(!l) return null; + const svg=l.ownerSVGElement, ctm=svg.getScreenCTM(); + const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];}; + const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")), b=pt(+l.getAttribute("x2"),+l.getAttribute("y2")); + return {x:(a[0]+b[0])/2, y:(a[1]+b[1])/2}; +}); +if(mid){ await p.mouse.click(mid.x, mid.y); await new Promise(r=>setTimeout(r,150)); } +const selKind=await p.evaluate(()=>document.querySelector(".objinfo-kind")?.textContent||"none"); +// 3) offset 0.5 +await focusCmd(); await typeLine("offset"); await typeLine("0.5"); +// 4) Linien (Modellkoordinaten) auslesen +const segs=await p.evaluate(()=>[...document.querySelectorAll(".plan-svg line.draw2d")].map(l=>{ + // toScreen: x*90, -y*90 → invert: model = (vbx/90, -vby/90) + return {x1:+l.getAttribute("x1")/90, y1:-l.getAttribute("y1")/90, x2:+l.getAttribute("x2")/90, y2:-l.getAttribute("y2")/90}; +})); +await p.screenshot({path:"scripts/probe-offset2.png"}); +console.log(JSON.stringify({selKind, segCount:segs.length, segs, errs},null,2)); +await b.close(); diff --git a/scripts/probe-phase2.mjs b/scripts/probe-phase2.mjs new file mode 100644 index 0000000..5a2d3b1 --- /dev/null +++ b/scripts/probe-phase2.mjs @@ -0,0 +1,58 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 600)); + +const clickTool = (label) => + page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label); +const svgBox = () => page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); +const clickAt = async (x, y) => { await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up(); await new Promise((r) => setTimeout(r, 70)); }; + +const box = await svgBox(); +const cx = box.x + box.w / 2, cy = box.y + box.h / 2; + +// 1) Rechteck (zwei Ecken). +await clickTool("Rechteck"); +await new Promise((r) => setTimeout(r, 120)); +await clickAt(cx - 210, cy - 110); +await clickAt(cx - 40, cy + 40); + +// 2) Polylinie (offen, 4 Punkte, Rechtsklick beendet). +await clickTool("Polylinie"); +await new Promise((r) => setTimeout(r, 120)); +const poly = [[cx + 40, cy - 100], [cx + 200, cy - 100], [cx + 200, cy + 30], [cx + 90, cy + 60]]; +for (const [x, y] of poly) await clickAt(x, y); +await page.mouse.move(cx + 90, cy + 60); +await page.mouse.click(cx + 90, cy + 60, { button: "right" }); +await new Promise((r) => setTimeout(r, 150)); + +// 3) Snap-Menü öffnen (Fang) und screenshoten. +await page.evaluate(() => { + const b = [...document.querySelectorAll("button")].find((x) => x.textContent.trim().startsWith("Fang")); + if (b) b.click(); +}); +await new Promise((r) => setTimeout(r, 150)); +const popover = await page.evaluate(() => !!document.querySelector(".snap-popover")); +await page.screenshot({ path: "scripts/probe-phase2-snapmenu.png" }); +// Menü schließen. +await page.keyboard.press("Escape"); +await new Promise((r) => setTimeout(r, 100)); + +await clickTool("Auswahl"); +await new Promise((r) => setTimeout(r, 150)); +const counts = await page.evaluate(() => ({ + draw2dLines: document.querySelectorAll(".plan-svg line.draw2d").length, +})); +await page.screenshot({ path: "scripts/probe-phase2.png" }); +console.log("popoverOpened:", popover); +console.log("counts:", JSON.stringify(counts)); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-refine.mjs b/scripts/probe-refine.mjs new file mode 100644 index 0000000..854aac5 --- /dev/null +++ b/scripts/probe-refine.mjs @@ -0,0 +1,42 @@ +import puppeteer from "puppeteer"; + +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 }); +const errs = []; +page.on("pageerror", (e) => errs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle0", timeout: 20000 }); } +catch { /* networkidle timeout ok */ } +await new Promise((r) => setTimeout(r, 1200)); + +// 1) Plan zoom: wheel-in over the plan center, then screenshot +const svg = await page.$(".plan-svg"); +if (svg) { + const box = await svg.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel({ deltaY: -600 }); + await new Promise((r) => setTimeout(r, 300)); +} +await page.screenshot({ path: "scripts/probe-zoom.png" }); + +// 2) Open Ressourcen manager, screenshot the table +const opened = await page.evaluate(() => { + const el = [...document.querySelectorAll("button, .res-trigger, [role=button]")] + .find((b) => /Ressourcen/i.test(b.textContent || "")); + if (el) { el.click(); return true; } + return false; +}); +await new Promise((r) => setTimeout(r, 600)); +await page.screenshot({ path: "scripts/probe-rm.png" }); + +const info = await page.evaluate(() => ({ + managerOpen: !!document.querySelector(".res-drawer, .res-table, [class*=res-]"), + hasTable: !!document.querySelector(".res-table, table, .res-thead"), + triggerFound: [...document.querySelectorAll("button")].some((b)=>/Ressourcen/i.test(b.textContent||"")), +})); +console.log("opened trigger:", opened, JSON.stringify(info)); +console.log("pageerrors:", errs.length ? errs.join("\n") : "none"); +await browser.close(); diff --git a/scripts/probe-resources.mjs b/scripts/probe-resources.mjs new file mode 100644 index 0000000..610da4d --- /dev/null +++ b/scripts/probe-resources.mjs @@ -0,0 +1,204 @@ +// Probe für die Migration auf Component/HatchStyle/LineStyle + den Ressourcen- +// Manager. Erfasst (deviceScaleFactor 2, headless, SwiftShader) vier Zustände: +// +// (a) Standard-App: EG-Grundriss (mehrschichtige Wände, Schraffuren, saubere +// Ecken, Tür) — beweist, dass die Modell-Migration das Verhalten erhält. +// (b) Ressourcen-Manager → Tab „Bauteile": Liste der Components MIT ihren +// joinPriority-Werten. +// (c) Live-Edit: Farbe eines Bauteils (Backstein) ändern → der Grundriss +// aktualisiert sich live auf die neue Farbe (Vorher/Nachher + DOM-Fills). +// (d) Tab „Schraffuren" und Tab „Linien" mit editierbaren Feldern. +// +// Aufruf: node scripts/probe-resources.mjs +// Hinweis: networkidle-GOTO-Timeout wird ignoriert (Vite-HMR hält Verbindungen +// offen); wir warten stattdessen auf konkrete DOM-Knoten. +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const OUT = "scripts"; + +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Klick auf das Element, dessen Textinhalt `text` enthält (innerhalb selector). +async function clickByText(selector, text) { + const handle = await page.evaluateHandle( + (sel, t) => + [...document.querySelectorAll(sel)].find((el) => + el.textContent.includes(t), + ), + selector, + text, + ); + const el = handle.asElement(); + if (!el) throw new Error(`Element nicht gefunden: ${selector} ~ "${text}"`); + await el.click(); +} + +// Setzt den Wert eines Inputs so, dass Reacts onChange feuert. +async function setInputValue(elHandle, value) { + await page.evaluate( + (el, v) => { + const proto = + el.type === "color" + ? window.HTMLInputElement.prototype + : window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value").set; + setter.call(el, v); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }, + elHandle, + value, + ); +} + +// Liest die Füllfarben aller Grundriss-Polygone (Component-Poché) + Anzahl. +const planFills = () => + page.evaluate(() => { + const polys = [...document.querySelectorAll(".plan-svg polygon")]; + const fills = polys + .map((p) => p.getAttribute("fill")) + .filter((f) => f && !f.startsWith("url(")); + const patterns = [...document.querySelectorAll(".plan-svg defs pattern")] + .length; + const arcs = document.querySelectorAll(".plan-svg .door-swing").length; + const leaves = document.querySelectorAll(".plan-svg .door-leaf").length; + return { + polyCount: polys.length, + fills, + uniqueFills: [...new Set(fills)], + patternDefs: patterns, + doorArcs: arcs, + doorLeaves: leaves, + }; + }); + +// Liest die Bauteil-Tabelle des Managers: Name + joinPriority je Zeile. +const componentRows = () => + page.evaluate(() => { + const rows = [...document.querySelectorAll(".res-list .res-row")]; + return rows.map((r) => { + const name = r.querySelector('input[type="text"]')?.value ?? null; + const prio = r.querySelector(".res-prio input")?.value ?? null; + const color = r.querySelector('input[type="color"]')?.value ?? null; + const hatch = + r.querySelector("select")?.selectedOptions?.[0]?.textContent ?? null; + return { name, joinPriority: prio, color, hatch }; + }); + }); + +// Liest die aktiven Tab-Beschriftungen + welcher Tab aktiv ist. +const tabState = () => + page.evaluate(() => { + const tabs = [...document.querySelectorAll(".res-tab-btn")].map((b) => ({ + label: b.textContent.trim(), + active: b.classList.contains("active"), + })); + const rowCount = document.querySelectorAll(".res-list .res-row").length; + const fieldLabels = [ + ...document.querySelectorAll(".res-list .res-row:first-child .res-field-label"), + ].map((s) => s.textContent.trim()); + const inputCount = document.querySelectorAll( + ".res-list .res-row:first-child input, .res-list .res-row:first-child select", + ).length; + return { tabs, rowCount, firstRowFieldLabels: fieldLabels, firstRowInputCount: inputCount }; + }); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 8000 }); +} catch (e) { + logs.push(`[GOTO-INFO ignoriert] ${e.message}`); +} + +// Auf den gerenderten Grundriss warten (statt networkidle). +await page.waitForSelector(".plan-svg polygon", { timeout: 15000 }); +await sleep(500); + +// ── (a) Standard: EG-Grundriss ────────────────────────────────────────────── +const fillsBefore = await planFills(); +logs.push("(a) PLAN-VORHER " + JSON.stringify(fillsBefore)); +await page.screenshot({ path: `${OUT}/probe-resources-a-grundriss.png` }); + +// ── (b) Ressourcen-Manager öffnen → Bauteile-Tab ──────────────────────────── +await clickByText(".res-trigger", "Ressourcen"); +await page.waitForSelector(".res-drawer", { timeout: 8000 }); +await page.waitForSelector(".res-list .res-row", { timeout: 8000 }); +await sleep(300); +logs.push("(b) TAB " + JSON.stringify(await tabState())); +logs.push("(b) BAUTEILE " + JSON.stringify(await componentRows())); +await page.screenshot({ path: `${OUT}/probe-resources-b-bauteile.png` }); + +// ── (c) Live-Edit: Backstein-Farbe auf knalliges Magenta ──────────────────── +// Backstein-Zeile finden (Name-Textfeld == "Backstein"), dessen color-Input setzen. +const NEW_COLOR = "#ff00cc"; +const colorHandle = await page.evaluateHandle((target) => { + const row = [...document.querySelectorAll(".res-list .res-row")].find( + (r) => r.querySelector('input[type="text"]')?.value === target, + ); + return row?.querySelector('input[type="color"]') ?? null; +}, "Backstein"); +if (!colorHandle.asElement()) + throw new Error("Backstein-Farbfeld nicht gefunden"); +await setInputValue(colorHandle.asElement(), NEW_COLOR); +await sleep(300); +logs.push("(c) BAUTEILE-NACH-EDIT " + JSON.stringify(await componentRows())); + +// Screenshot mit offenem Manager (zeigt neuen Swatch im Manager). +await page.screenshot({ path: `${OUT}/probe-resources-c1-manager-edit.png` }); + +// Manager schließen → Grundriss frei sichtbar, Live-Update prüfen. +await page.evaluate(() => { + const overlay = document.querySelector(".res-overlay"); + // auf das Overlay (außerhalb der Schublade) klicken schließt den Manager + overlay?.dispatchEvent(new MouseEvent("click", { bubbles: true })); +}); +// Falls der Overlay-Klick nicht griff, den ×-Knopf nutzen. +await sleep(200); +const stillOpen = await page.$(".res-drawer"); +if (stillOpen) { + await page.evaluate(() => document.querySelector(".res-close")?.click()); +} +await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 }).catch(() => {}); +await sleep(400); + +const fillsAfter = await planFills(); +logs.push("(c) PLAN-NACHHER " + JSON.stringify(fillsAfter)); +const colorApplied = fillsAfter.fills.includes(NEW_COLOR); +logs.push( + `(c) LIVE-UPDATE ${colorApplied ? "OK" : "FEHLT"} — neue Farbe ${NEW_COLOR} ` + + `${colorApplied ? "im" : "NICHT im"} Grundriss; ` + + `uniqueFills vorher=${JSON.stringify(fillsBefore.uniqueFills)} ` + + `nachher=${JSON.stringify(fillsAfter.uniqueFills)}`, +); +await page.screenshot({ path: `${OUT}/probe-resources-c2-plan-updated.png` }); + +// ── (d) Schraffuren-Tab und Linien-Tab ────────────────────────────────────── +await clickByText(".res-trigger", "Ressourcen"); +await page.waitForSelector(".res-drawer", { timeout: 8000 }); +await sleep(200); + +await clickByText(".res-tab-btn", "Schraffuren"); +await page.waitForSelector(".res-list .res-row", { timeout: 8000 }); +await sleep(300); +logs.push("(d) SCHRAFFUREN " + JSON.stringify(await tabState())); +await page.screenshot({ path: `${OUT}/probe-resources-d1-schraffuren.png` }); + +await clickByText(".res-tab-btn", "Linien"); +await page.waitForSelector(".res-list .res-row", { timeout: 8000 }); +await sleep(300); +logs.push("(d) LINIEN " + JSON.stringify(await tabState())); +await page.screenshot({ path: `${OUT}/probe-resources-d2-linien.png` }); + +console.log(logs.join("\n")); +await browser.close(); diff --git a/scripts/probe-sel.mjs b/scripts/probe-sel.mjs new file mode 100644 index 0000000..ba924f2 --- /dev/null +++ b/scripts/probe-sel.mjs @@ -0,0 +1,27 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +const focusCmd=async()=>{const el=await p.$(".cmdline-input");const bx=await el.boundingBox();await p.mouse.click(bx.x+bx.width/2,bx.y+bx.height/2);await new Promise(r=>setTimeout(r,60));}; +const typeLine=async t=>{await p.type(".cmdline-input",t,{delay:6});await p.keyboard.press("Enter");await new Promise(r=>setTimeout(r,80));}; +const lineCount=()=>p.$$eval(".plan-svg line.draw2d",ls=>ls.length).catch(()=>-1); +// zwei Linien zeichnen +await focusCmd(); await typeLine("line"); await typeLine("1,2.2"); await typeLine("r3,0"); +await focusCmd(); await typeLine("line"); await typeLine("1,2.8"); await typeLine("r3,0"); +const drawn=await lineCount(); +// Screen-bbox der draw2d-Linien +const bb=await p.evaluate(()=>{const ls=[...document.querySelectorAll(".plan-svg line.draw2d")];const svg=ls[0].ownerSVGElement,ctm=svg.getScreenCTM();const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};let xs=[],ys=[];for(const l of ls){const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")),c=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));xs.push(a[0],c[0]);ys.push(a[1],c[1]);}return{minX:Math.min(...xs),maxX:Math.max(...xs),minY:Math.min(...ys),maxY:Math.max(...ys)};}); +// Marquee links→rechts (window) um beide Linien +await p.mouse.move(bb.minX-20, bb.minY-20); await p.mouse.down(); +await p.mouse.move(bb.maxX+20, bb.maxY+20, {steps:6}); await new Promise(r=>setTimeout(r,60)); +await p.mouse.up(); await new Promise(r=>setTimeout(r,200)); +const selHi=await p.$$eval(".plan-svg .plan-sel-draw",e=>e.length).catch(()=>0); +const statusSel=await p.evaluate(()=>document.querySelector("footer,.statusbar")?.textContent?.match(/Auswahl[^|]*/)?.[0]||""); +// Löschen +await p.keyboard.press("Delete"); await new Promise(r=>setTimeout(r,200)); +const afterDel=await lineCount(); +await p.screenshot({path:"scripts/probe-sel.png"}); +console.log(JSON.stringify({drawnLines:drawn, selHighlightPrims:selHi, statusSel, linesAfterDelete:afterDel, errs},null,2)); +await b.close(); diff --git a/scripts/probe-shell.mjs b/scripts/probe-shell.mjs new file mode 100644 index 0000000..0cb79a4 --- /dev/null +++ b/scripts/probe-shell.mjs @@ -0,0 +1,300 @@ +// Probe für Shell-Phase: native-App-Verhalten, Marquee-Auswahl, i18n. +// Headless, dpr 2, swiftshader. Schreibt PNGs nach scripts/ und meldet je +// Test (a)-(e) ein Ergebnis als JSON auf stdout (letzte Zeile). +import puppeteer from "puppeteer"; + +const TARGET = "http://localhost:5173/"; +const OUT = new URL("./", import.meta.url).pathname; + +const results = {}; +const log = (k, v) => { + results[k] = v; + console.log(`[${k}]`, JSON.stringify(v)); +}; + +const browser = await puppeteer.launch({ + headless: "new", + args: [ + "--no-sandbox", + "--use-gl=swiftshader", + "--enable-unsafe-swiftshader", + "--disable-dev-shm-usage", + ], +}); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + page.on("console", (m) => { + const t = m.text(); + if (m.type() === "error") console.log("[console.error]", t); + }); + + try { + await page.goto(TARGET, { waitUntil: "networkidle0", timeout: 8000 }); + } catch { + // networkidle-Timeout ignorieren (Dev-Server hält Verbindungen offen). + } + await page.waitForSelector(".plan-svg", { timeout: 15000 }); + await new Promise((r) => setTimeout(r, 800)); + + // ── (a) Deutsch: Sichtbarer Text unverändert ──────────────────────────── + const ger = await page.evaluate(() => { + const body = document.body.innerText; + return { + grundriss: body.includes("Grundriss"), + perspektive: body.includes("Perspektive"), + auswahl: body.includes("Auswahl"), + lang: window.__i18n?.getLanguage?.() ?? null, + }; + }); + await page.screenshot({ path: OUT + "probe-shell-a-de.png" }); + log("a_german", { + ...ger, + ok: ger.grundriss && ger.perspektive && ger.lang === "de", + }); + + // ── (b) Native-App: contextmenu unterdrückt, user-select ──────────────── + const native = await page.evaluate(() => { + const ev = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + }); + const prevented = !document.body.dispatchEvent(ev); + const bodySel = getComputedStyle(document.body).userSelect || + getComputedStyle(document.body).webkitUserSelect; + return { defaultPrevented: prevented, bodyUserSelect: bodySel }; + }); + log("b_native_contextmenu", native); + + // Ressourcen-Fenster öffnen, dann ein auf user-select:text prüfen. + // Die Ressourcen-Aktion in der Topbar anklicken. + const openedResources = await page.evaluate(() => { + const btns = [...document.querySelectorAll("button, [role=button], .nav-tab, .topbar *")]; + const hit = btns.find((b) => /Ressourcen/i.test(b.textContent || "")); + if (hit) { hit.click(); return true; } + return false; + }); + await new Promise((r) => setTimeout(r, 500)); + // Falls noch kein input sichtbar, einen Manager-Tab öffnen (Bauteile etc.). + const inputSel = await page.evaluate(() => { + const inp = document.querySelector("input"); + if (!inp) return { found: false }; + const cs = getComputedStyle(inp); + return { + found: true, + userSelect: cs.userSelect || cs.webkitUserSelect, + }; + }); + await page.screenshot({ path: OUT + "probe-shell-b-resources.png" }); + log("b_input_userselect", { + openedResources, + ...inputSel, + ok: + native.defaultPrevented === true && + native.bodyUserSelect === "none" && + inputSel.userSelect === "text", + }); + + // Ressourcen-Fenster wieder schließen, damit der Plan frei liegt (ESC). + await page.keyboard.press("Escape"); + await new Promise((r) => setTimeout(r, 300)); + + // ── (c) Marquee: Links-Drag über den Raum wählt mehrere Wände ─────────── + const planBox = await page.evaluate(() => { + const svg = document.querySelector(".plan-svg"); + const r = svg.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + // Crossing-Marquee (rechts→rechts unten nach links oben): ein großes Rechteck, + // das den ganzen Raum kreuzt und damit ALLE vier Wände berührt. Rechts→links + // = crossing window (berührend), robust gegen exakte Wandlage. + const cx = planBox.x + planBox.w / 2; + const cy = planBox.y + planBox.h / 2; + const x0 = cx + planBox.w * 0.42; + const y0 = cy + planBox.h * 0.4; + const x1 = cx - planBox.w * 0.42; + const y1 = cy - planBox.h * 0.4; + + await page.mouse.move(x0, y0); + await page.mouse.down({ button: "left" }); + // Mehrere Zwischenschritte, damit pointermove + Schwelle greifen. + for (let i = 1; i <= 8; i++) { + await page.mouse.move( + x0 + ((x1 - x0) * i) / 8, + y0 + ((y1 - y0) * i) / 8, + ); + await new Promise((r) => setTimeout(r, 20)); + } + await page.mouse.up({ button: "left" }); + await new Promise((r) => setTimeout(r, 400)); + + const marquee = await page.evaluate(() => { + const highlights = document.querySelectorAll(".plan-selected").length; + const footer = document.body.innerText; + const m = footer.match(/Auswahl:\s*(\d+)\s*(Wand|Wände)/); + return { + highlightPolys: highlights, + footerMatch: m ? m[0] : null, + footerN: m ? Number(m[1]) : 0, + }; + }); + await page.screenshot({ path: OUT + "probe-shell-c-marquee.png" }); + log("c_marquee", { + ...marquee, + ok: marquee.footerN >= 2 && marquee.highlightPolys >= 1, + }); + + // Einzel-Klick wählt genau eine Wand. Eine echte Wandfläche im DOM finden + // (gefülltes innerhalb der SVG, das nicht Hintergrund/Auswahl ist), + // dann auf ihren Schwerpunkt in Client-Koordinaten klicken — deterministisch. + const wallPoint = await page.evaluate(() => { + const svg = document.querySelector(".plan-svg"); + const polys = [...svg.querySelectorAll("polygon")].filter( + (p) => + !p.classList.contains("plan-selected") && + !p.classList.contains("plan-bg"), + ); + const ctm = svg.getScreenCTM(); + let best = null; + let bestArea = 0; + for (const poly of polys) { + const pts = poly.getAttribute("points"); + if (!pts) continue; + const nums = pts.trim().split(/[\s,]+/).map(Number); + let cxv = 0, + cyv = 0, + n = 0; + let area = 0; + for (let i = 0; i + 1 < nums.length; i += 2) { + cxv += nums[i]; + cyv += nums[i + 1]; + n++; + } + if (n < 3) continue; + // grobe Bounding-Box-Fläche als Größenmaß + const xs = nums.filter((_, i) => i % 2 === 0); + const ys = nums.filter((_, i) => i % 2 === 1); + area = + (Math.max(...xs) - Math.min(...xs)) * + (Math.max(...ys) - Math.min(...ys)); + if (area > bestArea) { + bestArea = area; + // SVG-User-Space → Client über CTM transformieren. + const ptObj = svg.createSVGPoint(); + ptObj.x = cxv / n; + ptObj.y = cyv / n; + const sp = ptObj.matrixTransform(ctm); + best = { x: sp.x, y: sp.y }; + } + } + return best; + }); + if (wallPoint) { + await page.mouse.click(wallPoint.x, wallPoint.y, { button: "left" }); + await new Promise((r) => setTimeout(r, 350)); + } + const single = await page.evaluate(() => { + const footer = document.body.innerText; + const m = footer.match(/Auswahl:\s*(\d+)\s*(Wand|Wände)/); + return { + footerMatch: m ? m[0] : null, + footerN: m ? Number(m[1]) : 0, + highlightPolys: document.querySelectorAll(".plan-selected").length, + }; + }); + await page.screenshot({ path: OUT + "probe-shell-c2-single.png" }); + log("c_single_click", { ...single, ok: single.footerN === 1 }); + + // ── (d) Rechtsklick auf den Plan öffnet UNSER ContextMenu (.ctx-menu) ──── + // Erst sicherstellen, dass eine Wand gewählt ist (Einzelklick oben hat das + // getan). Rechtsklick auf den Plan. + await page.evaluate( + ({ x, y }) => { + const svg = document.querySelector(".plan-svg"); + const ev = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + button: 2, + }); + svg.dispatchEvent(ev); + }, + { x: cx, y: planBox.y + planBox.h * 0.22 }, + ); + await new Promise((r) => setTimeout(r, 300)); + const planCtx = await page.evaluate(() => ({ + ctxMenu: document.querySelectorAll(".ctx-menu").length, + })); + await page.screenshot({ path: OUT + "probe-shell-d-plan-context.png" }); + log("d_plan_context", { ...planCtx, ok: planCtx.ctxMenu >= 1 }); + // Menü schliessen. + await page.mouse.click(planBox.x + 4, planBox.y + 4, { button: "left" }); + await new Promise((r) => setTimeout(r, 200)); + + // Rechtsklick auf eine Ebenen-Zeile (linke Liste) öffnet ebenfalls .ctx-menu. + const layerCtx = await page.evaluate(() => { + const row = + document.querySelector(".nav-list .cat-name")?.closest("div") || + document.querySelector(".nav-list > *"); + if (!row) return { found: false, ctxMenu: 0 }; + const r = row.getBoundingClientRect(); + const ev = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: r.x + 10, + clientY: r.y + r.height / 2, + button: 2, + }); + row.dispatchEvent(ev); + return { found: true }; + }); + await new Promise((r) => setTimeout(r, 300)); + const layerCtxCount = await page.evaluate(() => ({ + ctxMenu: document.querySelectorAll(".ctx-menu").length, + })); + await page.screenshot({ path: OUT + "probe-shell-d2-layer-context.png" }); + log("d_layer_context", { + ...layerCtx, + ...layerCtxCount, + ok: layerCtx.found && layerCtxCount.ctxMenu >= 1, + }); + await page.mouse.click(planBox.x + 4, planBox.y + 4, { button: "left" }); + await new Promise((r) => setTimeout(r, 200)); + + // ── (e) i18n auf Englisch schalten + Screenshot ───────────────────────── + const enFlip = await page.evaluate(() => { + if (!window.__i18n?.setLanguage) return { exposed: false }; + window.__i18n.setLanguage("en"); + return { exposed: true, lang: window.__i18n.getLanguage() }; + }); + await new Promise((r) => setTimeout(r, 400)); + const enText = await page.evaluate(() => { + const body = document.body.innerText; + return { + plan: body.includes("Plan"), + perspective: body.includes("Perspective"), + stillGerman: body.includes("Grundriss"), + }; + }); + await page.screenshot({ path: OUT + "probe-shell-e-en.png" }); + log("e_english", { + ...enFlip, + ...enText, + ok: + enFlip.exposed === true && + enFlip.lang === "en" && + enText.perspective && + !enText.stillGerman, + }); + + // Zurück auf Deutsch. + await page.evaluate(() => window.__i18n?.setLanguage("de")); + + const allOk = Object.values(results).every((r) => r.ok !== false); + console.log("\nRESULT " + JSON.stringify({ allOk, results })); +} finally { + await browser.close(); +} diff --git a/scripts/probe-shiftsel.mjs b/scripts/probe-shiftsel.mjs new file mode 100644 index 0000000..32ca49b --- /dev/null +++ b/scripts/probe-shiftsel.mjs @@ -0,0 +1,23 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,800)); +const focus=async()=>{const el=await p.$(".cmdline-input");const bx=await el.boundingBox();await p.mouse.click(bx.x+bx.width/2,bx.y+bx.height/2);await new Promise(r=>setTimeout(r,50));}; +const cmd=async t=>{await p.type(".cmdline-input",t,{delay:5});await p.keyboard.press("Enter");await new Promise(r=>setTimeout(r,70));}; +await focus();await cmd("line");await cmd("1,2.2");await cmd("4,2.2"); +await focus();await cmd("line");await cmd("1,2.8");await cmd("4,2.8"); +// Bildschirmkoordinaten der zwei Linien +const mids=await p.evaluate(()=>{const ls=[...document.querySelectorAll(".plan-svg line.draw2d")];const svg=ls[0].ownerSVGElement,ctm=svg.getScreenCTM();const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};return ls.map(l=>{const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")),c=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));return[(a[0]+c[0])/2,(a[1]+c[1])/2];});}); +const selCount=()=>p.$$eval(".plan-svg .plan-sel-draw",e=>e.length).catch(()=>0); +// Linie A klicken +await p.mouse.click(mids[0][0],mids[0][1]); await new Promise(r=>setTimeout(r,150)); +const after1=await selCount(); +// Linie B shift-klicken +await p.keyboard.down("Shift"); await p.mouse.click(mids[1][0],mids[1][1]); await p.keyboard.up("Shift"); +await new Promise(r=>setTimeout(r,150)); +const after2=await selCount(); +await p.screenshot({path:"scripts/probe-shiftsel.png"}); +console.log(JSON.stringify({afterClickA:after1, afterShiftClickB:after2, errs:errs.slice(0,2)})); +await b.close(); diff --git a/scripts/probe-splitjoin.mjs b/scripts/probe-splitjoin.mjs new file mode 100644 index 0000000..1bd0b28 --- /dev/null +++ b/scripts/probe-splitjoin.mjs @@ -0,0 +1,166 @@ +// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick). +// Liest das Modell über den TEMP-Hook window.__drawings2d (id + geom). +import puppeteer from "puppeteer"; + +const b = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const p = await b.newPage(); +await p.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 }); +const errs = []; +p.on("pageerror", (e) => errs.push(e.message)); +await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {}); +await new Promise((r) => setTimeout(r, 800)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const focusCmd = async () => { + const el = await p.$(".cmdline-input"); + const bx = await el.boundingBox(); + await p.mouse.click(bx.x + bx.width / 2, bx.y + bx.height / 2); + await sleep(60); +}; +const typeLine = async (t) => { + await p.type(".cmdline-input", t, { delay: 5 }); + await p.keyboard.press("Enter"); + await sleep(80); +}; +// Laufenden Befehl beenden (sonst übernimmt er die linke Maustaste der Plan- +// Ansicht und Klicks selektieren nicht). Escape im Eingabefeld bricht ab. +const endCommand = async () => { + // Eingabefeld behält Fokus nach typeLine → Escape bricht den Befehl ab. + await p.keyboard.press("Escape"); + await sleep(80); + await p.keyboard.press("Escape"); + await sleep(80); +}; +const drawings = () => p.evaluate(() => window.__drawings2d || []); +// Modellpunkt (Meter) → Client-Pixel über das aktuelle SVG-CTM (umgekehrt). +const modelToClient = (mx, my) => + p.evaluate( + (mx, my) => { + const svg = document.querySelector(".plan-svg"); + // toScreen aus PlanView: x*40, -y*40 (PX_PER_M=40, Y gespiegelt). Wir nutzen + // direkt das CTM: viewBox-Punkt (x*40, -y*40) → Bildschirm. + const ctm = svg.getScreenCTM(); + const q = svg.createSVGPoint(); + q.x = mx * 90; + q.y = -my * 90; + const s = q.matrixTransform(ctm); + return [s.x, s.y]; + }, + mx, + my, + ); +const ctrlPress = async (key) => { + await p.keyboard.down("Control"); + await p.keyboard.press(key); + await p.keyboard.up("Control"); + await sleep(250); +}; +const isClosed = (g) => + g.shape === "rect" || (g.shape === "polyline" && g.closed); + +// ── 1) SPLIT RECHTECK ──────────────────────────────────────────────────────── +// Rechteck (0,0)-(4,4) + quer kreuzende vertikale Linie x=2. +await focusCmd(); +await typeLine("rect"); +await typeLine("0,0"); +await typeLine("4,4"); +await focusCmd(); +await typeLine("line"); +await typeLine("2,-1"); +await typeLine("2,5"); +await endCommand(); +await sleep(150); + +const before = await drawings(); +// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1). +let [cx, cy] = await modelToClient(1, 0); +await p.mouse.click(cx, cy); +await sleep(150); +await ctrlPress("s"); +await sleep(200); +const afterSplit = await drawings(); +// Erwartung: Rechteck (1 closed) ersetzt durch 2 closed Elemente; Linie bleibt. +const closedAfter = afterSplit.filter((d) => isClosed(d.geom)); +await p.screenshot({ path: "scripts/probe-splitjoin-1-split-rect.png" }); + +// ── 2) JOIN zwei Linien ────────────────────────────────────────────────────── +await p.reload({ waitUntil: "networkidle0" }); +await sleep(800); +await focusCmd(); +await typeLine("line"); +await typeLine("-3,-3"); +await typeLine("0,-3"); +await focusCmd(); +await typeLine("line"); +await typeLine("0,-3"); +await typeLine("0,0"); +await endCommand(); +await sleep(150); +const beforeJoin = await drawings(); +// Beide wählen: erste klicken, dann Shift+zweite. +let [ax, ay] = await modelToClient(-1.5, -3); +await p.mouse.click(ax, ay); +await sleep(100); +let [bx2, by2] = await modelToClient(0, -1.5); +await p.keyboard.down("Shift"); +await p.mouse.click(bx2, by2); +await p.keyboard.up("Shift"); +await sleep(150); +await ctrlPress("j"); +await sleep(200); +const afterJoin = await drawings(); +const polylines = afterJoin.filter((d) => d.geom.shape === "polyline"); +await p.screenshot({ path: "scripts/probe-splitjoin-2-join.png" }); + +// ── 3) ALT-SEGMENT (Schere) ────────────────────────────────────────────────── +await p.reload({ waitUntil: "networkidle0" }); +await sleep(800); +await focusCmd(); +await typeLine("rect"); +await typeLine("0,0"); +await typeLine("4,4"); +await endCommand(); +await sleep(150); +const beforeCut = await drawings(); +// Alt+Klick auf die untere Kante (y=0, x=2). +let [ex, ey] = await modelToClient(2, 0); +await p.keyboard.down("Alt"); +await p.mouse.click(ex, ey); +await p.keyboard.up("Alt"); +await sleep(200); +const afterCut = await drawings(); +const cutGeom = afterCut[0]?.geom; +await p.screenshot({ path: "scripts/probe-splitjoin-3-alt-cut.png" }); + +console.log( + JSON.stringify( + { + split: { + beforeCount: before.length, + afterCount: afterSplit.length, + closedCountAfter: closedAfter.length, + geomsAfter: afterSplit.map((d) => ({ shape: d.geom.shape, closed: isClosed(d.geom) })), + }, + join: { + beforeCount: beforeJoin.length, + afterCount: afterJoin.length, + polylineCount: polylines.length, + joinedPts: polylines.map((d) => d.geom.pts?.length), + }, + altCut: { + beforeShape: beforeCut[0]?.geom.shape, + afterCount: afterCut.length, + afterShape: cutGeom?.shape, + afterClosed: cutGeom ? isClosed(cutGeom) : null, + afterPts: cutGeom?.pts?.length, + }, + errs, + }, + null, + 2, + ), +); +await b.close(); diff --git a/scripts/probe-tab-fields.mjs b/scripts/probe-tab-fields.mjs new file mode 100644 index 0000000..b5aec33 --- /dev/null +++ b/scripts/probe-tab-fields.mjs @@ -0,0 +1,161 @@ +// Probe für den Tab-Feld-Zyklus (Rhino-Präzisionseingabe §2.7). +// +// Treibt headless: +// A) Tab → „line"⏎ → Startpunkt „0,0"⏎ → „3"⏎ (lockt Länge, weiter zu Winkel) +// → „45"⏎ (lockt Winkel ⇒ committet) ⇒ EXAKTE 3-m-Linie unter 45°. +// B) Tab → „rect"⏎ → erste Ecke „0,-3"⏎ → Maus nach rechts-oben (Quadrant) +// → „4"⏎ (Breite) → „2"⏎ (Höhe ⇒ committet) ⇒ 4×2-Rechteck. +// +// Verifikation: liest die gerenderten `.plan-svg line.draw2d`-Endpunkte. Die +// Plan-Transform ist toScreen(p) = { x: p.x*90, y: -p.y*90 } (PX_PER_M=90, Y +// geflippt), also gilt für Modell-Längen/-Winkel: +// modelLen = pixelLen / 90 +// modelAngle = atan2(-(y2-y1), x2-x1) (Y-Flip rückgängig) +// Screenshot scripts/probe-tab-fields.png zeigt Linie + Rechteck + Feld-Boxen. + +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const PX_PER_M = 90; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} +await sleep(600); + +// Liest die draw2d-Segmente (in toScreen-Koordinaten = x*90 / -y*90) und rechnet +// in Modell-Länge/-Winkel zurück. +async function segments() { + const raw = await page.evaluate(() => + [...document.querySelectorAll(".plan-svg line.draw2d")].map((l) => ({ + x1: +l.getAttribute("x1"), + y1: +l.getAttribute("y1"), + x2: +l.getAttribute("x2"), + y2: +l.getAttribute("y2"), + })), + ); + return raw.map((s) => { + const dxPx = s.x2 - s.x1; + const dyPx = s.y2 - s.y1; + const lenM = Math.hypot(dxPx, dyPx) / PX_PER_M; + // Y-Flip rückgängig: Modell-dy = -(Screen-dy). + let ang = (Math.atan2(-dyPx, dxPx) * 180) / Math.PI; + if (ang <= -0.0001) ang += 360; // 0..360 + return { lenM: +lenM.toFixed(4), angleDeg: +ang.toFixed(2) }; + }); +} + +// Liest die aktuellen Feld-Boxen der Command-Line (Label + Wert/„—" + aktiv). +async function fieldBoxes() { + return page.evaluate(() => + [...document.querySelectorAll(".cmdline-field")].map((f) => ({ + label: f.querySelector(".cmdline-field-label")?.textContent || "", + val: f.querySelector(".cmdline-field-val")?.textContent || "", + active: f.classList.contains("active"), + })), + ); +} + +async function focusCmdline() { + await page.mouse.click(600, 400); // in den Body, damit Tab nicht ein Feld trifft + await sleep(80); + await page.keyboard.press("Tab"); + await sleep(120); +} + +async function typeLine(text) { + await page.type(".cmdline-input", text, { delay: 12 }); + await page.keyboard.press("Enter"); + await sleep(140); +} + +const box = await page.evaluate(() => { + const r = document.querySelector(".plan-svg").getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; +}); +const cx = box.x + box.w / 2; +const cy = box.y + box.h / 2; + +// ── A) Linie über Felder: Länge 3, Winkel 45° ─────────────────────────────── +await focusCmdline(); +await typeLine("line"); +const promptLine = await page.evaluate( + () => document.querySelector(".cmdline-prompt")?.textContent || "", +); +await typeLine("0,0"); // Startpunkt → Schritt „end" (Feld-Modus aktiv) +const fieldsAtStart = await fieldBoxes(); +// Maus woanders hin (zeigt: ungelockte Felder folgen der Maus, gelockte nicht). +await page.mouse.move(cx + 120, cy - 30); +await sleep(80); +await typeLine("3"); // lockt Länge → weiter zu Winkel +const fieldsAfterLen = await fieldBoxes(); +// Screenshot MITTEN im Befehl: zeigt die Feld-Boxen (Länge=3, Winkel aktiv „—") +// in der Command-Line + die schon gelockte Vorschau-Linie. +await page.screenshot({ path: "scripts/probe-tab-fields-midcmd.png" }); +await typeLine("45"); // lockt Winkel ⇒ committet die Linie +await sleep(150); +const segsA = await segments(); + +await page.screenshot({ path: "scripts/probe-tab-fields.png" }); + +// ── B) Rechteck über Felder: Breite 4, Höhe 2 ─────────────────────────────── +await focusCmdline(); +await typeLine("rect"); +await typeLine("0,-3"); // erste Ecke (unter dem Ursprung, freie Fläche) +// Maus nach rechts-oben → Quadrant +x,+y (bestimmt Vorzeichen der Locks). +await page.mouse.move(cx + 150, cy - 80); +await sleep(80); +await typeLine("4"); // Breite +const rectFieldsAfterW = await fieldBoxes(); +await typeLine("2"); // Höhe ⇒ committet +await sleep(150); + +// Rechteck wird als 4 draw2d-Linien gerendert; sammeln + auf Auswahl umschalten, +// damit das PNG das fertige Rechteck zeigt. +const allSegs = await segments(); +await page.screenshot({ path: "scripts/probe-tab-fields-rect.png" }); + +console.log("prompt after 'line':", JSON.stringify(promptLine)); +console.log("fields at end-step start:", JSON.stringify(fieldsAtStart)); +console.log("fields after locking length=3:", JSON.stringify(fieldsAfterLen)); +console.log("LINE segments (model len/angle):", JSON.stringify(segsA)); +console.log("rect fields after width=4:", JSON.stringify(rectFieldsAfterW)); +console.log("ALL segments after rect (len/angle):", JSON.stringify(allSegs)); + +// ── Assertions ─────────────────────────────────────────────────────────────── +const line3 = segsA.find((s) => Math.abs(s.lenM - 3) < 0.02 && Math.abs(s.angleDeg - 45) < 0.5); +console.log( + line3 + ? `PASS: line length≈3 (${line3.lenM}) angle≈45 (${line3.angleDeg})` + : "FAIL: no 3m/45° line found", +); + +// Rechteck: zwei horizontale Kanten der Länge 4 + zwei vertikale der Länge 2. +const horiz4 = allSegs.filter( + (s) => Math.abs(s.lenM - 4) < 0.03 && (Math.abs(s.angleDeg) < 1 || Math.abs(s.angleDeg - 180) < 1 || Math.abs(s.angleDeg - 360) < 1), +); +const vert2 = allSegs.filter( + (s) => Math.abs(s.lenM - 2) < 0.03 && (Math.abs(s.angleDeg - 90) < 1 || Math.abs(s.angleDeg - 270) < 1), +); +console.log( + horiz4.length >= 2 && vert2.length >= 2 + ? `PASS: rect 4×2 (horiz4=${horiz4.length}, vert2=${vert2.length})` + : `FAIL: rect not 4×2 (horiz4=${horiz4.length}, vert2=${vert2.length})`, +); + +console.log("=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-terrain-ui.mjs b/scripts/probe-terrain-ui.mjs new file mode 100644 index 0000000..c3558d1 --- /dev/null +++ b/scripts/probe-terrain-ui.mjs @@ -0,0 +1,136 @@ +// Probe: Kontext/Gelände-Integration (Phase 2, UI). +// +// 1) Erzeugt eine kleine Test-DXF (LWPOLYLINE-Höhenlinien Z=400/401/402). +// 2) Treibt den Import über das versteckte DXF- (uploadFile) — +// derselbe Pfad wie der Panel-Button „DXF importieren". +// 3) Screenshot Grundriss: Konturen als dezente Linien sichtbar. +// 4) Site-Panel-Tab aktivieren, „Gelände erzeugen" klicken. +// 5) In die Perspektive/Isometrie wechseln, orbiten, Screenshot: TIN im 3D. +import puppeteer from "puppeteer"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; + +// ── Test-DXF: drei geschlossene LWPOLYLINE-Höhenlinien (Elevation-Gruppe 38) ── +function ring(z, pts, layer) { + let s = `0\nLWPOLYLINE\n8\n${layer}\n38\n${z}\n90\n${pts.length}\n70\n1\n`; + for (const [x, y] of pts) s += `10\n${x}\n20\n${y}\n`; + return s; +} +// Etwas größere Konturen, damit sie im Plan gut sichtbar sind (Meter). +const dxf = + `0\nSECTION\n2\nENTITIES\n` + + ring(400, [[0, 0], [20, 0], [20, 20], [0, 20]], "contours") + + ring(401, [[4, 4], [16, 4], [16, 16], [4, 16]], "contours") + + ring(402, [[8, 8], [12, 8], [12, 12], [8, 12]], "contours") + + `0\nENDSEC\n0\nEOF\n`; + +const dir = mkdtempSync(join(tmpdir(), "cad-dxf-")); +const dxfPath = join(dir, "contours.dxf"); +writeFileSync(dxfPath, dxf); + +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +await wait(600); + +// Frisches Default-Layout erzwingen (neuer „site"-Tab), localStorage leeren. +await page.evaluate(() => { + try { localStorage.clear(); } catch { /* ignore */ } +}); +await page.reload({ waitUntil: "networkidle0", timeout: 20000 }); +await wait(600); + +const clickByText = async (sel, re) => { + const el = ( + await page.evaluateHandle( + (sel, reSrc) => { + const re = new RegExp(reSrc); + return [...document.querySelectorAll(sel)].find((e) => re.test(e.textContent.trim())); + }, + sel, + re.source, + ) + ).asElement(); + if (!el) throw new Error(`kein ${sel} matcht ${re}`); + await el.click(); + return el; +}; + +// ── 2) Import über das versteckte DXF-Input ─────────────────────────────────── +const input = await page.$('input[type=file][accept*=".dxf"]'); +if (!input) throw new Error("DXF-Datei-Input nicht gefunden"); +await input.uploadFile(dxfPath); +await wait(700); + +// ── 3) Grundriss-Screenshot (Konturen sichtbar) ────────────────────────────── +// „Einpassen" über die Plan-Zoom-Buttons, damit die Konturen gerahmt werden. +await page.evaluate(() => { + const btn = [...document.querySelectorAll("button")].find((b) => + /einpassen|fit|⊡|⤢/i.test(b.textContent || b.title || ""), + ); + btn?.click(); +}); +await wait(500); +await page.screenshot({ path: "scripts/probe-terrain-ui-a-plan.png" }); + +// ── 4) Site-Panel öffnen + „Gelände erzeugen" ──────────────────────────────── +// Den „Gelände/Terrain"-Tab in der rechten Gruppe aktivieren (TabStrip nutzt +// .tab-Buttons; ein Klick aktiviert den Tab über den Drag-Controller). +await clickByText(".tab", /^(Gelände|Terrain)$/); +await wait(400); +await page.screenshot({ path: "scripts/probe-terrain-ui-b-panel.png" }); + +// „Gelände erzeugen"-Button im Site-Panel klicken. +const genClicked = await page.evaluate(() => { + const btn = [...document.querySelectorAll(".site-panel .site-btn")].find((b) => + /Gelände erzeugen|Generate terrain/i.test(b.textContent), + ); + if (btn) { btn.click(); return true; } + return false; +}); +console.log("Gelände-erzeugen geklickt:", genClicked); +await wait(600); + +// Kontext-Objekte zählen (Liste im Site-Panel). +const rows = await page.evaluate( + () => document.querySelectorAll(".site-panel .site-row").length, +); +const badges = await page.evaluate(() => + [...document.querySelectorAll(".site-panel .site-badge")].map((b) => b.textContent.trim()), +); +console.log("Site-Listenzeilen:", rows, "Badges:", JSON.stringify(badges)); + +// ── 5) Isometrie → 3D-Screenshot (TIN sichtbar) ────────────────────────────── +// Der „Isometrie"-view-btn setzt view3d=iso UND wechselt zugleich in die +// Perspektive (echter Ansichtswechsel) — ein Klick genügt. +await clickByText("button.view-btn", /^Isometrie$|^Isometric$/); +await wait(1200); + +const vp = await page.evaluate(() => { + const r = document.querySelector(".viewport")?.getBoundingClientRect(); + return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null; +}); +if (vp) { + const cx = vp.x + vp.w / 2, cy = vp.y + vp.h / 2; + await page.mouse.move(cx, cy); + await page.mouse.down({ button: "middle" }); + await page.mouse.move(cx + 120, cy + 30, { steps: 12 }); + await page.mouse.up({ button: "middle" }); + await wait(500); +} +await page.screenshot({ path: "scripts/probe-terrain-ui-c-3d.png" }); + +console.log("logs:", logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-terrain.mjs b/scripts/probe-terrain.mjs new file mode 100644 index 0000000..3e1cac5 --- /dev/null +++ b/scripts/probe-terrain.mjs @@ -0,0 +1,66 @@ +// Probe: Gelände-TIN (Kontext-Schicht) im 3D sichtbar. +// Seedet über window.__seedTerrain() einen Höhenlinien-Satz + abgeleitetes TIN, +// wechselt in die Perspektive, orbitet ein wenig und screenshotet. +// Erwartung: eine plausible Geländefläche (TIN) im 3D. +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +// 1) Gelände seeden (echte siteSlice-Actions über window.__seedTerrain). +const seed = await page.evaluate(() => window.__seedTerrain?.()); +console.log("seed:", JSON.stringify(seed)); +await wait(400); + +// 2) In die Perspektive wechseln (löst Szenen-Neuaufbau inkl. Kontext aus). +const clickByText = async (sel, text) => { + const handle = await page.evaluateHandle( + (sel, text) => + [...document.querySelectorAll(sel)].find((el) => el.textContent.trim() === text), + sel, + text, + ); + const el = handle.asElement(); + if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`); + await el.click(); +}; +await clickByText("button.view-btn", "Perspektive"); +await wait(1000); + +// 3) Isometrie für einen guten Blick auf die Geländefläche. +await clickByText("button.view-btn", "Isometrie"); +await wait(900); + +// 4) Etwas orbiten (mittlere Maustaste über dem Viewport). +const vp = await page.evaluate(() => { + const r = document.querySelector(".viewport")?.getBoundingClientRect(); + return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null; +}); +if (vp) { + const cx = vp.x + vp.w / 2; + const cy = vp.y + vp.h / 2; + await page.mouse.move(cx, cy); + await page.mouse.down({ button: "middle" }); + await page.mouse.move(cx + 120, cy + 40, { steps: 12 }); + await page.mouse.up({ button: "middle" }); + await wait(500); +} + +await page.screenshot({ path: "scripts/probe-terrain.png" }); + +// Diagnostik: Anzahl Meshes in der Szene ist im Headless nicht direkt lesbar; +// wir berichten Seed-Resultat + Konsolenfehler. +console.log("logs:", logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-tools-panel.mjs b/scripts/probe-tools-panel.mjs new file mode 100644 index 0000000..2cd1bcb --- /dev/null +++ b/scripts/probe-tools-panel.mjs @@ -0,0 +1,18 @@ +import puppeteer from "puppeteer"; +const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] }); +const p = await b.newPage(); await p.setViewport({width:1300,height:850,deviceScaleFactor:2}); +const logs=[]; p.on("pageerror",e=>logs.push(e.message)); +await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,800)); +const info = await p.evaluate(()=>({ + toolsPanel: !!document.querySelector(".tools-panel"), + toolRows: document.querySelectorAll(".tools-panel .tool-row").length, + activeTab: [...document.querySelectorAll(".tab")].find(t=>t.classList.contains("active"))?.textContent?.trim() ?? null, +})); +// Wand-Werkzeug im Panel klicken → wallType-Dropdown sollte erscheinen. +await p.evaluate(()=>{ const r=[...document.querySelectorAll(".tools-panel .tool-row")].find(x=>/Wand/.test(x.textContent)); r?.click(); }); +await new Promise(r=>setTimeout(r,200)); +const afterWall = await p.evaluate(()=>({ active: [...document.querySelectorAll(".tool-row.active")].map(x=>x.textContent.trim()), hasWallTypeSelect: !!document.querySelector(".tools-panel select") })); +await p.screenshot({ path:"scripts/probe-tools-panel.png" }); +console.log("info:", JSON.stringify(info), "afterWall:", JSON.stringify(afterWall), logs.length?logs.join("|"):"(no err)"); +await b.close(); diff --git a/scripts/probe-tools.mjs b/scripts/probe-tools.mjs new file mode 100644 index 0000000..2153f3d --- /dev/null +++ b/scripts/probe-tools.mjs @@ -0,0 +1,80 @@ +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} +await new Promise((r) => setTimeout(r, 600)); + +// 1) Wand-Werkzeug aktivieren (Button per Text finden). +const clickedWall = await page.evaluate(() => { + const btns = [...document.querySelectorAll("button")]; + const b = btns.find((x) => x.textContent.trim() === "Wand"); + if (b) { b.click(); return true; } + return false; +}); + +await new Promise((r) => setTimeout(r, 200)); + +// 2) SVG-Box ermitteln und eine L-Polylinie zeichnen (3 Punkte + Rechtsklick). +const box = await page.evaluate(() => { + const svg = document.querySelector(".plan-svg"); + const r = svg.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; +}); +const cx = box.x + box.w / 2; +const cy = box.y + box.h / 2; +const pts = [ + [cx - 160, cy - 120], + [cx - 160, cy + 60], + [cx + 80, cy + 60], +]; +for (const [x, y] of pts) { + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.up(); + await new Promise((r) => setTimeout(r, 80)); +} +// Hover, damit das letzte lebende Segment + Snap-Marker zu sehen sind. +await page.mouse.move(cx + 80, cy - 60); +await new Promise((r) => setTimeout(r, 120)); +await page.screenshot({ path: "scripts/probe-tools-drawing.png" }); + +// Rechtsklick = Polylinie abschließen (commit). +await page.mouse.click(cx + 80, cy - 60, { button: "right" }); +await new Promise((r) => setTimeout(r, 200)); + +// 3) Zurück auf Auswahl und Ergebnis screenshoten. +await page.evaluate(() => { + const b = [...document.querySelectorAll("button")].find( + (x) => x.textContent.trim() === "Auswahl", + ); + if (b) b.click(); +}); +await new Promise((r) => setTimeout(r, 200)); + +// Wand-/Drawing-Zählung aus dem DOM (Polygone mit Wandbezug sind schwer zu +// zählen; stattdessen Anzahl Pfade/Polygone als grober Indikator). +const counts = await page.evaluate(() => ({ + polygons: document.querySelectorAll(".plan-svg polygon").length, + lines: document.querySelectorAll(".plan-svg line").length, +})); + +await page.screenshot({ path: "scripts/probe-tools.png" }); +console.log("clickedWall:", clickedWall); +console.log("counts:", JSON.stringify(counts)); +console.log("=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-topbar.mjs b/scripts/probe-topbar.mjs new file mode 100644 index 0000000..d72c022 --- /dev/null +++ b/scripts/probe-topbar.mjs @@ -0,0 +1,16 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1400,height:200,deviceScaleFactor:3}); +await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,700)); +const tb=await p.$(".topbar"); const box=await tb.boundingBox(); +await p.screenshot({path:"scripts/probe-topbar.png", clip:{x:0,y:0,width:1400,height:Math.ceil(box.height)+8}}); +// zähle die Icon-Buttons + ihre computed Größe/Farbe +const info=await p.evaluate(()=>{ + const icons=[...document.querySelectorAll(".view-icon")]; + const svg=icons[0]?.querySelector("svg"); + const cs=icons[0]?getComputedStyle(icons[0]):null; + return { count:icons.length, cellW:cs?.width, cellH:cs?.height, color:cs?.color, svgW:svg?.getAttribute("width"), gridRows: document.querySelector(".view-grid")?getComputedStyle(document.querySelector(".view-grid")).gridTemplateColumns:null }; +}); +console.log(JSON.stringify(info)); +await b.close(); diff --git a/scripts/probe-transform.mjs b/scripts/probe-transform.mjs new file mode 100644 index 0000000..d463c6f --- /dev/null +++ b/scripts/probe-transform.mjs @@ -0,0 +1,62 @@ +import puppeteer from "puppeteer"; +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] }); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); +const logs = []; +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); +page.on("dialog", async (d) => { await d.accept("3"); }); // Kopienanzahl-Prompt → 3 +try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); } +await new Promise((r) => setTimeout(r, 600)); +const clickTool = (l) => page.evaluate((x) => [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === x)?.click(), l); +const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); }; +const nLines = () => page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length); +const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); +const cx = box.x + box.w / 2, cy = box.y + box.h / 2; + +// Eine Linie zeichnen + selektieren. +await clickTool("Linie"); await new Promise((r) => setTimeout(r, 80)); +const A = [cx - 160, cy - 120], B = [cx - 40, cy - 120]; +await click(...A); await click(...B); +await clickTool("Auswahl"); await new Promise((r) => setTimeout(r, 60)); +await click((A[0] + B[0]) / 2, A[1]); // selektieren +const n0 = await nLines(); + +// ── Modusleiste: 'm' drücken → Bar erscheint (Screenshot) ── +await page.keyboard.press("m"); +await new Promise((r) => setTimeout(r, 120)); +const barVisible = await page.evaluate(() => !!document.querySelector(".transform-bar")); +await page.screenshot({ path: "scripts/probe-transform-bar.png" }); + +// ── Move (U, default): Basispunkt → Ziel ── +await click(A[0], A[1]); // Basispunkt +await click(A[0] + 80, A[1] + 140); // Ziel +await new Promise((r) => setTimeout(r, 120)); +const nMove = await nLines(); +const movedPos = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return +l.getAttribute("y1"); }); + +// ── Copy (I): m, i, base, dest → +1 ── +await click((A[0] + B[0]) / 2 + 80, A[1] + 140); // selektieren (verschobene Linie) +await page.keyboard.press("m"); +await page.keyboard.press("i"); +await new Promise((r) => setTimeout(r, 60)); +await click(cx, cy); await click(cx + 100, cy + 40); +await new Promise((r) => setTimeout(r, 120)); +const nCopy = await nLines(); + +// ── Array (O=3): select a line, m, o(→3), base, dest → +3 ── +await click(cx + 50, cy + 20); // selektieren (eine der Linien) +await page.keyboard.press("m"); +await page.keyboard.press("o"); +await new Promise((r) => setTimeout(r, 150)); +await click(cx - 50, cy + 120); await click(cx - 20, cy + 150); +await new Promise((r) => setTimeout(r, 150)); +const nArray = await nLines(); + +await page.screenshot({ path: "scripts/probe-transform.png" }); +console.log("n0(initial):", n0, "barVisible:", barVisible); +console.log("nMove(expect ==n0):", nMove, "movedY:", movedPos); +console.log("nCopy(expect n0+1):", nCopy); +console.log("nArray(expect nCopy+3):", nArray); +console.log(logs.length ? logs.join("\n") : "(no errors)"); +await browser.close(); diff --git a/scripts/probe-verify2.mjs b/scripts/probe-verify2.mjs new file mode 100644 index 0000000..17e19d5 --- /dev/null +++ b/scripts/probe-verify2.mjs @@ -0,0 +1,54 @@ +import puppeteer from "puppeteer"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1500, height: 950, deviceScaleFactor: 2 }); +const errs = []; +page.on("pageerror", (e) => errs.push(`[PAGEERROR] ${e.message}`)); +try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle0", timeout: 20000 }); } catch {} +await new Promise((r) => setTimeout(r, 1200)); + +const footerScale = () => page.evaluate(() => { + const t = document.body.innerText; + const m = t.match(/MASSSTAB\s*(1:\d+)/i); + return m ? m[1] : "?"; +}); + +const before = await footerScale(); +// Massstab-Select auf 1:50 setzen +const changed = await page.evaluate(() => { + const selects = [...document.querySelectorAll("select")]; + for (const s of selects) { + const opt = [...s.options].find((o) => /1:50\b/.test(o.textContent || o.value)); + if (opt) { + s.value = opt.value; + s.dispatchEvent(new Event("change", { bubbles: true })); + return true; + } + } + return false; +}); +await new Promise((r) => setTimeout(r, 500)); +const after = await footerScale(); + +// Ressourcen öffnen +const resOpened = await page.evaluate(() => { + const b = [...document.querySelectorAll("button")].find((x) => /Ressourcen/i.test(x.textContent || "")); + if (b) { b.click(); return true; } + return false; +}); +await new Promise((r) => setTimeout(r, 500)); +const resInfo = await page.evaluate(() => { + const ov = document.querySelector(".res-drawer, .res-overlay, [class*=overlay], [class*=drawer]"); + const z = ov ? getComputedStyle(ov).zIndex : null; + return { hasOverlay: !!ov, z }; +}); +await page.screenshot({ path: "scripts/probe-ressourcen-window.png" }); + +console.log("Massstab-Select gefunden:", changed); +console.log("Footer Massstab vorher:", before, "→ nachher:", after, after !== before ? "✅ ändert sich" : "⚠️ gleich"); +console.log("Ressourcen geöffnet:", resOpened, "Overlay:", resInfo.hasOverlay, "z-index:", resInfo.z); +console.log("PageErrors:", errs.length ? errs.join("\n") : "keine"); +await browser.close(); diff --git a/scripts/probe-view3d.mjs b/scripts/probe-view3d.mjs new file mode 100644 index 0000000..bc8ff97 --- /dev/null +++ b/scripts/probe-view3d.mjs @@ -0,0 +1,84 @@ +// Probe: belegt die Ansichts-3D-Presets (Front/Top/Seite/Persp./Iso) + +// Kamera-FOV. Wechselt in die Perspektive, klickt je Preset den Button und +// screenshotet. Die PNGs müssen sich deutlich unterscheiden. +// probe-view3d-perspective.png — 3/4-Blick +// probe-view3d-top.png — Draufsicht +// probe-view3d-front.png — frontal +// probe-view3d-side.png — seitlich +// probe-view3d-iso.png — isometrisch +// probe-view3d-fov.png — Kamera-Popover mit FOV-Regler (weit) +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); + +const clickByText = async (sel, text) => { + const handle = await page.evaluateHandle( + (sel, text) => + [...document.querySelectorAll(sel)].find( + (el) => el.textContent.trim() === text, + ), + sel, + text, + ); + const el = handle.asElement(); + if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`); + await el.click(); +}; + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const shot = (name) => page.screenshot({ path: `scripts/${name}` }); + +// In die Perspektive wechseln. +await clickByText("button.view-btn", "Perspektive"); +await wait(1000); + +// Default-Preset (perspective) — Referenz. +await shot("probe-view3d-perspective.png"); + +// Presets durchschalten (per sichtbarem Button-Text in der 3D-Gruppe). +for (const [label, file] of [ + ["Oben", "probe-view3d-top.png"], + ["Front", "probe-view3d-front.png"], + ["Seite", "probe-view3d-side.png"], + ["Isometrie", "probe-view3d-iso.png"], + ["Perspektive", "probe-view3d-perspective2.png"], +]) { + await clickByText("button.view-btn", label); + await wait(700); + await shot(file); +} + +// Kamera-Popover öffnen + FOV weit aufdrehen (sichtbarer Effekt). +await clickByText("button.tb-btn", "Kamera"); +await wait(300); +const range = await page.$(".tb-popover input[type=range]"); +if (range) { + await page.evaluate((el) => { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + ).set; + setter.call(el, "90"); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }, range); + await wait(500); +} +await shot("probe-view3d-fov.png"); + +console.log("Screenshots: probe-view3d-{perspective,top,front,side,iso,fov}.png"); +console.log("\n=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/probe-wall-sel.mjs b/scripts/probe-wall-sel.mjs new file mode 100644 index 0000000..a1eaa62 --- /dev/null +++ b/scripts/probe-wall-sel.mjs @@ -0,0 +1,13 @@ +import puppeteer from "puppeteer"; +const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]}); +const p=await b.newPage(); await p.setViewport({width:1400,height:880,deviceScaleFactor:1.25}); +const errs=[]; p.on("pageerror",e=>errs.push(e.message)); +await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{}); +await new Promise(r=>setTimeout(r,1200)); +// auf eine Wandkante des Rechtecks klicken (linke Wand) +const hit=await p.evaluate(()=>{const polys=[...document.querySelectorAll(".plan-svg polygon, .plan-svg path")];if(!polys.length)return null;const svg=document.querySelector(".plan-svg");const r=svg.getBoundingClientRect();return{x:r.x+r.width*0.34,y:r.y+r.height*0.5};}); +if(hit){await p.mouse.click(hit.x,hit.y);await new Promise(r=>setTimeout(r,400));} +const hasWallSec=await p.evaluate(()=>!!document.querySelector(".objinfo-panel")&&/Aufbau|Referenz|UK|OK/.test(document.querySelector(".objinfo-panel")?.textContent||"")); +await p.screenshot({path:"scripts/probe-wall-sel.png"}); +console.log("wallSection:",hasWallSec,"errs:",errs.slice(0,2)); +await b.close(); diff --git a/scripts/probe-wallattr.mjs b/scripts/probe-wallattr.mjs new file mode 100644 index 0000000..d20b0f8 --- /dev/null +++ b/scripts/probe-wallattr.mjs @@ -0,0 +1,186 @@ +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5187/"; +const b = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const p = await b.newPage(); +await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 }); +const errs = []; +p.on("pageerror", (e) => errs.push(e.message)); +p.on("console", (m) => { if (m.type() === "error") errs.push("[console] " + m.text()); }); +await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {}); +await new Promise((r) => setTimeout(r, 800)); + +const clickTab = (label) => + p.evaluate((x) => { + const el = [...document.querySelectorAll("button, .tab, [role=tab]")].find( + (b) => b.textContent.trim() === x, + ); + el?.click(); + return !!el; + }, label); + +// Object-Info-Tab aktivieren (falls vorhanden). +const tabOk = await clickTab("Objekt-Info"); +await new Promise((r) => setTimeout(r, 200)); + +// Eine Wand im Grundriss anklicken (obere Bandkante eines Wandpolygons). +async function selectWall() { + const bb = await p.evaluate(() => { + const polys = [...document.querySelectorAll(".plan-svg polygon")]; + if (!polys.length) return null; + let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9; + for (const pl of polys) { + const r = pl.getBoundingClientRect(); + minX = Math.min(minX, r.left); minY = Math.min(minY, r.top); + maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom); + } + return { minX, minY, maxX, maxY }; + }); + if (!bb) return false; + const cx = (bb.minX + bb.maxX) / 2; + for (const dy of [4, 8, 12, 16, 20]) { + await p.mouse.click(cx, bb.minY + dy); + await new Promise((r) => setTimeout(r, 120)); + const k = await p.evaluate( + () => document.querySelector(".objinfo-kind")?.textContent || "", + ); + if (/Wand/.test(k)) return true; + } + return false; +} + +const selected = await selectWall(); + +// (1) Wand-Abschnitt sichtbar? +const hasWallSection = await p.evaluate(() => + [...document.querySelectorAll(".objinfo-section-label")].some( + (e) => e.textContent.trim() === "Wand", + ), +); +const hasRefDropdown = await p.evaluate( + () => !!document.querySelector(".objinfo-head-ref"), +); +await p.screenshot({ path: "scripts/probe-wallattr-1-panel.png" }); + +// Plan-Footprint VORHER (bbox aller Wandpolygone). +const planBBox = () => + p.evaluate(() => { + const polys = [...document.querySelectorAll(".plan-svg polygon")]; + let minY = 1e9, maxY = -1e9; + for (const pl of polys) { + const r = pl.getBoundingClientRect(); + minY = Math.min(minY, r.top); maxY = Math.max(maxY, r.bottom); + } + return { minY: Math.round(minY), maxY: Math.round(maxY) }; + }); +const beforeRef = await planBBox(); + +// (2) Referenzlinie auf „Außen (links)" stellen über das Kopf-Dropdown. +async function pickDropdown(triggerSel, optionText) { + const ok = await p.evaluate((sel) => { + const t = document.querySelector(sel); + if (!t) return false; + t.click(); + return true; + }, triggerSel); + if (!ok) return false; + await new Promise((r) => setTimeout(r, 150)); + const picked = await p.evaluate((txt) => { + const item = [...document.querySelectorAll(".tb-dd-menu .tb-dd-item")].find( + (i) => i.textContent.trim().includes(txt), + ); + item?.click(); + return !!item; + }, optionText); + await new Promise((r) => setTimeout(r, 200)); + return picked; +} + +const refPicked = await pickDropdown( + ".objinfo-head-ref .tb-dd-trigger", + "Außen", +); +await new Promise((r) => setTimeout(r, 250)); +const afterRef = await planBBox(); +await p.screenshot({ path: "scripts/probe-wallattr-2-refline-aussen.png" }); + +// (3) OK auf „eigene Höhe" → Z-Wert ändern, dann 3D. +// Den OK-Block (zweite objinfo-wall-anchor) auf „Eigene Höhe" schalten. +const okCustom = await p.evaluate(() => { + const blocks = [...document.querySelectorAll(".objinfo-wall-anchor")]; + const okBlock = blocks[1]; + if (!okBlock) return false; + const btn = [...okBlock.querySelectorAll(".objinfo-seg-btn")].find( + (b) => b.textContent.trim() === "Eigene Höhe", + ); + btn?.click(); + return !!btn; +}); +await new Promise((r) => setTimeout(r, 200)); +// Z-Feld setzen (5.0 m) im OK-Block. +const heightBefore = await p.evaluate( + () => + [...document.querySelectorAll(".objinfo-field")] + .find((f) => f.querySelector(".objinfo-flabel")?.textContent === "Höhe") + ?.querySelector(".objinfo-fval")?.textContent || "", +); +const setZ = await p.evaluate(() => { + const blocks = [...document.querySelectorAll(".objinfo-wall-anchor")]; + const okBlock = blocks[1]; + const input = okBlock?.querySelector("input.objinfo-input"); + if (!input) return false; + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + ).set; + setter.call(input, "5"); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + input.blur(); + return true; +}); +await new Promise((r) => setTimeout(r, 250)); +const heightAfter = await p.evaluate( + () => + [...document.querySelectorAll(".objinfo-field")] + .find((f) => f.querySelector(".objinfo-flabel")?.textContent === "Höhe") + ?.querySelector(".objinfo-fval")?.textContent || "", +); +await p.screenshot({ path: "scripts/probe-wallattr-3-ok-custom.png" }); + +// 3D (Isometrie) umschalten und Screenshot — der Umschalter ist ein Icon-Button +// mit title „Isometrie …" in der Oberleiste (kein Tab). +await p.evaluate(() => { + const el = [...document.querySelectorAll("button[title]")].find((b) => + (b.getAttribute("title") || "").startsWith("Isometrie"), + ); + el?.click(); +}); +await new Promise((r) => setTimeout(r, 1000)); +await p.screenshot({ path: "scripts/probe-wallattr-4-3d.png" }); + +console.log( + JSON.stringify( + { + tabOk, + selected, + hasWallSection, + hasRefDropdown, + refPicked, + beforeRef, + afterRef, + footprintShifted: beforeRef.minY !== afterRef.minY || beforeRef.maxY !== afterRef.maxY, + okCustom, + setZ, + heightBefore, + heightAfter, + errs, + }, + null, + 2, + ), +); +await b.close(); diff --git a/scripts/probe.mjs b/scripts/probe.mjs new file mode 100644 index 0000000..9b4d3ca --- /dev/null +++ b/scripts/probe.mjs @@ -0,0 +1,47 @@ +import puppeteer from "puppeteer"; + +const URL = process.env.PROBE_URL || "http://localhost:5173/"; +const browser = await puppeteer.launch({ + headless: "new", + args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], +}); +const page = await browser.newPage(); +// HiDPI simulieren — das ist die Bedingung, die die Schleife auslöste. +await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 }); + +const logs = []; +page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); +page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`)); + +try { + await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); +} catch (e) { + logs.push(`[GOTO-ERROR] ${e.message}`); +} + +const measure = () => + page.evaluate(() => { + const c = document.querySelector(".viewport canvas"); + const v = document.querySelector(".viewport"); + const r = c?.getBoundingClientRect(); + return { + canvasCss: r ? `${Math.round(r.width)}x${Math.round(r.height)}` : "none", + canvasBuffer: c ? `${c.width}x${c.height}` : "none", + viewport: v ? `${Math.round(v.clientWidth)}x${Math.round(v.clientHeight)}` : "none", + bodyScrollH: document.body.scrollHeight, + }; + }); + +const m1 = await measure(); +await new Promise((r) => setTimeout(r, 1200)); +const m2 = await measure(); + +await page.screenshot({ path: "scripts/probe.png" }); + +const stable = JSON.stringify(m1) === JSON.stringify(m2); +console.log("=== Messung 1 ===", JSON.stringify(m1)); +console.log("=== Messung 2 ===", JSON.stringify(m2)); +console.log(stable ? "✅ STABIL — keine Schleife" : "❌ INSTABIL — wächst/schrumpft noch"); +console.log("\n=== Logs ==="); +console.log(logs.length ? logs.join("\n") : "(keine)"); +await browser.close(); diff --git a/scripts/test-kernel2d.ts b/scripts/test-kernel2d.ts new file mode 100644 index 0000000..d6f6d85 --- /dev/null +++ b/scripts/test-kernel2d.ts @@ -0,0 +1,192 @@ +// Numerische Verifikation des 2D-Kernels (esbuild → node). Kein UI nötig. +import { + segmentIntersect, + offsetPolyline, + offsetSegment, + trimSegment, + extendSegment, + filletCorner, + pointSegmentDistance, + lineCircleIntersect, + circleCircleIntersect, + signedArea, + isCCW, + joinChains, + removeSegment, + splitAtIntersections, + splitClosedByChord, + splitPolylineAtParam, + vecEqual, +} from "../src/geometry/kernel2d.ts"; + +let pass = 0; +let fail = 0; +const approx = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) <= eps; +function check(name: string, cond: boolean, detail?: unknown) { + if (cond) { pass++; console.log(" ok " + name); } + else { fail++; console.log(" FAIL " + name, JSON.stringify(detail)); } +} + +// 1) Strecken-Schnitt: X durch (1,1) +{ + const h = segmentIntersect({x:0,y:0},{x:2,y:2},{x:0,y:2},{x:2,y:0}); + check("segmentIntersect cross at (1,1)", !!h && approx(h!.point.x,1) && approx(h!.point.y,1) && approx(h!.t,0.5), h); +} +// 2) parallele Strecken → null +{ + const h = segmentIntersect({x:0,y:0},{x:2,y:0},{x:0,y:1},{x:2,y:1}); + check("segmentIntersect parallel -> null", h === null); +} +// 3) kein Schnitt innerhalb der Segmente → null +{ + const h = segmentIntersect({x:0,y:0},{x:1,y:0},{x:2,y:-1},{x:2,y:1}); + check("segmentIntersect outside -> null", h === null); +} +// 4) Offset einer Strecke nach links (d>0): +y +{ + const [a,b] = offsetSegment({x:0,y:0},{x:1,y:0},0.5); + check("offsetSegment left +0.5y", approx(a.y,0.5) && approx(b.y,0.5) && approx(a.x,0) && approx(b.x,1), {a,b}); +} +// 5) Offset geschlossenes Quadrat: Konvention +leftNormal zeigt bei CCW NACH +// INNEN (CONVENTIONS.md), also d>0 = innen → (1,1)..(3,3); d<0 = außen → (-1,-1)..(5,5). +{ + const sq = [{x:0,y:0},{x:4,y:0},{x:4,y:4},{x:0,y:4}]; // CCW + const inn = offsetPolyline(sq, 1, true); + const okIn = inn.length===4 && + approx(inn[0].x,1)&&approx(inn[0].y,1)&& + approx(inn[1].x,3)&&approx(inn[1].y,1)&& + approx(inn[2].x,3)&&approx(inn[2].y,3)&& + approx(inn[3].x,1)&&approx(inn[3].y,3); + check("offsetPolyline square inward by 1 (CCW +n=inside)", okIn, inn); + const out = offsetPolyline(sq, -1, true); + const okOut = out.length===4 && approx(out[0].x,-1)&&approx(out[0].y,-1)&&approx(out[2].x,5)&&approx(out[2].y,5); + check("offsetPolyline square outward by 1 (d<0)", okOut, out); +} +// 6) Offset offene L-Polylinie (Gehrung am Innenknick) +{ + const L = [{x:0,y:0},{x:4,y:0},{x:4,y:4}]; + const off = offsetPolyline(L, 1, false); // links + // Endkanten verschoben; mittlerer Punkt = Gehrung bei (3,1) für links-offset + check("offsetPolyline open L mitre", off.length===3 && approx(off[1].x,3) && approx(off[1].y,1), off); +} +// 7) Trim: Strecke 0..4 von Cutter bei x=1 und x=3, Pick im Mittelstück → 2 Reststücke +{ + const cutters = [ + {pts:[{x:1,y:-1},{x:1,y:1}], closed:false}, + {pts:[{x:3,y:-1},{x:3,y:1}], closed:false}, + ]; + const rem = trimSegment({x:0,y:0},{x:4,y:0},cutters,{x:2,y:0}); + const ok = rem.length===2 && + approx(rem[0][0].x,0)&&approx(rem[0][1].x,1)&& + approx(rem[1][0].x,3)&&approx(rem[1][1].x,4); + check("trimSegment removes middle piece", ok, rem); +} +// 8) Extend: Strecke 0..1 auf x-Achse, Ende bis Cutter bei x=3 +{ + const cutters=[{pts:[{x:3,y:-1},{x:3,y:1}],closed:false}]; + const ex = extendSegment({x:0,y:0},{x:1,y:0},"end",cutters); + check("extendSegment to x=3", !!ex && approx(ex![1].x,3) && approx(ex![1].y,0), ex); +} +// 9) Extend start-Ende rückwärts bis x=-2 +{ + const cutters=[{pts:[{x:-2,y:-1},{x:-2,y:1}],closed:false}]; + const ex = extendSegment({x:0,y:0},{x:1,y:0},"start",cutters); + check("extendSegment start to x=-2", !!ex && approx(ex![0].x,-2), ex); +} +// 10) Fillet 90°-Ecke, r=1 → setback 1, center (1,1) +{ + const f = filletCorner({x:0,y:0},{x:5,y:0},{x:0,y:5},1); + const ok = !!f && approx(f!.tangentA.x,1)&&approx(f!.tangentA.y,0)&& + approx(f!.tangentB.x,0)&&approx(f!.tangentB.y,1)&& + approx(f!.center.x,1)&&approx(f!.center.y,1)&&approx(f!.radius,1); + check("filletCorner 90deg r1", ok, f); +} +// 11) Fillet kollinear → null +{ + const f = filletCorner({x:0,y:0},{x:1,y:0},{x:-1,y:0},1); + check("filletCorner collinear -> null", f === null); +} +// 12) pointSegmentDistance +{ + check("pointSegmentDistance perpendicular", approx(pointSegmentDistance({x:1,y:2},{x:0,y:0},{x:4,y:0}),2)); +} + +// 13) Linie durch Kreis (Mittelpunkt-Sekante) → 2 Punkte ±r +{ + const ps = lineCircleIntersect({x:-5,y:0},{x:5,y:0},{x:0,y:0},2); + const xs = ps.map(p=>p.x).sort((a,b)=>a-b); + check("lineCircleIntersect secant", ps.length===2 && approx(xs[0],-2) && approx(xs[1],2), ps); +} +// 14) Zwei Kreise (Abstand 2, r=√2 je) schneiden sich bei (1,±1) +{ + const ps = circleCircleIntersect({x:0,y:0},Math.SQRT2,{x:2,y:0},Math.SQRT2); + const ok = ps.length===2 && ps.every(p=>approx(Math.abs(p.x),1)&&approx(Math.abs(p.y),1)) && approx(ps[0].x,1); + check("circleCircleIntersect at (1,±1)", ok, ps); +} +// 15) Wicklung +{ + const ccw = [{x:0,y:0},{x:4,y:0},{x:4,y:4},{x:0,y:4}]; + check("signedArea/isCCW", approx(signedArea(ccw),16) && isCCW(ccw) && !isCCW([...ccw].reverse())); +} + +// ── Split / Join / Segment-Löschen ─────────────────────────────────────────── +const V = (x: number, y: number) => ({ x, y }); + +// 16) splitPolylineAtParam: offene Linie → 2 Stücke an (5,0) +{ + const parts = splitPolylineAtParam([V(0,0),V(10,0)], false, 0, 0.5); + check("splitPolyline open -> 2 pieces", parts.length===2 && approx(parts[0][1].x,5) && approx(parts[1][0].x,5), parts); +} +// 17) splitPolylineAtParam: geschlossenes Quadrat → eine offene Kette von/bis Schnitt +{ + const sq=[V(0,0),V(4,0),V(4,4),V(0,4)]; + const parts = splitPolylineAtParam(sq, true, 0, 0.5); + const c = parts[0]; + check("splitPolyline closed -> 1 open chain", parts.length===1 && vecEqual(c[0],V(2,0)) && vecEqual(c[c.length-1],V(2,0)), c); +} +// 18) splitClosedByChord: Quadrat unten/oben → 2 geschlossene Hälften +{ + const sq=[V(0,0),V(4,0),V(4,4),V(0,4)]; + const res = splitClosedByChord(sq, 0, 0.5, 2, 0.5); + const has=(p:{x:number;y:number}[],q:{x:number;y:number})=>p.some(r=>vecEqual(r,q)); + const ok = !!res && res[0].length>=3 && res[1].length>=3 && + has(res![0],V(2,0)) && has(res![0],V(2,4)) && has(res![1],V(2,0)) && has(res![1],V(2,4)); + check("splitClosedByChord -> 2 closed halves", ok, res); + check("splitClosedByChord same edge -> null", splitClosedByChord(sq,1,0.5,1,0.7)===null); +} +// 19) removeSegment: geschlossenes Quadrat → offen, Start nach Kante +{ + const sq=[V(0,0),V(4,0),V(4,4),V(0,4)]; + const r = removeSegment(sq, true, 0); + check("removeSegment closed -> open chain", r.closed===false && r.pts.length===4 && vecEqual(r.pts[0],V(4,0)), r); +} +// 20) splitAtIntersections: Rechteck × querende Linie → 2 geschlossene Hälften +{ + const rect=[V(0,0),V(4,0),V(4,4),V(0,4)]; + const cutter=[V(2,-1),V(2,5)]; + const pieces = splitAtIntersections(rect, true, [cutter]); + check("splitAtIntersections rect×line -> 2 closed", pieces.length===2 && pieces[0].length>=3 && pieces[1].length>=3, pieces); +} +// 21) splitAtIntersections: offene Linie × Cutter → 2 Stücke an (5,0) +{ + const pieces = splitAtIntersections([V(0,0),V(10,0)], false, [[V(5,-2),V(5,2)]]); + check("splitAtIntersections open line -> 2 pieces", pieces.length===2 && approx(pieces[0][pieces[0].length-1].x,5), pieces); +} +// 22) joinChains: zwei Linien mit gemeinsamem Endpunkt → eine Polylinie +{ + const out = joinChains([{pts:[V(0,0),V(5,0)],closed:false},{pts:[V(5,0),V(5,5)],closed:false}]); + check("joinChains 2 chains -> 1 polyline (3 pts)", out.length===1 && out[0].pts.length===3 && out[0].closed===false, out); +} +// 23) joinChains: vier Kanten schließen sich → eine geschlossene Schleife +{ + const out = joinChains([ + {pts:[V(0,0),V(4,0)],closed:false}, + {pts:[V(4,0),V(4,4)],closed:false}, + {pts:[V(4,4),V(0,4)],closed:false}, + {pts:[V(0,4),V(0,0)],closed:false}, + ]); + check("joinChains loop -> closed ring (4 pts)", out.length===1 && out[0].closed===true && out[0].pts.length===4, out); +} + +console.log(`\nKERNEL2D: ${pass} ok, ${fail} fail`); +if (fail > 0) process.exit(1); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..bf44d51 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,2783 @@ +// 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 { + DrawingLevel, + EdgeGrip, + LayerCategory, + Project, + Vec2, +} 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 { 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 type { DetailLevel, RenderMode, View3d, ViewType } from "./ui/TopBar"; +import { StatusBar } from "./ui/StatusBar"; +import { TransformBar } from "./ui/TransformBar"; +import { getTool } from "./tools/tools"; +import { 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 { + 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 } 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, + joinDrawings, + nearestEdgeIndex, + splitDrawing, + toPolyForm, +} from "./editors/splitJoin"; +import type { PolyForm } from "./editors/splitJoin"; + +// 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 { + 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; + +/** 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); + // 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); + + // ── 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; + + // ── 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); + + // 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); + 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]; + + // TEMP-PROBE-HOOK (split/join verification) — wird nach der Verifikation entfernt. + useEffect(() => { + (window as unknown as { __drawings2d?: unknown }).__drawings2d = + project.drawings2d.map((d) => ({ id: d.id, geom: d.geom })); + }, [project.drawings2d]); + + // 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; + 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); + } else { + setActiveTransform({ ...tr, points }); + } + }; + + const toolHandlers: ToolHandlers = { + onToolMove: (raw, pxPerMeter, mods) => { + // Läuft ein Befehl, übernimmt die Engine die Plan-Eingabe (ein Eingabepfad). + if (engineRef.current?.isActive()) { + engineRef.current.move(raw); + return; + } + activeTransform + ? transformMove(raw, pxPerMeter, mods) + : runToolStep("move", raw, pxPerMeter, mods); + }, + onToolClick: (raw, pxPerMeter, mods) => { + if (engineRef.current?.isActive()) { + engineRef.current.pick(raw); + 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, + visibleCodes, + snap, + selectedWallIds, + selectedDrawingId, + setProject, + setDraft, + }); + cmdLatest.current = { + project, + activeLevel, + activeCategoryCode, + activeLineStyleId, + visibleCodes, + snap, + selectedWallIds, + selectedDrawingId, + 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, + lastPoint, + // Vorab gesetzte Auswahl aus dem Store (Rhino: erst selektieren, dann + // Befehl). Move/Copy/Offset operieren darauf. + selection: { + wallIds: c.selectedWallIds, + drawingId: c.selectedDrawingId, + }, + }; + }, + snap: (raw, draftPoints) => { + 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: false, + 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; + + // 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; + } + 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; + } + 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 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]; + } + return []; + }, [selectedDrawing, 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: bewusst KEINE Kanten-Griffe (kein „außen"/„innen" + // bei offener Geometrie; Eckpunkt-Griffe genügen dort). + 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; + }; + if (selectedDrawing) { + const g = selectedDrawing.geom; + if (g.shape === "rect") return ringEdges(grips); + if (g.shape === "polyline" && g.closed) return ringEdges(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 }]; + } + } + return []; + }, [selectedDrawing, 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); + const gripHandlers = { + onGripMove: (index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods) => { + const snapProject: Project = 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 (bzw. Winkelraster) eingerastet wird. + const refIndex = index > 0 ? index - 1 : grips.length > 1 ? 1 : -1; + const lastPoint = refIndex >= 0 ? grips[refIndex] : null; + const snapResult = computeSnap({ + raw, + project: snapProject, + levelId: activeLevel.id, + visibleCodes, + settings: snap, + draftPoints: [], + lastPoint, + pxPerMeter, + shift: mods.shift, + ctrl: mods.ctrl, + }); + const pt = snapResult?.point ?? raw; + moveGripOf(selectedDrawingId, selectedWallId, index, pt); + setDraft({ preview: [], vertices: [], snap: snapResult }); + }, + onMoveBody: (delta: Vec2) => { + moveElementByOf(selectedDrawingId, selectedWallId, delta); + }, + // Kanten-Griff ziehen: NUR die Komponente senkrecht zur Kante wird wirksam + // (die Seite verschiebt sich parallel nach außen/innen). Der Offset wird als + // Skalarprojektion von (raw − mid) auf die Außennormale gebildet und — wenn + // der Snap aktiv ist (kein Ctrl) — aufs Raster gerundet. So bleibt die + // Geste robust und achsentreu, ohne dass ein Eckpunkt verkantet. + onEdgeMove: (edge: EdgeGrip, raw: Vec2, _pxPerMeter: number, mods: ToolMods) => { + // 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; + moveEdgeOf(selectedDrawingId, selectedWallId, edge.aIndex, edge.bIndex, scale(edge.normal, inc)); + setDraft({ preview: [], vertices: [], snap: null }); + }, + onGripEnd: () => { + setDraft(null); + edgeDragOffsetRef.current = null; // Drag beendet → Anker zurücksetzen + }, + }; + + // 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); + if (wallIds.length || drawingIds.length) { + setSelectedWallIds(wallIds); + setSelectedDrawingIds(drawingIds); + 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) { + const dids = new Set(selectedDrawingIds); + const wids = new Set(selectedWallIds); + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)), + walls: p.walls.filter((w) => !wids.has(w.id)), + doors: p.doors.filter((dr) => !wids.has(dr.hostWallId)), + })); + setSelectedDrawingIds([]); + setSelectedWallIds([]); + e.preventDefault(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + activeTool, + activeTransform, + selectedDrawingIds, + selectedWallIds, + project.walls, + project.drawings2d, + activeLevel.id, + ]); + + // Ctrl/Cmd+S = Split, Ctrl/Cmd+J = Join der selektierten Drawing2D-Elemente. + // Beide rufen preventDefault (sonst Browser-Speichern/-Lesezeichen). Wände sind + // ausgenommen (no-op). Tool-unabhängig, solange eine 2D-Auswahl besteht. + 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") { + // Split: jedes gewählte Element gegen ALLE ANDEREN Elemente seiner Ebene + // schneiden (ohne Schnitt: Mittel-Fallback in splitDrawing). Ersetzt jedes + // Quell-Drawing durch ≥1 Ergebnis; neue Elemente werden ausgewählt. + 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; + } + // Andere Elemente DERSELBEN Ebene (außer dem Ziel selbst) als Schneider. + 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([]); + return; + } + + // Join: gewählte Elemente an koinzidenten Endpunkten verschmelzen. + const idSet = new Set(ids); + const newIds: string[] = []; + setProject((p) => { + const srcs = p.drawings2d.filter((d) => idSet.has(d.id)); + if (srcs.length === 0) return p; + const joined = joinDrawings(srcs); + for (const j of joined) newIds.push(j.id); + const rest = p.drawings2d.filter((d) => !idSet.has(d.id)); + return { ...p, drawings2d: [...rest, ...joined] }; + }); + setSelectedDrawingIds(newIds); + setSelectedWallIds([]); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectedDrawingIds]); + + // 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(); + const op: TransformOp | null = k === "m" ? "move" : k === "d" ? "rotate" : k === "s" ? "mirror" : null; + if (op) { + setActiveTransform({ op, copyMode: "move", count: 1, points: [] }); + setDraft(null); + e.preventDefault(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [activeTool, activeTransform, selectedWallIds, selectedDrawingId]); + + // ── 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); + + // ── Selektions-Attribut-Kontrakt (Attributes-/Object-Info-Paletten) ─────── + // Normalisierte, effektiv aufgelöste Sicht auf das erste selektierte Element. + const selection = useMemo( + () => deriveSelection(project, selectedWallIds, selectedDrawingId), + [project, selectedWallIds, selectedDrawingId], + ); + + // ── 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: setActiveTool, + toolsEnabled: activeLevel.kind === "floor" && viewType === "grundriss", + 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); + }, + 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 }); + }, + // ── 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); + + // 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)); + } + }; + + // ── Maus-Schema: Auswahl + Kontextmenü aus Grundriss / 3D ───────────────── + // Links-Klick im Grundriss: genau eine Wand wählen (Menge ersetzen) bzw. + // Leerraum → Auswahl löschen. + 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], + ); + } + return; + } + // Ohne Shift: ersetzen. Wand hat Vorrang; sonst 2D-Element; sonst leeren. + if (sel.wallId) { + setSelectedWallIds([sel.wallId]); + setSelectedDrawingId(null); + } else if (sel.drawingId) { + setSelectedDrawingId(sel.drawingId); + setSelectedWallIds([]); + } else { + setSelectedWallIds([]); + setSelectedDrawingId(null); + } + }; + // 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 ?? []); + }; + // 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] : []); + // 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 = ""; + }} + /> + + 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))} + /> + } + /> + +
+ {/* 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. */} + + + {/* Modusleiste der laufenden Transformation (über der Mitte). */} + {activeTransform && ( + { + setActiveTransform(null); + setDraft(null); + }} + /> + )} + + {/* 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. */} + engine.submitText(text)} + onOption={(id) => engine.chooseOption(id)} + onCycleField={() => engine.cycleField()} + onCancel={() => 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)} + /> + )} + + {/* 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(), + })} + 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; +} + +/** + * 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[] { + if (wallId) { + return [ + { + label: t("ctx.fitSelection"), + icon: , + onClick: a.fitSelection, + }, + { + label: t("ctx.clearSelection"), + icon: , + onClick: a.clearSelection, + }, + { divider: true }, + { + label: t("ctx.fit"), + icon: , + onClick: a.fit, + }, + ]; + } + return [ + { + 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, + onPlanSelect, + onPlanMarquee, + onSegmentCut, + onPlanContextMenu, + onViewportSelectWall, + onViewportContextMenu, + activeTool, + toolInputActive, + toolHandlers, + grips, + edgeGrips, + selectedDrawingId, + selectedDrawingIds, + gripHandlers, +}: { + 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[]; + 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; + onViewportContextMenu: (info: ViewportContextInfo) => void; + activeTool: ToolId; + toolInputActive: boolean; + toolHandlers: ToolHandlers; + grips: Vec2[]; + edgeGrips: EdgeGrip[]; + selectedDrawingId: string | null; + selectedDrawingIds?: string[]; + gripHandlers: GripHandlers; +}) { + 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, + 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[]; + 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"), + })} + +
+
+ ); +} diff --git a/src/commands/cmds/circle.ts b/src/commands/cmds/circle.ts new file mode 100644 index 0000000..632aaba --- /dev/null +++ b/src/commands/cmds/circle.ts @@ -0,0 +1,141 @@ +// Circle — Mittelpunkt + Radius. Schritte: +// 1) „Mittelpunkt:" → Punkt +// 2) „Radius:" → Punkt (Abstand) ODER getippte Zahl (Radius) → commit +// +// Radius akzeptiert sowohl einen Maus-Punkt (Abstand zum Mittelpunkt) als auch +// eine getippte nackte Zahl. Der Kreis wird in generatePlan als geschlossene +// Polygon-Approximation gerendert. + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandField, + CommandResult, + CommandState, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const EPS = 1e-6; + +interface CircleIdle extends CommandState { + phase: "center"; +} +interface CircleDrawing extends CommandState { + phase: "radius"; + center: Vec2; + cursor: Vec2 | null; +} +type CircleState = CircleIdle | CircleDrawing; + +const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); + +/** Tab-Feld des Radius-Schritts: der Radius selbst. */ +const CIRCLE_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }]; + +/** + * Punkt auf dem Kreis aus gelocktem Radius (oder Cursor-Abstand), so dass + * dist(center, point) = Radius. Die Richtung folgt dem Cursor (sonst +X). + */ +function circlePointFromFields(center: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const r = "radius" in locks ? Math.abs(locks.radius) : cursor ? dist(center, cursor) : 0; + if (cursor) { + const d = dist(center, cursor); + if (d > EPS) { + return { x: center.x + ((cursor.x - center.x) / d) * r, y: center.y + ((cursor.y - center.y) / d) * r }; + } + } + return { x: center.x + r, y: center.y }; +} + +/** Punkte einer Kreis-Approximation (für die Vorschau). */ +function circlePts(center: Vec2, r: number): Vec2[] { + const N = 48; + const pts: Vec2[] = []; + for (let i = 0; i < N; i++) { + const t = (i / N) * Math.PI * 2; + pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) }); + } + return pts; +} + +/** Vorschau (Kreis) + HUD (Radius). */ +function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft { + if (r < EPS) return { preview: [], vertices: [center] }; + const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }]; + const draft: ToolDraft = { preview, vertices: [center] }; + if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` }; + return draft; +} + +function appendCircle(p: Project, center: Vec2, r: number, ctx: CommandContext): Project { + if (r < EPS) return p; + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "circle", center, r }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "center", lastPoint: null }, + { draft: null, done: true }, +]; + +export const circleCommand: Command = { + name: "circle", + labelKey: "cmd.circle.label", + prompt: (s) => ((s as CircleState).phase === "radius" ? "cmd.circle.radius" : "cmd.circle.center"), + accepts: (s) => ((s as CircleState).phase === "radius" ? ["point", "number"] : ["point"]), + options: () => [], + init: (): CircleIdle => ({ phase: "center", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as CircleState; + if (s.phase !== "radius") { + if (input.kind !== "point") return [s, { draft: null }]; + const ns: CircleDrawing = { phase: "radius", center: input.point, cursor: input.point, lastPoint: input.point }; + return [ns, { draft: circleDraft(input.point, 0, null) }]; + } + // Radius-Schritt: Zahl = Radius, Punkt = Abstand zum Mittelpunkt. + let r: number; + if (input.kind === "number") r = input.value; + else if (input.kind === "point") r = dist(s.center, input.point); + else { + const rr = s.cursor ? dist(s.center, s.cursor) : 0; + return [s, { draft: circleDraft(s.center, rr, s.cursor) }]; + } + const center = s.center; + return [ + { phase: "center", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendCircle(p, center, r, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as CircleState; + if (s.phase !== "radius") return [s, { draft: null }]; + const r = dist(s.center, point); + const ns: CircleDrawing = { ...s, cursor: point }; + return [ns, { draft: circleDraft(s.center, r, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), + + // Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert + // committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius). + fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as CircleState; + if (s.phase !== "radius") return null; + return circlePointFromFields(s.center, locks, cursor); + }, +}; diff --git a/src/commands/cmds/copy.ts b/src/commands/cmds/copy.ts new file mode 100644 index 0000000..3cef59a --- /dev/null +++ b/src/commands/cmds/copy.ts @@ -0,0 +1,165 @@ +// Copy — wie Move, aber jeder Zielpunkt fügt eine KOPIE der Auswahl (neue IDs) +// an der Ziellage hinzu; das Original bleibt (Rhino-Copy, docs §2.10). Schritte: +// 1) „Basispunkt:" → Punkt +// 2) „Zielpunkt:" → Punkt (Tab length/angle) → Kopie + BLEIBT AKTIV +// → weitere Zielpunkte = weitere Kopien (Basispunkt bleibt), bis Esc/Enter. +// +// Die Auswahl kommt vorab über ctx.selection. Leere Auswahl → No-op + Hinweis. +// Der Commit nutzt `commitTransform` (op:"move", mode:"copy") — eine Kopie der +// Auswahl an die Endlage, Original unverändert; neue IDs via uniqueId (dort). + +import { commitTransform } from "../../tools/transform"; +import type { TransformSelection } from "../../tools/transform"; +import { transformPreview } from "../../tools/transform"; +import type { + Command, + CommandField, + CommandResult, + CommandSelection, + CommandState, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const DEG = Math.PI / 180; +const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); +const segAngleDeg = (a: Vec2, b: Vec2): number => + (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; + +/** Tab-Felder des Zielschritts: Länge + Winkel des Kopier-Versatzes (§2.7). */ +const COPY_FIELDS: CommandField[] = [ + { id: "length", labelKey: "cmd.field.length" }, + { id: "angle", labelKey: "cmd.field.angle" }, +]; + +function targetFromFields(base: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const ref = cursor ?? base; + const length = "length" in locks ? locks.length : segLen(base, ref); + const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref); + const ang = angleDeg * DEG; + return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length }; +} + +function hasSelection(sel: CommandSelection): boolean { + return sel.wallIds.length > 0 || sel.drawingId !== null; +} +function toTransformSel(sel: CommandSelection): TransformSelection { + return { wallIds: sel.wallIds, drawingId: sel.drawingId }; +} + +interface CopyBase extends CommandState { + phase: "base"; +} +interface CopyTarget extends CommandState { + phase: "target"; + sel: CommandSelection; + base: Vec2; + cursor: Vec2 | null; +} +interface CopyDone extends CommandState { + phase: "done"; +} +type CopyState = CopyBase | CopyTarget | CopyDone; + +/** Vorschau EINER Kopie der Auswahl an der Cursor-Lage. */ +function copyDraft( + project: Project, + sel: CommandSelection, + base: Vec2, + target: Vec2, +): ToolDraft { + const preview = transformPreview( + project, + toTransformSel(sel), + "move", + [base, target], + "copy", + 1, + ); + return { + preview, + vertices: [base], + hud: { + at: target, + text: `${segLen(base, target).toFixed(2)} m · ${Math.abs( + (segAngleDeg(base, target) + 360) % 360, + ).toFixed(0)}°`, + }, + }; +} + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as CopyDone, + { draft: null, done: true }, +]; + +export const copyCommand: Command = { + name: "copy", + labelKey: "cmd.copy.label", + prompt: (s) => { + const cs = s as CopyState; + if (cs.phase === "target") return "cmd.copy.target"; + if (cs.phase === "done") return "cmd.copy.empty"; + return "cmd.copy.base"; + }, + accepts: () => ["point"], + options: () => [], + init: (): CopyBase => ({ phase: "base", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as CopyState; + if (s.phase === "done") return finish(); + if (!hasSelection(ctx.selection)) { + return [{ phase: "done", lastPoint: null } as CopyDone, { draft: null, done: true }]; + } + if (input.kind !== "point") { + if (s.phase === "target") { + return [s, { draft: s.cursor ? copyDraft(ctx.project, s.sel, s.base, s.cursor) : null }]; + } + return [s, { draft: null }]; + } + const pt = input.point; + if (s.phase !== "target") { + const next: CopyTarget = { + phase: "target", + sel: ctx.selection, + base: pt, + cursor: pt, + lastPoint: pt, + }; + return [next, { draft: copyDraft(ctx.project, next.sel, pt, pt) }]; + } + // Zielpunkt → eine Kopie committen, ABER aktiv bleiben (wiederholend): der + // Befehl kehrt NICHT in den Ruhezustand zurück (kein done), Basispunkt + // bleibt; jeder weitere Zielpunkt = weitere Kopie, bis Esc/Enter. + const base = s.base; + const sel = s.sel; + const next: CopyTarget = { ...s, cursor: pt, lastPoint: pt }; + return [ + next, + { + draft: copyDraft(ctx.project, sel, base, pt), + commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "copy", 1), + }, + ]; + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as CopyState; + if (s.phase !== "target") return [s, { draft: null }]; + const ns: CopyTarget = { ...s, cursor: point }; + return [ns, { draft: copyDraft(ctx.project, s.sel, s.base, point) }]; + }, + + // Enter/Space/Rechtsklick: Copy-Serie beenden (kein Commit hier). + onConfirm: (): [CommandState, CommandResult] => finish(), + onCancel: (): [CommandState, CommandResult] => finish(), + + fields: (state) => ((state as CopyState).phase === "target" ? COPY_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as CopyState; + if (s.phase !== "target") return null; + return targetFromFields(s.base, locks, cursor); + }, +}; diff --git a/src/commands/cmds/import.ts b/src/commands/cmds/import.ts new file mode 100644 index 0000000..da416f8 --- /dev/null +++ b/src/commands/cmds/import.ts @@ -0,0 +1,37 @@ +// Import — öffnet den DXF-Datei-Dialog (in App montiert) und beendet sich +// sofort. Der eigentliche Import (Datei lesen → parseDxf → addContextObjects) +// läuft asynchron über das App-onChange des versteckten ; ein +// Command kann den Datei-Dialog nicht selbst „committen" (er ist DOM-/async- +// seitig). Daher koppelt dieser Befehl nur den Trigger an (über den Modul-Hook +// dxfImportHook) und gibt keine Mutation zurück. +// +// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.import.*`). + +import { openDxfImport } from "../../io/dxfImportHook"; +import type { Command, CommandResult, CommandState } from "../types"; + +const IDLE: CommandState = { phase: "idle", lastPoint: null }; + +/** Öffnet den Datei-Dialog und beendet den Befehl sofort (keine Mutation). */ +function open(): [CommandState, CommandResult] { + openDxfImport(); + return [{ ...IDLE }, { draft: null, done: true }]; +} + +export const importCommand: Command = { + name: "import", + labelKey: "cmd.import.label", + prompt: () => "cmd.import.prompt", + accepts: () => [], + options: () => [], + init: () => ({ ...IDLE }), + + // Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet. + onInput: () => open(), + onMove: (state): [CommandState, CommandResult] => [state, { draft: null }], + onConfirm: () => open(), + onCancel: (): [CommandState, CommandResult] => [ + { ...IDLE }, + { draft: null, done: true }, + ], +}; diff --git a/src/commands/cmds/line.ts b/src/commands/cmds/line.ts new file mode 100644 index 0000000..5ce147c --- /dev/null +++ b/src/commands/cmds/line.ts @@ -0,0 +1,140 @@ +// Line — erster Befehl der Command-Engine (portiert lineTool aus +// src/tools/tools.ts). Zwei Punkte → Drawing2D{shape:"line"}. +// +// Schritte: +// 1) „Start of line:" → Punkt (Klick ODER getippt: 0,0 / r3,0 / 3<45) +// 2) „End of line:" → Punkt → commit Drawing2D line → done +// +// Akzeptiert in JEDEM Punkt-Schritt sowohl Maus-Picks (gesnappt) als auch +// getippte Koordinaten — das ist die Kern-Vereinheitlichung gegenüber dem alten +// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools. + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandField, + CommandResult, + CommandState, + DraftShape, + ToolDraft, + Vec2, +} from "../types"; + +const EPS = 1e-6; +const DEG = Math.PI / 180; +const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); +const segAngleDeg = (a: Vec2, b: Vec2): number => + (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; + +/** Tab-Felder des End-Schritts: erst Länge, dann Winkel (§2.7). */ +const LINE_FIELDS: CommandField[] = [ + { id: "length", labelKey: "cmd.field.length" }, + { id: "angle", labelKey: "cmd.field.angle" }, +]; + +/** + * Endpunkt aus gelockten Feldern (length/angle) + Cursor: ungelockte Werte + * folgen dem Cursor relativ zum Startpunkt `a`. + */ +function lineEndFromFields(a: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const ref = cursor ?? a; + const length = "length" in locks ? locks.length : segLen(a, ref); + const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref); + const ang = angleDeg * DEG; + return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length }; +} + +interface LineIdle extends CommandState { + phase: "start"; +} +interface LineDrawing extends CommandState { + phase: "end"; + a: Vec2; + cursor: Vec2 | null; +} +type LineState = LineIdle | LineDrawing; + +/** Vorschau (Rubber-Band) + HUD (Länge·Winkel) für das lebende Segment. */ +function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft { + const preview: DraftShape[] = []; + if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor }); + const draft: ToolDraft = { preview, vertices: [a] }; + if (cursor && segLen(a, cursor) >= EPS) { + draft.hud = { + at: cursor, + text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs( + (segAngleDeg(a, cursor) + 360) % 360, + ).toFixed(0)}°`, + }; + } + return draft; +} + +/** Hängt eine 2D-Linie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */ +function appendLine(p: import("../types").Project, a: Vec2, b: Vec2, ctx: CommandContext) { + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "line", a, b }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +export const lineCommand: Command = { + name: "line", + labelKey: "cmd.line.label", + prompt: (s) => ((s as LineState).phase === "end" ? "cmd.line.end" : "cmd.line.start"), + accepts: () => ["point", "number"], + options: () => [], + init: (): LineIdle => ({ phase: "start", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as LineState; + // Beide Punkt-Schritte erwarten einen aufgelösten Punkt; die Engine reicht + // getippte Koordinaten bereits als {kind:"point"} herein. + if (input.kind !== "point") return [s, { draft: s.phase === "end" ? lineDraft(s.a, s.cursor) : null }]; + const pt = input.point; + if (s.phase !== "end") { + const next: LineDrawing = { phase: "end", a: pt, cursor: pt, lastPoint: pt }; + return [next, { draft: lineDraft(pt, pt) }]; + } + // Zweiter Punkt: Null-Strecke verwerfen, sonst committen. + if (segLen(s.a, pt) < EPS) { + return [{ phase: "start", lastPoint: null }, { draft: null, done: true }]; + } + const a = s.a; + return [ + { phase: "start", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendLine(p, a, pt, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as LineState; + if (s.phase !== "end") return [s, { draft: null }]; + const next: LineDrawing = { ...s, cursor: point }; + return [next, { draft: lineDraft(s.a, point) }]; + }, + + // Line braucht genau zwei Punkte; Enter/Rechtsklick im offenen Entwurf bricht ab. + onConfirm: (): [CommandState, CommandResult] => [ + { phase: "start", lastPoint: null }, + { draft: null, done: true }, + ], + onCancel: (): [CommandState, CommandResult] => [ + { phase: "start", lastPoint: null }, + { draft: null, done: true }, + ], + + // Tab-Feld-Zyklus nur im End-Schritt: Länge + Winkel relativ zum Startpunkt. + fields: (state) => ((state as LineState).phase === "end" ? LINE_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as LineState; + if (s.phase !== "end") return null; + return lineEndFromFields(s.a, locks, cursor); + }, +}; diff --git a/src/commands/cmds/move.ts b/src/commands/cmds/move.ts new file mode 100644 index 0000000..dc198e0 --- /dev/null +++ b/src/commands/cmds/move.ts @@ -0,0 +1,181 @@ +// Move — verschiebt die AKTUELLE Auswahl (Wände + ein Drawing2D) um +// delta = Ziel − Basis (Rhino-Move, docs §2.10/§3.4 Tier-1). Schritte: +// 1) „Basispunkt:" → Punkt +// 2) „Zielpunkt:" → Punkt (mit Tab-Feldern length/angle wie Line) → commit +// +// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl). +// Ist sie leer, ist nichts zu verschieben: kurzer No-op mit Hinweis-Prompt +// („Nichts gewählt") und Beenden — das ist der einfache, robuste Weg (statt +// einen zweiten, divergierenden Pick-Pfad für die Objektwahl aufzumachen; die +// Selektion läuft schon sauber über die Plan-Ansicht/Store). +// +// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"move", +// mode:"move") statt eine zweite Verschiebe-Logik zu duplizieren — er bewegt +// Wände UND alle 2D-Formen immutabel an die Endlage. + +import { commitTransform } from "../../tools/transform"; +import type { TransformSelection } from "../../tools/transform"; +import { transformPreview } from "../../tools/transform"; +import type { + Command, + CommandField, + CommandResult, + CommandSelection, + CommandState, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const DEG = Math.PI / 180; +const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); +const segAngleDeg = (a: Vec2, b: Vec2): number => + (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; + +/** Tab-Felder des Zielschritts: Länge + Winkel des Verschiebevektors (§2.7). */ +const MOVE_FIELDS: CommandField[] = [ + { id: "length", labelKey: "cmd.field.length" }, + { id: "angle", labelKey: "cmd.field.angle" }, +]; + +/** Zielpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zum Basispunkt. */ +function targetFromFields(base: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const ref = cursor ?? base; + const length = "length" in locks ? locks.length : segLen(base, ref); + const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref); + const ang = angleDeg * DEG; + return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length }; +} + +/** Hat die Auswahl überhaupt etwas Verschiebbares? */ +function hasSelection(sel: CommandSelection): boolean { + return sel.wallIds.length > 0 || sel.drawingId !== null; +} + +/** Auswahl → TransformSelection (gleiche Form). */ +function toTransformSel(sel: CommandSelection): TransformSelection { + return { wallIds: sel.wallIds, drawingId: sel.drawingId }; +} + +// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start). +interface MoveBase extends CommandState { + phase: "base"; +} +interface MoveTarget extends CommandState { + phase: "target"; + sel: CommandSelection; + base: Vec2; + cursor: Vec2 | null; +} +// „done": leere Auswahl → No-op, Befehl beendet. +interface MoveDone extends CommandState { + phase: "done"; +} +type MoveState = MoveBase | MoveTarget | MoveDone; + +/** Live-Vorschau der Auswahl an der aktuellen Cursor-Lage (Move). */ +function moveDraft( + project: Project, + sel: CommandSelection, + base: Vec2, + target: Vec2, +): ToolDraft { + const preview = transformPreview( + project, + toTransformSel(sel), + "move", + [base, target], + "move", + 1, + ); + return { + preview, + vertices: [base], + hud: { + at: target, + text: `${segLen(base, target).toFixed(2)} m · ${Math.abs( + (segAngleDeg(base, target) + 360) % 360, + ).toFixed(0)}°`, + }, + }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as MoveDone, + { draft: null, done: true }, +]; + +export const moveCommand: Command = { + name: "move", + labelKey: "cmd.move.label", + prompt: (s) => { + const ms = s as MoveState; + if (ms.phase === "target") return "cmd.move.target"; + if (ms.phase === "done") return "cmd.move.empty"; + return "cmd.move.base"; + }, + accepts: () => ["point"], + options: () => [], + + // Beim Start die Auswahl aus dem Kontext einfrieren — dafür braucht init() + // den Kontext nicht, das macht onInput beim ersten Aufruf; bis dahin „base". + // Damit die leere Auswahl SOFORT (vor dem ersten Klick) auffällt, prüfen wir + // sie zusätzlich im ersten onMove/onInput. + init: (): MoveBase => ({ phase: "base", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as MoveState; + if (s.phase === "done") return idle(); + // Leere Auswahl → nichts zu verschieben (No-op + Hinweis, dann beenden). + if (!hasSelection(ctx.selection)) { + return [{ phase: "done", lastPoint: null } as MoveDone, { draft: null, done: true }]; + } + if (input.kind !== "point") { + if (s.phase === "target") { + return [s, { draft: s.cursor ? moveDraft(ctx.project, s.sel, s.base, s.cursor) : null }]; + } + return [s, { draft: null }]; + } + const pt = input.point; + if (s.phase !== "target") { + // Basispunkt gesetzt → Auswahl-Snapshot mitführen. + const next: MoveTarget = { + phase: "target", + sel: ctx.selection, + base: pt, + cursor: pt, + lastPoint: pt, + }; + return [next, { draft: moveDraft(ctx.project, next.sel, pt, pt) }]; + } + // Zielpunkt → commit (Auswahl an die Endlage verschieben). + const base = s.base; + const sel = s.sel; + return [ + { phase: "done", lastPoint: null } as MoveDone, + { + draft: null, + done: true, + commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "move", 1), + }, + ]; + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as MoveState; + if (s.phase !== "target") return [s, { draft: null }]; + const ns: MoveTarget = { ...s, cursor: point }; + return [ns, { draft: moveDraft(ctx.project, s.sel, s.base, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), + + // Tab-Feld-Zyklus nur im Zielschritt: Länge + Winkel relativ zum Basispunkt. + fields: (state) => ((state as MoveState).phase === "target" ? MOVE_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as MoveState; + if (s.phase !== "target") return null; + return targetFromFields(s.base, locks, cursor); + }, +}; diff --git a/src/commands/cmds/offset.ts b/src/commands/cmds/offset.ts new file mode 100644 index 0000000..6effa2e --- /dev/null +++ b/src/commands/cmds/offset.ts @@ -0,0 +1,339 @@ +// Offset — DAS Architektur-Primitiv (docs §2.10/§3.4 Tier-1, §3.6 persistente +// Distanz). Erzeugt eine parallel versetzte Kopie einer 2D-Kurve (line/polyline/ +// rect). Schritte: +// 1) Kurve bestimmen: ist genau EIN passendes Drawing2D (line/polyline/rect) +// selektiert, wird es genommen; sonst „Kurve wählen:" → Klick auf eine Kurve. +// 2) „Seite/Distanz:" → Distanz tippen (PERSISTENT als Default über Aufrufe, +// modulweite Variable) ODER Seite per Klick (Vorzeichen aus der Klickseite +// relativ zur Kurve). → commit neue versetzte Drawing2D-Kurve. +// +// Distanz-Vorzeichen: Konvention `+d = links` (kernel2d/leftNormal). Die +// Klickseite bestimmt links/rechts: liegt der Klick links der Kurve (bzw. der +// nächsten Kante), wird +|d| genommen, sonst −|d|. Eine getippte Zahl nutzt das +// Vorzeichen der zuletzt gewählten Seite (Default links/+), bzw. ihr eigenes +// Vorzeichen, wenn negativ getippt. + +import type { Drawing2D, Drawing2DGeom } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import { leftNormal, normalize, sub } from "../../model/geometry"; +import { + offsetPolyline, + offsetSegment, + pointSegmentDistance, + polylineEdges, +} from "../../geometry/kernel2d"; +import type { + CmdInput, + Command, + CommandContext, + CommandResult, + CommandSelection, + CommandState, + DraftShape, + Project, + Vec2, +} from "../types"; + +const EPS = 1e-6; +const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl + +/** + * Persistente Offset-Distanz über Aufrufe hinweg (§2.4/§3.6 — Nutzer erwarten, + * dass die letzte Distanz als Default wiederkommt). Modulweite Variable, |d|; + * das Vorzeichen (Seite) wird je Offset frisch aus Klick/Eingabe bestimmt. + */ +let lastDistance = 0.5; +/** Zuletzt gewählte Seite als Vorzeichen (+1 = links, −1 = rechts). */ +let lastSign = 1; + +// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ────────────── + +interface Curve { + pts: Vec2[]; + closed: boolean; +} + +/** Wandelt eine offset-fähige 2D-Form in eine Punktliste (+closed) oder null. */ +function geomToCurve(g: Drawing2DGeom): Curve | null { + if (g.shape === "line") return { pts: [g.a, g.b], closed: false }; + if (g.shape === "polyline") return { pts: g.pts, closed: g.closed }; + if (g.shape === "rect") { + // Rechteck als geschlossene 4-Punkt-Polylinie offsetten (Task). + return { + pts: [ + g.min, + { x: g.max.x, y: g.min.y }, + g.max, + { x: g.min.x, y: g.max.y }, + ], + closed: true, + }; + } + return null; // circle/arc/text: hier nicht offset-fähig +} + +/** Ist diese Form offset-fähig (line/polyline/rect)? */ +function isOffsetable(d: Drawing2D): boolean { + return d.geom.shape === "line" || d.geom.shape === "polyline" || d.geom.shape === "rect"; +} + +/** Genau ein passendes Drawing2D vorselektiert? Dann liefere es. */ +function singleSelectedCurve(project: Project, sel: CommandSelection): Drawing2D | null { + if (!sel.drawingId || sel.wallIds.length > 0) return null; + const d = project.drawings2d.find((x) => x.id === sel.drawingId); + if (d && isOffsetable(d)) return d; + return null; +} + +/** Nächstes offset-fähiges Drawing2D zum Klickpunkt (innerhalb Toleranz). */ +function pickCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null { + let best: Drawing2D | null = null; + let bestD = HIT_TOL; + for (const d of project.drawings2d) { + if (d.levelId !== levelId) continue; + const curve = geomToCurve(d.geom); + if (!curve) continue; + for (const [a, b] of polylineEdges(curve.pts, curve.closed)) { + const dd = pointSegmentDistance(pt, a, b); + if (dd < bestD) { + bestD = dd; + best = d; + } + } + } + return best; +} + +/** + * Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Kurve: die + * nächste Kante bestimmen, dann das Vorzeichen der Skalarprojektion von + * (klick − Kantenmitte) auf die LINKS-Normale der Kante. So zeigt das Resultat + * auf die angeklickte Seite (Konvention `+d = links`). + */ +function sideSign(curve: Curve, pt: Vec2): number { + let bestD = Infinity; + let sign = lastSign; + for (const [a, b] of polylineEdges(curve.pts, curve.closed)) { + const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const dd = pointSegmentDistance(pt, a, b); + if (dd < bestD) { + bestD = dd; + const u = normalize(sub(b, a)); + const n = leftNormal(u); + const rel = sub(pt, mid); + const s = rel.x * n.x + rel.y * n.y; + sign = s >= 0 ? 1 : -1; + } + } + return sign; +} + +/** Offset einer Kurve um die vorzeichenbehaftete Distanz `d` → neue Punktliste. */ +function offsetCurve(curve: Curve, d: number): Vec2[] { + if (!curve.closed && curve.pts.length === 2) { + const [a, b] = offsetSegment(curve.pts[0], curve.pts[1], d); + return [a, b]; + } + return offsetPolyline(curve.pts, d, curve.closed); +} + +/** Versetzte Form als neues Drawing2D (gleiche Attribute, neue ID/Geometrie). */ +function offsetGeom(src: Drawing2D, d: number): Drawing2DGeom | null { + const g = src.geom; + if (g.shape === "line") { + const [a, b] = offsetSegment(g.a, g.b, d); + return { shape: "line", a, b }; + } + if (g.shape === "polyline") { + return { shape: "polyline", pts: offsetPolyline(g.pts, d, g.closed), closed: g.closed }; + } + if (g.shape === "rect") { + const curve = geomToCurve(g)!; + return { shape: "polyline", pts: offsetPolyline(curve.pts, d, true), closed: true }; + } + return null; +} + +/** Vorschau-Formen der versetzten Kurve (für den Draft). */ +function offsetDraftShapes(curve: Curve, d: number): DraftShape[] { + const pts = offsetCurve(curve, d); + if (pts.length < 2) return []; + if (!curve.closed && pts.length === 2) return [{ kind: "line", a: pts[0], b: pts[1] }]; + return [{ kind: "poly", pts, closed: curve.closed }]; +} + +function appendOffset(p: Project, src: Drawing2D, d: number): Project { + const geom = offsetGeom(src, d); + if (!geom) return p; + const copy: Drawing2D = { ...src, id: uniqueId("dr2d"), geom }; + return { ...p, drawings2d: [...p.drawings2d, copy] }; +} + +// ── Zustand ─────────────────────────────────────────────────────────────────── + +// „pick": Kurve wählen (keine passende Vorselektion). „side": Kurve steht fest, +// jetzt Seite/Distanz. „done": Befehl beendet. +interface OffPick extends CommandState { + phase: "pick"; +} +interface OffSide extends CommandState { + phase: "side"; + drawingId: string; + cursor: Vec2 | null; +} +interface OffDone extends CommandState { + phase: "done"; +} +type OffState = OffPick | OffSide | OffDone; + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as OffDone, + { draft: null, done: true }, +]; + +/** Aktuelle Kurve aus dem Side-Zustand (oder null, wenn weg). */ +function curveOf(project: Project, drawingId: string): { src: Drawing2D; curve: Curve } | null { + const src = project.drawings2d.find((x) => x.id === drawingId); + if (!src) return null; + const curve = geomToCurve(src.geom); + if (!curve) return null; + return { src, curve }; +} + +export const offsetCommand: Command = { + name: "offset", + labelKey: "cmd.offset.label", + prompt: (s) => { + const os = s as OffState; + if (os.phase === "side") return "cmd.offset.side"; + return "cmd.offset.pick"; + }, + // Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). Auch der + // „pick"-Schritt nimmt eine Zahl an, damit eine getippte Distanz sofort greift, + // wenn eine Kurve vorselektiert ist (dann wird „pick" live wie „side" behandelt). + accepts: () => ["point", "number"], + options: () => [], + + // init() kennt den Kontext nicht; die Vorselektion wird im ersten onMove/ + // onInput ausgewertet (geht direkt auf „side", wenn genau eine Kurve gewählt + // ist). Bis dahin „pick". + init: (): OffPick => ({ phase: "pick", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as OffState; + if (s.phase === "done") return finish(); + + // Vorselektion: gibt es genau eine passende Kurve, direkt in den Side-Schritt + // springen (auch wenn die Engine erst onInput aufruft). + if (s.phase === "pick") { + const pre = singleSelectedCurve(ctx.project, ctx.selection); + if (pre) { + // Eingabe im selben Schritt direkt als Side/Distanz behandeln. + return offsetSideInput( + { phase: "side", drawingId: pre.id, cursor: null, lastPoint: null }, + input, + ctx, + ); + } + // Keine Vorselektion → Kurve per Klick wählen. + if (input.kind === "point") { + const hit = pickCurveAt(ctx.project, ctx.level.id, input.point); + if (!hit) return [s, { draft: null }]; + const ns: OffSide = { phase: "side", drawingId: hit.id, cursor: null, lastPoint: null }; + return [ns, { draft: null }]; + } + return [s, { draft: null }]; + } + + // phase === "side" + return offsetSideInput(s as OffSide, input, ctx); + }, + + onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { + const s = state as OffState; + // Vorselektion live: in „pick" mit genau einer Kurve → als Side behandeln. + if (s.phase === "pick") { + const pre = singleSelectedCurve(ctx.project, ctx.selection); + if (pre) { + return offsetSideMove({ phase: "side", drawingId: pre.id, cursor: point, lastPoint: null }, point, ctx); + } + return [s, { draft: null }]; + } + if (s.phase === "side") return offsetSideMove(s as OffSide, point, ctx); + return [s, { draft: null }]; + }, + + onConfirm: (): [CommandState, CommandResult] => finish(), + onCancel: (): [CommandState, CommandResult] => finish(), + + // KEIN Tab-Feld-Zyklus: die Offset-Distanz ist ein reiner Skalar, der als + // `number`-Eingabe in den Schritt läuft (nicht als feld-erzeugter Punkt). So + // committet eine getippte Distanz direkt; die Seite kommt per Klick/Vorzeichen. +}; + +// ── Side-Schritt-Logik (für Vorselektion UND echten pick wiederverwendet) ───── + +/** Eingabe im Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). */ +function offsetSideInput( + s: OffSide, + input: CmdInput, + ctx: CommandContext, +): [CommandState, CommandResult] { + const cc = curveOf(ctx.project, s.drawingId); + if (!cc) return finish(); + const { src, curve } = cc; + + if (input.kind === "number") { + // Getippte Distanz: Betrag merken; Vorzeichen aus der zuletzt gewählten + // Seite (bzw. dem getippten Vorzeichen, falls negativ). + const typed = input.value; + const mag = Math.abs(typed); + if (mag < EPS) return [s, { draft: null }]; + lastDistance = mag; + const sign = typed < 0 ? -1 : lastSign; + lastSign = sign; + const d = sign * mag; + return [ + { phase: "done", lastPoint: null } as OffDone, + { draft: null, done: true, commit: (p) => appendOffset(p, src, d) }, + ]; + } + + if (input.kind === "point") { + // Seite per Klick: Vorzeichen aus der Klickseite, Betrag = letzte Distanz. + const sign = sideSign(curve, input.point); + lastSign = sign; + const d = sign * lastDistance; + return [ + { phase: "done", lastPoint: null } as OffDone, + { draft: null, done: true, commit: (p) => appendOffset(p, src, d) }, + ]; + } + + return [s, { draft: null }]; +} + +/** Hover im Side-Schritt: Vorschau der versetzten Kurve an der Cursor-Seite. */ +function offsetSideMove( + s: OffSide, + point: Vec2, + ctx: CommandContext, +): [CommandState, CommandResult] { + const cc = curveOf(ctx.project, s.drawingId); + if (!cc) return [s, { draft: null }]; + const { curve } = cc; + const sign = sideSign(curve, point); + const d = sign * lastDistance; + const preview = offsetDraftShapes(curve, d); + const ns: OffSide = { ...s, cursor: point }; + return [ + ns, + { + draft: { + preview, + vertices: [], + hud: { at: point, text: `${lastDistance.toFixed(2)} m` }, + }, + }, + ]; +} diff --git a/src/commands/cmds/polyline.ts b/src/commands/cmds/polyline.ts new file mode 100644 index 0000000..e5b82ab --- /dev/null +++ b/src/commands/cmds/polyline.ts @@ -0,0 +1,181 @@ +// Polyline — mehrteiliger Zug (portiert polylineTool). Schritte: +// 1) „Startpunkt:" → Punkt +// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen, +// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls) +// +// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45). + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandField, + CommandResult, + CommandState, + CmdOption, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const EPS = 1e-6; +const DEG = Math.PI / 180; +const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Zug +const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); +const segAngleDeg = (a: Vec2, b: Vec2): number => + (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; + +/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */ +const POLY_FIELDS: CommandField[] = [ + { id: "length", labelKey: "cmd.field.length" }, + { id: "angle", labelKey: "cmd.field.angle" }, +]; + +/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */ +function polyNextFromFields(last: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const ref = cursor ?? last; + const length = "length" in locks ? locks.length : segLen(last, ref); + const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref); + const ang = angleDeg * DEG; + return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length }; +} + +interface PolyIdle extends CommandState { + phase: "start"; +} +interface PolyDrawing extends CommandState { + phase: "next"; + points: Vec2[]; + cursor: Vec2 | null; +} +type PolyState = PolyIdle | PolyDrawing; + +const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" }; +const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" }; + +/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */ +function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft { + const pts = cursor ? [...points, cursor] : points; + const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }]; + const draft: ToolDraft = { preview, vertices: points }; + const last = points[points.length - 1]; + if (cursor && last && segLen(last, cursor) >= EPS) { + draft.hud = { + at: cursor, + text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs( + (segAngleDeg(last, cursor) + 360) % 360, + ).toFixed(0)}°`, + }; + } + return draft; +} + +/** Hängt eine 2D-Polylinie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */ +function appendPolyline( + p: Project, + pts: Vec2[], + closed: boolean, + ctx: CommandContext, +): Project { + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "polyline", pts, closed }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "start", lastPoint: null }, + { draft: null, done: true }, +]; + +export const polylineCommand: Command = { + name: "polyline", + labelKey: "cmd.polyline.label", + prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"), + accepts: (s) => + (s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"], + options: (s) => { + const ps = s as PolyState; + if (ps.phase !== "next") return []; + return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO]; + }, + init: (): PolyIdle => ({ phase: "start", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as PolyState; + + if (input.kind === "option") { + if (s.phase !== "next") return [s, { draft: null }]; + if (input.id === "undo") { + const pts = s.points.slice(0, -1); + if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }]; + const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] }; + return [ns, { draft: polyDraft(pts, s.cursor) }]; + } + if (input.id === "close" && s.points.length >= 3) { + const pts = s.points; + return [ + { phase: "start", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) }, + ]; + } + return [s, { draft: polyDraft(s.points, s.cursor) }]; + } + + if (input.kind !== "point") { + return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }]; + } + const pt = input.point; + if (s.phase !== "next") { + const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt }; + return [ns, { draft: polyDraft([pt], pt) }]; + } + // Klick nahe Startpunkt → schließen. + if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) { + const pts = s.points; + return [ + { phase: "start", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) }, + ]; + } + const points = [...s.points, pt]; + const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt }; + return [ns, { draft: polyDraft(points, pt) }]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as PolyState; + if (s.phase !== "next") return [s, { draft: null }]; + const ns: PolyDrawing = { ...s, cursor: point }; + return [ns, { draft: polyDraft(s.points, point) }]; + }, + + // Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen. + onConfirm: (state, ctx): [CommandState, CommandResult] => { + const s = state as PolyState; + if (s.phase === "next" && s.points.length >= 2) { + const pts = s.points; + return [ + { phase: "start", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) }, + ]; + } + return idle(); + }, + onCancel: (): [CommandState, CommandResult] => idle(), + + // Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten + // Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.) + fields: (state) => ((state as PolyState).phase === "next" ? POLY_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as PolyState; + if (s.phase !== "next" || s.points.length === 0) return null; + return polyNextFromFields(s.points[s.points.length - 1], locks, cursor); + }, +}; diff --git a/src/commands/cmds/rect.ts b/src/commands/cmds/rect.ts new file mode 100644 index 0000000..3bc5e64 --- /dev/null +++ b/src/commands/cmds/rect.ts @@ -0,0 +1,143 @@ +// Rectangle — zwei Ecken (portiert rectTool). Schritte: +// 1) „Erste Ecke:" → Punkt +// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done +// +// Getippte Maße: die zweite Ecke kann als `r,` relativ zur ersten +// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf). + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandField, + CommandResult, + CommandState, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const EPS = 1e-6; + +/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */ +const RECT_FIELDS: CommandField[] = [ + { id: "width", labelKey: "cmd.field.width" }, + { id: "height", labelKey: "cmd.field.height" }, +]; + +/** + * Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine + * gelockte Kantenlänge nimmt ihren Betrag, das Vorzeichen (Quadrant) folgt dem + * Cursor relativ zur ersten Ecke `a`; ungelockt folgt die Kante direkt dem Cursor. + */ +function rectCornerFromFields(a: Vec2, locks: Record, cursor: Vec2 | null): Vec2 { + const cur = cursor ?? a; + const dx = cur.x - a.x; + const dy = cur.y - a.y; + const sx = dx < 0 ? -1 : 1; + const sy = dy < 0 ? -1 : 1; + const w = "width" in locks ? sx * Math.abs(locks.width) : dx; + const h = "height" in locks ? sy * Math.abs(locks.height) : dy; + return { x: a.x + w, y: a.y + h }; +} + +interface RectIdle extends CommandState { + phase: "corner1"; +} +interface RectDrawing extends CommandState { + phase: "corner2"; + a: Vec2; + cursor: Vec2 | null; +} +type RectState = RectIdle | RectDrawing; + +function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } { + return { + min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }, + max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }, + }; +} + +/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */ +function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft { + if (!cursor) return { preview: [], vertices: [a] }; + const g = rectGeom(a, cursor); + const pts: Vec2[] = [ + g.min, + { x: g.max.x, y: g.min.y }, + g.max, + { x: g.min.x, y: g.max.y }, + ]; + const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }]; + const w = g.max.x - g.min.x; + const h = g.max.y - g.min.y; + return { + preview, + vertices: [a], + hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` }, + }; +} + +function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project { + const g = rectGeom(a, b); + if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "rect", min: g.min, max: g.max }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "corner1", lastPoint: null }, + { draft: null, done: true }, +]; + +export const rectCommand: Command = { + name: "rect", + labelKey: "cmd.rect.label", + prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"), + accepts: () => ["point", "number"], + options: () => [], + init: (): RectIdle => ({ phase: "corner1", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as RectState; + if (input.kind !== "point") { + return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }]; + } + const pt = input.point; + if (s.phase !== "corner2") { + const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt }; + return [ns, { draft: rectDraft(pt, pt) }]; + } + const a = s.a; + return [ + { phase: "corner1", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as RectState; + if (s.phase !== "corner2") return [s, { draft: null }]; + const ns: RectDrawing = { ...s, cursor: point }; + return [ns, { draft: rectDraft(s.a, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), + + // Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor). + fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []), + pointFromFields: (state, locks, cursor) => { + const s = state as RectState; + if (s.phase !== "corner2") return null; + return rectCornerFromFields(s.a, locks, cursor); + }, +}; diff --git a/src/commands/cmds/terrain.ts b/src/commands/cmds/terrain.ts new file mode 100644 index 0000000..8d9c94d --- /dev/null +++ b/src/commands/cmds/terrain.ts @@ -0,0 +1,60 @@ +// Terrain — erzeugt aus dem (ersten) ContourSet der Kontext-Schicht ein +// Gelände-TIN und hängt es an `project.context`. Null-Schritt-Befehl: er +// committet sofort beim Start (über onConfirm, das die Engine direkt aufruft, +// wenn keine Punkte erwartet werden) — wir lösen das robust über onInput/ +// onConfirm, die beide dieselbe Mutation liefern. +// +// Die Mutation ist rein (kein Store/React): sie sucht den ersten ContourSet in +// p.context, ruft generateTerrainFromContours und fügt das TIN hinzu. Liefert +// das TIN kein sinnvolles Mesh (zu wenige/kollineare Punkte), bleibt p +// unverändert. +// +// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.terrain.*`). + +import { generateTerrainFromContours } from "../../model/terrain"; +import type { ContextObject } from "../../model/types"; +import type { + Command, + CommandResult, + CommandState, + Project, +} from "../types"; + +/** Hängt ein aus dem ersten ContourSet abgeleitetes TIN an (immutabel). */ +function appendTerrain(p: Project): Project { + const context: ContextObject[] = p.context ?? []; + const set = context.find((o) => o.type === "contourSet"); + if (!set || set.type !== "contourSet") return p; + const terrain = generateTerrainFromContours(set.contours); + if (terrain.indices.length === 0) return p; // kein sinnvolles TIN + return { ...p, context: [...context, terrain] }; +} + +/** Ergebnis, das den Befehl sofort beendet und das TIN committet. */ +const COMMIT_DONE: CommandResult = { + draft: null, + done: true, + commit: appendTerrain, +}; + +const IDLE: CommandState = { phase: "idle", lastPoint: null }; + +export const terrainCommand: Command = { + name: "terrain", + labelKey: "cmd.terrain.label", + prompt: () => "cmd.terrain.prompt", + // Erwartet keine echten Eingaben; Enter/Start committet sofort. + accepts: () => [], + options: () => [], + init: () => ({ ...IDLE }), + + // Jeglicher Input beendet den Befehl mit dem Commit (es gibt keine Schritte). + onInput: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE], + onMove: (state): [CommandState, CommandResult] => [state, { draft: null }], + // Enter/Space/Rechtsklick: Gelände erzeugen + beenden. + onConfirm: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE], + onCancel: (): [CommandState, CommandResult] => [ + { ...IDLE }, + { draft: null, done: true }, + ], +}; diff --git a/src/commands/engine.ts b/src/commands/engine.ts new file mode 100644 index 0000000..90fe65a --- /dev/null +++ b/src/commands/engine.ts @@ -0,0 +1,439 @@ +// Command-Runner / State-Machine (docs/design/rhino-command-system.md §3.2, §2.3, +// §2.6). Hält den aktiven Befehl + Schritt-Zustand, einen Prompt-Stack-Grundbau +// (für künftige transparente Befehle §2.6) und `lastCommand` (Leer-Enter bzw. +// Rechtsklick wiederholt den letzten Befehl §2.3). +// +// Eingaben kommen aus DREI Quellen, die alle in DENSELBEN Schritt fließen: +// • getippte Command-Line-Eingabe (submitText) +// • Maus-Pick aus PlanView (pick) / Hover (move) +// • Klick auf eine Inline-Option (chooseOption) +// +// Die Engine ist UI-agnostisch: Kontext (Projekt/Level/Kategorie), Snapping und +// das Anwenden von draft/commit liefert ein injizierter `EngineHost`. Bei jeder +// Zustandsänderung ruft sie `emit()` → React-Komponenten (CommandLine, App) +// abonnieren via subscribe() und re-rendern. + +import type { Project, Vec2 } from "../model/types"; +import type { SnapResult, ToolDraft } from "../tools/types"; +import { parseInput, resolvePoint } from "./parseInput"; +import { getCommand, resolveCommand, autocomplete } from "./registry"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + CmdInput, +} from "./types"; + +/** + * Brücke zur App: liefert den Live-Kontext + Snapping und wendet die Ergebnisse + * an (Vorschau zeigen, committen). So bleibt die Engine frei von React/Store. + */ +export interface EngineHost { + /** Aktueller Befehls-Kontext (Projekt, aktive Ebene/Kategorie, lastPoint). */ + context(lastPoint: Vec2 | null): CommandContext; + /** Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. */ + snap(raw: Vec2, draftPoints: Vec2[]): SnapResult | null; + /** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */ + pxPerMeter(): number; + /** Vorschau setzen (oder löschen). */ + setDraft(draft: ToolDraft | null): void; + /** Commit immutabel anwenden. */ + commit(mutate: (p: Project) => Project): void; + /** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */ + isFloor(): boolean; +} + +/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */ +export interface EngineView { + /** Läuft gerade ein Befehl? */ + active: boolean; + /** Aktueller Prompt-Key (i18n) oder null (Ruhezustand). */ + promptKey: string | null; + /** Inline-Optionen des aktuellen Schritts. */ + options: { id: string; labelKey: string; value?: string }[]; + /** Name des aktiven Befehls (für Anzeige), null im Ruhezustand. */ + commandName: string | null; + /** + * Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus §2.7). Leer, + * wenn der aktive Befehl/Schritt keine Felder anbietet. `value` ist der + * gelockte Wert (oder null = folgt der Maus), `active` markiert das aktuelle + * Tab-Ziel. + */ + fields: { id: string; labelKey: string; value: number | null; active: boolean }[]; +} + +export class CommandEngine { + private host: EngineHost; + private command: Command | null = null; + private state: CommandState | null = null; + private lastCommandName: string | null = null; + /** Letzter (gesnappter) Cursor-Punkt für Distanz-/Winkel-Lock-Auflösung. */ + private lastCursor: Vec2 | null = null; + /** Tab-Feld-Zyklus: aktiver Feld-Index des aktuellen Schritts (§2.7). */ + private activeFieldIndex = 0; + /** Tab-Feld-Zyklus: gelockte Feldwerte (id → Zahl); ungelockt folgt der Maus. */ + private locks: Record = {}; + /** Identität des Schritts, für den der Feld-Zustand gilt (Reset bei Wechsel). */ + private fieldPhaseKey: string | null = null; + private listeners = new Set<() => void>(); + + constructor(host: EngineHost) { + this.host = host; + } + + // ── Abo / Snapshot ───────────────────────────────────────────────────────── + + subscribe(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + private emit(): void { + for (const l of this.listeners) l(); + } + + /** Läuft gerade ein Befehl? */ + isActive(): boolean { + return this.command !== null; + } + + /** Lesender Schnappschuss für die Command-Line. */ + view(): EngineView { + if (!this.command || !this.state) { + return { active: false, promptKey: null, options: [], commandName: null, fields: [] }; + } + this.syncFields(); + const defs = this.currentFields(); + const fields = defs.map((f, i) => ({ + id: f.id, + labelKey: f.labelKey as string, + value: f.id in this.locks ? this.locks[f.id] : null, + active: i === this.activeFieldIndex, + })); + return { + active: true, + promptKey: this.command.prompt(this.state), + options: this.command.options(this.state), + commandName: this.command.name, + fields, + }; + } + + /** Autocomplete-Kandidaten für die getippte Eingabe (Präfix). */ + suggest(prefix: string): string[] { + return autocomplete(prefix); + } + + // ── Tab-Feld-Zyklus (§2.7) ──────────────────────────────────────────────── + + /** Feld-Definitionen des aktuellen Schritts (leer, wenn keine angeboten). */ + private currentFields(): { id: string; labelKey: string }[] { + if (!this.command || !this.state || !this.command.fields) return []; + return this.command.fields(this.state); + } + + /** Hat der aktuelle Schritt einen Tab-Feld-Modus? */ + hasFields(): boolean { + return this.currentFields().length > 0; + } + + /** + * Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur = + * phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein + * mehrteiliger Befehl (Polyline) je gesetztem Punkt FRISCHE Felder/Locks + * bekommt, obwohl die phase ("next") gleich bleibt. + */ + private syncFields(): void { + const defs = this.currentFields(); + const lp = this.state?.lastPoint; + const lpKey = lp ? `${lp.x.toFixed(6)},${lp.y.toFixed(6)}` : "∅"; + const key = this.state + ? `${this.state.phase}:${defs.map((f) => f.id).join(",")}:${lpKey}` + : null; + if (key !== this.fieldPhaseKey) { + this.fieldPhaseKey = key; + this.activeFieldIndex = 0; + this.locks = {}; + } + } + + /** Tab: nächstes Feld aktiv (zyklisch). No-op ohne Felder. */ + cycleField(): void { + this.syncFields(); + const n = this.currentFields().length; + if (n === 0) return; + this.activeFieldIndex = (this.activeFieldIndex + 1) % n; + this.emit(); + } + + /** + * Lockt einen getippten Zahlenwert ins aktive Feld und rückt aufs nächste vor + * (§2.7, Task-Schritt 2). Sind danach ALLE Felder gelockt, committet der Schritt + * sofort den daraus gebildeten Punkt — so commitet ein Ein-Feld-Befehl (Kreis: + * Radius) direkt und ein Zwei-Feld-Befehl (Linie) nach dem zweiten Wert. + */ + private lockActiveField(value: number): void { + this.syncFields(); + const defs = this.currentFields(); + if (defs.length === 0) return; + const field = defs[this.activeFieldIndex]; + this.locks = { ...this.locks, [field.id]: value }; + const allLocked = defs.every((f) => f.id in this.locks); + if (allLocked) { + this.commitFields(); + return; + } + this.activeFieldIndex = (this.activeFieldIndex + 1) % defs.length; + this.refreshFieldPreview(); + this.emit(); + } + + /** Committet den aus den Feld-Locks (+ Cursor) gebildeten Punkt als Schritt-Eingabe. */ + private commitFields(): void { + const pt = this.fieldResultPoint(this.lastCursor); + if (pt) this.feed({ kind: "point", point: pt }); + else this.emit(); + } + + /** Vorschau aus den gelockten Feldern + dem letzten Cursor neu zeichnen. */ + private refreshFieldPreview(): void { + if (!this.command || !this.state || !this.command.pointFromFields) return; + const pt = this.command.pointFromFields(this.state, this.locks, this.lastCursor); + if (!pt) return; + const ctx = this.context(); + const [st, res] = this.command.onMove(this.state, pt, null, ctx); + this.state = st; + if (res.draft) this.host.setDraft(res.draft); + } + + // ── Befehls-Lebenszyklus ───────────────────────────────────────────────── + + /** Startet einen Befehl per Name/Alias/Präfix. Liefert true bei Erfolg. */ + start(nameOrPrefix: string): boolean { + const cmd = resolveCommand(nameOrPrefix) ?? getCommand(nameOrPrefix); + if (!cmd) return false; + return this.startCommand(cmd); + } + + /** Startet einen konkreten Befehl (z. B. zur Wiederholung). */ + startCommand(cmd: Command): boolean { + if (cmd.floorOnly && !this.host.isFloor()) return false; + this.command = cmd; + this.state = cmd.init(); + this.lastCommandName = cmd.name; + this.lastCursor = null; + this.resetFields(); + this.host.setDraft(null); + this.emit(); + return true; + } + + /** Tab-Feld-Zustand vollständig zurücksetzen (Befehlsstart/-ende/Abbruch). */ + private resetFields(): void { + this.activeFieldIndex = 0; + this.locks = {}; + this.fieldPhaseKey = null; + } + + /** Bricht den laufenden Befehl ab (Esc). */ + cancel(): void { + if (this.command && this.state) { + const [, res] = this.command.onCancel(this.state); + this.applyResult(res); + } + this.command = null; + this.state = null; + this.resetFields(); + this.host.setDraft(null); + this.emit(); + } + + // ── Eingabe-Routing ──────────────────────────────────────────────────────── + + /** + * Getippte Command-Line-Eingabe (Enter). Im Ruhezustand: Befehl starten / bei + * leerer Zeile letzten wiederholen (§2.3). In einem Befehl: als Koordinate/ + * Zahl/Option in den aktuellen Schritt routen. + */ + submitText(raw: string): void { + const text = raw.trim(); + if (!this.command || !this.state) { + // Ruhezustand: leere Zeile = letzten Befehl wiederholen. + if (text === "") { + this.repeatLast(); + return; + } + this.start(text); + return; + } + // Laufender Befehl: leere Zeile = bestätigen/beenden (Enter §2.3). + if (text === "") { + this.confirm(); + return; + } + this.routeTypedInput(text); + } + + /** Routet getippten Text in den aktuellen Schritt (Koordinate/Zahl/Option). */ + private routeTypedInput(text: string): void { + if (!this.command || !this.state) return; + const accepts = this.command.accepts(this.state); + + // Optionsbuchstabe(n)? (nur wenn der Schritt Optionen annimmt) — Präfix-Match. + if (accepts.includes("option")) { + const opts = this.command.options(this.state); + const q = text.toLowerCase(); + const hit = opts.find((o) => o.id.toLowerCase().startsWith(q)); + if (hit) { + this.feed({ kind: "option", id: hit.id }); + return; + } + } + + const parsed = parseInput(text); + + // Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als + // distance/number durchzureichen) und rückt aufs nächste Feld vor. Absolute/ + // relative/polare Koordinaten umgehen die Felder (committen wie gewohnt). + if (parsed.kind === "distance" && this.hasFields()) { + this.lockActiveField(parsed.value); + return; + } + + const lastPoint = this.state.lastPoint; + const cursor = this.lastCursor; + + if (parsed.kind === "text") { + // Unparsbar im Schritt: ignorieren (kein gültiger Punkt/Option). + return; + } + // Punktbezogene Eingaben → zu Modellpunkt auflösen, wenn der Schritt Punkte + // annimmt; sonst nackte Zahl als number-Input. + if (parsed.kind === "distance" && accepts.includes("number") && !accepts.includes("point")) { + this.feed({ kind: "number", value: parsed.value }); + return; + } + const pt = resolvePoint(parsed, lastPoint, cursor); + if (pt && accepts.includes("point")) { + this.feed({ kind: "point", point: pt }); + return; + } + if (parsed.kind === "distance" && accepts.includes("number")) { + this.feed({ kind: "number", value: parsed.value }); + } + } + + /** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */ + pick(raw: Vec2): void { + if (!this.command || !this.state) return; + if (!this.command.accepts(this.state).includes("point")) return; + const snap = this.host.snap(raw, this.draftPoints()); + const point = snap?.point ?? raw; + this.lastCursor = point; + // Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix, + // Richtung per Maus). Der finale Punkt kommt aus pointFromFields. + const fieldPt = this.fieldResultPoint(point); + if (fieldPt) { + this.feed({ kind: "point", point: fieldPt }); + return; + } + this.feed({ kind: "point", point, snap }); + } + + /** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */ + move(raw: Vec2): void { + if (!this.command || !this.state) return; + const snap = this.host.snap(raw, this.draftPoints()); + const point = snap?.point ?? raw; + this.lastCursor = point; + const ctx = this.context(); + // Tab-Feld-Modus: ungelockte Felder folgen der Maus, gelockte überschreiben. + const previewPoint = this.fieldResultPoint(point) ?? point; + const [st, res] = this.command.onMove(this.state, previewPoint, snap, ctx); + this.state = st; + // Snap-Marker mit in den Draft hängen. + if (res.draft) this.host.setDraft({ ...res.draft, snap }); + else if (snap) this.host.setDraft({ preview: [], vertices: [], snap }); + else this.host.setDraft(null); + } + + /** + * Punkt aus dem Tab-Feld-Modus für einen gegebenen Cursor — oder null, wenn der + * Schritt keine Felder hat (dann gilt der rohe Cursor/Pick). + */ + private fieldResultPoint(cursor: Vec2 | null): Vec2 | null { + if (!this.command || !this.state || !this.command.pointFromFields) return null; + if (!this.hasFields()) return null; + return this.command.pointFromFields(this.state, this.locks, cursor); + } + + /** Klick auf eine Inline-Option (§2.4). */ + chooseOption(id: string): void { + if (!this.command || !this.state) return; + this.feed({ kind: "option", id }); + } + + /** Enter = Space = Rechtsklick: bestätigen/Default/mehrteilig-beenden (§2.3). */ + confirm(): void { + if (!this.command || !this.state) { + this.repeatLast(); + return; + } + // Tab-Feld-Modus (§2.7): existieren Felder, committet Enter den Schritt mit + // dem aus (Locks + Cursor) gebildeten Punkt — statt den Befehl abzubrechen. + const fieldPt = this.fieldResultPoint(this.lastCursor); + if (fieldPt) { + this.feed({ kind: "point", point: fieldPt }); + return; + } + const ctx = this.context(); + const [st, res] = this.command.onConfirm(this.state, ctx); + this.state = st; + this.applyResult(res); + } + + /** Leer-Enter/Rechtsklick im Ruhezustand: letzten Befehl wiederholen. */ + repeatLast(): void { + if (this.lastCommandName) { + const cmd = getCommand(this.lastCommandName); + if (cmd) this.startCommand(cmd); + } + } + + // ── interne Mechanik ─────────────────────────────────────────────────────── + + private feed(input: CmdInput): void { + if (!this.command || !this.state) return; + if (input.kind === "point") this.lastCursor = input.point; + const ctx = this.context(); + const [st, res] = this.command.onInput(this.state, input, ctx); + this.state = st; + this.applyResult(res); + } + + private applyResult(res: CommandResult): void { + if (res.commit) this.host.commit(res.commit); + if (res.draft) this.host.setDraft(res.draft); + else this.host.setDraft(null); + if (res.done) { + this.command = null; + this.state = null; + this.resetFields(); + } + this.emit(); + } + + private context(): CommandContext { + return this.host.context(this.state?.lastPoint ?? null); + } + + /** Bereits gesetzte Punkte des laufenden Befehls (für Snap-Quellen). */ + private draftPoints(): Vec2[] { + const s = this.state as (CommandState & { a?: Vec2; points?: Vec2[] }) | null; + if (!s) return []; + if (s.points) return s.points; + if (s.a) return [s.a]; + if (s.lastPoint) return [s.lastPoint]; + return []; + } +} diff --git a/src/commands/parseInput.ts b/src/commands/parseInput.ts new file mode 100644 index 0000000..d5c7dd4 --- /dev/null +++ b/src/commands/parseInput.ts @@ -0,0 +1,166 @@ +// Koordinaten- & Numerik-Parser (docs/design/rhino-command-system.md §2.7). +// +// Parst rohen Command-Line-Text TOLERANT (Komma/Space/Semikolon als Trenner) zu +// einer Discriminated Union; die Engine löst die punktbezogenen Varianten mit +// `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf. +// +// Unterstützte Formen: +// `5,3` → absolut X,Y +// `5,3,2` → absolut X,Y (Z ignoriert, 2D) +// `r5,3` → relativ zum letzten Punkt +// `5<45` → polar: Distanz 5 unter 45° vom letzten Punkt +// `<45` → nur Winkel-Constraint; Distanz folgt Maus/Zahl +// `5` → nackte Zahl = Distanz-Lock entlang Cursor-/Constraint-Richtung +// sonst → Roh-Text (Befehlsname / Optionsbuchstabe) — von der Engine geroutet +// +// Reine Funktionen, kein Zustand. Winkel in Grad, gegen die +X-Achse, CCW positiv +// (passend zur Y-flip-freien Modellkonvention: +Y ist „oben"). + +import type { Vec2 } from "../model/types"; + +/** Geparste, noch nicht aufgelöste Eingabe. */ +export type ParsedInput = + | { kind: "absolute"; point: Vec2 } // 5,3 + | { kind: "relative"; delta: Vec2 } // r5,3 + | { kind: "polar"; dist: number; angleDeg: number } // 5<45 + | { kind: "angle"; angleDeg: number } // <45 + | { kind: "distance"; value: number } // 5 (nackte Zahl) + | { kind: "text"; text: string }; // Befehlsname / Option / unparsbar + +const DEG = Math.PI / 180; + +/** Tolerant: Zahl aus einem Token (erlaubt führendes +, Dezimalpunkt/-komma). */ +function parseNumber(raw: string): number | null { + const s = raw.trim().replace(",", "."); // Hilfsfall: einzelnes Dezimalkomma + if (s === "" || s === "+" || s === "-") return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +} + +/** + * Zerlegt eine Koordinatenliste (X,Y[,Z]) tolerant in Zahlen. Trenner: Komma, + * Semikolon oder Whitespace. Liefert null, wenn nicht ≥2 valide Zahlen vorliegen. + * (Wir nutzen die echten Trenner, daher kollidiert das NICHT mit dem + * Dezimalkomma-Hilfsfall der nackten Zahl.) + */ +function parseCoordList(raw: string): number[] | null { + const parts = raw + .trim() + .split(/[\s,;]+/) + .filter((p) => p.length > 0); + if (parts.length < 2) return null; + const nums: number[] = []; + for (const p of parts) { + const n = Number(p.trim()); + if (!Number.isFinite(n)) return null; + nums.push(n); + } + return nums; +} + +/** + * Parst rohen Command-Line-Text zu einer {@link ParsedInput}. Befehlsnamen/ + * Optionsbuchstaben fallen als `{kind:"text"}` durch (die Engine routet sie). + */ +export function parseInput(raw: string): ParsedInput { + const s = raw.trim(); + if (s === "") return { kind: "text", text: "" }; + + // ── polar / Winkel: enthält '<' ─────────────────────────────────────────── + if (s.includes("<")) { + const i = s.indexOf("<"); + const left = s.slice(0, i).trim(); + const right = s.slice(i + 1).trim(); + const angle = parseNumber(right); + if (angle == null) return { kind: "text", text: s }; + if (left === "") return { kind: "angle", angleDeg: angle }; + const dist = parseNumber(left); + if (dist == null) return { kind: "text", text: s }; + return { kind: "polar", dist, angleDeg: angle }; + } + + // ── relativ: führendes r / @ ────────────────────────────────────────────── + if (/^[r@]/i.test(s)) { + const rest = s.slice(1); + const nums = parseCoordList(rest); + if (nums && nums.length >= 2) { + return { kind: "relative", delta: { x: nums[0], y: nums[1] } }; + } + // `r5` → relative Distanz entlang Cursor-Richtung. + const one = parseNumber(rest); + if (one != null) return { kind: "distance", value: one }; + return { kind: "text", text: s }; + } + + // ── absolute Koordinate: ≥2 zahlen mit echtem Trenner ───────────────────── + const coords = parseCoordList(s); + if (coords && coords.length >= 2) { + return { kind: "absolute", point: { x: coords[0], y: coords[1] } }; + } + + // ── nackte Zahl = Distanz-Lock ──────────────────────────────────────────── + const one = parseNumber(s); + if (one != null) return { kind: "distance", value: one }; + + // ── sonst: Text (Befehl/Option) ─────────────────────────────────────────── + return { kind: "text", text: s }; +} + +/** Punkt aus polarer Form (Distanz/Winkel) relativ zu einem Basispunkt. */ +export function polarPoint(base: Vec2, dist: number, angleDeg: number): Vec2 { + const a = angleDeg * DEG; + return { x: base.x + Math.cos(a) * dist, y: base.y + Math.sin(a) * dist }; +} + +/** + * Löst eine {@link ParsedInput} zu einem konkreten Modellpunkt auf — falls sie + * sich als Punkt auflösen lässt. `lastPoint` ist der letzte gesetzte Punkt im + * Befehl (für relativ/polar/Distanz), `cursorDir` die aktuelle (gesnappte) + * Cursor-Richtung als Roh-Cursorpunkt (für nackte Zahl / reinen Winkel-Lock). + * + * Liefert null, wenn die Eingabe keinen Punkt ergibt (z. B. reiner Text oder + * Distanz ohne Bezug). + */ +export function resolvePoint( + parsed: ParsedInput, + lastPoint: Vec2 | null, + cursor: Vec2 | null, +): Vec2 | null { + switch (parsed.kind) { + case "absolute": + return parsed.point; + case "relative": + if (!lastPoint) return parsed.delta; // ohne Bezug = vom Ursprung + return { x: lastPoint.x + parsed.delta.x, y: lastPoint.y + parsed.delta.y }; + case "polar": { + const base = lastPoint ?? { x: 0, y: 0 }; + return polarPoint(base, parsed.dist, parsed.angleDeg); + } + case "angle": { + // Reiner Winkel-Lock: ohne Distanz braucht es den Cursor, um die Länge + // entlang der gelockten Richtung zu projizieren. + if (!lastPoint || !cursor) return null; + const a = parsed.angleDeg * DEG; + const dir = { x: Math.cos(a), y: Math.sin(a) }; + const dx = cursor.x - lastPoint.x; + const dy = cursor.y - lastPoint.y; + const proj = dx * dir.x + dy * dir.y; // skalare Projektion + const len = Math.max(0, proj); + return { x: lastPoint.x + dir.x * len, y: lastPoint.y + dir.y * len }; + } + case "distance": { + // Distanz-Lock: Richtung per Cursor, Länge per Zahl. + if (!lastPoint || !cursor) return null; + const dx = cursor.x - lastPoint.x; + const dy = cursor.y - lastPoint.y; + const l = Math.hypot(dx, dy); + if (l < 1e-9) return { x: lastPoint.x + parsed.value, y: lastPoint.y }; + return { + x: lastPoint.x + (dx / l) * parsed.value, + y: lastPoint.y + (dy / l) * parsed.value, + }; + } + default: + return null; + } +} diff --git a/src/commands/registry.ts b/src/commands/registry.ts new file mode 100644 index 0000000..d92bb6b --- /dev/null +++ b/src/commands/registry.ts @@ -0,0 +1,79 @@ +// Befehls-Registry (docs/design/rhino-command-system.md §3.2) — `COMMANDS` + +// Aliase + Präfix-Autocomplete. Einzelne Befehle leben als eigene Module unter +// `cmds/`, sodass weitere Befehle ADDITIV (ohne Änderung an Engine/App) +// dazukommen: hier importieren + registrieren, fertig. + +import type { Command } from "./types"; +import { lineCommand } from "./cmds/line"; +import { polylineCommand } from "./cmds/polyline"; +import { rectCommand } from "./cmds/rect"; +import { circleCommand } from "./cmds/circle"; +import { moveCommand } from "./cmds/move"; +import { copyCommand } from "./cmds/copy"; +import { offsetCommand } from "./cmds/offset"; +import { importCommand } from "./cmds/import"; +import { terrainCommand } from "./cmds/terrain"; + +/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */ +export const COMMANDS: Record = { + line: lineCommand, + polyline: polylineCommand, + rect: rectCommand, + circle: circleCommand, + move: moveCommand, + copy: copyCommand, + offset: offsetCommand, + import: importCommand, + terrain: terrainCommand, +}; + +/** + * Aliase / Kürzel → Befehlsname (§2.2). Werden VOR dem Autocomplete gematcht + * (case-insensitiv). Minimal: Einzelbuchstaben. NICHT `r` (kollidiert mit dem + * relativ-Koordinaten-Präfix `r5,3`). + */ +export const ALIASES: Record = { + l: "line", + pl: "polyline", + rec: "rect", + c: "circle", + m: "move", + cp: "copy", + o: "offset", + imp: "import", + ter: "terrain", +}; + +/** Liefert den Befehl zu einem exakten Namen (oder undefined). */ +export function getCommand(name: string): Command | undefined { + return COMMANDS[name.toLowerCase()]; +} + +/** + * Löst eine getippte Eingabe zu einem Befehl auf: erst exakter Name, dann Alias, + * dann eindeutiges Präfix (case-insensitiv). Mehrdeutige/leere Präfixe → null. + */ +export function resolveCommand(raw: string): Command | null { + const q = raw.trim().toLowerCase(); + if (q === "") return null; + if (COMMANDS[q]) return COMMANDS[q]; + if (ALIASES[q]) return COMMANDS[ALIASES[q]] ?? null; + const matches = autocomplete(q); + if (matches.length === 1) return COMMANDS[matches[0]] ?? null; + // Exaktes Präfix, das auch ein Alias ist, hat Vorrang, wenn eindeutig. + return null; +} + +/** + * Präfix-Autocomplete: alle Befehlsnamen, die mit dem (lowercase) Präfix + * beginnen, alphabetisch. Aliase werden NICHT mitgelistet (sie sind Kürzel, + * keine Vorschläge), aber ein exakter Alias-Treffer wird vorangestellt. + */ +export function autocomplete(prefix: string): string[] { + const q = prefix.trim().toLowerCase(); + if (q === "") return Object.keys(COMMANDS).sort(); + const names = Object.keys(COMMANDS) + .filter((n) => n.startsWith(q)) + .sort(); + return names; +} diff --git a/src/commands/types.ts b/src/commands/types.ts new file mode 100644 index 0000000..32666f6 --- /dev/null +++ b/src/commands/types.ts @@ -0,0 +1,183 @@ +// Command-Engine — Verallgemeinerung des `Tool`-Interfaces (src/tools/types.ts) +// zu einem Rhino-artigen Befehlsmodell (docs/design/rhino-command-system.md §3.1). +// +// Ein `Command` ist eine kleine State-Machine: jeder Schritt zeigt einen Prompt, +// nimmt bestimmte Eingabe-Arten an (Punkt | Zahl | Option | Auswahl) und liefert +// aus (state, input) den nächsten Zustand + ein `CommandResult` (Vorschau + +// optional commit/done). Damit speisen Maus-Picks UND getippte Koordinaten/ +// Optionen DENSELBEN Schritt — es gibt nur einen Eingabepfad. +// +// Bezeichner englisch, sichtbarer Text via t() (i18n-Keys, Namespace `cmd.*`). + +import type { Project, Vec2 } from "../model/types"; +import type { TranslationKey } from "../i18n"; +import type { DrawingLevel } from "../model/types"; +import type { SnapResult, ToolDraft } from "../tools/types"; + +export type { DraftShape, SnapResult, ToolDraft } from "../tools/types"; + +// ── Eingabe-Arten ──────────────────────────────────────────────────────────── + +/** Arten von Eingaben, die ein Schritt annehmen kann. */ +export type AcceptKind = "point" | "number" | "option" | "selection"; + +/** + * Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der + * Maus. Discriminated Union (§2.7). Die Engine löst `point`-bezogene Varianten + * mit `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf, bevor + * sie den Schritt aufruft. + */ +export type CmdInput = + | { kind: "point"; point: Vec2; snap?: SnapResult | null } + | { kind: "number"; value: number } + | { kind: "option"; id: string } + | { kind: "text"; text: string } + | { kind: "selection"; wallIds: string[]; drawingIds: string[] }; + +// ── Optionen (klickbare Inline-Klammern, §2.4) ─────────────────────────────── + +/** + * Eine Inline-Option eines Schritts. `value` (gesetzt) → wird als „Name=Value" + * angezeigt und ist togglebar/abfragbar; ohne `value` ist es eine Action-Option + * (verzweigt sofort). `id` ist der englische Schlüssel (auch zum Tippen, Präfix). + */ +export interface CmdOption { + id: string; + /** Sichtbares Label (i18n). */ + labelKey: TranslationKey; + /** Aktueller Wert (Toggle/Value-Option); fehlt → Action-Option. */ + value?: string; +} + +// ── Kontext + Ergebnis ─────────────────────────────────────────────────────── + +/** + * Die aktuelle Auswahl (Rhino: erst selektieren, dann Befehl). Editierbefehle + * (Move/Copy/Offset) lesen sie hieraus, statt sie selbst zu picken. `wallIds` + * sind die gewählten Wände, `drawingId` ist das gewählte 2D-Element (sich + * gegenseitig ausschließend, wie in selectionSlice). + */ +export interface CommandSelection { + wallIds: string[]; + drawingId: string | null; +} + +/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */ +export interface CommandContext { + project: Project; + /** Aktive Zeichnungsebene (Ziel neuer Elemente). */ + level: DrawingLevel; + /** Default-Kategorie-Code für neue Elemente. */ + defaultCategoryCode: string; + /** Aktiver Linienstil-Code für 2D-Primitive. */ + activeLineStyleId: string; + /** + * Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null + * am ersten Punkt. + */ + lastPoint: Vec2 | null; + /** + * Vorab gesetzte Auswahl (Wände + ein 2D-Element). Editierbefehle operieren + * darauf (Rhino: erst selektieren, dann Befehl). Beim Befehlsstart aus dem + * Store gefüllt. + */ + selection: CommandSelection; +} + +/** Was ein Befehls-Schritt nach außen meldet (analog ToolResult + commit/done). */ +export interface CommandResult { + /** Neuer Vorschau-Zustand; null = nichts zu zeigen. */ + draft: ToolDraft | null; + /** Bei Abschluss: immutable Mutation, die die Engine über setProject anwendet. */ + commit?: (p: Project) => Project; + /** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */ + done?: boolean; +} + +// ── Befehls-Zustand ────────────────────────────────────────────────────────── + +/** + * Interner Zustand eines laufenden Befehls. Basis-Form (`phase` + `lastPoint`); + * jeder Befehl verfeinert sie zu seiner eigenen Discriminated Union und castet + * beim Eintritt. `lastPoint` lebt im Zustand, damit der Parser `r`/polar relativ + * dazu auflösen kann. + */ +export interface CommandState { + phase: string; + /** Letzter gesetzter Punkt (für relative/polare Eingabe), null am Anfang. */ + lastPoint: Vec2 | null; +} + +// ── Tab-Feld-Zyklus (Präzisionseingabe §2.7, erweitert) ────────────────────── + +/** + * Ein geordnetes Zahlenfeld eines Schritts (z. B. Länge, Winkel, Breite, Höhe, + * Radius). Mit Tab zyklt der Nutzer durch die Felder; eine getippte Zahl LOCKT + * das aktive Feld, ungelockte Felder folgen weiter der Maus. + */ +export interface CommandField { + /** Englischer Schlüssel (Identität des Feldes, z. B. „length"). */ + id: string; + /** Sichtbares Label (i18n, Namespace `cmd.field.*`). */ + labelKey: TranslationKey; +} + +// ── Befehls-Schnittstelle ──────────────────────────────────────────────────── + +/** Die Command-Schnittstelle (reine Funktionen über einen internen State). */ +export interface Command { + /** Befehlsname (englisch, in der Command-Line getippt), z. B. „line". */ + name: string; + /** UI-Label-Key (i18n). */ + labelKey: TranslationKey; + /** Prompt-Text-Key des aktuellen Schritts. */ + prompt(state: CommandState): TranslationKey; + /** Eingabe-Arten, die der aktuelle Schritt annimmt. */ + accepts(state: CommandState): AcceptKind[]; + /** Inline-Optionen des aktuellen Schritts (klickbar/tippbar). */ + options(state: CommandState): CmdOption[]; + /** Nur auf Geschossen aktiv? (analog floorOnly bei Tools.) */ + floorOnly?: boolean; + /** Initialer Schritt-Zustand. */ + init(): CommandState; + /** Eine Eingabe (Punkt/Zahl/Option/Auswahl) verarbeiten. */ + onInput( + state: CommandState, + input: CmdInput, + ctx: CommandContext, + ): [CommandState, CommandResult]; + /** Hover/Bewegung (roher bzw. gesnappter Cursor): nur Vorschau. */ + onMove( + state: CommandState, + point: Vec2, + snap: SnapResult | null, + ctx: CommandContext, + ): [CommandState, CommandResult]; + /** Enter/Space/Rechtsklick: Schritt bestätigen / mehrteilig beenden. */ + onConfirm(state: CommandState, ctx: CommandContext): [CommandState, CommandResult]; + /** Esc: Entwurf verwerfen, Befehl beenden. */ + onCancel(state: CommandState): [CommandState, CommandResult]; + + // ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden + // verhalten sich exakt wie bisher) ────────────────────────────────────── + + /** + * Geordnete Zahlenfelder des AKTUELLEN Schritts (leer/undefined = kein + * Feld-Modus). Beispiel Linie-Endschritt: [length, angle]. + */ + fields?(state: CommandState): CommandField[]; + + /** + * Berechnet den resultierenden Modellpunkt aus den gelockten Feldern + dem + * Cursor. Ungelockte Felder folgen dem Cursor (Maus); gelockte Felder + * überschreiben. Liefert null, wenn (noch) kein Punkt bestimmbar ist. + */ + pointFromFields?( + state: CommandState, + locks: Record, + cursor: Vec2 | null, + ): Vec2 | null; +} + +/** Convenience-Re-Export für Befehls-Module. */ +export type { Project, Vec2 } from "../model/types"; diff --git a/src/editors/splitJoin.ts b/src/editors/splitJoin.ts new file mode 100644 index 0000000..b9c2471 --- /dev/null +++ b/src/editors/splitJoin.ts @@ -0,0 +1,277 @@ +// Editier-Operationen Split / Join / Segment-Löschen für 2D-Zeichenelemente +// (Drawing2D). Wandeln Drawing2D ↔ {pts, closed}, rufen den reinen Geometrie- +// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-, +// Kategorie- und Level-Attribute der Quelle werden geerbt. +// +// Bezeichner englisch, Kommentare deutsch (CLAUDE.md). + +import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types"; +import { + joinChains, + removeSegment, + splitAtIntersections, + splitClosedByChord, + splitPolylineAtParam, + vecEqual, +} from "../geometry/kernel2d"; + +/** Eine Polylinien-Darstellung eines Drawing2D (für die Kernel-Funktionen). */ +export interface PolyForm { + pts: Vec2[]; + closed: boolean; +} + +/** + * Wandelt die Geometrie eines Drawing2D in eine Polylinien-Form `{pts, closed}`. + * Nur line/polyline/rect werden unterstützt (circle/arc/text → `null`). + */ +export function toPolyForm(geom: Drawing2DGeom): PolyForm | null { + switch (geom.shape) { + case "line": + return { pts: [geom.a, geom.b], closed: false }; + case "polyline": + return { pts: geom.pts.slice(), closed: geom.closed }; + case "rect": + return { + pts: [ + geom.min, + { x: geom.max.x, y: geom.min.y }, + geom.max, + { x: geom.min.x, y: geom.max.y }, + ], + closed: true, + }; + default: + return null; + } +} + +/** + * Baut aus einer Polylinien-Form die passende Drawing2D-Geometrie. Zwei Punkte + + * offen → `line`; sonst `polyline` (geschlossen oder offen). Rechtecke werden + * NICHT rekonstruiert (nach dem Schneiden sind die Teile beliebige Polygone). + */ +export function fromPolyForm(form: PolyForm): Drawing2DGeom { + const pts = form.pts; + if (pts.length === 2 && !form.closed) { + return { shape: "line", a: pts[0], b: pts[1] }; + } + return { shape: "polyline", pts, closed: form.closed }; +} + +let counter = 0; +/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler, damit ein Split nicht kollidiert). */ +function newId(prefix: string): string { + counter += 1; + return `${prefix}-${Date.now()}-${counter}`; +} + +/** Übernimmt Stil-/Kategorie-/Level-Attribute der Quelle auf ein neues Drawing2D. */ +function inherit(src: Drawing2D, geom: Drawing2DGeom): Drawing2D { + const out: Drawing2D = { + id: newId("d2d"), + type: "drawing2d", + levelId: src.levelId, + categoryCode: src.categoryCode, + geom, + }; + if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId; + if (src.color !== undefined) out.color = src.color; + if (src.weightMm !== undefined) out.weightMm = src.weightMm; + // Füllung/Schraffur nur an geschlossene Ergebnisse weitergeben. + const closed = + geom.shape === "polyline" ? geom.closed : geom.shape === "rect"; + if (closed) { + if (src.hatchId !== undefined) out.hatchId = src.hatchId; + if (src.fillColor !== undefined) out.fillColor = src.fillColor; + } + return out; +} + +/** + * Split eines Drawing2D gegen alle ANDEREN Elemente (`others`, als PolyForm). + * • Trifft das Ziel andere Elemente, wird an den Schnittpunkten geteilt + * (geschlossen + 2 Schnitte → zwei GESCHLOSSENE Polygone via Sehne). + * • Ohne Schnitt: Fallback — an der Kanten-MITTE der „längsten" Kante teilen + * (offen → zwei Stücke; geschlossen → an einer Kantenmitte + der gegenüber- + * liegenden Kantenmitte zu zwei geschlossenen Hälften). + * Liefert ≥2 neue Drawing2D, oder `[src]` (unverändert), wenn keine Teilung + * möglich war (z. B. nicht-polyline Form). Stil/Kategorie/Level werden geerbt. + */ +export function splitDrawing( + src: Drawing2D, + others: PolyForm[], +): Drawing2D[] { + const form = toPolyForm(src.geom); + if (!form) return [src]; + + const otherPts = others.map((o) => + // Geschlossene als Ring (erster==letzter) übergeben, damit der Kernel die + // Schlusskante als Kante erkennt. + o.closed && o.pts.length > 2 ? [...o.pts, o.pts[0]] : o.pts, + ); + + let pieces = splitAtIntersections(form.pts, form.closed, otherPts); + + // Kein Schnitt-Ergebnis (eine Liste, identisch zur Quelle) → Fallback. + const noCut = pieces.length < 2; + if (noCut) { + pieces = fallbackSplit(form); + } + + if (pieces.length < 2) return [src]; + + // Jedes Stück: geschlossen bleibt geschlossen NUR, wenn das Original + // geschlossen war UND der Schnitt zwei geschlossene Hälften erzeugte + // (Sehnen-Split / Fallback-geschlossen). Bei offenem Aufschneiden sind die + // Stücke offen. + const piecesClosed = form.closed && isClosedSplit(form, pieces); + return pieces.map((pts) => + inherit(src, fromPolyForm({ pts, closed: piecesClosed })), + ); +} + +/** + * Heuristik: erzeugte der Schnitt zwei GESCHLOSSENE Hälften (Sehnen-Split)? + * Wahr, wenn das Original geschlossen war und genau zwei Stücke entstanden + * (Chord-Split) — diese sollen wieder geschlossen sein. Bei mehr Stücken + * (geschlossen an >2 Schnitten aufgetrennt) bleiben sie offen. + */ +function isClosedSplit(form: PolyForm, pieces: Vec2[][]): boolean { + return form.closed && pieces.length === 2; +} + +/** + * Fallback ohne Schnitt mit anderen Elementen: an der Mitte teilen. + * • Offen: längste Kante an t=0.5 in zwei offene Stücke. + * • Geschlossen: an den Mitten zweier „gegenüberliegender" Kanten in zwei + * geschlossene Polygone (Sehne zwischen den beiden Kantenmitten). + */ +function fallbackSplit(form: PolyForm): Vec2[][] { + const n = form.pts.length; + if (n < 2) return [form.pts]; + if (!form.closed) { + // Längste Kante suchen. + let best = 0; + let bestLen = -1; + for (let i = 0; i < n - 1; i++) { + const d = dist(form.pts[i], form.pts[i + 1]); + if (d > bestLen) { + bestLen = d; + best = i; + } + } + return splitPolylineAtParam(form.pts, false, best, 0.5); + } + // Geschlossen: Kante 0 und die „gegenüberliegende" Kante. + const i = 0; + const j = Math.max(1, Math.floor(n / 2)) % n; + if (i === j) return [form.pts]; + // Direkt den Kernel-Chord-Split nutzen. + const res = splitClosedHalf(form.pts, i, j); + return res ?? [form.pts]; +} + +/** Geschlossenes Polygon an den Mitten der Kanten i und j halbieren. */ +function splitClosedHalf(pts: Vec2[], i: number, j: number): Vec2[][] | null { + // Reuse: splitClosedByChord ist im Kernel; hier über splitAtIntersections-Form + // nachzubauen wäre umständlich — direkt importieren. + const res = chordSplit(pts, i, 0.5, j, 0.5); + return res; +} + +function chordSplit( + pts: Vec2[], + i: number, + ti: number, + j: number, + tj: number, +): Vec2[][] | null { + const r = splitClosedByChord(pts, i, ti, j, tj); + return r ? [r[0], r[1]] : null; +} + +const dist = (a: Vec2, b: Vec2): number => + Math.hypot(a.x - b.x, a.y - b.y); + +/** + * Join: verbindet mehrere Drawing2D an koinzidenten Endpunkten zu Polylinien + * (geschlossen, wenn sich eine Kette schließt). Nicht-polyline Elemente + * (circle/arc/text) werden unverändert durchgereicht. Liefert die NEUE Menge + * der Drawing2D, die die Eingabe-Elemente ersetzt. Attribute werden vom ersten + * verbundenen Quell-Element geerbt. + */ +export function joinDrawings(srcs: Drawing2D[]): Drawing2D[] { + const joinable: { form: PolyForm; src: Drawing2D }[] = []; + const passthrough: Drawing2D[] = []; + for (const d of srcs) { + const form = toPolyForm(d.geom); + if (form) joinable.push({ form, src: d }); + else passthrough.push(d); + } + if (joinable.length === 0) return srcs; + + const chains = joinChains(joinable.map((j) => j.form)); + const template = joinable[0].src; + const joined = chains.map((c) => + inherit(template, fromPolyForm({ pts: c.pts, closed: c.closed })), + ); + return [...joined, ...passthrough]; +} + +/** + * Segment-Löschen (Schere): entfernt das Segment `edgeIndex` aus einem + * Drawing2D. Geschlossene Form → offene Polylinie. Liefert das geänderte + * Drawing2D, oder `null`, wenn weniger als 2 Punkte übrig blieben (Element + * komplett löschen) bzw. die Form nicht unterstützt wird. + */ +export function cutSegment(src: Drawing2D, edgeIndex: number): Drawing2D | null { + const form = toPolyForm(src.geom); + if (!form) return src; // nicht unterstützte Form unverändert lassen + const res = removeSegment(form.pts, form.closed, edgeIndex); + if (res.pts.length < 2) return null; + return inherit(src, fromPolyForm(res)); +} + +/** + * Liefert den Index der Kante eines Drawing2D, die dem Modellpunkt `p` am + * nächsten liegt (für Alt+Klick = Segment-Löschen). `null`, wenn die Form keine + * Kanten hat (circle/arc/text) oder zu wenige Punkte. Reuse: Polyline-Form + + * Punkt-Strecke-Distanz. + */ +export function nearestEdgeIndex(geom: Drawing2DGeom, p: Vec2): number | null { + const form = toPolyForm(geom); + if (!form || form.pts.length < 2) return null; + const edges: [number, Vec2, Vec2][] = []; + for (let i = 0; i < form.pts.length - 1; i++) { + edges.push([i, form.pts[i], form.pts[i + 1]]); + } + if (form.closed && form.pts.length > 2) { + edges.push([form.pts.length - 1, form.pts[form.pts.length - 1], form.pts[0]]); + } + let best: number | null = null; + let bestD = Infinity; + for (const [i, a, b] of edges) { + const d = segDist(p, a, b); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; +} + +/** Abstand Punkt → Strecke (lokal, klein gehalten). */ +function segDist(p: Vec2, a: Vec2, b: Vec2): number { + const abx = b.x - a.x; + const aby = b.y - a.y; + const l2 = abx * abx + aby * aby; + let t = l2 > 0 ? ((p.x - a.x) * abx + (p.y - a.y) * aby) / l2 : 0; + t = Math.max(0, Math.min(1, t)); + const cx = a.x + abx * t; + const cy = a.y + aby * t; + return Math.hypot(p.x - cx, p.y - cy); +} + +// `vecEqual` re-export für etwaige Aufrufer (Auswahl-Vergleich). +export { vecEqual }; diff --git a/src/geometry/kernel2d.ts b/src/geometry/kernel2d.ts new file mode 100644 index 0000000..0c2d626 --- /dev/null +++ b/src/geometry/kernel2d.ts @@ -0,0 +1,735 @@ +// 2D-Geometrie-Kernel — die „echte Arbeit" für Offset / Trim / Extend / Fillet / +// Split (Rhino-artige Editierbefehle, Tier 1). Reine Funktionen über `Vec2`, +// kein externer Kernel. Baut auf den Helfern in `../model/geometry` auf. +// +// Konvention: Polylinien sind `Vec2[]`; `closed` heißt, die letzte Kante geht +// von pts[n-1] zu pts[0] (der Schlusspunkt wird NICHT dupliziert). Offsets sind +// vorzeichenbehaftet: positiv = nach LINKS bezogen auf die Laufrichtung +// (`leftNormal`-Konvention des Projekts), negativ = nach rechts. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { Vec2 } from "../model/types"; +import { + add, + cross, + leftNormal, + len, + lineIntersect, + normalize, + scale, + sub, +} from "../model/geometry"; + +/** Numerische Toleranz (Meter) für Schnitt-/Gleichheitstests. */ +export const EPS = 1e-7; + +const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y; +const dist = (a: Vec2, b: Vec2): number => len(sub(a, b)); + +/** Punkt-Gleichheit innerhalb Toleranz. */ +export function vecEqual(a: Vec2, b: Vec2, eps = EPS): boolean { + return Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps; +} + +// ── Punkt/Strecke ──────────────────────────────────────────────────────────── + +/** Projektionsparameter t von p auf die Gerade a→b (nicht geklemmt). */ +export function projectParam(p: Vec2, a: Vec2, b: Vec2): number { + const ab = sub(b, a); + const l2 = dot(ab, ab); + if (l2 < EPS) return 0; + return dot(sub(p, a), ab) / l2; +} + +/** Nächster Punkt auf der STRECKE a→b zu p (t auf [0,1] geklemmt). */ +export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { + const t = Math.max(0, Math.min(1, projectParam(p, a, b))); + return add(a, scale(sub(b, a), t)); +} + +/** Abstand von p zur Strecke a→b. */ +export function pointSegmentDistance(p: Vec2, a: Vec2, b: Vec2): number { + return dist(p, closestPointOnSegment(p, a, b)); +} + +// ── Schnitt ────────────────────────────────────────────────────────────────── + +/** Ergebnis eines Strecken-/Linienschnitts. */ +export interface Hit { + point: Vec2; + /** Parameter auf der ersten Strecke (0 = a1, 1 = a2). */ + t: number; + /** Parameter auf der zweiten Strecke (0 = b1, 1 = b2). */ + s: number; +} + +/** + * Schnitt zweier STRECKEN a1→a2 und b1→b2. Liefert den Schnittpunkt nur, wenn er + * auf BEIDEN Strecken liegt (t,s ∈ [-eps, 1+eps]); sonst null. Parallel → null. + */ +export function segmentIntersect( + a1: Vec2, + a2: Vec2, + b1: Vec2, + b2: Vec2, + eps = EPS, +): Hit | null { + const da = sub(a2, a1); + const db = sub(b2, b1); + const denom = cross(da, db); + if (Math.abs(denom) < EPS) return null; // parallel/kollinear + const t = cross(sub(b1, a1), db) / denom; + const s = cross(sub(b1, a1), da) / denom; + if (t < -eps || t > 1 + eps || s < -eps || s > 1 + eps) return null; + return { point: add(a1, scale(da, t)), t, s }; +} + +/** + * Schnitt der unendlichen GERADE durch a1→a2 mit der STRECKE b1→b2 (für Extend/ + * Trim gegen einen Schneider): Schnittpunkt muss auf der Strecke b liegen + * (s ∈ [0,1]), t (auf a) ist frei. Null bei parallel / kein Treffer auf b. + */ +export function lineSegmentIntersect( + a1: Vec2, + a2: Vec2, + b1: Vec2, + b2: Vec2, + eps = EPS, +): Hit | null { + const da = sub(a2, a1); + const db = sub(b2, b1); + const denom = cross(da, db); + if (Math.abs(denom) < EPS) return null; + const t = cross(sub(b1, a1), db) / denom; + const s = cross(sub(b1, a1), da) / denom; + if (s < -eps || s > 1 + eps) return null; + return { point: add(a1, scale(da, t)), t, s }; +} + +/** Kanten einer Polylinie als [from,to]-Paare (Schlusskante bei `closed`). */ +export function polylineEdges(pts: Vec2[], closed: boolean): [Vec2, Vec2][] { + const out: [Vec2, Vec2][] = []; + for (let i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i + 1]]); + if (closed && pts.length > 2) out.push([pts[pts.length - 1], pts[0]]); + return out; +} + +/** + * Alle Schnittpunkte einer STRECKE a1→a2 mit den Kanten einer Polylinie, nach + * Parameter t auf der Strecke sortiert (für Trim/Split). Endpunkt-Duplikate + * (gleicher Punkt aufeinanderfolgender Kanten) werden zusammengefasst. + */ +export function segmentPolylineHits( + a1: Vec2, + a2: Vec2, + pts: Vec2[], + closed: boolean, +): Hit[] { + const hits: Hit[] = []; + for (const [b1, b2] of polylineEdges(pts, closed)) { + const h = segmentIntersect(a1, a2, b1, b2); + if (h) hits.push(h); + } + hits.sort((p, q) => p.t - q.t); + const dedup: Hit[] = []; + for (const h of hits) { + if (dedup.length === 0 || Math.abs(dedup[dedup.length - 1].t - h.t) > 1e-6) { + dedup.push(h); + } + } + return dedup; +} + +// ── Offset (DAS Architektur-Primitiv) ─────────────────────────────────────── + +/** Offset einer einzelnen Strecke um `d` (links positiv). */ +export function offsetSegment(a: Vec2, b: Vec2, d: number): [Vec2, Vec2] { + const n = leftNormal(normalize(sub(b, a))); + const off = scale(n, d); + return [add(a, off), add(b, off)]; +} + +/** + * Offset einer Polylinie um `d` (links positiv) mit GEHRUNG (miter): jede Kante + * wird parallel verschoben, benachbarte verschobene Kanten werden geschnitten. + * Bei (nahezu) geraden/parallelen Nachbarn wird der verschobene Endpunkt direkt + * genommen. Funktioniert für offene und geschlossene Polylinien; degenerierte + * (zu kurze) Kanten werden übersprungen. Selbstüberschneidungen bei starkem + * Offset werden NICHT bereinigt (genügt für den Architektur-Workflow; ein + * vollständiger Polygon-Offset mit Selbstschnitt-Heilung wäre Tier-2-Arbeit). + */ +export function offsetPolyline(pts: Vec2[], d: number, closed: boolean): Vec2[] { + // Auf signifikante Kanten reduzieren (Duplikate verwerfen). + const clean: Vec2[] = []; + for (const p of pts) { + if (clean.length === 0 || dist(clean[clean.length - 1], p) > EPS) clean.push(p); + } + if (closed && clean.length > 1 && dist(clean[0], clean[clean.length - 1]) <= EPS) { + clean.pop(); + } + const n = clean.length; + if (n < 2) return [...pts]; + + const edges = polylineEdges(clean, closed).map(([a, b]) => offsetSegment(a, b, d)); + if (edges.length === 0) return [...pts]; + + const result: Vec2[] = []; + + // Hilfsfunktion: Schnitt zweier (verschobener) Kanten als unendliche Geraden. + const join = (e1: [Vec2, Vec2], e2: [Vec2, Vec2], fallback: Vec2): Vec2 => { + const d1 = sub(e1[1], e1[0]); + const d2 = sub(e2[1], e2[0]); + const p = lineIntersect(e1[0], d1, e2[0], d2); + return p ?? fallback; + }; + + if (!closed) { + // Offener Zug: erster Punkt = Start der ersten Kante; innere Punkte = + // Schnitt aufeinanderfolgender Kanten; letzter Punkt = Ende der letzten Kante. + result.push(edges[0][0]); + for (let i = 0; i < edges.length - 1; i++) { + result.push(join(edges[i], edges[i + 1], edges[i][1])); + } + result.push(edges[edges.length - 1][1]); + return result; + } + + // Geschlossen: jeder Ausgabepunkt = Schnitt von Kante i mit Kante i+1 (zyklisch). + for (let i = 0; i < edges.length; i++) { + const prev = edges[(i - 1 + edges.length) % edges.length]; + const curr = edges[i]; + result.push(join(prev, curr, curr[0])); + } + return result; +} + +// ── Trim / Split ───────────────────────────────────────────────────────────── + +/** + * Schneidet eine STRECKE seg=[a1,a2] an allen Schnittpunkten mit den Schneider- + * Kurven (Polylinien) und liefert die resultierenden Teilstrecken. `cutters` + * sind Polylinien (jede mit `closed`-Flag). Reine Geometrie; welche Teile + * behalten/verworfen werden, entscheidet der Aufrufer (z. B. Trim verwirft das + * Stück, das den Pick-Punkt enthält — siehe {@link trimSegment}). + */ +export function splitSegmentByCutters( + a1: Vec2, + a2: Vec2, + cutters: { pts: Vec2[]; closed: boolean }[], +): [Vec2, Vec2][] { + const ts: number[] = [0, 1]; + for (const c of cutters) { + for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) { + if (h.t > EPS && h.t < 1 - EPS) ts.push(h.t); + } + } + ts.sort((p, q) => p - q); + const da = sub(a2, a1); + const pieces: [Vec2, Vec2][] = []; + for (let i = 0; i < ts.length - 1; i++) { + if (ts[i + 1] - ts[i] < 1e-6) continue; + pieces.push([add(a1, scale(da, ts[i])), add(a1, scale(da, ts[i + 1]))]); + } + return pieces; +} + +/** + * Trim: schneidet seg an den Cuttern und VERWIRFT das Teilstück, das `pick` + * am nächsten liegt (das angeklickte „wegzuschneidende" Stück). Liefert die + * verbleibenden Teilstücke (0..n). + */ +export function trimSegment( + a1: Vec2, + a2: Vec2, + cutters: { pts: Vec2[]; closed: boolean }[], + pick: Vec2, +): [Vec2, Vec2][] { + const pieces = splitSegmentByCutters(a1, a2, cutters); + if (pieces.length <= 1) return pieces; + let best = 0; + let bestD = Infinity; + for (let i = 0; i < pieces.length; i++) { + const d = pointSegmentDistance(pick, pieces[i][0], pieces[i][1]); + if (d < bestD) { + bestD = d; + best = i; + } + } + return pieces.filter((_, i) => i !== best); +} + +// ── Extend ─────────────────────────────────────────────────────────────────── + +/** + * Verlängert das gewählte Ende (`end`: "start" → a1, "end" → a2) der Strecke + * a1→a2 bis zur NÄCHSTGELEGENEN Schneider-Kante in Verlängerungsrichtung. + * Liefert die neue Strecke oder null, wenn kein Treffer in der korrekten + * Richtung existiert. + */ +export function extendSegment( + a1: Vec2, + a2: Vec2, + end: "start" | "end", + cutters: { pts: Vec2[]; closed: boolean }[], +): [Vec2, Vec2] | null { + // Richtung der Verlängerung: bei "end" über a2 hinaus (t>1), bei "start" + // über a1 hinaus (t<0). Wir parametrisieren stets entlang a1→a2. + let bestT: number | null = null; + for (const c of cutters) { + for (const [b1, b2] of polylineEdges(c.pts, c.closed)) { + const h = lineSegmentIntersect(a1, a2, b1, b2); + if (!h) continue; + if (end === "end" && h.t > 1 + EPS) { + if (bestT === null || h.t < bestT) bestT = h.t; // nächstgelegen über a2 + } else if (end === "start" && h.t < -EPS) { + if (bestT === null || h.t > bestT) bestT = h.t; // nächstgelegen unter a1 + } + } + } + if (bestT === null) return null; + const da = sub(a2, a1); + const hitPoint = add(a1, scale(da, bestT)); + return end === "end" ? [a1, hitPoint] : [hitPoint, a2]; +} + +// ── Kreis-Schnitte (für Circle/Arc, 2P/3P/Tangenten) ──────────────────────── + +/** Schnittpunkte einer unendlichen GERADE a→b mit einem Kreis (0/1/2 Punkte). */ +export function lineCircleIntersect( + a: Vec2, + b: Vec2, + center: Vec2, + r: number, +): Vec2[] { + const d = sub(b, a); + const f = sub(a, center); + const A = dot(d, d); + if (A < EPS) return []; + const B = 2 * dot(f, d); + const C = dot(f, f) - r * r; + let disc = B * B - 4 * A * C; + if (disc < -EPS) return []; + if (disc < 0) disc = 0; + const sq = Math.sqrt(disc); + const t1 = (-B - sq) / (2 * A); + const t2 = (-B + sq) / (2 * A); + const out = [add(a, scale(d, t1))]; + if (Math.abs(t1 - t2) > EPS) out.push(add(a, scale(d, t2))); + return out; +} + +/** Schnittpunkte einer STRECKE a→b mit einem Kreis (nur t ∈ [0,1]). */ +export function segmentCircleIntersect( + a: Vec2, + b: Vec2, + center: Vec2, + r: number, +): Vec2[] { + return lineCircleIntersect(a, b, center, r).filter((p) => { + const t = projectParam(p, a, b); + return t >= -EPS && t <= 1 + EPS; + }); +} + +/** Schnittpunkte zweier Kreise (0/1/2 Punkte). */ +export function circleCircleIntersect( + c1: Vec2, + r1: number, + c2: Vec2, + r2: number, +): Vec2[] { + const d = dist(c1, c2); + if (d < EPS) return []; // konzentrisch + if (d > r1 + r2 + EPS || d < Math.abs(r1 - r2) - EPS) return []; // getrennt/innen + const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); + const h2 = r1 * r1 - a * a; + const h = h2 > 0 ? Math.sqrt(h2) : 0; + const u = normalize(sub(c2, c1)); + const mid = add(c1, scale(u, a)); + const n = leftNormal(u); + if (h < EPS) return [mid]; + return [add(mid, scale(n, h)), add(mid, scale(n, -h))]; +} + +// ── Polygon-Fläche / Wicklung ─────────────────────────────────────────────── + +/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW, <0 = CW. */ +export function signedArea(pts: Vec2[]): number { + let s = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + s += a.x * b.y - b.x * a.y; + } + return s / 2; +} + +/** Ob ein Polygonzug gegen den Uhrzeigersinn (CCW) gewickelt ist. */ +export function isCCW(pts: Vec2[]): boolean { + return signedArea(pts) > 0; +} + +// ── Fillet (Eck-Verrundung) ───────────────────────────────────────────────── + +/** Ergebnis einer Eck-Verrundung. */ +export interface Fillet { + center: Vec2; + radius: number; + /** Tangentenpunkt auf dem ersten Schenkel (corner→p1). */ + tangentA: Vec2; + /** Tangentenpunkt auf dem zweiten Schenkel (corner→p2). */ + tangentB: Vec2; + /** Bogen-Endwinkel in Radiant (für ein Arc-Primitiv). */ + startAngle: number; + endAngle: number; +} + +/** + * Verrundet die Ecke bei `corner`, gebildet von den beiden Schenkeln corner→p1 + * und corner→p2, mit Radius `r`. Liefert Bogenmittelpunkt + Tangentenpunkte + * (an denen die Schenkel zurückgetrimmt werden) oder null, wenn die Schenkel + * (nahezu) kollinear sind oder zu kurz für den Radius. Standard-Kanten-Fillet + * (Distanz vom Eck = r / tan(θ/2)). + */ +export function filletCorner( + corner: Vec2, + p1: Vec2, + p2: Vec2, + r: number, +): Fillet | null { + const u1 = normalize(sub(p1, corner)); + const u2 = normalize(sub(p2, corner)); + const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2))); + const theta = Math.acos(cosTheta); + if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear + const tanHalf = Math.tan(theta / 2); + if (tanHalf < EPS) return null; + const setback = r / tanHalf; // Abstand Eck → Tangentenpunkt entlang je Schenkel + // Genügend Schenkellänge? + if (setback > len(sub(p1, corner)) + EPS || setback > len(sub(p2, corner)) + EPS) { + return null; + } + const tangentA = add(corner, scale(u1, setback)); + const tangentB = add(corner, scale(u2, setback)); + // Mittelpunkt liegt auf der Winkelhalbierenden, Abstand r/sin(θ/2) vom Eck. + const bis = normalize(add(u1, u2)); + const centerDist = r / Math.sin(theta / 2); + const center = add(corner, scale(bis, centerDist)); + const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x); + const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x); + return { center, radius: r, tangentA, tangentB, startAngle, endAngle }; +} + +// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ───────────────────── +// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht +// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese +// Funktionen. Punkt P einer Kante: `lerp(pts[i], pts[i+1], t)`. + +/** Lineare Interpolation zweier Punkte. */ +const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => add(a, scale(sub(b, a), t)); + +/** + * Teilt eine Polylinie an EINEM Punkt P = lerp(pts[edgeIndex], pts[edgeIndex+1], + * t) (t ∈ (0,1) entlang der Kante; bei geschlossenen Zügen ist die Schlusskante + * `edgeIndex === pts.length-1`). + * • OFFEN → zwei offene Polylinien [start…P] und [P…ende]. + * • GESCHLOSSEN → EINE offene Polylinie, am Punkt P aufgeschnitten (der Zug + * läuft von P einmal um die Schleife zurück nach P). + * Liegt P (nahezu) auf einem vorhandenen Vertex, wird KEIN Duplikat eingefügt. + */ +export function splitPolylineAtParam( + pts: Vec2[], + closed: boolean, + edgeIndex: number, + t: number, +): Vec2[][] { + const n = pts.length; + if (n < 2) return [pts.slice()]; + const a = pts[edgeIndex]; + const b = pts[(edgeIndex + 1) % n]; + const p = lerp(a, b, t); + const atStart = t <= EPS; + const atEnd = t >= 1 - EPS; + + if (!closed) { + // Offen: [0..edgeIndex] + P + [edgeIndex+1..n-1]. + const left = pts.slice(0, edgeIndex + 1); + if (!atStart && !atEnd) left.push(p); + else if (atEnd) left.push(b); // P == b → b gehört noch links dazu + const right: Vec2[] = []; + if (!atStart && !atEnd) right.push(p); + else if (atStart) right.push(a); + for (let i = edgeIndex + 1; i < n; i++) right.push(pts[i]); + const pieces = [left, right].filter((s) => s.length >= 2); + return pieces.length ? pieces : [pts.slice()]; + } + + // Geschlossen: an P aufschneiden → eine offene Kette, die einmal herumläuft. + const out: Vec2[] = []; + if (!atStart) out.push(p); + // Ab dem Punkt NACH der Schnittkante einmal komplett um die Schleife. + for (let k = 1; k <= n; k++) { + out.push(pts[(edgeIndex + k) % n]); + } + // out endet bei pts[edgeIndex] (== a); P schließt den Zug am Ende ab. + if (!atStart) out.push(p); + else out.push(a); // bei P==a: a doppelt (Start==Ende) ergäbe eine Schleife; + // dedupe identische aufeinanderfolgende Punkte + return [dedupeConsecutive(out)]; +} + +/** Entfernt aufeinanderfolgende (nahezu) gleiche Punkte (kein Ringschluss). */ +function dedupeConsecutive(pts: Vec2[]): Vec2[] { + const out: Vec2[] = []; + for (const p of pts) { + if (out.length === 0 || !vecEqual(out[out.length - 1], p)) out.push(p); + } + return out; +} + +/** + * Teilt ein GESCHLOSSENES Polygon an zwei Randpunkten (auf den Kanten `i` bzw. + * `j`, Parameter `ti`/`tj`) in ZWEI GESCHLOSSENE Polygone: jedes besteht aus dem + * jeweiligen Rand-Bogen plus der Sehne (Verbindung Pi—Pj). Liefert `null`, wenn + * i==j (dieselbe Kante → keine sinnvolle Sehne). + */ +export function splitClosedByChord( + pts: Vec2[], + i: number, + ti: number, + j: number, + tj: number, +): [Vec2[], Vec2[]] | null { + const n = pts.length; + if (n < 3 || i === j) return null; + // Reihenfolge sicherstellen: a vor b entlang des Ringes. + let ia = i, ta = ti, ib = j, tb = tj; + if (i > j) { + ia = j; ta = tj; ib = i; tb = ti; + } + const Pa = lerp(pts[ia], pts[(ia + 1) % n], ta); + const Pb = lerp(pts[ib], pts[(ib + 1) % n], tb); + + // Bogen 1: Pa → (Vertices ia+1 … ib) → Pb. + const arc1: Vec2[] = [Pa]; + for (let k = ia + 1; k <= ib; k++) arc1.push(pts[k % n]); + arc1.push(Pb); + // Bogen 2: Pb → (Vertices ib+1 … ia, modular) → Pa. + const arc2: Vec2[] = [Pb]; + for (let k = ib + 1; k <= ia + n; k++) arc2.push(pts[k % n]); + arc2.push(Pa); + + return [dedupeRing(arc1), dedupeRing(arc2)]; +} + +/** + * Dedupliziert aufeinanderfolgende gleiche Punkte UND einen schließenden + * Duplikat-Endpunkt (Ring: pts[last]==pts[0] → letzten verwerfen). + */ +function dedupeRing(pts: Vec2[]): Vec2[] { + const out = dedupeConsecutive(pts); + if (out.length > 1 && vecEqual(out[0], out[out.length - 1])) out.pop(); + return out; +} + +/** + * Entfernt das Segment `edgeIndex` aus einer Polylinie. Liefert die neue + * Punktliste plus neues `closed`-Flag: + * • OFFEN: innere Kante → die Polylinie zerfällt in zwei Stücke; hier wird das + * LÄNGERE Stück (mehr Punkte) zurückgegeben (Schere). Erste/letzte Kante → + * einfach den End-Vertex abschneiden. + * • GESCHLOSSEN: die Schleife wird an dieser Kante zur OFFENEN Polylinie + * aufgetrennt (Start = Punkt nach der Kante, einmal herum bis zum Punkt davor). + */ +export function removeSegment( + pts: Vec2[], + closed: boolean, + edgeIndex: number, +): { pts: Vec2[]; closed: boolean } { + const n = pts.length; + if (closed) { + // Auftrennen: Start = (edgeIndex+1) … einmal herum bis edgeIndex. + const out: Vec2[] = []; + for (let k = 1; k <= n; k++) out.push(pts[(edgeIndex + k) % n]); + return { pts: dedupeConsecutive(out), closed: false }; + } + // Offen. + if (edgeIndex <= 0) return { pts: pts.slice(1), closed: false }; + if (edgeIndex >= n - 1) return { pts: pts.slice(0, n - 1), closed: false }; + const left = pts.slice(0, edgeIndex + 1); + const right = pts.slice(edgeIndex + 1); + // Das punktreichere (geometrisch i. d. R. „größere") Teilstück behalten. + return { pts: left.length >= right.length ? left : right, closed: false }; +} + +/** Ein Schnitt-Treffer auf einer Ziel-Kante: Kantenindex + Parameter + Punkt. */ +interface EdgeHit { + edge: number; + t: number; + point: Vec2; +} + +/** + * Teilt das Ziel (`targetPts`, `closed`) an allen Schnittpunkten mit den anderen + * Polylinien (`others`). Schnittpunkte entstehen je Kantenpaar via + * {@link segmentIntersect}. + * • GESCHLOSSEN + genau zwei Schnitte → {@link splitClosedByChord} (zwei + * geschlossene Polygone). Bei !=2 Schnitten: unverändert (eine Liste). + * • OFFEN → an den (nach Bogenlänge geordneten) Schnittpunkten in Stücke + * zerschnitten. + * Schnitte an vorhandenen Vertices/Endpunkten werden zusammengefasst. + */ +export function splitAtIntersections( + targetPts: Vec2[], + closed: boolean, + others: Vec2[][], +): Vec2[][] { + const edges = polylineEdges(targetPts, closed); + const hits: EdgeHit[] = []; + for (let ei = 0; ei < edges.length; ei++) { + const [a1, a2] = edges[ei]; + for (const o of others) { + // jede Kante jeder anderen Polylinie (offen UND geschlossen behandeln wir + // gleich: die Aufrufer-Schicht liefert `others` bereits als Polylinien- + // Punktlisten; geschlossene Schließkante über vecEqual-Heuristik unnötig, + // da der Aufrufer Endpunkt nicht dupliziert — wir testen offene Kanten). + const oEdges = polylineEdgesAuto(o); + for (const [b1, b2] of oEdges) { + const h = segmentIntersect(a1, a2, b1, b2); + if (!h) continue; + // Nur „echte" innere Schnitte zählen (nicht exakt am Ziel-Vertex 0/1, + // die ohnehin Trennstellen sind). + hits.push({ edge: ei, t: h.t, point: h.point }); + } + } + } + // Pro Ziel-Kante nach t sortieren + dedupen. + const dedup: EdgeHit[] = []; + hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t)); + for (const h of hits) { + const prev = dedup[dedup.length - 1]; + if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue; + // Schnitte exakt auf einem Ziel-Vertex (t≈0/1) ignorieren — keine echte Teilung. + if (h.t <= EPS || h.t >= 1 - EPS) continue; + dedup.push(h); + } + + if (dedup.length === 0) return [targetPts.slice()]; + + if (closed) { + if (dedup.length === 2) { + const [h1, h2] = dedup; + const res = splitClosedByChord(targetPts, h1.edge, h1.t, h2.edge, h2.t); + return res ? res : [targetPts.slice()]; + } + // Mehr/weniger als 2 Schnitte: geschlossen an allen Schnitten zur offenen + // Polylinie aufgetrennt (am ersten Schnitt aufgeschnitten, dann in Stücke). + // Pragmatisch: am ersten Schnitt aufschneiden, dann wie offen weiter. + const opened = splitPolylineAtParam(targetPts, true, dedup[0].edge, dedup[0].t)[0]; + return splitOpenAtHits(opened, dedup.slice(1).map((h) => h.point)); + } + + // Offen: an den geordneten Schnittpunkten zerschneiden. + return splitOpenByEdgeHits(targetPts, dedup); +} + +/** Kanten einer (offenen ODER implizit geschlossenen) Punktliste. */ +function polylineEdgesAuto(pts: Vec2[]): [Vec2, Vec2][] { + // Heuristik: ist der erste == letzter Punkt, gilt sie als geschlossen. + const closed = pts.length > 2 && vecEqual(pts[0], pts[pts.length - 1]); + return polylineEdges(closed ? pts.slice(0, -1) : pts, closed); +} + +/** Offene Polylinie an gegebenen EdgeHits (auf ihren Kanten) zerschneiden. */ +function splitOpenByEdgeHits(pts: Vec2[], hits: EdgeHit[]): Vec2[][] { + const pieces: Vec2[][] = []; + let cur: Vec2[] = [pts[0]]; + let hi = 0; + for (let ei = 0; ei < pts.length - 1; ei++) { + while (hi < hits.length && hits[hi].edge === ei) { + const p = hits[hi].point; + cur.push(p); + pieces.push(cur); + cur = [p]; + hi++; + } + cur.push(pts[ei + 1]); + } + pieces.push(cur); + return pieces.map(dedupeConsecutive).filter((s) => s.length >= 2); +} + +/** Offene Polylinie an einer Liste von Schnitt-PUNKTEN (auf dem Zug) zerschneiden. */ +function splitOpenAtHits(pts: Vec2[], cutPoints: Vec2[]): Vec2[][] { + if (cutPoints.length === 0) return [pts.slice()]; + const hits: EdgeHit[] = []; + for (let ei = 0; ei < pts.length - 1; ei++) { + const a = pts[ei]; + const b = pts[ei + 1]; + for (const cp of cutPoints) { + const t = projectParam(cp, a, b); + if (t > EPS && t < 1 - EPS && pointSegmentDistance(cp, a, b) < 1e-6) { + hits.push({ edge: ei, t, point: cp }); + } + } + } + hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t)); + return splitOpenByEdgeHits(pts, hits); +} + +/** + * Verschmilzt Polylinien an koinzidenten Endpunkten zu längeren Ketten. Jeder + * Eintrag ist `{pts, closed}`. Bereits geschlossene Ketten bleiben unverändert. + * Offene Ketten werden an gemeinsamen Endpunkten (vecEqual) aneinandergehängt; + * schließt sich eine Kette (erster == letzter Punkt), wird sie als `closed` + * markiert (der Schlusspunkt-Duplikat entfernt). Reihenfolge-/Richtungs-stabil + * genug für den Editier-Workflow (kein Verzweigungs-Handling: Knoten mit >2 + * Enden werden greedy verbunden). + */ +export function joinChains( + polylines: { pts: Vec2[]; closed: boolean }[], +): { pts: Vec2[]; closed: boolean }[] { + const closedOut: { pts: Vec2[]; closed: boolean }[] = []; + // Offene Ketten als veränderbare Punktlisten sammeln (geschlossene bleiben). + const open: Vec2[][] = []; + for (const pl of polylines) { + if (pl.closed) closedOut.push({ pts: pl.pts.slice(), closed: true }); + else if (pl.pts.length >= 2) open.push(pl.pts.slice()); + else if (pl.pts.length === 1) open.push(pl.pts.slice()); + } + + let merged = true; + while (merged) { + merged = false; + outer: for (let i = 0; i < open.length; i++) { + for (let j = i + 1; j < open.length; j++) { + const A = open[i]; + const B = open[j]; + const aS = A[0], aE = A[A.length - 1]; + const bS = B[0], bE = B[B.length - 1]; + let combined: Vec2[] | null = null; + if (vecEqual(aE, bS)) combined = [...A, ...B.slice(1)]; + else if (vecEqual(aE, bE)) combined = [...A, ...B.slice(0, -1).reverse()]; + else if (vecEqual(aS, bE)) combined = [...B, ...A.slice(1)]; + else if (vecEqual(aS, bS)) combined = [...B.slice().reverse(), ...A.slice(1)]; + if (combined) { + open.splice(j, 1); + open.splice(i, 1, combined); + merged = true; + break outer; + } + } + } + } + + const out: { pts: Vec2[]; closed: boolean }[] = [...closedOut]; + for (const chain of open) { + if (chain.length > 2 && vecEqual(chain[0], chain[chain.length - 1])) { + out.push({ pts: chain.slice(0, -1), closed: true }); + } else { + out.push({ pts: chain, closed: false }); + } + } + return out; +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts new file mode 100644 index 0000000..e707714 --- /dev/null +++ b/src/i18n/de.ts @@ -0,0 +1,500 @@ +// Deutsches Wörterbuch (Default-Sprache). Die Werte hier sind EXAKT die bisher +// sichtbaren UI-Texte — die i18n-Umstellung darf optisch nichts ändern. +// +// Identifier/Keys sind englisch und namespaced wie bei DOSSIER +// (z. B. `topbar.grundriss`); nur die Werte sind übersetzbarer Text. +// Platzhalter in geschweiften Klammern (`{name}`) werden von t(...) ersetzt. + +export const de = { + // ── Marke / Topbar ─────────────────────────────────────────────────────── + "brand.tag": "Dokumentmodell", + + "topbar.viewGroup": "Ansicht", + "topbar.grundriss": "Grundriss", + "topbar.perspektive": "Perspektive", + + "view3d.group": "Ansicht 3D", + "view3d.front": "Front", + "view3d.front.hint": "Frontalansicht von vorne", + "view3d.top": "Oben", + "view3d.top.hint": "Draufsicht (senkrecht von oben)", + "view3d.side": "Seite", + "view3d.side.hint": "Seitenansicht (90° zur Front)", + "view3d.perspective": "Perspektive", + "view3d.perspective.hint": "Freie Perspektive (3/4-Blick)", + "view3d.iso": "Isometrie", + "view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)", + "view3d.camera": "Kamera", + "view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)", + "view3d.fov": "Sichtwinkel", + "view3d.fov.hint": "Sichtwinkel (FOV) der Perspektivkamera in Grad", + + "topbar.detailGroup": "Detailgrad", + "topbar.detail": "Detailgrad", + "topbar.detail.hint": "Detailgrad der Plandarstellung (Symbole, Schraffuren, Linien)", + "topbar.detail.hintDisabled": "Detailgrad wirkt im Grundriss (in der Perspektive noch nicht)", + "topbar.detail.grob": "grob", + "topbar.detail.mittel": "mittel", + "topbar.detail.fein": "fein", + + "topbar.scaleGroup": "Massstab und Zoom", + "topbar.scale": "Massstab", + "topbar.scale.apply": "Papier-Massstab 1:N anwenden", + "topbar.scale.custom": "frei …", + "topbar.scale.live": "Aktueller Papier-Massstab (live)", + "topbar.scale.prompt": "Massstab eingeben (z. B. 1:35 oder 35):", + "topbar.zoom100": "100%", + "topbar.zoom100.hint": "Zoom exakt auf den gewählten Massstab 1:{n}", + "topbar.fit": "Einpassen", + "topbar.fit.hint": "Auf Plan einpassen", + "topbar.fitSelection": "Auswahl", + "topbar.fitSelection.hint": "Auf Auswahl einpassen (sonst auf Plan)", + "topbar.zoom.current": "Aktueller Zoom", + + "topbar.renderGroup": "Render-Modus", + "topbar.render.shaded": "Schattiert", + "topbar.render.shaded.hint": "Schattiert", + "topbar.render.wireframe": "Draht", + "topbar.render.wireframe.hint": "Drahtgitter", + "topbar.render.hidden": "Kanten", + "topbar.render.hidden.hint": "Verdeckte Kanten (Hidden-Line)", + "topbar.render.hintDisabled": "Render-Modus wirkt in der Perspektive", + // Darstellungsart (kontextabhängiger Dropdown): Grundriss = Farbig/SW, + // Perspektive = Schattiert/Weiss/Draht/Kanten. + "display.style": "Darstellung", + "display.style.group": "Darstellungsart", + "display.style.hint": "Darstellungsart der aktiven Ansicht", + "display.style.color": "Farbig", + "display.style.mono": "Schwarz-Weiss", + "display.style.shaded": "Schattiert", + "display.style.white": "Weiss", + "display.style.wireframe": "Drahtgitter", + "display.style.hidden": "Kanten", + + "topbar.referenceLinesGroup": "Referenzlinien", + "topbar.referenceLines": "Referenzlinien", + "topbar.referenceLines.hint": "Referenzlinien (Wandachsen) ein-/ausblenden", + "topbar.referenceLines.hintDisabled": "Referenzlinien wirken im Grundriss", + + "topbar.resources": "Ressourcen", + "topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden", + + // ── Layout-Menü (Topbar) ───────────────────────────────────────────────── + "layout.title": "Layout", + "layout.placeholder": "Layout …", + "layout.saveCurrent": "+ Aktuelles speichern …", + "layout.loadGroup": "Laden", + "layout.deleteGroup": "Löschen", + "layout.savePrompt": "Layout speichern als:", + + // ── Sichtbarkeits-Kombinationen (Topbar) ───────────────────────────────── + "combo.layers": "Ebenen-Komb.", + "combo.drawings": "Zeichn.-Komb.", + "combo.placeholder": "Kombination …", + "combo.saveCurrent": "+ Aktuelle speichern …", + "combo.loadGroup": "Laden", + "combo.deleteGroup": "Löschen", + "combo.layers.title": "Ebenenkombinationen", + "combo.drawings.title": "Zeichnungskombinationen", + "combo.layers.savePrompt": "Ebenenkombination speichern als:", + "combo.drawings.savePrompt": "Zeichnungskombination speichern als:", + + // ── Zeichenwerkzeuge (Topbar-Werkzeugleiste + Statushinweise) ──────────── + "tool.group": "Werkzeuge", + "tool.select": "Auswahl", + "tool.wall": "Wand", + "tool.line": "Linie", + "tool.polyline": "Polylinie", + "tool.rect": "Rechteck", + "tool.select.hint": "Auswahl — Elemente klicken/aufziehen", + "tool.wall.firstPoint": "Wand: ersten Achspunkt setzen", + "tool.wall.nextPoint": "Wand: nächsten Punkt — Doppelklick/Rechtsklick beendet", + "tool.line.firstPoint": "Linie: Startpunkt setzen", + "tool.line.secondPoint": "Linie: Endpunkt setzen", + "tool.polyline.firstPoint": "Polylinie: ersten Punkt setzen", + "tool.polyline.nextPoint": "Polylinie: nächster Punkt — Klick auf Start schließt, Doppelklick beendet", + "tool.rect.firstCorner": "Rechteck: erste Ecke setzen", + "tool.rect.secondCorner": "Rechteck: gegenüberliegende Ecke setzen", + "tool.wallType": "Wandtyp", + "tool.layer": "Ebene", + "tool.layer.hint": "Aktive Ebene — alles Gezeichnete kommt auf diese Kategorie", + "tool.floorOnlyDisabled": "Wand nur auf einem Geschoss zeichenbar", + "tool.snap": "Fang", + "tool.snap.hint": "Fang an Endpunkten/Raster; Shift = Ortho, Ctrl = Fang aus", + "snap.enabled": "Fang aktiv", + "snap.endpoint": "Endpunkt", + "snap.midpoint": "Mittelpunkt", + "snap.intersection": "Schnittpunkt", + "snap.onEdge": "Auf Kante (Lot)", + "snap.grid": "Raster", + "snap.ortho": "Ortho (Shift)", + "snap.gridSize": "Rasterweite (m)", + "snap.angleStep": "Winkelraster", + + // ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ─────────────── + "transform.bar": "Transformation", + "transform.cancel": "Abbrechen (Esc)", + "transform.countPrompt": "Anzahl Kopien:", + "transform.move": "Bewegen (M)", + "transform.mirror": "Spiegeln (S)", + "transform.rotate": "Drehen (D)", + "transform.mode.move": "Bewegen", + "transform.mode.copy": "Kopie", + "transform.mode.array": "N Kopien", + "transform.mode.distribute": "Verteilen", + "transform.hint.move.0": "Basispunkt klicken", + "transform.hint.move.1": "Zielpunkt klicken", + "transform.hint.rotate.0": "Drehzentrum klicken", + "transform.hint.rotate.1": "Bezugspunkt (Startwinkel) klicken", + "transform.hint.rotate.2": "Zielwinkel klicken", + "transform.hint.mirror.0": "Erster Punkt der Spiegelachse", + "transform.hint.mirror.1": "Zweiter Punkt der Spiegelachse", + + // ── Linien-Darstellung (Display/Print) ─────────────────────────────────── + "topbar.lineModeGroup": "Linien-Darstellung", + "topbar.lineMode.display": "Display", + "topbar.lineMode.print": "Print", + "topbar.lineMode.display.hint": "Display: alle Linien als konstante Haarlinie (ruhiges Arbeiten)", + "topbar.lineMode.print.hint": "Print: echte mm-Strichstärken (Papier-/Druckbild)", + + // ── Statusleiste ───────────────────────────────────────────────────────── + "status.selection": "Auswahl", + "status.selectionN": "Auswahl: {n} {noun}", + "status.wall": "Wand", + "status.walls": "Wände", + "status.xy": "X/Y", + "status.unit": "Einheit", + "status.scale": "Massstab", + "status.zoom": "Zoom", + "status.floor": "Geschoss", + "status.layer": "Ebene", + "status.none": "—", + + // ── Navigator-Panels ───────────────────────────────────────────────────── + "nav.drawingLevels": "Zeichnungsebenen", + "nav.layers": "Ebenen", + "nav.tools": "Werkzeuge", + "nav.attributes": "Attribute", + "nav.objectInfo": "Objekt-Info", + + // Attribute-Palette (AttributesPanel) + "attr.title": "Attribute", + "attr.empty": "Keine Auswahl", + "attr.stroke": "Stift", + "attr.color": "Farbe", + "attr.weight": "Strichstärke (mm)", + "attr.lineStyle": "Linienstil", + "attr.fill": "Füllung", + "attr.fillColor": "Füllfarbe", + "attr.clearFill": "Füllfarbe entfernen", + "attr.hatch": "Schraffur", + "attr.none": "keine", + "attr.inheritedFromLayer": "Wände erben die Strichstärke aus der Ebene", + "attr.fillNeedsClosed": "Füllung nur für geschlossene Formen", + "attr.size": "Maße", + "attr.width": "Breite", + "attr.height": "Höhe", + + // Objekt-Info-Palette (ObjectInfoPanel) + "objinfo.title": "Objekt-Info", + "objinfo.empty": "Kein Objekt gewählt", + "objinfo.refpoint": "Bezugspunkt", + "objinfo.setRefpoint": "Bezugspunkt wählen", + "objinfo.x": "X", + "objinfo.y": "Y", + "objinfo.z": "Z", + "objinfo.zHint": "2D-Ebene – keine Höhe am Element", + "objinfo.dimensions": "Maße", + "objinfo.width": "Breite", + "objinfo.height": "Höhe", + "objinfo.type": "Typ", + "objinfo.kind.wall": "Wand", + "objinfo.kind.drawing2d": "2D-Objekt", + "objinfo.kind.door": "Tür", + // ── Wand-Attribute (Object-Info-Panel) ────────────────────────────────── + "objinfo.wall.section": "Wand", + "objinfo.wall.refLine": "Referenzlinie", + "objinfo.wall.refLine.left": "Außen (links)", + "objinfo.wall.refLine.center": "Mitte", + "objinfo.wall.refLine.right": "Innen (rechts)", + "objinfo.wall.buildup": "Aufbau", + "objinfo.wall.single": "Einschichtig", + "objinfo.wall.multi": "Mehrschichtig", + "objinfo.wall.thickness": "Dicke", + "objinfo.wall.preset": "Wandaufbau", + "objinfo.wall.presetLabel": "{name} · {thickness} m", + "objinfo.wall.refFloor": "Referenzgeschoss", + "objinfo.wall.linkAbove": "An oberes Geschoss binden", + "objinfo.wall.bottom": "Unterkante (UK)", + "objinfo.wall.top": "Oberkante (OK)", + "objinfo.wall.anchorFloor": "An Geschoss gebunden", + "objinfo.wall.anchorCustom": "Eigene Höhe", + "objinfo.wall.height": "Höhe", + "objinfo.wall.floorAboveNone": "Kein oberes Geschoss", + "levels.addFloor": "+ Geschoss", + "levels.addDrawing": "+ Zeichnung", + "layers.addCategory": "+ Ebene", + "nav.visibility": "Sichtbarkeit", + "nav.hide": "Ausblenden", + "nav.show": "Einblenden", + "nav.categoryVisible": "{name} sichtbar", + "nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)", + + // ── Badges (Art einer Zeichnungsebene) ─────────────────────────────────── + "badge.floor": "Geschoss", + "badge.section": "Schnitt", + "badge.elevation": "Ansicht", + "badge.drawing": "Zeichnung", + + // ── Darstellungsmodus-Umschalter ───────────────────────────────────────── + "display.title": "Darstellungsmodus", + "display.all_force": "Alle anzeigen", + "display.all": "Ausgewählte", + "display.active": "Nur aktive", + "display.grey": "Andere grau", + "display.grey_locked": "Andere grau & gesperrt", + + // ── Dock ───────────────────────────────────────────────────────────────── + "dock.resize": "Breite anpassen", + "dock.dock": "Andocken", + "dock.close": "Schließen", + "dock.float": "Lösen", + "dock.redock": "Andocken", + "dock.resizePanel": "Größe ändern", + "dock.dropLeft": "Links andocken", + "dock.dropRight": "Rechts andocken", + + // ── Kontextmenü (Einträge) ─────────────────────────────────────────────── + "ctx.layerSettings": "Ebeneneinstellungen…", + "ctx.addSubLayer": "Sub-Ebene hinzufügen…", + "ctx.assignSelection": "Selektion hierher übertragen", + "ctx.assignSelection.disabled": "Keine Wand ausgewählt — zuerst im Grundriss/3D eine Wand anklicken.", + "ctx.duplicate": "Duplizieren", + "ctx.copyProps": "Eigenschaften kopieren", + "ctx.pasteProps": "Eigenschaften einfügen", + "ctx.pasteProps.disabled": "Zwischenablage leer — zuerst „Eigenschaften kopieren“.", + "ctx.delete": "Löschen", + "ctx.delete.lastLayer": "Die letzte Ebene kann nicht gelöscht werden.", + "ctx.delete.lastLevel": "Die letzte Zeichnungsebene kann nicht gelöscht werden.", + "ctx.levelSettings": "Einstellungen…", + "ctx.fitSelection": "Auf Auswahl einpassen", + "ctx.clearSelection": "Auswahl aufheben", + "ctx.fit": "Ansicht einpassen", + "ctx.noWallHit": "Keine Wand getroffen", + "ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.", + + // Kontextmenü-Titel je Quelle. + "ctx.title.level": "Zeichnungsebene", + "ctx.title.wall": "Wand", + "ctx.title.view": "Ansicht", + + // ── Inline-Editor (Ebene / Geschoss) ───────────────────────────────────── + "editor.close": "Schließen", + "editor.layerTitle": "Ebene {code}", + "editor.name": "Name", + "editor.color": "Farbe", + "editor.lineWeight": "Linienstärke", + "editor.floorHeight": "Geschosshöhe", + "editor.cutHeight": "Schnitthöhe", + "editor.unit.mm": "mm", + "editor.unit.m": "m", + "editor.noHeightDrawing": "Für Zeichnungen gibt es keine Geschoss-/Schnitthöhe.", + "editor.noHeightSection": "Für Schnitte/Ansichten gibt es keine Geschoss-/Schnitthöhe.", + + // ── Hauptinhalt (Panes / Eigenschaften) ────────────────────────────────── + "content.perspektiveTitle": "Perspektive · {name}", + "content.grundrissTitle": "Grundriss · {name}", + "content.grundrissLegend": "Grundriss · {name} · Schnitthöhe {cutHeight}", + "content.drawingTitle": "Zeichnung · {name}", + "content.drawingCanvas": "Zeichenfläche", + "content.drawingLegend": "Freie 2D-Zeichnung", + "props.floorHeight": "Geschosshöhe", + "props.cutHeight": "Schnitthöhe", + "props.okff": "OKFF", + "props.okffValue": "+{value} m", + + // ── Stub (Schnitt / Ansicht) ───────────────────────────────────────────── + "stub.section": "Schnitt", + "stub.elevation": "Ansicht", + "stub.note": "{kind} — abgeleitete Projektion folgt in einer späteren Phase", + + // ── Default-Namen neuer Elemente ───────────────────────────────────────── + "default.floorName": "Geschoss {n}", + "default.drawingName": "Zeichnung {n}", + "default.categoryName": "Neue Ebene", + "default.subCategoryName": "Neue Sub-Ebene", + "default.componentName": "Neues Bauteil", + "default.hatchName": "Neue Schraffur", + "default.lineStyleName": "Neuer Linienstil", + "default.hatchNone": "Ohne", + "default.copySuffixLevel": "{name} Kopie", + "default.copySuffixLayer": "{name} KOPIE", + "default.wallTypeSingle": "Wand {thickness} m", + + // ── Warnungen (Lösch-Schutz) ───────────────────────────────────────────── + "alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.", + "alert.componentInUse": "Bauteil „{name}\" wird von einem Wandtyp verwendet und kann nicht gelöscht werden.", + "alert.hatchInUse": "Schraffur „{name}\" wird von einem Bauteil verwendet und kann nicht gelöscht werden.", + "alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.", + + // ── Ressourcen-Manager ─────────────────────────────────────────────────── + "resources.title": "Ressourcen", + "resources.label": "Ressourcen-Manager", + "resources.close": "Schließen", + "resources.tab.components": "Bauteile", + "resources.tab.hatches": "Schraffuren", + "resources.tab.lines": "Linien", + "resources.add": "Neu", + "resources.import.lin": "⤓ .lin importieren", + "resources.import.pat": "⤓ .pat importieren", + + // Bauteile. + "resources.components.hint": "Höhere Priorität läuft am Stoß durch (verschneidet zuerst); niedrigere stößt seitlich an.", + "resources.components.empty": "Noch keine Bauteile.", + "resources.components.placeholder": "Bauteil", + "resources.col.name": "Name", + "resources.col.color": "Farbe", + "resources.col.hatch": "Schraffur", + "resources.col.prio": "Prio", + "resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch", + "resources.col.texture": "Textur", + "resources.delete.component": "Bauteil „{name}\" löschen", + + // Schraffuren. + "resources.hatches.hint": "Muster, Maßstab und Winkel steuern die Schnitt-Schraffur; der Linienstil bestimmt die Stärke der Musterlinien.", + "resources.hatches.empty": "Noch keine Schraffuren.", + "resources.hatches.placeholder": "Schraffur", + "resources.col.pattern": "Muster", + "resources.col.scale": "Maßstab", + "resources.col.angle": "Winkel °", + "resources.col.lineStyle": "Linienstil", + "resources.lineStyle.none": "— ohne —", + "resources.delete.hatch": "Schraffur „{name}\" löschen", + + // Schraffur-Muster. + "pattern.none": "Ohne", + "pattern.solid": "Vollfläche", + "pattern.insulation": "Wärmedämmung", + "pattern.diagonal": "Diagonal", + "pattern.crosshatch": "Kreuzschraffur", + + // Linien. + "resources.lines.hint": "Strichstärke in Millimetern (Paper-Space). Strichmuster: durchgezogen, gestrichelt oder gepunktet.", + "resources.lines.empty": "Noch keine Linienstile.", + "resources.lines.placeholder": "Linienstil", + "resources.col.weight": "Stärke (mm)", + "resources.col.stroke": "Strich", + "resources.col.preview": "Vorschau", + "resources.delete.lineStyle": "Linienstil „{name}\" löschen", + "resources.texture.empty": "—", + + // Strichmuster. + "dash.solid": "Durchgezogen", + "dash.dashed": "Gestrichelt", + "dash.dotted": "Gepunktet", + + // ── Command-Line / Befehle (Rhino-artiges Befehlssystem) ────────────────── + "cmd.aria": "Befehlszeile", + "cmd.idle.prompt": "Befehl:", + "cmd.idle.placeholder": "Befehl eingeben … (Tab)", + "cmd.line.label": "Linie", + "cmd.line.start": "Startpunkt der Linie:", + "cmd.line.end": "Endpunkt der Linie:", + "cmd.polyline.label": "Polylinie", + "cmd.polyline.start": "Startpunkt:", + "cmd.polyline.next": "Nächster Punkt:", + "cmd.polyline.close": "Schliessen", + "cmd.polyline.undo": "Zurück", + "cmd.rect.label": "Rechteck", + "cmd.rect.first": "Erste Ecke:", + "cmd.rect.second": "Gegenüberliegende Ecke:", + "cmd.circle.label": "Kreis", + "cmd.circle.center": "Mittelpunkt:", + "cmd.circle.radius": "Radius:", + // Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl. + "cmd.move.label": "Verschieben", + "cmd.move.base": "Basispunkt:", + "cmd.move.target": "Zielpunkt:", + "cmd.move.empty": "Nichts gewählt (erst Objekte wählen).", + "cmd.copy.label": "Kopieren", + "cmd.copy.base": "Basispunkt:", + "cmd.copy.target": "Zielpunkt (Enter beendet):", + "cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).", + "cmd.offset.label": "Versatz", + "cmd.offset.pick": "Kurve wählen:", + "cmd.offset.side": "Seite oder Distanz:", + // Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J / + // Alt+Klick). Labels für Befehlsleiste/Menüs. + "edit.split.label": "Teilen", + "edit.join.label": "Verbinden", + "edit.cutSegment.label": "Segment löschen", + // Tab-Feld-Zyklus (Präzisionseingabe): geordnete Zahlenfelder je Schritt. + "cmd.field.length": "Länge", + "cmd.field.angle": "Winkel", + "cmd.field.width": "Breite", + "cmd.field.height": "Höhe", + "cmd.field.radius": "Radius", + "cmd.field.distance": "Distanz", + + // ── Befehle: Import / Gelände ──────────────────────────────────────────── + "cmd.import.label": "Importieren", + "cmd.import.prompt": "DXF-Datei wählen …", + "cmd.terrain.label": "Gelände", + "cmd.terrain.prompt": "Gelände aus Konturen erzeugen …", + + // ── Site-/Kontext-Panel (SitePanel) ────────────────────────────────────── + "nav.site": "Gelände", + "site.title": "Gelände & Kontext", + "site.import": "⤓ DXF importieren", + "site.generateTerrain": "Gelände erzeugen", + "site.contourSet": "Kontur-Satz", + "site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).", + "site.delete": "Entfernen", + "site.type.mesh": "Mesh", + "site.type.contours": "Konturen", + "site.type.terrain": "Gelände", + "site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.", + "site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).", + + // ── Import-Dialog (ImportDialog) ───────────────────────────────────────── + "import.title": "DXF importieren", + "import.close": "Schließen", + "import.file": "Datei", + "import.summary.heading": "Inhalt", + "import.summary.contours": "{n} Konturen", + "import.summary.meshes": "{n} Meshes", + "import.summary.layers": "Layer: {layers}", + "import.summary.noLayers": "keine Layer-Namen", + "import.summary.empty": "Keine importierbare 2D-Geometrie gefunden.", + "import.target.heading": "Ziel-Zeichnungsebene", + "import.target.existing": "In bestehende Zeichnungsebene importieren", + "import.target.new": "Neue Zeichnung anlegen", + "import.target.newNamePlaceholder": "Name der neuen Zeichnung", + "import.layers.heading": "Ebenen-Handling (DXF-Layer)", + "import.layers.asCategories": "DXF-Layer als Kategorien übernehmen", + "import.layers.toActive": "Alles auf die aktive Kategorie ({code})", + "import.mesh.heading": "Meshes", + "import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren", + "import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.", + "import.dwg.heading": "DWG-Datei", + "import.dwg.loading": "DWG wird gelesen …", + "import.dwg.hint": + "DWG konnte nicht gelesen werden. Wandle die Datei alternativ mit dem gratis ODA File Converter in DXF um und importiere die DXF-Datei.", + "import.confirm": "Importieren", + "import.cancel": "Abbrechen", + "import.badge.floor": "Geschoss", + "import.badge.section": "Schnitt", + "import.badge.elevation": "Ansicht", + "import.badge.drawing": "Zeichnung", + + // ── Drag & Drop (App-weiter Datei-Drop) ────────────────────────────────── + "drop.hint": "DXF/DWG-Datei hier ablegen", + + // ── Fehler-Boundary ────────────────────────────────────────────────────── + "error.console": "Unerwarteter Fehler:", + "error.title": "Etwas ist schiefgelaufen", + "error.body": "Ein unerwarteter Fehler ist aufgetreten. Details in der Konsole.", + "error.reload": "Neu laden", +} as const; + +export type TranslationKey = keyof typeof de; diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..18f351d --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,490 @@ +// English dictionary — proves translatability. Same keys as de.ts; only the +// values differ. Identifiers/keys stay English (DOSSIER namespacing); values are +// the translated, user-visible text. + +import type { TranslationKey } from "./de"; + +export const en: Record = { + // Brand / Topbar. + "brand.tag": "Document model", + + "topbar.viewGroup": "View", + "topbar.grundriss": "Plan", + "topbar.perspektive": "Perspective", + + "view3d.group": "3D view", + "view3d.front": "Front", + "view3d.front.hint": "Front view (frontal)", + "view3d.top": "Top", + "view3d.top.hint": "Top view (straight down)", + "view3d.side": "Side", + "view3d.side.hint": "Side view (90° to front)", + "view3d.perspective": "Perspective", + "view3d.perspective.hint": "Free perspective (3/4 view)", + "view3d.iso": "Isometric", + "view3d.iso.hint": "Isometric view (equiangular from front-top-right)", + "view3d.camera": "Camera", + "view3d.camera.hint": "Camera settings (field of view)", + "view3d.fov": "Field of view", + "view3d.fov.hint": "Field of view (FOV) of the perspective camera in degrees", + + "topbar.detailGroup": "Detail level", + "topbar.detail": "Detail level", + "topbar.detail.hint": "Detail level of the plan (symbols, hatches, lines)", + "topbar.detail.hintDisabled": "Detail level applies in the plan (not yet in perspective)", + "topbar.detail.grob": "coarse", + "topbar.detail.mittel": "medium", + "topbar.detail.fein": "fine", + + "topbar.scaleGroup": "Scale and zoom", + "topbar.scale": "Scale", + "topbar.scale.apply": "Apply paper scale 1:N", + "topbar.scale.custom": "custom …", + "topbar.scale.live": "Current paper scale (live)", + "topbar.scale.prompt": "Enter scale (e.g. 1:35 or 35):", + "topbar.zoom100": "100%", + "topbar.zoom100.hint": "Zoom exactly to the chosen scale 1:{n}", + "topbar.fit": "Fit", + "topbar.fit.hint": "Fit to plan", + "topbar.fitSelection": "Selection", + "topbar.fitSelection.hint": "Fit to selection (otherwise to plan)", + "topbar.zoom.current": "Current zoom", + + "topbar.renderGroup": "Render mode", + "topbar.render.shaded": "Shaded", + "topbar.render.shaded.hint": "Shaded", + "topbar.render.wireframe": "Wire", + "topbar.render.wireframe.hint": "Wireframe", + "topbar.render.hidden": "Edges", + "topbar.render.hidden.hint": "Hidden line", + "topbar.render.hintDisabled": "Render mode applies in perspective", + // Display style (context-dependent dropdown): plan = Color/Mono, + // perspective = Shaded/White/Wireframe/Hidden. + "display.style": "Display", + "display.style.group": "Display style", + "display.style.hint": "Display style of the active view", + "display.style.color": "Color", + "display.style.mono": "Black & white", + "display.style.shaded": "Shaded", + "display.style.white": "White", + "display.style.wireframe": "Wireframe", + "display.style.hidden": "Hidden line", + + "topbar.referenceLinesGroup": "Reference lines", + "topbar.referenceLines": "Reference lines", + "topbar.referenceLines.hint": "Show/hide reference lines (wall axes)", + "topbar.referenceLines.hintDisabled": "Reference lines apply in the plan", + + "topbar.resources": "Resources", + "topbar.resources.hint": "Show/hide the resources window", + + // Layout menu. + "layout.title": "Layout", + "layout.placeholder": "Layout …", + "layout.saveCurrent": "+ Save current …", + "layout.loadGroup": "Load", + "layout.deleteGroup": "Delete", + "layout.savePrompt": "Save layout as:", + + // Visibility combinations (topbar). + "combo.layers": "Layer comb.", + "combo.drawings": "Drawing comb.", + "combo.placeholder": "Combination …", + "combo.saveCurrent": "+ Save current …", + "combo.loadGroup": "Load", + "combo.deleteGroup": "Delete", + "combo.layers.title": "Layer combinations", + "combo.drawings.title": "Drawing combinations", + "combo.layers.savePrompt": "Save layer combination as:", + "combo.drawings.savePrompt": "Save drawing combination as:", + + // Drawing tools (topbar toolbar + status hints). + "tool.group": "Tools", + "tool.select": "Select", + "tool.wall": "Wall", + "tool.line": "Line", + "tool.polyline": "Polyline", + "tool.rect": "Rectangle", + "tool.select.hint": "Select — click/drag elements", + "tool.wall.firstPoint": "Wall: set first axis point", + "tool.wall.nextPoint": "Wall: next point — double/right-click to finish", + "tool.line.firstPoint": "Line: set start point", + "tool.line.secondPoint": "Line: set end point", + "tool.polyline.firstPoint": "Polyline: set first point", + "tool.polyline.nextPoint": "Polyline: next point — click start to close, double-click to finish", + "tool.rect.firstCorner": "Rectangle: set first corner", + "tool.rect.secondCorner": "Rectangle: set opposite corner", + "tool.wallType": "Wall type", + "tool.layer": "Layer", + "tool.layer.hint": "Active layer — everything drawn goes onto this category", + "tool.floorOnlyDisabled": "Walls can only be drawn on a floor", + "tool.snap": "Snap", + "tool.snap.hint": "Snap to endpoints/grid; Shift = ortho, Ctrl = snap off", + "snap.enabled": "Snap on", + "snap.endpoint": "Endpoint", + "snap.midpoint": "Midpoint", + "snap.intersection": "Intersection", + "snap.onEdge": "On edge (perp.)", + "snap.grid": "Grid", + "snap.ortho": "Ortho (Shift)", + "snap.gridSize": "Grid size (m)", + "snap.angleStep": "Angle step", + + // Transform (move/mirror/rotate) + mode bar. + "transform.bar": "Transform", + "transform.cancel": "Cancel (Esc)", + "transform.countPrompt": "Number of copies:", + "transform.move": "Move (M)", + "transform.mirror": "Mirror (S)", + "transform.rotate": "Rotate (D)", + "transform.mode.move": "Move", + "transform.mode.copy": "Copy", + "transform.mode.array": "N copies", + "transform.mode.distribute": "Distribute", + "transform.hint.move.0": "Click base point", + "transform.hint.move.1": "Click destination point", + "transform.hint.rotate.0": "Click rotation centre", + "transform.hint.rotate.1": "Click reference point (start angle)", + "transform.hint.rotate.2": "Click target angle", + "transform.hint.mirror.0": "First point of mirror axis", + "transform.hint.mirror.1": "Second point of mirror axis", + + // Line display (Display/Print). + "topbar.lineModeGroup": "Line display", + "topbar.lineMode.display": "Display", + "topbar.lineMode.print": "Print", + "topbar.lineMode.display.hint": "Display: all lines as constant hairlines (calm editing)", + "topbar.lineMode.print.hint": "Print: real mm line weights (paper/print look)", + + // Status bar. + "status.selection": "Selection", + "status.selectionN": "Selection: {n} {noun}", + "status.wall": "wall", + "status.walls": "walls", + "status.xy": "X/Y", + "status.unit": "Unit", + "status.scale": "Scale", + "status.zoom": "Zoom", + "status.floor": "Floor", + "status.layer": "Layer", + "status.none": "—", + + // Navigator panels. + "nav.drawingLevels": "Drawing levels", + "nav.layers": "Layers", + "nav.tools": "Tools", + "nav.attributes": "Attributes", + "nav.objectInfo": "Object Info", + + // Attribute-Palette (AttributesPanel) + "attr.title": "Attributes", + "attr.empty": "No selection", + "attr.stroke": "Pen", + "attr.color": "Color", + "attr.weight": "Line weight (mm)", + "attr.lineStyle": "Line style", + "attr.fill": "Fill", + "attr.fillColor": "Fill color", + "attr.clearFill": "Remove fill color", + "attr.hatch": "Hatch", + "attr.none": "none", + "attr.inheritedFromLayer": "Walls inherit line weight from the layer", + "attr.fillNeedsClosed": "Fill only for closed shapes", + "attr.size": "Size", + "attr.width": "Width", + "attr.height": "Height", + + // Objekt-Info-Palette (ObjectInfoPanel) + "objinfo.title": "Object Info", + "objinfo.empty": "No object selected", + "objinfo.refpoint": "Reference point", + "objinfo.setRefpoint": "Set reference point", + "objinfo.x": "X", + "objinfo.y": "Y", + "objinfo.z": "Z", + "objinfo.zHint": "2D layer – elements have no height", + "objinfo.dimensions": "Dimensions", + "objinfo.width": "Width", + "objinfo.height": "Height", + "objinfo.type": "Type", + "objinfo.kind.wall": "Wall", + "objinfo.kind.drawing2d": "2D object", + "objinfo.kind.door": "Door", + // ── Wall attributes (object info panel) ────────────────────────────────── + "objinfo.wall.section": "Wall", + "objinfo.wall.refLine": "Reference line", + "objinfo.wall.refLine.left": "Exterior (left)", + "objinfo.wall.refLine.center": "Center", + "objinfo.wall.refLine.right": "Interior (right)", + "objinfo.wall.buildup": "Build-up", + "objinfo.wall.single": "Single layer", + "objinfo.wall.multi": "Multi layer", + "objinfo.wall.thickness": "Thickness", + "objinfo.wall.preset": "Wall style", + "objinfo.wall.presetLabel": "{name} · {thickness} m", + "objinfo.wall.refFloor": "Reference floor", + "objinfo.wall.linkAbove": "Bind to floor above", + "objinfo.wall.bottom": "Bottom (UK)", + "objinfo.wall.top": "Top (OK)", + "objinfo.wall.anchorFloor": "Bound to floor", + "objinfo.wall.anchorCustom": "Custom height", + "objinfo.wall.height": "Height", + "objinfo.wall.floorAboveNone": "No floor above", + "levels.addFloor": "+ Floor", + "levels.addDrawing": "+ Drawing", + "layers.addCategory": "+ Layer", + "nav.visibility": "Visibility", + "nav.hide": "Hide", + "nav.show": "Show", + "nav.categoryVisible": "{name} visible", + "nav.categorySelect": "Make {name} the active layer (drawing target)", + + // Badges. + "badge.floor": "Floor", + "badge.section": "Section", + "badge.elevation": "Elevation", + "badge.drawing": "Drawing", + + // Display mode select. + "display.title": "Display mode", + "display.all_force": "Show all", + "display.all": "Selected", + "display.active": "Active only", + "display.grey": "Others grey", + "display.grey_locked": "Others grey & locked", + + // Dock. + "dock.resize": "Adjust width", + "dock.dock": "Dock", + "dock.close": "Close", + "dock.float": "Float", + "dock.redock": "Dock", + "dock.resizePanel": "Resize", + "dock.dropLeft": "Dock left", + "dock.dropRight": "Dock right", + + // Context menu. + "ctx.layerSettings": "Layer settings…", + "ctx.addSubLayer": "Add sub-layer…", + "ctx.assignSelection": "Assign selection here", + "ctx.assignSelection.disabled": "No wall selected — first click a wall in the plan/3D.", + "ctx.duplicate": "Duplicate", + "ctx.copyProps": "Copy properties", + "ctx.pasteProps": "Paste properties", + "ctx.pasteProps.disabled": "Clipboard empty — first use “Copy properties”.", + "ctx.delete": "Delete", + "ctx.delete.lastLayer": "The last layer cannot be deleted.", + "ctx.delete.lastLevel": "The last drawing level cannot be deleted.", + "ctx.levelSettings": "Settings…", + "ctx.fitSelection": "Fit to selection", + "ctx.clearSelection": "Clear selection", + "ctx.fit": "Fit view", + "ctx.noWallHit": "No wall hit", + "ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.", + + "ctx.title.level": "Drawing level", + "ctx.title.wall": "Wall", + "ctx.title.view": "View", + + // Inline editor. + "editor.close": "Close", + "editor.layerTitle": "Layer {code}", + "editor.name": "Name", + "editor.color": "Color", + "editor.lineWeight": "Line weight", + "editor.floorHeight": "Floor height", + "editor.cutHeight": "Cut height", + "editor.unit.mm": "mm", + "editor.unit.m": "m", + "editor.noHeightDrawing": "Drawings have no floor/cut height.", + "editor.noHeightSection": "Sections/elevations have no floor/cut height.", + + // Main content. + "content.perspektiveTitle": "Perspective · {name}", + "content.grundrissTitle": "Plan · {name}", + "content.grundrissLegend": "Plan · {name} · Cut height {cutHeight}", + "content.drawingTitle": "Drawing · {name}", + "content.drawingCanvas": "Drawing canvas", + "content.drawingLegend": "Free 2D drawing", + "props.floorHeight": "Floor height", + "props.cutHeight": "Cut height", + "props.okff": "FFL", + "props.okffValue": "+{value} m", + + // Stub. + "stub.section": "Section", + "stub.elevation": "Elevation", + "stub.note": "{kind} — derived projection follows in a later phase", + + // Default names. + "default.floorName": "Floor {n}", + "default.drawingName": "Drawing {n}", + "default.categoryName": "New layer", + "default.subCategoryName": "New sub-layer", + "default.componentName": "New component", + "default.hatchName": "New hatch", + "default.lineStyleName": "New line style", + "default.hatchNone": "None", + "default.copySuffixLevel": "{name} copy", + "default.copySuffixLayer": "{name} COPY", + "default.wallTypeSingle": "Wall {thickness} m", + + // Alerts. + "alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.", + "alert.componentInUse": "Component “{name}” is used by a wall type and cannot be deleted.", + "alert.hatchInUse": "Hatch “{name}” is used by a component and cannot be deleted.", + "alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.", + + // Resource manager. + "resources.title": "Resources", + "resources.label": "Resource manager", + "resources.close": "Close", + "resources.tab.components": "Components", + "resources.tab.hatches": "Hatches", + "resources.tab.lines": "Lines", + "resources.add": "New", + "resources.import.lin": "⤓ Import .lin", + "resources.import.pat": "⤓ Import .pat", + + "resources.components.hint": "Higher priority runs through at the join (intersects first); lower butts against the side.", + "resources.components.empty": "No components yet.", + "resources.components.placeholder": "Component", + "resources.col.name": "Name", + "resources.col.color": "Color", + "resources.col.hatch": "Hatch", + "resources.col.prio": "Prio", + "resources.col.prio.hint": "Higher priority runs through at the join", + "resources.col.texture": "Texture", + "resources.delete.component": "Delete component “{name}”", + + "resources.hatches.hint": "Pattern, scale and angle drive the section hatch; the line style sets the weight of the pattern lines.", + "resources.hatches.empty": "No hatches yet.", + "resources.hatches.placeholder": "Hatch", + "resources.col.pattern": "Pattern", + "resources.col.scale": "Scale", + "resources.col.angle": "Angle °", + "resources.col.lineStyle": "Line style", + "resources.lineStyle.none": "— none —", + "resources.delete.hatch": "Delete hatch “{name}”", + + "pattern.none": "None", + "pattern.solid": "Solid", + "pattern.insulation": "Insulation", + "pattern.diagonal": "Diagonal", + "pattern.crosshatch": "Crosshatch", + + "resources.lines.hint": "Line weight in millimetres (paper space). Stroke: solid, dashed or dotted.", + "resources.lines.empty": "No line styles yet.", + "resources.lines.placeholder": "Line style", + "resources.col.weight": "Weight (mm)", + "resources.col.stroke": "Stroke", + "resources.col.preview": "Preview", + "resources.delete.lineStyle": "Delete line style “{name}”", + "resources.texture.empty": "—", + + "dash.solid": "Solid", + "dash.dashed": "Dashed", + "dash.dotted": "Dotted", + + // Command line / commands (Rhino-style command system). + "cmd.aria": "Command line", + "cmd.idle.prompt": "Command:", + "cmd.idle.placeholder": "Type a command … (Tab)", + "cmd.line.label": "Line", + "cmd.line.start": "Start of line:", + "cmd.line.end": "End of line:", + "cmd.polyline.label": "Polyline", + "cmd.polyline.start": "Start point:", + "cmd.polyline.next": "Next point:", + "cmd.polyline.close": "Close", + "cmd.polyline.undo": "Undo", + "cmd.rect.label": "Rectangle", + "cmd.rect.first": "First corner:", + "cmd.rect.second": "Opposite corner:", + "cmd.circle.label": "Circle", + "cmd.circle.center": "Center:", + "cmd.circle.radius": "Radius:", + "cmd.move.label": "Move", + "cmd.move.base": "Base point:", + "cmd.move.target": "Target point:", + "cmd.move.empty": "Nothing selected (select objects first).", + "cmd.copy.label": "Copy", + "cmd.copy.base": "Base point:", + "cmd.copy.target": "Target point (Enter to finish):", + "cmd.copy.empty": "Nothing selected (select objects first).", + "cmd.offset.label": "Offset", + "cmd.offset.pick": "Select curve:", + "cmd.offset.side": "Side or distance:", + // Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J / + // Alt+Click). Labels for the command line / menus. + "edit.split.label": "Split", + "edit.join.label": "Join", + "edit.cutSegment.label": "Delete segment", + // Tab field cycle (precision input): ordered numeric fields per step. + "cmd.field.length": "Length", + "cmd.field.angle": "Angle", + "cmd.field.width": "Width", + "cmd.field.height": "Height", + "cmd.field.radius": "Radius", + "cmd.field.distance": "Distance", + + // Commands: import / terrain. + "cmd.import.label": "Import", + "cmd.import.prompt": "Choose DXF file …", + "cmd.terrain.label": "Terrain", + "cmd.terrain.prompt": "Generate terrain from contours …", + + // Site / context panel (SitePanel). + "nav.site": "Terrain", + "site.title": "Terrain & Context", + "site.import": "⤓ Import DXF", + "site.generateTerrain": "Generate terrain", + "site.contourSet": "Contour set", + "site.empty": "No context loaded. Import a DXF file (contour lines or mesh).", + "site.delete": "Remove", + "site.type.mesh": "Mesh", + "site.type.contours": "Contours", + "site.type.terrain": "Terrain", + "site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.", + "site.terrainFailed": "No terrain could be generated (too few/collinear contour points).", + + // Import dialog. + "import.title": "Import DXF", + "import.close": "Close", + "import.file": "File", + "import.summary.heading": "Contents", + "import.summary.contours": "{n} contours", + "import.summary.meshes": "{n} meshes", + "import.summary.layers": "Layers: {layers}", + "import.summary.noLayers": "no layer names", + "import.summary.empty": "No importable 2D geometry found.", + "import.target.heading": "Target drawing level", + "import.target.existing": "Import into existing drawing level", + "import.target.new": "Create new drawing", + "import.target.newNamePlaceholder": "New drawing name", + "import.layers.heading": "Layer handling (DXF layers)", + "import.layers.asCategories": "Keep DXF layers as categories", + "import.layers.toActive": "Everything onto the active category ({code})", + "import.mesh.heading": "Meshes", + "import.mesh.asContext": "Import meshes as context geometry (3D)", + "import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.", + "import.dwg.heading": "DWG file", + "import.dwg.loading": "Reading DWG …", + "import.dwg.hint": + "DWG could not be read. Alternatively, convert the file to DXF with the free ODA File Converter, then import the DXF.", + "import.confirm": "Import", + "import.cancel": "Cancel", + "import.badge.floor": "Floor", + "import.badge.section": "Section", + "import.badge.elevation": "Elevation", + "import.badge.drawing": "Drawing", + + // Drag & drop (app-wide file drop). + "drop.hint": "Drop a DXF/DWG file here", + + // Error boundary. + "error.console": "Unexpected error:", + "error.title": "Something went wrong", + "error.body": "An unexpected error occurred. Details in the console.", + "error.reload": "Reload", +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..44ae431 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,94 @@ +// i18n-Schicht — eine einzige Übersetzungsfunktion `t(key, params?)` plus ein +// optionaler React-Hook `useT`. Default-Sprache ist Deutsch; das deutsche +// Wörterbuch (de.ts) hält die EXAKTEN bisherigen UI-Texte, sodass die Umstellung +// optisch nichts ändert. en.ts beweist die Übersetzbarkeit. +// +// Bewusst minimal gehalten: ein modulweiter „current language" + Wörterbuch- +// Lookup mit {param}-Interpolation. Kein Provider-Zwang — t(...) ist überall +// direkt aufrufbar; useT() liefert dieselbe Funktion und re-rendert Komponenten +// beim Sprachwechsel (subscribe), falls je ein Umschalter gebaut wird. +// +// Identifier/Keys bleiben englisch (DOSSIER-Namespacing); nur die Wörterbuch- +// Werte sind übersetzbarer Text. + +import { useSyncExternalStore } from "react"; +import { de } from "./de"; +import { en } from "./en"; +import type { TranslationKey } from "./de"; + +export type { TranslationKey } from "./de"; + +/** Unterstützte Sprachen. Default ist „de". */ +export type Language = "de" | "en"; + +/** Wörterbücher je Sprache. de ist die Quelle der Keys (Default-Sprache). */ +const DICTS: Record> = { de, en }; + +/** Werte für die Platzhalter-Interpolation (`{name}` → params.name). */ +export type TParams = Record; + +// ── Modulweiter Sprachzustand + Abonnenten (für useT) ────────────────────── + +let currentLanguage: Language = "de"; +const listeners = new Set<() => void>(); + +/** Aktuell gewählte Sprache. */ +export function getLanguage(): Language { + return currentLanguage; +} + +/** + * Sprache umschalten. Benachrichtigt abonnierte Komponenten (useT), damit sie + * mit den neuen Texten neu rendern. (Noch kein UI-Umschalter — die Naht ist + * aber vorhanden und verdrahtet.) + */ +export function setLanguage(lang: Language): void { + if (lang === currentLanguage) return; + currentLanguage = lang; + for (const l of listeners) l(); +} + +// ── Kern: Lookup + Interpolation ─────────────────────────────────────────── + +/** Ersetzt `{name}`-Platzhalter im Wert durch die übergebenen Parameter. */ +function interpolate(value: string, params?: TParams): string { + if (!params) return value; + return value.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); +} + +/** + * Übersetzt einen Key in der aktuellen Sprache mit optionaler {param}- + * Interpolation. Fehlt der Key in der gewählten Sprache, greift Deutsch als + * Fallback; fehlt er auch dort, wird der Key selbst zurückgegeben (sichtbar, + * damit fehlende Texte auffallen statt leer zu bleiben). + */ +export function t(key: TranslationKey | string, params?: TParams): string { + const dict = DICTS[currentLanguage]; + const value = + (dict as Record)[key] ?? + (de as Record)[key] ?? + key; + return interpolate(value, params); +} + +// ── React-Hook ───────────────────────────────────────────────────────────── + +/** + * Hook-Variante: liefert dieselbe `t`-Funktion, abonniert aber den + * Sprachzustand, sodass die Komponente beim Sprachwechsel neu rendert. In den + * meisten Komponenten reicht der direkte `t`-Import; useT() ist für den Fall + * gedacht, dass ein Live-Sprachumschalter existiert. + */ +export function useT(): typeof t { + useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + getLanguage, + getLanguage, + ); + return t; +} diff --git a/src/io/dwgParser.ts b/src/io/dwgParser.ts new file mode 100644 index 0000000..13cac94 --- /dev/null +++ b/src/io/dwgParser.ts @@ -0,0 +1,353 @@ +// DWG-Import → Kontext-Geometrie (three-frei), echtes In-Browser-Parsing. +// +// DWG ist ein geschlossenes Binärformat; wir lesen es hier über LibreDWG-WASM +// (`@mlightcad/libredwg-web`) DIREKT im Browser — KEIN ODA-Zwischenschritt mehr. +// Ergebnis ist dasselbe `{meshes, contours}`-Format wie der DXF-Parser, damit der +// gesamte nachgelagerte Import-Fluss (Dialog, Ziel-Ebene, Layer-Handling, +// Mesh→Kontext) unverändert wiederverwendet wird. +// +// API (aus den .d.ts der Lib abgelesen, NICHT geraten): +// LibreDwg.create() → Promise (lädt WASM) +// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer +// dwg.convert(ptr) → DwgDatabase (stark typisiert) +// dwg.dwg_free(ptr) → Speicher freigeben +// Der WASM-Build dieser Lib hat DXF-Schreiben/-Lesen DEAKTIVIERT (disable-dxf), +// daher gehen wir bewusst NICHT über DWG→DXF-Text, sondern mappen die geparste +// DwgDatabase direkt (ein Mapping, gespiegelt aus dxfParser). +// +// Unterstützte Entities (Modellraum): +// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri). +// • POLYLINE2D (Polyface, Flag 64) → Dreiecks-Mesh (Polyface-Faces). +// • LWPOLYLINE / POLYLINE2D / 3D → Kontur (z aus elevation/Vertex-Z). +// • LINE → Kontur (zwei-Punkt-Linienzug). +// +// Lazy: Das Paket wird per dynamischem import() geladen (kein Bloat im Initial- +// Bundle); WASM startet erst beim ersten DWG-Import. +// +// Bezeichner englisch, Kommentare deutsch (CLAUDE.md). + +import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; +import type { DxfImportResult } from "./dxfParser"; + +// Lose getippte Sicht auf die libredwg-web-Entities (wir greifen tolerant auf die +// Felder zu, die wir brauchen; alle Punkte sind {x,y,z}). +interface DwgPoint { + x: number; + y: number; + z?: number; +} +// Polyface-/Polyline-Vertex: Punktkoordinaten + optionale Polyface-Indizes. +interface DwgVertexLike extends DwgPoint { + polyfaceIndex0?: number; + polyfaceIndex1?: number; + polyfaceIndex2?: number; + polyfaceIndex3?: number; + flag?: number; +} +interface DwgEntityLike { + type?: string; + layer?: string; + flag?: number; + elevation?: number; + startPoint?: DwgPoint; + endPoint?: DwgPoint; + corner1?: DwgPoint; + corner2?: DwgPoint; + corner3?: DwgPoint; + corner4?: DwgPoint; + vertices?: DwgVertexLike[]; + [k: string]: unknown; +} + +// Schmale Sicht auf die LibreDWG-Instanz (nur die Methoden, die wir nutzen). +interface LibreDwgApi { + dwg_read_data(data: ArrayBuffer, fileType: number): number | undefined; + convert(ptr: number): { entities?: unknown[] }; + dwg_free(ptr: number): void; +} + +// Singleton der WASM-Instanz (einmal laden, dann wiederverwenden). +let libredwgPromise: Promise | null = null; + +/** + * Lädt + initialisiert LibreDWG-WASM (lazy, einmalig). Wir laden die Emscripten- + * Glue + die `.wasm`-URL explizit über Vite (`?url`), damit die WASM-Datei in + * Dev UND Build mit korrektem MIME-Type aufgelöst wird (sonst liefert der Dev- + * Server `index.html` statt der WASM → „Incorrect response MIME type"). + */ +function createLibreDwg(): Promise { + if (!libredwgPromise) { + libredwgPromise = (async () => { + const [{ LibreDwg }, glue, wasmUrlMod] = await Promise.all([ + import("@mlightcad/libredwg-web"), + // Glue + WASM über ein lokales virtuelles Modul (Alias in vite.config), + // da das Paket seine `wasm/`-Dateien nicht über `exports` freigibt. + import("virtual:libredwg-glue"), + // Vite gibt die WASM als gehashtes Asset aus und liefert ihre URL. + import("virtual:libredwg-wasm-url"), + ]); + const createModule = glue.default; + const wasmUrl = wasmUrlMod.default; + const wasmInstance = await createModule({ + // locateFile bekommt den Original-Dateinamen; wir liefern die von Vite + // aufgelöste (gehashte) URL zurück → korrektes Laden in Dev + Build. + locateFile: () => wasmUrl, + }); + // createByWasmInstance erwartet das (intern getippte) MainModule; die + // Glue liefert es als unknown → bewusster Cast, Rückgabe schmal getippt. + return LibreDwg.createByWasmInstance( + wasmInstance as Parameters[0], + ) as unknown as LibreDwgApi; + })(); + } + return libredwgPromise; +} + +let importSeq = 0; +const nextId = (prefix: string): string => `${prefix}-${Date.now()}-${importSeq++}`; + +// POLYLINE-Flag 64 = Polyface-Mesh (siehe DwgPolylineFlag in der Lib). +const POLYFACE_FLAG = 64; +// POLYLINE-Flag 1 = geschlossen. +const CLOSED_FLAG = 1; + +/** + * Parst eine DWG-Datei (als ArrayBuffer) in dasselbe Kontext-Geometrie-Format + * wie der DXF-Parser. Lädt LibreDWG-WASM lazy. Wirft bei fatalen Fehlern (der + * Aufrufer fängt das ab und zeigt den ODA-Fallback) — pro-Entity-Fehler nicht. + */ +export async function parseDwg(data: ArrayBuffer): Promise { + const libredwg = await createLibreDwg(); + + let dwgPtr: number | undefined; + try { + // Dwg_File_Type.DWG === 0 (siehe enums.d.ts der Lib). Literal, damit wir den + // Enum-Wert nicht zusätzlich in den lazy Chunk ziehen. + const FILE_TYPE_DWG = 0; + dwgPtr = libredwg.dwg_read_data(data, FILE_TYPE_DWG); + if (dwgPtr == null) { + throw new Error("LibreDWG: dwg_read_data lieferte keinen Pointer."); + } + const db = libredwg.convert(dwgPtr); + const entities = (db?.entities ?? []) as unknown as DwgEntityLike[]; + + const meshTriangles: number[] = []; + const meshIndices: number[] = []; + const contours: Contour[] = []; + + for (const e of entities) { + const type = (e.type ?? "").toUpperCase(); + switch (type) { + case "3DFACE": + addFace(meshTriangles, meshIndices, e); + break; + case "POLYLINE2D": + case "POLYLINE3D": + if (isPolyfaceMesh(e)) { + addPolyfaceMesh(meshTriangles, meshIndices, e); + } else { + const ct = polylineContour(e); + if (ct) contours.push(ct); + } + break; + case "LWPOLYLINE": { + const ct = polylineContour(e); + if (ct) contours.push(ct); + break; + } + case "LINE": { + const ct = lineContour(e); + if (ct) contours.push(ct); + break; + } + default: + // Unbekannte/irrelevante Entity → ignorieren (tolerant). + break; + } + } + + const meshes: ImportedMesh[] = []; + if (meshIndices.length > 0) { + meshes.push({ + id: nextId("imported"), + type: "importedMesh", + name: "DWG-Mesh", + positions: meshTriangles, + indices: meshIndices, + }); + } + + const contourSets: ContourSet[] = []; + if (contours.length > 0) { + contourSets.push({ + id: nextId("contours"), + type: "contourSet", + name: "DWG-Konturen", + contours, + }); + } + + return { meshes, contours: contourSets }; + } finally { + // Speicher der WASM-Instanz freigeben (auch im Fehlerfall). + if (dwgPtr != null) { + try { + libredwg.dwg_free(dwgPtr); + } catch { + // Freigabe-Fehler ignorieren — nicht fatal. + } + } + } +} + +// ── Mesh-Entities ──────────────────────────────────────────────────────────── + +/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */ +function pushVertex( + positions: number[], + x: number, + y: number, + z: number, +): number { + const idx = positions.length / 3; + positions.push(x, y, z); + return idx; +} + +/** + * 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan). + */ +function addFace( + positions: number[], + indices: number[], + e: DwgEntityLike, +): void { + const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter( + (c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y), + ); + if (corners.length < 3) return; + const idx = corners.map((c) => + pushVertex(positions, c.x, c.y, c.z ?? 0), + ); + pushTri(indices, positions, idx[0], idx[1], idx[2]); + if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]); +} + +/** Ob eine POLYLINE eine Polyface-Mesh-Variante ist (Flag 64 oder Polyface-Indizes). */ +function isPolyfaceMesh(e: DwgEntityLike): boolean { + if (((e.flag ?? 0) & POLYFACE_FLAG) !== 0) return true; + return (e.vertices ?? []).some( + (v) => + Number.isFinite(v.polyfaceIndex0) || + Number.isFinite(v.polyfaceIndex1) || + Number.isFinite(v.polyfaceIndex2) || + Number.isFinite(v.polyfaceIndex3), + ); +} + +/** + * Polyface-Mesh (POLYLINE, Flag 64): Geometrie-Vertices (mit Koordinaten, keine + * Polyface-Indizes) und Face-Records (mit 1-basierten, ggf. negativen = unsicht- + * bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face. + */ +function addPolyfaceMesh( + positions: number[], + indices: number[], + e: DwgEntityLike, +): void { + const all = e.vertices ?? []; + const hasFaceIdx = (v: DwgVertexLike): boolean => + !!( + v.polyfaceIndex0 || + v.polyfaceIndex1 || + v.polyfaceIndex2 || + v.polyfaceIndex3 + ); + const geom = all.filter((v) => !hasFaceIdx(v)); + const faceRecs = all.filter((v) => hasFaceIdx(v)); + if (geom.length < 3) return; + const base = positions.length / 3; + for (const v of geom) positions.push(v.x, v.y, v.z ?? 0); + for (const fr of faceRecs) { + // 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs(). + const ring = [ + fr.polyfaceIndex0, + fr.polyfaceIndex1, + fr.polyfaceIndex2, + fr.polyfaceIndex3, + ] + .map((i) => Math.abs(i ?? 0)) + .filter((i) => i >= 1) + .map((i) => base + (i - 1)); + if (ring.length < 3) continue; + for (let i = 1; i + 1 < ring.length; i++) { + pushTri(indices, positions, ring[0], ring[i], ring[i + 1]); + } + } +} + +/** + * Fügt ein Dreieck hinzu, sofern es nicht entartet ist (gleiche/ungültige + * Indizes). Hält das importierte Mesh sauber. + */ +function pushTri( + indices: number[], + positions: number[], + a: number, + b: number, + c: number, +): void { + if (a === b || b === c || a === c) return; + const n = positions.length / 3; + if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return; + indices.push(a, b, c); +} + +// ── Kontur-Entities ────────────────────────────────────────────────────────── + +/** + * LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger + * Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null. + */ +function polylineContour(e: DwgEntityLike): Contour | null { + const vs = e.vertices ?? []; + const pts: Vec2[] = []; + let zFromVertex: number | undefined; + for (const v of vs) { + if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue; + pts.push({ x: v.x, y: v.y }); + if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z; + } + if (pts.length < 2) return null; + const z = + zFromVertex ?? + (Number.isFinite(e.elevation) ? (e.elevation as number) : 0); + const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0; + return { z, pts, closed, layer: e.layer }; +} + +/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */ +function lineContour(e: DwgEntityLike): Contour | null { + const a = e.startPoint; + const b = e.endPoint; + if ( + !a || + !b || + !Number.isFinite(a.x) || + !Number.isFinite(a.y) || + !Number.isFinite(b.x) || + !Number.isFinite(b.y) + ) { + return null; + } + const z = Number.isFinite(a.z) ? (a.z as number) : 0; + return { + z, + pts: [ + { x: a.x, y: a.y }, + { x: b.x, y: b.y }, + ], + closed: false, + layer: e.layer, + }; +} diff --git a/src/io/dxfImportHook.ts b/src/io/dxfImportHook.ts new file mode 100644 index 0000000..919bfb5 --- /dev/null +++ b/src/io/dxfImportHook.ts @@ -0,0 +1,22 @@ +// Kleiner Modul-Hook, der das (in App montierte) versteckte DXF-Datei-Input +// mit dem `import`-Befehl entkoppelt verbindet. App registriert beim Mount eine +// Trigger-Funktion (öffnet den Datei-Dialog); der Befehl ruft sie auf, ohne die +// Command-Engine an React/DOM zu koppeln. So bleibt der Import-Befehl additiv. +// +// Bewusst KEIN window-Global: ein Modul-Singleton genügt (eine App-Instanz). +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +type DxfImportTrigger = () => void; + +let trigger: DxfImportTrigger | null = null; + +/** App registriert hier den Datei-Dialog-Öffner (oder hebt ihn beim Unmount auf). */ +export function setDxfImportTrigger(fn: DxfImportTrigger | null): void { + trigger = fn; +} + +/** Öffnet den DXF-Datei-Dialog, falls App ihn registriert hat. */ +export function openDxfImport(): void { + trigger?.(); +} diff --git a/src/io/dxfParser.ts b/src/io/dxfParser.ts new file mode 100644 index 0000000..1591d7e --- /dev/null +++ b/src/io/dxfParser.ts @@ -0,0 +1,295 @@ +// DXF-Import → Kontext-Geometrie (three-frei). +// +// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres +// Snap-Ziel), NICHT semantisch. Dieser Parser liefert nur rohe Buffer-Daten +// (positions/indices) bzw. Konturen (Vec2 + Z), die in `Project.context` landen. +// +// ── DWG-Hinweis (bewusst KEIN DWG-Parser) ────────────────────────────────── +// DWG ist ein geschlossenes Binärformat ohne robusten Pure-JS-Parser; wir bauen +// hier KEINEN DWG-Parser. Der Import-Weg führt über DXF: +// DWG → DXF konvertieren (gratis „ODA File Converter" von der Open Design +// Alliance), dann die DXF-Datei hier importieren. +// LibreDWG-WASM ist nicht zuverlässig genug und wird bewusst nicht verfolgt. +// +// Unterstützte DXF-Entities: +// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri). +// • POLYLINE (Polyface/PolygonMesh)→ Dreiecks-Mesh (Faces bzw. Gitter). +// • MESH → Dreiecks-Mesh (Vertices + Face-Liste). +// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z). +// • LINE → Kontur (zwei-Punkt-Linienzug). + +import DxfParser from "dxf-parser"; +import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; + +/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */ +export interface DxfImportResult { + meshes: ImportedMesh[]; + contours: ContourSet[]; +} + +// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen +// Typen); wir greifen tolerant auf die Felder zu, die wir brauchen. +interface DxfVertex { + x?: number; + y?: number; + z?: number; + // Polyface-Mesh: 1-basierte, ggf. negative Vertex-Indizes (negativ = unsichtbare Kante). + faces?: number[]; +} +interface DxfEntity { + type?: string; + layer?: string; + elevation?: number; + vertices?: DxfVertex[]; + // MESH-Entity-Felder (variieren je nach Parser-Version): + position?: DxfVertex[]; + faces?: number[][]; + isPolyfaceMesh?: boolean; + is3dPolygonMesh?: boolean; + [k: string]: unknown; +} +interface DxfDocument { + entities?: DxfEntity[]; +} + +let importSeq = 0; +const nextId = (prefix: string) => + `${prefix}-${Date.now()}-${importSeq++}`; + +/** + * Parst DXF-Text in Kontext-Geometrie. Tolerant gegen fehlende Felder; nicht + * unterstützte/leere Entities werden übersprungen. Ein fataler Parse-Fehler wird + * geworfen (der Aufrufer kann ihn anzeigen) — pro-Entity-Fehler nicht. + */ +export function parseDxf(text: string): DxfImportResult { + const parser = new DxfParser(); + // parseSync wirft bei strukturell kaputtem DXF; das soll nach oben. + const doc = parser.parseSync(text) as unknown as DxfDocument; + const entities = doc?.entities ?? []; + + const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…) + const meshIndices: number[] = []; + const contours: Contour[] = []; + + for (const e of entities) { + const type = (e.type ?? "").toUpperCase(); + switch (type) { + case "3DFACE": + addFace(meshTriangles, meshIndices, e); + break; + case "MESH": + addMesh(meshTriangles, meshIndices, e); + break; + case "POLYLINE": + // POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur. + if (isMeshPolyline(e)) { + addPolyfaceMesh(meshTriangles, meshIndices, e); + } else { + const ct = polylineContour(e); + if (ct) contours.push(ct); + } + break; + case "LWPOLYLINE": { + const ct = polylineContour(e); + if (ct) contours.push(ct); + break; + } + case "LINE": { + const ct = lineContour(e); + if (ct) contours.push(ct); + break; + } + default: + // Unbekannte/irrelevante Entity → ignorieren (tolerant). + break; + } + } + + const meshes: ImportedMesh[] = []; + if (meshIndices.length > 0) { + meshes.push({ + id: nextId("imported"), + type: "importedMesh", + name: "DXF-Mesh", + positions: meshTriangles, + indices: meshIndices, + }); + } + + const contourSets: ContourSet[] = []; + if (contours.length > 0) { + contourSets.push({ + id: nextId("contours"), + type: "contourSet", + name: "DXF-Konturen", + contours, + }); + } + + return { meshes, contours: contourSets }; +} + +// ── Mesh-Entities ──────────────────────────────────────────────────────────── + +/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */ +function pushVertex( + positions: number[], + x: number, + y: number, + z: number, +): number { + const idx = positions.length / 3; + positions.push(x, y, z); + return idx; +} + +/** + * 3DFACE: 3 oder 4 Eckpunkte. Bei 4 Punkten zwei Dreiecke (Fan). Entartete + * (degenerierte) Punkte (4. == 3.) ergeben nur ein Dreieck. + */ +function addFace(positions: number[], indices: number[], e: DxfEntity): void { + const vs = (e.vertices ?? []).filter( + (v) => Number.isFinite(v.x) && Number.isFinite(v.y), + ); + if (vs.length < 3) return; + const idx = vs.map((v) => + pushVertex(positions, v.x ?? 0, v.y ?? 0, v.z ?? 0), + ); + // Dreieck (0,1,2). + pushTri(indices, positions, idx[0], idx[1], idx[2]); + // Viereck → zweites Dreieck (0,2,3), falls 4. Punkt vorhanden und ≠ 3. + if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]); +} + +/** + * MESH-Entity: explizite Vertex-Liste + Face-Liste. Die Face-Liste kann Drei- + * oder Vierecke enthalten (jeweils 0-basierte Vertex-Indizes); Vierecke werden + * fan-trianguliert. + */ +function addMesh(positions: number[], indices: number[], e: DxfEntity): void { + const verts = e.position ?? e.vertices ?? []; + if (verts.length === 0) return; + const base = positions.length / 3; + for (const v of verts) { + positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0); + } + const faces = Array.isArray(e.faces) ? e.faces : []; + for (const face of faces) { + if (!Array.isArray(face) || face.length < 3) continue; + for (let i = 1; i + 1 < face.length; i++) { + pushTri( + indices, + positions, + base + face[0], + base + face[i], + base + face[i + 1], + ); + } + } +} + +/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */ +function isMeshPolyline(e: DxfEntity): boolean { + if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true; + // Heuristik: enthält irgendein Vertex eine `faces`-Liste → Polyface-Mesh. + return (e.vertices ?? []).some( + (v) => Array.isArray(v.faces) && v.faces.length > 0, + ); +} + +/** + * Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records + * mit 1-basierten (ggf. negativen = unsichtbare Kante) Vertex-Indizes. Wir + * trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face. + */ +function addPolyfaceMesh( + positions: number[], + indices: number[], + e: DxfEntity, +): void { + const all = e.vertices ?? []; + const geom = all.filter((v) => !(Array.isArray(v.faces) && v.faces.length)); + const faceRecs = all.filter((v) => Array.isArray(v.faces) && v.faces.length); + if (geom.length < 3) return; + const base = positions.length / 3; + for (const v of geom) { + positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0); + } + for (const fr of faceRecs) { + // 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs(). + const ring = (fr.faces ?? []) + .map((i) => Math.abs(i)) + .filter((i) => i >= 1) + .map((i) => base + (i - 1)); + if (ring.length < 3) continue; + for (let i = 1; i + 1 < ring.length; i++) { + pushTri(indices, positions, ring[0], ring[i], ring[i + 1]); + } + } +} + +/** + * Fügt ein Dreieck hinzu, sofern es nicht entartet ist (zwei gleiche Indizes + * oder kollineare/Null-Flächen-Ecken). Hält das importierte Mesh sauber. + */ +function pushTri( + indices: number[], + positions: number[], + a: number, + b: number, + c: number, +): void { + if (a === b || b === c || a === c) return; + const n = positions.length / 3; + if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return; + indices.push(a, b, c); +} + +// ── Kontur-Entities ────────────────────────────────────────────────────────── + +/** + * LWPOLYLINE / 2D-/3D-POLYLINE → Kontur. Z-Höhe: bei 3D-Polylinie aus den + * Vertex-Z (erster gültiger), sonst aus `elevation`, sonst 0. Die 2D-Punkte sind + * (x,y) jedes Vertex. Bei < 2 Punkten wird nichts erzeugt. + */ +function polylineContour(e: DxfEntity): Contour | null { + const vs = e.vertices ?? []; + const pts: Vec2[] = []; + let zFromVertex: number | undefined; + for (const v of vs) { + if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue; + pts.push({ x: v.x ?? 0, y: v.y ?? 0 }); + if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z; + } + if (pts.length < 2) return null; + const z = zFromVertex ?? (Number.isFinite(e.elevation) ? (e.elevation as number) : 0); + // closed-Flag aus dem Parser ableiten (LWPOLYLINE: `shape`, POLYLINE: `shape`). + const closed = e.shape === true || e.closed === true; + return { z, pts, closed, layer: e.layer }; +} + +/** LINE → zweipunktige (offene) Kontur. Z aus dem Start-Vertex (sonst 0). */ +function lineContour(e: DxfEntity): Contour | null { + const vs = e.vertices ?? []; + if (vs.length < 2) return null; + const a = vs[0]; + const b = vs[1]; + if ( + !Number.isFinite(a.x) || + !Number.isFinite(a.y) || + !Number.isFinite(b.x) || + !Number.isFinite(b.y) + ) { + return null; + } + const z = Number.isFinite(a.z) ? (a.z as number) : 0; + return { + z, + pts: [ + { x: a.x ?? 0, y: a.y ?? 0 }, + { x: b.x ?? 0, y: b.y ?? 0 }, + ], + closed: false, + layer: e.layer, + }; +} diff --git a/src/io/dxfToDrawings.ts b/src/io/dxfToDrawings.ts new file mode 100644 index 0000000..f828549 --- /dev/null +++ b/src/io/dxfToDrawings.ts @@ -0,0 +1,89 @@ +// DXF-Konturen → semantische 2D-Zeichengeometrie (Drawing2D). +// +// Der DXF-Parser liefert „dumme" Konturen (Vec2 + Z + optionaler Layer-Name). +// Beim Import in eine ZEICHNUNGSEBENE werden daraus echte Drawing2D-Elemente +// (line/polyline). Die Z-Höhe der Kontur wird in 2D bewusst IGNORIERT (eine +// Zeichnungsebene ist flach). Das `closed`-Flag steuert line vs. polyline: +// • genau zwei Punkte, nicht geschlossen → `line`. +// • sonst → `polyline` (mit `closed`-Flag). +// +// Kategorie-Zuordnung (categoryMode): +// • "active" — alles bekommt die aktive Kategorie (activeCategoryCode). +// • "byLayer" — der DXF-Layer-Name wird auf einen Kategorie-Code gemappt. +// `layerToCode` liefert (deterministisch) einen Code je +// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`. +// +// Bezeichner englisch, Kommentare deutsch (CLAUDE.md). + +import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types"; + +/** Wie DXF-Layer auf Kategorien abgebildet werden. */ +export type CategoryMode = "active" | "byLayer"; + +let drawingSeq = 0; +const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`; + +/** + * Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene + * `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den + * Kategorie-Code je DXF-Layer-Name; sonst bekommt alles `fallbackCode`. + */ +export function contoursToDrawings( + sets: ContourSet[], + levelId: string, + categoryMode: CategoryMode, + fallbackCode: string, + layerToCode: (layerName: string) => string, +): Drawing2D[] { + const out: Drawing2D[] = []; + for (const set of sets) { + for (const contour of set.contours) { + const geom = contourGeom(contour); + if (!geom) continue; + const layerName = contour.layer ?? set.layer ?? ""; + const categoryCode = + categoryMode === "byLayer" && layerName + ? layerToCode(layerName) + : fallbackCode; + out.push({ + id: nextId(), + type: "drawing2d", + levelId, + categoryCode, + geom, + }); + } + } + return out; +} + +/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */ +function contourGeom(contour: Contour): Drawing2DGeom | null { + const pts = contour.pts; + if (pts.length < 2) return null; + if (pts.length === 2 && !contour.closed) { + return { shape: "line", a: { ...pts[0] }, b: { ...pts[1] } }; + } + return { + shape: "polyline", + pts: pts.map((p) => ({ ...p })), + closed: contour.closed, + }; +} + +/** Liste der unterschiedlichen DXF-Layer-Namen über alle Konturen-Sätze. */ +export function distinctLayers(sets: ContourSet[]): string[] { + const seen = new Set(); + for (const set of sets) { + for (const contour of set.contours) { + const name = contour.layer ?? set.layer ?? ""; + if (name) seen.add(name); + } + } + return [...seen]; +} + +/** Gesamtzahl der Konturen über alle Sätze (für die Datei-Zusammenfassung). */ +export function countContours(sets: ContourSet[]): number { + return sets.reduce((sum, s) => sum + s.contours.length, 0); +} diff --git a/src/io/libredwg-web.d.ts b/src/io/libredwg-web.d.ts new file mode 100644 index 0000000..cd6f87d --- /dev/null +++ b/src/io/libredwg-web.d.ts @@ -0,0 +1,19 @@ +// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von +// @mlightcad/libredwg-web. Das Paket gibt seine `wasm/`-Dateien NICHT über +// `exports` frei, daher laden wir sie über zwei virtuelle Module (Aliase in +// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs. +// (Identifier englisch.) + +declare module "virtual:libredwg-glue" { + /** Emscripten-Glue: erzeugt die WASM-Instanz; `locateFile` löst die .wasm-URL. */ + const createModule: (opts?: { + locateFile?: (filename: string, scriptDir: string) => string; + }) => Promise; + export default createModule; +} + +declare module "virtual:libredwg-wasm-url" { + /** Von Vite aufgelöste URL der WASM-Datei (gehashtes Asset im Build). */ + const url: string; + export default url; +} diff --git a/src/io/linParser.ts b/src/io/linParser.ts new file mode 100644 index 0000000..0951cc6 --- /dev/null +++ b/src/io/linParser.ts @@ -0,0 +1,88 @@ +// Parser für AutoCAD .lin Linientyp-Definitionsdateien. +// Liefert benannte Strichmuster (dash patterns) zur späteren Übernahme in die LineStyle-Bibliothek. + +export interface LinPattern { + name: string; + description: string; + dash: number[] | null; +} + +// Punkt (0) wird als sehr kurzer Strich dargestellt, da SVG dasharray keine echten Punkte kennt. +const DOT_LENGTH = 0.01; + +// Zerlegt eine Musterzeile in Tokens und entfernt eingeklammerte Shape-/Text-Segmente. +function extractNumericSegments(patternLine: string): number[] { + // Klammer-Segmente (z. B. ["TEXT",STYLE,...]) entfernen. + const withoutBrackets = patternLine.replace(/\[[^\]]*\]/g, ''); + const tokens = withoutBrackets.split(','); + const numbers: number[] = []; + for (const raw of tokens) { + const token = raw.trim(); + if (token === '') continue; + // Alignment-Token überspringen (üblicherweise 'A'). + if (/^[A-Za-z]/.test(token)) continue; + const value = Number(token); + if (Number.isFinite(value)) { + numbers.push(value); + } + // Nicht-numerische Tokens werden ignoriert. + } + return numbers; +} + +// Wandelt die Roh-Segmente in ein positives alternierendes dash/gap-Array um. +function toDashArray(segments: number[]): number[] | null { + if (segments.length === 0) return null; + const dash: number[] = []; + for (const seg of segments) { + if (seg === 0) { + dash.push(DOT_LENGTH); + } else { + dash.push(Math.abs(seg)); + } + } + return dash.length > 0 ? dash : null; +} + +export function parseLin(text: string): LinPattern[] { + const result: LinPattern[] = []; + // CRLF/CR vereinheitlichen. + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '') continue; + if (line.startsWith(';')) continue; + if (!line.startsWith('*')) continue; + + // Definitionszeile: *NAME,Description + const body = line.slice(1); + const commaIndex = body.indexOf(','); + const name = (commaIndex >= 0 ? body.slice(0, commaIndex) : body).trim(); + const description = commaIndex >= 0 ? body.slice(commaIndex + 1).trim() : ''; + if (name === '') continue; + + // Folgende Musterzeile suchen (Kommentare/Leerzeilen überspringen). + let j = i + 1; + while (j < lines.length) { + const candidate = lines[j].trim(); + if (candidate === '' || candidate.startsWith(';')) { + j++; + continue; + } + break; + } + // Keine folgende Zeile oder direkt nächste Definition -> Eintrag überspringen. + if (j >= lines.length || lines[j].trim().startsWith('*')) { + continue; + } + + const patternLine = lines[j].trim(); + const segments = extractNumericSegments(patternLine); + const dash = toDashArray(segments); + result.push({ name, description, dash }); + i = j; + } + + return result; +} diff --git a/src/io/patParser.ts b/src/io/patParser.ts new file mode 100644 index 0000000..18cf446 --- /dev/null +++ b/src/io/patParser.ts @@ -0,0 +1,75 @@ +// Parser für AutoCAD .pat Schraffur-Musterdateien. +// Liefert strukturierte Linienfamilien zur späteren Übernahme in die Hatch-Bibliothek. + +export interface PatLineFamily { + angle: number; // Grad + x: number; + y: number; // Ursprung + dx: number; + dy: number; // Verschiebung entlang / senkrecht + dashes: number[]; // ggf. leer (durchgezogene Familie) +} + +export interface PatPattern { + name: string; + description: string; + families: PatLineFamily[]; +} + +// Zerlegt eine Familienzeile in numerische Tokens. +function parseNumbers(line: string): number[] { + const tokens = line.split(','); + const numbers: number[] = []; + for (const raw of tokens) { + const token = raw.trim(); + if (token === '') continue; + const value = Number(token); + numbers.push(value); + } + return numbers; +} + +export function parsePat(text: string): PatPattern[] { + const result: PatPattern[] = []; + // CRLF/CR vereinheitlichen. + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + + let current: PatPattern | null = null; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (line === '') continue; + if (line.startsWith(';')) continue; + + if (line.startsWith('*')) { + // Header: *PATTERNNAME, optional description + const body = line.slice(1); + const commaIndex = body.indexOf(','); + const name = (commaIndex >= 0 ? body.slice(0, commaIndex) : body).trim(); + const description = commaIndex >= 0 ? body.slice(commaIndex + 1).trim() : ''; + current = { name, description, families: [] }; + result.push(current); + continue; + } + + // Familienzeile nur gültig innerhalb eines Pattern-Blocks. + if (!current) continue; + + const numbers = parseNumbers(line); + // Mindestens angle, x, y, dx, dy erforderlich. + if (numbers.length < 5) continue; + if (!numbers.slice(0, 5).every((n) => Number.isFinite(n))) continue; + + const [angle, x, y, dx, dy] = numbers; + const dashes: number[] = []; + for (let k = 5; k < numbers.length; k++) { + if (Number.isFinite(numbers[k])) { + dashes.push(numbers[k]); + } + } + + current.families.push({ angle, x, y, dx, dy, dashes }); + } + + return result; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..b4e8d9c --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,60 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import { ErrorBoundary } from "./ui/ErrorBoundary"; +import { getLanguage, setLanguage } from "./i18n"; +import type { Language } from "./i18n"; +import "./styles.css"; + +// Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n- +// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar. +declare global { + interface Window { + __i18n: { getLanguage: () => Language; setLanguage: (lang: Language) => void }; + } +} +window.__i18n = { getLanguage, setLanguage }; + +// ── Native-App-Verhalten (kein Browser-Standard) ───────────────────────────── +// Die App soll sich wie ein natives Programm anfühlen. Diese globalen Hooks +// laufen einmal beim Start (außerhalb von React, am document) — sie greifen +// app-weit und stören die eigenen React-Handler (z. B. unser ContextMenu) nicht. + +/** Prüft, ob ein Element eine echte Texteingabe ist (Input/Textarea/CE). */ +const isTextEntry = (el: EventTarget | null): boolean => { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + return ( + tag === "INPUT" || + tag === "TEXTAREA" || + el.isContentEditable + ); +}; + +// 1) Browser-Kontextmenü global unterdrücken. Unser eigenes ContextMenu wird +// über React-onContextMenu-Handler geöffnet (die preventDefault separat +// aufrufen); auf Flächen ohne eigenes Menü passiert hier nur das Verhindern +// des Browser-Menüs — sonst nichts. +document.addEventListener("contextmenu", (e) => e.preventDefault()); + +// 2) Ctrl/Cmd+A („Alles markieren") außerhalb echter Eingaben unterbinden. +// In Eingaben bleibt es das normale Markieren des Feldinhalts. +document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) { + if (!isTextEntry(document.activeElement)) e.preventDefault(); + } +}); + +// 3) Browser-Drag-Gesten unterbinden (Bild-/Element-Drag, Text-Drag). Echte +// Eingaben bleiben unberührt, damit Auswahl-Drag im Feld weiter funktioniert. +document.addEventListener("dragstart", (e) => { + if (!isTextEntry(e.target)) e.preventDefault(); +}); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/src/model/geometry.ts b/src/model/geometry.ts new file mode 100644 index 0000000..50cdb3e --- /dev/null +++ b/src/model/geometry.ts @@ -0,0 +1,110 @@ +// Geometrie-Helfer für das Gebäudemodell. Reine 2D/3D-Mathematik, +// kein externer Kernel nötig (Phase 0). + +import type { Vec2 } from "./types"; + +export const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y }); +export const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y }); +export const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s }); +export const len = (a: Vec2): number => Math.hypot(a.x, a.y); + +export const normalize = (a: Vec2): Vec2 => { + const l = len(a) || 1; + return { x: a.x / l, y: a.y / l }; +}; + +/** Linke Normale (90° gegen den Uhrzeigersinn gedreht). */ +export const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x }); + +/** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */ +export const cross = (p: Vec2, q: Vec2): number => p.x * q.y - p.y * q.x; + +/** Eine unendliche Gerade als Stützpunkt + Richtung. */ +export interface Line { + point: Vec2; + dir: Vec2; +} + +/** + * Schnittpunkt der Geraden (a + t·da) mit (b + s·db). + * Liefert null bei (nahezu) parallelen Richtungen. + */ +export function lineIntersect( + a: Vec2, + da: Vec2, + b: Vec2, + db: Vec2, +): Vec2 | null { + const denom = cross(da, db); + if (Math.abs(denom) < 1e-9) return null; // parallel → kein Schnitt + const t = cross(sub(b, a), db) / denom; + return add(a, scale(da, t)); +} + +/** Punkt entlang einer Strecke p1→p2 im Abstand d vom Start. */ +export const along = (p1: Vec2, p2: Vec2, d: number): Vec2 => + add(p1, scale(normalize(sub(p2, p1)), d)); + +/** + * Die vier Eckpunkte eines Bandes, das quer zur Normalen zwischen den + * Offsets offA und offB liegt (gemessen entlang der linken Normalen n). + * Reihenfolge: start+n*offA, end+n*offA, end+n*offB, start+n*offB. + */ +export function wallBand( + start: Vec2, + end: Vec2, + offA: number, + offB: number, +): [Vec2, Vec2, Vec2, Vec2] { + const u = normalize(sub(end, start)); + const n = leftNormal(u); + return [ + add(start, scale(n, offA)), + add(end, scale(n, offA)), + add(end, scale(n, offB)), + add(start, scale(n, offB)), + ]; +} + +/** Die vier Eckpunkte einer Wand als Polygon (gegen den Uhrzeigersinn). */ +export function wallCorners( + start: Vec2, + end: Vec2, + thickness: number, +): [Vec2, Vec2, Vec2, Vec2] { + const h = thickness / 2; + return wallBand(start, end, h, -h); +} + +/** + * Wie {@link wallBand}, aber die Bandenden werden bei Bedarf an Geh­rungs­linien + * (startCut / endCut) abgeschnitten statt rechtwinklig gekappt. Jede der beiden + * Bandkanten (bei Offset offA bzw. offB, Richtung u) wird mit der jeweiligen + * Schnittlinie verschnitten; fehlt eine Schnittlinie, bleibt das Ende + * rechtwinklig (Punkt auf der Achssenkrechten). + * Reihenfolge bleibt konsistent zu wallBand: [A.start, A.end, B.end, B.start]. + */ +export function clippedBand( + start: Vec2, + end: Vec2, + offA: number, + offB: number, + startCut: Line | null, + endCut: Line | null, +): [Vec2, Vec2, Vec2, Vec2] { + const u = normalize(sub(end, start)); + const n = leftNormal(u); + + const edgeStart = (off: number): Vec2 => { + const base = add(start, scale(n, off)); + if (!startCut) return base; + return lineIntersect(base, u, startCut.point, startCut.dir) ?? base; + }; + const edgeEnd = (off: number): Vec2 => { + const base = add(end, scale(n, off)); + if (!endCut) return base; + return lineIntersect(base, u, endCut.point, endCut.dir) ?? base; + }; + + return [edgeStart(offA), edgeEnd(offA), edgeEnd(offB), edgeStart(offB)]; +} diff --git a/src/model/joins.ts b/src/model/joins.ts new file mode 100644 index 0000000..5d64ea5 --- /dev/null +++ b/src/model/joins.ts @@ -0,0 +1,141 @@ +// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Wände aufeinander­ +// treffen, sollen sich die Schicht-Bänder nicht überlappen, sondern an einer +// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand +// die optionalen Schnittlinien an Start- und Endpunkt. + +import type { Project, Vec2, Wall } from "./types"; +import { getWallType, wallTypeThickness } from "./types"; +import { wallReferenceOffset } from "./wall"; +import { + add, + leftNormal, + len, + lineIntersect, + normalize, + scale, + sub, +} from "./geometry"; +import type { Line } from "./geometry"; + +/** Schnittlinien einer Wand an ihren beiden Achsenden. */ +export interface WallCuts { + startCut: Line | null; + endCut: Line | null; +} + +/** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */ +const roundKey = (p: Vec2): string => { + const r = (v: number) => Math.round(v * 1e4) / 1e4; + return `${r(p.x)},${r(p.y)}`; +}; + +interface WallEnd { + wallId: string; + end: "start" | "end"; +} + +/** + * Berechnet für jede Wand die Gehrungs-Schnittlinien. + * Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie + * Enden und T-/X-Stöße bleiben rechtwinklig (siehe Kommentare unten). + * + * `walls` ist die bereits gefilterte Wandmenge (z. B. ein Geschoss); die + * Gehrung wird nur innerhalb dieser Menge gebildet. + */ +export function computeJoins( + project: Project, + walls: Wall[], +): Map { + const byId = new Map(walls.map((w) => [w.id, w])); + + const result = new Map(); + for (const w of walls) result.set(w.id, { startCut: null, endCut: null }); + + // Knotenkarte: gerundeter Endpunkt → Liste der dort endenden Wandenden. + const junctions = new Map(); + const push = (p: Vec2, we: WallEnd) => { + const key = roundKey(p); + const list = junctions.get(key); + if (list) list.push(we); + else junctions.set(key, [we]); + }; + for (const w of walls) { + push(w.start, { wallId: w.id, end: "start" }); + push(w.end, { wallId: w.id, end: "end" }); + } + + for (const [, ends] of junctions) { + // Freies Ende → kein Schnitt. + if (ends.length === 1) continue; + // T-/X-Stöße (>2 Enden): vorerst rechtwinklig lassen (Folge-Arbeit). + if (ends.length !== 2) continue; + + const a = byId.get(ends[0].wallId)!; + const b = byId.get(ends[1].wallId)!; + const cut = miterLine(project, a, ends[0].end, b); + if (!cut) continue; // kollinear → kein Schnitt + + setCut(result, ends[0], cut); + setCut(result, ends[1], cut); + } + + return result; +} + +/** Trägt eine Schnittlinie am passenden Ende einer Wand ein. */ +function setCut(result: Map, we: WallEnd, cut: Line): void { + const cuts = result.get(we.wallId)!; + if (we.end === "start") cuts.startCut = cut; + else cuts.endCut = cut; +} + +/** Achsrichtung start→end, normalisiert. */ +const dirOf = (w: Wall): Vec2 => normalize(sub(w.end, w.start)); + +/** + * Gemeinsame Gehrungslinie zweier Wände A, B, die sich im Knoten J treffen. + * Robust gegen beliebige Wicklung und ungleiche Dicken: A's Außenfläche wird + * mit der NÄCHSTGELEGENEN Fläche von B verschnitten, A's Innenfläche mit der + * jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung. + */ +function miterLine( + project: Project, + a: Wall, + aEnd: "start" | "end", + b: Wall, +): Line | null { + const j = aEnd === "start" ? a.start : a.end; + + const tA = wallTypeThickness(getWallType(project, a)); + const tB = wallTypeThickness(getWallType(project, b)); + const uA = dirOf(a); + const uB = dirOf(b); + const nA = leftNormal(uA); + const nB = leftNormal(uB); + + // Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden + // Wandflächen um diesen Betrag entlang +n verschoben (außen −T/2+off, innen + // +T/2+off). Für „center" (Default) ist off=0 → unverändert. + const offA = wallReferenceOffset(a, tA); + const offB = wallReferenceOffset(b, tB); + + // Flächen-Stützpunkte am Knoten (linke/rechte Wandseite), inkl. Referenzversatz. + const pLA = add(j, scale(nA, offA + tA / 2)); + const pRA = add(j, scale(nA, offA - tA / 2)); + const pLB = add(j, scale(nB, offB + tB / 2)); + const pRB = add(j, scale(nB, offB - tB / 2)); + + // Für A's linke Fläche die nähere B-Fläche wählen; A's rechte bekommt die andere. + const lbCloser = + len(sub(pLA, pLB)) <= len(sub(pLA, pRB)); + const bForLeft = lbCloser ? pLB : pRB; + const bForRight = lbCloser ? pRB : pLB; + + const c1 = lineIntersect(pLA, uA, bForLeft, uB); + const c2 = lineIntersect(pRA, uA, bForRight, uB); + if (!c1 || !c2) return null; + + const dir = sub(c2, c1); + if (len(dir) < 1e-9) return null; + return { point: c1, dir }; +} diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts new file mode 100644 index 0000000..73052a8 --- /dev/null +++ b/src/model/sampleProject.ts @@ -0,0 +1,182 @@ +import type { Project } from "./types"; +import { recomputeFloorElevations } from "./types"; + +// Demo-Haus mit zwei Geschossen: +// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand. +// OG — rechteckiger Raum (5 × 3 m) ohne Tür. +// Maße in Metern. Die Außenwand ist ein realistischer SIA-naher +// Schichtaufbau (außen → innen). +// +// Zwei UNABHÄNGIGE Achsen (Dokumentmodell nach DOSSIER): +// • drawingLevels — Geschosse (EG, OG) + Platzhalter Schnitt/Ansicht. +// • layers — Grafik-Kategorie-Baum (Codes/Namen/Farben/lw 1:1 aus DOSSIER, +// DEFAULT_LAYER_SCHEMA in launcher/src/App.jsx). 21 Türen/Fenster ist hier +// als Kind von 20 Wände gehängt. +export const sampleProject: Project = { + id: "demo", + name: "Demo-Haus", + // ── Ressourcen-Bibliotheken (zentral verwiesen per id) ────────────────── + // Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line" + // ist der dünne Strich für Schraffur-Muster. + lineStyles: [ + { id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null }, + { id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null }, + { id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null }, + // Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren). + { id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null }, + ], + // Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad. + // "none" = keine Schraffur (nur Component-Füllung). + // Grundeinstellung aller Linien-Schraffuren: schwarze Haarlinie (color) auf + // weißem Grund (die zugehörigen Bauteile tragen weiße Füllfarbe). + hatches: [ + { id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#1a1a1a" }, + { + id: "insulation", + name: "Wärmedämmung", + pattern: "insulation", + scale: 1, + angle: 0, + color: "#1a1a1a", + lineStyleId: "hatch-line", + }, + { + id: "diagonal", + name: "Diagonal", + pattern: "diagonal", + scale: 1, + angle: 45, + color: "#1a1a1a", + lineStyleId: "hatch-line", + }, + { + id: "crosshatch", + name: "Kreuzschraffur", + pattern: "crosshatch", + scale: 1, + angle: 0, + color: "#1a1a1a", + lineStyleId: "hatch-line", + }, + { + id: "solid-concrete", + name: "Beton (voll)", + pattern: "solid", + scale: 1, + angle: 0, + color: "#9aa0a6", + lineStyleId: "thin", + }, + ], + // Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe; + // joinPriority höher = läuft am Stoß durch (für spätere T-Stöße). + components: [ + { id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }, + // Weißer Grund unter der schwarzen Dämmungs-Haarlinie (Schraffur-Grundeinstellung). + { id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "insulation", joinPriority: 20 }, + { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 }, + { id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 }, + // Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität). + { id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "solid-concrete", joinPriority: 100 }, + ], + wallTypes: [ + { + id: "aw", + name: "Aussenwand 34.5 cm", + // Schichten außen → innen; Priorität lebt jetzt am Component. + layers: [ + { componentId: "render-ext", thickness: 0.02 }, + { componentId: "insulation", thickness: 0.16 }, + { componentId: "brick", thickness: 0.15 }, + { componentId: "render-int", thickness: 0.015 }, + ], + }, + ], + // Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung. + // baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6. + drawingLevels: recomputeFloorElevations([ + { id: "eg", name: "Erdgeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 }, + { id: "og", name: "Obergeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 }, + { + id: "schnitt-a", + name: "Schnitt A", + kind: "section", + visible: true, + locked: false, + linePoints: [{ x: -1, y: 2 }, { x: 6, y: 2 }], + directionSign: 1, + }, + { id: "ansicht-sued", name: "Ansicht Süd", kind: "elevation", visible: true, locked: false }, + { id: "detail-1", name: "Detail 1", kind: "drawing", visible: true, locked: false }, + ]), + // Grafik-Kategorie-Baum (DOSSIER-Codes 1:1). + layers: [ + { code: "00", name: "Raster", color: "#484850", lw: 0.13, visible: true, locked: false }, + { code: "01", name: "Vermessung", color: "#707078", lw: 0.18, visible: true, locked: false }, + { code: "10", name: "Situation", color: "#909090", lw: 0.18, visible: true, locked: false }, + { code: "11", name: "Strasse", color: "#a89070", lw: 0.18, visible: true, locked: false }, + { code: "12", name: "Gebäude", color: "#888888", lw: 0.25, visible: true, locked: false }, + { code: "13", name: "Bäume", color: "#50a050", lw: 0.13, visible: true, locked: false }, + { code: "14", name: "Höhenlinien", color: "#909050", lw: 0.18, visible: true, locked: false }, + { + code: "20", + name: "Wände", + color: "#0a0a0a", + lw: 0.5, + visible: true, + locked: false, + children: [ + { code: "21", name: "Türen/Fenster", color: "#5080c8", lw: 0.25, visible: true, locked: false }, + { code: "22", name: "Möbel", color: "#909090", lw: 0.13, visible: true, locked: false }, + { code: "25", name: "Stützen", color: "#c87050", lw: 0.5, visible: true, locked: false }, + ], + }, + { code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false }, + { code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false }, + { code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false }, + { code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false }, + { code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false }, + { code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false }, + { code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false }, + ], + walls: [ + // ── EG: 5 × 4 m Raum (CCW) ── + // Süd (unten) + { id: "W1", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 }, + // Ost (rechts) + { id: "W2", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 4 }, wallTypeId: "aw", height: 2.6 }, + // Nord (oben) + { id: "W3", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 4 }, end: { x: 0, y: 4 }, wallTypeId: "aw", height: 2.6 }, + // West (links) + { id: "W4", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 4 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 }, + + // ── OG: 5 × 3 m Raum (CCW), keine Tür ── + // Süd (unten) + { id: "W5", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 }, + // Ost (rechts) + { id: "W6", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 3 }, wallTypeId: "aw", height: 2.6 }, + // Nord (oben) + { id: "W7", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 3 }, end: { x: 0, y: 3 }, wallTypeId: "aw", height: 2.6 }, + // West (links) + { id: "W8", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 3 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 }, + ], + doors: [ + { + id: "D1", + type: "door", + hostWallId: "W1", + categoryCode: "21", + position: 2.0, // 2 m vom Wand-Start (Ecke unten links) + width: 0.9, + height: 2.1, + swing: "left", // schlägt nach innen (in den Raum) auf + hinge: "start", + }, + ], + // Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen + // gefüllt (docs/design/drawing-tools.md). + drawings2d: [], + // Kontext-Schicht (importierte/abgeleitete Geometrie) — anfangs leer; wird + // über den DXF-Import bzw. die Gelände-Generierung gefüllt. + context: [], +}; diff --git a/src/model/terrain.ts b/src/model/terrain.ts new file mode 100644 index 0000000..0f97b9f --- /dev/null +++ b/src/model/terrain.ts @@ -0,0 +1,100 @@ +// Gelände-Generierung (TIN) aus Konturen (Höhenlinien). +// +// Prinzip: Die Stütz-Vertices der Konturen werden als Punktmenge genommen +// (sie liegen direkt auf den Höhenlinien = bevorzugte „Breaklines"). Über deren +// (x,y)-Projektion wird eine 2D-Delaunay-Triangulation gelegt; die Z-Höhe je +// Vertex stammt aus der jeweiligen Kontur. Daraus entsteht ein TIN (Triangulated +// Irregular Network) als rohes positions/indices-Mesh (three-frei). +// +// Gelände ist NICHT semantisch — es ist Kontext-Geometrie (Project.context). + +import Delaunator from "delaunator"; +import type { Contour, TerrainMesh } from "./types"; + +let terrainSeq = 0; + +/** + * Erzeugt ein TIN aus Konturen. + * + * Algorithmus: + * 1. Sampling: jeder Stützpunkt jeder Kontur wird ein 3D-Vertex (x, y, z) mit + * z = Kontur-Höhe. Es wird KEIN zusätzliches Resampling betrieben — die + * Kontur-Vertices selbst sind die Breaklines. + * 2. Delaunay: 2D-Triangulation über die (x,y)-Projektion aller Vertices + * (Delaunator). Die Z-Höhe je Vertex bleibt erhalten → echtes Gelände-TIN. + * 3. Degenerate-Filter: Dreiecke mit (nahezu) null Fläche in der (x,y)-Ebene + * bzw. mit nicht-endlichen Koordinaten werden verworfen. + * + * Robustheit: + * • offene UND geschlossene Konturen werden gleich behandelt (nur die Punkte + * zählen; closed/offen beeinflusst die TIN-Fläche nicht). + * • leere/zu kleine Eingabe (< 3 Punkte) → leeres Mesh (kein Crash). + * • NaN/Infinity-Punkte werden vor der Triangulation aussortiert. + */ +export function generateTerrainFromContours(contours: Contour[]): TerrainMesh { + const id = `terrain-${Date.now()}-${terrainSeq++}`; + const name = "Gelände"; + + // 1. Sampling: alle Kontur-Stützpunkte als (x,y,z)-Vertices sammeln, ungültige + // (nicht-endliche) Punkte überspringen. + const xs: number[] = []; + const ys: number[] = []; + const zs: number[] = []; + for (const c of contours) { + const z = Number.isFinite(c.z) ? c.z : 0; + for (const p of c.pts) { + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + xs.push(p.x); + ys.push(p.y); + zs.push(z); + } + } + + // Zu wenige Punkte für ein Dreieck → triviales leeres Mesh. + if (xs.length < 3) { + return { id, type: "terrainMesh", name, positions: [], indices: [] }; + } + + // 2. Delaunay über die (x,y)-Projektion. Delaunator erwartet ein flaches + // [x0,y0, x1,y1, …]-Koordinatenarray. + const coords2d = new Float64Array(xs.length * 2); + for (let i = 0; i < xs.length; i++) { + coords2d[i * 2] = xs[i]; + coords2d[i * 2 + 1] = ys[i]; + } + // Bei kollinearen Punkten liefert Delaunator eine leere Triangulation — das ist + // korrekt (kein Crash). Trotzdem schützen wir gegen einen möglichen Wurf. + let triangles: Uint32Array; + try { + triangles = new Delaunator(coords2d).triangles; + } catch { + return { id, type: "terrainMesh", name, positions: [], indices: [] }; + } + + // 3. positions (x,y,z) aufbauen; Z aus zs je Vertex. + const positions: number[] = new Array(xs.length * 3); + for (let i = 0; i < xs.length; i++) { + positions[i * 3] = xs[i]; + positions[i * 3 + 1] = ys[i]; + positions[i * 3 + 2] = zs[i]; + } + + // Degenerierte Dreiecke (Null-Fläche in (x,y)) herausfiltern, damit das TIN + // keine entarteten Faces enthält. + const indices: number[] = []; + const EPS_AREA = 1e-9; + for (let t = 0; t < triangles.length; t += 3) { + const a = triangles[t]; + const b = triangles[t + 1]; + const c = triangles[t + 2]; + const ax = xs[a], ay = ys[a]; + const bx = xs[b], by = ys[b]; + const cx = xs[c], cy = ys[c]; + // doppelte Dreiecksfläche (Kreuzprodukt) in der (x,y)-Ebene. + const area2 = Math.abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay)); + if (area2 <= EPS_AREA) continue; + indices.push(a, b, c); + } + + return { id, type: "terrainMesh", name, positions, indices }; +} diff --git a/src/model/types.ts b/src/model/types.ts new file mode 100644 index 0000000..648896a --- /dev/null +++ b/src/model/types.ts @@ -0,0 +1,469 @@ +// Das semantische Gebäudemodell — der Kern von BIM. +// Bauteile haben Bedeutung, nicht nur Form. Eine Tür "kennt" ihre Wand, +// eine Wand "kennt" ihren mehrschichtigen Aufbau (WallType). +// +// Dokumentmodell (nach DOSSIER): zwei UNABHÄNGIGE Achsen. +// 1. Zeichnungsebenen (DrawingLevel) = oberste Schnitte: Geschosse + +// Schnitte/Ansichten. +// 2. Ebenen (LayerCategory) = Grafik-Kategorie-Schema, das für jedes +// Geschoss gilt. Ein Element lebt auf einer Kategorie (code) UND einem +// Geschoss. + +export type Vec2 = { x: number; y: number }; + +// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ──────────────────── +// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette: +// Component → Hatch → LineStyle. +// Darstellung wird beim Rendern aus diesen Ressourcen aufgelöst, nie in die +// Geometrie eingebacken (siehe docs/design/resources-graphics.md). + +/** Ein wiederverwendbarer Linienstil (Line Manager). */ +export interface LineStyle { + id: string; + name: string; + /** Strichstärke in Millimetern (≙ Rhino PlotWeight). */ + weight: number; + /** Farbe (hex). */ + color: string; + /** Strichmuster in Millimetern; `null` = durchgezogen. */ + dash: number[] | null; +} + +/** Mögliche Schraffur-Muster im Plan/Schnitt. */ +export type HatchPattern = + | "none" + | "solid" + | "insulation" + | "diagonal" + | "crosshatch"; + +/** Eine wiederverwendbare Schraffur (Hatch Manager). */ +export interface HatchStyle { + id: string; + name: string; + /** Muster-Typ. */ + pattern: HatchPattern; + /** Grundmaßstab des Musters (1 = Standardteilung). */ + scale: number; + /** Drehung des Musters in Grad. */ + angle: number; + /** + * Farbe der Musterlinien bzw. der Vollfüllung (`pattern==="solid"`). Bei + * `pattern==="none"` ungenutzt. + */ + color: string; + /** Optionaler Linienstil für die Musterlinien (Line Manager). */ + lineStyleId?: string; +} + +/** + * Ein Bauteil-Material (Component Manager) — vereint Plan-Schnittdarstellung + * (Schraffur) und 3D-Erscheinung. Ersetzt das frühere `Material`. + */ +export interface Component { + id: string; + name: string; + /** Füllfarbe im Grundriss (Poché) und 3D-Diffusfarbe. */ + color: string; + /** Schnitt-Schraffur → Hatch Manager. */ + hatchId: string; + /** Optionale 3D-Textur (vorerst ignoriert). */ + texture3d?: string; + /** Verschneidungs-Rang: höher läuft am Stoß durch (Backbone). */ + joinPriority: number; +} + +/** Eine Schicht eines mehrschichtigen Bauteils. */ +export interface Layer { + /** Verweis auf das Bauteil-Material (Component Manager). */ + componentId: string; + /** Schichtdicke in Metern. */ + thickness: number; +} + +/** Ein Wandtyp = geordneter Schichtaufbau (außen → innen). */ +export interface WallType { + id: string; + name: string; + layers: Layer[]; +} + +/** + * Art einer Zeichnungsebene. + * • "floor" — Geschoss, trägt Bauteile. + * • "section" — Schnitt (abgeleitete Projektion entlang einer Linie). + * • "elevation" — Ansicht (abgeleitete Projektion entlang einer Linie). + * • "drawing" — freie 2D-Zeichnung, nicht an ein Geschoss gebunden. + */ +export type DrawingLevelKind = "floor" | "section" | "elevation" | "drawing"; + +/** + * Eine Zeichnungsebene (DrawingLevel) — eine oberste Schnittebene des + * Dokuments. Ein Geschoss trägt die Bauteile (über `floorId`); ein + * Schnitt/Ansicht ist eine abgeleitete Projektion (vorerst Platzhalter); eine + * reine Zeichnung ist eine freie 2D-Ebene ohne Geschossbezug. + * + * Geschoss nutzt floorHeight/cutHeight/baseElevation; Schnitt/Ansicht nutzen + * linePoints/directionSign; "drawing" nutzt keines dieser Felder. + */ +export interface DrawingLevel { + id: string; + name: string; + kind: DrawingLevelKind; + /** Sichtbarkeit der Zeichnungsebene im Navigator. */ + visible: boolean; + /** Gesperrt (keine Bearbeitung). */ + locked: boolean; + /** Lichte Geschosshöhe in Metern (nur Geschoss). */ + floorHeight?: number; + /** Schnitthöhe über OKFF in Metern (nur Geschoss, für den Grundriss). */ + cutHeight?: number; + /** Oberkante Fertigfußboden in Metern (nur Geschoss). */ + baseElevation?: number; + /** Schnitt-/Ansichtslinie im Grundriss (nur Schnitt/Ansicht). */ + linePoints?: [Vec2, Vec2]; + /** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */ + directionSign?: 1 | -1; +} + +/** + * Eine Ebene (LayerCategory) — ein Knoten im Grafik-Kategorie-Baum. Das + * Schema gilt geschossübergreifend; Elemente verweisen über `code` darauf. + */ +export interface LayerCategory { + /** Eindeutiger Kategorie-Code, z. B. "20" für Wände. */ + code: string; + name: string; + /** Darstellungsfarbe (hex). */ + color: string; + /** Linienstärke in Millimetern. */ + lw: number; + visible: boolean; + locked: boolean; + /** Optionale Standard-Schraffur der Kategorie. */ + hatch?: string; + /** Unterkategorien (Baum). */ + children?: LayerCategory[]; +} + +/** + * Lage der Wandachse (Referenzlinie) über die Dicke der Wand — analog + * Vectorworks. Gemessen relativ zur Laufrichtung (start→end) mit der + * leftNormal-Konvention `n = (-u.y, u.x)`: + * • "center" — Achse mittig (Default = heutiges Verhalten). + * • "left" — Achse liegt auf der linken Wandfläche (+n-Seite, „außen"). + * • "right" — Achse liegt auf der rechten Wandfläche (−n-Seite, „innen"). + */ +export type WallReferenceLine = "left" | "center" | "right"; + +/** + * Vertikale Bindung einer Wandkante (UK/OK). + * • "floor" — an ein Geschoss gebunden: Z = baseElevation des Geschosses + * (+ optionalem `offset`). Stapelt automatisch mit dem Geschoss. + * • "custom" — fester absoluter Z-Wert (Meter). + */ +export type VerticalAnchor = + | { mode: "floor"; floorId: string; offset?: number } + | { mode: "custom"; z: number }; + +/** Eine Wand, definiert über ihre Achse (Centerline) und ihren Typ. */ +export interface Wall { + id: string; + type: "wall"; + /** Zugehörige Zeichnungsebene (Geschoss). */ + floorId: string; + /** Grafik-Kategorie (Ebene), z. B. "20" für Wände. */ + categoryCode: string; + /** Achs-Startpunkt im Grundriss (Meter). */ + start: Vec2; + /** Achs-Endpunkt im Grundriss (Meter). */ + end: Vec2; + /** Verweis auf den (mehrschichtigen) Wandtyp. */ + wallTypeId: string; + /** Wandhöhe in Metern. */ + height: number; + /** + * Optionale Übersteuerung der Strich-/Umrandungsfarbe der Wand; sonst gilt + * die Kategorie-Farbe. Übersteuert NUR die Linienfarbe (Umriss/Schichtfugen), + * nicht die Schicht-Füllfarben/Schraffuren. + */ + color?: string; + /** + * Lage der Wandachse über die Dicke. Fehlt sie, gilt "center" (= heutiges + * Verhalten: Schichten symmetrisch −T/2 … +T/2 um die Achse). + */ + referenceLine?: WallReferenceLine; + /** + * Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der + * baseElevation des zugehörigen Geschosses (= heutiges Verhalten). + */ + bottom?: VerticalAnchor; + /** + * Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus + * UK + `height` (= heutiges Verhalten). „custom" setzt einen absoluten + * Z-Wert; „floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss. + */ + top?: VerticalAnchor; +} + +/** Schwenkrichtung der Tür relativ zur Wandachse. */ +export type SwingSide = "left" | "right"; + +/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */ +export interface Door { + id: string; + type: "door"; + hostWallId: string; + /** Grafik-Kategorie (Ebene), z. B. "21" für Türen/Fenster. */ + categoryCode: string; + /** Abstand des Türanschlags (erster Pfosten) vom Wand-Startpunkt, in Metern. */ + position: number; + /** Türbreite (lichte Öffnung) in Metern. */ + width: number; + /** Türhöhe in Metern. */ + height: number; + /** Auf welche Seite der Wandachse die Tür aufschlägt. */ + swing: SwingSide; + /** An welchem Pfosten das Scharnier sitzt. */ + hinge: "start" | "end"; +} + +// ── Freie 2D-Zeichengeometrie (Drawing2D) ────────────────────────────────── +// Ein semantisches 2D-Element (wie Wall/Door), das beim Rendern abgeleitet wird +// (keine vorab erzeugten Plan-Primitive). Siehe docs/design/drawing-tools.md §7. + +/** Geometrie-Form eines 2D-Zeichenelements. */ +export type Drawing2DGeom = + | { shape: "line"; a: Vec2; b: Vec2 } + | { shape: "polyline"; pts: Vec2[]; closed: boolean } + | { shape: "rect"; min: Vec2; max: Vec2 } + | { shape: "circle"; center: Vec2; r: number } + | { shape: "arc"; center: Vec2; r: number; a0: number; a1: number } + | { shape: "text"; at: Vec2; text: string; height: number; angle: number }; + +/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */ +export interface Drawing2D { + id: string; + type: "drawing2d"; + /** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */ + levelId: string; + /** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */ + categoryCode: string; + geom: Drawing2DGeom; + /** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */ + lineStyleId?: string; + /** Optionale Schraffur für geschlossene Formen (Hatch Manager). */ + hatchId?: string; + /** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */ + color?: string; + /** + * Optionale Vollton-Füllfarbe für geschlossene Formen (getrennt von der + * Strichfarbe `color`). Fehlt sie, ist die Fläche transparent (nur Schraffur + * bzw. ungefüllt). + */ + fillColor?: string; + /** + * Optionale direkte Strichstärke-Übersteuerung in Millimetern; hat Vorrang + * vor dem LineStyle-Gewicht und der Kategorie-Strichstärke. + */ + weightMm?: number; +} + +/** + * Ein Kanten-/Seiten-Griff eines selektierten Elements (zusätzlich zu den + * Eckpunkt-Griffen). Liegt am Mittelpunkt einer Seite (Modell-Meter) und zeigt + * mit `normal` als Einheitsvektor nach AUSSEN (vom Element weg). `aIndex`/ + * `bIndex` sind die beiden Vertex-Indizes der Kante — passend zur Indizierung + * von `drawingVertices`/`moveGripOf`. Ziehen verschiebt BEIDE Vertices senkrecht + * zur Kante (Form wächst/schrumpft an dieser Seite). + */ +export interface EdgeGrip { + mid: Vec2; + normal: Vec2; + aIndex: number; + bIndex: number; +} + +export type Element = Wall | Door | Drawing2D; + +// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ─────────── +// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres +// Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen +// Schicht `Project.context` und wird beim Rendern wie eine Referenz behandelt. +// Bewusst three-frei: nur rohe Buffer-Daten (positions/indices), three-Objekte +// entstehen erst im Viewport. + +/** + * Ein importiertes Dreiecks-Mesh (z. B. aus DXF 3DFACE/POLYFACE/MESH). Rohe + * BufferGeometry-Daten: `positions` = flaches Array (x,y,z, x,y,z, …) in Metern, + * `indices` = Dreiecks-Indizes (je 3 ein Dreieck). Keine three-Objekte. + */ +export interface ImportedMesh { + id: string; + type: "importedMesh"; + name: string; + /** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */ + layer?: string; + positions: number[]; + indices: number[]; +} + +/** + * Eine einzelne Kontur (Höhenlinie / Polylinie) auf konstanter Höhe `z`. `pts` + * sind 2D-Stützpunkte (x,y) in Metern; `closed` schließt den Linienzug. + */ +export interface Contour { + z: number; + pts: Vec2[]; + closed: boolean; + /** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */ + layer?: string; +} + +/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */ +export interface ContourSet { + id: string; + type: "contourSet"; + name: string; + /** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */ + layer?: string; + contours: Contour[]; +} + +/** + * Ein TIN-Geländemodell, abgeleitet aus Konturen (Delaunay über (x,y), Z aus der + * jeweiligen Kontur-Höhe). `positions` = flaches (x,y,z…)-Array in Metern, + * `indices` = Dreiecks-Indizes. Gelände ist NICHT semantisch (Kontext-Schicht). + */ +export interface TerrainMesh { + id: string; + type: "terrainMesh"; + name: string; + positions: number[]; + indices: number[]; +} + +/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */ +export type ContextObject = ImportedMesh | ContourSet | TerrainMesh; + +/** Das gesamte Projekt. */ +export interface Project { + id: string; + name: string; + /** Linienstil-Bibliothek (Line Manager). */ + lineStyles: LineStyle[]; + /** Schraffur-Bibliothek (Hatch Manager). */ + hatches: HatchStyle[]; + /** Bauteil-Material-Bibliothek (Component Manager). */ + components: Component[]; + wallTypes: WallType[]; + /** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */ + drawingLevels: DrawingLevel[]; + /** Grafik-Kategorie-Baum (geschossübergreifend). */ + layers: LayerCategory[]; + walls: Wall[]; + doors: Door[]; + /** Freie 2D-Zeichengeometrie (Line/Polyline/Rect/Circle/Arc/Text). */ + drawings2d: Drawing2D[]; + /** + * Kontext-Schicht: importierte/abgeleitete „dumme" Geometrie (Meshes, + * Konturen, Gelände-TIN) — NICHT semantisch. Optional, damit bestehende + * Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln). + */ + context?: ContextObject[]; +} + +// ── Helfer ─────────────────────────────────────────────────────────────── + +export const getWallType = (project: Project, wall: Wall): WallType => { + const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId); + if (!wt) throw new Error(`Unbekannter Wandtyp: ${wall.wallTypeId}`); + return wt; +}; + +/** Liefert ein Bauteil-Material (Component) per ID oder wirft. */ +export const getComponent = (project: Project, id: string): Component => { + const c = project.components.find((co) => co.id === id); + if (!c) throw new Error(`Unbekanntes Bauteil-Material: ${id}`); + return c; +}; + +/** Liefert eine Schraffur (HatchStyle) per ID oder wirft. */ +export const getHatch = (project: Project, id: string): HatchStyle => { + const h = project.hatches.find((ht) => ht.id === id); + if (!h) throw new Error(`Unbekannte Schraffur: ${id}`); + return h; +}; + +/** Liefert einen Linienstil (LineStyle) per ID oder wirft. */ +export const getLineStyle = (project: Project, id: string): LineStyle => { + const l = project.lineStyles.find((ls) => ls.id === id); + if (!l) throw new Error(`Unbekannter Linienstil: ${id}`); + return l; +}; + +/** Liefert eine Zeichnungsebene (Geschoss) per ID oder wirft. */ +export const getFloor = (project: Project, id: string): DrawingLevel => { + const g = project.drawingLevels.find((z) => z.id === id); + if (!g) throw new Error(`Unbekanntes Geschoss: ${id}`); + return g; +}; + +/** + * Stapelt baseElevation der Geschosse in Dokumentreihenfolge: Das erste + * Geschoss beginnt bei 0, jedes weitere bei baseElevation + floorHeight des + * vorigen Geschosses. Nicht-Geschoss-Ebenen behalten baseElevation undefined. + * Liefert eine neue Liste (mit neuen Geschoss-Objekten); die Eingabe bleibt + * unverändert. + */ +export const recomputeFloorElevations = ( + levels: DrawingLevel[], +): DrawingLevel[] => { + let nextBase = 0; + return levels.map((level) => { + if (level.kind !== "floor") { + return { ...level, baseElevation: undefined }; + } + const baseElevation = nextBase; + nextBase = baseElevation + (level.floorHeight ?? 0); + return { ...level, baseElevation }; + }); +}; + +/** Flacht den Kategorie-Baum (Tiefensuche) in eine Liste ab. */ +export const flattenCategories = (cats: LayerCategory[]): LayerCategory[] => { + const out: LayerCategory[] = []; + const walk = (list: LayerCategory[]) => { + for (const c of list) { + out.push(c); + if (c.children) walk(c.children); + } + }; + walk(cats); + return out; +}; + +/** Menge aller Codes sichtbarer Kategorien (Baum berücksichtigt). */ +export const collectVisibleCodes = (layers: LayerCategory[]): Set => { + const codes = new Set(); + for (const c of flattenCategories(layers)) { + if (c.visible) codes.add(c.code); + } + return codes; +}; + +/** Gesamtdicke eines Wandtyps = Summe der Schichtdicken. */ +export const wallTypeThickness = (wt: WallType): number => + wt.layers.reduce((sum, l) => sum + l.thickness, 0); + +/** Formatiert Meter mit zwei Nachkommastellen, z. B. "0.35 m". */ +export const formatM = (meters: number): string => meters.toFixed(2) + " m"; + +/** + * Standard-Stiftstärken (mm Papier bei 100 %), Vorgabeliste für Linienstil-/ + * Strichstärke-Eingaben. Bedeutung: Breite auf dem Papier — die Linien skalieren + * mit dem Massstab (non-scaling-stroke). Werte sind Vorschläge; man darf abweichen. + */ +export const PEN_WEIGHTS: number[] = [ + 0.02, 0.1, 0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0, +]; diff --git a/src/model/wall.ts b/src/model/wall.ts new file mode 100644 index 0000000..332be76 --- /dev/null +++ b/src/model/wall.ts @@ -0,0 +1,87 @@ +// Wand-Resolver: leitet aus den (optionalen) Wand-Attributen die konkreten +// geometrischen Größen ab — Versatz der Achse über die Dicke (Referenzlinie) +// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem +// bisherigen Verhalten, sodass bestehende Wände unverändert rendern. +// +// Bezeichner englisch, Kommentare deutsch (CLAUDE.md). + +import type { Project, VerticalAnchor, Wall } from "./types"; +import { getFloor } from "./types"; + +/** + * Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal), + * sodass die Achse auf der gewünschten Wandfläche liegt. Die Schichten werden + * normalerweise symmetrisch von `−T/2` (außen, +n-Seite) bis `+T/2` (innen) + * gestapelt; dieser Versatz wird ZUSÄTZLICH auf jeden Schicht-Offset addiert. + * + * • "center" → 0 (Schichten symmetrisch um die Achse — Default). + * • "left" → +T/2 (Achse auf der +n-/„außen"-Fläche; Wand liegt auf −n). + * • "right" → −T/2 (Achse auf der −n-/„innen"-Fläche; Wand liegt auf +n). + * + * Die Schicht-Offsets laufen von −T/2 (= +n außen) nach +T/2 (= −n innen). + * Für "left" soll die −T/2-Kante (außen) auf der Achse liegen → +T/2 addieren, + * sodass die Schichten von 0…T (alle auf der −n-Seite) liegen. + */ +export function wallReferenceOffset( + wall: Wall, + totalThickness: number, +): number { + const ref = wall.referenceLine ?? "center"; + const half = totalThickness / 2; + if (ref === "left") return +half; + if (ref === "right") return -half; + return 0; +} + +/** Löst einen VerticalAnchor zu einem absoluten Z (Meter) auf oder `null`. */ +function resolveAnchor( + project: Project, + anchor: VerticalAnchor | undefined, +): number | null { + if (!anchor) return null; + if (anchor.mode === "custom") return anchor.z; + // "floor": baseElevation des gebundenen Geschosses (+ optionalem Offset). + const floor = project.drawingLevels.find((z) => z.id === anchor.floorId); + if (!floor) return null; + return (floor.baseElevation ?? 0) + (anchor.offset ?? 0); +} + +/** + * Vertikale Ausdehnung der Wand (absolute Z-Werte in Metern). + * • zBottom: aus `wall.bottom`, sonst baseElevation des Wand-Geschosses + * (= heutiges Verhalten). + * • zTop: aus `wall.top`, sonst `zBottom + wall.height` (= heute). + * Sind beide UK/OK ungesetzt, liefert dies exakt `[baseElevation, base+height]`. + */ +export function wallVerticalExtent( + project: Project, + wall: Wall, +): { zBottom: number; zTop: number } { + // Geschoss-baseElevation als Default-UK (heutiges Verhalten). Fehlt das + // Geschoss, 0 als sicherer Rückfall. + let floorBase = 0; + try { + floorBase = getFloor(project, wall.floorId).baseElevation ?? 0; + } catch { + floorBase = 0; + } + const zBottom = resolveAnchor(project, wall.bottom) ?? floorBase; + const zTop = resolveAnchor(project, wall.top) ?? zBottom + wall.height; + return { zBottom, zTop }; +} + +/** + * Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der + * "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt. + * Dient der „Verknüpfung zum oberen Geschoss" (OK an nächstes Geschoss binden). + */ +export function nextFloorAbove( + project: Project, + wall: Wall, +): { id: string; name: string } | undefined { + const floors = project.drawingLevels.filter((z) => z.kind === "floor"); + const idx = floors.findIndex((z) => z.id === wall.floorId); + if (idx < 0) return undefined; + const above = floors[idx + 1]; + return above ? { id: above.id, name: above.name } : undefined; +} diff --git a/src/panels/AttributesPanel.tsx b/src/panels/AttributesPanel.tsx new file mode 100644 index 0000000..31b64f3 --- /dev/null +++ b/src/panels/AttributesPanel.tsx @@ -0,0 +1,177 @@ +// Inhalts-Panel „Attribute" — die Attribut-Palette (Vectorworks-Stil) für die +// aktuelle Auswahl. Zeigt die EFFEKTIV aufgelösten Stift-/Füll-Eigenschaften des +// ersten selektierten Elements und erlaubt das direkte Bearbeiten (Farbe, +// Strichstärke, Füllschraffur). +// +// Daten/Handler kommen ausschließlich über usePanelHost (kein Prop-Drilling). +// Es werden NUR Eigenschaften angeboten, die der Host-Kontrakt tatsächlich +// setzen kann — keine Stubs/Fake-Setter (Konvention wire-dont-stub). Felder ohne +// echten Setter (Linienstil) werden read-only/informativ gezeigt; Felder, die +// das Modell gar nicht trägt (Deckkraft, Caps, Schlagschatten), werden bewusst +// weggelassen, um die Palette ehrlich und schlank zu halten. +// +// DOSSIER-Tabellen-Look: KEINE wiederholten Feld-Labels pro Zeile — ein kompaktes +// Label→Wert-Grid. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). +// Alle sichtbaren Texte über t(...). + +import { t } from "../i18n"; +import { formatM } from "../model/types"; +import { PEN_WEIGHTS } from "../model/types"; +import { usePanelHost } from "./host"; + +export function AttributesPanel() { + const host = usePanelHost(); + const sel = host.selection; + const { project } = host; + + // Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist. + if (sel === null) { + return ( +
+
{t("attr.empty")}
+
+ ); + } + + const isDrawing = sel.kind === "drawing2d"; + // Strichstärke wirkt laut Kontrakt nur auf Drawing2D; Wände erben sie aus der + // Ebene und sind daher hier nicht editierbar. + const weightEditable = isDrawing; + // Füllung nur bei geschlossener 2D-Form sinnvoll. + const fillEditable = isDrawing && sel.closed === true; + + // Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake). + // Wir leiten ihn aus dem Modell-Element ab, wenn explizit gesetzt; sonst „—". + const drawing = isDrawing + ? project.drawings2d.find((d) => d.id === sel.id) + : undefined; + const lineStyle = drawing?.lineStyleId + ? project.lineStyles.find((l) => l.id === drawing.lineStyleId) + : undefined; + + const bboxW = sel.bbox.maxX - sel.bbox.minX; + const bboxH = sel.bbox.maxY - sel.bbox.minY; + + return ( +
+
{t("attr.title")}
+ + {/* ── Stift/Strich ──────────────────────────────────────────────── */} +
{t("attr.stroke")}
+
+ {/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */} + {t("attr.color")} + + + + + {/* Strichstärke (mm) — nur Drawing2D editierbar; Wände erben aus Ebene. */} + {t("attr.weight")} + + { + const v = Number(e.target.value); + if (Number.isFinite(v)) host.onSetSelectionWeight(v); + }} + /> + + {PEN_WEIGHTS.map((w) => ( + + + + {/* Linienstil — read-only/informativ (kein Setter im Kontrakt). */} + {t("attr.lineStyle")} + + {lineStyle ? lineStyle.name : "—"} + +
+ + {/* ── Füllung (nur geschlossene 2D-Form) ────────────────────────── */} +
{t("attr.fill")}
+
+ {/* Vollton-Füllfarbe (getrennt von der Strichfarbe). „keine" = transparent. */} + {t("attr.fillColor")} + + + + + {t("attr.hatch")} + + + +
+ + {/* ── Maße (informativ, aus der bbox) ───────────────────────────── */} +
{t("attr.size")}
+
+ {t("attr.width")} + {formatM(bboxW)} + {t("attr.height")} + {formatM(bboxH)} +
+
+ ); +} diff --git a/src/panels/DisplayModeSelect.tsx b/src/panels/DisplayModeSelect.tsx new file mode 100644 index 0000000..200548f --- /dev/null +++ b/src/panels/DisplayModeSelect.tsx @@ -0,0 +1,58 @@ +// Darstellungsmodus-Umschalter — kleines dunkles Dropdown für die Panel-Kopfzeile. +// +// Reines, kontrolliertes Steuerelement: zeigt die fünf DOSSIER-DisplayMode- +// Optionen (Alle anzeigen / Ausgewählte / Nur aktive / Andere grau / Andere +// grau & gesperrt) und meldet die Wahl per Callback. Es kennt weder Host noch +// Registry — der Rahmen (PanelFrame) reicht `value` und `onChange` durch, die +// App verdrahtet sie mit `host.displayMode`. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import type { DisplayMode } from "./types"; +import { t } from "../i18n"; + +/** + * Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die + * fünf DOSSIER-Modi aus docs/design/context-menu.md. + */ +const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [ + { value: "all_force", labelKey: "display.all_force" }, + { value: "all", labelKey: "display.all" }, + { value: "active", labelKey: "display.active" }, + { value: "grey", labelKey: "display.grey" }, + { value: "grey_locked", labelKey: "display.grey_locked" }, +]; + +/** + * Kleines Pill-Dropdown für den Darstellungsmodus. Vollständig kontrolliert: + * `value` ist der aktuelle Modus, `onChange` liefert den neuen. + */ +export function DisplayModeSelect({ + value, + onChange, + title, +}: { + /** Aktuell gewählter Darstellungsmodus. */ + value: DisplayMode; + /** Wird mit dem neuen Modus aufgerufen, wenn der Nutzer wählt. */ + onChange: (mode: DisplayMode) => void; + /** Optionaler Tooltip auf dem Dropdown (Default: i18n „Darstellungsmodus"). */ + title?: string; +}) { + const label = title ?? t("display.title"); + return ( + + ); +} diff --git a/src/panels/Dock.tsx b/src/panels/Dock.tsx new file mode 100644 index 0000000..c4fa4a3 --- /dev/null +++ b/src/panels/Dock.tsx @@ -0,0 +1,265 @@ +// Dock — eine angedockte Spalte (links oder rechts) mit GESTAPELTEN Gruppen. +// +// Ein Dock hält eine Liste vertikal gestapelter Gruppen (DockState.groups). Jede +// Gruppe ist ein eigener Tab-Stapel (TabStrip + aktives Panel im PanelFrame) und +// trägt ein Höhen-Gewicht; zwischen den Gruppen sitzt ein Splitter, der die +// Gewichte der Nachbarn verschiebt. So entsteht das Vectorworks-Bild (z. B. +// links Werkzeuge OBEN, Attribute UNTEN gleichzeitig). +// +// Jede Gruppe rendert ihren PanelFrame in einem EIGENEN Host-Provider, damit der +// Darstellungsmodus-Umschalter dem aktiven Tab DIESER Gruppe folgt. Den Provider +// baut der Host (App) und reicht ihn als `buildHost(activeTab)` herein — das Dock +// bleibt so vom konkreten Host-Modell entkoppelt. +// +// Breite ist per Ziehgriff an der INNEREN Kante veränderbar (rechte Kante beim +// linken Dock, linke Kante beim rechten Dock); `dock.size` ist die Quelle der +// Wahrheit, `onResize(size)` meldet jede Änderung zurück. Die Gruppen-Höhen +// meldet `onResizeGroups(weights)`. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useCallback, useRef, useState } from "react"; +import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; +import { PanelFrame } from "./PanelFrame"; +import { TabStrip } from "./TabStrip"; +import { getPanel } from "./registry"; +import { PanelHostContext } from "./types"; +import type { + DisplayMode, + DockId, + DockState, + PanelContext, + PanelHost, +} from "./types"; +import { t } from "../i18n"; + +/** Untere/obere Grenze der Dock-Breite in CSS-Pixeln (Voreinstellung). */ +const DEFAULT_MIN_WIDTH = 200; +const DEFAULT_MAX_WIDTH = 480; +/** Mindest-Pixelhöhe, die eine Gruppe beim Splitter-Ziehen behält. */ +const MIN_GROUP_PX = 70; + +export interface DockProps { + /** Welches Dock: bestimmt Rand + Seite des Ziehgriffs. */ + side: DockId; + /** Zustand dieses Docks (Gruppen + Breite). */ + dock: DockState; + /** + * Beginn einer Tab-Geste (pointerdown auf einem Tab) — an die TabStrip + * weitergereicht. Der Drag-Controller klärt Klick (aktivieren) vs. Ziehen + * (Reorder/Andocken/Lösen/Stapeln). + */ + onStartTabDrag: (panelId: string, e: React.PointerEvent) => void; + /** Breite geändert — liefert die neue (bereits geklemmte) Breite in px. */ + onResize: (size: number) => void; + /** Gruppen-Höhen geändert — liefert die neue Gewichts-Liste (so lang wie groups). */ + onResizeGroups: (weights: number[]) => void; + /** + * Baut den Host-Context-Wert für eine Gruppe (passend zu ihrem aktiven Tab). + * App reicht hier `hostWithMode(...)` herein; das Dock wickelt jede Gruppe in + * einen eigenen . + */ + buildHost: (activeTab: string | null) => PanelHost; + /** Generischer Kontext, den der PanelFrame an `def.render` weiterreicht. */ + ctx?: PanelContext; + /** Aktueller Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */ + displayModeFor: (activeTab: string | null) => DisplayMode; + /** Setter des Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */ + onDisplayModeChange: (activeTab: string | null, mode: DisplayMode) => void; + /** Minimale Breite in px (Voreinstellung 200). */ + minWidth?: number; + /** Maximale Breite in px (Voreinstellung 480). */ + maxWidth?: number; +} + +/** Begrenzt eine Breite auf [min, max]. */ +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Eine angedockte Spalte mit gestapelten Gruppen. Rendert je Gruppe TabStrip + + * aktives Panel (im eigenen Host-Provider) und erlaubt das Verändern von Breite + * (innere Kante) und Gruppen-Höhen (Splitter zwischen Gruppen). + */ +export function Dock({ + side, + dock, + onStartTabDrag, + onResize, + onResizeGroups, + buildHost, + ctx, + displayModeFor, + onDisplayModeChange, + minWidth = DEFAULT_MIN_WIDTH, + maxWidth = DEFAULT_MAX_WIDTH, +}: DockProps) { + // Wurzelelement, um die Breite während des Ziehens aus clientX zu berechnen. + const rootRef = useRef(null); + // Container der Gruppen (für die Gesamthöhe beim Gruppen-Splitter). + const stackRef = useRef(null); + // Breite während des aktiven Ziehens; null = nicht im Ziehen (dock.size gilt). + const [dragWidth, setDragWidth] = useState(null); + + const onResizeStart = useCallback( + (e: ReactPointerEvent) => { + // Nur primäre Taste / Berührung; Griff fängt den Zeiger ein, damit das + // Ziehen auch außerhalb des Elements weiterläuft. + e.preventDefault(); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + + const move = (ev: PointerEvent) => { + const root = rootRef.current; + if (!root) return; + const rect = root.getBoundingClientRect(); + // Linkes Dock wächst nach rechts (Zeiger − linke Kante); + // rechtes Dock wächst nach links (rechte Kante − Zeiger). + const raw = + side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX; + const next = clamp(Math.round(raw), minWidth, maxWidth); + setDragWidth(next); + onResize(next); + }; + + const up = (ev: PointerEvent) => { + setDragWidth(null); + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + handle.removeEventListener("pointercancel", up); + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId); + } + }; + + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + handle.addEventListener("pointercancel", up); + }, + [side, minWidth, maxWidth, onResize], + ); + + // Splitter zwischen Gruppe `index` und `index+1`: verschiebt Gewicht zwischen + // den beiden Nachbarn. Umgerechnet wird der Pixel-Versatz über die Gesamthöhe + // des Stapels in einen Gewichts-Anteil (Summe der Gewichte bleibt erhalten). + const onGroupResizeStart = useCallback( + (index: number, e: ReactPointerEvent) => { + e.preventDefault(); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + + const stack = stackRef.current; + const totalPx = stack ? stack.getBoundingClientRect().height : 0; + const weights = dock.groups.map((g) => g.weight); + const sumW = weights.reduce((s, w) => s + w, 0) || 1; + const startY = e.clientY; + const wA = weights[index]; + const wB = weights[index + 1]; + // Pixel ↔ Gewicht: ein Pixel entspricht sumW/totalPx an Gewicht. + const perPx = totalPx > 0 ? sumW / totalPx : 0; + const minW = MIN_GROUP_PX * perPx; + + const move = (ev: PointerEvent) => { + if (perPx === 0) return; + const dW = (ev.clientY - startY) * perPx; + // A wächst nach unten, B schrumpft; beide bleiben über dem Minimum. + let nextA = wA + dW; + let nextB = wB - dW; + const pair = wA + wB; + nextA = clamp(nextA, minW, pair - minW); + nextB = pair - nextA; + const out = [...weights]; + out[index] = nextA; + out[index + 1] = nextB; + onResizeGroups(out); + }; + + const up = (ev: PointerEvent) => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + handle.removeEventListener("pointercancel", up); + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId); + } + }; + + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + handle.addEventListener("pointercancel", up); + }, + [dock.groups, onResizeGroups], + ); + + const width = dragWidth ?? dock.size; + // Breite als CSS-Variable; styles.css liest --dock-w für flex-basis/width. + const style = { "--dock-w": `${width}px` } as CSSProperties; + + return ( + + ); +} diff --git a/src/panels/DrawingLevelsPanel.tsx b/src/panels/DrawingLevelsPanel.tsx new file mode 100644 index 0000000..c8e0fc4 --- /dev/null +++ b/src/panels/DrawingLevelsPanel.tsx @@ -0,0 +1,157 @@ +// Inhalts-Panel „Zeichnungsebenen" — die obere Navigator-Liste als Panel. +// +// Zeigt die Zeichnungsebenen (Geschosse + Schnitte/Ansichten/Zeichnungen) +// exakt wie der bisherige Navigator: EyeIcon schaltet die Sichtbarkeit, ein +// Kind-Badge zeigt die Art, die aktive Ebene ist hervorgehoben, Höhen-Readout +// (+OKFF) bei Geschossen. Kopfzeile mit + Geschoss / + Zeichnung. +// +// Neu gegenüber dem Inline-Navigator: das Panel deklariert hasDisplayMode und +// liest den gewählten DisplayMode aus dem Host-Context. `itemDisplay` +// entscheidet je Zeile, ob sie gezeichnet und ob sie ausgegraut wird (aktives +// Element = Arbeitsfokus, immer sichtbar). Daten/Handler kommen über +// usePanelHost — kein Prop-Drilling. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { EyeIcon } from "../ui/EyeIcon"; +import { itemDisplay } from "./displayMode"; +import { usePanelHost } from "./host"; +import type { DrawingLevel } from "./host"; +import type { DisplayMode } from "./types"; +import type { DrawingLevelKind } from "../model/types"; +import { t } from "../i18n"; + +/** i18n-Key des Kurzbadges für die Art einer Zeichnungsebene. */ +const KIND_BADGE_KEY: Record = { + floor: "badge.floor", + section: "badge.section", + elevation: "badge.elevation", + drawing: "badge.drawing", +}; + +/** Inhalt des Panels „Zeichnungsebenen". */ +export function DrawingLevelsPanel() { + const host = usePanelHost(); + const { project, activeLevelId, displayMode } = host; + + // Oberstes Geschoss zuoberst (wie DOSSIER): Anzeige umkehren. + const levelsTopDown = [...project.drawingLevels].reverse(); + + // Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf + // „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift. + const toggle = (id: string) => { + const m = displayMode.mode; + if (m === "all_force" || m === "active") displayMode.setMode("all"); + host.onToggleLevel(id); + }; + + // Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus). + const onRowContext = (id: string, e: React.MouseEvent) => { + e.preventDefault(); + host.onLevelContextMenu(id, e.clientX, e.clientY); + }; + + return ( +
+
+ {t("nav.drawingLevels")} + + + + +
+ +
+ {levelsTopDown.map((level) => ( + host.onSelectLevel(level.id)} + onToggle={() => toggle(level.id)} + onContext={(e) => onRowContext(level.id, e)} + /> + ))} +
+
+ ); +} + +/** Eine Zeile im Zeichnungsebenen-Bereich. */ +function LevelRow({ + level, + active, + mode, + onSelect, + onToggle, + onContext, +}: { + level: DrawingLevel; + active: boolean; + mode: DisplayMode; + onSelect: () => void; + onToggle: () => void; + onContext: (e: React.MouseEvent) => void; +}) { + // Darstellungsmodus auf diese Zeile anwenden: greyed steuert das Dimmen, + // render würde im Modus "active" Nicht-Aktive ausblenden. Im Navigator bleibt + // jede Zeile bedienbar (Liste = Steuerung), daher blenden wir nicht ganz aus, + // sondern dimmen nicht gezeichnete Zeilen wie ausgegraute. + const display = itemDisplay(mode, active, level.visible); + const dimmed = display.greyed || !display.render; + + // In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen, Flag + // ohne Wirkung). „grey_locked" sperrt nicht-aktive Zeilen. + const eyeDimmed = mode === "all_force"; + + const cls = [ + "nav-row", + active ? "active" : "", + display.locked ? "locked" : "", + // Bisheriges Verhalten: nicht-aktive, unsichtbare Zeilen gedimmt. + !active && !level.visible ? "hidden" : "", + ] + .filter(Boolean) + .join(" "); + + // Zusätzliches Dimmen aus dem DisplayMode (nicht in styles.css → inline). + // Aktiv bleibt immer voll sichtbar. + const style = !active && dimmed ? { opacity: 0.45 } : undefined; + + return ( +
+ + + {level.name} + + {level.kind === "floor" && level.baseElevation !== undefined && ( + +{level.baseElevation.toFixed(2)} + )} + + + {t(KIND_BADGE_KEY[level.kind])} + +
+ ); +} diff --git a/src/panels/FloatingPanel.tsx b/src/panels/FloatingPanel.tsx new file mode 100644 index 0000000..15084ab --- /dev/null +++ b/src/panels/FloatingPanel.tsx @@ -0,0 +1,214 @@ +// Schwebendes Panel — ein frei positioniertes, dunkles Fenster über der Mitte. +// +// Rendert denselben Panel-Inhalt wie ein Dock (über Registry + PanelFrame, inkl. +// Darstellungsmodus-Dropdown für Ebenen-Panels), aber in einem eigenen Fenster +// mit Titelleiste (zum Verschieben), Andock-/Schließen-Knopf und einem +// Greifer unten rechts zum Ändern der Größe. Ein Klick irgendwo im Fenster holt +// es nach vorne (z-Index). +// +// Verschieben/Größe ändern laufen über POINTER-Events mit Pointer-Capture, damit +// die Geste auch außerhalb des Fensters weiterläuft. Während des Ziehens der +// Titelleiste meldet `onTitleDrag` jede Cursorposition an den Host (App), der +// daraus eine mögliche Andock-Geste an einer Rand-Zone ableitet; beim Loslassen +// entscheidet `onTitleDrop`, ob das Fenster schwebend bleibt (verschoben) oder +// in ein Dock wandert. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useCallback, useRef } from "react"; +import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; +import { PanelFrame } from "./PanelFrame"; +import { getPanel } from "./registry"; +import type { DisplayMode, FloatingPanel as FloatingPanelState, PanelContext } from "./types"; +import { t } from "../i18n"; + +/** Mindestgröße eines schwebenden Fensters in CSS-Pixeln. */ +const MIN_W = 220; +const MIN_H = 160; + +export interface FloatingPanelProps { + /** Zustand (Position/Größe/z) dieses schwebenden Panels. */ + panel: FloatingPanelState; + /** Generischer Kontext für den Panel-Inhalt (an def.render). */ + ctx?: PanelContext; + /** Aktueller Darstellungsmodus (für Panels mit hasDisplayMode). */ + displayMode?: DisplayMode; + /** Setter des Darstellungsmodus (zeigt den Umschalter in der Kopfzeile). */ + onDisplayModeChange?: (mode: DisplayMode) => void; + /** Holt das Fenster nach vorne (z-Index) — bei Klick/Geste-Beginn. */ + onFocus: (panelId: string) => void; + /** Verschiebt das Fenster (Position-Patch). */ + onMove: (panelId: string, x: number, y: number) => void; + /** Ändert die Größe (w/h-Patch). */ + onResize: (panelId: string, w: number, h: number) => void; + /** Dockt das Fenster wieder an (Standard-Andockziel des Redock-Knopfs). */ + onRedock: (panelId: string) => void; + /** Schließt das Fenster (entfernt es ganz). */ + onClose: (panelId: string) => void; + /** + * Während des Titel-Ziehens: meldet die aktuelle Cursorposition (clientX/Y) an + * den Host. Der Host hebt mögliche Rand-Andock-Zonen hervor. + */ + onTitleDrag?: (panelId: string, clientX: number, clientY: number) => void; + /** + * Beim Loslassen der Titelleiste über einer Andock-Zone: meldet die finale + * Cursorposition. Liefert `true`, wenn der Host das Fenster angedockt hat (dann + * unterbleibt das reine Verschieben). Sonst bleibt das Fenster schwebend. + */ + onTitleDrop?: (panelId: string, clientX: number, clientY: number) => boolean; +} + +/** + * Ein einzelnes schwebendes Panel-Fenster. Titelleiste = verschieben (+ Andocken + * über Rand-Zonen), Greifer unten rechts = Größe ändern, Andock-/Schließen-Knopf + * in der Titelleiste. + */ +export function FloatingPanel({ + panel, + ctx, + displayMode, + onDisplayModeChange, + onFocus, + onMove, + onResize, + onRedock, + onClose, + onTitleDrag, + onTitleDrop, +}: FloatingPanelProps) { + const def = getPanel(panel.panelId); + // Verschiebe-Offset (Cursor − linke/obere Kante) für ein ruckelfreies Greifen. + const dragOffset = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0 }); + + const onTitlePointerDown = useCallback( + (e: ReactPointerEvent) => { + // Knöpfe in der Titelleiste nicht als Verschiebe-Start werten. + if ((e.target as HTMLElement).closest("button")) return; + e.preventDefault(); + onFocus(panel.panelId); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + dragOffset.current = { + dx: e.clientX - panel.x, + dy: e.clientY - panel.y, + }; + + const move = (ev: PointerEvent) => { + const x = ev.clientX - dragOffset.current.dx; + const y = ev.clientY - dragOffset.current.dy; + onMove(panel.panelId, x, y); + onTitleDrag?.(panel.panelId, ev.clientX, ev.clientY); + }; + const up = (ev: PointerEvent) => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + handle.removeEventListener("pointercancel", up); + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId); + } + // Über einer Rand-Zone? Dann dockt der Host an; sonst bleibt es schweben. + onTitleDrop?.(panel.panelId, ev.clientX, ev.clientY); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + handle.addEventListener("pointercancel", up); + }, + [panel.panelId, panel.x, panel.y, onFocus, onMove, onTitleDrag, onTitleDrop], + ); + + const onResizePointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + onFocus(panel.panelId); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + const startX = e.clientX; + const startY = e.clientY; + const startW = panel.w; + const startH = panel.h; + + const move = (ev: PointerEvent) => { + const w = Math.max(MIN_W, Math.round(startW + (ev.clientX - startX))); + const h = Math.max(MIN_H, Math.round(startH + (ev.clientY - startY))); + onResize(panel.panelId, w, h); + }; + const up = (ev: PointerEvent) => { + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + handle.removeEventListener("pointercancel", up); + if (handle.hasPointerCapture(ev.pointerId)) { + handle.releasePointerCapture(ev.pointerId); + } + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + handle.addEventListener("pointercancel", up); + }, + [panel.panelId, panel.w, panel.h, onFocus, onResize], + ); + + const style: CSSProperties = { + left: `${panel.x}px`, + top: `${panel.y}px`, + width: `${panel.w}px`, + height: `${panel.h}px`, + zIndex: 500 + panel.z, + }; + + const title = def ? t(def.title) : panel.panelId; + + return ( +
onFocus(panel.panelId)} + role="dialog" + aria-label={title} + > +
+ {title} +
+ + +
+
+ + {def ? ( + + ) : ( +
+ )} + + {/* Greifer unten rechts (Größe ändern). */} +
+
+ ); +} diff --git a/src/panels/LayersPanel.tsx b/src/panels/LayersPanel.tsx new file mode 100644 index 0000000..20d0def --- /dev/null +++ b/src/panels/LayersPanel.tsx @@ -0,0 +1,155 @@ +// Inhalts-Panel „Ebenen" — der Kategorie-Baum als Panel. +// +// Zeigt den Grafik-Kategorie-Baum (LayerCategory) exakt wie der bisherige +// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte +// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost. +// +// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive" +// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false; +// `itemDisplay` ergibt damit (fünf DOSSIER-Modi): +// • "all_force" — alle erzwungen sichtbar; Augen gedimmt. +// • "all" — alle sichtbaren normal (Standard, wie bisher). +// • "active" — keine Kategorie aktiv → alle gedimmt. +// • "grey" — sichtbare Kategorien ausgegraut. +// • "grey_locked" — wie „grey", zusätzlich gesperrt. +// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die +// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity +// (styles.css bleibt unangetastet). +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { EyeIcon } from "../ui/EyeIcon"; +import { itemDisplay } from "./displayMode"; +import { usePanelHost } from "./host"; +import type { LayerCategory } from "./host"; +import type { DisplayMode } from "./types"; +import { t } from "../i18n"; + +/** Inhalt des Panels „Ebenen". */ +export function LayersPanel() { + const host = usePanelHost(); + const { project, displayMode } = host; + + // Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf + // „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift. + const toggle = (code: string) => { + const m = displayMode.mode; + if (m === "all_force" || m === "active") displayMode.setMode("all"); + host.onToggleCategory(code); + }; + + // Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus). + const onRowContext = (code: string, e: React.MouseEvent) => { + e.preventDefault(); + host.onLayerContextMenu(code, e.clientX, e.clientY); + }; + + return ( +
+
+ {t("nav.layers")} + + + +
+ +
+ {project.layers.map((cat) => ( + + ))} +
+
+ ); +} + +/** Eine Kategorie-Zeile im Ebenen-Baum (rekursiv für Kinder). */ +function CategoryRow({ + category, + depth, + mode, + activeCode, + onSelect, + onToggle, + onContext, +}: { + category: LayerCategory; + depth: number; + mode: DisplayMode; + activeCode: string; + onSelect: (code: string) => void; + onToggle: (code: string) => void; + onContext: (code: string, e: React.MouseEvent) => void; +}) { + // „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt). + const isActive = category.code === activeCode; + const display = itemDisplay(mode, isActive, category.visible); + const dimmed = display.greyed || !display.render; + + // In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen). + const eyeDimmed = mode === "all_force"; + + const cls = [ + "cat-row", + isActive ? "active" : "", + display.locked ? "locked" : "", + !category.visible ? "hidden" : "", + ] + .filter(Boolean) + .join(" "); + + // Einrückung wie bisher; zusätzliches Dimmen aus dem DisplayMode inline. + // In „Alle anzeigen" erzwungen sichtbar → auch unsichtbare Zeilen normal. + const style: React.CSSProperties = { paddingLeft: 6 + depth * 14 }; + if (dimmed) style.opacity = 0.45; + + return ( + <> +
onSelect(category.code)} + onContextMenu={(e) => onContext(category.code, e)} + title={t("nav.categorySelect", { name: category.name })} + > + + {category.code} + + {category.name} +
+ {category.children?.map((child) => ( + + ))} + + ); +} diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx new file mode 100644 index 0000000..c077172 --- /dev/null +++ b/src/panels/ObjectInfoPanel.tsx @@ -0,0 +1,440 @@ +// Inhalts-Panel „Objekt-Info" (Vectorworks-Stil) — kompakte Sicht auf die +// aktuelle Auswahl: ein 3×3-Bezugspunkt-„Würfel", die X/Y-Koordinaten des +// gewählten Bezugspunkts (read-only) sowie editierbare Maße Breite×Höhe. +// +// Bei Auswahl GENAU EINER Wand zeigt das Panel zusätzlich einen Wand-Abschnitt +// (Referenzlinie, Aufbau-Typ/Dicke/Preset, Referenzgeschoss + UK/OK) — analog +// Vectorworks. Die Referenzlinie sitzt rechts oben im Kopf. +// +// Alle Daten/Handler kommen über usePanelHost (kein Prop-Drilling, kein +// Modellzugriff). Der gewählte Bezugspunkt (fx,fy ∈ {0,0.5,1}) ist lokaler +// State und wird als Anker an onResizeSelection durchgereicht: Beim Ändern +// der Maße bleibt genau dieser Punkt fix. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren +// Texte über t(...). + +import { useState } from "react"; +import { t } from "../i18n"; +import { formatM } from "../model/types"; +import type { VerticalAnchor, WallReferenceLine } from "../model/types"; +import { Dropdown } from "../ui/Dropdown"; +import { usePanelHost } from "./host"; +import type { WallInfo } from "../state/selectionInfo"; + +// ── Bezugspunkt-Raster ─────────────────────────────────────────────────────── +// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0). +// Reihenfolge des Arrays = Lesereihenfolge des Grids (links→rechts, oben→unten). +const FRACTIONS = [0, 0.5, 1] as const; + +/** Stabiler Schlüssel je Anker (z. B. "0-0.5"), unabhängig von Float-Anzeige. */ +function anchorKey(fx: number, fy: number): string { + return `${fx}-${fy}`; +} + +export function ObjectInfoPanel() { + const host = usePanelHost(); + const sel = host.selection; + + // Gewählter Bezugspunkt — Default Mitte (Vectorworks-Standardanker beim + // Skalieren). Bleibt erhalten, solange das Panel montiert ist. + const [anchor, setAnchor] = useState<{ fx: number; fy: number }>({ + fx: 0.5, + fy: 0.5, + }); + + // ── Leerzustand ─────────────────────────────────────────────────────────── + if (sel === null) { + return ( +
+
{t("objinfo.empty")}
+
+ ); + } + + const { bbox } = sel; + const width = bbox.maxX - bbox.minX; + const height = bbox.maxY - bbox.minY; + + // X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt + // bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket. + const px = bbox.minX + anchor.fx * width; + const py = bbox.minY + anchor.fy * height; + + // ── Maß-Commit ────────────────────────────────────────────────────────────── + // Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven + // Anker. Negative/ungültige Werte werden ignoriert (kein Resize). + function commitSize(nextWidth: number, nextHeight: number) { + if (!isFinite(nextWidth) || !isFinite(nextHeight)) return; + if (nextWidth < 0 || nextHeight < 0) return; + host.onResizeSelection(nextWidth, nextHeight, anchor); + } + + const wall = sel.wall; + + return ( +
+ {/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das + Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */} +
+ {t(`objinfo.kind.${sel.kind}`)} + {wall ? ( +
+ + {t("objinfo.wall.refLine")} + + + host.onSetWallReferenceLine(v as WallReferenceLine) + } + options={[ + { value: "left", label: t("objinfo.wall.refLine.left") }, + { value: "center", label: t("objinfo.wall.refLine.center") }, + { value: "right", label: t("objinfo.wall.refLine.right") }, + ]} + title={t("objinfo.wall.refLine")} + width={120} + /> +
+ ) : ( + {sel.categoryCode} + )} +
+ +
+ + {/* Bezugspunkt-Würfel (3×3). */} +
{t("objinfo.refpoint")}
+
+ {FRACTIONS.map((fy) => + FRACTIONS.map((fx) => { + const active = anchor.fx === fx && anchor.fy === fy; + return ( + + ); + }), + )} +
+ + {/* X / Y des gewählten Bezugspunkts (read-only). */} +
+ {t("objinfo.x")} + {formatM(px)} +
+
+ {t("objinfo.y")} + {formatM(py)} +
+ {/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element. + Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */} +
+ {t("objinfo.z")} + + — + +
+ +
+ + {/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */} +
{t("objinfo.dimensions")}
+ commitSize(w, height)} + /> + commitSize(width, h)} + /> + + {/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */} + {wall && } +
+ ); +} + +// ── Wand-Attribut-Abschnitt ────────────────────────────────────────────────── +// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss + +// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host. +function WallSection({ + wall, + host, +}: { + wall: WallInfo; + host: ReturnType; +}) { + // Aufbau-Typ-Segment: einschichtig vs. mehrschichtig. Aus dem Modell + // abgeleitet (singleLayer); der Umschalter auf „mehrschichtig" wählt einen + // mehrschichtigen Wandtyp-Preset (erster mit >1 Schicht), auf „einschichtig" + // setzt er die Dicke des aktuellen Aufbaus als einschichtige Wand. + const isSingle = wall.singleLayer; + + const multiPresets = wall.wallTypes.filter((wt) => wt.layerCount > 1); + + function chooseSingle() { + if (isSingle) return; + // Auf einschichtig wechseln: aktuelle Gesamtdicke als Einzelschicht setzen. + host.onSetWallThickness(wall.thickness); + } + function chooseMulti() { + if (!isSingle) return; + const first = multiPresets[0]; + if (first) host.onSetWallType(first.id); + } + + return ( + <> +
+
{t("objinfo.wall.section")}
+ + {/* Aufbau-Typ-Segment. */} +
+ {t("objinfo.wall.buildup")} +
+ + +
+
+ + {/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */} + {isSingle ? ( + host.onSetWallThickness(v)} + /> + ) : ( +
+ {t("objinfo.wall.preset")} + host.onSetWallType(id)} + options={wall.wallTypes.map((wt) => ({ + value: wt.id, + label: t("objinfo.wall.presetLabel", { + name: wt.name, + thickness: wt.thickness.toFixed(2), + }), + }))} + title={t("objinfo.wall.preset")} + width={150} + /> +
+ )} + + {/* Referenzgeschoss (read-only Anzeige). */} +
+ {t("objinfo.wall.refFloor")} + {wall.floorName} +
+ + {/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */} + + +
+ + {/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */} + host.onSetWallBottom(a)} + /> + host.onSetWallTop(a)} + /> + + {/* Höhe = OK − UK (abgeleitet, read-only). */} +
+ {t("objinfo.wall.height")} + {formatM(wall.zTop - wall.zBottom)} +
+ + ); +} + +// ── UK/OK-Zeile: Modus-Umschalter (Geschoss/eigene Höhe) + Feld ────────────── +// „Geschoss gebunden" → Geschoss-Dropdown; „eigene Höhe" → absolutes Z-Feld. +// `null`-Anchor (undefined im Modell) = Geschoss-Default; zeigt „eigene Höhe" +// mit dem aufgelösten Default-Z als Ausgangswert NICHT — Default ist +// „an Geschoss gebunden" (Geschoss-Default-Verhalten). +function VerticalAnchorRow({ + label, + anchor, + defaultZ, + floors, + defaultFloorId, + onChange, +}: { + label: string; + anchor?: VerticalAnchor; + defaultZ: number; + floors: { id: string; name: string }[]; + defaultFloorId: string; + onChange: (a: VerticalAnchor | null) => void; +}) { + const mode: "floor" | "custom" = anchor?.mode === "custom" ? "custom" : "floor"; + + function setMode(next: "floor" | "custom") { + if (next === mode) return; + if (next === "custom") { + onChange({ mode: "custom", z: defaultZ }); + } else { + // Zurück auf Geschoss-Bindung: bei vorhandenem expliziten Geschoss dieses, + // sonst Default-Geschoss → entspricht dem Geschoss-Default-Verhalten. + onChange({ mode: "floor", floorId: defaultFloorId }); + } + } + + return ( +
+
+ {label} +
+ + +
+
+ {mode === "floor" ? ( +
+ onChange({ mode: "floor", floorId: id })} + options={floors.map((f) => ({ value: f.id, label: f.name }))} + title={label} + width={150} + /> +
+ ) : ( +
+ onChange({ mode: "custom", z })} + /> +
+ )} +
+ ); +} + +// ── Editierbares Maßfeld ───────────────────────────────────────────────────── +// Kontrolliert über den vom Host gelieferten Wert, hält aber während des +// Tippens einen lokalen Entwurf. Commit auf blur/Enter; Escape verwirft. +// Nach erfolgreichem Resize liefert der Host eine neue bbox → neuer `value` → +// das Feld zeigt automatisch die aktualisierte Größe. `min` erlaubt negative +// Werte (z. B. absolute Z-Höhen unter 0). +function DimensionField({ + label, + value, + onCommit, + min = 0, +}: { + label: string; + value: number; + onCommit: (v: number) => void; + min?: number; +}) { + // Entwurf als String, damit Zwischenzustände (leer, „1.") tippbar sind. + // `null` = kein aktiver Entwurf → zeige den Host-Wert. + const [draft, setDraft] = useState(null); + + const shown = draft ?? value.toFixed(3); + + function commit() { + if (draft === null) return; + const v = Number(draft); + setDraft(null); + if (isFinite(v) && v >= min) onCommit(v); + } + + return ( + + ); +} diff --git a/src/panels/PanelFrame.tsx b/src/panels/PanelFrame.tsx new file mode 100644 index 0000000..309fbe4 --- /dev/null +++ b/src/panels/PanelFrame.tsx @@ -0,0 +1,67 @@ +// Panel-Rahmen — Kopfzeile + scrollbarer Körper um einen Panel-Inhalt. +// +// Der Rahmen ist inhaltsneutral: er bekommt eine PanelDef (für Titel und das +// Flag hasDisplayMode) und den generischen PanelContext, mit dem er +// `def.render(ctx)` aufruft. Den eigentlichen Inhalt liefert also das Panel +// selbst; der Rahmen kümmert sich nur um Titelzeile, optionalen +// Darstellungsmodus-Umschalter und den überlaufenden, scrollbaren Körper. +// +// Der Darstellungsmodus wird von außen durchgereicht (value + onChange), damit +// der Rahmen nichts vom Host-Context wissen muss. Der Umschalter erscheint nur, +// wenn das Panel ihn deklariert (def.hasDisplayMode) UND ein onChange anliegt. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { DisplayModeSelect } from "./DisplayModeSelect"; +import type { DisplayMode, PanelContext, PanelDef } from "./types"; +import { t } from "../i18n"; + +export interface PanelFrameProps { + /** Definition des darzustellenden Panels (liefert Titel + hasDisplayMode). */ + def: PanelDef; + /** Generischer Kontext, der an `def.render` weitergereicht wird. */ + ctx?: PanelContext; + /** + * Aktueller Darstellungsmodus. Nur relevant, wenn das Panel + * `hasDisplayMode` deklariert; der Umschalter ist sonst ausgeblendet. + */ + displayMode?: DisplayMode; + /** + * Setter für den Darstellungsmodus. Liegt er an und deklariert das Panel + * `hasDisplayMode`, zeigt die Kopfzeile das Dropdown. + */ + onDisplayModeChange?: (mode: DisplayMode) => void; +} + +/** + * Container eines einzelnen Panels: Kopfzeile (Titel + optionaler + * Darstellungsmodus-Umschalter) über einem scrollbaren Körper, der den + * gerenderten Panel-Inhalt aufnimmt. + */ +export function PanelFrame({ + def, + ctx, + displayMode, + onDisplayModeChange, +}: PanelFrameProps) { + // Umschalter nur, wenn das Panel ihn will UND er verdrahtet ist. + const showMode = + Boolean(def.hasDisplayMode) && + onDisplayModeChange !== undefined && + displayMode !== undefined; + + return ( +
+
+ {t(def.title)} + {showMode && ( + void} + /> + )} +
+
{def.render(ctx ?? {})}
+
+ ); +} diff --git a/src/panels/ResourcesPanel.tsx b/src/panels/ResourcesPanel.tsx new file mode 100644 index 0000000..a810bd0 --- /dev/null +++ b/src/panels/ResourcesPanel.tsx @@ -0,0 +1,91 @@ +// Inhalts-Panel „Ressourcen" — dünner Adapter um den ResourceManager. +// +// Wiederverwendet die bestehende -Komponente (Bauteile / +// Schraffuren / Linien) UNVERÄNDERT, rendert sie aber INLINE als Panel-Körper +// statt als rechte Schublade. Projekt + Handler kommen aus dem Host-Context. +// +// ResourceManager ist als Overlay-Schublade gebaut (.res-overlay fixed/inset:0 +// → .res-drawer mit fester Breite, Rand und Schatten) und erwartet onClose. +// Da styles.css und ResourceManager selbst nicht angefasst werden dürfen, +// zähmt dieser Adapter die Schubladen-Chrome lokal: ein eingebetteter, +// gescopter + +
+ ); +} + +/** onClose-Platzhalter: als Panel gibt es keinen Schubladen-Schließer. */ +const NO_OP = () => {}; + +/** + * Lokale Override-Regeln, die die fixe Overlay-Schublade in einen normalen, + * im Panel sitzenden Block verwandeln. Klassen-Selektoren sind unter + * `.panel-resources-host` verschachtelt, damit nichts außerhalb des Panels + * betroffen ist. + */ +const PANEL_RESOURCES_CSS = ` +.panel-resources-host { + height: 100%; + min-height: 0; + display: flex; +} +.panel-resources-host .res-overlay { + position: static; + inset: auto; + z-index: auto; + background: transparent; + display: flex; + flex: 1; + min-height: 0; + justify-content: stretch; +} +.panel-resources-host .res-drawer { + width: 100%; + height: 100%; + border-left: none; + box-shadow: none; +} +/* Im Panel ohne Schubladen-Schließer. */ +.panel-resources-host .res-close { + display: none; +} +`; diff --git a/src/panels/SitePanel.tsx b/src/panels/SitePanel.tsx new file mode 100644 index 0000000..240cca8 --- /dev/null +++ b/src/panels/SitePanel.tsx @@ -0,0 +1,125 @@ +// Inhalts-Panel „Gelände & Kontext" — verwaltet die Kontext-Schicht +// (importierte Meshes, Höhenlinien-Sätze, abgeleitetes Gelände-TIN). Daten/ +// Handler kommen über usePanelHost (kein Prop-Drilling). +// +// Aufbau: +// • Aktions-Zeile: „DXF importieren" (öffnet den in App montierten Datei- +// Dialog) + „Gelände erzeugen" (aus dem gewählten Kontur-Satz). +// • Liste der Kontext-Objekte: Name + Typ-Badge + „Entfernen". +// • DWG-Hinweis am Fuß. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren +// Texte über t(...). + +import { useState } from "react"; +import { t } from "../i18n"; +import type { TranslationKey } from "../i18n"; +import { usePanelHost } from "./host"; +import type { ContextObject } from "../model/types"; + +/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */ +function typeBadgeKey(obj: ContextObject): TranslationKey { + switch (obj.type) { + case "importedMesh": + return "site.type.mesh"; + case "contourSet": + return "site.type.contours"; + case "terrainMesh": + return "site.type.terrain"; + } +} + +export function SitePanel() { + const host = usePanelHost(); + const objects = host.contextObjects; + const contourSets = objects.filter((o) => o.type === "contourSet"); + + // Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener). + const [selectedSet, setSelectedSet] = useState(""); + const effectiveSet = + selectedSet && contourSets.some((c) => c.id === selectedSet) + ? selectedSet + : (contourSets[0]?.id ?? ""); + + const onGenerate = () => { + if (!effectiveSet) return; + const id = host.onGenerateTerrain(effectiveSet); + if (id === null) { + // Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox). + // eslint-disable-next-line no-alert + alert(t("site.terrainFailed")); + } + }; + + return ( +
+ {/* Aktions-Zeile: Import + Gelände-Erzeugung. */} +
+ +
+ + {contourSets.length > 0 && ( +
+ {contourSets.length > 1 ? ( + + ) : ( + + {contourSets[0].name} + + )} + +
+ )} + +
+ + {/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */} + {objects.length === 0 ? ( +
{t("site.empty")}
+ ) : ( +
    + {objects.map((obj) => ( +
  • + + {obj.name} + + + {t(typeBadgeKey(obj))} + + +
  • + ))} +
+ )} + +
{t("site.dwgHint")}
+
+ ); +} diff --git a/src/panels/TabStrip.tsx b/src/panels/TabStrip.tsx new file mode 100644 index 0000000..29b290c --- /dev/null +++ b/src/panels/TabStrip.tsx @@ -0,0 +1,78 @@ +// Tab-Leiste eines Docks — ein Tab je Panel-ID, Titel aus der Registry. +// +// Rein darstellend: bekommt einen DockState (Reihenfolge der Panel-IDs + +// aktiver Tab) und meldet das Greifen eines Tabs über `onStartDrag`. Der +// Drag-Controller (panelDrag) entscheidet anhand der Bewegung, ob daraus ein +// Klick (Tab aktivieren) oder ein Ziehen (Reorder/Andocken/Lösen) wird — die +// Leiste selbst kennt diese Logik nicht. +// +// Titel werden über die Registry (getPanel) aufgelöst, sodass die Leiste nichts +// über konkrete Panels weiß. IDs ohne registriertes Panel (z. B. ein +// abgeschaltetes Plugin) werden übersprungen — vgl. Hinweis in layout.ts. +// +// Datenattribute für die Drag-Trefferprüfung (panelDrag.resolveHover): +// data-tabstrip="" an der Leiste, +// data-group-index="" an der Leiste (welche Gruppe im Dock), +// data-tab-index="" an jedem Tab. +// +// Viele Tabs laufen nicht um, sondern werden horizontal scrollbar (die Leiste +// trägt .tabstrip mit overflow-x in styles.css). So bleibt jeder Tab erreichbar, +// ohne ein zusätzliches Überlaufmenü zu brauchen. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { getPanel } from "./registry"; +import type { DockGroup, DockId } from "./types"; +import { t } from "../i18n"; + +export interface TabStripProps { + /** Welches Dock diese Leiste darstellt (für die Drag-Auflösung). */ + side: DockId; + /** Index der Gruppe im Dock (für die Drag-Auflösung). */ + groupIndex: number; + /** Zustand der Gruppe (liefert tabs-Reihenfolge + activeTab). */ + group: DockGroup; + /** + * Beginn einer Tab-Geste (pointerdown auf einem Tab). Der Drag-Controller + * entscheidet über die Bewegung, ob Klick oder Ziehen. + */ + onStartDrag: (panelId: string, e: React.PointerEvent) => void; +} + +/** + * Reihe greifbarer Tabs für eine Dock-Gruppe. Der aktive Tab ist hervorgehoben; + * das Greifen eines Tabs meldet `onStartDrag` (Klick vs. Ziehen klärt der + * Controller). + */ +export function TabStrip({ side, groupIndex, group, onStartDrag }: TabStripProps) { + return ( +
+ {group.tabs.map((id, index) => { + const def = getPanel(id); + // Unbekannte ID (kein registriertes Panel) überspringen. + if (!def) return null; + const active = id === group.activeTab; + const title = t(def.title); + return ( + + ); + })} +
+ ); +} diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx new file mode 100644 index 0000000..3541659 --- /dev/null +++ b/src/panels/ToolsPanel.tsx @@ -0,0 +1,215 @@ +// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/ +// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das +// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist — +// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen. +// +// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Die Werkzeug- +// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool). +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren +// Texte über t(...). + +import { t } from "../i18n"; +import { usePanelHost } from "./host"; +import type { ToolId } from "./host"; +import { TOOL_ORDER, getTool } from "../tools/tools"; + +// ── Icons ────────────────────────────────────────────────────────────────── +// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug +// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der +// Zeile übernimmt. +function ToolIcon({ id }: { id: ToolId }) { + const common = { + width: 16, + height: 16, + viewBox: "0 0 16 16", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.4, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + }; + switch (id) { + case "select": + // Pfeil-Cursor. + return ( + + + + ); + case "wall": + // Zwei parallele Linien (Wandband). + return ( + + + + + ); + case "line": + // Einzelne Diagonale. + return ( + + + + ); + case "polyline": + // Dreisegmentiger Zickzack. + return ( + + + + ); + case "rect": + // Rechteck-Umriss. + return ( + + + + ); + default: + return null; + } +} + +// ── Panel ──────────────────────────────────────────────────────────────────── + +export function ToolsPanel() { + const host = usePanelHost(); + const snap = host.snap; + + return ( +
+ {/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */} + {TOOL_ORDER.map((id) => { + const tool = getTool(id); + const active = host.activeTool === id; + const disabled = id !== "select" && !host.toolsEnabled; + const title = disabled + ? t("tool.floorOnlyDisabled") + : t(tool.hintKey(tool.init())); + const cls = + "tool-row" + (active ? " active" : "") + (disabled ? " disabled" : ""); + return ( + + ); + })} + +
+ + {/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */} + {host.activeTool === "wall" && ( + + )} + + {host.activeTool === "wall" &&
} + + {/* Fang-Abschnitt. Master-Checkbox steuert die übrigen Fang-Optionen. */} + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/panels/builtinPanels.tsx b/src/panels/builtinPanels.tsx new file mode 100644 index 0000000..d069dc9 --- /dev/null +++ b/src/panels/builtinPanels.tsx @@ -0,0 +1,81 @@ +// Registrierung der eingebauten Inhalts-Panels. +// +// Importiert man dieses Modul (Seiteneffekt), tragen sich die beiden +// dockbaren Kern-Panels in die Registry ein und tauchen damit in den Docks auf: +// • "drawing-levels" — Zeichnungsebenen (mit Darstellungsmodus). +// • "layers" — Ebenen (Kategorie-Baum, mit Darstellungsmodus). +// +// „Ressourcen" ist KEIN Dock-Panel mehr: der ResourceManager ist ein +// schwebendes Fenster (Overlay über allem), das die Oberleiste öffnet — siehe +// App.tsx. Es wird daher hier nicht (mehr) registriert. +// +// Reine Verdrahtung: jedes Panel liest seinen Zustand selbst über den +// Host-Context (usePanelHost), daher reicht render() ohne ctx-Auswertung. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { registerPanel } from "./registry"; +import { DrawingLevelsPanel } from "./DrawingLevelsPanel"; +import { LayersPanel } from "./LayersPanel"; +import { ToolsPanel } from "./ToolsPanel"; +import { AttributesPanel } from "./AttributesPanel"; +import { ObjectInfoPanel } from "./ObjectInfoPanel"; +import { SitePanel } from "./SitePanel"; + +/** IDs der eingebauten Dock-Panels (auch für defaultLayout/Filter nutzbar). */ +export const BUILTIN_PANEL_IDS = { + tools: "tools", + drawingLevels: "drawing-levels", + layers: "layers", + attributes: "attributes", + objectInfo: "object-info", + site: "site", +} as const; + +// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil). +registerPanel({ + id: BUILTIN_PANEL_IDS.tools, + title: "nav.tools", + hasDisplayMode: false, + render: () => , +}); + +// Attribute-Palette (Stift/Füllung/Strichstärke der Auswahl, Vectorworks-Stil). +registerPanel({ + id: BUILTIN_PANEL_IDS.attributes, + title: "nav.attributes", + hasDisplayMode: false, + render: () => , +}); + +// Objekt-Info-Palette (Bezugspunkt-Würfel + X/Y, Maße B×H der Auswahl). +registerPanel({ + id: BUILTIN_PANEL_IDS.objectInfo, + title: "nav.objectInfo", + hasDisplayMode: false, + render: () => , +}); + +registerPanel({ + id: BUILTIN_PANEL_IDS.drawingLevels, + // i18n-Key statt fertigem Text: TabStrip/PanelFrame lösen ihn über t() auf, + // damit der Titel der gewählten Sprache folgt. + title: "nav.drawingLevels", + hasDisplayMode: true, + render: () => , +}); + +registerPanel({ + id: BUILTIN_PANEL_IDS.layers, + title: "nav.layers", + hasDisplayMode: true, + render: () => , +}); + +// Gelände-/Kontext-Palette (DXF-Import, Konturen-Liste, Gelände-Erzeugung). +registerPanel({ + id: BUILTIN_PANEL_IDS.site, + title: "nav.site", + hasDisplayMode: false, + render: () => , +}); diff --git a/src/panels/displayMode.ts b/src/panels/displayMode.ts new file mode 100644 index 0000000..3bbacb8 --- /dev/null +++ b/src/panels/displayMode.ts @@ -0,0 +1,69 @@ +// Darstellungsmodus — reiner Helfer. +// +// Übersetzt einen DisplayMode plus den Status eines Elements (aktiv? sichtbar?) +// in eine Render-Entscheidung: ob das Element gezeichnet wird, ob es ausgegraut +// erscheint und ob es gesperrt ist. Dieselbe Logik nutzen Navigator-Listen und +// die abgeleiteten Ansichten (3D, Grundriss), damit die Auswahl überall +// identisch wirkt. Die fünf Modi entsprechen DOSSIER (docs/design/context-menu.md). +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Bewusst seiteneffektfrei. + +import type { DisplayMode } from "./types"; + +/** Ergebnis von `itemDisplay`: zeichnen? ausgegraut? gesperrt? */ +export interface ItemDisplay { + /** Element zeichnen. */ + render: boolean; + /** Element ausgegraut (gedimmt) darstellen. */ + greyed: boolean; + /** Element gesperrt (nicht bearbeitbar) — nur im Modus „grey_locked". */ + locked: boolean; +} + +/** + * Entscheidet anhand des Modus und des Element-Status, wie ein Element + * dargestellt wird — die fünf DOSSIER-Modi: + * + * • "all_force" — alle erzwungen zeichnen (Sichtbarkeits-Flag ignoriert), + * nie ausgegraut. + * • "all" — nur sichtbare zeichnen, nie ausgegraut. + * • "active" — nur das aktive Element zeichnen, nie ausgegraut. + * • "grey" — aktives normal; übrige nur, wenn sichtbar — dann + * ausgegraut. + * • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich + * gesperrt. + * + * Das aktive Element wird in "active"/"grey"/"grey_locked" stets gezeichnet, + * auch wenn seine Sichtbarkeit abgeschaltet ist — es ist der Arbeitsfokus. Ein + * nicht aktives, unsichtbares Element wird (außer in "all_force") nie + * gezeichnet. + */ +export function itemDisplay( + mode: DisplayMode, + isActive: boolean, + isVisible: boolean, +): ItemDisplay { + switch (mode) { + case "all_force": + // Alles erzwungen sichtbar (Flags ignoriert), normal. + return { render: true, greyed: false, locked: false }; + + case "all": + return { render: isVisible, greyed: false, locked: false }; + + case "active": + // Nur das aktive Element; andere werden nicht gezeichnet. + return { render: isActive, greyed: false, locked: false }; + + case "grey": { + const other = isVisible && !isActive; + return { render: isActive || isVisible, greyed: other, locked: false }; + } + + case "grey_locked": { + // Wie „grey", übrige sichtbare zusätzlich gesperrt. + const other = isVisible && !isActive; + return { render: isActive || isVisible, greyed: other, locked: other }; + } + } +} diff --git a/src/panels/host.ts b/src/panels/host.ts new file mode 100644 index 0000000..8934671 --- /dev/null +++ b/src/panels/host.ts @@ -0,0 +1,197 @@ +// Host-Vertrag für Inhalts-Panels — die konkrete Form des PanelHostContext. +// +// Die Foundation (types.ts) hält den Context bewusst lose (Record), damit der Kern nicht vom Projekt-/Handler-Modell abhängt. Die +// eingebauten Panels brauchen jedoch eine klare, getippte Sicht auf den Host. +// Dieses Modul definiert daher EINEN Vertrag (`PanelHostValue`) und einen Hook +// (`usePanelHost`), der den Context liest, gegen das Fehlen eines Providers +// absichert und das Ergebnis auf diesen Vertrag verengt. +// +// App stellt einen Wert dieser Form über bereit +// (siehe Report am Ende der Aufgabe). Bezeichner englisch, Kommentare deutsch +// (CONVENTIONS.md). + +import { useContext } from "react"; +import { PanelHostContext } from "./types"; +import type { DisplayMode } from "./types"; +import type { + Component, + ContextObject, + DrawingLevel, + HatchStyle, + LayerCategory, + LineStyle, + Project, + VerticalAnchor, + WallReferenceLine, +} from "../model/types"; +import type { SnapSettings, ToolId } from "../tools/types"; +import type { Selection } from "../state/selectionInfo"; + +// ── Darstellungsmodus-Steuerung je Dock-Inhalt ──────────────────────────── + +/** + * Aktueller Darstellungsmodus plus Setter. Panels, die `hasDisplayMode: true` + * deklarieren, lesen `mode` und gruppieren ihre Zeilen danach (über + * `itemDisplay` aus displayMode.ts). Der Umschalter selbst sitzt in der + * Panel-Kopfzeile des Rahmens und ruft `setMode`. + */ +export interface DisplayModeControl { + mode: DisplayMode; + setMode: (mode: DisplayMode) => void; +} + +// ── Vollständiger Host-Vertrag ───────────────────────────────────────────── + +/** + * Was die eingebauten Panels vom Host erwarten. App reicht genau dieses Objekt + * über hinein; jedes Feld entspricht + * einem Stück Zustand bzw. einem immutablen Handler, der heute in App.tsx als + * Closure über setProject existiert. + */ +export interface PanelHostValue { + // Projektzustand (Single Source of Truth aus App). + project: Project; + + // Aktive Auswahl (Zeichnungsebene) — Arbeitsfokus für „active"/„grey". + activeLevelId: string; + + // ── Werkzeug-Palette (Tools-Panel) ────────────────────────────────────── + /** Aktives Zeichenwerkzeug. */ + activeTool: ToolId; + /** Werkzeug wählen. */ + onSelectTool: (id: ToolId) => void; + /** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */ + toolsEnabled: boolean; + /** Aktiver Wandtyp (für das Wand-Werkzeug). */ + activeWallTypeId: string; + onActiveWallTypeId: (id: string) => void; + /** Snap-Einstellungen + Setter (Fang-Optionen in der Werkzeug-Palette). */ + snap: SnapSettings; + onSnapChange: (s: SnapSettings) => void; + + // Darstellungsmodus für ortsabhängige Panels (DrawingLevels, Layers). + displayMode: DisplayModeControl; + + // Zeichnungsebenen-Handler. + onSelectLevel: (id: string) => void; + onToggleLevel: (id: string) => void; + onAddFloor: () => void; + onAddDrawing: () => void; + /** + * Rechtsklick auf eine Zeichnungsebenen-Zeile: öffnet das Kontextmenü an der + * Cursorposition. App hält das offene Menü und baut die Einträge + * (Einstellungen / Duplizieren / Löschen). + */ + onLevelContextMenu: (id: string, clientX: number, clientY: number) => void; + + // Ebenen-(Kategorie-)Handler. + /** Aktive Ebene (Kategorie-Code) — Ziel neuer Zeichnungen; per Klick wählbar. */ + activeCategoryCode: string; + /** Eine Ebene zur aktiven machen (Klick auf die Zeile im Ebenen-Panel). */ + onSelectCategory: (code: string) => void; + onToggleCategory: (code: string) => void; + onAddCategory: () => void; + /** + * Rechtsklick auf eine Ebenen-(Kategorie-)Zeile: öffnet das Kontextmenü an der + * Cursorposition. App baut die Einträge (Einstellungen / Sub-Ebene / + * Selektion übertragen / Duplizieren / Eigenschaften kopieren+einfügen / + * Löschen) und hält das offene Menü. + */ + onLayerContextMenu: (code: string, clientX: number, clientY: number) => void; + + // Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt + // ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von + // der UI-Komponente abhängt. + onPatchComponent: (id: string, patch: Partial) => void; + onAddComponent: () => void; + onDeleteComponent: (id: string) => void; + onPatchHatch: (id: string, patch: Partial) => void; + onAddHatch: () => void; + onDeleteHatch: (id: string) => void; + onPatchLineStyle: (id: string, patch: Partial) => void; + onAddLineStyle: () => void; + onDeleteLineStyle: (id: string) => void; + /** Import fertiger (id-loser) Linienstile/Schraffuren (.lin/.pat). */ + onImportLineStyles: (styles: Omit[]) => void; + onImportHatches: (hatches: Omit[]) => void; + + // ── Selektions-Attribute (Attributes-/Object-Info-Paletten) ───────────── + /** + * Normalisierte, effektiv aufgelöste Sicht auf das ERSTE selektierte Element + * (oder `null`). Die Paletten lesen daraus Farbe/Strichstärke/Füllung/bbox + * und grauen nicht-anwendbare Felder per `selection.kind` aus. + */ + selection: Selection | null; + /** Setzt die (Strich-)Farbe der aktuellen Selektion (Wand oder Drawing2D). */ + onSetSelectionColor: (color: string) => void; + /** Setzt die Strichstärke (mm) — wirkt NUR auf Drawing2D (sonst No-op). */ + onSetSelectionWeight: (weightMm: number) => void; + /** Setzt/entfernt die Füllschraffur — wirkt NUR auf Drawing2D (sonst No-op). */ + onSetSelectionFill: (hatchId: string | null) => void; + /** + * Setzt/entfernt die Vollton-Füllfarbe — wirkt NUR auf Drawing2D (sonst No-op). + * `null` entfernt die Füllfarbe (Fläche wieder transparent). + */ + onSetSelectionFillColor: (color: string | null) => void; + /** + * Skaliert die Selektion auf Zielbreite×-höhe (Meter) um einen Anker + * (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links … 1,1 = unten-rechts). + */ + onResizeSelection: ( + w: number, + h: number, + anchor: { fx: number; fy: number }, + ) => void; + + // ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─ + /** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */ + onSetWallReferenceLine: (ref: WallReferenceLine) => void; + /** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */ + onSetWallType: (wallTypeId: string) => void; + /** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */ + onSetWallThickness: (thickness: number) => void; + /** Setzt/entfernt die UK-Bindung (`null` = Geschoss-Default). */ + onSetWallBottom: (anchor: VerticalAnchor | null) => void; + /** Setzt/entfernt die OK-Bindung (`null` = UK + height). */ + onSetWallTop: (anchor: VerticalAnchor | null) => void; + + // ── Site-/Kontext-Schicht (SitePanel) ─────────────────────────────────── + /** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */ + contextObjects: ContextObject[]; + /** Fügt mehrere Kontext-Objekte hinzu (z. B. aus einem DXF-Import). */ + onAddContextObjects: (objs: ContextObject[]) => void; + /** Entfernt ein Kontext-Objekt per ID. */ + onRemoveContextObject: (id: string) => void; + /** + * Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue + * TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN). + */ + onGenerateTerrain: (contourSetId: string) => string | null; + /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ + onImportDxf: () => void; +} + +// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so +// importieren Panels nur aus „./host" und bleiben auf einen Pfad fokussiert. +export type { DrawingLevel, LayerCategory }; +export type { SnapSettings, ToolId }; +export type { Selection } from "../state/selectionInfo"; + +// ── Hook ─────────────────────────────────────────────────────────────────── + +/** + * Liest den Host-Context und verengt ihn auf `PanelHostValue`. Wirft, wenn das + * Panel außerhalb eines Providers gerendert wird — das ist immer ein + * Programmierfehler im Rahmen und soll laut scheitern statt still „leer" + * anzuzeigen. + */ +export function usePanelHost(): PanelHostValue { + const host = useContext(PanelHostContext); + if (host === null) { + throw new Error( + "usePanelHost: außerhalb von verwendet.", + ); + } + return host as unknown as PanelHostValue; +} diff --git a/src/panels/layout.ts b/src/panels/layout.ts new file mode 100644 index 0000000..f227808 --- /dev/null +++ b/src/panels/layout.ts @@ -0,0 +1,551 @@ +// Panel-Layout — Standard + Persistenz (localStorage). +// +// Verwaltet den LayoutState (welche Panels in welchem Dock, aktiver Tab, +// Größen). Drei Ebenen: +// 1. defaultLayout() — der eingebaute Startzustand. +// 2. loadLayout()/saveLayout() — das zuletzt benutzte Layout (1 Slot). +// 3. saveNamedLayout()/… und Co. — benannte Layouts (Arbeitsbereiche). +// +// Sämtlicher localStorage-Zugriff ist in try/catch gekapselt und prüft, ob +// `localStorage` überhaupt existiert (SSR/Tests/Privatmodus) — Lesefehler +// liefern den Standard, Schreibfehler werden still geschluckt. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { + DockGroup, + DockId, + DockState, + FloatingPanel, + LayoutState, +} from "./types"; + +/** + * Aktuelle Layout-Schema-Version (bei Strukturänderungen erhöhen). v2: rechtes + * Dock standardmäßig leer, „Ressourcen" nun schwebendes Fenster statt Dock- + * Panel. v3: schwebende Panels (`floating`). v4: Werkzeug-Palette als Kern- + * Panel. v5: GESTAPELTE GRUPPEN — ein Dock hält eine Liste vertikaler Gruppen + * (je eigener Tab-Stapel + aktiver Tab + Höhen-Gewicht) statt eines einzigen + * Tab-Stapels. v6: Attribute- + Objekt-Info-Palette im Default-Layout + * (Vectorworks-Bild). v7: Gelände-/Kontext-Palette („site") als weiterer Tab in + * der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts + * sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze). + */ +export const LAYOUT_VERSION = 7; + +/** localStorage-Schlüssel des zuletzt benutzten Layouts. */ +const CURRENT_KEY = "cad.layout"; +/** Präfix der localStorage-Schlüssel benannter Layouts: `cad.layouts.`. */ +const NAMED_PREFIX = "cad.layouts."; + +/** + * Konventionelle IDs der Kern-Panels für den Standard (entsprechen den IDs in + * builtinPanels: "drawing-levels", "layers"). Sind diese Panels nicht + * registriert (Plugin aus, Test), sollte der Host die IDs gegen die Registry + * filtern (siehe `hasPanel`); das Layout selbst bleibt registry-unabhängig. + * + * Das rechte Dock ist standardmäßig LEER: „Ressourcen" ist kein Dock-Panel + * mehr, sondern ein schwebendes Fenster (ResourceManager), das über die + * Oberleiste geöffnet wird. Ein leeres Dock wird nicht gerendert, sodass die + * Mitte (Grundriss/Perspektive) die Breite voll nutzt. + */ +/** + * Standard-Gruppen je Dock-Seite (oben → unten) — das Vectorworks-Bild. + * Links: Werkzeug-Palette OBEN, Attribute-Palette UNTEN. Rechts: Objekt-Info + * OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein + * schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht. + */ +const DEFAULT_LEFT_GROUPS: DockGroup[] = [ + { tabs: ["tools"], activeTab: "tools", weight: 1 }, + { tabs: ["attributes"], activeTab: "attributes", weight: 1.1 }, +]; +const DEFAULT_RIGHT_GROUPS: DockGroup[] = [ + { tabs: ["object-info"], activeTab: "object-info", weight: 1 }, + { + tabs: ["drawing-levels", "layers", "site"], + activeTab: "drawing-levels", + weight: 1.5, + }, +]; + +/** Standardbreiten der Docks in CSS-Pixeln. */ +const DEFAULT_LEFT_SIZE = 280; +const DEFAULT_RIGHT_SIZE = 340; + +/** Höhen-Gewicht einer neu erzeugten Gruppe (Mittel der vorhandenen, sonst 1). */ +function newGroupWeight(groups: DockGroup[]): number { + if (groups.length === 0) return 1; + return groups.reduce((s, g) => s + g.weight, 0) / groups.length; +} + +/** Tiefe (frische Objekte) Kopie einer Gruppenliste. */ +function cloneGroups(groups: DockGroup[]): DockGroup[] { + return groups.map((g) => ({ tabs: [...g.tabs], activeTab: g.activeTab, weight: g.weight })); +} + +// ── Standard ──────────────────────────────────────────────────────────────── + +/** + * Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben, + * Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische + * Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können. + */ +export function defaultLayout(): LayoutState { + return { + left: { groups: cloneGroups(DEFAULT_LEFT_GROUPS), size: DEFAULT_LEFT_SIZE }, + right: { groups: cloneGroups(DEFAULT_RIGHT_GROUPS), size: DEFAULT_RIGHT_SIZE }, + floating: [], + version: LAYOUT_VERSION, + }; +} + +// ── Mutations-Helfer (rein, immutabel) ────────────────────────────────────── +// +// Alle Helfer liefern ein NEUES LayoutState und mutieren die Eingabe nicht. +// Sie wahren die Invariante „jedes Panel an genau einer Stelle": ein Panel wird +// zuerst überall entfernt (beide Docks + floating), dann am Ziel eingefügt. Sie +// kennen die Registry NICHT — Aufrufer reichen gültige Panel-IDs herein. + +/** Der gegenüberliegende Dock (für Aufräum-Logik). */ +function otherDock(dock: DockId): DockId { + return dock === "left" ? "right" : "left"; +} + +/** + * Entfernt eine Panel-ID aus einer Gruppe (immutabel) oder liefert die Gruppe + * unverändert zurück, wenn sie das Panel nicht enthält. Wird das Panel der + * aktive Tab, rückt `activeTab` auf den nächstgelegenen Nachbarn (rechts, sonst + * links); ist die Gruppe danach leer, wird `activeTab` `null`. + */ +function removeFromGroup(group: DockGroup, panelId: string): DockGroup { + const i = group.tabs.indexOf(panelId); + if (i < 0) return group; + const tabs = group.tabs.filter((id) => id !== panelId); + let activeTab = group.activeTab; + if (activeTab === panelId) activeTab = tabs[i] ?? tabs[i - 1] ?? null; + return { ...group, tabs, activeTab }; +} + +/** + * Entfernt eine Panel-ID aus allen Gruppen eines Docks und wirft die dadurch + * leer gewordenen Gruppen weg (immutabel). Dock-Breite bleibt erhalten. + */ +function removeFromDock(dock: DockState, panelId: string): DockState { + const groups = dock.groups + .map((g) => removeFromGroup(g, panelId)) + .filter((g) => g.tabs.length > 0); + if (groups.length === dock.groups.length && groups.every((g, i) => g === dock.groups[i])) { + return dock; // nichts geändert + } + return { ...dock, groups }; +} + +/** + * Entfernt eine Panel-ID überall (beide Docks + floating). Basis für alle + * „verschiebe nach …"-Helfer, damit die Eindeutigkeits-Invariante hält. + */ +function detach(layout: LayoutState, panelId: string): LayoutState { + return { + ...layout, + left: removeFromDock(layout.left, panelId), + right: removeFromDock(layout.right, panelId), + floating: layout.floating.filter((f) => f.panelId !== panelId), + }; +} + +/** Höchster vergebener z-Index der schwebenden Panels (0, wenn keine). */ +function maxZ(floating: FloatingPanel[]): number { + return floating.reduce((m, f) => Math.max(m, f.z), 0); +} + +/** + * Verschiebt ein Panel in eine BESTEHENDE Gruppe eines Docks und macht es dort + * aktiv (Tab-Stapel zusammenlegen). Es wird zuvor überall entfernt. `tabIndex` + * bestimmt die Einfügeposition in der Gruppe; fehlt er, wird angehängt. Liegt + * `groupIndex` außerhalb (z. B. weil das Detachen eine Gruppe entfernt hat), + * wird auf die letzte Gruppe geklemmt; gibt es danach keine Gruppe, wird eine + * neue angelegt. + */ +export function moveToGroup( + layout: LayoutState, + panelId: string, + dock: DockId, + groupIndex: number, + tabIndex?: number, +): LayoutState { + const detached = detach(layout, panelId); + const target = detached[dock]; + if (target.groups.length === 0) { + return moveToNewGroup(detached, panelId, dock, 0); + } + const gi = Math.max(0, Math.min(groupIndex, target.groups.length - 1)); + const groups = target.groups.map((g, i) => { + if (i !== gi) return g; + const at = + tabIndex === undefined + ? g.tabs.length + : Math.max(0, Math.min(tabIndex, g.tabs.length)); + const tabs = [...g.tabs.slice(0, at), panelId, ...g.tabs.slice(at)]; + return { ...g, tabs, activeTab: panelId }; + }); + return { ...detached, [dock]: { ...target, groups } }; +} + +/** + * Legt ein Panel als NEUE Gruppe (eigener Stapel) an Position `atIndex` im Dock + * an (für gestapelte Paletten). Es wird zuvor überall entfernt. `atIndex` wird + * auf [0, groups.length] geklemmt; das neue Gewicht ist das Mittel der + * vorhandenen Gruppen, damit der Stapel ausgewogen startet. + */ +export function moveToNewGroup( + layout: LayoutState, + panelId: string, + dock: DockId, + atIndex: number, +): LayoutState { + const detached = detach(layout, panelId); + const target = detached[dock]; + const at = Math.max(0, Math.min(atIndex, target.groups.length)); + const group: DockGroup = { + tabs: [panelId], + activeTab: panelId, + weight: newGroupWeight(target.groups), + }; + const groups = [...target.groups.slice(0, at), group, ...target.groups.slice(at)]; + return { ...detached, [dock]: { ...target, groups } }; +} + +/** + * Rückwärtskompatible Hülle: verschiebt ein Panel in ein Dock, indem es als + * neue Gruppe ans Ende gehängt wird (das natürliche Ziel beim Andocken an die + * Rand-Zone oder beim Redock eines schwebenden Fensters). + */ +export function moveToDock( + layout: LayoutState, + panelId: string, + dock: DockId, +): LayoutState { + return moveToNewGroup(layout, panelId, dock, layout[dock].groups.length); +} + +/** + * Macht ein Panel schwebend (frei positioniert). Es wird zuvor aus jedem Dock + * entfernt. `rect` setzt Position/Größe; das Panel kommt nach vorne (z über + * allen anderen). War es bereits schwebend, wird sein Eintrag durch den neuen + * (mit `rect` und Front-z) ersetzt. + */ +export function moveToFloat( + layout: LayoutState, + panelId: string, + rect: { x: number; y: number; w: number; h: number }, +): LayoutState { + const detached = detach(layout, panelId); + const z = maxZ(detached.floating) + 1; + const panel: FloatingPanel = { panelId, ...rect, z }; + return { ...detached, floating: [...detached.floating, panel] }; +} + +/** + * Ordnet einen Tab innerhalb EINER Gruppe um (immutabel). Bewegt den Tab an + * `fromIndex` vor den Tab, der nach dem Entfernen an `toIndex` steht. Indizes + * außerhalb des Bereichs werden geklemmt; ist nichts zu tun, kommt das Layout + * unverändert zurück. Der aktive Tab bleibt erhalten (folgt seiner ID). + */ +export function reorderInGroup( + layout: LayoutState, + dock: DockId, + groupIndex: number, + fromIndex: number, + toIndex: number, +): LayoutState { + const d = layout[dock]; + const g = d.groups[groupIndex]; + if (!g) return layout; + const n = g.tabs.length; + if (n === 0) return layout; + const from = Math.max(0, Math.min(fromIndex, n - 1)); + const to = Math.max(0, Math.min(toIndex, n - 1)); + if (from === to) return layout; + const tabs = [...g.tabs]; + const [moved] = tabs.splice(from, 1); + tabs.splice(to, 0, moved); + const groups = d.groups.map((gr, i) => (i === groupIndex ? { ...gr, tabs } : gr)); + return { ...layout, [dock]: { ...d, groups } }; +} + +/** + * Verteilt die Höhen-Gewichte der Gruppen eines Docks neu (immutabel) — vom + * Gruppen-Splitter aufgerufen. `weights` muss so lang sein wie `groups`; zu + * kleine/0-Werte werden auf ein Minimum angehoben, damit keine Gruppe + * verschwindet. + */ +export function setGroupWeights( + layout: LayoutState, + dock: DockId, + weights: number[], +): LayoutState { + const d = layout[dock]; + if (weights.length !== d.groups.length) return layout; + const groups = d.groups.map((g, i) => ({ ...g, weight: Math.max(0.05, weights[i]) })); + return { ...layout, [dock]: { ...d, groups } }; +} + +/** + * Aktualisiert ein schwebendes Panel (Position/Größe/z) per Teil-Patch. Ein + * `z`-Wert im Patch wird übernommen wie gegeben (für gezieltes Setzen); zum + * Nach-vorne-Holen siehe `bringToFront`. Unbekannte ID → Layout unverändert. + */ +export function updateFloat( + layout: LayoutState, + panelId: string, + partial: Partial>, +): LayoutState { + let changed = false; + const floating = layout.floating.map((f) => { + if (f.panelId !== panelId) return f; + changed = true; + return { ...f, ...partial }; + }); + return changed ? { ...layout, floating } : layout; +} + +/** + * Holt ein schwebendes Panel nach vorne (höchster z-Index). Ist es bereits + * vorne (oder unbekannt), kommt das Layout unverändert zurück. + */ +export function bringToFront(layout: LayoutState, panelId: string): LayoutState { + const top = maxZ(layout.floating); + const current = layout.floating.find((f) => f.panelId === panelId); + if (!current || current.z === top) return layout; + return updateFloat(layout, panelId, { z: top + 1 }); +} + +/** + * Entfernt ein schwebendes Panel aus `floating` (immutabel). Damit ist das + * Panel an KEINER Stelle mehr — Aufrufer, die es nicht schließen, sondern + * andocken wollen, nutzen `moveToDock`. + */ +export function removeFloat(layout: LayoutState, panelId: string): LayoutState { + const floating = layout.floating.filter((f) => f.panelId !== panelId); + if (floating.length === layout.floating.length) return layout; + return { ...layout, floating }; +} + +/** + * Entfernt ein Panel aus einem Dock (immutabel). Dünne, benannte Hülle um + * `removeFromDock` auf Layout-Ebene; nützlich, um ein Panel ganz zu schließen. + */ +export function removeFromDockLayout( + layout: LayoutState, + dock: DockId, + panelId: string, +): LayoutState { + const next = removeFromDock(layout[dock], panelId); + if (next === layout[dock]) return layout; + return { ...layout, [dock]: next }; +} + +/** + * Setzt das Panel als aktiven Tab innerhalb seiner Gruppe (immutabel). Sucht + * die Gruppe im Dock, die das Panel enthält, und macht es dort aktiv. Liegt die + * ID in keiner Gruppe des Docks (oder ist sie schon aktiv), kommt das Layout + * unverändert zurück. + */ +export function setActiveTab( + layout: LayoutState, + dock: DockId, + panelId: string, +): LayoutState { + const d = layout[dock]; + const gi = d.groups.findIndex((g) => g.tabs.includes(panelId)); + if (gi < 0 || d.groups[gi].activeTab === panelId) return layout; + const groups = d.groups.map((g, i) => (i === gi ? { ...g, activeTab: panelId } : g)); + return { ...layout, [dock]: { ...d, groups } }; +} + +// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum- +// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann. +export { otherDock }; + +// ── localStorage-Sicherung ────────────────────────────────────────────────── + +/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */ +function storage(): Storage | null { + try { + if (typeof localStorage === "undefined") return null; + return localStorage; + } catch { + // Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus). + return null; + } +} + +/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */ +function readJson(key: string): T | null { + const ls = storage(); + if (!ls) return null; + try { + const raw = ls.getItem(key); + if (raw === null) return null; + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */ +function writeJson(key: string, value: unknown): boolean { + const ls = storage(); + if (!ls) return false; + try { + ls.setItem(key, JSON.stringify(value)); + return true; + } catch { + // Kontingent überschritten oder Zugriff verweigert — still ignorieren. + return false; + } +} + +// ── Validierung / Migration ───────────────────────────────────────────────── + +/** Typwächter für eine DockGroup (toleriert Fremddaten aus localStorage). */ +function isDockGroup(v: unknown): v is DockGroup { + if (typeof v !== "object" || v === null) return false; + const g = v as Record; + return ( + Array.isArray(g.tabs) && + g.tabs.every((t) => typeof t === "string") && + (g.activeTab === null || typeof g.activeTab === "string") && + typeof g.weight === "number" + ); +} + +/** Typwächter für einen DockState (toleriert Fremddaten aus localStorage). */ +function isDockState(v: unknown): v is DockState { + if (typeof v !== "object" || v === null) return false; + const d = v as Record; + return ( + Array.isArray(d.groups) && + d.groups.every(isDockGroup) && + typeof d.size === "number" + ); +} + +/** Typwächter für einen FloatingPanel (toleriert Fremddaten aus localStorage). */ +function isFloatingPanel(v: unknown): v is FloatingPanel { + if (typeof v !== "object" || v === null) return false; + const f = v as Record; + return ( + typeof f.panelId === "string" && + typeof f.x === "number" && + typeof f.y === "number" && + typeof f.w === "number" && + typeof f.h === "number" && + typeof f.z === "number" + ); +} + +/** + * Prüft eine geladene Struktur und führt sie bei Bedarf auf ein gültiges + * Layout zurück. Beschädigte Daten ergeben den Standard (konservativ statt + * Absturz). + * + * MIGRATION: Ältere Layouts (Version < aktuell) ohne `floating` werden + * angehoben, statt verworfen — die Docks bleiben erhalten, `floating` wird zu + * `[]`. So überlebt das zuletzt benutzte Layout den Umbau auf schwebende + * Panels. Unbekannte/zukünftige (zu hohe) Versionen ergeben den Standard. + */ +function normalizeLayout(v: unknown): LayoutState { + if (typeof v !== "object" || v === null) return defaultLayout(); + const o = v as Record; + // Andere Version (älter ODER neuer) → auf den aktuellen Standard zurückfallen. + // So erscheinen bei Schema-/Default-Änderungen (z. B. neues Kern-Panel + // „tools") die neuen Standard-Tabs, statt dass ein altes Layout sie verdeckt. + if (typeof o.version !== "number" || o.version !== LAYOUT_VERSION) { + return defaultLayout(); + } + if (!isDockState(o.left) || !isDockState(o.right)) return defaultLayout(); + // floating fehlt in v2 → leeres Array; sonst nur gültige Einträge übernehmen. + const floating = Array.isArray(o.floating) + ? o.floating.filter(isFloatingPanel) + : []; + return { + left: o.left, + right: o.right, + floating, + version: LAYOUT_VERSION, + }; +} + +// ── Aktuelles Layout (1 Slot) ─────────────────────────────────────────────── + +/** + * Lädt das zuletzt benutzte Layout. Fehlt es, ist es beschädigt oder ist + * localStorage nicht verfügbar, kommt `defaultLayout()` zurück. + */ +export function loadLayout(): LayoutState { + const raw = readJson(CURRENT_KEY); + if (raw === null) return defaultLayout(); + return normalizeLayout(raw); +} + +/** Speichert das aktuelle Layout. Liefert `true` bei Erfolg. */ +export function saveLayout(layout: LayoutState): boolean { + return writeJson(CURRENT_KEY, layout); +} + +// ── Benannte Layouts (Arbeitsbereiche) ────────────────────────────────────── + +/** Baut den localStorage-Schlüssel für ein benanntes Layout. */ +function namedKey(name: string): string { + return NAMED_PREFIX + name; +} + +/** Speichert ein Layout unter einem Namen. Liefert `true` bei Erfolg. */ +export function saveNamedLayout(name: string, layout: LayoutState): boolean { + if (!name) return false; + return writeJson(namedKey(name), layout); +} + +/** + * Lädt ein benanntes Layout. Fehlt es oder ist es beschädigt, kommt `null` + * zurück (anders als `loadLayout`, damit Aufrufer „nicht gefunden" erkennen). + */ +export function loadNamedLayout(name: string): LayoutState | null { + const raw = readJson(namedKey(name)); + if (raw === null) return null; + return normalizeLayout(raw); +} + +/** + * Listet die Namen aller gespeicherten benannten Layouts (alphabetisch + * sortiert). Leer, wenn localStorage nicht verfügbar ist. + */ +export function listNamedLayouts(): string[] { + const ls = storage(); + if (!ls) return []; + const names: string[] = []; + try { + for (let i = 0; i < ls.length; i++) { + const key = ls.key(i); + if (key && key.startsWith(NAMED_PREFIX)) { + names.push(key.slice(NAMED_PREFIX.length)); + } + } + } catch { + return []; + } + return names.sort(); +} + +/** Löscht ein benanntes Layout. Liefert `true`, wenn der Zugriff gelang. */ +export function deleteNamedLayout(name: string): boolean { + const ls = storage(); + if (!ls) return false; + try { + ls.removeItem(namedKey(name)); + return true; + } catch { + return false; + } +} diff --git a/src/panels/panelDrag.tsx b/src/panels/panelDrag.tsx new file mode 100644 index 0000000..48abec4 --- /dev/null +++ b/src/panels/panelDrag.tsx @@ -0,0 +1,304 @@ +// Panel-Tab-Drag — die Steuerung für „Tab greifen → Reorder / Andocken / Lösen". +// +// Eine Geste beginnt am TAB selbst (pointerdown). Unter dem Schwellwert (~5 px) +// gilt sie als Klick (Tab aktivieren); darüber wird sie zum Ziehen. Während des +// Ziehens folgt ein kleines Geist-Etikett dem Cursor, und gültige Ablageziele +// werden hervorgehoben: +// • über einer Tab-Leiste (links/rechts) → REORDER bzw. Verschieben an den +// ermittelten Einfügeindex (Caret zwischen den Tabs); +// • über einer Rand-Andock-Zone (links/rechts) → in dieses Dock andocken; +// • sonst über der Arbeitsfläche → an der Cursorposition LÖSEN (schweben). +// +// Die Auflösung läuft rein über DOM-Datenattribute, die TabStrip/Dock setzen: +// data-tabstrip="" + data-group-index an der Tab-Leiste einer Gruppe, +// data-tab-index="" an jedem Tab-Knopf, +// data-group-body + data-dock + data-group-index am Körper einer Gruppe, +// data-drop-zone="" an den Rand-Andock-Zonen. +// So braucht der Drag-Controller keine Refs der einzelnen Tabs und bleibt vom +// konkreten Layout entkoppelt. +// +// Ablage-Semantik: +// • über einer Tab-Leiste → in DIESE Gruppe einreihen (Stapel zusammenlegen), +// • über einem Gruppen-Körper (obere/untere Hälfte) → NEUE Gruppe darüber/-unter, +// • über einer Rand-Zone → neue Gruppe ans Ende des Docks, +// • sonst → lösen (schweben). +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { useCallback, useRef, useState } from "react"; +import type { DockId } from "./types"; +import { getPanel } from "./registry"; +import { t } from "../i18n"; + +/** Pixel-Schwelle, ab der ein pointerdown auf einem Tab zum Ziehen wird. */ +const DRAG_THRESHOLD = 5; +/** Standardgröße eines neu gelösten (schwebenden) Panels in CSS-Pixeln. */ +export const DEFAULT_FLOAT_W = 300; +export const DEFAULT_FLOAT_H = 360; + +/** Ein aufgelöstes Ablageziel beim Loslassen. */ +export type DropTarget = + | { kind: "join"; dock: DockId; group: number; index?: number } + | { kind: "newgroup"; dock: DockId; at: number } + | { kind: "float"; x: number; y: number }; + +/** Laufender Drag-Zustand (für Geist + Hervorhebung). */ +interface DragState { + panelId: string; + /** Aktuelle Cursorposition (Geist folgt). */ + x: number; + y: number; + /** Aktuell überfahrenes Ziel (für Hervorhebung). */ + hover: HoverTarget; +} + +/** Was gerade überfahren wird (für Caret/Zonen-Hervorhebung). */ +type HoverTarget = + | { + kind: "tabstrip"; + dock: DockId; + group: number; + index: number; + caretX: number; + stripTop: number; + stripHeight: number; + } + | { + kind: "split"; + dock: DockId; + /** Einfügeposition der neuen Gruppe (0..groups.length). */ + at: number; + /** Y-Position der horizontalen Caret-Linie + horizontale Ausdehnung. */ + lineY: number; + lineLeft: number; + lineWidth: number; + } + | { kind: "zone"; dock: DockId } + | { kind: "none" }; + +/** Was App zum Abschluss einer Drag-Geste umsetzt. */ +export interface PanelDragCallbacks { + /** Tab unter Schwelle losgelassen → als Klick: Tab aktivieren. */ + onActivate: (panelId: string) => void; + /** Über einer Tab-Leiste losgelassen → in diese Gruppe einreihen (am Index). */ + onJoinGroup: (panelId: string, dock: DockId, group: number, index?: number) => void; + /** Über einem Gruppen-Körper / einer Rand-Zone → neue Gruppe an `at`. */ + onNewGroup: (panelId: string, dock: DockId, at: number) => void; + /** Über der Arbeitsfläche losgelassen → an Position lösen (schweben). */ + onDropFloat: (panelId: string, x: number, y: number) => void; +} + +/** + * Ermittelt das überfahrene Ziel an (clientX, clientY): zuerst eine Tab-Leiste + * (einreihen), dann ein Gruppen-Körper (neue Gruppe ober-/unterhalb), dann eine + * Rand-Andock-Zone, sonst nichts (= später lösen). Liest die Datenattribute der + * unter dem Cursor liegenden Elemente. + */ +function resolveHover(clientX: number, clientY: number): HoverTarget { + const stack = document.elementsFromPoint(clientX, clientY); + + // 1) Tab-Leiste? Einfügeindex aus den Tab-Mittelpunkten bestimmen. + const strip = stack.find((el) => + (el as HTMLElement).dataset?.tabstrip !== undefined, + ) as HTMLElement | undefined; + if (strip) { + const dock = strip.dataset.tabstrip as DockId; + const group = Number(strip.dataset.groupIndex ?? 0); + const tabs = Array.from( + strip.querySelectorAll("[data-tab-index]"), + ); + const stripRect = strip.getBoundingClientRect(); + let index = tabs.length; + let caretX = stripRect.left + 4; + for (let i = 0; i < tabs.length; i++) { + const r = tabs[i].getBoundingClientRect(); + const mid = r.left + r.width / 2; + if (clientX < mid) { + index = i; + caretX = r.left - 2; + break; + } + caretX = r.right + 2; + } + return { + kind: "tabstrip", + dock, + group, + index, + caretX, + stripTop: stripRect.top, + stripHeight: stripRect.height, + }; + } + + // 2) Gruppen-Körper? Obere Hälfte → neue Gruppe DARÜBER (at = gi), untere + // Hälfte → DARUNTER (at = gi + 1). Caret-Linie an der jeweiligen Kante. + const body = stack.find((el) => + (el as HTMLElement).dataset?.groupBody !== undefined, + ) as HTMLElement | undefined; + if (body) { + const dock = body.dataset.dock as DockId; + const gi = Number(body.dataset.groupIndex ?? 0); + const r = body.getBoundingClientRect(); + const below = clientY > r.top + r.height / 2; + return { + kind: "split", + dock, + at: below ? gi + 1 : gi, + lineY: below ? r.bottom : r.top, + lineLeft: r.left, + lineWidth: r.width, + }; + } + + // 3) Rand-Andock-Zone? + const zone = stack.find((el) => + (el as HTMLElement).dataset?.dropZone !== undefined, + ) as HTMLElement | undefined; + if (zone) { + return { kind: "zone", dock: zone.dataset.dropZone as DockId }; + } + + // 4) Sonst: lösen (schweben). + return { kind: "none" }; +} + +/** + * Drag-Controller. Liefert: + * • startTabDrag — pointerdown-Handler, den TabStrip an jeden Tab hängt; + * • drag — der laufende Zustand (null = nichts) für das Overlay; + * • dropDockHint — der aktuell hervorzuhebende Dock (für Zonen/Strip), oder null. + */ +export function usePanelDrag(cb: PanelDragCallbacks) { + const [drag, setDrag] = useState(null); + // Mutable Mitschrift der Geste (Listener lesen daraus, ohne Re-Renders). + const ref = useRef<{ + panelId: string; + startX: number; + startY: number; + active: boolean; + hover: HoverTarget; + } | null>(null); + + const startTabDrag = useCallback( + (panelId: string, e: React.PointerEvent) => { + // Nur primäre Taste / Touch. + if (e.button !== 0 && e.pointerType === "mouse") return; + const startX = e.clientX; + const startY = e.clientY; + ref.current = { panelId, startX, startY, active: false, hover: { kind: "none" } }; + + const move = (ev: PointerEvent) => { + const s = ref.current; + if (!s) return; + if (!s.active) { + const moved = + Math.abs(ev.clientX - s.startX) + Math.abs(ev.clientY - s.startY); + if (moved < DRAG_THRESHOLD) return; + s.active = true; // Schwelle überschritten → echtes Ziehen. + } + const hover = resolveHover(ev.clientX, ev.clientY); + s.hover = hover; + setDrag({ panelId: s.panelId, x: ev.clientX, y: ev.clientY, hover }); + }; + + const up = (ev: PointerEvent) => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", cancel); + const s = ref.current; + ref.current = null; + setDrag(null); + if (!s) return; + if (!s.active) { + // Unter der Schwelle: als Klick werten (Tab aktivieren). + cb.onActivate(s.panelId); + return; + } + const hover = resolveHover(ev.clientX, ev.clientY); + if (hover.kind === "tabstrip") { + cb.onJoinGroup(s.panelId, hover.dock, hover.group, hover.index); + } else if (hover.kind === "split") { + cb.onNewGroup(s.panelId, hover.dock, hover.at); + } else if (hover.kind === "zone") { + // Rand-Zone → neue Gruppe ans Ende (at sehr groß, Helfer klemmt). + cb.onNewGroup(s.panelId, hover.dock, Number.MAX_SAFE_INTEGER); + } else { + // Lösen: Geist mittig über dem Cursor platzieren. + cb.onDropFloat( + s.panelId, + Math.round(ev.clientX - DEFAULT_FLOAT_W / 2), + Math.round(ev.clientY - 12), + ); + } + }; + + const cancel = () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", cancel); + ref.current = null; + setDrag(null); + }; + + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", cancel); + }, + [cb], + ); + + return { drag, startTabDrag }; +} + +/** + * Drag-Overlay: rendert während eines aktiven Tab-Ziehens den Geist am Cursor + * und das Einfüge-Caret in der überfahrenen Tab-Leiste. Die Rand-Andock-Zonen + * selbst rendert der Host (App), damit sie auch beim Verschieben eines + * schwebenden Fensters dieselben Trefferflächen bilden. Liegt über allem; + * reagiert nicht auf Zeiger (pointer-events: none), damit die Trefferprüfung + * weiter die darunterliegenden Tab-Leisten/Zonen erreicht. + */ +export function DragOverlay({ drag }: { drag: ReturnType["drag"] }) { + if (!drag) return null; + const def = getPanel(drag.panelId); + const label = def ? t(def.title) : drag.panelId; + const hover = drag.hover; + + return ( +
+ {/* Einfüge-Caret in der überfahrenen Tab-Leiste (vertikal). */} + {hover.kind === "tabstrip" && ( +
+ )} + + {/* Neue-Gruppe-Caret am oberen/unteren Rand eines Gruppen-Körpers + (horizontal). */} + {hover.kind === "split" && ( +
+ )} + + {/* Geist-Etikett am Cursor. */} +
+ {label} +
+
+ ); +} diff --git a/src/panels/registry.ts b/src/panels/registry.ts new file mode 100644 index 0000000..cb01d44 --- /dev/null +++ b/src/panels/registry.ts @@ -0,0 +1,51 @@ +// Panel-Registry — die Plugin-Naht. +// +// Modulweites Verzeichnis aller bekannten Panels. Kern-Panels und Plugins/ +// eigene Panels rufen `registerPanel` (typisch beim Import ihres Moduls) auf; +// die Docks fragen anschließend über `getPanel`/`listPanels` ab. So bleibt der +// Rahmen offen für Erweiterungen, ohne ihn zu ändern. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { PanelDef } from "./types"; + +/** Modulweites Verzeichnis: Panel-ID → Definition (Einfügereihenfolge bewahrt). */ +const registry = new Map(); + +/** + * Registriert ein Panel. Idempotent pro ID: ein erneuter Aufruf mit derselben + * ID ersetzt die vorhandene Definition (praktisch für Hot-Reload und für + * Plugins, die Kern-Panels überschreiben). Liefert die übergebene Definition + * zurück, damit Aufrufe verkettet werden können. + */ +export function registerPanel(def: PanelDef): PanelDef { + registry.set(def.id, def); + return def; +} + +/** Liefert die registrierte Panel-Definition zur ID oder `undefined`. */ +export function getPanel(id: string): PanelDef | undefined { + return registry.get(id); +} + +/** Liefert alle registrierten Panels in Registrierungsreihenfolge. */ +export function listPanels(): PanelDef[] { + return [...registry.values()]; +} + +/** + * Ob zu einer ID ein Panel registriert ist. Hilfreich, um aus einem + * persistierten Layout verschwundene (z. B. von einem deaktivierten Plugin + * stammende) Panel-IDs herauszufiltern. + */ +export function hasPanel(id: string): boolean { + return registry.has(id); +} + +/** + * Entfernt ein Panel aus der Registry. Liefert `true`, wenn etwas entfernt + * wurde. Vor allem für Tests und das Entladen von Plugins gedacht. + */ +export function unregisterPanel(id: string): boolean { + return registry.delete(id); +} diff --git a/src/panels/types.ts b/src/panels/types.ts new file mode 100644 index 0000000..439ae5e --- /dev/null +++ b/src/panels/types.ts @@ -0,0 +1,164 @@ +// Panel-System — Kerntypen. +// +// Das Panel-System ist der erweiterbare Andockrahmen der App: links und rechts +// je ein Dock mit Tabs, jeder Tab ist ein registriertes Panel. Plugins/eigene +// Panels registrieren sich über die Registry (registry.ts) und tauchen dann in +// den Docks auf — der Rahmen kennt keine konkreten Panels. +// +// Bezeichner englisch, Kommentare/UI-Text deutsch (CONVENTIONS.md). Dieses Modul ist +// rein typdeklarativ plus der React-Context-Träger; es hat keine Laufzeitlogik +// außer createContext und ist daher SSR-/test-sicher. + +import { createContext } from "react"; +import type { ReactNode } from "react"; + +// ── Dock-Identität & Layout ──────────────────────────────────────────────── + +/** Welches der beiden Docks (links/rechts) gemeint ist. */ +export type DockId = "left" | "right"; + +/** + * Eine vertikal gestapelte Gruppe innerhalb eines Docks: ein eigener + * Tab-Stapel mit eigenem aktivem Tab. Mehrere Gruppen übereinander ergeben das + * Vectorworks-Bild (z. B. links Werkzeuge OBEN, Attribute UNTEN). Jede Gruppe + * trägt ein Höhen-Gewicht (relativer flex-Anteil im Dock); die Splitter + * zwischen den Gruppen verschieben diese Gewichte. + */ +export interface DockGroup { + /** Geordnete Liste der Panel-IDs in dieser Gruppe (Tab-Reihenfolge). */ + tabs: string[]; + /** Aktiver Tab (Panel-ID) oder `null`, wenn die Gruppe leer ist. */ + activeTab: string | null; + /** + * Relatives Höhen-Gewicht der Gruppe im Dock (flex-grow-Anteil). Nur die + * Verhältnisse zählen; ein Splitter-Zug verteilt Gewicht zwischen Nachbarn. + */ + weight: number; +} + +/** + * Zustand eines einzelnen Docks: eine geordnete Liste vertikal gestapelter + * Gruppen (oben → unten) plus die Dock-Breite. Ein leeres Dock hat `groups: []` + * und wird nicht gerendert (die Mitte nutzt den Platz). + */ +export interface DockState { + /** Vertikal gestapelte Gruppen (oben → unten). */ + groups: DockGroup[]; + /** + * Breite des Docks in CSS-Pixeln. Persistiert, damit die Aufteilung über + * Sitzungen erhalten bleibt. + */ + size: number; +} + +/** + * Ein schwebendes (frei positioniertes) Panel: weder im linken noch im rechten + * Dock, sondern als eigenes Fenster über der Mitte. Position/Größe sind + * CSS-Pixel relativ zum Anwendungsbereich; `z` ist die Stapelreihenfolge + * (höher = weiter vorne), damit zuletzt fokussierte Fenster oben liegen. + */ +export interface FloatingPanel { + /** Panel-ID (Registry-Schlüssel) — wie in DockState.tabs eindeutig. */ + panelId: string; + /** Linke Kante in CSS-Pixeln (relativ zum Anwendungsbereich). */ + x: number; + /** Obere Kante in CSS-Pixeln (relativ zum Anwendungsbereich). */ + y: number; + /** Breite in CSS-Pixeln. */ + w: number; + /** Höhe in CSS-Pixeln. */ + h: number; + /** Stapelindex (höher = weiter vorne). */ + z: number; +} + +/** + * Gesamter Layout-Zustand des Panel-Rahmens: beide Docks, die Liste der + * schwebenden Panels plus eine Schema-Version (für Migrationen beim Laden aus + * localStorage). + * + * INVARIANTE: Jede registrierte Panel-ID erscheint an GENAU EINER Stelle — + * in einer Gruppe des linken Docks, in einer Gruppe des rechten Docks oder in + * `floating` (als `panelId`). Die Helfer in layout.ts wahren diese Invariante + * (sie entfernen ein Panel überall, bevor sie es am Ziel einfügen, und werfen + * leer gewordene Gruppen weg). + */ +export interface LayoutState { + left: DockState; + right: DockState; + /** Frei positionierte Panels (nicht in einem Dock). */ + floating: FloatingPanel[]; + /** Schema-Version des Layouts (für künftige Migrationen). */ + version: number; +} + +// ── Darstellungsmodus ────────────────────────────────────────────────────── + +/** + * Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss). + * Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden — die + * fünf DOSSIER-Modi (siehe docs/design/context-menu.md), für Ebenen UND + * Zeichnungsebenen identisch: + * • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge + * wechselt zu „Ausgewählte". + * • "all" — sichtbar nach per-Zeile-Flag (Standard). + * • "active" — nur das aktive Element; andere stark gedimmt. + * • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten). + * • "grey_locked" — wie „grey", andere zusätzlich gesperrt. + */ +export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked"; + +// ── Panel-Definition & Kontext ───────────────────────────────────────────── + +/** + * Was ein Panel beim Rendern erhält. Bewusst generisch gehalten: konkrete + * App-Daten (Projekt, Handler, aktive Auswahl) reicht der Host stattdessen über + * den React-Context `PanelHostContext` durch — so bleiben Panels von der + * Props-Signatur des Hosts entkoppelt und Plugins müssen diesen Typ nicht + * kennen. Die Index-Signatur erlaubt es, bei Bedarf trotzdem ad-hoc-Werte + * mitzugeben, ohne den Typ zu brechen. + */ +export interface PanelContext { + [key: string]: unknown; +} + +/** + * Definition eines Panels — die Einheit, die in der Registry registriert und in + * einem Dock als Tab dargestellt wird. + */ +export interface PanelDef { + /** Stabile, eindeutige ID (Registry-Schlüssel, in Layouts persistiert). */ + id: string; + /** + * Im Tab angezeigter Titel — als i18n-Key (z. B. „nav.layers"). Der Rahmen + * (TabStrip/PanelFrame) löst ihn über `t()` auf, sodass der Titel der + * gewählten Sprache folgt. Ist der String kein bekannter Key, gibt `t()` ihn + * unverändert zurück (Plugins können also auch einen fertigen Text setzen). + */ + title: string; + /** Rendert den Panel-Inhalt. Erhält den (generischen) PanelContext. */ + render: (ctx: PanelContext) => ReactNode; + /** + * Ob das Panel den Darstellungsmodus-Umschalter (DisplayMode) in seiner + * Kopfzeile anbieten soll. Default: kein Umschalter. + */ + hasDisplayMode?: boolean; +} + +// ── Host-Context (App-Zustand + Handler für Panels) ──────────────────────── + +/** + * Träger des App-Zustands und der Mutations-Handler, den die App über einen + * Provider bereitstellt und den Panels per `useContext(PanelHostContext)` + * abgreifen. Bewusst lose typisiert (Record), damit dieses Kernmodul nicht vom + * konkreten Projekt-/Handler-Modell abhängt und Plugins frei darauf zugreifen + * können. Die App liefert hier u. a. `project`, die aktive Auswahl, die + * Ressourcen-Handler und die Darstellungsmodi hinein. + * + * `null` bedeutet „außerhalb eines Providers gerendert" — Consumer sollten das + * abfangen (siehe usePanelHost in einem späteren Schritt). + */ +export type PanelHost = Record; + +/** React-Context, über den der Host seinen Zustand an Panels durchreicht. */ +export const PanelHostContext = createContext(null); diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx new file mode 100644 index 0000000..9be6cfe --- /dev/null +++ b/src/plan/PlanView.tsx @@ -0,0 +1,1752 @@ +// Rendert einen Plan als SVG. Vektor → unendlich scharf & druckbar. +// +// Schraffuren werden aus der je Polygon aufgelösten HatchStyle-Ressource +// erzeugt (Muster, Maßstab, Winkel, Farbe, Linienstärke). Pro schraffiertem +// Polygon wird ein eigenes in definiert, damit Maßstab/Winkel/ +// Farbe variieren können. + +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import type { HatchRender, Plan, Primitive } from "./generatePlan"; +import type { EdgeGrip, Vec2 } from "../model/types"; +import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../tools/types"; + +const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum) +const PAD = 60; // Rand in viewBox-Einheiten + +/** + * Modell-Meter → SVG-Benutzerraum (viewBox-Einheiten) mit FIXEM Welt-Ursprung + * am Modell-Nullpunkt (0,0): `x·PX_PER_M`, Plan-Y zeigt nach oben → SVG-Y nach + * unten, daher `-y·PX_PER_M`. BEWUSST unabhängig von den Modell-Bounds — so + * verschiebt sich die Abbildung NICHT, wenn sich Elemente ändern (Verschieben/ + * Löschen). Der sichtbare Ausschnitt (viewBox) bleibt dann stehen; nur manuelles + * Pan/Zoom/Einpassen ändert ihn. (Inverse: {@link viewToModel} in der Komponente.) + */ +function toScreen(p: Vec2): Vec2 { + return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M }; +} + +/** + * Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space. + * dpi = 96·devicePixelRatio (CSS definiert 1px = 1/96 inch). Strichstärken sind + * in mm Papier definiert und werden über diese dpi in konstante Bildschirm-Pixel + * umgerechnet (zusammen mit vector-effect:non-scaling-stroke bleiben sie beim + * Zoomen papierkonstant). Live wird zusätzlich gelesen, falls sich der Monitor + * (devicePixelRatio) ändert. + */ +const dpi = (): number => 96 * (window.devicePixelRatio || 1); +/** mm Papier → Bildschirm-Pixel (für non-scaling-stroke / dasharray). */ +const mmToPx = (mm: number): number => (mm / 25.4) * dpi(); +/** mm-Strichmuster → SVG dasharray in Bildschirm-Pixel (non-scaling). */ +const dashMm = (dash: number[] | null | undefined): string | undefined => + dash && dash.length ? dash.map((d) => mmToPx(d)).join(" ") : undefined; + +// Zoom-Grenzen relativ zur eingepassten Default-Ansicht. Kleinere viewBox- +// Breite = stärkerer Zoom. ZOOM_MAX → max. Hineinzoomen, ZOOM_MIN → Herauszoomen. +const ZOOM_MAX = 50; // bis 50× hinein +const ZOOM_MIN = 0.2; // bis 5× heraus +const WHEEL_STEP = 0.0015; // Empfindlichkeit des Mausrads + +// Schwelle (Bildschirm-Pixel), ab der ein Links-Ziehen als Marquee gilt; darunter +// bleibt es ein Klick (Einzelwahl). Verhindert versehentliche Mini-Rechtecke. +const MARQUEE_THRESHOLD_PX = 4; + +// Trefferradius (Bildschirm-Pixel) für das Greifen eines Editier-Griffs. +const GRIP_HIT_PX = 9; +// Toleranz (Bildschirm-Pixel) für die Linien-Auswahl von 2D-Zeichenelementen. +const LINE_PICK_PX = 6; + +/** Rechteckiger Ausschnitt in SVG-Benutzerkoordinaten (viewBox-Einheiten). */ +interface ViewBox { + x: number; + y: number; + w: number; + h: number; +} + +/** Ein Polygon mit Schraffur + die ihm zugewiesene -ID. */ +interface HatchedPoly { + patternId: string; + hatch: HatchRender; +} + +/** + * Imperatives Steuerhandle der Plan-Ansicht für die Oberleiste (Zoom-Buttons). + * Die Ansicht behält Pan/Zoom intern; das Handle löst nur die fest definierten + * Sprünge aus, ohne den sonstigen Zustand zu berühren. + */ +export interface PlanViewHandle { + /** Auf die eingepasste Standard-Ansicht zurücksetzen (Einpassen). */ + fit: () => void; + /** + * Auf die aktuelle Auswahl einpassen (lokal hervorgehobenes Polygon); gibt es + * keine Auswahl, verhält es sich wie „Einpassen". + */ + fitSelection: () => void; + /** + * Den Zoom so setzen, dass das Modell im PAPIER-Massstab 1:N abgebildet wird + * (siehe docs/design/plans-output.md §3). Hält die aktuelle Bildmitte. Wird + * von der Oberleiste benutzt (Massstab-Preset wählen, „100 %"). + */ + applyScale: (n: number) => void; + /** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */ + pxPerMeter: () => number; +} + +/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */ +export interface PlanSelection { + /** Klickposition in Modell-Metern. */ + model: Vec2; + /** Index des getroffenen Polygon-Primitivs, oder `null` (Leerraum). */ + hitIndex: number | null; + /** + * ID der getroffenen Wand (aus dem Polygon-Primitiv), oder `null`, wenn der + * Klick keine Wand traf (Leerraum bzw. ein Polygon ohne Wandbezug). Damit + * setzt der Aufrufer (App) die Auswahl auf genau diese Wand (bzw. leert sie). + */ + wallId: string | null; + /** + * ID des getroffenen 2D-Zeichenelements (Drawing2D), wenn der Klick nahe an + * dessen Linien lag und KEINE Wand getroffen wurde; sonst `null`. + */ + drawingId: string | null; + /** + * War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die + * Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu + * ersetzen. Bei Leerraum-Klick mit Shift bleibt die Auswahl erhalten. + */ + shift: boolean; +} + +/** + * Ergebnis eines Auswahl-Rechtecks (Marquee). `wallIds` ist die nach der + * CAD-Konvention bestimmte Menge getroffener Wände (siehe `crossing`). Der + * Aufrufer (App) ersetzt die aktuelle Auswahl durch diese Menge. + */ +export interface MarqueeSelection { + wallIds: string[]; + /** Getroffene 2D-Zeichenelemente (gleiche window/crossing-Konvention). */ + drawingIds: string[]; + /** + * Zieh-Richtung: `false` = links→rechts (positiv Δx) → nur VOLLSTÄNDIG + * eingeschlossene Wände; `true` = rechts→links (negativ Δx) → auch berührte/ + * kreuzende Wände (CAD-Konvention: „crossing window"). + */ + crossing: boolean; +} + +export interface PlanViewProps { + plan: Plan; + /** + * Stabiler Schlüssel des Plan-Kontexts (z. B. Geschoss-/Ebenen-ID). Der + * sichtbare Ausschnitt wird NUR auf „eingepasst" zurückgesetzt, wenn sich + * dieser Schlüssel ändert (Plan-/Geschoss-Wechsel) — NICHT bei jeder Modell-/ + * Bounds-Änderung durch Editieren. So „wandert" die Ansicht beim Verschieben/ + * Löschen nicht mehr mit; sie ändert sich nur durch manuelles Pan/Zoom/ + * Einpassen. Fehlt der Schlüssel, bleibt der Ausschnitt nach dem ersten + * Einpassen stehen (kein Auto-Reset). + */ + resetKey?: string; + /** + * Meldet die Cursor-Position in Modell-Koordinaten (Meter) nach oben, bzw. + * `null`, wenn der Zeiger die Fläche verlässt. Optional — ohne Callback + * verhält sich die Ansicht wie zuvor. + */ + onCursor?: (model: Vec2 | null) => void; + /** + * Meldet den aktuellen Zoom in Prozent relativ zur eingepassten Ansicht + * (100 % = eingepasst). Optional. + */ + onZoom?: (percent: number) => void; + /** + * Meldet den LIVE Papier-Massstab als Nenner N (1:N), berechnet aus dem + * aktuellen Ausschnitt + Canvas-Größe + dpi (docs/design/plans-output.md §3). + * Aktualisiert sich beim Wheel-Zoom/Pan/Resize. Optional. + */ + onScale?: (n: number) => void; + /** + * Links-Klick (Auswahl): meldet die getroffene Wand (bzw. `hitIndex: null` + * bei Klick in den Leerraum → Auswahl löschen). Optional. Die Ansicht + * markiert das getroffene Polygon zusätzlich selbst (lokale Hervorhebung). + */ + onSelect?: (selection: PlanSelection) => void; + /** + * Alt+Klick (Schere/Segment löschen): meldet das nahe getroffene Drawing2D plus + * den Modellpunkt des Klicks. Der Aufrufer (App) ermittelt daraus die nächst- + * gelegene Kante und entfernt sie. Optional. Nur ausgelöst, wenn ein 2D-Element + * unter dem Cursor liegt (sonst no-op). + */ + onSegmentCut?: (drawingId: string, model: Vec2) => void; + /** + * Aktuell ausgewählte Wand-IDs (kontrolliert von App). Die Ansicht markiert + * ALLE Polygone JEDER dieser Wände mit einer Akzentkontur — unabhängig davon, + * welches Band der Klick traf. Leeres Array/undefined = keine Wand-Auswahl. + */ + selectedWallIds?: string[]; + /** + * Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach + * der CAD-Konvention (siehe {@link MarqueeSelection}). Optional. + */ + onMarquee?: (selection: MarqueeSelection) => void; + /** + * Rechts-Klick: öffnet ein Kontextmenü an der Cursorposition. Erhält die + * Viewport-Pixel (clientX/Y) plus den Modellpunkt. Optional. Das Standard- + * Browser-Kontextmenü wird immer unterdrückt. + */ + onContextMenu?: (info: { clientX: number; clientY: number; model: Vec2 }) => void; + /** + * Aktives Zeichenwerkzeug. `"select"`/undefined = bisheriges Verhalten + * (Auswahl/Marquee). Bei einem aktiven Zeichenwerkzeug übernimmt das Werkzeug + * die linke Maustaste (Punkte setzen) statt der Auswahl; Pan/Zoom bleiben. + */ + activeTool?: ToolId; + /** + * Übernimmt ein Zeichenwerkzeug ODER eine Transformation die linke Maustaste? + * Wird von App berechnet (Zeichenwerkzeug aktiv ODER laufende Transformation). + * Fehlt der Wert, wird aus `activeTool` abgeleitet (Rückwärtskompatibilität). + */ + toolInputActive?: boolean; + /** + * Werkzeug-Treiber: PlanView meldet rohe Modellpunkte + Pixel/Meter hierher + * und zeichnet den zurückgegebenen `draft` als Overlay (Vorschau + Snap-Marker). + */ + toolHandlers?: ToolHandlers; + /** + * Editier-Griffe (Grips) des aktuell EINZELN selektierten Elements, in Modell- + * Metern (Wand-Enden, 2D-Vertices/Ecken). Werden als ziehbare Quadrate + * gezeichnet; das Ziehen meldet sich über {@link gripHandlers}. Nur im + * Auswahl-Werkzeug sichtbar/aktiv. + */ + grips?: Vec2[]; + /** + * Kanten-/Seiten-Griffe (Edge Grips) des selektierten Elements: je Seite ein + * dreieckiger Anfasser am Kanten-Mittelpunkt, dessen Spitze nach AUSSEN zeigt. + * Ziehen meldet sich über {@link GripHandlers.onEdgeMove}. Nur sichtbar/aktiv, + * wenn auch die Eckpunkt-Griffe aktiv sind. + */ + edgeGrips?: EdgeGrip[]; + /** + * „Display"-Modus: alle Plan-Linien als konstante Haarlinie (1 px) zeichnen, + * statt der echten mm-Strichstärken (Print). Reine Darstellung. + */ + hairline?: boolean; + /** ID des selektierten 2D-Elements (für „Körper greifen → verschieben"). */ + selectedDrawingId?: string | null; + /** Alle selektierten 2D-Elemente (Mehrfach-/Marquee-Auswahl) — für Hervorhebung. */ + selectedDrawingIds?: string[]; + /** Griff-Zieh-Treiber: Live-Bewegung (gesnappt) + Abschluss. */ + gripHandlers?: GripHandlers; +} + +/** Treiber für das Ziehen von Editier-Griffen (von App bereitgestellt). */ +export interface GripHandlers { + onGripMove(index: number, raw: Vec2, pxPerMeter: number, mods: ToolMods): void; + /** Ganzes Element parallel verschieben (inkrementelles Delta in Metern). */ + onMoveBody(delta: Vec2): void; + /** + * Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht + * (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe + * Modellpunkt unter dem Cursor. + */ + onEdgeMove(edge: EdgeGrip, raw: Vec2, pxPerMeter: number, mods: ToolMods): void; + onGripEnd(): void; +} + +export const PlanView = forwardRef( + function PlanView( + { + plan, + resetKey, + onCursor, + onZoom, + onScale, + onSelect, + onMarquee, + onSegmentCut, + onContextMenu, + selectedWallIds, + activeTool, + toolInputActive, + toolHandlers, + grips, + edgeGrips, + hairline, + selectedDrawingId, + selectedDrawingIds, + gripHandlers, + }, + ref, + ) { + // Linke Maustaste liegt beim Werkzeug/der Transformation, wenn App das meldet + // (toolInputActive) bzw. — fallback — ein Zeichenwerkzeug aktiv ist. + const inputActive = toolInputActive ?? (!!activeTool && activeTool !== "select"); + const toolActive = inputActive && !!toolHandlers; + // Editier-Griffe sind nur im Auswahl-Werkzeug aktiv. + const gripsActive = !toolActive && !!grips && grips.length > 0 && !!gripHandlers; + // Kanten-Griffe nur aktiv, wenn auch die Eckpunkt-Griffe aktiv sind. + const edgeGripsActive = gripsActive && !!edgeGrips && edgeGrips.length > 0; + // Eingepasste Standard-Ansicht aus den AKTUELLEN Bounds, über die fixe + // toScreen-Abbildung. Reset-/Einpassen-Ziel + Bezug für die Zoom-Grenzen; wird + // NICHT automatisch angewandt (nur bei resetKey-Wechsel und „Einpassen"). + const defaultView = useMemo(() => { + const { minX, minY, maxX, maxY } = plan.bounds; + return { + x: minX * PX_PER_M - PAD, + y: -maxY * PX_PER_M - PAD, + w: (maxX - minX) * PX_PER_M + PAD * 2, + h: (maxY - minY) * PX_PER_M + PAD * 2, + }; + }, [plan]); + + // Aktueller Ausschnitt; treibt die viewBox des SVG. Initial = eingepasst. + const [view, setView] = useState(defaultView); + // Ausschnitt NUR bei Plan-/Geschoss-Wechsel (resetKey) auf „eingepasst" + // zurücksetzen — NICHT bei jeder Bounds-Änderung durch Editieren (sonst + // „wandert" die Ansicht beim Verschieben/Löschen mit). `defaultView` ist beim + // resetKey-Wechsel bereits frisch (neues Geschoss), daher direkt anwendbar. + useEffect(() => { + setView(defaultView); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]); + + const svgRef = useRef(null); + // Frische Werte in Refs spiegeln, damit das (stabile) imperative Handle und + // der Resize-Beobachter stets den aktuellen Stand lesen (Handle hat leere Deps). + const viewRef = useRef(view); + viewRef.current = view; + const defaultViewRef = useRef(defaultView); + defaultViewRef.current = defaultView; + const toScreenRef = useRef(toScreen); + toScreenRef.current = toScreen; + const planRef = useRef(plan); + planRef.current = plan; + + // Zoom in Prozent (eingepasst = 100 %) nach oben melden: kleinere viewBox- + // Breite = stärker hineingezoomt. Reiner Bericht; ändert die Ansicht nicht. + useEffect(() => { + onZoom?.((defaultView.w / view.w) * 100); + }, [onZoom, defaultView.w, view.w]); + + // LIVE Papier-Massstab (1:N) nach oben melden. Berechnung aus der effektiven + // meet-Skala (Bildschirm-px je viewBox-Einheit) → px je Meter → mm je Meter, + // verglichen mit der Papier-Abbildung. Aktualisiert bei Ausschnitts-/Größen- + // änderung; ein ResizeObserver fängt reine Größenänderungen ab. + const reportScale = () => { + if (!onScale) return; + const n = scaleFromView(viewRef.current, svgRef.current); + if (n != null && isFinite(n)) onScale(n); + }; + useEffect(() => { + reportScale(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onScale, view, plan]); + useEffect(() => { + const el = svgRef.current; + if (!el) return; + const ro = new ResizeObserver(() => reportScale()); + ro.observe(el); + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onScale]); + + // Imperatives Steuerhandle für die Oberleiste (Einpassen / Auswahl / Massstab). + // Stabil (leere Deps); liest den frischen Ausschnitt + die Auswahl aus Refs. + useImperativeHandle( + ref, + () => ({ + fit: () => setView(defaultViewRef.current), + fitSelection: () => { + const sel = selectedRef.current; + const prim = sel !== null ? planRef.current.primitives[sel] : undefined; + if (prim && prim.kind === "polygon") { + setView(fitBoxFor(prim.pts, toScreenRef.current)); + } else { + setView(defaultViewRef.current); + } + }, + applyScale: (n: number) => { + const target = viewForScale(n, viewRef.current, svgRef.current); + if (target) setView(target); + }, + pxPerMeter: () => currentPxPerMeter(), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + // Transiente Pan-Zieh-Daten (Mitteltaste; kein State → kein Re-Render je + // Bewegung). + const drag = useRef<{ + pointerId: number; + startClientX: number; + startClientY: number; + startView: ViewBox; + } | null>(null); + const [dragging, setDragging] = useState(false); + + // Transiente Marquee-Zieh-Daten (linke Taste). Startpunkt in viewBox-Einheiten + // + die Bildschirm-Pixel (für die Klick-vs-Drag-Schwelle). + const marquee = useRef<{ + pointerId: number; + startClientX: number; + startClientY: number; + startView: Vec2; // Startpunkt in viewBox-Einheiten + moved: boolean; // Schwelle überschritten → echtes Rechteck (kein Klick) + } | null>(null); + // Aktuelles Auswahl-Rechteck in viewBox-Einheiten (für die sichtbare Kontur); + // null = kein Marquee aktiv. + const [marqueeRect, setMarqueeRect] = useState<{ + x: number; + y: number; + w: number; + h: number; + crossing: boolean; + } | null>(null); + + // Transiente Griff-Zieh-Daten (linke Taste auf einem Grip). Kein State je + // Bewegung; die Modell-Mutation läuft live über gripHandlers.onGripMove. + const gripDrag = useRef<{ pointerId: number; index: number } | null>(null); + // Transiente Kanten-Griff-Zieh-Daten (linke Taste auf einem Dreieck-Anfasser). + // Hält den gegriffenen EdgeGrip; die Mutation läuft live über onEdgeMove. + const edgeDrag = useRef<{ pointerId: number; edge: EdgeGrip } | null>(null); + // Transiente „Körper verschieben"-Daten: letzter Modellpunkt, gegen den das + // inkrementelle Delta gebildet wird (parallel-Verschiebung des Elements). + const moveDrag = useRef<{ pointerId: number; last: Vec2 } | null>(null); + + // Lokale Auswahl: Index des hervorgehobenen Polygon-Primitivs (oder null). + // Reine Darstellung; die eigentliche Wand-Auswahl meldet onSelect nach oben. + // Beim Plan-Wechsel zurücksetzen (Indizes sind nicht mehr gültig). + const [selected, setSelected] = useState(null); + const selectedRef = useRef(selected); + selectedRef.current = selected; + useEffect(() => { + setSelected(null); + }, [plan]); + + // Verhältnis viewBox-Einheiten ↔ Bildschirm-Pixel. preserveAspectRatio + // "xMidYMid meet" passt die viewBox einheitlich (kleinerer Faktor) ein und + // zentriert sie; daraus ergibt sich die effektive Skala + der Versatz. + const measure = (vb: ViewBox) => { + const el = svgRef.current; + const rect = el?.getBoundingClientRect(); + const pxW = rect?.width ?? vb.w; + const pxH = rect?.height ?? vb.h; + // Bildschirm-Pixel je viewBox-Einheit (meet = min beider Achsen). + const scale = Math.min(pxW / vb.w, pxH / vb.h); + const drawnW = vb.w * scale; + const drawnH = vb.h * scale; + // Letterbox-Versatz durch die Zentrierung. + const offX = (pxW - drawnW) / 2; + const offY = (pxH - drawnH) / 2; + return { rect, scale, offX, offY }; + }; + + // Cursor-Position (clientX/Y) → viewBox-Koordinaten des aktuellen Ausschnitts. + const clientToView = (clientX: number, clientY: number, vb: ViewBox): Vec2 => { + const { rect, scale, offX, offY } = measure(vb); + const left = rect?.left ?? 0; + const top = rect?.top ?? 0; + return { + x: vb.x + (clientX - left - offX) / scale, + y: vb.y + (clientY - top - offY) / scale, + }; + }; + + // viewBox-Einheiten → Modell-Koordinaten in Metern (Inverse des fixen + // toScreen): x/PX_PER_M, Plan-Y zeigt nach oben → −y/PX_PER_M. + const viewToModel = (vbX: number, vbY: number): Vec2 => ({ + x: vbX / PX_PER_M, + y: -vbY / PX_PER_M, + }); + + // Roher Modellpunkt (Meter) unter dem Cursor — für die Zeichenwerkzeuge. + const rawModelAt = (clientX: number, clientY: number): Vec2 => { + const v = clientToView(clientX, clientY, view); + return viewToModel(v.x, v.y); + }; + // viewBox-Koordinaten → Client-Pixel (Inverse von clientToView), für die + // Griff-Trefferprüfung in Bildschirm-Pixeln. + const vbToClient = (vbX: number, vbY: number): { x: number; y: number } => { + const { rect, scale, offX, offY } = measure(view); + const left = rect?.left ?? 0; + const top = rect?.top ?? 0; + return { x: left + offX + (vbX - view.x) * scale, y: top + offY + (vbY - view.y) * scale }; + }; + // Index des Griffs unter dem Cursor (Bildschirm-Pixel) oder null. + const gripAt = (clientX: number, clientY: number): number | null => { + if (!grips) return null; + for (let i = 0; i < grips.length; i++) { + const vb = toScreen(grips[i]); + const c = vbToClient(vb.x, vb.y); + if (Math.hypot(c.x - clientX, c.y - clientY) <= GRIP_HIT_PX) return i; + } + return null; + }; + // Bildschirm-Versatz (viewBox-Einheiten) eines Kanten-Anfassers entlang seiner + // Normale: das Dreieck sitzt etwas AUSSERHALB der Kante. Y wird gespiegelt + // (toScreen spiegelt Y), damit „außen" am Bildschirm auch wirklich außen ist. + // Dreieck-Höhe (nach außen) + halbe Basisbreite in Bildschirm-Pixeln. Die Basis + // sitzt AUF der Linie (am Kanten-Mittelpunkt), die Spitze zeigt nach außen. + const EDGE_GRIP_H_PX = 14; + const EDGE_GRIP_HALF_PX = 8; + const screenNormal = (n: Vec2): Vec2 => ({ x: n.x, y: -n.y }); + // Index des Kanten-Griffs unter dem Cursor (Bildschirm-Pixel) oder null. Die + // Trefferfläche liegt um die Dreiecks-Mitte (etwas außerhalb der Linie). + const edgeGripAt = (clientX: number, clientY: number): EdgeGrip | null => { + if (!edgeGrips) return null; + const s = meetScale(view, svgRef.current) ?? 1; + const midOutVb = (EDGE_GRIP_H_PX * 0.45) / s; // Mitte des Dreiecks + for (const e of edgeGrips) { + const m = toScreen(e.mid); + const sn = screenNormal(e.normal); + const cVb = { x: m.x + sn.x * midOutVb, y: m.y + sn.y * midOutVb }; + const c = vbToClient(cVb.x, cVb.y); + if (Math.hypot(c.x - clientX, c.y - clientY) <= GRIP_HIT_PX) return e; + } + return null; + }; + // Aktuelle Bildschirm-Pixel pro Meter (für die zoom-unabhängige Snap-Toleranz). + const currentPxPerMeter = (): number => + (meetScale(view, svgRef.current) ?? 1) * PX_PER_M; + // Modifikatoren eines Pointer-Events (Shift=Ortho, Ctrl/Cmd=Snap aus). + const toolMods = (e: React.PointerEvent | React.MouseEvent) => ({ + shift: e.shiftKey, + ctrl: e.ctrlKey || e.metaKey, + alt: e.altKey, + }); + + // Cursor in Modell-Metern nach oben melden (für die Status-Leiste). + const reportCursor = (clientX: number, clientY: number) => { + if (!onCursor) return; + const v = clientToView(clientX, clientY, view); + onCursor(viewToModel(v.x, v.y)); + }; + + // Trefferprüfung: liefert den Index des obersten Polygon-Primitivs unter dem + // Cursor (Modellpunkt in einem Schicht-Polygon) oder null. In umgekehrter + // Reihenfolge testen, damit das zuletzt gezeichnete (oberste) zuerst trifft. + const hitTest = (clientX: number, clientY: number): number | null => { + const v = clientToView(clientX, clientY, view); + const m = viewToModel(v.x, v.y); + const prims = plan.primitives; + for (let i = prims.length - 1; i >= 0; i--) { + const p = prims[i]; + if (p.kind === "polygon" && pointInPolygon(m, p.pts)) return i; + } + return null; + }; + + // Nächstgelegenes 2D-Zeichenelement (Drawing2D) zum Cursor per Linien-Nähe in + // Bildschirm-Pixeln (zoom-unabhängige Toleranz). Liefert die drawingId oder null. + const pickDrawing = (clientX: number, clientY: number): string | null => { + let bestId: string | null = null; + let bestDist = LINE_PICK_PX; + for (const p of plan.primitives) { + if (p.kind !== "line" || !p.drawingId) continue; + const a = vbToClient(toScreen(p.a).x, toScreen(p.a).y); + const b = vbToClient(toScreen(p.b).x, toScreen(p.b).y); + const d = pointSegDistPx({ x: clientX, y: clientY }, a, b); + if (d < bestDist) { + bestDist = d; + bestId = p.drawingId; + } + } + return bestId; + }; + + // Liegt der Cursor auf dem KÖRPER des aktuell selektierten Elements (Wand- + // Poché bzw. 2D-Linie)? Dann startet ein Links-Drag das Parallel-Verschieben. + const onSelectedBody = (clientX: number, clientY: number): boolean => { + const selWall = selectedWallIds && selectedWallIds.length === 1 ? selectedWallIds[0] : null; + if (selWall) { + const hi = hitTest(clientX, clientY); + if (hi !== null) { + const p = plan.primitives[hi]; + if (p.kind === "polygon" && p.wallId === selWall) return true; + } + } + if (selectedDrawingId && pickDrawing(clientX, clientY) === selectedDrawingId) return true; + return false; + }; + + // Maus-Schema (docs/design/context-menu.md): MITTE = schwenken (Pan), + // LINKS = Auswahl, RECHTS = Kontextmenü, RAD = Zoom. + const onPointerDown = (e: React.PointerEvent) => { + if (e.button === 1) { + // Mittlere Taste: Ausschnitt zum Ziehen einfrieren (Pan). + e.preventDefault(); // verhindert Auto-Scroll-Modus im Browser. + e.currentTarget.setPointerCapture(e.pointerId); + drag.current = { + pointerId: e.pointerId, + startClientX: e.clientX, + startClientY: e.clientY, + startView: view, + }; + setDragging(true); + return; + } + if (e.button === 0 && e.altKey && onSegmentCut) { + // Alt+Klick = Schere: getroffenes 2D-Element bestimmen und Segment-Löschen + // an App melden (App ermittelt die nächstgelegene Kante aus dem Modellpunkt). + const id = pickDrawing(e.clientX, e.clientY); + if (id) { + e.preventDefault(); + const v = clientToView(e.clientX, e.clientY, view); + onSegmentCut(id, viewToModel(v.x, v.y)); + return; + } + // Kein 2D-Element getroffen → wie ein normaler Klick weiterbehandeln. + } + if (e.button === 0 && toolActive) { + // Aktives Zeichenwerkzeug: linke Taste setzt Punkte (kein Marquee). Capture, + // damit move/up zuverlässig ankommen; sofort eine Vorschau anstoßen. + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + toolHandlers!.onToolMove( + rawModelAt(e.clientX, e.clientY), + currentPxPerMeter(), + toolMods(e), + ); + return; + } + if (e.button === 0 && gripsActive) { + // Linke Taste auf einem Editier-Griff: Griff ziehen (Endpunkt verschieben). + const gi = gripAt(e.clientX, e.clientY); + if (gi !== null) { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + gripDrag.current = { pointerId: e.pointerId, index: gi }; + return; + } + // Linke Taste auf einem Kanten-Anfasser (Dreieck): Seite ziehen. + if (edgeGripsActive) { + const eg = edgeGripAt(e.clientX, e.clientY); + if (eg) { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + edgeDrag.current = { pointerId: e.pointerId, edge: eg }; + return; + } + } + // Sonst: auf dem Körper des selektierten Elements? → Parallel-Verschieben. + if (onSelectedBody(e.clientX, e.clientY)) { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + moveDrag.current = { pointerId: e.pointerId, last: rawModelAt(e.clientX, e.clientY) }; + return; + } + } + if (e.button === 0) { + // Linke Taste: Auswahl-Geste beginnen. Ob daraus ein Klick (eine Wand + // wählen) oder ein Marquee (Mengenauswahl) wird, entscheidet sich erst + // beim Loslassen anhand der zurückgelegten Strecke (Schwelle in + // onPointerMove). Startpunkt in viewBox-Einheiten festhalten. + e.currentTarget.setPointerCapture(e.pointerId); + const start = clientToView(e.clientX, e.clientY, view); + marquee.current = { + pointerId: e.pointerId, + startClientX: e.clientX, + startClientY: e.clientY, + startView: start, + moved: false, + }; + return; + } + // Rechte Taste (button 2): Kontextmenü läuft über onContextMenu. + }; + + const onPointerMove = (e: React.PointerEvent) => { + // Immer die Cursor-Position melden (auch ohne Ziehen), für die Statusleiste. + reportCursor(e.clientX, e.clientY); + + // Kanten-Griff ziehen: rohen Modellpunkt nach oben melden (App projiziert + // ihn auf die Kanten-Normale → Seite verschiebt sich senkrecht). + const ed = edgeDrag.current; + if (ed && ed.pointerId === e.pointerId) { + gripHandlers!.onEdgeMove( + ed.edge, + rawModelAt(e.clientX, e.clientY), + currentPxPerMeter(), + toolMods(e), + ); + return; + } + + // Editier-Griff ziehen: gesnappten Modellpunkt nach oben melden (live). + const gd = gripDrag.current; + if (gd && gd.pointerId === e.pointerId) { + gripHandlers!.onGripMove( + gd.index, + rawModelAt(e.clientX, e.clientY), + currentPxPerMeter(), + toolMods(e), + ); + return; + } + + // Element parallel verschieben: inkrementelles Delta gegen den letzten Punkt. + const md = moveDrag.current; + if (md && md.pointerId === e.pointerId) { + const cur = rawModelAt(e.clientX, e.clientY); + const delta = { x: cur.x - md.last.x, y: cur.y - md.last.y }; + if (delta.x !== 0 || delta.y !== 0) { + gripHandlers!.onMoveBody(delta); + md.last = cur; + } + return; + } + + // Aktives Zeichenwerkzeug: Hover/Drag liefert die Live-Vorschau + Snap-Marker. + // Mittlere Taste (Pan) bleibt davon unberührt (eigener Zweig unten). + if (toolActive && drag.current === null) { + toolHandlers!.onToolMove( + rawModelAt(e.clientX, e.clientY), + currentPxPerMeter(), + toolMods(e), + ); + return; + } + + // Marquee (linke Taste) aufziehen: Rechteck in viewBox-Einheiten vom + // Startpunkt zum aktuellen Cursor. Erst ab einer kleinen Pixel-Schwelle gilt + // es als Drag (sonst bleibt es ein Klick → Einzelwahl beim Loslassen). + const mq = marquee.current; + if (mq && mq.pointerId === e.pointerId) { + const dxPx = e.clientX - mq.startClientX; + const dyPx = e.clientY - mq.startClientY; + if (!mq.moved && Math.hypot(dxPx, dyPx) < MARQUEE_THRESHOLD_PX) return; + mq.moved = true; + const cur = clientToView(e.clientX, e.clientY, view); + // crossing-Richtung aus dem Vorzeichen von Δx am Bildschirm (rechts→links + // = negativ = berührend). Bildschirm-x und viewBox-x sind gleichläufig. + const crossing = dxPx < 0; + setMarqueeRect({ + x: Math.min(mq.startView.x, cur.x), + y: Math.min(mq.startView.y, cur.y), + w: Math.abs(cur.x - mq.startView.x), + h: Math.abs(cur.y - mq.startView.y), + crossing, + }); + return; + } + + const d = drag.current; + if (!d || d.pointerId !== e.pointerId) return; + const { scale } = measure(d.startView); + // Bildschirm-Delta → viewBox-Einheiten; Inhalt folgt dem Cursor, also + // verschiebt sich der Ausschnitt entgegengesetzt. + const dx = (e.clientX - d.startClientX) / scale; + const dy = (e.clientY - d.startClientY) / scale; + setView({ + x: d.startView.x - dx, + y: d.startView.y - dy, + w: d.startView.w, + h: d.startView.h, + }); + }; + + const endDrag = (e: React.PointerEvent) => { + // Kanten-Griff loslassen: Ziehen abschließen. + const ed = edgeDrag.current; + if (ed && ed.pointerId === e.pointerId) { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + edgeDrag.current = null; + gripHandlers!.onGripEnd(); + return; + } + + // Editier-Griff loslassen: Ziehen abschließen. + const gd = gripDrag.current; + if (gd && gd.pointerId === e.pointerId) { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + gripDrag.current = null; + gripHandlers!.onGripEnd(); + return; + } + + // Körper-Verschieben loslassen. + const md = moveDrag.current; + if (md && md.pointerId === e.pointerId) { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + moveDrag.current = null; + gripHandlers!.onGripEnd(); + return; + } + + // Aktives Zeichenwerkzeug: linke Taste loslassen = Punkt setzen (Klick). + if (toolActive && e.button === 0) { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + toolHandlers!.onToolClick( + rawModelAt(e.clientX, e.clientY), + currentPxPerMeter(), + toolMods(e), + ); + return; + } + + // Marquee/Klick (linke Taste) abschließen. + const mq = marquee.current; + if (mq && mq.pointerId === e.pointerId) { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + marquee.current = null; + if (mq.moved) { + // Echtes Auswahl-Rechteck: Wände nach CAD-Konvention bestimmen und nach + // oben melden. Das Rechteck stand zuletzt in marqueeRect (viewBox-Raum) + // → in Modell-Raum umrechnen und gegen die Wand-Polygone testen. + const cur = clientToView(e.clientX, e.clientY, view); + const crossing = e.clientX - mq.startClientX < 0; + const a = viewToModel(mq.startView.x, mq.startView.y); + const b = viewToModel(cur.x, cur.y); + const rect = { + minX: Math.min(a.x, b.x), + minY: Math.min(a.y, b.y), + maxX: Math.max(a.x, b.x), + maxY: Math.max(a.y, b.y), + }; + if (onMarquee) { + onMarquee({ + wallIds: marqueeHit(plan.primitives, rect, crossing), + drawingIds: marqueeHitDrawings(plan.primitives, rect, crossing), + crossing, + }); + } + // Die lokale Einzel-Hervorhebung räumen (jetzt gilt die Mengenauswahl). + setSelected(null); + setMarqueeRect(null); + return; + } + // Kein Drag → Klick: Wand unter dem Cursor wählen bzw. Auswahl löschen. + setMarqueeRect(null); + const hitIndex = hitTest(e.clientX, e.clientY); + setSelected(hitIndex); + if (onSelect) { + const v = clientToView(e.clientX, e.clientY, view); + const hit = hitIndex !== null ? plan.primitives[hitIndex] : undefined; + const wallId = hit && hit.kind === "polygon" ? hit.wallId ?? null : null; + // Traf der Klick KEINE Wand: zuerst eine getroffene gefüllte 2D-Fläche + // (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe. + const polyDrawingId = + hit && hit.kind === "polygon" ? hit.drawingId ?? null : null; + const drawingId = wallId + ? null + : polyDrawingId ?? pickDrawing(e.clientX, e.clientY); + onSelect({ + model: viewToModel(v.x, v.y), + hitIndex, + wallId, + drawingId, + shift: e.shiftKey, + }); + } + return; + } + + const d = drag.current; + if (!d || d.pointerId !== e.pointerId) return; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + drag.current = null; + setDragging(false); + }; + + const onWheel = (e: React.WheelEvent) => { + e.preventDefault(); + setView((prev) => { + // Punkt unter dem Cursor (bleibt nach dem Zoom ortsfest). + const anchor = clientToView(e.clientX, e.clientY, prev); + // Hochrollen (deltaY < 0) → hinein → kleinere viewBox. + let factor = Math.exp(e.deltaY * WHEEL_STEP); + // Zoom relativ zur Default-Breite begrenzen. + const minW = defaultView.w / ZOOM_MAX; + const maxW = defaultView.w / ZOOM_MIN; + const targetW = clamp(prev.w * factor, minW, maxW); + // Tatsächlich angewandter Faktor nach Klemmung (hält Seitenverhältnis). + factor = targetW / prev.w; + const w = prev.w * factor; + const h = prev.h * factor; + // x/y so anpassen, dass der Anker auf demselben Pixel bleibt. + return { + x: anchor.x - (anchor.x - prev.x) * factor, + y: anchor.y - (anchor.y - prev.y) * factor, + w, + h, + }; + }); + }; + + // Doppelklick → bei aktivem Werkzeug den mehrteiligen Entwurf abschließen, + // sonst wieder auf die eingepasste Standard-Ansicht. + const onDoubleClick = () => { + if (toolActive) { + toolHandlers!.onToolCommit(); + return; + } + setView(defaultView); + }; + + // Rechtsklick: Browser-Menü unterdrücken. Bei aktivem Werkzeug mit laufendem + // Entwurf = abschließen (CAD-üblich, KEIN Kontextmenü); sonst Kontextmenü. + const onContextMenuHandler = (e: React.MouseEvent) => { + e.preventDefault(); + // Bei aktivem Werkzeug/Befehl ist Rechtsklick = Enter (abschließen/wiederholen, + // §2.3) — KEIN Kontextmenü, auch ohne laufenden Entwurf. + if (toolActive) { + toolHandlers!.onToolCommit(); + return; + } + if (!onContextMenu) return; + const v = clientToView(e.clientX, e.clientY, view); + onContextMenu({ + clientX: e.clientX, + clientY: e.clientY, + model: viewToModel(v.x, v.y), + }); + }; + + // Hervorhebungs-Konturen der Auswahl. Vorrang hat die wand-bezogene Auswahl + // (selectedWallIds, von App kontrolliert): dann werden ALLE Bänder JEDER + // gewählten Wand umrandet. Ohne Wand-Auswahl greift die lokale Klick- + // Hervorhebung (ein einzelnes Polygon per hitIndex), z. B. für Polygone ohne + // Wandbezug. + const highlightPolys = useMemo(() => { + const ids = selectedWallIds && selectedWallIds.length ? new Set(selectedWallIds) : null; + if (ids) { + return plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.wallId != null && ids.has(p.wallId), + ); + } + if (selected !== null) { + const p = plan.primitives[selected]; + if (p && p.kind === "polygon") return [p]; + } + return []; + }, [plan, selectedWallIds, selected]); + + // Hervorhebung der selektierten 2D-Zeichenelemente (Mehrfach/Marquee): alle + // Linien-/Polygon-Primitive, deren drawingId in der Auswahl liegt, werden mit + // Akzentkontur überzeichnet (sichtbares Feedback auch ohne Griffe). + const highlightDrawPrims = useMemo(() => { + const ids = + selectedDrawingIds && selectedDrawingIds.length + ? new Set(selectedDrawingIds) + : null; + if (!ids) return []; + return plan.primitives.filter( + (p): p is Extract => + (p.kind === "line" || p.kind === "polygon") && + p.drawingId != null && + ids.has(p.drawingId), + ); + }, [plan, selectedDrawingIds]); + + // Pro Polygon mit Linien-/Kreuz-Schraffur ein eigenes sammeln. + // Vollfüllung ("solid") und "none" brauchen kein Muster. + const hatched = useMemo(() => { + const list: HatchedPoly[] = []; + plan.primitives.forEach((p, i) => { + if (p.kind === "polygon" && needsPattern(p.hatch.pattern)) { + list.push({ patternId: `hatch-${i}`, hatch: p.hatch }); + } + }); + return list; + }, [plan]); + + return ( + onCursor?.(null)} + onWheel={onWheel} + onDoubleClick={onDoubleClick} + onContextMenu={onContextMenuHandler} + // Maus-Schema: MITTE schwenkt (grabbing), sonst Standard-Cursor (LINKS + // wählt aus). touchAction:none, damit das Schwenken nicht scrollt. + style={{ + cursor: dragging + ? "grabbing" + : toolActive || marqueeRect + ? "crosshair" + : "default", + touchAction: "none", + }} + > + + {hatched.map((h) => ( + + ))} + + {/* Zeichenblatt-Hintergrund: deckt den aktuellen Ausschnitt (viewBox) + vollständig — unabhängig von den Modell-Bounds (fixer Ursprung). */} + + {plan.primitives.map((p, i) => ( + + ))} + {/* Auswahl-Hervorhebung: alle Bänder der gewählten Wand (bzw. das lokal + getroffene Polygon) mit Akzentkontur überzeichnen. */} + {highlightPolys.map((poly, i) => ( + `${s.x},${s.y}`) + .join(" ")} + pointerEvents="none" + /> + ))} + {/* Hervorhebung der selektierten 2D-Zeichenelemente (Akzentkontur). */} + {highlightDrawPrims.map((p, i) => + p.kind === "line" ? ( + + ) : ( + `${s.x},${s.y}`) + .join(" ")} + pointerEvents="none" + /> + ), + )} + {/* Auswahl-Rechteck (Marquee). Gestrichelt; die crossing-Variante (rechts→ + links, berührend) hebt sich farblich/strichlich ab. Das Rechteck steht + bereits in viewBox-Einheiten → direkt setzbar. */} + {marqueeRect && ( + + )} + {/* Editier-Griffe des selektierten Elements (Wand-Enden / 2D-Vertices): + ziehbare Quadrate. Bildschirmkonstante Größe über die meet-Skala. */} + {gripsActive && + grips!.map((g, i) => { + const s = toScreen(g); + const size = 6 / (meetScale(view, svgRef.current) ?? 1); + return ( + + ); + })} + {/* Kanten-/Seiten-Griffe: je Seite ein dreieckiger Anfasser, leicht nach + AUSSEN entlang der (bildschirm-transformierten) Normale versetzt, mit + der Spitze nach außen. Y wird gespiegelt (toScreen spiegelt Y), damit + das Dreieck wirklich nach außen zeigt. Größe bildschirmkonstant. */} + {edgeGripsActive && + edgeGrips!.map((eg, i) => { + const s = meetScale(view, svgRef.current) ?? 1; + const m = toScreen(eg.mid); + // Normale in den Bildschirmraum (Y spiegeln); auf Bildschirm-px-Größen. + const sn = screenNormal(eg.normal); + const hVb = EDGE_GRIP_H_PX / s; // Dreieckshöhe (nach außen) + const halfVb = EDGE_GRIP_HALF_PX / s; // halbe Basisbreite + // Tangente = 90°-Drehung der (Bildschirm-)Normale, für die Basisbreite. + const tx = -sn.y; + const ty = sn.x; + // Basis SITZT AUF DER LINIE (Mittelpunkt m), Spitze zeigt nach außen. + const tipX = m.x + sn.x * hVb; + const tipY = m.y + sn.y * hVb; + const b1x = m.x + tx * halfVb; + const b1y = m.y + ty * halfVb; + const b2x = m.x - tx * halfVb; + const b2y = m.y - ty * halfVb; + return ( + + ); + })} + {/* Werkzeug-Overlay: Live-Vorschau (Rubber-Band) + gesetzte Stützpunkte + + Snap-Marker + HUD. Immer obenauf, pointerEvents none. Glyph-/Schrift- + Größen werden über die aktuelle meet-Skala bildschirmkonstant gehalten. */} + {toolHandlers?.draft && ( + + )} + + ); +}); + +/** + * Zeichnet die Werkzeug-Vorschau: Vorschau-Formen (gestrichelte Akzentlinien / + * halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate), den aktiven + * Snap-Marker und ein Maß-/Winkel-HUD. `vbPerPx` rechnet eine gewünschte + * Bildschirm-Pixelgröße in viewBox-Einheiten um (bildschirmkonstante Marker). + */ +function DraftOverlay({ + draft, + toScreen, + vbPerPx, +}: { + draft: { preview: DraftShape[]; vertices: Vec2[]; snap?: SnapResult | null; hud?: { at: Vec2; text: string } }; + toScreen: (v: Vec2) => Vec2; + vbPerPx: number; +}) { + const vertSize = 7 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats + const markSize = 6 * vbPerPx; // halbe Kantenlänge des Snap-Markers + const fontPx = 13 * vbPerPx; + return ( + + {/* Vorschau-Formen. */} + {draft.preview.map((s, i) => + s.kind === "line" ? ( + + ) : ( + `${p.x},${p.y}`).join(" ")} + vectorEffect="non-scaling-stroke" + /> + ), + )} + {/* Gesetzte Stützpunkte. */} + {draft.vertices.map((v, i) => { + const s = toScreen(v); + return ( + + ); + })} + {/* Snap-Marker + optionale Ortho-Hilfslinie. */} + {draft.snap && ( + + )} + {/* HUD (Länge · Winkel) leicht versetzt über dem Cursor. */} + {draft.hud && ( + + {draft.hud.text} + + )} + + ); +} + +/** Marker-Glyph je Snap-Art + (bei ortho) eine gestrichelte Hilfslinie. */ +function SnapMarker({ + snap, + toScreen, + size, +}: { + snap: SnapResult; + toScreen: (v: Vec2) => Vec2; + size: number; +}) { + const s = toScreen(snap.point); + const r = size; + return ( + + {(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && ( + + )} + {snap.kind === "grid" && ( + + )} + {snap.kind === "midpoint" && ( + + )} + {snap.kind === "intersection" && ( + <> + + + + )} + {(snap.kind === "center" || snap.kind === "quadrant") && ( + + )} + {snap.kind === "onEdge" && ( + + )} + {(snap.kind === "endpoint" || snap.kind === "ortho" || snap.kind === "extension") && ( + + )} + + ); +} + +/** Wert auf [lo, hi] begrenzen. */ +function clamp(value: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, value)); +} + +/** + * Effektive meet-Skala (Bildschirm-px je viewBox-Einheit) für einen Ausschnitt. + * preserveAspectRatio="xMidYMid meet" nimmt die kleinere der beiden Achsen. + * Liefert null, solange das SVG noch nicht gemessen werden kann. + */ +function meetScale(vb: ViewBox, el: SVGSVGElement | null): number | null { + const rect = el?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return null; + return Math.min(rect.width / vb.w, rect.height / vb.h); +} + +/** + * LIVE Papier-Massstab 1:N aus dem Ausschnitt (docs/design/plans-output.md §3): + * 1 m belegt `scale·PX_PER_M` CSS-px; in mm = ·25.4/dpi; auf Papier 1:N sind + * das 1000/N mm ⇒ N = 1000·dpi / (scale·PX_PER_M·25.4). Letterbox-korrekt, da + * `scale` die tatsächliche meet-Skala ist. + */ +function scaleFromView(vb: ViewBox, el: SVGSVGElement | null): number | null { + const scale = meetScale(vb, el); + if (scale == null) return null; + return (1000 * dpi()) / (scale * PX_PER_M * 25.4); +} + +/** + * Ziel-Ausschnitt, der das Modell exakt im Papier-Massstab 1:N abbildet (Inverse + * von {@link scaleFromView}). Setzt die viewBox auf das Canvas-Seitenverhältnis + * (kein Letterbox → horizontale wie vertikale Skala = Zielskala) und hält die + * Bildmitte des bisherigen Ausschnitts. Ohne Messung null (kein Sprung blind). + */ +function viewForScale( + n: number, + prev: ViewBox, + el: SVGSVGElement | null, +): ViewBox | null { + const rect = el?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0 || !(n > 0)) return null; + // Ziel-meet-Skala aus N, dann viewBox = Canvas-px / Skala (beide Achsen). + const targetScale = (1000 * dpi()) / (n * PX_PER_M * 25.4); + const w = rect.width / targetScale; + const h = rect.height / targetScale; + const cx = prev.x + prev.w / 2; + const cy = prev.y + prev.h / 2; + return { x: cx - w / 2, y: cy - h / 2, w, h }; +} + +/** + * Einpass-Ausschnitt für ein Polygon (in Modell-Metern): dessen Bounding-Box + * → viewBox-Einheiten (über toScreen) mit etwas Rand. Für „auf Auswahl einpassen". + */ +function fitBoxFor(pts: Vec2[], toScreen: (v: Vec2) => Vec2): ViewBox { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const p of pts) { + const s = toScreen(p); + minX = Math.min(minX, s.x); + minY = Math.min(minY, s.y); + maxX = Math.max(maxX, s.x); + maxY = Math.max(maxY, s.y); + } + const w = maxX - minX; + const h = maxY - minY; + const margin = Math.max(w, h) * 0.25 + PAD; // etwas Luft um die Auswahl + return { + x: minX - margin, + y: minY - margin, + w: w + margin * 2, + h: h + margin * 2, + }; +} + +/** + * Punkt-in-Polygon (Ray-Casting, gerade Kreuzungszahl). Arbeitet in beliebigen + * 2D-Koordinaten — hier in Modell-Metern, damit die Trefferprüfung unabhängig + * vom Zoom ist. Rand zählt nicht garantiert; für die Wand-Auswahl ausreichend. + */ +function pointInPolygon(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; +} + +/** Abstand Punkt→Strecke (alle in denselben Koordinaten, hier Client-Pixel). */ +function pointSegDistPx( + p: { x: number; y: number }, + a: { x: number; y: number }, + b: { x: number; y: number }, +): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y); + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)); +} + +/** Achsenparalleles Rechteck in Modell-Metern (Auswahl-Marquee). */ +interface ModelRect { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * Bestimmt die vom Auswahl-Rechteck getroffenen Wand-IDs nach CAD-Konvention: + * • crossing = false (links→rechts): nur Wände, deren Polygone VOLLSTÄNDIG im + * Rechteck liegen (jeder Eckpunkt enthalten). + * • crossing = true (rechts→links): zusätzlich Wände, die das Rechteck nur + * BERÜHREN/kreuzen (Überlappung der Polygone mit dem Rechteck). + * Getestet wird je Wand über ALLE ihre Polygon-Bänder; ein Treffer eines Bandes + * genügt. Liefert eindeutige IDs in Auftreten-Reihenfolge. + */ +function marqueeHit( + prims: Primitive[], + rect: ModelRect, + crossing: boolean, +): string[] { + // Polygone je Wand sammeln (nur Primitive mit Wandbezug zählen für die Auswahl). + const byWall = new Map(); + for (const p of prims) { + if (p.kind !== "polygon" || p.wallId == null) continue; + const list = byWall.get(p.wallId); + if (list) list.push(p.pts); + else byWall.set(p.wallId, [p.pts]); + } + const out: string[] = []; + for (const [wallId, polys] of byWall) { + const hit = crossing + ? polys.some((poly) => polyTouchesRect(poly, rect)) + : polys.every((poly) => polyEnclosedByRect(poly, rect)); + if (hit) out.push(wallId); + } + return out; +} + +/** + * Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing- + * Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus + * den Linien-/Polygon-Primitiven; crossing = irgendein Punkt/Kante im Rechteck, + * window = alle Punkte vollständig eingeschlossen. + */ +function marqueeHitDrawings( + prims: Primitive[], + rect: ModelRect, + crossing: boolean, +): string[] { + const byDrawing = new Map(); + const collect = (id: string, pts: Vec2[]) => { + const list = byDrawing.get(id); + if (list) list.push(...pts); + else byDrawing.set(id, [...pts]); + }; + for (const p of prims) { + if (p.kind === "line" && p.drawingId) collect(p.drawingId, [p.a, p.b]); + else if (p.kind === "polygon" && p.drawingId) collect(p.drawingId, p.pts); + } + const out: string[] = []; + for (const [id, pts] of byDrawing) { + const hit = crossing + ? polyTouchesRect(pts, rect) + : polyEnclosedByRect(pts, rect); + if (hit) out.push(id); + } + return out; +} + +/** Alle Eckpunkte des Polygons liegen im Rechteck (vollständig eingeschlossen). */ +function polyEnclosedByRect(poly: Vec2[], r: ModelRect): boolean { + if (poly.length === 0) return false; + return poly.every( + (pt) => + pt.x >= r.minX && pt.x <= r.maxX && pt.y >= r.minY && pt.y <= r.maxY, + ); +} + +/** + * Polygon und Rechteck überlappen/berühren sich (crossing-Test). Wahr, wenn: + * • ein Polygon-Eckpunkt im Rechteck liegt, ODER + * • eine Rechteck-Ecke im Polygon liegt, ODER + * • eine Polygon-Kante eine Rechteck-Kante schneidet. + * Deckt damit auch den Fall ab, dass das Rechteck ganz innerhalb der Wand liegt. + */ +function polyTouchesRect(poly: Vec2[], r: ModelRect): boolean { + // 1) Polygon-Punkt im Rechteck? + for (const pt of poly) { + if (pt.x >= r.minX && pt.x <= r.maxX && pt.y >= r.minY && pt.y <= r.maxY) { + return true; + } + } + // 2) Rechteck-Ecke im Polygon? (Rechteck ganz in der Wand.) + const corners: Vec2[] = [ + { x: r.minX, y: r.minY }, + { x: r.maxX, y: r.minY }, + { x: r.maxX, y: r.maxY }, + { x: r.minX, y: r.maxY }, + ]; + for (const c of corners) { + if (pointInPolygon(c, poly)) return true; + } + // 3) Kanten-Schnitt Polygon × Rechteck. + const rectEdges: [Vec2, Vec2][] = [ + [corners[0], corners[1]], + [corners[1], corners[2]], + [corners[2], corners[3]], + [corners[3], corners[0]], + ]; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[j]; + const b = poly[i]; + for (const [c, d] of rectEdges) { + if (segmentsIntersect(a, b, c, d)) return true; + } + } + return false; +} + +/** Orientierung des Tripels (a,b,c): >0 CCW, <0 CW, 0 kollinear. */ +function cross3(a: Vec2, b: Vec2, c: Vec2): number { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +/** Schneiden sich die Strecken a-b und c-d (inkl. Berührung)? */ +function segmentsIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { + const d1 = cross3(c, d, a); + const d2 = cross3(c, d, b); + const d3 = cross3(a, b, c); + const d4 = cross3(a, b, d); + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && + ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true; + } + // Kollineare Berührung. + if (d1 === 0 && onSegment(c, d, a)) return true; + if (d2 === 0 && onSegment(c, d, b)) return true; + if (d3 === 0 && onSegment(a, b, c)) return true; + if (d4 === 0 && onSegment(a, b, d)) return true; + return false; +} + +/** Liegt p auf der Strecke a-b (bei bereits kollinearen Punkten)? */ +function onSegment(a: Vec2, b: Vec2, p: Vec2): boolean { + return ( + Math.min(a.x, b.x) <= p.x && + p.x <= Math.max(a.x, b.x) && + Math.min(a.y, b.y) <= p.y && + p.y <= Math.max(a.y, b.y) + ); +} + +/** Lineare/Kreuz-Muster brauchen ein ; solid/none nicht. */ +function needsPattern(pattern: HatchRender["pattern"]): boolean { + return ( + pattern === "insulation" || + pattern === "diagonal" || + pattern === "crosshatch" + ); +} + +/** + * Übersetzt eine Linienstärke (mm) in Bildschirm-Pixel für Musterlinien. + * Bei 0.13 mm ergibt sich ~1 px (entspricht der bisherigen Dämmungs-/ + * Diagonal-Strichstärke), damit die Schraffuren wie zuvor aussehen. + */ +const hatchStrokePx = (weightMm: number): number => + Math.max(0.6, weightMm * (1 / 0.13)); + +/** Strichmuster (mm) → SVG dasharray (px) oder undefined (durchgezogen). */ +function dashArray(dash: number[] | null): string | undefined { + if (!dash || dash.length === 0) return undefined; + return dash.map((d) => d * (1 / 0.13)).join(" "); +} + +/** + * Ein parametrisiertes Schraffur-. Maßstab skaliert die Kachelgröße, + * Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem + * aufgelösten Hatch/LineStyle. + */ +function HatchPattern({ id, hatch }: { id: string; hatch: HatchRender }) { + const sw = hatchStrokePx(hatch.lineWeight); + const dashes = dashArray(hatch.dash); + + if (hatch.pattern === "insulation") { + // Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab. + const w = 14 * hatch.scale; + const h = 10 * hatch.scale; + const my = 5 * hatch.scale; + const qy1 = -1 * hatch.scale; + const qy2 = 11 * hatch.scale; + const qx1 = 3.5 * hatch.scale; + const qx2 = 7 * hatch.scale; + const qx3 = 10.5 * hatch.scale; + return ( + + + + ); + } + + // Diagonal + Crosshatch: dünne Linien, Winkel via patternTransform. + // Kachel 8×8 × Maßstab; crosshatch fügt eine zweite, senkrechte Schar hinzu. + const tile = 8 * hatch.scale; + return ( + + + {hatch.pattern === "crosshatch" && ( + + )} + + ); +} + +/** Opazität gedimmter („grau") Primitive im Darstellungsmodus „andere grau". */ +const GREYED_OPACITY = 0.3; + +function PrimitiveShape({ + p, + index, + toScreen, + hairline, +}: { + p: Primitive; + index: number; + toScreen: (v: Vec2) => Vec2; + hairline: boolean; +}) { + // Gedimmte Elemente bekommen eine niedrige Gruppen-Opazität, sodass Strich + // UND Füllung gleichmäßig zurücktreten (best-effort, ohne Farbumrechnung). + const shape = renderPrimitive(p, index, toScreen, hairline); + if (p.greyed) { + return {shape}; + } + return shape; +} + +/** Konstante Haarlinien-Breite (Bildschirm-px) im Display-Modus. */ +const HAIRLINE_PX = 1; + +/** Erzeugt das reine SVG eines Primitivs (ohne Dimm-Hülle). */ +function renderPrimitive( + p: Primitive, + index: number, + toScreen: (v: Vec2) => Vec2, + hairline: boolean, +) { + // Display-Modus: konstante Haarlinie statt echter mm-Strichstärke. + const weight = (mm: number) => (hairline ? HAIRLINE_PX : mmToPx(mm)); + switch (p.kind) { + case "polygon": { + const pts = p.pts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); + // Umriss-Strichstärke in mm Papier → konstante Bildschirm-px + // (non-scaling-stroke ⇒ papierkonstant beim Zoomen). + const sw = weight(p.strokeWidthMm); + // Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss). + if (p.hatch.pattern === "none") { + return ( + + ); + } + // Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché). + if (p.hatch.pattern === "solid") { + return ( + + ); + } + // Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster darüber. + return ( + + + + + ); + } + case "line": { + const a = toScreen(p.a); + const b = toScreen(p.b); + // Strichstärke + Strichmuster in mm Papier (non-scaling). Die Farbe kommt + // weiterhin aus der CSS-Klasse (z. B. .door-leaf / .wall-axis). + return ( + + ); + } + case "arc": { + const c = toScreen(p.center); + const from = toScreen(p.from); + const to = toScreen(p.to); + const r = p.r * PX_PER_M; + // Kurzer Weg (≤180°): large-arc = 0. Drehrichtung aus Kreuzprodukt + // im Bildschirmraum (y nach unten). + const v1 = { x: from.x - c.x, y: from.y - c.y }; + const v2 = { x: to.x - c.x, y: to.y - c.y }; + const cross = v1.x * v2.y - v1.y * v2.x; + const sweep = cross > 0 ? 1 : 0; + const d = `M ${from.x} ${from.y} A ${r} ${r} 0 0 ${sweep} ${to.x} ${to.y}`; + return ( + + ); + } + } +} diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts new file mode 100644 index 0000000..3d00dc8 --- /dev/null +++ b/src/plan/generatePlan.ts @@ -0,0 +1,692 @@ +// Grundriss-Generator: erzeugt aus dem semantischen Modell den +// 2D-Grundriss als abstrakte Vektor-Primitive (später → SVG). +// +// Kern-Erkenntnis: Der Grundriss entsteht NICHT durch Zerschneiden eines +// 3D-Meshes, sondern direkt aus den Parametern (Wandachsen, Schichtaufbau, +// Öffnungen) — exakt, schnell, sauber. + +import type { + Drawing2D, + HatchPattern, + LayerCategory, + Project, + Vec2, + Wall, +} from "../model/types"; +import { + flattenCategories, + getComponent, + getHatch, + getLineStyle, + getWallType, + wallTypeThickness, +} from "../model/types"; +import { wallReferenceOffset } from "../model/wall"; +import { + add, + along, + clippedBand, + leftNormal, + normalize, + scale, + sub, + wallCorners, +} from "../model/geometry"; + +/** + * Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier + * konkret zwei Dinge im Grundriss: + * • Tür-/Fenstersymbole: grob = nur Öffnungs-Lücke + gerade Türblatt-Linie; + * mittel = + Schwenkbogen; fein = + Rahmen/Anschlag-Striche. + * • Schraffuren + Linienfeinheit: grob = vereinfachte Poché (eine Sammelfläche + * ohne Schraffur, dickere Umrisslinie); mittel = aktueller mehrschichtiger + * Aufbau mit Schraffuren; fein = volle Schraffuren + dünnere Linien. + * + * NOCH OFFEN (folgt, sobald die Features existieren — hier bewusst KEIN + * Schein-Effekt): Bemaßung/Beschriftung werden mit dem Detailgrad auf-/abblenden + * (Maßketten, Raumstempel), und die 3D-Geometrie-Auflösung (z. B. Profil- + * Rundungen, Tritt-/Setzstufen) wird ebenfalls dem Detailgrad folgen, sobald + * Bemaßung bzw. eine feinere 3D-Tessellierung implementiert sind. + */ +export type DetailLevel = "grob" | "mittel" | "fein"; +import { computeJoins } from "../model/joins"; +import type { WallCuts } from "../model/joins"; + +/** Stroke-Farbe der Schicht-Polygone (dunkles Grau). */ +const POCHE_STROKE = "#2b3039"; + +/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */ +const MONO_INK = "#111111"; + +// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ── +// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über +// dpi = 96·devicePixelRatio in konstante Bildschirm-Pixel um (non-scaling-stroke), +// damit Linien beim Zoomen ihre Papiergröße behalten und der Massstab stimmt. + +/** Dünne Trennlinie zwischen den Schichten (Schichtfugen) in mm. */ +const LAYER_LINE_MM = 0.13; +/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */ +const OUTLINE_DETAIL_FACTOR: Record = { + grob: 1.6, // dickere Sammellinie + mittel: 1.0, // wie die Kategorie-Strichstärke + fein: 0.6, // feiner +}; +/** Stärke der Schichtfugen je Detailgrad (mittel/fein; grob hat keine Fugen). */ +const LAYER_DETAIL_FACTOR: Record = { + grob: 1.0, + mittel: 1.0, + fein: 0.7, +}; +/** Default-Umrandungs-Strichstärke (mm), falls die Kategorie keine lw trägt + (Grundeinstellung für „normale Elemente"). */ +const WALL_FALLBACK_MM = 0.18; + +/** + * Aufgelöste Schraffur eines Polygons — alle Werte stammen aus einer + * HatchStyle-Ressource (Muster/Maßstab/Winkel/Farbe) plus dem optional + * verknüpften LineStyle (Linienstärke/Strichmuster der Musterlinien). Die + * PlanView ist damit rein darstellend und kennt das Projekt nicht. + */ +export interface HatchRender { + pattern: HatchPattern; + scale: number; + angle: number; + /** Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid"). */ + color: string; + /** Linienstärke der Musterlinien in mm. */ + lineWeight: number; + /** Strichmuster der Musterlinien in mm; null = durchgezogen. */ + dash: number[] | null; +} + +/** + * Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus + * dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus, + * `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für + * jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten). + */ +export interface CategoryDisplay { + render: boolean; + greyed: boolean; +} + +/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */ +export type CategoryDisplayResolver = (code: string) => CategoryDisplay; + +/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */ +const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false }); + +export type Primitive = + | { + kind: "polygon"; + pts: Vec2[]; + /** Füllfarbe (Component-Poché) bzw. "none" für reine Umrisslinien. */ + fill: string; + stroke: string; + /** Umriss-Strichstärke in PAPIER-Millimeter (siehe oben). */ + strokeWidthMm: number; + hatch: HatchRender; + /** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */ + greyed?: boolean; + /** + * ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die + * Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben + * Wand und meldet die wallId nach oben). Reine Umriss-/Symbol-Primitive + * ohne Wandbezug lassen das Feld weg. + */ + wallId?: string; + /** + * ID des 2D-Zeichenelements (Drawing2D), falls dieses gefüllte Polygon eine + * geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche). + */ + drawingId?: string; + } + | { + kind: "line"; + a: Vec2; + b: Vec2; + cls: string; + /** Strichstärke in mm Papier (non-scaling). */ + weightMm: number; + /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ + dash?: number[] | null; + /** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */ + color?: string; + /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */ + drawingId?: string; + greyed?: boolean; + } + | { + kind: "arc"; + center: Vec2; + from: Vec2; + to: Vec2; + r: number; + cls: string; + /** Strichstärke in mm Papier (non-scaling). */ + weightMm: number; + /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ + dash?: number[] | null; + greyed?: boolean; + }; + +/** + * Löst eine HatchStyle-Ressource (per id) zu einer darstellungsfertigen + * {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt, + * aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default. + */ +function resolveHatch(project: Project, hatchId: string): HatchRender { + const h = getHatch(project, hatchId); + const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null; + return { + pattern: h.pattern, + scale: h.scale, + angle: h.angle, + color: h.color, + lineWeight: ls?.weight ?? 0.13, + dash: ls?.dash ?? null, + }; +} + +export interface Plan { + primitives: Primitive[]; + bounds: { minX: number; minY: number; maxX: number; maxY: number }; +} + +/** + * Erzeugt den Grundriss eines Geschosses, gefiltert nach sichtbaren Ebenen + * (Kategorie-Codes). + * + * Enthält Wände dieses Geschosses, deren `categoryCode` ∈ `visibleCodes`. Eine + * Türöffnung gehört zur Wandgeometrie und wird stets ausgespart; das + * Tür-SYMBOL (Blatt + Bogen) erscheint nur, wenn der Tür-Code sichtbar ist. + */ +export function generatePlan( + project: Project, + floorId: string, + visibleCodes: Set, + categoryDisplay: CategoryDisplayResolver = SHOW_ALL, + detail: DetailLevel = "mittel", + showReferenceLines = false, + mono = false, +): Plan { + const primitives: Primitive[] = []; + // Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich: + // render=false blendet die Wand aus, greyed=true dimmt sie. + const walls = project.walls.filter( + (w) => + w.floorId === floorId && + visibleCodes.has(w.categoryCode) && + categoryDisplay(w.categoryCode).render, + ); + + // Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem + // Modell (Ebene) stammt statt aus einer Magie-Konstante. + const lwByCode = categoryLwMap(project.layers); + const colorByCode = categoryColorMap(project.layers); + + // Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren + // Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché + // gezeichnet (am Ende angehängt). + const drawings = project.drawings2d.filter( + (d) => + d.levelId === floorId && + visibleCodes.has(d.categoryCode) && + categoryDisplay(d.categoryCode).render, + ); + + // Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge). + const joins = computeJoins(project, walls); + + for (const wall of walls) { + const wallGreyed = categoryDisplay(wall.categoryCode).greyed; + const wallLwMm = lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM; + // Türen am Wandhost: die Öffnung bestimmt immer die Wandgeometrie. + const doors = project.doors.filter((d) => d.hostWallId === wall.id); + const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null }; + addWallPoche(primitives, project, wall, doors, cuts, wallGreyed, detail, wallLwMm); + // Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste + // umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt. + if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed); + // Tür-Symbol nur, wenn die Tür-Kategorie sichtbar ist und der Modus sie zeigt. + for (const door of doors) { + const d = categoryDisplay(door.categoryCode); + if (visibleCodes.has(door.categoryCode) && d.render) { + const doorLwMm = lwByCode.get(door.categoryCode) ?? LAYER_LINE_MM; + addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm); + } + } + } + + // 2D-Zeichengeometrie zuletzt (über der Poché). + for (const d of drawings) { + const greyed = categoryDisplay(d.categoryCode).greyed; + addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode); + } + + // Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der + // Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist + // die Reihenfolge unkritisch; wir hängen sie als Referenz an. + addContextContours(primitives, project); + + // Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen. + // Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss, + // Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan. + if (mono) toMono(primitives); + + return { primitives, bounds: computeBounds(project, walls, drawings) }; +} + +/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */ +const CONTEXT_LINE_COLOR = "#9aa3ad"; +/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */ +const CONTEXT_LINE_MM = 0.1; + +/** + * Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als + * dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert + * (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT + * (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt. + */ +function addContextContours(out: Primitive[], project: Project): void { + const context = project.context ?? []; + for (const obj of context) { + if (obj.type !== "contourSet") continue; + for (const c of obj.contours) { + const pts = c.pts; + if (pts.length < 2) continue; + const segEnd = c.closed ? pts.length : pts.length - 1; + for (let i = 0; i < segEnd; i++) { + out.push({ + kind: "line", + a: pts[i], + b: pts[(i + 1) % pts.length], + cls: "context-line", + weightMm: CONTEXT_LINE_MM, + color: CONTEXT_LINE_COLOR, + }); + } + } + } +} + +/** + * Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der + * Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place. + * - Polygone: Füllung → weiss, Umriss → Tinte, Schraffur-Musterlinien → Tinte + * (Vollfüllung „solid" wird zu Weiss neutralisiert, damit keine Poché-Farbe). + * - Linien: explizite Strichfarbe → Tinte (CSS-Klassen sind bereits neutral). + * - Bögen tragen keine Farbe (rein CSS, neutral) → unverändert. + */ +function toMono(primitives: Primitive[]): void { + for (const p of primitives) { + if (p.kind === "polygon") { + // Vollfüllung „solid" deckte die Poché farbig — in mono auf Weiss setzen, + // damit nur der Umriss trägt. Linien-/Kreuzschraffur bleibt, aber in Tinte. + if (p.hatch.pattern === "solid") { + p.hatch = { ...p.hatch, color: "#ffffff" }; + } else if (p.hatch.pattern !== "none") { + p.hatch = { ...p.hatch, color: MONO_INK }; + } + p.fill = p.fill === "none" ? "none" : "#ffffff"; + p.stroke = MONO_INK; + } else if (p.kind === "line") { + p.color = MONO_INK; + } + } +} + +/** Baut eine Map Kategorie-Code → Strichstärke (mm) über den ganzen Baum. */ +function categoryLwMap(layers: LayerCategory[]): Map { + const map = new Map(); + for (const c of flattenCategories(layers)) map.set(c.code, c.lw); + return map; +} + +/** Baut eine Map Kategorie-Code → Farbe über den ganzen Baum. */ +function categoryColorMap(layers: LayerCategory[]): Map { + const map = new Map(); + for (const c of flattenCategories(layers)) map.set(c.code, c.color); + return map; +} + +/** + * Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md + * §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der + * Kategorie. Phase 1 deckt line/polyline/rect ab (alles → line-Primitive); + * circle/arc/text folgen mit den Primitive-Erweiterungen (Phase 3). + */ +function addDrawing2D( + out: Primitive[], + project: Project, + d: Drawing2D, + greyed: boolean, + lwByCode: Map, + colorByCode: Map, +): void { + const ls = d.lineStyleId + ? project.lineStyles.find((l) => l.id === d.lineStyleId) + : undefined; + // Plan-Tinte fix dunkel: Fallback auf MONO_INK (statt hell „#d0d0d0"), damit + // ein kategorieloses 2D-Element auf dem hellen Papier sichtbar bleibt. + const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK; + const weightMm = d.weightMm ?? ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM; + const dash = ls?.dash ?? null; + const seg = (a: Vec2, b: Vec2) => + out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, color, greyed, drawingId: d.id }); + + /** + * Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe + * (`d.fillColor`, sonst transparent) UND die optionale Füllschraffur + * (`d.hatchId`, sonst NO_HATCH). Wird VOR den Strichen gepusht, damit die + * Umrisslinien oben liegen. Trägt `drawingId`, sodass ein Klick auf die Fläche + * das Element selektiert (analog zu den Linien). + */ + const fillPoly = (pts: Vec2[]) => + out.push({ + kind: "polygon", + pts, + fill: d.fillColor ?? "none", + // Umriss zeichnen die separaten Linien-Primitive (mit Strichmuster); die + // Füllfläche selbst bleibt randlos, um doppelte Striche zu vermeiden. + stroke: "none", + strokeWidthMm: 0, + hatch: d.hatchId ? resolveHatch(project, d.hatchId) : NO_HATCH, + greyed, + drawingId: d.id, + }); + + const g = d.geom; + switch (g.shape) { + case "line": + seg(g.a, g.b); + break; + case "polyline": { + // Geschlossene Polylinie: zuerst die gefüllte Fläche (Vollton/Schraffur). + if (g.closed && g.pts.length > 2) fillPoly(g.pts); + for (let i = 0; i < g.pts.length - 1; i++) seg(g.pts[i], g.pts[i + 1]); + if (g.closed && g.pts.length > 2) seg(g.pts[g.pts.length - 1], g.pts[0]); + break; + } + case "rect": { + const c1 = g.min; + const c2 = { x: g.max.x, y: g.min.y }; + const c3 = g.max; + const c4 = { x: g.min.x, y: g.max.y }; + // Rechteck ist immer geschlossen → zuerst die gefüllte Fläche. + fillPoly([c1, c2, c3, c4]); + seg(c1, c2); + seg(c2, c3); + seg(c3, c4); + seg(c4, c1); + break; + } + case "circle": { + // Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die + // vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv. + const N = 64; + const pts: Vec2[] = []; + for (let i = 0; i < N; i++) { + const t = (i / N) * Math.PI * 2; + pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) }); + } + fillPoly(pts); + for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]); + break; + } + // arc/text: Primitive-Erweiterungen folgen später. + default: + break; + } +} + +/** + * Wand als gefüllte Schnittfläche (Poché), um Öffnungen ausgespart. + * + * Detailgrad: + * • mittel/fein — jede Schicht des Wandtyps als eigenes Band (Füllfarbe + + * Schraffur des Bauteils), dazu eine kräftige Wand-Umrisslinie. fein zeichnet + * Schichtfugen und Umriss dünner als mittel. + * • grob — KEINE Schichten, KEINE Schraffur: eine einzige Sammelfläche über die + * volle Wanddicke (Füllung = Farbe des „Backbone"-Bauteils mit höchster + * joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER + * „Einfach"). + */ +function addWallPoche( + out: Primitive[], + project: Project, + wall: Wall, + doors: Project["doors"], + cuts: WallCuts, + greyed: boolean, + detail: DetailLevel, + wallLwMm: number, +): void { + const wt = getWallType(project, wall); + const total = wallTypeThickness(wt); + // Versatz der Schichtstapelung über die Dicke gemäß Referenzlinie (center=0, + // left=+T/2, right=−T/2). Alle Band-Offsets unten werden hierum verschoben, + // sodass die Achse bei „außen"/„innen" auf der jeweiligen Wandfläche liegt. + const refOff = wallReferenceOffset(wall, total); + // Jede Schicht-/Umrissfläche trägt die Wand-ID, damit ein Links-Klick die + // GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben + // melden kann. + const wallId = wall.id; + // Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color), + // sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss + + // Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren. + const stroke = wall.color ?? POCHE_STROKE; + + // Öffnungs-Intervalle entlang der Wandachse, sortiert. + const openings = doors + .map((d) => ({ from: d.position, to: d.position + d.width })) + .sort((a, b) => a.from - b.from); + + // Wand in Segmente zwischen den Öffnungen zerlegen. + let cursor = 0; + const segments: Array<[number, number]> = []; + for (const op of openings) { + if (op.from > cursor) segments.push([cursor, op.from]); + cursor = Math.max(cursor, op.to); + } + const axisLen = length(wall); + if (cursor < axisLen) segments.push([cursor, axisLen]); + + const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail]; + const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]; + // Vereinfachte Füllung (grob): Farbe des tragenden Bauteils (höchste Priorität). + const simpleFill = backboneColor(project, wt); + + for (const [s, e] of segments) { + const p1 = along(wall.start, wall.end, s); + const p2 = along(wall.start, wall.end, e); + // Nur die echten Wandenden bekommen die Gehrung. Türnahe Segmentenden + // bleiben rechtwinklig (kein Schnitt). + const startCut = s <= 1e-6 ? cuts.startCut : null; + const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null; + + if (detail === "grob") { + // Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss. + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), + fill: simpleFill, + stroke, + strokeWidthMm: outlineMm, + hatch: NO_HATCH, + greyed, + wallId, + }); + continue; + } + + // mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede + // Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe) + // auf; die Schichtfugen werden dünn gestrichen. + let off = refOff - total / 2; + for (const layer of wt.layers) { + const comp = getComponent(project, layer.componentId); + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut), + fill: comp.color, + stroke, + strokeWidthMm: layerLineMm, + hatch: resolveHatch(project, comp.hatchId), + greyed, + wallId, + }); + off += layer.thickness; + } + // Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), + // damit die Wand sich klar von den dünnen Schichtfugen abhebt. + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), + fill: "none", + stroke, + strokeWidthMm: outlineMm, + hatch: NO_HATCH, + greyed, + wallId, + }); + } +} + +/** Farbe des tragenden Bauteils (höchste joinPriority) für die grobe Poché. */ +function backboneColor(project: Project, wt: Project["wallTypes"][number]): string { + let best: { color: string; prio: number } | null = null; + for (const layer of wt.layers) { + const comp = getComponent(project, layer.componentId); + if (!best || comp.joinPriority > best.prio) + best = { color: comp.color, prio: comp.joinPriority }; + } + return best?.color ?? "#9aa0a6"; +} + +/** + * Tür-Symbol je Detailgrad: + * • grob — nur die gerade Türblatt-Linie (die Öffnungs-Lücke kommt aus der + * ausgesparten Poché). + * • mittel — + Schwenkbogen (geschlossen → offen). + * • fein — + Rahmen/Anschlag-Striche an beiden Pfosten (kurze Striche quer + * zur Wand, die den Türrahmen/-anschlag andeuten). + */ +function addDoorSymbol( + out: Primitive[], + wall: Wall, + door: Project["doors"][number], + greyed: boolean, + detail: DetailLevel, + doorLwMm: number, +): void { + const u = normalize(sub(wall.end, wall.start)); + const n = leftNormal(u); // linke Seite der Wandachse + const swingDir = door.swing === "left" ? n : scale(n, -1); + + const jambStart = along(wall.start, wall.end, door.position); + const jambEnd = along(wall.start, wall.end, door.position + door.width); + const hinge = door.hinge === "start" ? jambStart : jambEnd; + const closedDir = door.hinge === "start" ? u : scale(u, -1); + + const openEnd = add(hinge, scale(swingDir, door.width)); + const closedEnd = add(hinge, scale(closedDir, door.width)); + + // Türblatt in geöffneter Stellung (alle Stufen). + out.push({ + kind: "line", + a: hinge, + b: openEnd, + cls: "door-leaf", + weightMm: doorLwMm, + greyed, + }); + + // mittel + fein: Schwenkbogen von geschlossen → offen. + if (detail !== "grob") { + out.push({ + kind: "arc", + center: hinge, + from: closedEnd, + to: openEnd, + r: door.width, + cls: "door-swing", + weightMm: doorLwMm * 0.6, + dash: [0.06, 0.04], // gestrichelt in mm Papier + greyed, + }); + } + + // fein: Rahmen/Anschlag-Striche an beiden Pfosten (kurz quer zur Wand, + // symmetrisch um die Achse über eine halbe Wanddicke-Andeutung). + if (detail === "fein") { + const reveal = 0.06; // 6 cm Anschlag-Andeutung je Seite + for (const jamb of [jambStart, jambEnd]) { + out.push({ + kind: "line", + a: add(jamb, scale(n, reveal)), + b: add(jamb, scale(n, -reveal)), + cls: "door-frame", + weightMm: doorLwMm, + greyed, + }); + } + } +} + +/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */ +const NO_HATCH: HatchRender = { + pattern: "none", + scale: 1, + angle: 0, + color: POCHE_STROKE, + lineWeight: LAYER_LINE_MM, + dash: null, +}; + +/** + * Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse + * (start → end). Von der Oberleiste umschaltbar; rein darstellend. + */ +function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void { + out.push({ + kind: "line", + a: wall.start, + b: wall.end, + cls: "wall-axis", + weightMm: 0.13, + dash: [0.18, 0.06, 0.04, 0.06], // Strich-Punkt-Achse in mm Papier + greyed, + }); +} + +const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y); + +function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] = []) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const acc = (p: Vec2) => { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); + }; + for (const w of walls) { + const t = wallTypeThickness(getWallType(project, w)); + for (const c of wallCorners(w.start, w.end, t)) acc(c); + } + for (const d of drawings) { + const g = d.geom; + if (g.shape === "line") { acc(g.a); acc(g.b); } + else if (g.shape === "polyline") g.pts.forEach(acc); + else if (g.shape === "rect") { acc(g.min); acc(g.max); } + else if (g.shape === "circle" || g.shape === "arc") { + acc({ x: g.center.x - g.r, y: g.center.y - g.r }); + acc({ x: g.center.x + g.r, y: g.center.y + g.r }); + } else if (g.shape === "text") acc(g.at); + } + // Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt. + for (const obj of project.context ?? []) { + if (obj.type !== "contourSet") continue; + for (const c of obj.contours) for (const p of c.pts) acc(p); + } + if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 }; + return { minX, minY, maxX, maxY }; +} diff --git a/src/state/appStore.ts b/src/state/appStore.ts new file mode 100644 index 0000000..bf50d69 --- /dev/null +++ b/src/state/appStore.ts @@ -0,0 +1,51 @@ +// App-Store: komponiert die vier Domänen-Slices (project/selection/view/layout) +// über `createStore` zu EINEM RootState und exportiert die Store-Schnittstelle +// (useStore/getState/setState). Jede Slice ist eine Factory `(api) => slice`; +// alle bekommen dieselbe `set/get`-API über den Gesamt-RootState, sodass +// Cross-Slice-Aktionen (z. B. deleteLevel ↔ activeLevelId) typsicher arbeiten. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { createStore } from "./store"; +import type { StoreApi } from "./store"; +import { createProjectSlice } from "./projectSlice"; +import type { ProjectSlice } from "./projectSlice"; +import { createSelectionSlice } from "./selectionSlice"; +import type { SelectionSlice } from "./selectionSlice"; +import { createViewSlice } from "./viewSlice"; +import type { ViewSlice } from "./viewSlice"; +import { createLayoutSlice } from "./layoutSlice"; +import type { LayoutSlice } from "./layoutSlice"; +import { createSiteSlice } from "./siteSlice"; +import type { SiteSlice } from "./siteSlice"; + +/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */ +export type RootState = ProjectSlice & + SelectionSlice & + ViewSlice & + LayoutSlice & + SiteSlice; + +const store = createStore((api) => ({ + // Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur + // ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil + // `set/get` immer über den Gesamt-RootState laufen. + ...createProjectSlice(api as unknown as StoreApi), + ...createSelectionSlice(api as unknown as StoreApi), + ...createViewSlice(api as unknown as StoreApi), + ...createLayoutSlice(api as unknown as StoreApi), + // Site-/Kontext-Slice liest `project` und schreibt über `setProject` + // (beide aus der Projekt-Slice) — daher die kombinierte Sicht im Cast. + ...createSiteSlice( + api as unknown as StoreApi< + SiteSlice & { + project: ProjectSlice["project"]; + setProject: ProjectSlice["setProject"]; + } + >, + ), +})); + +export const useStore = store.useStore; +export const getState = store.getState; +export const setState = store.setState; diff --git a/src/state/layoutSlice.ts b/src/state/layoutSlice.ts new file mode 100644 index 0000000..129ff07 --- /dev/null +++ b/src/state/layoutSlice.ts @@ -0,0 +1,202 @@ +// Layout-Slice: das Panel-Layout (Docks/Tabs/Größen + schwebende Fenster) und +// alle Mutationen darauf. Nutzt die reinen Helfer aus src/panels/layout.ts +// (nicht dupliziert). Reiner Umzug aus App.tsx. Bezeichner englisch, Kommentare +// deutsch (CONVENTIONS.md). +// +// Persistenz (saveLayout) und das Init-Reconcile bleiben in App (useState-Init + +// useEffect); diese Slice liefert nur die Mutationen + reconcileLayout-Helfer. + +import { + bringToFront, + defaultLayout, + loadLayout, + moveToDock, + moveToFloat, + moveToGroup, + moveToNewGroup, + removeFloat, + reorderInGroup, + setActiveTab, + setGroupWeights, + updateFloat, +} from "../panels/layout"; +import { DEFAULT_FLOAT_W, DEFAULT_FLOAT_H } from "../panels/panelDrag"; +import { hasPanel } from "../panels/registry"; +// Seiteneffekt: registriert die dockbaren Kern-Panels in der Registry. MUSS vor +// `reconcileLayout(loadLayout())` (Slice-Init) laufen, sonst filtert hasPanel +// alle Tabs weg und die Docks blieben leer (Import-Reihenfolge!). +import "../panels/builtinPanels"; +import type { DockId, DockState, LayoutState } from "../panels/types"; +import type { StoreApi } from "./store"; + +/** Felder, die diese Slice in den RootState beisteuert. */ +export interface LayoutSlice { + layout: LayoutState; + + /** Layout direkt ersetzen (z. B. nach „Layout anwenden" aus dem Menü). */ + setLayout: (next: LayoutState | ((l: LayoutState) => LayoutState)) => void; + + // ── Dock-Größen / Gruppen ──────────────────────────────────────────────── + resizeDock: (side: "left" | "right", size: number) => void; + resizeGroups: (side: "left" | "right", weights: number[]) => void; + + // ── Tab-Drag-Auflösung (Reorder / Einreihen / Stapeln / Lösen) ──────────── + activateTab: (panelId: string) => void; + joinGroup: ( + panelId: string, + dock: DockId, + group: number, + index?: number, + ) => void; + newGroup: (panelId: string, dock: DockId, at: number) => void; + dropFloat: (panelId: string, x: number, y: number) => void; + + // ── Schwebende Fenster ──────────────────────────────────────────────────── + focusFloat: (panelId: string) => void; + moveFloat: (panelId: string, x: number, y: number) => void; + resizeFloat: (panelId: string, w: number, h: number) => void; + closeFloat: (panelId: string) => void; + redockFloat: (panelId: string) => void; + /** Titel-Drop eines schwebenden Fensters auf eine Rand-Zone (dock). */ + dockFloatTo: (panelId: string, dock: DockId) => void; +} + +export function createLayoutSlice(api: StoreApi): LayoutSlice { + const { set, get } = api; + + const setLayout: LayoutSlice["setLayout"] = (next) => + set((s) => ({ + layout: typeof next === "function" ? (next as (l: LayoutState) => LayoutState)(s.layout) : next, + })); + + // Welcher Dock enthält ein Panel gerade? + const dockOf = (panelId: string): DockId | null => { + const layout = get().layout; + if (layout.left.groups.some((g) => g.tabs.includes(panelId))) return "left"; + if (layout.right.groups.some((g) => g.tabs.includes(panelId))) return "right"; + return null; + }; + + // Genaue Lage eines Panels im Layout: Dock + Gruppen-Index + Tab-Index. + const locate = ( + panelId: string, + ): { dock: DockId; group: number; tab: number } | null => { + const layout = get().layout; + for (const dock of ["left", "right"] as const) { + const groups = layout[dock].groups; + for (let g = 0; g < groups.length; g++) { + const tab = groups[g].tabs.indexOf(panelId); + if (tab >= 0) return { dock, group: g, tab }; + } + } + return null; + }; + + return { + layout: reconcileLayout(loadLayout()), + + setLayout, + + resizeDock: (side, size) => + setLayout((l) => ({ ...l, [side]: { ...l[side], size } })), + resizeGroups: (side, weights) => + setLayout((l) => setGroupWeights(l, side, weights)), + + // Unter der Schwelle losgelassen → Klick: Tab in seiner Gruppe aktivieren. + activateTab: (panelId) => { + const side = dockOf(panelId); + if (side) setLayout((l) => setActiveTab(l, side, panelId)); + }, + + // Über einer Tab-Leiste → in DIESE Gruppe einreihen (Stapel zusammenlegen). + joinGroup: (panelId, dock, group, index) => { + const from = locate(panelId); + const layout = get().layout; + if (from && from.dock === dock && from.group === group && index !== undefined) { + setLayout((l) => + reorderInGroup(l, dock, group, from.tab, clampReorderIndex(from.tab, index)), + ); + return; + } + const shift = + from && + from.dock === dock && + from.group < group && + layout[dock].groups[from.group].tabs.length === 1 + ? 1 + : 0; + setLayout((l) => moveToGroup(l, panelId, dock, group - shift, index)); + }, + + // Über einem Gruppen-Körper / einer Rand-Zone → NEUE gestapelte Gruppe. + newGroup: (panelId, dock, at) => { + const from = locate(panelId); + const layout = get().layout; + const shift = + from && + from.dock === dock && + from.group < at && + layout[dock].groups[from.group].tabs.length === 1 + ? 1 + : 0; + setLayout((l) => moveToNewGroup(l, panelId, dock, at - shift)); + }, + + // Über der Arbeitsfläche → lösen (schweben) an der Cursorposition. + dropFloat: (panelId, x, y) => + setLayout((l) => + moveToFloat(l, panelId, { + x: Math.max(0, x), + y: Math.max(0, y), + w: DEFAULT_FLOAT_W, + h: DEFAULT_FLOAT_H, + }), + ), + + focusFloat: (panelId) => setLayout((l) => bringToFront(l, panelId)), + moveFloat: (panelId, x, y) => + setLayout((l) => updateFloat(l, panelId, { x: Math.max(0, x), y: Math.max(0, y) })), + resizeFloat: (panelId, w, h) => + setLayout((l) => updateFloat(l, panelId, { w, h })), + closeFloat: (panelId) => setLayout((l) => removeFloat(l, panelId)), + redockFloat: (panelId) => setLayout((l) => moveToDock(l, panelId, "left")), + dockFloatTo: (panelId, dock) => setLayout((l) => moveToDock(l, panelId, dock)), + }; +} + +// ── Layout-Helfer (verschoben aus App.tsx, 1:1) ────────────────────────────── + +/** + * Übersetzt einen Einfüge-Index (0..n) in einen Reorder-Zielindex (0..n-1). + */ +export function clampReorderIndex(fromIndex: number, insertIndex: number): number { + const to = insertIndex > fromIndex ? insertIndex - 1 : insertIndex; + return Math.max(0, to); +} + +/** + * Gleicht ein (geladenes) Layout gegen die Registry ab: Tabs ohne registriertes + * Panel werden entfernt, ein verwaister activeTab korrigiert. Wird ein Dock + * dadurch leer, bleibt es leer (Größe erhalten). + */ +export function reconcileLayout(layout: LayoutState): LayoutState { + const def = defaultLayout(); + const fix = (dock: DockState): DockState => { + const groups = dock.groups + .map((g) => { + const tabs = g.tabs.filter((id) => hasPanel(id)); + const activeTab = + g.activeTab && tabs.includes(g.activeTab) ? g.activeTab : (tabs[0] ?? null); + return { tabs, activeTab, weight: g.weight }; + }) + .filter((g) => g.tabs.length > 0); + return { groups, size: dock.size }; + }; + const floating = layout.floating.filter((f) => hasPanel(f.panelId)); + return { + left: fix(layout.left), + right: fix(layout.right), + floating, + version: def.version, + }; +} diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts new file mode 100644 index 0000000..56bc45b --- /dev/null +++ b/src/state/projectSlice.ts @@ -0,0 +1,1137 @@ +// Projekt-Slice: das semantische Modell (`project`) plus ALLE Mutationen, die +// heute in App.tsx als Closures über setProject existieren. Reiner Umzug — die +// Logik ist 1:1 übernommen (inkl. recomputeFloorElevations, refs-aware delete, +// Tree-Helfern). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). +// +// Cross-Slice-Zugriffe: einige Aktionen lesen/schreiben View-Felder +// (activeLevelId) — das geht, weil `get()`/`set` über den Gesamt-RootState +// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert +// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu. + +import { sampleProject } from "../model/sampleProject"; +import { + flattenCategories, + recomputeFloorElevations, +} from "../model/types"; +import type { + Component, + DrawingLevel, + HatchStyle, + LayerCategory, + LineStyle, + Project, + Vec2, +} from "../model/types"; +import { commitTransform } from "../tools/transform"; +import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform"; +import { t } from "../i18n"; +import type { StoreApi } from "./store"; + +/** Felder, die diese Slice in den RootState beisteuert. */ +export interface ProjectSlice { + project: Project; + + // ── Projekt direkt setzen (für Werkzeuge/Transformationen aus App) ──────── + /** Ersetzt das Projekt (Teil-/Updater wie React-setState). */ + setProject: (next: Project | ((p: Project) => Project)) => void; + + // ── Zeichnungsebenen (Levels/Geschosse) ────────────────────────────────── + toggleLevelVisible: (id: string) => void; + patchActiveFloor: (patch: Partial) => void; + addFloor: () => void; + addDrawing: () => void; + /** + * Legt eine neue freie Zeichnungsebene (kind:"drawing") mit dem gegebenen + * Namen an und liefert ihre ID zurück (z. B. als Ziel eines DXF-Imports). + */ + addDrawingLevel: (name: string) => string; + /** Hängt fertige Drawing2D-Elemente an (z. B. aus einem DXF-Import). */ + importDrawings: (drawings: import("../model/types").Drawing2D[]) => void; + patchLevel: (id: string, patch: Partial) => void; + duplicateLevel: (id: string) => void; + deleteLevel: (id: string) => void; + + // ── Ebenen (Kategorien) ─────────────────────────────────────────────────── + toggleCategoryVisible: (code: string) => void; + addCategory: () => void; + patchCategory: (code: string, patch: Partial) => void; + addSubCategory: (parentCode: string) => void; + duplicateCategory: (code: string) => void; + deleteCategory: (code: string) => void; + assignWallCategory: (wallId: string, code: string) => void; + + // ── Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungs-Komb.) ──────────────── + /** Liest je Kategorie-`code` → `visible` aus dem Layer-Baum (rekursiv). */ + snapshotLayerVisibility: () => Record; + /** + * Setzt im Layer-Baum jede Kategorie, deren `code` in `codes` vorkommt, auf + * den gespeicherten Wert (rekursiv, immutabel). Unbekannte Codes werden + * ignoriert; nicht enthaltene Kategorien bleiben unverändert. + */ + applyLayerVisibility: (codes: Record) => void; + /** Liest je DrawingLevel-`id` → `visible`. */ + snapshotDrawingVisibility: () => Record; + /** Setzt `drawingLevels[].visible` gemäß `ids` (unbekannte Ids ignorieren). */ + applyDrawingVisibility: (ids: Record) => void; + + // ── Ressourcen (Components / Hatches / LineStyles) ──────────────────────── + patchComponent: (id: string, patch: Partial) => void; + addComponent: () => void; + deleteComponent: (id: string) => void; + patchHatch: (id: string, patch: Partial) => void; + addHatch: () => void; + deleteHatch: (id: string) => void; + patchLineStyle: (id: string, patch: Partial) => void; + addLineStyle: () => void; + deleteLineStyle: (id: string) => void; + importLineStyles: (styles: Omit[]) => void; + importHatches: (hatches: Omit[]) => void; + + // ── Editier-Griffe + Body-Move des selektierten Elements ────────────────── + moveGripOf: ( + drawingId: string | null, + wallId: string | null, + index: number, + pt: Vec2, + ) => void; + moveElementByOf: ( + drawingId: string | null, + wallId: string | null, + delta: Vec2, + ) => void; + /** + * Verschiebt eine SEITE (Kante) des selektierten Elements: BEIDE Vertices der + * Kante (`aIndex`/`bIndex`) um denselben `delta` (immutabel). Beim Rechteck + * werden min/max anschließend neu normalisiert; bei der Wand bewegt sich + * `start`+`end` gemeinsam (ganze Wand senkrecht). + */ + moveEdgeOf: ( + drawingId: string | null, + wallId: string | null, + aIndex: number, + bIndex: number, + delta: Vec2, + ) => void; + + // ── Selektions-Attribute (für Attributes-/Object-Info-Paletten) ─────────── + /** Setzt die (Strich-)Farbe eines Drawing2D bzw. einer Wand (Override). */ + setElementColor: ( + kind: "wall" | "drawing2d", + id: string, + color: string, + ) => void; + /** Setzt die direkte Strichstärke (mm) — NUR Drawing2D (Wände haben keine). */ + setElementWeight: (id: string, weightMm: number) => void; + /** Setzt/entfernt die Füllschraffur — NUR Drawing2D (geschlossene Formen). */ + setElementFill: (id: string, hatchId: string | null) => void; + /** + * Setzt/entfernt die Vollton-Füllfarbe — NUR Drawing2D (geschlossene Formen). + * `null` entfernt das Feld (Fläche wieder transparent). + */ + setElementFillColor: (id: string, fillColor: string | null) => void; + /** + * Skaliert die Geometrie eines Elements auf Zielbreite×-höhe (Meter) um einen + * Ankerpunkt (fx/fy ∈ [0,1] relativ zur bbox; 0,0 = oben-links). + */ + resizeElement: ( + kind: "wall" | "drawing2d", + id: string, + w: number, + h: number, + anchor: { fx: number; fy: number }, + ) => void; + + // ── Transformation (Bewegen/Spiegeln/Drehen) committen ──────────────────── + commitTransformOn: ( + sel: TransformSelection, + op: TransformOp, + points: Vec2[], + copyMode: CopyMode, + count: number, + ) => void; + + // ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────── + /** Generischer immutabler Patch auf eine Wand (per ID). */ + updateWall: (id: string, patch: Partial) => void; + /** + * Setzt die Gesamtdicke einer EINSCHICHTIGEN Wand. Ansatz: editiert die + * Schichtdicke des Wandtyps. Ist der Wandtyp mehrschichtig oder von mehr als + * dieser einen Wand genutzt, wird ein dedizierter einschichtiger Wandtyp + * (Klon der ersten Schicht) erzeugt und der Wand zugewiesen — so wirkt die + * Dicke nur auf diese Wand und teilt keine Presets versehentlich. + */ + setWallThickness: (wallId: string, thickness: number) => void; +} + +/** + * Minimaler View-Ausschnitt, den Projekt-Aktionen lesen/schreiben dürfen + * (Cross-Slice). Der Store komponiert ProjectSlice mit dieser Sicht, sodass + * `get()`/`set` typsicher auf `activeLevelId` zugreifen können. + */ +type ProjectSliceDeps = { + activeLevelId: string; +}; + +export function createProjectSlice( + api: StoreApi, +): ProjectSlice { + const { set, get } = api; + + // Bequemer immutabler Projekt-Updater (wie setProject in App). + const setProject: ProjectSlice["setProject"] = (next) => + set((s) => ({ + project: typeof next === "function" ? (next as (p: Project) => Project)(s.project) : next, + })); + + return { + project: sampleProject, + + setProject, + + // ── Zeichnungsebenen ─────────────────────────────────────────────────── + toggleLevelVisible: (id) => + setProject((p) => ({ + ...p, + drawingLevels: p.drawingLevels.map((z) => + z.id === id ? { ...z, visible: !z.visible } : z, + ), + })), + + patchActiveFloor: (patch) => { + const activeLevelId = get().activeLevelId; + setProject((p) => ({ + ...p, + drawingLevels: recomputeFloorElevations( + p.drawingLevels.map((z) => + z.id === activeLevelId ? { ...z, ...patch } : z, + ), + ), + })); + }, + + addFloor: () => + setProject((p) => { + const n = p.drawingLevels.filter((z) => z.kind === "floor").length + 1; + const floor: DrawingLevel = { + id: `floor-${Date.now()}`, + name: t("default.floorName", { n }), + kind: "floor", + visible: true, + locked: false, + floorHeight: 2.6, + cutHeight: 1.0, + }; + return { + ...p, + drawingLevels: recomputeFloorElevations([...p.drawingLevels, floor]), + }; + }), + + addDrawing: () => + setProject((p) => { + const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1; + const drawing: DrawingLevel = { + id: `drawing-${Date.now()}`, + name: t("default.drawingName", { n }), + kind: "drawing", + visible: true, + locked: false, + }; + return { ...p, drawingLevels: [...p.drawingLevels, drawing] }; + }), + + // Neue freie Zeichnungsebene mit explizitem Namen anlegen und ihre ID + // zurückgeben (Ziel-Ebene eines DXF-Imports). Der Name kommt vom Aufrufer + // (Dialog); leere Eingabe fällt auf den Default-Namen zurück. + addDrawingLevel: (name) => { + const id = `drawing-${Date.now()}`; + setProject((p) => { + const n = p.drawingLevels.filter((z) => z.kind === "drawing").length + 1; + const drawing: DrawingLevel = { + id, + name: name.trim() || t("default.drawingName", { n }), + kind: "drawing", + visible: true, + locked: false, + }; + return { ...p, drawingLevels: [...p.drawingLevels, drawing] }; + }); + return id; + }, + + importDrawings: (drawings) => + setProject((p) => + drawings.length + ? { ...p, drawings2d: [...p.drawings2d, ...drawings] } + : p, + ), + + patchLevel: (id, patch) => + setProject((p) => ({ + ...p, + drawingLevels: recomputeFloorElevations( + p.drawingLevels.map((z) => (z.id === id ? { ...z, ...patch } : z)), + ), + })), + + duplicateLevel: (id) => + setProject((p) => { + const i = p.drawingLevels.findIndex((z) => z.id === id); + if (i < 0) return p; + const src = p.drawingLevels[i]; + const clone: DrawingLevel = { + ...src, + id: `${src.kind}-${Date.now()}`, + name: t("default.copySuffixLevel", { name: src.name }), + }; + const next = [...p.drawingLevels]; + next.splice(i + 1, 0, clone); + return { ...p, drawingLevels: recomputeFloorElevations(next) }; + }), + + // Zeichnungsebene löschen (≥1 behalten). War es das aktive Geschoss, auf + // die erste verbleibende Ebene wechseln (View-Feld, Cross-Slice). + deleteLevel: (id) => { + const { project, activeLevelId } = get(); + if (project.drawingLevels.length <= 1) return; // letzte behalten + if (!project.drawingLevels.some((z) => z.id === id)) return; + if (activeLevelId === id) { + const fallback = project.drawingLevels.find((z) => z.id !== id); + if (fallback) set({ activeLevelId: fallback.id }); + } + setProject((p) => { + const removedWallIds = new Set( + p.walls.filter((w) => w.floorId === id).map((w) => w.id), + ); + return { + ...p, + drawingLevels: recomputeFloorElevations( + p.drawingLevels.filter((z) => z.id !== id), + ), + walls: p.walls.filter((w) => w.floorId !== id), + doors: p.doors.filter((d) => !removedWallIds.has(d.hostWallId)), + }; + }); + }, + + // ── Ebenen (Kategorien) ──────────────────────────────────────────────── + toggleCategoryVisible: (code) => + setProject((p) => ({ + ...p, + layers: toggleCategoryByCode(p.layers, code), + })), + + addCategory: () => + setProject((p) => { + const code = nextFreeCode(p.layers); + const category: LayerCategory = { + code, + name: t("default.categoryName"), + color: "#888888", + lw: 0.18, + visible: true, + locked: false, + }; + return { ...p, layers: [...p.layers, category] }; + }), + + patchCategory: (code, patch) => + setProject((p) => ({ + ...p, + layers: patchCategoryByCode(p.layers, code, patch), + })), + + addSubCategory: (parentCode) => + setProject((p) => { + const code = nextFreeCode(p.layers); + const child: LayerCategory = { + code, + name: t("default.subCategoryName"), + color: "#888888", + lw: 0.18, + visible: true, + locked: false, + }; + return { ...p, layers: addChildByCode(p.layers, parentCode, child) }; + }), + + duplicateCategory: (code) => + setProject((p) => { + const src = findCategory(p.layers, code); + if (!src) return p; + const clone: LayerCategory = { + ...src, + code: nextFreeCode(p.layers), + name: t("default.copySuffixLayer", { name: src.name }), + children: undefined, + }; + return { ...p, layers: insertAfterCode(p.layers, code, clone) }; + }), + + deleteCategory: (code) => + setProject((p) => { + if (flattenCategories(p.layers).length <= 1) return p; // letzte behalten + const src = findCategory(p.layers, code); + if (!src) return p; + const codes = new Set(flattenCategories([src]).map((c) => c.code)); + const usedByWall = p.walls.some((w) => codes.has(w.categoryCode)); + const usedByDoor = p.doors.some((d) => codes.has(d.categoryCode)); + if (usedByWall || usedByDoor) { + window.alert(t("alert.layerInUse", { code: src.code, name: src.name })); + return p; + } + return { ...p, layers: removeByCode(p.layers, code) }; + }), + + assignWallCategory: (wallId, code) => + setProject((p) => ({ + ...p, + walls: p.walls.map((w) => + w.id === wallId ? { ...w, categoryCode: code } : w, + ), + })), + + // ── Sichtbarkeits-Kombinationen ──────────────────────────────────────── + snapshotLayerVisibility: () => { + const out: Record = {}; + for (const c of flattenCategories(get().project.layers)) { + out[c.code] = c.visible; + } + return out; + }, + + applyLayerVisibility: (codes) => + setProject((p) => ({ + ...p, + layers: applyVisibilityByCode(p.layers, codes), + })), + + snapshotDrawingVisibility: () => { + const out: Record = {}; + for (const z of get().project.drawingLevels) out[z.id] = z.visible; + return out; + }, + + applyDrawingVisibility: (ids) => + setProject((p) => ({ + ...p, + drawingLevels: p.drawingLevels.map((z) => + z.id in ids ? { ...z, visible: ids[z.id] } : z, + ), + })), + + // ── Ressourcen ───────────────────────────────────────────────────────── + patchComponent: (id, patch) => + setProject((p) => ({ + ...p, + components: p.components.map((c) => + c.id === id ? { ...c, ...patch } : c, + ), + })), + + addComponent: () => + setProject((p) => { + let hatches = p.hatches; + let hatchId = hatches[0]?.id; + if (!hatchId) { + const fallback: HatchStyle = { + id: `hatch-${Date.now()}`, + name: t("default.hatchNone"), + pattern: "none", + scale: 1, + angle: 0, + color: "#2b3039", + }; + hatches = [fallback]; + hatchId = fallback.id; + } + const component: Component = { + id: `component-${Date.now()}`, + name: t("default.componentName"), + color: "#9aa0a6", + hatchId, + joinPriority: 50, + }; + return { ...p, hatches, components: [...p.components, component] }; + }), + + deleteComponent: (id) => + setProject((p) => { + const used = p.wallTypes.some((wt) => + wt.layers.some((l) => l.componentId === id), + ); + if (used) { + const name = p.components.find((c) => c.id === id)?.name ?? id; + window.alert(t("alert.componentInUse", { name })); + return p; + } + return { ...p, components: p.components.filter((c) => c.id !== id) }; + }), + + patchHatch: (id, patch) => + setProject((p) => ({ + ...p, + hatches: p.hatches.map((h) => (h.id === id ? { ...h, ...patch } : h)), + })), + + addHatch: () => + setProject((p) => { + const hatch: HatchStyle = { + id: `hatch-${Date.now()}`, + name: t("default.hatchName"), + pattern: "diagonal", + scale: 1, + angle: 45, + color: "#2b3039", + lineStyleId: p.lineStyles[0]?.id, + }; + return { ...p, hatches: [...p.hatches, hatch] }; + }), + + deleteHatch: (id) => + setProject((p) => { + const used = p.components.some((c) => c.hatchId === id); + if (used) { + const name = p.hatches.find((h) => h.id === id)?.name ?? id; + window.alert(t("alert.hatchInUse", { name })); + return p; + } + return { ...p, hatches: p.hatches.filter((h) => h.id !== id) }; + }), + + patchLineStyle: (id, patch) => + setProject((p) => ({ + ...p, + lineStyles: p.lineStyles.map((l) => + l.id === id ? { ...l, ...patch } : l, + ), + })), + + addLineStyle: () => + setProject((p) => { + const lineStyle: LineStyle = { + id: `line-${Date.now()}`, + name: t("default.lineStyleName"), + weight: 0.18, + color: "#2b3039", + dash: null, + }; + return { ...p, lineStyles: [...p.lineStyles, lineStyle] }; + }), + + deleteLineStyle: (id) => + setProject((p) => { + const used = p.hatches.some((h) => h.lineStyleId === id); + if (used) { + const name = p.lineStyles.find((l) => l.id === id)?.name ?? id; + window.alert(t("alert.lineStyleInUse", { name })); + return p; + } + return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) }; + }), + + importLineStyles: (styles) => + setProject((p) => ({ + ...p, + lineStyles: [ + ...p.lineStyles, + ...styles.map((s, i) => ({ ...s, id: `ls-${Date.now()}-${i}` })), + ], + })), + + importHatches: (hatches) => + setProject((p) => ({ + ...p, + hatches: [ + ...p.hatches, + ...hatches.map((h, i) => ({ ...h, id: `ht-${Date.now()}-${i}` })), + ], + })), + + // ── Editier-Griffe ───────────────────────────────────────────────────── + moveGripOf: (drawingId, wallId, index, pt) => + setProject((p) => moveGrip(p, drawingId, wallId, index, pt)), + + moveElementByOf: (drawingId, wallId, delta) => + setProject((p) => moveElementBy(p, drawingId, wallId, delta)), + + moveEdgeOf: (drawingId, wallId, aIndex, bIndex, delta) => + setProject((p) => moveEdge(p, drawingId, wallId, aIndex, bIndex, delta)), + + // ── Selektions-Attribute ─────────────────────────────────────────────── + setElementColor: (kind, id, color) => + setProject((p) => { + if (kind === "drawing2d") { + return { + ...p, + drawings2d: p.drawings2d.map((d) => + d.id === id ? { ...d, color } : d, + ), + }; + } + return { + ...p, + walls: p.walls.map((w) => (w.id === id ? { ...w, color } : w)), + }; + }), + + // Strichstärke gibt es als direktes Feld NUR auf Drawing2D; bei unbekannter + // ID bleibt das Projekt unverändert (No-op). + setElementWeight: (id, weightMm) => + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.map((d) => + d.id === id ? { ...d, weightMm } : d, + ), + })), + + // Füllschraffur NUR für Drawing2D; `null` entfernt die Schraffur (Feld weg). + setElementFill: (id, hatchId) => + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.map((d) => { + if (d.id !== id) return d; + if (hatchId === null) { + const { hatchId: _omit, ...rest } = d; + return rest; + } + return { ...d, hatchId }; + }), + })), + + // Vollton-Füllfarbe NUR für Drawing2D; `null` entfernt das Feld. + setElementFillColor: (id, fillColor) => + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.map((d) => { + if (d.id !== id) return d; + if (fillColor === null) { + const { fillColor: _omit, ...rest } = d; + return rest; + } + return { ...d, fillColor }; + }), + })), + + // ── Größe (Resize um Anker) ──────────────────────────────────────────── + resizeElement: (kind, id, w, h, anchor) => + setProject((p) => resizeElement(p, kind, id, w, h, anchor)), + + // ── Transformation ───────────────────────────────────────────────────── + commitTransformOn: (sel, op, points, copyMode, count) => + setProject((p) => commitTransform(p, sel, op, points, copyMode, count)), + + // ── Wand-Attribute ───────────────────────────────────────────────────── + updateWall: (id, patch) => + setProject((p) => ({ + ...p, + walls: p.walls.map((w) => (w.id === id ? { ...w, ...patch } : w)), + })), + + setWallThickness: (wallId, thickness) => + setProject((p) => setWallThickness(p, wallId, thickness)), + }; +} + +/** + * Setzt die Gesamtdicke einer einschichtigen Wand über ihre Wandtyp-Schicht. + * Wenn der Wandtyp einschichtig ist UND nur von dieser Wand genutzt wird, wird + * er direkt editiert. Sonst wird ein dedizierter einschichtiger Wandtyp (Klon + * der ersten Schicht des aktuellen Typs) erzeugt und der Wand zugewiesen. + */ +function setWallThickness( + project: Project, + wallId: string, + thickness: number, +): Project { + if (!isFinite(thickness) || thickness <= 0) return project; + const wall = project.walls.find((w) => w.id === wallId); + if (!wall) return project; + const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId); + if (!wt || wt.layers.length === 0) return project; + + const single = wt.layers.length === 1; + const usedByOthers = project.walls.some( + (w) => w.id !== wallId && w.wallTypeId === wt.id, + ); + + if (single && !usedByOthers) { + // Direkt die Schichtdicke des (exklusiven, einschichtigen) Wandtyps setzen. + return { + ...project, + wallTypes: project.wallTypes.map((t) => + t.id === wt.id + ? { ...t, layers: [{ ...t.layers[0], thickness }] } + : t, + ), + }; + } + + // Dedizierten einschichtigen Wandtyp erzeugen (erste Schicht klonen) und der + // Wand zuweisen — damit die Dicke nur diese Wand betrifft. + const id = `wt-${Date.now()}`; + const clone = { + id, + name: t("default.wallTypeSingle", { thickness: thickness.toFixed(2) }), + layers: [{ ...wt.layers[0], thickness }], + }; + return { + ...project, + wallTypes: [...project.wallTypes, clone], + walls: project.walls.map((w) => + w.id === wallId ? { ...w, wallTypeId: id } : w, + ), + }; +} + +// ── Resize (Skalierung auf Zielmaß um einen Anker) ─────────────────────────── + +/** + * Skaliert ein Element auf Zielbreite `w` × -höhe `h` (Meter) um einen + * Ankerpunkt. `anchor.fx/fy ∈ [0,1]` liegen relativ zur aktuellen bbox + * (0,0 = oben-links, 1,1 = unten-rechts). Der Ankerpunkt bleibt fix; alle + * übrigen Punkte werden um die Skalierungsfaktoren um ihn herum gestreckt. + */ +function resizeElement( + project: Project, + kind: "wall" | "drawing2d", + id: string, + w: number, + h: number, + anchor: { fx: number; fy: number }, +): Project { + if (kind === "drawing2d") { + return { + ...project, + drawings2d: project.drawings2d.map((d) => + d.id === id ? { ...d, geom: resizeGeom(d.geom, w, h, anchor) } : d, + ), + }; + } + return { + ...project, + walls: project.walls.map((wall) => { + if (wall.id !== id) return wall; + const box = bbox2([wall.start, wall.end]); + const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor); + const apply = (p: Vec2): Vec2 => ({ + x: ax + (p.x - ax) * sx, + y: ay + (p.y - ay) * sy, + }); + return { ...wall, start: apply(wall.start), end: apply(wall.end) }; + }), + }; +} + +/** bbox einer Punktliste. */ +function bbox2(pts: Vec2[]): { minX: number; minY: number; maxX: number; maxY: number } { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of pts) { + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); + } + if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + return { minX, minY, maxX, maxY }; +} + +/** + * Liefert Ankerpunkt (ax,ay) und Skalierungsfaktoren (sx,sy), um eine bbox auf + * Zielbreite `w` × -höhe `h` zu bringen. Bei Null-Ausdehnung in einer Achse + * bleibt der Faktor 1 (keine Division durch 0, keine Verschiebung). + */ +function scaleAround( + box: { minX: number; minY: number; maxX: number; maxY: number }, + w: number, + h: number, + anchor: { fx: number; fy: number }, +): { ax: number; ay: number; sx: number; sy: number } { + const bw = box.maxX - box.minX; + const bh = box.maxY - box.minY; + const ax = box.minX + anchor.fx * bw; + const ay = box.minY + anchor.fy * bh; + const sx = bw > 1e-9 ? w / bw : 1; + const sy = bh > 1e-9 ? h / bh : 1; + return { ax, ay, sx, sy }; +} + +/** + * Skaliert eine 2D-Form auf Zielmaß um den Anker. + * • line/polyline/rect: jeder Punkt um den Anker skaliert (B/H aus bbox). + * • circle: Radius = halbe Zielbreite (min/Mittelwert wäre auch denkbar; Breite + * ist eindeutig), Mittelpunkt um den Anker verschoben. + * • arc: wie circle (Radius aus Zielbreite), Winkel unverändert. + * • text: nur die Einfügeposition wird um den Anker verschoben; die Schrifthöhe + * bleibt (Text skaliert über `height`, nicht über bbox — bewusst unverändert). + */ +function resizeGeom( + g: import("../model/types").Drawing2DGeom, + w: number, + h: number, + anchor: { fx: number; fy: number }, +): import("../model/types").Drawing2DGeom { + switch (g.shape) { + case "line": { + const { ax, ay, sx, sy } = scaleAround(bbox2([g.a, g.b]), w, h, anchor); + const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy }); + return { ...g, a: f(g.a), b: f(g.b) }; + } + case "polyline": { + const { ax, ay, sx, sy } = scaleAround(bbox2(g.pts), w, h, anchor); + const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy }); + return { ...g, pts: g.pts.map(f) }; + } + case "rect": { + const box = bbox2([g.min, g.max]); + const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor); + const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy }); + const a = f(g.min); + const b = f(g.max); + return { + ...g, + min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }, + max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }, + }; + } + case "circle": + case "arc": { + // bbox eines Kreises ist 2r×2r; der Anker liegt relativ dazu. + const box = { + minX: g.center.x - g.r, + minY: g.center.y - g.r, + maxX: g.center.x + g.r, + maxY: g.center.y + g.r, + }; + const ax = box.minX + anchor.fx * (2 * g.r); + const ay = box.minY + anchor.fy * (2 * g.r); + const r = Math.max(w, h) / 2 > 0 ? Math.max(w, h) / 2 : g.r; + const sx = g.r > 1e-9 ? (w / 2) / g.r : 1; + const sy = g.r > 1e-9 ? (h / 2) / g.r : 1; + // Mittelpunkt anhand der nicht-uniformen Skalierung um den Anker schieben, + // den Radius aber als skalaren Kreis (max der Zielmaße/2) setzen. + const center: Vec2 = { + x: ax + (g.center.x - ax) * sx, + y: ay + (g.center.y - ay) * sy, + }; + return { ...g, center, r }; + } + case "text": { + // Text: nur die Einfügeposition um den Anker schieben (Höhe bleibt). + const box = { minX: g.at.x, minY: g.at.y, maxX: g.at.x, maxY: g.at.y }; + const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor); + return { ...g, at: { x: ax + (g.at.x - ax) * sx, y: ay + (g.at.y - ay) * sy } }; + } + default: + return g; + } +} + +// ── Editier-Griffe (verschoben aus App.tsx, 1:1) ───────────────────────────── + +/** + * Editier-Griffe (Eckpunkte) eines 2D-Zeichenelements in Modell-Metern. Phase 4 + * unterstützt line/polyline/rect; circle/arc/text bekommen (noch) keine Griffe. + */ +export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { + const g = d.geom; + if (g.shape === "line") return [g.a, g.b]; + if (g.shape === "polyline") return [...g.pts]; + if (g.shape === "rect") { + return [ + g.min, + { x: g.max.x, y: g.min.y }, + g.max, + { x: g.min.x, y: g.max.y }, + ]; + } + return []; +} + +/** + * Verschiebt den Griff `index` des selektierten Elements (2D-Element ODER Wand) + * auf `pt` (immutabel). Beim Rechteck bleibt die Achsparallelität erhalten. + */ +function moveGrip( + project: Project, + drawingId: string | null, + wallId: string | null, + index: number, + pt: Vec2, +): Project { + if (drawingId) { + return { + ...project, + drawings2d: project.drawings2d.map((d) => { + if (d.id !== drawingId) return d; + const g = d.geom; + if (g.shape === "line") { + return { ...d, geom: index === 0 ? { ...g, a: pt } : { ...g, b: pt } }; + } + if (g.shape === "polyline") { + const pts = g.pts.map((p, i) => (i === index ? pt : p)); + return { ...d, geom: { ...g, pts } }; + } + if (g.shape === "rect") { + const corners = [ + g.min, + { x: g.max.x, y: g.min.y }, + g.max, + { x: g.min.x, y: g.max.y }, + ]; + const opp = corners[(index + 2) % 4]; + const min = { x: Math.min(pt.x, opp.x), y: Math.min(pt.y, opp.y) }; + const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) }; + return { ...d, geom: { ...g, min, max } }; + } + return d; + }), + }; + } + if (wallId) { + return { + ...project, + walls: project.walls.map((w) => + w.id === wallId ? { ...w, ...(index === 0 ? { start: pt } : { end: pt }) } : w, + ), + }; + } + return project; +} + +/** Verschiebt das selektierte Element (2D-Element ODER Wand) um `delta` (parallel). */ +function moveElementBy( + project: Project, + drawingId: string | null, + wallId: string | null, + delta: Vec2, +): Project { + const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y }); + if (drawingId) { + return { + ...project, + drawings2d: project.drawings2d.map((d) => { + if (d.id !== drawingId) return d; + const g = d.geom; + switch (g.shape) { + case "line": + return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } }; + case "polyline": + return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; + case "rect": + return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } }; + case "circle": + case "arc": + return { ...d, geom: { ...g, center: mv(g.center) } }; + case "text": + return { ...d, geom: { ...g, at: mv(g.at) } }; + default: + return d; + } + }), + }; + } + if (wallId) { + return { + ...project, + walls: project.walls.map((w) => + w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w, + ), + }; + } + return project; +} + +/** + * Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der + * Kante um `delta` (immutabel). Für 2D-Elemente: + * • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu + * normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite. + * • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben. + * • line: beide Endpunkte verschieben (das gesamte Segment senkrecht). + * Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse). + */ +function moveEdge( + project: Project, + drawingId: string | null, + wallId: string | null, + aIndex: number, + bIndex: number, + delta: Vec2, +): Project { + const mv = (p: Vec2): Vec2 => ({ x: p.x + delta.x, y: p.y + delta.y }); + if (drawingId) { + return { + ...project, + drawings2d: project.drawings2d.map((d) => { + if (d.id !== drawingId) return d; + const g = d.geom; + if (g.shape === "line") { + // Linie hat nur eine Kante [0,1] → das ganze Segment verschieben. + return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } }; + } + if (g.shape === "polyline") { + const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p)); + return { ...d, geom: { ...g, pts } }; + } + if (g.shape === "rect") { + // Ecken in derselben Reihenfolge wie drawingVertices, die zwei Kanten- + // Ecken verschieben, dann bbox neu bilden (achsparallel halten). + const corners = [ + g.min, + { x: g.max.x, y: g.min.y }, + g.max, + { x: g.min.x, y: g.max.y }, + ]; + const moved = corners.map((c, i) => + i === aIndex || i === bIndex ? mv(c) : c, + ); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const c of moved) { + minX = Math.min(minX, c.x); minY = Math.min(minY, c.y); + maxX = Math.max(maxX, c.x); maxY = Math.max(maxY, c.y); + } + return { ...d, geom: { ...g, min: { x: minX, y: minY }, max: { x: maxX, y: maxY } } }; + } + return d; + }), + }; + } + if (wallId) { + // Wand: beide Enden gemeinsam → ganze Wand senkrecht verschieben. + return { + ...project, + walls: project.walls.map((w) => + w.id === wallId ? { ...w, start: mv(w.start), end: mv(w.end) } : w, + ), + }; + } + return project; +} + +// ── Immutable Tree-Helfer (verschoben aus App.tsx, 1:1) ────────────────────── + +/** Schaltet die Sichtbarkeit der Kategorie mit `code` im Baum um (immutabel). */ +export function toggleCategoryByCode( + cats: LayerCategory[], + code: string, +): LayerCategory[] { + return cats.map((c) => { + if (c.code === code) return { ...c, visible: !c.visible }; + if (c.children) + return { ...c, children: toggleCategoryByCode(c.children, code) }; + return c; + }); +} + +/** + * Setzt im Baum die `visible`-Flags gemäß `codes` (Code → sichtbar?, immutabel). + * Kategorien, deren Code nicht in der Map steht, bleiben unverändert; Codes der + * Map, die es nicht mehr gibt, werden stillschweigend ignoriert. + */ +export function applyVisibilityByCode( + cats: LayerCategory[], + codes: Record, +): LayerCategory[] { + return cats.map((c) => { + const next = c.code in codes ? { ...c, visible: codes[c.code] } : c; + if (next.children) + return { ...next, children: applyVisibilityByCode(next.children, codes) }; + return next; + }); +} + +/** Nächster freier zweistelliger Code über den ganzen Baum hinweg. */ +export function nextFreeCode(cats: LayerCategory[]): string { + const used = new Set(); + const walk = (list: LayerCategory[]) => { + for (const c of list) { + used.add(c.code); + if (c.children) walk(c.children); + } + }; + walk(cats); + const nums = [...used].map((c) => parseInt(c, 10)).filter((n) => !isNaN(n)); + const start = nums.length ? Math.max(...nums) : 0; + for (let i = start + 1; i < 1000; i++) { + const c = String(i).padStart(2, "0"); + if (!used.has(c)) return c; + } + return String(Date.now()); +} + +/** Findet eine Kategorie (per Code) im Baum oder `undefined`. */ +export function findCategory( + cats: LayerCategory[], + code: string, +): LayerCategory | undefined { + for (const c of cats) { + if (c.code === code) return c; + if (c.children) { + const hit = findCategory(c.children, code); + if (hit) return hit; + } + } + return undefined; +} + +/** Patcht die Kategorie mit `code` im Baum (immutabel). */ +function patchCategoryByCode( + cats: LayerCategory[], + code: string, + patch: Partial, +): LayerCategory[] { + return cats.map((c) => { + if (c.code === code) return { ...c, ...patch }; + if (c.children) + return { ...c, children: patchCategoryByCode(c.children, code, patch) }; + return c; + }); +} + +/** Hängt `child` als letztes Kind an die Kategorie mit `parentCode` (immutabel). */ +function addChildByCode( + cats: LayerCategory[], + parentCode: string, + child: LayerCategory, +): LayerCategory[] { + return cats.map((c) => { + if (c.code === parentCode) + return { ...c, children: [...(c.children ?? []), child] }; + if (c.children) + return { ...c, children: addChildByCode(c.children, parentCode, child) }; + return c; + }); +} + +/** Fügt `clone` direkt nach der Kategorie mit `code` auf derselben Ebene ein. */ +function insertAfterCode( + cats: LayerCategory[], + code: string, + clone: LayerCategory, +): LayerCategory[] { + const out: LayerCategory[] = []; + for (const c of cats) { + const next = + c.children && !c.children.some((k) => k.code === code) + ? { ...c, children: insertAfterCode(c.children, code, clone) } + : c; + out.push(next); + if (c.code === code) out.push(clone); + } + return out; +} + +/** Entfernt die Kategorie mit `code` (samt Kindern) aus dem Baum (immutabel). */ +function removeByCode(cats: LayerCategory[], code: string): LayerCategory[] { + const out: LayerCategory[] = []; + for (const c of cats) { + if (c.code === code) continue; + out.push(c.children ? { ...c, children: removeByCode(c.children, code) } : c); + } + return out; +} + +/** Beschriftung einer Kategorie für die Status-Leiste: „Code Name". */ +export function categoryLabel(cats: LayerCategory[], code: string): string { + const c = findCategory(cats, code); + return c ? `${c.code} ${c.name}` : code; +} diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts new file mode 100644 index 0000000..def7076 --- /dev/null +++ b/src/state/selectionInfo.ts @@ -0,0 +1,268 @@ +// Selektions-Attribut-Kontrakt: leitet aus der aktuellen Auswahl +// (selectedWallIds/selectedDrawingId) + dem Projekt ein normalisiertes, +// darstellungsfertiges Attribut-Objekt für das ERSTE selektierte Element ab. +// Die kommenden Paletten (Attributes, Object-Info) lesen ausschließlich diesen +// Kontrakt — sie kennen weder das Modell noch die Auflösungs-Logik. +// +// „Effektiv aufgelöst" = dieselbe Reihenfolge wie generatePlan: explizite +// Übersteuerung am Element → LineStyle → Kategorie-Default. So zeigt die Palette +// den TATSÄCHLICH gezeichneten Wert, nicht nur das (oft leere) Override-Feld. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { flattenCategories, getWallType, wallTypeThickness } from "../model/types"; +import type { + Drawing2D, + Drawing2DGeom, + LayerCategory, + Project, + VerticalAnchor, + Wall, + WallReferenceLine, +} from "../model/types"; +import { nextFloorAbove, wallVerticalExtent } from "../model/wall"; + +/** Eintrag für die WallType-Auswahl (Preset-Dropdown) im Object-Info-Panel. */ +export interface WallTypeChoice { + id: string; + name: string; + /** Gesamtdicke (Meter) — informativ im Dropdown-Label. */ + thickness: number; + /** Anzahl Schichten (1 = einschichtig). */ + layerCount: number; +} + +/** Eintrag für die Geschoss-Auswahl (Referenzgeschoss / OK-Bindung). */ +export interface FloorChoice { + id: string; + name: string; +} + +/** + * Wand-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl einer + * einzelnen Wand gesetzt). Effektiv aufgelöste, anzeigefertige Werte plus die + * Listen, die die Dropdowns brauchen. + */ +export interface WallInfo { + referenceLine: WallReferenceLine; + wallTypeId: string; + /** Aktuelle Gesamtdicke (Meter). */ + thickness: number; + /** Ob der aktuelle Wandtyp einschichtig ist (1 Schicht). */ + singleLayer: boolean; + /** Verfügbare Wandtyp-Presets (für den Mehrschicht-Aufbau). */ + wallTypes: WallTypeChoice[]; + /** Geschoss, dem die Wand zugeordnet ist (Referenzgeschoss). */ + floorId: string; + floorName: string; + /** Alle Geschosse (für UK/OK-„an Geschoss gebunden"-Auswahl). */ + floors: FloorChoice[]; + /** Geschoss direkt darüber (für „Verknüpfung zum oberen Geschoss"). */ + floorAbove?: FloorChoice; + /** UK-Bindung (undefined = Geschoss-Default). */ + bottom?: VerticalAnchor; + /** OK-Bindung (undefined = UK + height). */ + top?: VerticalAnchor; + /** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */ + zBottom: number; + zTop: number; +} + +/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */ +export interface SelectionBBox { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * Normalisierte Sicht auf das erste selektierte Element. Farbe/Strichstärke sind + * EFFEKTIV aufgelöst (Override sonst Default), damit die Palette den real + * gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll. + */ +export interface Selection { + kind: "wall" | "drawing2d" | "door"; + id: string; + /** Grafik-Kategorie (Ebene) des Elements. */ + categoryCode: string; + /** Effektiv aufgelöste Strich-/Umrandungsfarbe (hex). */ + color: string; + /** Effektiv aufgelöste Strichstärke in Millimetern. */ + weightMm: number; + /** + * Schraffur-ID der Füllung (nur Drawing2D, geschlossene Form); `null` = keine. + * Bei Wänden/Türen `undefined` (Konzept nicht anwendbar). + */ + fillHatchId?: string | null; + /** + * Vollton-Füllfarbe der Füllung (nur Drawing2D, geschlossene Form); `null` = + * keine (transparent). Bei Wänden/Türen `undefined` (nicht anwendbar). + */ + fillColor?: string | null; + /** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */ + closed?: boolean; + /** Achsparallele Bounding-Box in Modell-Metern. */ + bbox: SelectionBBox; + /** + * Wand-Attribute (nur bei `kind === "wall"`). Trägt Referenzlinie, Aufbau-Typ + * (Wandtyp/Dicke), Referenzgeschoss + UK/OK für den Wand-Abschnitt im Panel. + */ + wall?: WallInfo; +} + +const WALL_FALLBACK_MM = 0.18; +const DRAW_FALLBACK_COLOR = "#d0d0d0"; +const WALL_FALLBACK_COLOR = "#2b3039"; + +/** Kategorie (per Code) im Baum suchen — flach, da Codes baumweit eindeutig sind. */ +function findCategory( + layers: LayerCategory[], + code: string, +): LayerCategory | undefined { + return flattenCategories(layers).find((c) => c.code === code); +} + +/** Ob eine 2D-Form geschlossen ist (Füllung/Schraffur sinnvoll). */ +function isClosedGeom(g: Drawing2DGeom): boolean { + return ( + g.shape === "rect" || + g.shape === "circle" || + (g.shape === "polyline" && g.closed) + ); +} + +/** Bounding-Box einer 2D-Form. */ +function geomBBox(g: Drawing2DGeom): SelectionBBox { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + const acc = (x: number, y: number) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }; + switch (g.shape) { + case "line": + acc(g.a.x, g.a.y); + acc(g.b.x, g.b.y); + break; + case "polyline": + for (const p of g.pts) acc(p.x, p.y); + break; + case "rect": + acc(g.min.x, g.min.y); + acc(g.max.x, g.max.y); + break; + case "circle": + case "arc": + acc(g.center.x - g.r, g.center.y - g.r); + acc(g.center.x + g.r, g.center.y + g.r); + break; + case "text": + acc(g.at.x, g.at.y); + break; + } + if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + return { minX, minY, maxX, maxY }; +} + +/** Selektion für eine Wand ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */ +function wallSelection(project: Project, wall: Wall): Selection { + const cat = findCategory(project.layers, wall.categoryCode); + const color = wall.color ?? cat?.color ?? WALL_FALLBACK_COLOR; + const weightMm = cat?.lw ?? WALL_FALLBACK_MM; + const minX = Math.min(wall.start.x, wall.end.x); + const minY = Math.min(wall.start.y, wall.end.y); + const maxX = Math.max(wall.start.x, wall.end.x); + const maxY = Math.max(wall.start.y, wall.end.y); + // Wand-Attribute auflösen (Referenzlinie, Aufbau, Referenzgeschoss, UK/OK). + const wt = getWallType(project, wall); + const thickness = wallTypeThickness(wt); + const floor = project.drawingLevels.find((z) => z.id === wall.floorId); + const { zBottom, zTop } = wallVerticalExtent(project, wall); + const above = nextFloorAbove(project, wall); + const wallInfo: WallInfo = { + referenceLine: wall.referenceLine ?? "center", + wallTypeId: wall.wallTypeId, + thickness, + singleLayer: wt.layers.length <= 1, + wallTypes: project.wallTypes.map((t) => ({ + id: t.id, + name: t.name, + thickness: wallTypeThickness(t), + layerCount: t.layers.length, + })), + floorId: wall.floorId, + floorName: floor?.name ?? wall.floorId, + floors: project.drawingLevels + .filter((z) => z.kind === "floor") + .map((z) => ({ id: z.id, name: z.name })), + floorAbove: above, + bottom: wall.bottom, + top: wall.top, + zBottom, + zTop, + }; + // bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt, + // damit die Box der Achs-Geometrie folgt (genügt für die Paletten). + return { + kind: "wall", + id: wall.id, + categoryCode: wall.categoryCode, + color, + weightMm, + fillHatchId: undefined, + fillColor: undefined, + closed: undefined, + bbox: { minX, minY, maxX, maxY }, + wall: wallInfo, + }; +} + +/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */ +function drawingSelection(project: Project, d: Drawing2D): Selection { + const ls = d.lineStyleId + ? project.lineStyles.find((l) => l.id === d.lineStyleId) + : undefined; + const cat = findCategory(project.layers, d.categoryCode); + const color = d.color ?? ls?.color ?? cat?.color ?? DRAW_FALLBACK_COLOR; + const weightMm = d.weightMm ?? ls?.weight ?? cat?.lw ?? WALL_FALLBACK_MM; + const closed = isClosedGeom(d.geom); + return { + kind: "drawing2d", + id: d.id, + categoryCode: d.categoryCode, + color, + weightMm, + fillHatchId: closed ? d.hatchId ?? null : null, + fillColor: closed ? d.fillColor ?? null : null, + closed, + bbox: geomBBox(d.geom), + }; +} + +/** + * Leitet den Selektions-Kontrakt für das ERSTE selektierte Element ab. + * Drawing-Auswahl und Wand-Auswahl schließen sich aus; bei Drawing-Auswahl + * hat diese Vorrang. Liefert `null`, wenn nichts (oder kein auffindbares + * Element) selektiert ist. + */ +export function deriveSelection( + project: Project, + selectedWallIds: string[], + selectedDrawingId: string | null, +): Selection | null { + if (selectedDrawingId) { + const d = project.drawings2d.find((x) => x.id === selectedDrawingId); + return d ? drawingSelection(project, d) : null; + } + const wallId = selectedWallIds[0]; + if (wallId) { + const w = project.walls.find((x) => x.id === wallId); + return w ? wallSelection(project, w) : null; + } + return null; +} diff --git a/src/state/selectionSlice.ts b/src/state/selectionSlice.ts new file mode 100644 index 0000000..bc60ec0 --- /dev/null +++ b/src/state/selectionSlice.ts @@ -0,0 +1,33 @@ +// Auswahl-Slice: gewählte Wände (Menge) und das gewählte 2D-Zeichenelement. +// Wand- und 2D-Auswahl schließen sich gegenseitig aus. Reiner Umzug aus +// App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { StoreApi } from "./store"; + +/** Felder, die diese Slice in den RootState beisteuert. */ +export interface SelectionSlice { + // Gewählte Wände als MENGE von IDs (Marquee/Einzelauswahl). + selectedWallIds: string[]; + // Gewählte 2D-Zeichenelemente als MENGE (Marquee/Mehrfach/Einzel) — getrennt + // von der Wand-Auswahl. Einzel-Auswahl = Array der Länge 1. + selectedDrawingIds: string[]; + + setSelectedWallIds: (ids: string[]) => void; + setSelectedDrawingIds: (ids: string[]) => void; + /** Auswahl (Wände + 2D-Elemente) vollständig leeren. */ + clearSelection: () => void; +} + +export function createSelectionSlice( + api: StoreApi, +): SelectionSlice { + const { set } = api; + return { + selectedWallIds: [], + selectedDrawingIds: [], + setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), + setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), + clearSelection: () => + set({ selectedWallIds: [], selectedDrawingIds: [] }), + }; +} diff --git a/src/state/siteSlice.ts b/src/state/siteSlice.ts new file mode 100644 index 0000000..ab453a7 --- /dev/null +++ b/src/state/siteSlice.ts @@ -0,0 +1,81 @@ +// Site-/Kontext-Slice: verwaltet die Kontext-Schicht `project.context` +// (importierte Meshes, Konturen-Sätze, abgeleitetes Gelände-TIN). Diese +// Geometrie ist „dumm"/nicht-semantisch — sie liegt bewusst NEBEN dem BIM-Modell. +// +// Entwurfsentscheidung: `context` ist Teil von `project` (ein einziger +// Wahrheits-Baum, konsistent mit projectSlice). Diese Slice MUTIERT daher +// `project.context` IMMUTABEL über die vorhandene `setProject`-Aktion +// (Cross-Slice), genau wie projectSlice ein neues Project-Objekt liefert. So +// bleiben Persistenz/Undo später einheitlich am Project hängen. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { generateTerrainFromContours } from "../model/terrain"; +import type { ContextObject, Project } from "../model/types"; +import type { StoreApi } from "./store"; + +/** Felder/Actions, die diese Slice in den RootState beisteuert. */ +export interface SiteSlice { + /** Fügt ein Kontext-Objekt (Mesh/Konturen/Gelände) hinzu (immutabel). */ + addContextObject: (obj: ContextObject) => void; + /** Fügt mehrere Kontext-Objekte in einem Schritt hinzu (z. B. DXF-Import). */ + addContextObjects: (objs: ContextObject[]) => void; + /** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */ + removeContextObject: (id: string) => void; + /** + * Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als + * weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder + * null, wenn der ContourSet nicht existiert / kein Mesh entsteht. + */ + generateTerrain: (contourSetId: string) => string | null; + /** Leert die gesamte Kontext-Schicht. */ + clearContext: () => void; +} + +/** + * Cross-Slice-Abhängigkeit: diese Slice liest `project` und schreibt es über + * `setProject` (beide aus projectSlice). Der Store komponiert SiteSlice mit + * dieser Sicht, sodass `get()`/Actions typsicher auf das Projekt zugreifen. + */ +type SiteSliceDeps = { + project: Project; + setProject: (next: Project | ((p: Project) => Project)) => void; +}; + +export function createSiteSlice( + api: StoreApi, +): SiteSlice { + const { get } = api; + + // Immutabler Kontext-Updater: ersetzt `project.context` über die vorhandene + // setProject-Aktion (konsistent mit projectSlice-Stil). + const setContext = ( + update: (ctx: ContextObject[]) => ContextObject[], + ): void => { + get().setProject((p) => ({ ...p, context: update(p.context ?? []) })); + }; + + return { + addContextObject: (obj) => setContext((ctx) => [...ctx, obj]), + + addContextObjects: (objs) => + setContext((ctx) => (objs.length ? [...ctx, ...objs] : ctx)), + + removeContextObject: (id) => + setContext((ctx) => ctx.filter((o) => o.id !== id)), + + generateTerrain: (contourSetId) => { + const ctx = get().project.context ?? []; + const set = ctx.find( + (o) => o.id === contourSetId && o.type === "contourSet", + ); + if (!set || set.type !== "contourSet") return null; + const terrain = generateTerrainFromContours(set.contours); + if (terrain.indices.length === 0) return null; // kein sinnvolles TIN + setContext((c) => [...c, terrain]); + return terrain.id; + }, + + clearContext: () => setContext(() => []), + }; +} diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 0000000..eb6da51 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,87 @@ +// Globaler Store — dependency-freier Kern (useSyncExternalStore). +// +// Bewusst leichtgewichtig: kein Zustand-/Redux-Paket, sondern ein winziger +// Store nach demselben Muster wie src/i18n (externe Quelle + React-Hook). Er +// kombiniert die Slices (project/selection/view/layout) zu EINEM Zustand und +// bietet selektives Lesen über `useStore(selector)`, sodass Komponenten nur bei +// Änderung ihres Ausschnitts neu rendern (kein Re-Render-Sturm, kein +// Prop-Drilling). +// +// Eine Slice ist eine Factory `(set, get) => ({ ...state, ...actions })`. `set` +// nimmt ein Teil-Patch ODER eine Updater-Funktion (wie React-setState); `get` +// liest den aktuellen Gesamtzustand. Actions leben damit IM Zustand (z. B. +// `useStore(s => s.addFloor)`), genau wie in der Spec (state-architecture.md). +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { useSyncExternalStore } from "react"; + +/** Patch-Form für `set`: Teilobjekt oder Updater über den Gesamtzustand. */ +export type SetStatePatch = Partial | ((state: T) => Partial); + +/** Was eine Slice-Factory bekommt, um Zustand zu lesen/schreiben. */ +export type StoreApi = { + /** Schreibt ein Teil-Patch (oder Updater) und benachrichtigt Abonnenten. */ + set: (patch: SetStatePatch) => void; + /** Liefert den aktuellen Gesamtzustand (für Actions, die andere Felder lesen). */ + get: () => T; +}; + +/** Eine Slice: Factory, die ihren Zustands-/Action-Ausschnitt liefert. */ +export type SliceCreator = (api: StoreApi) => S; + +/** Öffentliche Store-Schnittstelle (Hook + imperative Helfer). */ +export interface Store { + /** React-Hook: liest einen Ausschnitt; rendert nur bei dessen Änderung neu. */ + useStore: (selector: (state: T) => U) => U; + /** Imperatives Lesen (außerhalb von React, z. B. in Event-Handlern). */ + getState: () => T; + /** Imperatives Schreiben (Teil-Patch oder Updater). */ + setState: (patch: SetStatePatch) => void; + /** Abonnement (selten direkt nötig; useStore nutzt es intern). */ + subscribe: (listener: () => void) => () => void; +} + +/** + * Baut den Store aus einer Initializer-Funktion, die mit `(set, get)` den + * kompletten Zustand (Felder + Actions) erzeugt. Der Zustand wird flach mit dem + * Patch zusammengeführt (`{ ...state, ...patch }`); für tiefe/immutable Updates + * sind die Actions selbst verantwortlich (sie liefern fertige neue Werte). + */ +export function createStore(init: SliceCreator): Store { + let state: T; + const listeners = new Set<() => void>(); + + const set: StoreApi["set"] = (patch) => { + const partial = typeof patch === "function" ? (patch as (s: T) => Partial)(state) : patch; + // Referenz-Gleichheit als No-op-Schutz: identisches Objekt → kein Notify. + let changed = false; + for (const k in partial) { + if ((partial as Record)[k] !== (state as Record)[k]) { + changed = true; + break; + } + } + if (!changed) return; + state = { ...state, ...partial }; + for (const l of listeners) l(); + }; + + const get: StoreApi["get"] = () => state; + + state = init({ set, get }); + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + + const useStore = (selector: (s: T) => U): U => + useSyncExternalStore( + subscribe, + () => selector(state), + () => selector(state), + ); + + return { useStore, getState: get, setState: set, subscribe }; +} diff --git a/src/state/viewSlice.ts b/src/state/viewSlice.ts new file mode 100644 index 0000000..dbd17d9 --- /dev/null +++ b/src/state/viewSlice.ts @@ -0,0 +1,97 @@ +// View-Slice: alles, was die Darstellung steuert, ohne Modell zu sein — +// Ansichtstyp, Detailgrad, Render-Modus, Referenzlinien, aktive Zeichnungsebene, +// Massstab, Linien-Modus sowie der Darstellungsmodus je Panel. Reiner Umzug aus +// App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). +// +// Entscheidung `activeCategoryCode`: liegt hier (View), NICHT in selectionSlice. +// Es ist keine Element-Selektion, sondern die „aktive Ebene" (Arbeitsfokus für +// neue Zeichnungen / Status-Leiste) — view-seitiger Zustand wie activeLevelId. + +import { sampleProject } from "../model/sampleProject"; +import type { DetailLevel, RenderMode, View3d, ViewType } from "../ui/TopBar"; +import type { DisplayMode } from "../panels/types"; +import type { StoreApi } from "./store"; + +/** Standard-Darstellungsmodus je Panel (wie bisher: alles sichtbar normal). */ +const DEFAULT_DISPLAY_MODE: DisplayMode = "all"; + +/** Felder, die diese Slice in den RootState beisteuert. */ +export interface ViewSlice { + // Aktive Zeichnungsebene (Geschoss/Schnitt/Zeichnung). + activeLevelId: string; + // Ansichtstyp (Grundriss / Perspektive). + viewType: ViewType; + // Detailgrad im Grundriss. + detail: DetailLevel; + // Render-Modus der 3D-Ansicht. + renderMode: RenderMode; + // Kanonischer Blickwinkel der 3D-Ansicht (Front/Top/Seite/Iso/freie Persp.). + view3d: View3d; + // Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad. + fov: number; + // Farb-Modus des Grundrisses: "color" = Kategorie-/Element-Farben (heute), + // "mono" = reiner Schwarz-Weiss-Plan (Tinte, keine Farb-/Schraffurfarben). + planColorMode: "color" | "mono"; + // Wand-Achslinien im Grundriss. + referenceLines: boolean; + // Linien-Darstellung: "display" = Haarlinie, "print" = echte mm-Stärken. + lineMode: "display" | "print"; + // Gewählter (angewandter) Papier-Massstab „1:N". + scaleDenominator: number; + // Aktive Ebene (Kategorie-Code) — Ziel neuer Zeichnungen (Arbeitsfokus). + activeCategoryCode: string; + // Darstellungsmodus je Panel-ID (z. B. "drawing-levels", "layers"). + displayModes: Record; + + setActiveLevelId: (id: string) => void; + setViewType: (v: ViewType) => void; + setDetail: (d: DetailLevel) => void; + setRenderMode: (m: RenderMode) => void; + setView3d: (v: View3d) => void; + setFov: (n: number) => void; + setPlanColorMode: (m: "color" | "mono") => void; + setReferenceLines: (b: boolean) => void; + setLineMode: (m: "display" | "print") => void; + setScaleDenominator: (n: number) => void; + setActiveCategoryCode: (code: string) => void; + /** Darstellungsmodus eines Panels lesen (Default „all"). */ + modeFor: (id: string | null) => DisplayMode; + /** Darstellungsmodus eines Panels setzen (No-op bei null id). */ + setModeFor: (id: string | null, mode: DisplayMode) => void; +} + +export function createViewSlice(api: StoreApi): ViewSlice { + const { set, get } = api; + return { + activeLevelId: sampleProject.drawingLevels[0].id, + viewType: "grundriss", + detail: "mittel", + renderMode: "shaded", + view3d: "perspective", + fov: 50, + planColorMode: "color", + referenceLines: false, + lineMode: "display", + scaleDenominator: 100, + activeCategoryCode: + sampleProject.walls[0]?.categoryCode ?? sampleProject.layers[0].code, + displayModes: {}, + + setActiveLevelId: (id) => set({ activeLevelId: id }), + setViewType: (v) => set({ viewType: v }), + setDetail: (d) => set({ detail: d }), + setRenderMode: (m) => set({ renderMode: m }), + setView3d: (v) => set({ view3d: v }), + setFov: (n) => set({ fov: n }), + setPlanColorMode: (m) => set({ planColorMode: m }), + setReferenceLines: (b) => set({ referenceLines: b }), + setLineMode: (m) => set({ lineMode: m }), + setScaleDenominator: (n) => set({ scaleDenominator: n }), + setActiveCategoryCode: (code) => set({ activeCategoryCode: code }), + modeFor: (id) => (id && get().displayModes[id]) || DEFAULT_DISPLAY_MODE, + setModeFor: (id, mode) => { + if (!id) return; + set((s) => ({ displayModes: { ...s.displayModes, [id]: mode } })); + }, + }; +} diff --git a/src/state/visibilitySets.ts b/src/state/visibilitySets.ts new file mode 100644 index 0000000..4d841f3 --- /dev/null +++ b/src/state/visibilitySets.ts @@ -0,0 +1,176 @@ +// Sichtbarkeits-Kombinationen — Persistenz (localStorage). +// +// Speichert benannte Sichtbarkeits-Sätze (Vectorworks: „Ebenenkombinationen" +// / „Zeichnungskombinationen") für zwei unabhängige Achsen: +// • Ebenen-Kombination (LayerCombo) — pro Kategorie-`code` → sichtbar? +// • Zeichnungs-Komb. (DrawingCombo) — pro DrawingLevel-`id` → sichtbar? +// +// Analog zu panels/layout.ts: reine Speicher-Helfer OHNE React-Abhängigkeit, +// sämtlicher localStorage-Zugriff in try/catch + storage()-Guard (SSR/Tests/ +// Privatmodus). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). +// +// Hinweis: Diese Sätze liegen (wie die benannten Layouts) im localStorage und +// sind damit GERÄTE-/Browser-lokal, nicht im Projekt-Modell. Sollen sie mit dem +// Projekt mitwandern (Export/Teilen), gehören sie später ins `Project` (siehe +// Report). + +/** Eine gespeicherte Ebenen-Kombination: Kategorie-Code → sichtbar?. */ +export interface LayerCombo { + codes: Record; +} + +/** Eine gespeicherte Zeichnungs-Kombination: DrawingLevel-Id → sichtbar?. */ +export interface DrawingCombo { + ids: Record; +} + +/** Präfix der localStorage-Schlüssel für Ebenen-Kombinationen. */ +const LAYER_PREFIX = "cad.layercombos."; +/** Präfix der localStorage-Schlüssel für Zeichnungs-Kombinationen. */ +const DRAWING_PREFIX = "cad.drawingcombos."; + +// ── localStorage-Sicherung (wie panels/layout.ts) ─────────────────────────── + +/** Liefert das localStorage-Objekt oder `null`, wenn nicht verfügbar. */ +function storage(): Storage | null { + try { + if (typeof localStorage === "undefined") return null; + return localStorage; + } catch { + // Zugriff kann werfen (z. B. blockierte Cookies/Privatmodus). + return null; + } +} + +/** Liest und parst einen JSON-Wert; `null` bei Fehlen/ungültig. */ +function readJson(key: string): T | null { + const ls = storage(); + if (!ls) return null; + try { + const raw = ls.getItem(key); + if (raw === null) return null; + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +/** Schreibt einen JSON-Wert; liefert `false`, wenn das Schreiben scheitert. */ +function writeJson(key: string, value: unknown): boolean { + const ls = storage(); + if (!ls) return false; + try { + ls.setItem(key, JSON.stringify(value)); + return true; + } catch { + // Kontingent überschritten oder Zugriff verweigert — still ignorieren. + return false; + } +} + +/** Löscht einen Schlüssel; liefert `true`, wenn der Zugriff gelang. */ +function removeKey(key: string): boolean { + const ls = storage(); + if (!ls) return false; + try { + ls.removeItem(key); + return true; + } catch { + return false; + } +} + +/** Listet alle Namen unter einem Präfix (alphabetisch sortiert). */ +function listNames(prefix: string): string[] { + const ls = storage(); + if (!ls) return []; + const names: string[] = []; + try { + for (let i = 0; i < ls.length; i++) { + const key = ls.key(i); + if (key && key.startsWith(prefix)) { + names.push(key.slice(prefix.length)); + } + } + } catch { + return []; + } + return names.sort(); +} + +/** + * Typwächter für eine `Record`-Map (toleriert Fremddaten aus + * localStorage). Liefert eine bereinigte Map (nur boolesche Werte), sonst `null`. + */ +function asBoolMap(v: unknown): Record | null { + if (typeof v !== "object" || v === null) return null; + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (typeof val === "boolean") out[k] = val; + } + return out; +} + +// ── Ebenen-Kombinationen ───────────────────────────────────────────────────── + +/** Speichert eine Ebenen-Kombination unter einem Namen. `true` bei Erfolg. */ +export function saveLayerCombo( + name: string, + codes: Record, +): boolean { + if (!name) return false; + const value: LayerCombo = { codes }; + return writeJson(LAYER_PREFIX + name, value); +} + +/** + * Lädt eine Ebenen-Kombination (nur die `codes`-Map). Fehlt sie oder ist sie + * beschädigt, kommt `null` zurück (so erkennt der Aufrufer „nicht gefunden"). + */ +export function loadLayerCombo(name: string): Record | null { + const raw = readJson(LAYER_PREFIX + name); + if (raw === null || typeof raw !== "object") return null; + return asBoolMap((raw as { codes?: unknown }).codes); +} + +/** Listet die Namen aller gespeicherten Ebenen-Kombinationen (alphabetisch). */ +export function listLayerCombos(): string[] { + return listNames(LAYER_PREFIX); +} + +/** Löscht eine Ebenen-Kombination. `true`, wenn der Zugriff gelang. */ +export function deleteLayerCombo(name: string): boolean { + return removeKey(LAYER_PREFIX + name); +} + +// ── Zeichnungs-Kombinationen ───────────────────────────────────────────────── + +/** Speichert eine Zeichnungs-Kombination unter einem Namen. `true` bei Erfolg. */ +export function saveDrawingCombo( + name: string, + ids: Record, +): boolean { + if (!name) return false; + const value: DrawingCombo = { ids }; + return writeJson(DRAWING_PREFIX + name, value); +} + +/** + * Lädt eine Zeichnungs-Kombination (nur die `ids`-Map). Fehlt sie oder ist sie + * beschädigt, kommt `null` zurück. + */ +export function loadDrawingCombo(name: string): Record | null { + const raw = readJson(DRAWING_PREFIX + name); + if (raw === null || typeof raw !== "object") return null; + return asBoolMap((raw as { ids?: unknown }).ids); +} + +/** Listet die Namen aller gespeicherten Zeichnungs-Kombinationen (alphabetisch). */ +export function listDrawingCombos(): string[] { + return listNames(DRAWING_PREFIX); +} + +/** Löscht eine Zeichnungs-Kombination. `true`, wenn der Zugriff gelang. */ +export function deleteDrawingCombo(name: string): boolean { + return removeKey(DRAWING_PREFIX + name); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..4be654b --- /dev/null +++ b/src/styles.css @@ -0,0 +1,3339 @@ +/* Erscheinungsbild nach DOSSIER ("RAPPORT": Creme + Petrol-Grün), inkl. + Dark Mode. Der linke Navigator (Zeichnungsebenen + Ebenen-Baum) übernimmt + das DOSSIER-Look: aktive Zeile als Petrol-Pille, nicht-aktive gedimmt, + ausgeblendete grau; Augen-Schalter, Farbtupfer, eingerückte Kinder. */ + +:root { + color-scheme: light; + + /* Flächen */ + --bg: #e0dbd4; + --panel: #e0dbd4; + --panel-2: #d4cfc8; + --input: #ece8e2; + --line: #d4cfc8; + --border: #c8c2ba; + + /* Zeichenblatt-Hintergrund (Plan) — bewusst fix, unabhängig von der Panel- + Farbe: helles „Papier" im Light-Mode. */ + --sheet: #f0f0f0; + + /* Schrift */ + --ink: #1a1a18; + --ink-2: #555550; + --muted: #8a8580; + --label: #1a1a18; + + /* Petrol-Akzent + aktiver Marker */ + --accent: #2f5d54; + --accent-light: #4a8a7c; + --accent-dim: #e6efed; + --accent-border: rgba(47, 93, 84, 0.35); + --active: #1a655a; + --active-light: #2f8275; + --active-dim: rgba(26, 101, 90, 0.12); + + /* Plan-/3D-Töne */ + --wall: #2b3039; + --swing: #9aa3b2; + + /* Warn-/Gefahr-Akzente (z. B. Löschen). */ + --danger: #8a1a1a; + --danger-light: #b03030; + + --shadow-1: 0 1px 2px rgba(26, 26, 24, 0.08); + --shadow-2: 0 2px 8px rgba(26, 26, 24, 0.14); + --shadow-3: 0 6px 24px rgba(26, 26, 24, 0.18); + --overlay: rgba(26, 26, 24, 0.36); + + --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + sans-serif; + --font-mono: "DM Mono", ui-monospace, "SF Mono", monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + /* Gestufte Elevations-Palette (Wunsch: dunkler + mehr Tiefe). Tiefster + Grund hinten, Flächen werden nach vorn schrittweise heller — leicht + kühl-neutral, harmoniert mit dem Petrol-Akzent. + --bg Ebene 0 — App-Grund / Rinnen hinter allem (am dunkelsten) + --panel Ebene 1 — Oberleiste, Docks, Panel-Körper + --panel-2 Ebene 2 — angehobene Flächen: Panel-Köpfe, Hover-Zeilen + --input versenkt — Eingabefelder (dunkler als die Fläche → Inset) */ + /* Zwei-Ton-Grundlage (Nutzer-Vorgabe): Fast-Schwarz hinten, dunkles + Neutralgrau für die Flächen. Neutral (kein Blaustich), mit dezenten + Elevations-Schritten für Tiefe. */ + --bg: #0e0e0e; + --panel: #1d1d1d; + --panel-2: #262626; + --input: #0a0a0a; + --line: #2b2b2b; + --border: #383838; + + /* --sheet bleibt bewusst hell (#f0f0f0) wie im Light-Mode: das Zeichenblatt + ist immer „Papier", unabhängig vom UI-Theme (Wunsch des Nutzers). */ + + --ink: #ededed; + --ink-2: #a8a8a8; + --muted: #6e6e6e; + --label: #c8c8c8; + + /* Akzent NEUTRAL (kein Petrol-Grün mehr, Nutzer-Vorgabe): helles Neutralgrau + für aktive/ausgewählte Flächen; weiße Schrift darauf bleibt lesbar. Hover/ + Dim sind dezente weiße Aufhellungen. */ + --accent: #4d4d4d; + --accent-light: #5e5e5e; + --accent-dim: rgba(255, 255, 255, 0.07); + --accent-border: rgba(255, 255, 255, 0.22); + --active: #6a6a6a; + --active-light: #7d7d7d; + --active-dim: rgba(255, 255, 255, 0.1); + + --wall: #c8ccd2; + --swing: #7e8794; + --danger: #c85050; + --danger-light: #d86060; + /* Tiefere Schatten für klare Schichtung auf dunklem Grund. */ + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5); + --shadow-2: 0 3px 12px rgba(0, 0, 0, 0.55); + --shadow-3: 0 12px 34px rgba(0, 0, 0, 0.62); + --overlay: rgba(0, 0, 0, 0.62); + } +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +/* Native-App-Verhalten: KEINE Textauswahl / „Alles markieren" in der App-Chrome. + user-select global aus; nur in echten Eingaben wieder an, damit Tippen und + Markieren im Feld weiter funktioniert. (Ctrl+A außerhalb von Eingaben wird in + main.tsx zusätzlich unterbunden.) */ +html, +body, +#root { + -webkit-user-select: none; + user-select: none; + /* Kein Browser-eigenes Antippen/Highlight auf Touch. */ + -webkit-tap-highlight-color: transparent; +} + +input, +textarea, +[contenteditable], +[contenteditable] * { + -webkit-user-select: text; + user-select: text; +} + +body { + font-family: var(--font); + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* ── Oberleiste ──────────────────────────────────────────────────────── */ +/* Gruppen links nach rechts mit Trennern; die rechte Gruppe (Layout/ + Ressourcen/Projekt) wird per margin-left:auto an den Rand geschoben. Bei + sehr schmalen Fenstern darf die Leiste horizontal scrollen, statt zu brechen. */ +.topbar { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + height: 56px; + border-bottom: 1px solid var(--line); + /* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten + nach unten → trennt sich klar von der Zeichenfläche (Tiefe). */ + background: var(--panel); + box-shadow: var(--shadow-1); + position: relative; + z-index: 2; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + /* Dezente, normalerweise unsichtbare Scrollleiste (Firefox). */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} +/* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen + (`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */ +.topbar::-webkit-scrollbar { + height: 6px; +} +.topbar::-webkit-scrollbar-track { + background: transparent; +} +.topbar::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 3px; + transition: background 0.25s ease; +} +.topbar.is-scrolling { + scrollbar-color: rgba(140, 140, 140, 0.45) transparent; +} +.topbar.is-scrolling::-webkit-scrollbar-thumb, +.topbar:hover::-webkit-scrollbar-thumb { + background: rgba(140, 140, 140, 0.4); +} + +/* Eine Werkzeug-Gruppe der Oberleiste. */ +.tb-group { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +/* Vertikaler Trenner zwischen Gruppen. */ +.tb-sep { + flex: 0 0 auto; + width: 1px; + height: 22px; + background: var(--border); +} + +/* Beschriftetes Feld (Label + Control) in der Oberleiste. */ +.tb-field { + display: inline-flex; + align-items: center; + gap: 6px; +} +.tb-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--muted); +} + +/* Kompaktes Dropdown der Oberleiste (Detailgrad, Massstab). */ +.tb-select { + border: 1px solid var(--border); + background: var(--input); + color: var(--ink); + font-family: inherit; + font-size: 11px; + font-weight: 600; + padding: 3px 6px; + border-radius: 6px; + cursor: pointer; +} +.tb-select:hover { + border-color: var(--accent-border); +} +.tb-scale { + font-family: var(--font-mono); + min-width: 78px; +} + +/* Gruppe aus Knöpfen (Zoom). */ +.tb-btns { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; + background: var(--input); +} +.tb-btn { + border: none; + background: transparent; + color: var(--ink-2); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 5px 10px; + cursor: pointer; + transition: background 0.14s, color 0.14s; +} +.tb-btn + .tb-btn { + border-left: 1px solid var(--border); +} +.tb-btn:hover:not(:disabled) { + background: var(--accent-dim); + color: var(--accent); +} +.tb-btn:disabled { + color: var(--muted); + opacity: 0.5; + cursor: default; +} + +/* Live-Zoom-Anzeige in der Oberleiste (monospace, fixe Breite). */ +.tb-zoom { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--ink-2); + min-width: 44px; + text-align: right; +} + +/* Live-Papier-Massstab „1:N" in der Oberleiste (Akzent, monospace). Hebt sich + vom gewählten Massstab im Dropdown ab — dies ist der aktuelle Ist-Massstab. */ +.tb-live-scale { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + color: var(--accent); + min-width: 56px; + text-align: right; +} + +/* Pillen-Toggle der Oberleiste (Referenzlinien). */ +.tb-toggle { + border: 1px solid var(--border); + background: var(--input); + color: var(--ink-2); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.tb-toggle:hover { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent); +} +.tb-toggle.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; + font-weight: 650; + font-size: 16px; + letter-spacing: 0.2px; +} + +.logo { + color: var(--accent); + font-size: 18px; +} + +.tag { + font-weight: 500; + font-size: 11px; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 8px; +} + +.project-name { + color: var(--muted); + font-size: 13px; +} + +/* Rechte Gruppe der Oberleiste: Layout-Menü + Ressourcen-Knopf + Projektname. + margin-left:auto schiebt sie an den rechten Rand. */ +.topbar-right { + display: inline-flex; + align-items: center; + gap: 12px; + margin-left: auto; + padding-left: 10px; + flex: 0 0 auto; +} + +/* „Ressourcen"-Eintrag der Oberleiste (öffnet die Manager-Schublade). */ +.res-trigger { + border: 1px solid var(--border); + background: var(--input); + color: var(--ink-2); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + padding: 6px 14px; + border-radius: 999px; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.res-trigger:hover { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent); +} +.res-trigger.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +/* Ansichtstyp-Umschalter (Geschoss). */ +.view-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 999px; + overflow: hidden; + background: var(--input); +} +.view-btn { + border: none; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.03em; + padding: 6px 16px; + cursor: pointer; + transition: background 0.14s, color 0.14s; +} +.view-btn + .view-btn { + border-left: 1px solid var(--border); +} +.view-btn:hover { + background: var(--accent-dim); + color: var(--ink); +} +.view-btn.active { + background: var(--accent); + color: #fff; +} + +/* ── Ansichten als kompaktes Icon-Grid (DOSSIER-Stil, 3 oben / 3 unten) ──── */ +.view-grid { + display: grid; + grid-template-columns: repeat(3, auto); + grid-auto-rows: 1fr; + gap: 3px; + flex: 0 0 auto; +} +.view-grid-4 { + grid-template-columns: repeat(4, auto); +} +.view-icon { + border: 1px solid var(--border); + background: var(--input); + color: var(--ink-2); + display: inline-flex; + align-items: center; + justify-content: center; + width: 31px; + height: 22px; + padding: 0; + border-radius: 6px; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.view-icon:hover:not(:disabled) { + background: var(--accent-dim); + color: var(--ink); + border-color: var(--accent-border); +} +.view-icon.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.view-icon:disabled { + opacity: 0.4; + cursor: default; +} +/* Kamera-Button steckt im Popover-Wrapper → als Grid-Zelle behandeln. */ +.view-grid .tb-popwrap { + display: inline-flex; +} + +/* Gestapelte Gruppe (Label-Felder untereinander, z. B. Ebenen-/Zeichn.-Komb.). */ +.tb-group.tb-stack { + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.tb-field-row { + justify-content: space-between; + gap: 8px; +} + +/* Rumpf: Navigator + Inhalt nebeneinander. */ +.body { + position: relative; /* Bezugsrahmen für schwebende Panels + Andock-Zonen. */ + flex: 1; + display: flex; + min-height: 0; +} + +/* ── Footer / Status-Leiste (unten) ────────────────────────────────────── + App-Shell ist eine Spalte: Oberleiste / Rumpf (flex:1) / Status-Leiste. + Die Leiste hat eine feste Höhe (~22 px) und schrumpft nicht. Links der + Werkzeug-Hinweis, rechts ein Cluster abgeleiteter View-/Modell-Werte. */ +.statusbar { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 22px; + padding: 0 12px; + border-top: 1px solid var(--line); + background: var(--panel-2); + font-size: 11px; + color: var(--ink-2); + user-select: none; +} + +/* Werkzeug-Hinweis links (kontextueller Text). */ +.sb-hint { + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Rechter Wert-Cluster. */ +.sb-cluster { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + white-space: nowrap; +} + +/* Ein Wert-Feld: kleine Beschriftung + Wert. */ +.sb-field { + display: inline-flex; + align-items: baseline; + gap: 5px; +} +.sb-key { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +.sb-val { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink); +} + +/* Vertikaler Trenner im Cluster. */ +.sb-sep { + width: 1px; + height: 12px; + background: var(--border); +} + +/* ── Command-Line (Rhino-artig) — über der Statusleiste ────────────────────── + Dunkle, ruhige Leiste mit Prompt links, klickbaren Inline-Optionen und einer + monospace-Texteingabe rechts; das Autocomplete-Dropdown öffnet nach oben. */ +.cmdline { + position: relative; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; + height: 26px; + padding: 0 12px; + border-top: 1px solid var(--line); + background: var(--input); + font-size: 12px; + user-select: none; +} +.cmdline-prompt { + flex: 0 0 auto; + font-weight: 600; + color: var(--ink); + white-space: nowrap; +} +.cmdline-options { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.cmdline-option { + appearance: none; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--ink); + font: inherit; + font-size: 11px; + padding: 1px 7px; + border-radius: 4px; + cursor: pointer; +} +.cmdline-option:hover { + border-color: var(--accent-border); + background: var(--accent-dim); +} +.cmdline-option-val { + color: var(--accent); + font-family: var(--font-mono); +} +/* Tab-Feld-Zyklus: kleine beschriftete Boxen (Länge/Winkel/…); aktives Feld + hervorgehoben (Akzent-Rand). Wert „—" = ungelockt (folgt der Maus). */ +.cmdline-fields { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.cmdline-field { + display: inline-flex; + align-items: baseline; + gap: 5px; + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--panel-2); + font-size: 11px; + white-space: nowrap; +} +.cmdline-field.active { + border-color: var(--accent-border); + background: var(--accent-dim); +} +.cmdline-field-label { + color: var(--muted); +} +.cmdline-field.active .cmdline-field-label { + color: var(--accent); +} +.cmdline-field-val { + font-family: var(--font-mono); + color: var(--ink); + min-width: 1.2em; + text-align: right; +} +.cmdline-input { + flex: 1 1 auto; + min-width: 80px; + appearance: none; + border: none; + background: transparent; + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + user-select: text; +} +.cmdline-input::placeholder { + color: var(--muted); +} +/* Autocomplete-Dropdown öffnet nach OBEN (Leiste sitzt ganz unten). */ +.cmdline-suggest { + position: absolute; + left: 12px; + bottom: 100%; + margin: 0 0 2px; + padding: 4px 0; + list-style: none; + min-width: 160px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--shadow-2); + z-index: 50; +} +.cmdline-suggest-item { + padding: 3px 12px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink); + cursor: pointer; +} +.cmdline-suggest-item:hover { + background: var(--accent-dim); + color: var(--accent); +} + +/* ── Navigator (links) ───────────────────────────────────────────────── */ +.navigator { + width: 252px; + flex: 0 0 252px; + display: flex; + flex-direction: column; + gap: 18px; + padding: 14px 8px; + border-right: 1px solid var(--line); + background: var(--panel); + overflow-y: auto; +} + +.nav-group { + display: flex; + flex-direction: column; +} + +.nav-group-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 2px 6px 6px; +} + +.nav-group-title { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +.nav-group-actions { + display: inline-flex; + gap: 4px; +} + +.nav-add { + border: 1px solid var(--border); + background: var(--input); + color: var(--ink-2); + font-family: inherit; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 3px 9px; + border-radius: 999px; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.nav-add:hover { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent); +} + +.nav-list { + display: flex; + flex-direction: column; +} + +/* Zeile (Zeichnungsebene). */ +.nav-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-height: 26px; + padding: 2px 10px 2px 2px; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--label); + font-size: 12px; + text-align: left; + cursor: pointer; + user-select: none; + transition: background 0.12s; +} +.nav-row:hover { + background: var(--panel-2); +} + +/* Aktive Zeile = Petrol-Pille; Trennlinie verschwindet. */ +.nav-row.active { + background: var(--active-dim); + border-radius: 999px; + border-bottom-color: transparent; + color: var(--active-light); +} +.nav-row.active .nav-label { + font-weight: 600; + color: var(--active-light); +} + +/* Ausgeblendete (nicht-aktive) Zeile: gedimmt. */ +.nav-row.hidden { + opacity: 0.45; +} + +.nav-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--label); + font-weight: 500; +} + +.nav-elev { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: 10px; + color: var(--muted); +} + +/* Augen-Schalter (Sichtbarkeit) — erbt Zeilenfarbe via currentColor. */ +.eye { + flex: 0 0 auto; + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 50%; + padding: 0; + transition: background 0.12s, color 0.12s; +} +.eye:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--ink); +} +.eye.is-on { + color: var(--ink); +} +.nav-row.active .eye, +.nav-row.active .eye.is-on { + color: var(--active-light); +} + +/* ── Kategorie-Badges (Chips) ────────────────────────────────────────── */ +.chip { + flex: 0 0 auto; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--input); + color: var(--ink-2); + text-transform: uppercase; +} +.badge-floor { + background: var(--active-dim); + border-color: var(--accent-border); + color: var(--active); +} +.badge-section { + background: rgba(120, 90, 160, 0.14); + border-color: rgba(120, 90, 160, 0.35); + color: #6d4f93; +} +.badge-elevation { + background: rgba(40, 120, 110, 0.14); + border-color: rgba(40, 120, 110, 0.35); + color: #2f7a6c; +} +.badge-drawing { + background: rgba(181, 98, 30, 0.14); + border-color: rgba(181, 98, 30, 0.35); + color: #b5621e; +} + +/* ── Ebenen-Baum (Kategorien) ────────────────────────────────────────── */ +.cat-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 1px 10px 1px 6px; + font-size: 12px; + color: var(--label); + border-bottom: 1px solid var(--line); + cursor: default; + user-select: none; + transition: background 0.12s; +} +.cat-row:hover { + background: var(--panel-2); +} +.cat-row { + cursor: pointer; /* Zeile ist als „aktive Ebene wählen" klickbar */ +} +/* Aktive Ebene (Zeichen-Ziel): Akzentstreifen links + leichte Akzentfüllung. */ +.cat-row.active { + background: var(--accent-dim); + box-shadow: inset 2px 0 0 var(--accent); +} +.cat-row.active .cat-name { + color: var(--text, #e6e6e6); + font-weight: 600; +} +.cat-row.hidden { + opacity: 0.45; +} + +/* Gesperrt (Modus „Andere grau & gesperrt"): gedämpft, Cursor signalisiert + Nicht-Bearbeitbarkeit. Gilt für Ebenen- und Zeichnungsebenen-Zeilen. */ +.cat-row.locked, +.nav-row.locked { + cursor: not-allowed; +} +.cat-row.locked .cat-name, +.nav-row.locked .nav-label { + font-style: italic; +} + +.cat-code { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: 9px; + color: var(--muted); + width: 20px; + text-align: left; +} + +.cat-swatch { + flex: 0 0 auto; + width: 12px; + height: 12px; + border-radius: 2px; + border: 1px solid var(--border); +} + +.cat-name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; +} + +/* ── Inhalt (rechts) ─────────────────────────────────────────────────── */ +.content { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +/* Eigenschaften-Leiste eines Geschosses. */ +.props-strip { + display: flex; + align-items: center; + gap: 18px; + padding: 8px 16px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +.prop { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.prop-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +.prop-input { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.prop-input input { + width: 64px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink); + background: var(--input); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px; + text-align: right; + outline: none; + transition: border-color 0.14s, box-shadow 0.14s; +} +.prop-input input:hover { + border-color: var(--accent); +} +.prop-input input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +.prop-unit { + font-size: 11px; + color: var(--muted); +} + +.prop-readonly .prop-value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-2); +} + +/* Eine Ansicht füllt die Restfläche. */ +.pane { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.pane-title { + position: absolute; + top: 12px; + left: 12px; + z-index: 2; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + background: var(--accent-dim); + border: 1px solid var(--accent-border); + padding: 4px 9px; + border-radius: 999px; +} + +.placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--muted); + background: var(--panel); +} +.placeholder strong { + color: var(--ink); + font-size: 16px; +} +.placeholder span { + font-size: 13px; + max-width: 360px; + text-align: center; + line-height: 1.5; +} + +/* Freie 2D-Zeichenfläche. */ +.drawing-area { + position: relative; + flex: 1; + min-height: 0; + background: var(--bg); + overflow: hidden; +} +.drawing-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} + +/* ── 3D-Viewport (Contract: Viewport3D rendert
) ── */ +.viewport { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; +} +/* Anzeigegröße per CSS festnageln, entkoppelt von der Renderauflösung — + verhindert die ResizeObserver-Wachstumsschleife auf HiDPI-Displays. */ +.viewport canvas { + position: absolute; + inset: 0; + display: block; + width: 100% !important; + height: 100% !important; +} + +.viewport-fallback { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + text-align: center; + color: var(--muted); + background: var(--panel); +} +.viewport-fallback strong { + color: var(--ink); + font-size: 15px; +} +.viewport-fallback span { + max-width: 320px; + font-size: 13px; + line-height: 1.5; +} +.viewport-fallback code { + font-family: var(--font-mono); + background: var(--panel-2); + padding: 1px 5px; + border-radius: 4px; + font-size: 12px; +} + +/* ── Grundriss (SVG) ─────────────────────────────────────────────────── */ +.plan-legend { + position: absolute; + bottom: 12px; + left: 12px; + z-index: 2; + font-size: 11px; + font-weight: 600; + color: var(--muted); + background: var(--accent-dim); + border: 1px solid var(--accent-border); + padding: 4px 9px; + border-radius: 999px; +} + +.plan-svg { + flex: 1; + min-height: 0; + width: 100%; + /* Die GESAMTE Zeichenfläche ist Papier (hell), nicht nur der Modell-Bereich: + das SVG-Element-Backing füllt den sichtbaren Bereich unabhängig von Pan/Zoom + (die .plan-bg-Rechteckfläche deckt nur die Modellgrenzen ab). */ + background: var(--sheet); +} + +.plan-bg { + fill: var(--sheet); +} + +/* Tür-/Fenster- + Referenz-Linien: NUR Farbe + fill hier. Strichstärke und + Strichmuster kommen aus dem Modell in mm Papier und werden in PlanView inline + gesetzt (vector-effect:non-scaling-stroke → papierkonstant beim Zoomen). + Das Zeichenblatt ist IMMER hell (--sheet) → Plan-Tinte bleibt fix dunkel, + unabhängig vom UI-Theme (sonst unsichtbar auf Weiß im Dark-Mode). */ +.plan-svg .door-leaf { + stroke: #2b3039; + fill: none; +} + +.plan-svg .door-swing { + stroke: #6b7280; + fill: none; +} + +/* Anschlag-/Rahmenstriche der Tür (nur im Detailgrad „fein"). */ +.plan-svg .door-frame { + stroke: #2b3039; + fill: none; +} + +/* Referenzlinie = Wand-Achslinie (Toggle „Referenzlinien"), gestrichelt. */ +.plan-svg .wall-axis { + stroke: var(--accent); + fill: none; + opacity: 0.85; +} + +/* Auswahl-Hervorhebung (Links-Klick): Akzentkontur + leichte Füllung. */ +.plan-svg .plan-selected { + fill: var(--accent-dim); + stroke: var(--accent); + stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +/* Hervorhebung selektierter 2D-Zeichenelemente (Mehrfach/Marquee): Akzentkontur + über der Geometrie, keine Füllung. */ +.plan-svg .plan-sel-draw { + fill: none; + stroke: var(--accent); + stroke-width: 2.5; + vector-effect: non-scaling-stroke; +} + +/* Auswahl-Rechteck (Marquee, Links-Drag): gestrichelte Akzentkontur, leichte + Füllung. Strichstärke + Strichmuster papierkonstant (non-scaling-stroke). Die + Default-Variante (links→rechts, „einschließend") ist durchgezogen; die + crossing-Variante (rechts→links, „berührend") gestrichelt — wie in CAD üblich. */ +.plan-svg .plan-marquee { + fill: var(--accent-dim); + fill-opacity: 0.35; + stroke: var(--accent); + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} +.plan-svg .plan-marquee-crossing { + stroke-dasharray: 5 4; +} + +/* Editier-Griffe (Grips) des selektierten Elements: gefüllte Akzent-Quadrate + mit hellem Rand. Ziehbar (Treffer in PlanView per Bildschirm-Pixel). */ +.plan-svg .plan-grip { + fill: var(--accent-light); + stroke: #fff; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +/* Kanten-/Seiten-Griffe (Edge Grips): dreieckiger Anfasser je Seite, Spitze + nach außen. Gleiche Akzent-/Rand-Farben wie die Eckpunkt-Griffe. */ +.plan-svg .plan-edge-grip { + fill: var(--accent); + stroke: #fff; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +/* ── Freie 2D-Zeichengeometrie (Drawing2D) ─────────────────────────────── + Farbe kommt aus dem Modell (Kategorie/LineStyle) und wird in PlanView inline + als stroke gesetzt; hier nur der Fallback + fill:none. */ +.plan-svg .draw2d { + /* Plan-Tinte fix dunkel: KEINE CSS-`stroke`-Regel hier — eine CSS-Property + überstimmt sonst das inline gesetzte `stroke`-Presentation-Attribut, sodass + im Dark-Mode `var(--ink)` (#edeef0, hell) gewänne und 2D-Linien auf dem + hellen Papier (#f0f0f0) unsichtbar würden. Die Strichfarbe kommt deshalb + ausschließlich aus dem Modell (Kategorie/LineStyle), inline in PlanView + gesetzt, theme-unabhängig (generatePlan: dunkler Plan-Tinte-Default). */ + fill: none; +} + +/* Kontext-Konturen (importierte Höhenlinien): dezente Referenz-Linien. Die + Strichfarbe wird in generatePlan inline gesetzt (gedämpftes Grau); hier nur + fill:none + leichte Transparenz, damit sie hinter den Bauteilen ruhig bleiben. */ +.plan-svg .context-line { + fill: none; + opacity: 0.7; +} + +/* ── Zeichenwerkzeug-Overlay (Live-Vorschau, Stützpunkte, Snap, HUD) ───── + Immer obenauf, nicht klickbar (pointer-events:none am in PlanView). */ +.plan-svg .tool-preview-line { + stroke: var(--accent-light); + fill: none; + stroke-width: 1.5; + stroke-dasharray: 6 4; + opacity: 0.95; +} +.plan-svg .tool-preview-fill { + fill: var(--accent-dim); + fill-opacity: 0.35; + stroke: var(--accent-light); + stroke-width: 1.25; +} +.plan-svg .tool-vertex { + fill: var(--accent); + stroke: #fff; + stroke-width: 1; +} +.plan-svg .snap-glyph { + fill: none; + stroke: #ffcc44; + stroke-width: 1.75; +} +.plan-svg .snap-glyph.snap-fill { + fill: #ffcc44; +} +.plan-svg .snap-helper-line { + stroke: #ffcc44; + stroke-width: 1; + stroke-dasharray: 4 4; + opacity: 0.8; +} +.plan-svg .tool-hud { + fill: var(--accent-light); + paint-order: stroke; + stroke: rgba(0, 0, 0, 0.55); + stroke-width: 3px; + font-family: var(--mono, ui-monospace, monospace); + dominant-baseline: middle; +} + +/* ── Modusleiste der Transformation (über der Mitte) ─────────────────────── */ +.transform-bar { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 60; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + background: var(--panel, #1c2128); + border: 1px solid var(--accent-border, #355); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + font-size: 12px; + user-select: none; +} +.transform-bar .tf-op { + font-weight: 700; + color: var(--accent-light, #6db5a4); +} +.transform-bar .tf-sep { + width: 1px; + align-self: stretch; + background: var(--line, #333); +} +.transform-bar .tf-modes { + display: flex; + gap: 4px; +} +.transform-bar .tf-mode { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: inherit; + cursor: pointer; +} +.transform-bar .tf-mode:hover { + background: var(--accent-dim); +} +.transform-bar .tf-mode.active { + background: var(--accent); + color: #fff; +} +.transform-bar .tf-key { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.14); + font-weight: 700; + font-size: 10px; +} +.transform-bar .tf-hint { + opacity: 0.8; +} +.transform-bar .tf-cancel { + border: none; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.7; + font-size: 13px; +} +.transform-bar .tf-cancel:hover { + opacity: 1; +} + +/* ── Snap-Menü (TopBar-Popover) ──────────────────────────────────────────── */ +.snap-menu { + position: relative; + display: inline-flex; +} +.snap-popover { + position: fixed; + z-index: 200; + min-width: 200px; + padding: 6px; + background: var(--panel, #1c2128); + border: 1px solid var(--line, #333); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 2px; +} +.snap-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + font-size: 12px; + border-radius: 5px; + cursor: pointer; + user-select: none; +} +.snap-row:hover { + background: var(--accent-dim); +} +.snap-row input[type="checkbox"] { + accent-color: var(--accent); +} +.snap-master { + font-weight: 600; +} +.snap-field { + justify-content: space-between; +} +.snap-field input, +.snap-field select { + width: 84px; +} +.snap-sep { + height: 1px; + margin: 4px 2px; + background: var(--line, #333); +} + +/* ── Kamera-Einstellungen (Popover in der Ansicht-3D-Gruppe) ─────────── */ +.tb-popwrap { + position: relative; + display: inline-flex; +} +.tb-popover { + position: fixed; + z-index: 200; + min-width: 200px; + padding: 10px; + background: var(--panel, #1c2128); + border: 1px solid var(--line, #333); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.tb-poprow { + display: flex; + flex-direction: column; + gap: 6px; +} +.tb-poprow input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +/* ── Fehlerseite (ErrorBoundary) ─────────────────────────────────────── */ +.error-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + height: 100%; + padding: 40px; + text-align: center; +} +.error-screen pre { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px 16px; + color: #b42318; + font-size: 13px; + max-width: 90%; + overflow: auto; +} +.error-screen button { + border: 1px solid var(--accent); + background: var(--accent); + color: white; + border-radius: 999px; + padding: 8px 18px; + font-size: 14px; + cursor: pointer; +} + +/* ── Ressourcen-Manager (schwebendes Fenster / Overlay) ─────────────────── + Drei Tabs (Bauteile / Schraffuren / Linien). Dunkles DOSSIER-Look: + abgesetzte Sektionen, Pill-Inputs, Petrol-Akzent für aktive Elemente. + Schwebt ÜBER allem (hoher z-index, über dem Kontextmenü ~300) mit + abgedunkeltem Hintergrund; Klick auf den Hintergrund schließt. */ +.res-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: var(--overlay); + display: flex; + justify-content: flex-end; +} + +.res-drawer { + width: min(880px, 94vw); + height: 100%; + display: flex; + flex-direction: column; + background: var(--panel); + border-left: 1px solid var(--border); + box-shadow: var(--shadow-3); +} + +/* Kopf der Schublade. */ +.res-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + height: 52px; + flex: 0 0 auto; + border-bottom: 1px solid var(--line); + background: var(--bg); +} +.res-head-title { + display: inline-flex; + align-items: center; + gap: 9px; + font-weight: 650; + font-size: 15px; + letter-spacing: 0.2px; + color: var(--ink); +} +.res-head-icon { + display: inline-flex; + color: var(--accent); +} +.res-close { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + font-size: 22px; + line-height: 1; + border-radius: 50%; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.res-close:hover { + background: var(--panel-2); + color: var(--ink); +} + +/* Tab-Leiste. */ +.res-tabs { + display: flex; + gap: 4px; + padding: 10px 16px 0; + flex: 0 0 auto; + border-bottom: 1px solid var(--line); + background: var(--bg); +} +.res-tab-btn { + border: 1px solid transparent; + border-bottom: none; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.03em; + padding: 8px 16px; + border-radius: 8px 8px 0 0; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.res-tab-btn:hover { + background: var(--accent-dim); + color: var(--ink); +} +.res-tab-btn.active { + background: var(--panel); + border-color: var(--line); + color: var(--accent); + margin-bottom: -1px; +} + +/* Scrollbarer Körper. */ +.res-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 14px 16px 20px; +} + +.res-tab { + display: flex; + flex-direction: column; + gap: 12px; +} + +.res-hint { + font-size: 11px; + font-style: italic; + color: var(--muted); + line-height: 1.4; +} + +/* ── Tabelle (saubere Liste, CONVENTIONS.md) ────────────────────────────────── + Ein CSS-Grid trägt die Spalten (gridTemplateColumns wird je Tab inline + gesetzt). Kopfzeile und Datenzeilen sind `display:contents`, damit ihre + Zellen direkte Grid-Kinder werden und exakt fluchten. */ +.res-table { + display: grid; + align-items: center; + column-gap: 8px; + row-gap: 2px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel-2); + overflow: hidden; +} + +/* Kopf- und Datenzeile lösen sich auf — ihre Zellen sind Grid-Kinder. */ +.res-thead, +.res-tr { + display: contents; +} + +/* Sticky Kopfzeile: jede Titel-Zelle klebt am oberen Rand des Scrollbereichs + (.res-body). Da Zellen einzelne Grid-Kinder sind, wird sticky je Zelle + gesetzt; ein durchgehender Hintergrund verhindert Durchscheinen. */ +.res-th { + position: sticky; + top: 0; + z-index: 2; + align-self: stretch; + display: flex; + align-items: center; + padding: 7px 4px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--muted); + background: var(--panel-2); + border-bottom: 1px solid var(--border); +} +.res-th.num { + justify-content: flex-end; +} +/* Erste/letzte Kopfzelle randvoll, damit die Sticky-Leiste durchläuft. */ +.res-th:first-child { + padding-left: 12px; +} +.res-th:last-child { + padding-right: 12px; +} + +/* Datenzeilen: dezente Trennlinie, Hover hebt die ganze Zeile leicht hervor. + Die Trennlinie sitzt auf jeder Zelle, da die Zeile selbst kein Box hat. */ +.res-td { + display: flex; + align-items: center; + padding: 5px 4px; + min-width: 0; +} +.res-td.num { + justify-content: flex-end; +} +.res-td:first-child { + padding-left: 12px; +} +.res-td:last-child { + padding-right: 12px; +} +.res-tr:hover .res-td { + background: var(--panel); +} +/* Verschneidungs-Rang (joinPriority) — die tragende Zelle hervorheben. */ +.res-td.emphasis .res-input { + font-weight: 700; + color: var(--accent); + border-color: var(--accent-border); + background: var(--accent-dim); +} + +/* Text-/Zahleneingabe (Pill). */ +.res-input { + font-family: var(--font); + font-size: 12px; + color: var(--ink); + background: var(--input); + border: 1px solid var(--border); + border-radius: 999px; + padding: 5px 11px; + outline: none; + width: 100%; + transition: border-color 0.14s, box-shadow 0.14s, background 0.14s; +} +.res-input.mono { + font-family: var(--font-mono); +} +.res-input.num { + text-align: right; +} +.res-input:hover { + border-color: var(--accent); + background: var(--panel); +} +.res-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +/* Pill-Dropdown — nativer Pfeil, Petrol-Hover. */ +.res-select { + appearance: none; + -webkit-appearance: none; + font-family: var(--font); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--ink); + background-color: var(--input); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 10px center; + border: 1px solid var(--border); + border-radius: 999px; + padding: 5px 26px 5px 12px; + cursor: pointer; + outline: none; + width: 100%; + min-width: 0; + text-overflow: ellipsis; + transition: border-color 0.14s, background-color 0.14s, box-shadow 0.14s; +} +.res-select:hover { + border-color: var(--accent); + background-color: var(--panel); +} +.res-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +/* Farb-Swatch mit unsichtbarem nativem Picker darüber. */ +.res-color { + position: relative; + display: block; + width: 46px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--border); + overflow: hidden; + cursor: pointer; +} +.res-color:hover { + border-color: var(--accent); +} +.res-color-swatch { + position: absolute; + inset: 0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); + pointer-events: none; +} +.res-color input[type="color"] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + background: transparent; +} + +/* Linienstil-Vorschau (füllt die Vorschau-Zelle). */ +.res-line-preview { + width: 100%; + min-width: 80px; + height: 26px; + background: var(--input); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0 8px; +} + +/* Löschen-Knopf (Mülleimer in der letzten Spalte, zentriert). */ +.res-delete { + flex: 0 0 auto; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + background: var(--input); + color: var(--muted); + border-radius: 50%; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.res-delete:hover { + background: var(--danger-light); + border-color: var(--danger-light); + color: #fff; +} + +/* „+ Neu"-Knopf je Tab. */ +.res-add-row { + display: flex; + padding-top: 2px; +} +.res-add { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px dashed var(--border); + background: var(--input); + color: var(--ink-2); + font-family: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 7px 16px; + border-radius: 999px; + cursor: pointer; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.res-add:hover { + background: var(--accent-dim); + border-color: var(--accent-border); + color: var(--accent); +} +.res-add-plus { + font-size: 15px; + line-height: 1; +} + +/* Leerer Zustand — überspannt alle Spalten der Tabelle. */ +.res-empty { + grid-column: 1 / -1; + padding: 26px 16px; + text-align: center; + font-size: 12px; + color: var(--muted); + background: var(--panel-2); +} + +/* ══ Panel-Chrome (Andockrahmen: Docks, Tabs, Panel-Rahmen) ═══════════════════ + Der erweiterbare Rahmen: links/rechts je ein Dock mit Tab-Leiste und dem + aktiven Panel. Greift dieselben Tokens wie die nav- und res-Klassen (dunkles DOSSIER- + Look: Petrol-Akzent, Pill-Form, dezente Trennlinien). Die Breite trägt jedes + Dock als CSS-Variable --dock-w (vom Dock-Component inline gesetzt). */ + +/* ── Dock (angedockte Spalte) ────────────────────────────────────────────── */ +.dock { + position: relative; + flex: 0 0 var(--dock-w, 280px); + width: var(--dock-w, 280px); + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + background: var(--panel); + overflow: hidden; +} +/* Trennkante zum Inhalt: links rechts, rechts links. */ +.dock-left { + border-right: 1px solid var(--line); +} +.dock-right { + border-left: 1px solid var(--line); +} +/* Während des Ziehens: keine Text-/Cursor-Flacker, weicher Cursor überall. */ +.dock.resizing { + user-select: none; + cursor: col-resize; +} + +/* Ziehgriff an der inneren Kante (eine schmale, unsichtbare Greiffläche mit + sichtbarer Linie auf Hover/Drag). Liegt über dem Rand des Docks. Cursor = + col-resize (horizontales Verschieben der Dock-Breite). */ +.dock-resize { + position: absolute; + top: 0; + bottom: 0; + width: 7px; + z-index: 5; + cursor: col-resize; + background: transparent; + transition: background 0.12s; + touch-action: none; +} +.dock-left .dock-resize { + right: -3px; +} +.dock-right .dock-resize { + left: -3px; +} +.dock-resize:hover, +.dock.resizing .dock-resize { + background: var(--accent-border); +} + +/* ── Gestapelte Gruppen im Dock ──────────────────────────────────────────── */ +/* Vertikaler Stapel der Gruppen; füllt den Dock-Körper. */ +.dock-stack { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} +/* Eine Gruppe = eigener Tab-Stapel (TabStrip + aktives Panel). Die Höhe ergibt + sich aus flex-grow (= group.weight, inline gesetzt); flex-basis 0, damit nur + die Gewichte zählen. */ +.dock-group { + flex: 1 1 0; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} +/* Trennlinie zwischen gestapelten Gruppen (außer der ersten). */ +.dock-group + .dock-group, +.dock-group-resize + .dock-group { + border-top: 1px solid var(--line); +} +/* Splitter zwischen zwei Gruppen: schmale Greiffläche, Cursor row-resize. */ +.dock-group-resize { + flex: 0 0 7px; + margin: -3px 0; + z-index: 4; + cursor: row-resize; + background: transparent; + transition: background 0.12s; + touch-action: none; +} +.dock-group-resize:hover { + background: var(--accent-border); +} + +/* ── Tab-Leiste (Dock-Kopf) ──────────────────────────────────────────────── */ +.tabstrip { + display: flex; + gap: 4px; + flex: 0 0 auto; + padding: 8px 8px 0; + border-bottom: 1px solid var(--line); + background: var(--bg); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +/* Dünne, dezente Scrollleiste, wenn viele Tabs umlaufen (WebKit). */ +.tabstrip::-webkit-scrollbar { + height: 5px; +} +.tabstrip::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 999px; +} + +.tab { + flex: 0 0 auto; + max-width: 160px; + border: 1px solid transparent; + border-bottom: none; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.03em; + padding: 8px 14px; + border-radius: 8px 8px 0 0; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.14s, color 0.14s, border-color 0.14s; +} +.tab:hover { + background: var(--accent-dim); + color: var(--ink); +} +.tab.active { + background: var(--panel); + border-color: var(--line); + color: var(--accent); + margin-bottom: -1px; +} + +/* ── Panel-Rahmen (Kopf + scrollbarer Körper) ────────────────────────────── */ +.panel-frame { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + background: var(--panel); +} +.panel-frame-empty { + /* Platzhalter, wenn kein Panel aktiv ist — nur Fläche, kein Inhalt. */ + background: var(--panel); +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex: 0 0 auto; + padding: 9px 12px; + border-bottom: 1px solid var(--line); + /* Panel-Kopf leicht angehoben (Ebene 2) → setzt sich vom Körper ab. */ + background: var(--panel-2); +} +.panel-title { + min-width: 0; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.panel-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 10px 8px 16px; +} + +/* ── Darstellungsmodus-Dropdown (kompaktes Pill, vgl. .res-select) ────────── */ +.display-mode { + flex: 0 0 auto; + appearance: none; + -webkit-appearance: none; + max-width: 160px; + font-family: var(--font); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ink-2); + background-color: var(--input); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 8px center; + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 22px 4px 10px; + cursor: pointer; + outline: none; + text-overflow: ellipsis; + transition: border-color 0.14s, background-color 0.14s, color 0.14s, + box-shadow 0.14s; +} +.display-mode:hover { + border-color: var(--accent); + color: var(--accent); + background-color: var(--panel); +} +.display-mode:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +/* ── Layout-Menü (Oberleiste: benannte Layouts sichern/laden) ─────────────── */ +/* Kleines Pill-Dropdown im selben Stil wie .res-trigger; ein Aktions-Trigger, + daher steht der angezeigte Wert immer auf „Layout …". */ +.layout-menu { + appearance: none; + -webkit-appearance: none; + font-family: var(--font); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--ink-2); + background-color: var(--input); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 10px center; + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px 26px 6px 14px; + cursor: pointer; + outline: none; + transition: border-color 0.14s, background-color 0.14s, color 0.14s, + box-shadow 0.14s; +} +.layout-menu:hover { + border-color: var(--accent-border); + background-color: var(--accent-dim); + color: var(--accent); +} +.layout-menu:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +/* ── Eigenes Dropdown (Oberleiste) — Trigger + Popover im Kontextmenü-Stil ─── + Ersetzt die nativen setText(e.target.value)} + onKeyDown={onKeyDown} + /> +
+ ); + }, +); diff --git a/src/ui/ContextMenu.tsx b/src/ui/ContextMenu.tsx new file mode 100644 index 0000000..eaf69d5 --- /dev/null +++ b/src/ui/ContextMenu.tsx @@ -0,0 +1,164 @@ +// Generisches, wiederverwendbares Kontextmenü — 1:1 nach DOSSIER +// (docs/design/context-menu.md, Quelle src/components/ContextMenu.jsx). +// +// Reine Darstellung + Callbacks: der Aufrufer hält die offene Position (x/y in +// Viewport-Pixeln), die Item-Liste und reagiert über `onClose`. Das Menü selbst +// kümmert sich nur um Positionierung (Fixed + Rand-Clamp), das Schließen +// (Außenklick · Escape · erneuter Rechtsklick · Scroll · nach Item-Klick) und +// die Darstellung der Items (Icon, Shortcut, danger, divider, disabled). +// +// Gerendert über ein Portal in , damit es über allem liegt und nicht vom +// overflow/transform eines Panels abgeschnitten wird (z-index ~300, unter +// Modals). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; + +/** + * Ein Eintrag im Kontextmenü. Ein reiner Trenner setzt nur `divider: true` + * (alle anderen Felder werden dann ignoriert). Sonst ist `label` Pflicht und + * `onClick` die ausgelöste Aktion. + */ +export interface ContextMenuItem { + /** Sichtbarer Text (deutsch). Bei `divider` ungenutzt. */ + label?: string; + /** Optionales Icon (z. B. Material-Symbol-Name oder eigener Knoten). */ + icon?: ReactNode; + /** Aktion beim Klick. Wird nur ausgeführt, wenn nicht `disabled`. */ + onClick?: () => void; + /** Ausgegraut, nicht klickbar. */ + disabled?: boolean; + /** Rote Schrift (destruktive Aktion, z. B. Löschen). */ + danger?: boolean; + /** Optionales Tastenkürzel rechtsbündig (nur Anzeige). */ + shortcut?: string; + /** + * Optionaler Tooltip (title) — vor allem, um bei `disabled` den Grund zu + * nennen (z. B. „keine Auswahl"). Reine Anzeige. + */ + title?: string; + /** Reiner Trenner (1px Linie); ignoriert alle anderen Felder. */ + divider?: boolean; +} + +export interface ContextMenuProps { + /** Ankerposition in Viewport-Pixeln (clientX). */ + x: number; + /** Ankerposition in Viewport-Pixeln (clientY). */ + y: number; + /** Die Einträge in Anzeigereihenfolge. */ + items: ContextMenuItem[]; + /** Schließen-Callback (Außenklick · Esc · Scroll · nach Item-Klick). */ + onClose: () => void; + /** Optionaler Titel oben (caps, gedämpft) — z. B. Ebenenname/-code. */ + title?: string; +} + +/** Rand-Abstand zum Viewport beim Clampen (px). */ +const EDGE_PAD = 4; + +/** + * Material-Symbols-Icon (DOSSIER nutzt die Icon-Font). Fällt auf reinen Text + * zurück; ist die Font nicht geladen, bleibt der Slot via min-width leer + * ausgerichtet, sodass die Labels trotzdem fluchten. + */ +export function MenuIcon({ name }: { name: string }) { + return ( + + ); +} + +export function ContextMenu({ x, y, items, onClose, title }: ContextMenuProps) { + const ref = useRef(null); + // Tatsächliche Position nach dem Clampen an die Viewport-Ränder. Bis das Menü + // gemessen ist, am Anker zeigen (kein Sprung — useLayoutEffect korrigiert vor + // dem Paint). + const [pos, setPos] = useState({ x, y }); + + // Nach dem Mount messen und so verschieben, dass das Menü vollständig im + // Viewport bleibt (rechter/unterer Rand → nach links/oben klappen). + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + let nx = x; + let ny = y; + if (nx + rect.width > vw - EDGE_PAD) nx = vw - rect.width - EDGE_PAD; + if (ny + rect.height > vh - EDGE_PAD) ny = vh - rect.height - EDGE_PAD; + if (nx < EDGE_PAD) nx = EDGE_PAD; + if (ny < EDGE_PAD) ny = EDGE_PAD; + setPos({ x: nx, y: ny }); + }, [x, y, items]); + + // Schließen bei: Klick/Rechtsklick außerhalb · Escape · Scroll · Resize. + // pointerdown (capture) fängt auch den Beginn eines erneuten Rechtsklicks. + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + const onScrollOrResize = () => onClose(); + // capture: vor anderen Handlern; passive scroll reicht (wir reagieren nur). + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKey); + window.addEventListener("scroll", onScrollOrResize, true); + window.addEventListener("resize", onScrollOrResize); + return () => { + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKey); + window.removeEventListener("scroll", onScrollOrResize, true); + window.removeEventListener("resize", onScrollOrResize); + }; + }, [onClose]); + + const onItemClick = (item: ContextMenuItem) => { + if (item.disabled || item.divider) return; + item.onClick?.(); + onClose(); // Auto-Close nach Item-Klick. + }; + + const menu = ( +
e.preventDefault()} + > + {title &&
{title}
} + {items.map((item, i) => + item.divider ? ( +
+ ) : ( + + ), + )} +
+ ); + + return createPortal(menu, document.body); +} diff --git a/src/ui/Dropdown.tsx b/src/ui/Dropdown.tsx new file mode 100644 index 0000000..739eecf --- /dev/null +++ b/src/ui/Dropdown.tsx @@ -0,0 +1,330 @@ +// Eigene, themebare Dropdown-Komponente im Stil des Kontextmenüs (ContextMenu). +// +// Ersetzt die nativen : `options` + `value` + `onChange` (Häkchen an +// der aktiven Option). +// • Aktions-Menü wie ein Trigger-Dropdown: `items` (mit Trennern/danger/ +// disabled, analog ContextMenu-Items). Der Trigger zeigt dann `value`-Label +// bzw. den festen `placeholder`. +// +// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text +// kommt als Props (i18n) herein (CLAUDE.md). + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; + +/** Eine wählbare Option (Wert-Auswahl-Modus, analog
+ +
+ + +
+
+
+ ); +} + +/** + * Baut aus einer Import-Entscheidung die fertigen Drawing2D-Elemente für die + * Ziel-Ebene `levelId`. `byLayer` mappt jeden DXF-Layer-Namen deterministisch + * auf einen Kategorie-Code: existiert eine Kategorie mit passendem Namen, wird + * deren Code genutzt; sonst fällt der Layer auf den aktiven Code zurück. + * + * (Die App ruft diese Funktion, NACHDEM sie ggf. die neue Ebene angelegt hat — + * sie braucht die echte Ziel-`levelId`.) + */ +export function buildDrawings( + decision: ImportDecision, + levelId: string, + project: Project, + activeCategoryCode: string, +) { + // Layer-Name → Kategorie-Code: bevorzugt eine Kategorie mit demselben Namen + // (case-insensitiv), sonst der aktive Code. Identifier bleiben so stabil. + const byName = new Map(); + for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code); + const layerToCode = (layerName: string): string => + byName.get(layerName.toLowerCase()) ?? activeCategoryCode; + return contoursToDrawings( + decision.parsed.contours, + levelId, + decision.categoryMode, + activeCategoryCode, + layerToCode, + ); +} diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx new file mode 100644 index 0000000..ba9f1d3 --- /dev/null +++ b/src/ui/ResourceManager.tsx @@ -0,0 +1,840 @@ +// Ressourcen-Manager — verwaltete Stil-Bibliotheken nach DOSSIER-Vorbild +// (Vectorworks-Stil). Drei Sektionen in einer rechten Schublade (Drawer): +// • Bauteile (Components) — Poché-/3D-Farbe, Verschneidungs-Rang, Schraffur. +// • Schraffuren (Hatches) — Muster/Maßstab/Winkel/Farbe + Linienstil. +// • Linien (Line Styles) — Strichstärke/Farbe/Strichmuster. +// +// Darstellung als saubere Tabelle (CONVENTIONS.md): pro Tab EINE sticky Kopfzeile +// mit Spaltentiteln, darunter kompakte Datenzeilen mit Inline-Edit pro Zelle — +// keine wiederholten Feld-Beschriftungen je Zeile. Umgesetzt als CSS-Grid, das +// Kopf- und Datenzeilen dieselben Spalten teilen (`grid-template-columns` je +// Tab), damit die Spalten exakt fluchten. +// +// Reine Darstellung + Callbacks: alle Mutationen laufen immutabel über die +// vom App gereichten Handler (setProject). Plan & 3D leiten aus demselben +// Projekt ab und aktualisieren dadurch live — diese Komponente kennt weder +// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch +// (CONVENTIONS.md). + +import { useRef, useState } from "react"; +import type { + Component, + HatchPattern, + HatchStyle, + LineStyle, + Project, +} from "../model/types"; +import { PEN_WEIGHTS } from "../model/types"; +import { parseLin } from "../io/linParser"; +import { parsePat } from "../io/patParser"; +import { EyeIcon } from "./EyeIcon"; +import { t } from "../i18n"; + +// ── Auswahl-Optionen (UI-Beschriftung übersetzt, Werte englisch) ─────────── + +/** Schraffur-Muster mit i18n-Key für die Beschriftung. */ +const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [ + { value: "none", labelKey: "pattern.none" }, + { value: "solid", labelKey: "pattern.solid" }, + { value: "insulation", labelKey: "pattern.insulation" }, + { value: "diagonal", labelKey: "pattern.diagonal" }, + { value: "crosshatch", labelKey: "pattern.crosshatch" }, +]; + +/** + * Benannte Strichmuster (mm). `null` = durchgezogen. Werte als Schlüssel + * serialisiert, damit das Dropdown den aktuellen Stil wiedererkennt. + */ +const DASH_OPTIONS: { key: string; labelKey: string; dash: number[] | null }[] = [ + { key: "solid", labelKey: "dash.solid", dash: null }, + { key: "dashed", labelKey: "dash.dashed", dash: [6, 4] }, + { key: "dotted", labelKey: "dash.dotted", dash: [1, 3] }, +]; + +/** Findet den Dash-Schlüssel, der zu einem Strichmuster passt (sonst custom). */ +function dashKey(dash: number[] | null): string { + const hit = DASH_OPTIONS.find( + (o) => + (o.dash === null && dash === null) || + (o.dash !== null && + dash !== null && + o.dash.length === dash.length && + o.dash.every((v, i) => v === dash[i])), + ); + return hit ? hit.key : "custom"; +} + +// ── Wiederverwendbare Feld-Bausteine (DOSSIER-Look) ──────────────────────── +// Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über +// die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig. + +/** Kompaktes Textfeld (Pill) für Namen/Platzhalter. */ +function TextField({ + value, + onChange, + placeholder, + mono = false, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + mono?: boolean; +}) { + return ( + onChange(e.target.value)} + /> + ); +} + +/** Zahlenfeld (Pill, rechtsbündig, monospace) mit Live-Commit. */ +function NumberField({ + value, + onChange, + step = 1, + min, + max, + list, +}: { + value: number; + onChange: (v: number) => void; + step?: number; + min?: number; + max?: number; + /** Optionale -ID mit Vorschlagswerten (z. B. Stiftstärken). */ + list?: string; +}) { + return ( + { + const v = Number(e.target.value); + if (!Number.isNaN(v)) onChange(v); + }} + /> + ); +} + +/** + * Farb-Swatch mit nativem Color-Picker. Das