From 35299307d62169c36aa0ed79b7a8bb3e3afe103d Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 9 Jul 2026 00:57:29 +0200 Subject: [PATCH] =?UTF-8?q?Akkumulierten=20gr=C3=BCnen=20Arbeitsstand=20la?= =?UTF-8?q?nden=20(Basis=20f=C3=BCr=20Weiterarbeit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen. Verifikation: tsc --noEmit sauber, vitest 600/600 grün. Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md): - truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs), Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell. - kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness). - render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau, Prioritäts-Verschneidung (section_boolean.rs), einstellbare Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung. - Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport. - Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog). - Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine, Tragwerk-Stützen (Column), BIM-Tree-Panel. - Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für Öffnungen/Treppen, Snap-Symbol-Restyle. --- HANDOVER.md | 86 ++ PENDENZEN.md | 101 +- README.md | 28 +- package-lock.json | 20 + package.json | 14 +- src-tauri/Cargo.lock | 176 ++- src-tauri/Cargo.toml | 8 +- src-tauri/capabilities/default.json | 4 +- src-tauri/kernel2d/src/lib.rs | 394 +++++ src-tauri/render3d/examples/section_svg.rs | 11 + src-tauri/render3d/src/bin/spike3d.rs | 3 + src-tauri/render3d/src/gpu.rs | 305 +++- src-tauri/render3d/src/lib.rs | 79 +- src-tauri/render3d/src/math.rs | 64 +- src-tauri/render3d/src/mesh.rs | 58 +- src-tauri/render3d/src/section.rs | 40 +- src-tauri/render3d/src/section_boolean.rs | 516 +++++++ src-tauri/render3d/src/section_fill.rs | 526 ++++++- src-tauri/render3d/src/shaders.rs | 126 +- src-tauri/render3d/src/types.rs | 131 +- src-tauri/render3d/src/web.rs | 45 +- src-tauri/src/lib.rs | 46 +- src-tauri/src/lock.rs | 145 ++ src-tauri/src/native.rs | 6 + src-tauri/trucksolid/Cargo.lock | 1488 +++++++++++++++++++ src-tauri/trucksolid/Cargo.toml | 42 + src-tauri/trucksolid/src/boolean.rs | 212 +++ src-tauri/trucksolid/src/lib.rs | 546 +++++++ src/App.tsx | 1558 +++++++++++++++++++- src/commands/cmds/column.ts | 152 ++ src/commands/cmds/copy.ts | 14 +- src/commands/cmds/extrude.ts | 287 ++++ src/commands/cmds/measure.ts | 74 + src/commands/cmds/mirror.ts | 14 +- src/commands/cmds/move.ts | 14 +- src/commands/cmds/text.ts | 76 + src/commands/cmds/textbox.ts | 127 ++ src/commands/engine.ts | 8 + src/commands/registry.ts | 16 + src/commands/types.ts | 6 +- src/engine/truckSolid.ts | 70 + src/export/exportDxf.ts | 10 + src/export/exportIfc.test.ts | 327 ++++ src/export/exportIfc.ts | 687 +++++++++ src/export/exportMesh.test.ts | 306 ++++ src/export/exportMesh.ts | 353 +++++ src/export/exportSchedule.test.ts | 110 +- src/export/exportSchedule.ts | Bin 7461 -> 11349 bytes src/export/layoutPdf.ts | 181 +++ src/geometry/column.ts | 46 + src/geometry/kernel2d.parity.test.ts | 326 +++- src/geometry/roomArea.test.ts | 102 ++ src/geometry/roomArea.ts | 21 +- src/i18n/de.ts | 372 ++++- src/i18n/en.ts | 367 ++++- src/io/geoContext.ts | 17 +- src/io/osm.ts | 36 +- src/io/projectFile.ts | 161 ++ src/io/saveFile.test.ts | 51 + src/io/saveFile.ts | 116 ++ src/model/roomStamp.test.ts | 49 + src/model/roomStamp.ts | 27 +- src/model/sampleProject.ts | 125 +- src/model/types.ts | 695 ++++++++- src/model/wall.test.ts | 115 ++ src/model/wall.ts | 32 +- src/overrides/engine.test.ts | 189 +++ src/overrides/engine.ts | 131 ++ src/panels/AttributesPanel.tsx | 4 + src/panels/ElementTreePanel.tsx | 287 ++++ src/panels/LayoutsPanel.tsx | 912 ++++++++++++ src/panels/ObjectInfoPanel.tsx | 445 +++++- src/panels/RoomStampEditor.tsx | 34 + src/panels/SitePanel.tsx | 8 +- src/panels/ToolsPanel.tsx | 23 +- src/panels/ViewSnapshotsPanel.tsx | 667 +++++++++ src/panels/builtinPanels.tsx | 35 + src/panels/host.ts | 224 +++ src/panels/layout.ts | 10 +- src/panels/layoutModel.test.ts | 654 ++++++++ src/panels/layoutModel.ts | 673 +++++++++ src/plan/PlanView.tsx | 230 ++- src/plan/column.test.ts | 162 ++ src/plan/generatePlan.overrides.test.ts | 181 +++ src/plan/generatePlan.ts | 290 +++- src/plan/nativeSync.ts | 15 +- src/plan/toWalls3d.test.ts | 633 +++++++- src/plan/toWalls3d.ts | 1367 +++++++++++++++-- src/plan/wallMeshCut.test.ts | 169 +++ src/plan/wallMeshCut.ts | 370 +++++ src/state/appStore.ts | 14 +- src/state/notify.test.ts | 61 + src/state/notifySlice.ts | 58 + src/state/projectSlice.ts | 16 +- src/state/projectStore.test.ts | 59 + src/state/projectStore.ts | 184 +++ src/state/selectionInfo.ts | 182 ++- src/state/selectionSlice.ts | 12 + src/state/viewSnapshotFolders.test.ts | 158 ++ src/state/viewSnapshotFolders.ts | 183 +++ src/state/viewSnapshots.test.ts | 196 +++ src/state/viewSnapshots.ts | 160 ++ src/styles.css | 542 ++++++- src/text/RichTextEditor.tsx | 30 + src/theme/accents.ts | 2 +- src/tools/tools.ts | 30 + src/tools/transform.ts | 79 +- src/tools/types.ts | 4 +- src/ui/ColorHexField.tsx | 3 +- src/ui/ContextImportDialog.tsx | 47 +- src/ui/ExportSaveDialog.tsx | 125 ++ src/ui/LayoutCreateDialogs.tsx | 313 ++++ src/ui/LayoutSheet.tsx | 1116 ++++++++++++++ src/ui/LockConflictDialog.tsx | 75 + src/ui/PromptDialog.tsx | 92 ++ src/ui/ResourceManager.tsx | 890 ++++++++++- src/ui/SettingsDialog.tsx | 9 + src/ui/Toast.tsx | 81 + src/ui/TopBar.tsx | 736 +++++++-- src/ui/layoutSheetMath.test.ts | 304 ++++ src/ui/layoutSheetMath.ts | 429 ++++++ src/ui/ribbon/CommandIcon.tsx | 28 + src/ui/ribbon/RibbonBar.tsx | 6 +- src/ui/ribbon/ribbonItems.ts | 5 + src/viewport/Viewport3D.tsx | 209 ++- src/viewport/Wasm3DViewport.tsx | 356 ++++- src/viewport/drawingGrips.ts | 50 + src/viewport/materialTextures.ts | 93 ++ src/viewport/raycast3d.test.ts | 82 ++ src/viewport/raycast3d.ts | 321 +++- src/viewport/useWasm3dRenderer.ts | 32 + 131 files changed, 26501 insertions(+), 852 deletions(-) create mode 100644 src-tauri/render3d/src/section_boolean.rs create mode 100644 src-tauri/src/lock.rs create mode 100644 src-tauri/trucksolid/Cargo.lock create mode 100644 src-tauri/trucksolid/Cargo.toml create mode 100644 src-tauri/trucksolid/src/boolean.rs create mode 100644 src-tauri/trucksolid/src/lib.rs create mode 100644 src/commands/cmds/column.ts create mode 100644 src/commands/cmds/extrude.ts create mode 100644 src/commands/cmds/measure.ts create mode 100644 src/commands/cmds/text.ts create mode 100644 src/commands/cmds/textbox.ts create mode 100644 src/engine/truckSolid.ts create mode 100644 src/export/exportIfc.test.ts create mode 100644 src/export/exportIfc.ts create mode 100644 src/export/exportMesh.test.ts create mode 100644 src/export/exportMesh.ts create mode 100644 src/export/layoutPdf.ts create mode 100644 src/geometry/column.ts create mode 100644 src/geometry/roomArea.test.ts create mode 100644 src/io/projectFile.ts create mode 100644 src/io/saveFile.test.ts create mode 100644 src/io/saveFile.ts create mode 100644 src/model/wall.test.ts create mode 100644 src/overrides/engine.test.ts create mode 100644 src/overrides/engine.ts create mode 100644 src/panels/ElementTreePanel.tsx create mode 100644 src/panels/LayoutsPanel.tsx create mode 100644 src/panels/ViewSnapshotsPanel.tsx create mode 100644 src/panels/layoutModel.test.ts create mode 100644 src/panels/layoutModel.ts create mode 100644 src/plan/column.test.ts create mode 100644 src/plan/generatePlan.overrides.test.ts create mode 100644 src/plan/wallMeshCut.test.ts create mode 100644 src/plan/wallMeshCut.ts create mode 100644 src/state/notify.test.ts create mode 100644 src/state/notifySlice.ts create mode 100644 src/state/projectStore.test.ts create mode 100644 src/state/projectStore.ts create mode 100644 src/state/viewSnapshotFolders.test.ts create mode 100644 src/state/viewSnapshotFolders.ts create mode 100644 src/state/viewSnapshots.test.ts create mode 100644 src/state/viewSnapshots.ts create mode 100644 src/ui/ExportSaveDialog.tsx create mode 100644 src/ui/LayoutCreateDialogs.tsx create mode 100644 src/ui/LayoutSheet.tsx create mode 100644 src/ui/LockConflictDialog.tsx create mode 100644 src/ui/PromptDialog.tsx create mode 100644 src/ui/Toast.tsx create mode 100644 src/ui/layoutSheetMath.test.ts create mode 100644 src/ui/layoutSheetMath.ts create mode 100644 src/viewport/drawingGrips.ts create mode 100644 src/viewport/materialTextures.ts diff --git a/HANDOVER.md b/HANDOVER.md index 14d10d1..012fe31 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -1,3 +1,89 @@ +# HANDOVER — Stand 2026-07-08 spät (Schnitt-Parität + Projektdatei, ALLES uncommittet) + +Fortsetzungs-Session (nach dem älteren 2026-07-08-Handover weiter unten). **Alles uncommittet.** Schwerpunkt: 3D-Live-Schnitt = 2D-Schnitt + eigene Projektdatei. + +## Verifikations-Baseline (zuletzt grün) +`npx tsc --noEmit` sauber · `npx vitest run` **588** · `cargo test -p render3d --features render` **76** · `cargo check --features native3d` grün · WASM `npm run build:engine3d` neu gebaut (nötig nach Rust/WGSL-Änderungen). **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren.** + +## Was diese Session gebaut wurde +- **3D-Live-Schnitt = 2D-Schnitt (Kernfunktion):** (a) 45°-Schraffur-Verdrehung gefixt (Shader auf 2D-`parallelLines`-Konvention kalibriert, angle 0 = horizontal); (b) geschichteter Bodenaufbau (Decke per-Schicht statt Einkörper, `emitSlabs`); (c) **Prioritäts-Verschneidung** von 2D nach Rust portiert (`section_boolean.rs` = Port von `subtractDominantBands`/`subtractRect`; Bänder tragen `join_priority` via `CutBandMeta`); (d) `collectCeilingCutters` emittiert per-Schicht-Cutter → Backstein (50) schneidet Estrich/Dämmung (30/20) korrekt weg, nur Beton (100) kappt ihn; (e) **einstellbare Schichttrennlinien** aus `jointLineStyleId`; (f) **Schraffur-Strichstärke pro Hatch** aus deren `lineStyleId` (`hatch-hair` 0.02 / `thin` 0.13); (g) `relativeToWall`-Orientierung: Boden-Dämmung vertikal, Wand horizontal (via `resolveHatch(axisAngle)` wie 2D). Betrifft `src-tauri/render3d/src/{shaders,gpu,section,section_fill,section_boolean,types}.rs` + `src/plan/toWalls3d.ts`. `native.rs` `WallInput`-Literal um die additiven Felder ergänzt. +- **Projekt als Einzeldatei `.obp`** („openbureau project"): `src/io/projectFile.ts` (`PROJECT_EXT`, nativer Öffnen/Speichern-Dialog, alte `.json` weiter ladbar) + **OS-Lock gegen Doppelöffnung** (`src-tauri/src/lock.rs`, `fs4` exklusiver flock über gehaltenes Handle → Auto-Freigabe bei Absturz/Exit; `LockConflictDialog`). QA: Lock-Logik korrekt, einzige Lücke: „schreibgeschützt" == „erzwingen" auf Datenebene (kein app-weiter Read-only-Modus). +- **Identität:** Open-Source-CAAD statt „BIM für Wohnbau"; `about.desc` (de/en) + README + `package.json` (`license: AGPL-3.0-or-later`, `author`). **LICENSE-File fehlt noch** (README verlinkt darauf) — offizieller AGPL-Text muss rein (nicht fabrizieren; `curl gnu.org/licenses/agpl-3.0.txt`). About-Dialog-Umbruch gefixt (`.about-dialog { white-space: normal }` — er erbt `nowrap` von `.topbar`). +- **2D:** Text-Platzierungswerkzeug als 2D-Element (`text`-Command + Freitext-Eingabe in der Command-Engine); 2D-Zeichenwerkzeuge auf `"drawing"`-Ebenen freigegeben (ToolsPanel-Gating = `floorOnly`, Parität mit Ribbon). +- **Teil B — 2D-Annotationen auf Layout-Blättern:** `LayoutAnnotation` (line/rect/text in mm) + CRUD (`layoutModel.ts`) + Editor in `LayoutSheet.tsx` (zeichnen/selektieren/verschieben/löschen, `PromptDialog` für Text) + `layoutSheetMath.ts` (Hit-Tests). Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping. + +## Offene Fäden +1. **Idee 1 — per-Wand „an Decke enden"** (Terminierung ≠ Priorität): AGENT LÄUFT gerade (per-Wand `sliceTermination`, Default off). Details/Design im Memory `design-schicht-verschneidung-3d-schnitt.md`. **Idee 2 (Schichteinzüge, per-Schicht Endversätze)** = größeres Folge-Feature, noch nicht gebaut. +2. **Ressourcen-Fenster als eigenes OS-Fenster** (Nutzer-Wunsch): machbar (Tauri `WebviewWindow` + BroadcastChannel-Sync, „Ausklappen"-Button, Browser bleibt intern). Noch NICHT gebaut — braucht Store-Sync-Architektur-Entscheidung. +3. **Visuelle Tauri-Abnahme** aller 3D-Schnitt-Änderungen steht aus (render3d nur naga-validiert + WASM gebaut; Nutzer testet selbst). Der Backstein hat prioritäts-korrekt noch ein Stück oberhalb des Betons — das ist Idee 1 (Option), nicht ein Bug. +4. **Konventionen:** wie immer — keine KI-Spuren, nicht committen, disjunkte Agent-Lanes, echte Eigenschaft prüfen (nicht nur grüne Tests). + +--- + +# HANDOVER — Stand 2026-07-08 (Account-/Instanzwechsel, ALLES uncommittet) + +Übergabe an eine frische Instanz (Nutzer wechselt Account, Budget aufgebraucht). **Alles unten ist UNCOMMITTET** (Bearbeiter committet nie selbst). Zuerst `PENDENZEN.md` lesen (Single Source of Truth, ✅-Erledigt-Liste + offene Items ganz konkret). Diese Session war eine sehr lange Feature-/Fix-Serie mit vielen Sub-Agents. + +## Verifikations-Baseline (zuletzt grün) +`npx tsc --noEmit` sauber · `npx vitest run` **491/491** · `cargo check --manifest-path src-tauri/Cargo.toml` grün · `vite build` OK. (Der letzte Layout-Viewport-Agent meldete 491; nach dem interaktiven Ausschnitte-Fix waren es 465 → 491 ist der aktuelle Stand nach dem Layout-2b-Agenten.) **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren**, da alles uncommittet nebeneinander liegt. + +## Was diese Session gebaut wurde (alles in PENDENZEN ✅ dokumentiert) +- **Interop-Export:** IFC4 (`exportIfc.ts`, jetzt `IfcTriangulatedFaceSet` mit ausgeschnittenen Fenster/Tür-Löchern + SOLIDE Wände — Winding-Fix), STL+OBJ (`exportMesh.ts`), gemeinsamer Loch-Ausschnitt `src/plan/wallMeshCut.ts` (Port von render3d `extrude_layer_segment_with_holes`, `isWatertight` via Gauss/Volumen). Schnellexport-Dialog (`ExportSaveDialog`, Format+Name). +- **Nativer Speichern-Dialog:** Tauri `plugin-dialog`+`plugin-fs` (Cargo/lib.rs/capabilities), `src/io/saveFile.ts` (`saveTextFile`: nativ unter Tauri, Blob-Fallback im Browser). App.tsx `downloadTextFile`→`saveTextFile`. +- **Ausschnitte (View-Snapshots) Phase 1:** `Project.viewSnapshots`, `state/viewSnapshots.ts`, `ViewSnapshotsPanel` (Speicher-Fix: Inline-Input statt window.prompt). +- **Layout-Blätter Phase 2a+2b:** Modell `Layout`/`LayoutViewport`/`MasterLayout` (`Project.layouts`/`masterLayouts`), In-Viewport-Editor (`activeLayoutId` in App.tsx → `LayoutSheetView`/`LayoutSheet.tsx`, Maus-Editing, `layoutSheetMath.ts`), `LayoutsPanel`. Floating `LayoutEditor` wurde ersetzt. +- **Tragwerk-Stützen:** `Column`/`ColumnProfile`, `geometry/column.ts`, Platzieren/2D/3D/Selektion/Attribute/move. +- **Weiteres:** A1 Override-Regel-Engine, kernel2d Phase 5 komplett, Bauteil-CSV volles Element-Set, SIA-416 AGF, Kamera-Presets Kardinal+Iso, Decken-UK/OK, BIM-Tree-Panel, ObjectInfo-Volumen, Basis-Material-Texturen, OSM 7 Kat., IndexedDB-Kern, Rich-Text super/sub, Auto-Zoom-Geo, Raumstempel Personen/Rundung, diverse TopBar-Feinschliffe (Export-Sammelmenü, Über-Dialog, Petrol-Punkt, Detailgrad+Darstellung gestapelt, Arbeitsumgebung in Settings). + +## ⚠️ WICHTIG / offene Fäden (Details in PENDENZEN, Abschnitt „In Arbeit"/oben) +1. **NOCH NICHTS VISUELL IN TAURI ABGENOMMEN:** IFC/OBJ/STL-Geometrie, nativer Speichern-Dialog, Layout-im-Viewport, Ausschnitte — alle agent-/testverifiziert, aber der Nutzer hat sie im laufenden Fenster noch NICHT gesehen. **Tauri-Dev NEU STARTEN** (`npm run tauri:dev`, ggf. Vite separat auf Port 5187 — es gibt KEIN `beforeDevCommand`) wegen der neuen Rust-Plugins. +2. **`window.prompt`/`confirm`-SWEEP:** in Tauri-WKWebView DEAKTIVIERT (→ null). Gefixt: ViewSnapshotsPanel, LayoutsPanel. NOCH betroffen: `ComboMenu` (Kombinationen speichern), TopBar Massstab „frei" (`topbar.scale.prompt`), evtl. weitere. Grep `window.prompt|window.confirm` über `src/`, alle auf Inline-Eingaben umstellen. +3. **NÄCHSTES GROSSES ITEM (fertig gescoped, war startbereit als Opus-Agent):** „Layouts als Ordner-/Baumstruktur" — voller Auftrag steht in PENDENZEN (Ordner-Baum, ein „+" für Ordner/Layout/Masterlayout, Inline-Rename, Layout-Erstell-Dialog mit Master-Vorlage/freie Grösse, Masterlayout mit Grössenwahl, Ordner→Mehrseiten-PDF). Direkt umsetzbar. +4. **Konventionen:** keine Fremd-Tool-Spuren/Co-Authored-By im Repo; Sub-Agents in disjunkte Datei-Lanes schicken (i18n de.ts/en.ts + App.tsx sind Hotspots → exklusiv EINEM Agent pro Welle, sonst NUL-Byte-Kollision); grüne Tests NICHT als alleinigen Beweis nehmen (echte Eigenschaft prüfen — z. B. beim IFC-Solidity-Fix war der naive Manifold-Test falsch, Gauss/Volumen war richtig). + +--- + +# HANDOVER — Stand 2026-07-07 abends (Instanzwechsel, alles uncommittet) + +Übergabe an eine frische Instanz (Nutzer wechselt bewusst die Instanz). **Alles unten Gelistete ist NOCH UNCOMMITTET** — Bearbeiter committet laut Arbeitsprotokoll nicht selbst. Zuerst `CONVENTIONS.md` + `PENDENZEN.md` lesen (dort steht die volle, aktuelle Aufgaben-Historie inkl. aller Details der truck-Integration/Deep-Review-Fixes unten — hier nur die Kurzfassung + der offene Faden). + +## Sofort-Kontext für die neue Instanz + +**Environment:** macOS-Gerät, Toolchain vollständig (node/npm/cargo/wasm-pack), `npm run tauri:build` läuft. Kein Blocker. + +**Verifikations-Baseline (zuletzt geprüft, alles grün):** `npx tsc --noEmit` sauber · `npx vitest run` 341/341 · `cargo test` in `src-tauri/trucksolid` 15/15 · `cargo test --manifest-path src-tauri/Cargo.toml -p render3d` 58/58. + +**Was in dieser Session geschah (chronologisch, Details je in PENDENZEN.md „truck-Integration" Phase 5 / Backlog):** +1. Vorherige Session ist wegen RAM-Absturz des Geräts abgebrochen (lief eine LOKALE LLM auf dem Laptop, nicht diese Instanz) — Stand war: truck-Integration Phasen 1–4 fertig (Extrusion + Verjüngung/Taper + Boolean-CSG via `csgrs`), aber noch uncommittet. +2. Diese Instanz hat den übernommenen Stand **deep-reviewt** (8-Winkel-Review: Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) und mehrere echte Bugs gefunden + gefixt — volle Liste in PENDENZEN.md unter „truck-Integration" → „Deep-Review + Fixes (2026-07-07)": konkave Profil-Triangulierung (Fächer→Ohr-Clipping), 6 fehlende Selektions-Resets für Extrusionen, Mirror/Copy ignorierten Extrusions-Auswahl, DXF-Export verlor den Layer, `truckMeshCache`-Leak + Flacker-Bug, unnötige `fitTargetDist`-Neuberechnung, fehlende i18n (Schnittebene-Button + Farbwähler-Tooltip). +3. Danach auf Nutzer-Wunsch **eigenständig durch PENDENZEN.md** gearbeitet (nur unblockierte Items, keine Design-Entscheidungen geraten): **Ortho-Ray-Picking gefixt** (`cameraRay` in `src/viewport/raycast3d.ts` konnte nur perspektivisch — jetzt echte parallele Strahlen bei `perspective:false`, rückwärtskompatibel; +2 Tests). Siehe PENDENZEN.md „3D-REST" Abschnitt. + +## ✅ ERLEDIGT 2026-07-07 (Folge-Instanz) — Bauteil-CSV volles Element-Set (D2, vertieft A6), umgesetzt exakt nach dem Scope unten; Treppen-Fläche = null (leer), Aggregat-Zellen leer statt 0.00. Details/Status in PENDENZEN.md „✅ Erledigt". + +`src/export/exportSchedule.ts` deckt aktuell nur **Wand + Decke** ab (Muster: `evalWall`/`evalCeiling` → `ScheduleRow`, siehe Datei). PENDENZEN-Backlog-Item „AUDIT (DOSSIER-Studie)" nennt D2 = „Bauteil-CSV mit vollem Element-Set". Ich habe das untersucht und **bewusst gescoped, aber NICHT umgesetzt** (Instanzwechsel kam dazwischen) — der Datei-Zustand ist unverändert/clean (ein Testballon-Import wurde wieder entfernt, `tsc` ist grün). Damit die nächste Instanz nicht neu recherchieren muss, hier die bereits getroffenen, gut begründeten Scope-Entscheidungen: + +- **Aufnehmen:** `Door` (Tür), `Opening` (kind="window"→Fenster, kind="door"→Öffnung), `Stair` (Treppe), `ExtrudedSolid` (Extrusion, aus der truck-Integration). +- **NICHT aufnehmen: `Room`** — Räume haben bereits einen eigenen, dedizierten CSV-Export (`roomsToCsv` in `src/geometry/roomArea.ts`, SIA-Flächenbilanz-Spalten). Eine zweite Raum-Zeile in der allgemeinen Bauteilliste wäre Duplikation. +- **Spalten-Mapping** (bestehende feste Header „Länge/Höhe/Dicke/Fläche" — nur dort füllen, wo eine Ableitung wirklich verlässlich ist, sonst leer lassen wie es `evalCeiling` heute schon für Decken macht): + - Door/Opening: `length = width` (lichte Breite), `heightM = height`, `thickness = null` (keine sinnvolle Entsprechung), `area = width * height`. Geschoss auflösen über `hostWallId` → `project.walls.find(w => w.id === hostWallId)?.floorId` (neuer kleiner Helper nötig, Wand evtl. verwaist behandeln wie bei `evalWall`/`evalCeiling`). + - Stair: `length = runLength + (run2Length ?? 0)` (Näherung, bei Wendel evtl. wenig aussagekräftig — akzeptabel, da nur „verlässlich Ableitbares" Anspruch gilt), `heightM = stair.totalRise ?? null` (KEINE Geschosshöhen-Fallback-Auflösung nachbauen — Kopplung/Risiko vermeiden), `thickness = null`, `area`: **noch nicht entschieden** — eine echte Footprint-Fläche bräuchte `geometry/stair.ts`s `stairGeometry()` (Kopplung an Geschoss-Höhen-Fallback) → einfachster sicherer Weg ist vermutlich `area = null` lassen (ehrlich statt erfunden), aber nochmal gegen die bestehenden Tests/Konventionen prüfen, bevor final entschieden wird. + - ExtrudedSolid: `typeName = "Extrusion"`, Geschoss über `levelId`, `length = null`, `heightM = solid.height`, `thickness = null`, `area = polygonArea(solid.points)` (Import aus `geometry/roomArea.ts`, bereits im File importiert). + - Neue `kind`-Werte in `ScheduleRow`/`TypeAggregate`: `"Tür" | "Fenster" | "Öffnung" | "Treppe" | "Extrusion"` zusätzlich zu `"Wand" | "Decke"`. +- **Aggregat-Zeile (`aggregateByType`/Summary-Block):** die bestehende Zeile `fmtNum(agg.kind === "Wand" ? agg.totalLength : null)` sollte verallgemeinert werden zu `agg.totalLength > 0 ? fmtNum(agg.totalLength) : ""` — dann zeigen Tür/Fenster/Öffnung/Treppe (die jetzt auch eine `length`-Spalte befüllen) automatisch korrekt eine Summe, während Decke/Extrusion (deren `length` immer `null`→0 bleibt) weiterhin korrekt leer bleiben. Kein Sonderfall pro Kind nötig. +- **Tests:** `src/export/exportSchedule.test.ts` folgt einem klaren `fixtureProject()`-Muster (2 Wände + 1 Decke) — beim Erweitern eine Tür/ein Fenster/eine Treppe/eine Extrusion in die Fixture aufnehmen und je einen Zeilen-Assert wie bei den bestehenden Wand-/Decken-Zeilen ergänzen. +- Warum nicht in dieser Session fertig: Instanzwechsel kam mitten in der Umsetzung. **Kein Blocker, keine Nutzer-Entscheidung nötig** — reine Fleissarbeit nach obigem Schema, dann `tsc`/`vitest` grün prüfen und in PENDENZEN.md abhaken. + +## Aus dieser Session bewusst NICHT angefasst (recherchiert, aber zu unsicher/blockiert) + +- **Treppe Pfeil-Style „filled" + Tür `swing_invert`/`aussenseite`** (PENDENZEN „BAUTEILE… Gruppe B"): die Referenzquelle (`/tmp/dossier-ref/rhino/*.py`) existiert auf **diesem** Gerät/dieser Session NICHT (geprüft, `find` liefert nichts) — ohne das exakte Rhino-Verhalten zu kennen, wäre die visuelle Ausgestaltung geraten. Nicht angefasst, um nicht falsch zu raten. +- **E2b Schraffur-Kachel-Motiv:** `MotifEditor` (`src/ui/MotifEditor.tsx`) wird schon für Linienstil-Motive wiederverwendet (1D, entlang einer Linie) — ein Flächen-Kachel-Motiv für `HatchStyle` wäre ein NEUES Datenmodell (2D-Tile-Grid), kein reines Wiederverwenden. Braucht eigentlich einen Scope-Entscheid. +- Alles, was in PENDENZEN.md explizit „Mit Nutzer klären"/„Nutzer-Entscheid nötig" trägt (Ribbon 3D-Tab, kernel2d Phase 6, Feld-Controller, Schnitt/Ansicht Phase 3, Teamwork-Granularität, Geo-Block-Priorität, Textur-Pipeline-Scope, STRATEGIE-Priorisierung, Bildschraffur-Freigabe) — bewusst übersprungen, nicht neu bewertet. + +## Wichtige Memory-Notiz (persistiert, gilt für zukünftige Instanzen dieses Repos) +Der Nutzer lässt teils eine **lokale LLM auf seinem Laptop autonom** an diesem Projekt arbeiten (kann abstürzen, z. B. RAM). Deren Output sah in dieser Session strukturell sauber + gut getestet aus, enthielt aber einen echten geometrischen Bug (Fächer-Triangulierung bricht bei konkaven Profilen), den die eigene Testsuite NICHT gefangen hatte (Tests prüften nur Form/Anzahl, nicht geometrische Korrektheit). **Bei Übernahme von Arbeit aus solchen Sessions: grüne Tests allein NICHT als Beweis nehmen, gezielt nachprüfen ob die Tests die richtige Eigenschaft testen.** + +--- + # HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen) Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen. diff --git a/PENDENZEN.md b/PENDENZEN.md index 8f69d50..8e6db78 100644 --- a/PENDENZEN.md +++ b/PENDENZEN.md @@ -18,22 +18,40 @@ --- -## 📅 Geplant - -- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** — **Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~4–6 Wochen. Mit Nutzer Scope/MVP klären vor Start. - ## ⛔ Blocker (zuerst klären) - [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.** ## 🔧 In Arbeit +- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** (Plan: [docs/design/truck-plan.md](docs/design/truck-plan.md)). Ziel: Nutzer zeichnet 2D-Querschnitt → truck-Extrusion → 3D-Körper + später Boolean gegen Wand/Decke. Umfang gesamt: ~4–6 Wochen. Fortschritt: + - [x] Phase 1: Geometrie-Schicht — Crate `src-tauri/trucksolid` (`extrude_polygon_core`/`extrude_circle_core`, `truck_modeling` nur zur B-Rep-Validierung/`try_attach_plane`/`tsweep`, Tessellierung manuell aus den Ring-Koordinaten — truck-rendimesh-Tessellierung für Solids in 0.3 nicht verfügbar, daher Abweichung vom Übergabe-Dokument), WASM-Bindings (Feature `web`) + `build:truck`-Script + `exclude`-Eintrag, TS-Wrapper `src/engine/truckSolid.ts` (`extrudePolygon`/`extrudeCircle`), `RMeshKind` um `"extrusion"` erweitert (`toWalls3d.ts`). Verifiziert: `cargo test` 5/5 grün, `npm run build:truck` sauber, `tsc --noEmit` 0 Fehler, `vitest run` 339/339 grün. + - [x] Phase 2 (MVP, Nutzer-Entscheid 2026-07-06: **"Nur Viewer-Wiring"**, kein Werkzeug/Store) — Ende-zu-Ende-Beweis, dass die Pipeline bis ins Bild funktioniert: ein fest verdrahtetes L-Profil (`emitTruckFixture` in `toWalls3d.ts`, 2 m abseits vom Ursprung) wird bei Bedarf per `trucksolid`-WASM extrudiert und erscheint im 3D-Viewport. **Zwei echte Lücken dabei gefunden und geschlossen:** (1) `render3d::types::MeshKind` kannte nur `Terrain`/`Imported` — ein `kind:"extrusion"` ohne passende Rust-Variante hätte die serde-Deserialisierung des GESAMTEN Modell-Pushes zum Absturz gebracht (nicht nur die Fixture); `Extrusion`-Variante + warmes Orange als Default-Farbe ergänzt (`cargo test` render3d 58/58 weiterhin grün). (2) `updateModel(project)` läuft nur bei Projektänderung (`useEffect`-Dep `project`) — die asynchron ladende Fixture hätte trotz Erfolg NIE einen Re-Push ausgelöst; Fix via Browser-Event `TRUCK_FIXTURE_READY_EVENT` (`toWalls3d.ts` dispatcht, `Wasm3DViewport.tsx` hört + stösst `updateModel` erneut an). **Visuell verifiziert** (Playwright, `?engine=wasm`, Chromium mit `--use-angle=metal` für echten WebGPU-Adapter headed): orangene Extrusion sichtbar neben dem Demo-Haus im Viewport. `tsc --noEmit` + `vitest run` 339/339 + `cargo test` render3d 58/58 grün. **Noch uncommittet** (Bearbeiter committet nicht selbst). + - [x] Phase 3: UI-Werkzeug + Store-Integration — **erledigt 2026-07-06:** `extrude`-Befehl (`src/commands/cmds/extrude.ts`, Alias „ex") nimmt Polylinie/Rechteck/Kreis als Profil (vorselektiert ODER Klick), Höhe tippen oder Enter für Default; Quell-Zeichnung wird beim Commit ENTFERNT (nicht dupliziert) — der Grundriss zeigt die Extrusions-Footprint-Kontur (`generatePlan.ts`, warmes Orange `#d98c40`) statt der alten Linie. `project.extrudedSolids`-Array statt Fixture. **Volle Auswahl-Parität mit Wand/Decke/Raum** (Nutzer-Entscheid „Voll: wie Wand/Decke/Raum"): eigener Selektionskanal, Klick-Priorität in `PlanView.tsx`, Attribute-Panel-Sektion (Höhe editierbar → löst Re-Extrusion aus, Grundfläche, Geschoss), Löschen, Ctrl+A, `move`-Befehl (`src/tools/transform.ts`). Kreis-Profil nutzt die echte `extrudeCircle`-WASM-Rundextrusion (kein N-Eck-Fallback) über eine 48-Eck-Tessellierung für 2D/Auswahl. Verifiziert: `tsc`/`vitest` 339/339 grün, Playwright-Durchlauf (zeichnen→extrudieren→auswählen→Höhe ändern→löschen) ohne Konsolenfehler. **Bewusst NICHT gebaut** (Konsistenz mit Raum/Decke/Treppe/Öffnung, die das auch nicht haben): Marquee-Auswahl, Klick-Drag-Verschieben. **Noch uncommittet.** + - [x] Phase 4: Boolean gegen Wand/Decke — Mesh-CSG statt B-Rep-Boolean. **Spike 1, 2026-07-06 (Monstertruck-Fork getestet, nicht nur Recherche):** eigenes Rust-Testprogramm gegen `monstertruck-solid` 0.3.2 (Fork von `ricosjp/truck`, wirbt mit gehärteten Booleans) — der Crate-eigene Smoke-Test (echter Überlapp, versetzt in allen 3 Achsen) läuft sauber (`or`/`and` liefern exakte Volumina). Aber: **jede Konstellation mit deckungsgleichen/berührenden Seitenflächen schlägt fehl** — exakt der Praxisfall „Extrusion flächenbündig an Wand" oder „Extrusion mit gleichem Wandquerschnitt eingebunden" (gleiche y/z-Ausdehnung wie die Wand, nur in x versetzt): 0 Überlapp → `Internal{operation:"or"/"and"}`-Fehler; 1e-4 UND sogar 0.1 m sichtbarer Überlapp bei deckungsgleichen Seitenflächen → derselbe interne Fehler; 1e-9 Gleitkomma-Rauschen (winziger Spalt statt exakt 0) → **kein Fehler, aber STILL FALSCHES Ergebnis** (2 unverschmolzene Shells, Volumen = Summe statt Vereinigung). Sobald y/z zusätzlich leicht versetzt sind (keine deckungsgleichen Seiten mehr), funktioniert derselbe x-Überlapp einwandfrei. **Schluss: B-Rep-Booleans (truck/monstertruck) sind für diesen Projekt-Praxisfall (Extrusion mit Wandbreite/-höhe eingebunden) grundsätzlich ungeeignet** — teils Absturz, teils stiller Falsch-Wert. + + **Spike 2, 2026-07-06 (`csgrs`, Mesh-Ebenen-CSG/BSP-Baum statt B-Rep):** strukturell anderer Ansatz — rechnet auf Dreiecks-Ebene statt über analytische Kurven/Flächen-Schnitte, damit unempfindlich gegen genau die koinzidenten-Flächen-Fälle, an denen truck/monstertruck scheiterten. Gegen dieselben Testfälle geprüft: generischer Überlapp exakt korrekt (`or`/`and`); flächenbündig (0 Überlapp) → `or` exakt 2.0 (korrekt verschmolzen, kein doppeltes Volumen), `and` liefert korrekt LEER (als Trimesh-Fehler statt sauberem `None` — muss beim Aufrufer als "leer" interpretiert werden); 1e-9-Rauschen → weiterhin exakt korrekt (kein falsches Doppel-Volumen wie bei monstertruck); 1e-4 winziger Überlapp → exakt korrekt; **der kritische Praxisfall (0.1 m Einbindetiefe, deckungsgleicher Wandquerschnitt)** → `or`/`difference` beide exakt korrekt. Jedes Ergebnis analytisch exakt (nicht nur "nah dran"). + - Packungs-Caveat: alle crates.io-Releases (0.16.0–0.20.1) sind wegen einer harten, zurückgezogenen `core2`-Abhängigkeit nicht installierbar — auf dem unveröffentlichten `main`-Branch bereits behoben. **Nutzerautorisiert** ("Git-Fetch für den Spike erlauben", danach "alles klar mach das" zur Umsetzung als echte Abhängigkeit): `trucksolid/Cargo.toml` pinnt `csgrs` auf Commit `5e7a37a8803d4e56617734687edc9b98f4ebeed7` (Git-Dependency, kein crates.io-Release — Risiko: kein durchnummeriertes/geprüftes Release, sollte bei Gelegenheit auf ein echtes `0.21.0`-Release umgestellt werden, sobald verfügbar). + - **Umgesetzt:** `src-tauri/trucksolid/src/boolean.rs` — `boolean_mesh_core(a, b, op)` baut aus flachen Positions-/Indices-Arrays `csgrs::mesh::Mesh`-Polygone (`Polygon::new`/`Vertex::new`, Normale je Dreieck aus dem Kreuzprodukt, NICHT aus den Eingabe-Normalen — die Ebenen-Orientierung fürs BSP kommt aus der Vertex-REIHENFOLGE/Winding, nicht aus einem mitgelieferten Normalenfeld), ruft `union`/`difference`/`intersection` (Trait `csgrs::csg::CSG`) auf, tesselliert das Ergebnis zurück über `Triangulated3D::visit_triangles`. `empty: bool` im Output normalisiert den "Trimesh-Fehler bei leerem Schnitt"-Fall. WASM-Export `boolean_mesh` (Feature `web`, JSON-Schnittstelle wie `extrude_polygon`) + TS-Wrapper `booleanMesh()` in `src/engine/truckSolid.ts`. **Wichtige Voraussetzung für Aufrufer:** beide Eingabe-Meshes müssen konsistent nach AUSSEN gewundene Dreiecke haben (Rechte-Hand-Regel) — falsches Winding liefert ein falsches Ergebnis, OHNE Fehler zu werfen (im eigenen Test zunächst selbst hineingetappt: alle 6 Quader-Seiten der Test-Fixture waren invertiert, dadurch schlugen 3 von 4 Boolean-Tests fehl, bis das Winding korrigiert wurde — reiner Test-Fixture-Bug, nicht im Produktivcode). + - Verifiziert: `cargo test` in `trucksolid` 11/11 grün (inkl. `wall_minus_embedded_extrusion_matching_cross_section`, `flush_touching_union_is_exact`), `cargo build --target wasm32-unknown-unknown --features web` sauber (csgrs + truck-modeling gemeinsam im selben WASM-Modul, keine Konflikte), `npm run build:truck` (echter `wasm-pack`-Build) sauber — `boolean_mesh` ist reell im generierten `.d.ts` exportiert. + - **Noch offen (bewusst NICHT gebaut, eigene Scope-Entscheidung nötig):** die eigentliche Verdrahtung in die Live-3D-Szene (WANN soll eine Wand automatisch um eine eingebundene Extrusion gekürzt werden? Bei jeder geometrischen Überlappung? Nur bei explizit markierten Fällen?) ist eine UX/Scope-Frage, keine technische — analog zum Feld-Controller-Rückstand unten nicht blind entschieden, sondern auf Nutzer-Entscheid wartend. Aktuell bleibt eine Extrusion weiterhin ein unabhängiges, nicht mit der Wand verschmolzenes Solid im 3D (wie seit Phase 2/3). + - [x] Phase 5: Verjüngung (Taper) — `taper` 0 (Prisma) … 1 (Spitze/Kegel-Pyramide), linear zum Profil-Schwerpunkt skaliert. Rust-Kern (`extrude_polygon_core`/`extrude_circle_core` in `lib.rs`), WASM/TS-Wrapper, dritte Befehls-Phase in `extrude.ts` (Höhe → Verjüngung, Enter für persistenten Default), `ExtrudedSolid.taper?`, Attribute-Panel-Feld (editierbar → Re-Extrusion). Tests: Kegel-Volumen (Divergenzsatz gg. analytisch), Pyramidenstumpf-Schwerpunkt-Skalierung, Range-Check. + - [x] **Deep-Review + Fixes (2026-07-07, nach Geräte-Absturz der vorherigen Session):** 8-Winkel-Review (Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) über den gesamten uncommitteten Stand ergab mehrere echte Bugs, alle gefixt: + - **Konkave Profile falsch trianguliert** (`trucksolid/src/lib.rs`): Deckel/Boden nutzten eine Fächer-Triangulierung ab Vertex 0 — korrekt nur für konvexe/von Vertex 0 aus sternförmige Polygone. Bei einem T-Träger (genau das im Plan genannte Zielprofil) erzeugte das nachweislich Phantom-Dreiecke quer durch die konkave Kerbe (nachgerechnet: 2 von 6 Deckel-Dreiecken lagen mit Schwerpunkt ausserhalb). Fix: Ohr-Clipping-Triangulierung (portiert von der bereits vorhandenen, robusten `render3d::mesh::triangulate`) ersetzt den Fächer; Schwerpunkt-Berechnung für die Verjüngung ebenfalls auf die korrekte flächen-gewichtete Formel umgestellt (vorher reiner Vertex-Mittelwert, bei asymmetrischen Profilen verzerrt). Neuer Regressionstest `t_beam_caps_stay_inside_polygon` (prüft, dass jeder Deckel-/Boden-Dreieck-Schwerpunkt im wahren Polygon liegt). `cargo test` trucksolid 15/15 grün. + - **Auswahl-Zustand (`selectedExtrudedSolidIds`) an 6 Stellen in `App.tsx` nicht zurückgesetzt**, wo alle anderen Auswahl-Arten es bereits werden: Geschosswechsel-Effekt, `onOpenStampEditor`, die vier alten three.js-Viewport-Pick-Handler (Wand/Treppe/Decke/Öffnung), `onViewport3dPick`. Ohne Fix: Extrusion bleibt nach Geschosswechsel/anderer Auswahl "geisterhaft" mit-selektiert (falsches Panel, Löschen träfe das falsche Objekt). + - **Mirror/Copy ignorierten eine reine Extrusions-Auswahl** (`hasSelection`/`toTransformSel` in `mirror.ts`/`copy.ts` kannten `extrudedSolidId` nicht, obwohl `transform.ts`/`move.ts` es längst unterstützen) → stiller No-op statt Spiegeln/Kopieren. + - **DXF-Export verlor den Layer von Extrusions-Footprints**: da `extrude` die Quell-Zeichnung (mit `categoryCode`) entfernt, fiel `layerFor()` in `exportDxf.ts` auf `LAYER_DEFAULT` zurück. Fix: eigener `EXTRUSION`-Layer (gleiches Orange wie 2D/3D-Darstellung). + - **`truckMeshCache` (toWalls3d.ts) ohne Eviction** — wuchs unbegrenzt über eine Session (Extrudieren→Kopieren→Löschen in Serie liess Mesh-Daten toter Körper im Speicher). Fix: gelöschte Ids werden bei jedem `emitExtrudedSolids`-Lauf aus dem Cache entfernt. **Gleichzeitig behoben:** sichtbares Flackern beim Tippen von Höhe/Verjüngung im Attribute-Panel (jeder Tastendruck = neue Signatur = Cache-Eintrag wurde sofort auf `mesh:null` gesetzt, bevor die neue Extrusion geladen war) — die alte Mesh bleibt jetzt sichtbar, bis die neue fertig ist. + - **`fitTargetDist` im 3D-Viewport** (Wasm3DViewport.tsx) rief bei JEDER Projekt-Änderung (auch während eines laufenden Griff-Drags) unnötig die komplette Wand-/Öffnungs-/Decken-Flatten-Pipeline auf, nur um eine Bounding-Box für die Schnittebene zu ziehen — die aber nur bei aktiver Schnittebene gebraucht wird. Fix: Aufruf hinter `sectionActive` gegated. + - **Nutzer-Report währenddessen:** Schnittebene-Umschalter (Wasm3DViewport.tsx) hatte `title`/`aria-label` hart auf Deutsch verdrahtet statt über `t()` — einzige Stelle im 3D-Viewport, die das i18n-System umging. Fix + Audit der ganzen App auf dasselbe Muster (title/aria-label/placeholder ausserhalb `t()`): genau eine weitere Stelle gefunden (`ColorHexField.tsx` Swatch-Tooltip „Farbe wählen"), beide jetzt über neue i18n-Keys (`viewport3d.section.*`, `attr.pickColor`). + - Verifiziert nach jedem Schritt: `tsc --noEmit` sauber, `cargo test` trucksolid grün. `vitest run`/`cargo test render3d` am Ende der ganzen Fix-Reihe erneut komplett gegengeprüft (339/339, 58/58, 15/15). + - [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt: - [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d` — `a78e7c7` - [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521` - [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf` - [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1` - - [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert. + - [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. ✅ **Phase 5 komplett (2026-07-07):** `opening` portiert (`wall_axis_length`/`opening_interval`/`wall_axis_frame`/`opening_jambs`/`opening_gap_quad`/`opening_center`/`door_symbol`/`window_symbol` + `along`-Helper, geflachte Signaturen nach PORT_PLAN; `openingVerticalExtent` bleibt bewusst TS — echte Modell-Kopplung an drawingLevels). +7 Parity-Tests (Suite 40 Parity). `cargo test` kernel2d 18/18, `build:kernel2d` sauber, vitest 348/348. Produktive Aufrufer unverändert (Phase 6 weiter offen/Nutzer-Entscheid). **Noch uncommittet.** - [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären. - **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung). @@ -49,6 +67,8 @@ ## 📋 Backlog (Priorität grob absteigend) +- [x] ~~**Shift-Ortho beim Körper-Verschieben (2D) fehlte**~~ — **erledigt 2026-07-06:** Nutzer-Report — Shift zum H/V-Einrasten wirkte beim Ziehen eines Vertex-Griffs (`onGripMove`) und beim freien Kanten-Zug (`onEdgeMove`), aber NICHT beim Verschieben eines ganzen Elements per Körper-Griff (`onMoveBody` — Wand/2D-Element/Decke/Treppe/Raum als Ganzes): `PlanView.tsx` reichte `mods` an dieser einen Stelle schlicht nicht durch. Fix: `GripHandlers.onMoveBody` bekommt optionales drittes Argument `mods: ToolMods`, `App.tsx` zwingt bei `mods.shift` das Delta auf die dominante Achse (H/V) — dieselbe Regel wie beim freien Kanten-Zug. `tsc`/`vitest` 339/339 grün. +- [ ] **Feld-Controller (getippte Länge/Winkel relativ zum Ausgangspunkt) fehlt beim Körper-Verschieben + im 3D.** Nutzer-Wunsch 2026-07-06: „allgemein brauchen wir ein System bei 2D- und 3D-Elementen, dass wenn man einen Punkt anwählt, man ihn verschiebt — mit der Option, Länge und Winkel relativ zur alten Position zu wählen." Bestandsaufnahme: **existiert bereits** für den 2D-Vertex-Griff-Drag (`gripEditRef`/„Feld-Controller", Tab öffnet/zykelt Länge↔Winkel-Sperre, `gripEditPoint` in `App.tsx`) — fehlt aber (a) beim 2D-Körper-Verschieben (`onMoveBody`, ganzes Element ziehen) und (b) komplett im neuen 3D-Griffsystem (`Wasm3DViewport.tsx`, s. „3D-Griffe/Editieren" oben — weder Vertex- noch Verschiebe-Griff haben dort eine Locks/HUD-Eingabe). Für (b) zusätzlich zu klären: wie eine getippte Zahl im 3D-Viewport erfasst wird, ohne mit der freien Maus-Navigation (Orbit/Pan) zu kollidieren — eigenes UI-Element (z. B. ein kleines Eingabefeld neben dem gezogenen Griff-Button) naheliegend, aber nicht mit dem 2D-HUD-Muster identisch übertragbar. Mit Nutzer Scope/Reihenfolge klären (2D-Körper zuerst, da mechanische Erweiterung des bestehenden Feld-Controllers; 3D danach als grössere UI-Frage). - [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten: - [x] ~~**Kreis-Toolbar** (trivial)~~ — **erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als ``.) - [x] ~~**Bogen-Werkzeug** (mittel)~~ — **erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334). @@ -75,12 +95,53 @@ - **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen. - **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung. - [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~ — **bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden. -- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers. +- [x] ~~**Einstellungs-Fenster (Rest)**~~ — **erledigt:** Verdrahtung war schon **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`); der einzig offene Punkt (Snap/Endpunkt-Default „aki") ist längst entschieden (2026-07-04: Sora #5FA1C9, s. „❓ Offene Rückfragen"). Zeile war stehen geblieben, obwohl die Frage schon geschlossen war. - [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`). +- [x] ~~**Tragwerk-Start: Stützen (Column) — MVP end-to-end**~~ (DOSSIER-Audit A4) — **erledigt 2026-07-07:** `Column`/`ColumnProfile` (rect/round) als platzierte Profil-Extrusion, `Project.columns`, `columnFootprint()` (gemeinsame Geometrie 2D/3D/Selektion/Transform), `columnVerticalExtent` (UK/OK-Anker wie Wand). Platzieren via BIM-Ribbon-Befehl (`column`, Alias stütze; Default Rect 0.3×0.3, Höhe=Geschosshöhe, Rechteck/Rund-Toggle + Live-Vorschau), 2D-Poché auf Kat. 50 (`addColumnPoche`, Component/Hatch-Auflösung), 3D-Prisma (`emitColumns`, synchron, Rust unberührt), eigener Selektionskanal (ALLE Reset-Stellen gespiegelt), `ColumnSection` (Profil/Masse/Höhe/Drehung editierbar), Löschen/move/copy/mirror, DXF-Layer, i18n. +8 Tests (2D-Position/Rotation, 3D-Box/Prisma, vertikale Lage). tsc/vitest 422. **Bewusst ausgelassen:** 3D-Pick (wie ExtrudedSolid), Marquee, Schedule-Zeile, eigene ToolId (Extrude-Muster gefolgt). **Noch uncommittet. Nutzer prüft 3D/2D visuell.** +- [x] ~~**Schnellexport-Dialog (Format + Dateiname) für Topbar-Export**~~ (Nutzer-Wunsch) — **erledigt 2026-07-07:** Klick auf CSV/IFC/OBJ/STL im Topbar-Export-Menü öffnet jetzt `ExportSaveDialog` (Format-Dropdown + Dateiname-Feld, Endung folgt dem Format, Enter/Speichern), statt sofort mit Default-Namen zu laden. PDF/DXF behalten ihre eigenen Options-Dialoge. `runExport(format, filename)` in App.tsx erzeugt+lädt. +i18n `exportSave.*`. tsc/vitest 434. **Offen (bewusst, Nutzer nannte es selbst als Folge):** echter Speicherort-Picker („wo") braucht den nativen Tauri-Save-Dialog (plugin-dialog/fs, Rust) — aktuell Download-Ordner + frei wählbarer Dateiname; die späteren „Ausschnitte" bekommen vordefinierten Namen+Ort. **Noch uncommittet.** +- [x] ~~**Nativer Tauri-Speichern-Dialog für Exporte**~~ (Nutzer „ausnahmsweise JA") — **erledigt 2026-07-07:** `@tauri-apps/plugin-dialog`+`plugin-fs` eingebunden (Cargo/lib.rs/capabilities `dialog:allow-save`+`fs:allow-write-text-file`, `cargo check` grün), Util `src/io/saveFile.ts` (`saveTextFile`: unter Tauri nativer „Speichern unter"-Dialog `save()`+`writeTextFile`, sonst Blob-Fallback mit octet-stream + verzögertem revoke; +2 Tests). App.tsx-`downloadTextFile` ruft jetzt `saveTextFile` (Import ergänzt). **Nutzer testet den nativen Dialog in Tauri.** **Scope-Hinweis:** falls fs-Scope-Fehler, `fs:scope` `$HOME/**` nachrüsten. **Noch uncommittet.** +- [x] ~~**Ausschnitte liessen sich nicht speichern (Tauri)**~~ — **erledigt 2026-07-08:** Ursache `window.prompt` ist im Tauri-WKWebView DEAKTIVIERT (→ null, kein Name, nichts gespeichert). `ViewSnapshotsPanel` nutzt jetzt In-App-Inline-Eingaben statt prompt/confirm (Speichern + Umbenennen inline, Löschen ohne confirm). tsc/vitest 465. +- [x] ~~**Layouts als Ordner-/Baumstruktur**~~ — **erledigt 2026-07-08:** Modell additiv `LayoutFolder{id,name,parentId?}` + `Layout.folderId?` + `Project.layoutFolders?`; freie Grösse `customWidthMm?`/`customHeightMm?` an Layout UND MasterLayout (übersteuert paper/orientation), zentraler Helfer `effectiveSheetSizeMm()`. Pure Logik `layoutModel.ts`: `buildLayoutTree`, `collectFolderLayouts`, `deleteFolder` (Kinder reparent auf Elternebene), `folderPdfPages`. `LayoutsPanel.tsx` als Baum (auf/zuklappbare Ordner, Master als eigener Vorlagen-Abschnitt), EIN „+"-Dropdown {Ordner·Layout·Masterlayout}, neues Element landet SOFORT im Inline-Rename (ViewSnapshotsPanel-Muster, kein window.prompt). Zwei Erstell-Dialoge (`LayoutCreateDialogs.tsx`): Layout mit Master-Vorlage-Dropdown ODER freie Grösse (A4/A3/Custom-mm); Masterlayout mit Format/freie Grösse+Ausrichtung. **Ordner→Mehrseiten-PDF funktioniert:** `src/export/layoutPdf.ts` (`buildFolderPdf`/`saveFolderPdf`, jsPDF, `addPage` je Layout mit eigener Grösse, Viewports via generatePlan→planToPrintSvg, Master-Titelblock als Vektor), neuer `saveBinaryFile` in `saveFile.ts` (nativer Speichern-Dialog für PDF-Bytes). +14 Tests (Custom-Grösse, Ordner-CRUD, Baumaufbau inkl. verwaister Refs, Reparenting, PDF-Seitenplan). **Nachtrag (Orchestrator):** `LayoutSheet.tsx` (In-Viewport-Editor, vom Agent bewusst nicht angefasst) nutzte noch `sheetSizeMm(layout.paper,...)` ohne Custom-Grösse zu respektieren — auf `effectiveSheetSizeMm(master ?? layout)` umgestellt (identische Regel wie der PDF-Export), damit Blätter mit freier Grösse auch im Viewport korrekt dargestellt werden. tsc/vitest 505. **Noch uncommittet. Nutzer prüft visuell in Tauri** (+-Menü, Ordner-Zuordnung, Dialoge, Mehrseiten-PDF-Reihenfolge/Grössen). +- [x] ~~**Icons/A0-A6+B0-B6/aktiver Ordner/Drag&Drop im Layouts-Baum**~~ — **erledigt 2026-07-08:** eigene inline-SVG-Icons (`FolderIcon` auf/zu, `LayoutSheetIcon`, `MasterSheetIcon` mit Stern-Akzent), Muster wie die bestehenden Tab-Icons. `LayoutPaperFormat` volle ISO-216-Reihe A0–A6+B0–B6 (`PAGE_MM`/`PAPER_FORMATS`/`PAPER_FORMAT_GROUPS`), Dropdowns gruppiert A-/B-Reihe. „Aktiver Ordner" war schon korrekt (verifiziert, keine Änderung nötig). HTML5-Drag&Drop (Layout/Ordner zwischen Ordnern, Root-Drop, Ziel-Highlight) mit Zyklus-Schutz (`isDescendant`, `moveFolderToFolder` verwirft Selbst-Verschachtelung als No-op). `layoutPdf.ts`/`LayoutSheet.tsx` bekommen die neuen Formate automatisch (laufen über `effectiveSheetSizeMm`/`PAGE_MM`, kein Hardcoding). +14 Tests. tsc/vitest 519. **Noch uncommittet. Nutzer prüft visuell.** +- [x] ~~**Ausschnitte-Panel: Footer-Bar + Anwahl-Persistenz**~~ — **erledigt 2026-07-08 (Agent starb erst beim Schluss-Testlauf, aber vollständig verdrahtet + grün):** Footer im ViewSnapshotsPanel zeigt bei angewähltem Ausschnitt Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal `boolMapEqual`/`matchingComboName` gegen gespeicherte Combos), aktive Override-Namen, + Inline-Rename in der Footer-Bar. Anwahl bleibt markiert bis Abweichung: `selectedViewSnapshotId`-State + `snapshotMatchesLiveState`-Vergleich in App.tsx (bei Änderung eines erfassten Feldes → Auswahl weg). host.ts um `selectedViewSnapshotId`/`listLayerCombos`/`loadLayerCombo`/`listDrawingCombos`/`loadDrawingCombo` erweitert. tsc sauber, vitest 532 (+13). **Noch uncommittet. Nutzer prüft visuell.** +- [x] ~~**Ausschnitte-Panel: Ordner wie bei Layouts**~~ — **erledigt 2026-07-08:** echte Ordner-/Baumstruktur (spiegelbildlich zu Layouts). Modell additiv `ViewSnapshotFolder{id,name,parentId?}` + `ViewSnapshot.folderId?` (altes `folder?`-Namensfeld bleibt LEGACY-kompatibel), `Project.viewSnapshotFolders?`. Pure Baum-Logik `src/state/viewSnapshotFolders.ts` (`buildViewSnapshotTree`/`moveSnapshotToFolder`/`moveFolder` mit Zyklus-Schutz/`deleteFolder`-Reparent, +20 Tests). Panel: auf/zuklappbare Ordner, zwei Header-Buttons (FolderPlus/Plus), aktiver-Ordner-State, Inline-Rename, Löschen, Drag&Drop. **Footer-Bar + Anwahl-Persistenz UNVERÄNDERT erhalten** (unter dem Baum). +i18n. tsc/vitest 558. **Noch uncommittet. Nutzer prüft visuell.** +- [x] **2D-Zeichnen auf „Zeichnung"-Ebenen UND auf Layout-Blättern freigeben; 3D/BIM dort sperren (Nutzer-Wunsch 2026-07-08):** ✅ **ALLE drei Teile erledigt 2026-07-08.** **TEIL B erledigt 2026-07-08:** 2D-Annotationen (Linie/Rechteck/Text in mm-Papier) direkt auf Layout-Blättern — `LayoutAnnotation` an `Layout` (additiv) + CRUD (`layoutModel.ts`: create/add/remove/patchAnnotation) + Editor in `LayoutSheet.tsx` (Tool-Buttons, zeichnen/Vorschau/commit, `pickAnnotation`/`translateAnnotation` in `layoutSheetMath.ts`, Selektion+Verschieben+Löschen, HUD mit Farbe/Stärke, `PromptDialog` für Text — kein window.prompt) + Handler in App.tsx. Tests: `layoutModel.test.ts` + `layoutSheetMath.test.ts`, Suite 588 grün. Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping, Sheet-Clamping beim Verschieben. **TEIL A erledigt 2026-07-08** — die 2D-Zeichenwerkzeuge (select/line/polyline/rect/circle/arc/text) sind bereits nicht `floorOnly`, die zugehörigen Commands (line/rect/polyline/circle/arc/text) auch nicht → die Command-Engine erlaubt das Zeichnen auf jeder Ebene, und eine `"drawing"`-Ebene rendert schon die volle interaktive `LevelPlanView` (App.tsx:5515). Der einzige echte Blocker war das UI-Gating: die **ToolsPanel-Sidebar** sperrte ALLE Werkzeuge ausser „select", sobald `!toolsEnabled` (App.tsx `activeLevel.kind === "floor"`). Fix: `ToolsPanel.tsx` gatet jetzt per `tool.floorOnly && !toolsEnabled` — **Parität mit dem Ribbon** (`RibbonBar.tsx`, das schon so gatete). Damit sind auf „drawing"-Ebenen die 2D-Tools nutzbar, BIM/3D-Tools (wall/ceiling/window/door/stair/room/column, alle `floorOnly`) bleiben grau. `AttributesPanel.tsx:78` bleibt unverändert (gatet nur den Wandtyp/Deckentyp-Picker, der ohnehin nur bei floorOnly-Tools erscheint). Verifiziert: `tsc` sauber, `vitest` 558/558. TEIL B (grösser, OFFEN): **auf Layout-Blättern 2D zeichnen** — der `LayoutSheet`-Viewport-Editor kann bisher nur Viewports platzieren; für Annotationen (Linien/Text/Rechtecke direkt aufs Blatt) fehlt ein Annotations-Datenmodell am `Layout` + das Zeichnen/Rendern auf dem Blatt (mm-Koordinaten). Scope Teil B mit Nutzer/als eigene Phase. **TEIL C erledigt 2026-07-08 („endlich"): Text-Platzierungswerkzeug als 2D-Element.** Das `{shape:"text"}`-Drawing2D-Modell + Rendering (`generatePlan` → `drawingText`) + Transform (`transform.ts` `at`) existierten schon (DXF-Import-Weg); es fehlte nur das PLATZIER-Werkzeug. Umgesetzt: neues `text`-Command (`src/commands/cmds/text.ts`: Ankerpunkt klicken → Freitext in der Command-Line eingeben → commit `Drawing2D {shape:"text", height:0.25m, angle:0}`), Placeholder-Tool `text` (nicht floorOnly) gekoppelt via `TOOL_COMMAND`, in Registry + Aliase (tx/txt/beschriftung) + `ToolId` + `TOOL_ORDER` + Ribbon-2D-Gruppe + ToolIcon (Serifen-A) + i18n (de/en). Neu: die Command-Engine routet jetzt Freitext-Eingabe (`AcceptKind` um `"text"` erweitert, `engine.ts` `routeTypedInput` reicht bei `accepts:["text"]` die getippte Zeile 1:1 durch — vor der numerischen Auflösung, damit auch Zahlen/Kommas als Label ankommen). Nutzer-Test: Text-Tool wählen → in den Plan klicken → Label tippen → Enter. **Noch uncommittet, WASM-unabhängig (rein TS).** +- [ ] **Masterlayout im Viewport ansehen + Elemente darauf platzieren (Nutzer-Wunsch 2026-07-08) — NACH Rollback+Footer-Agenten (gleiche Dateien LayoutsPanel/LayoutSheet):** ein Masterlayout soll wie ein Layout im `LayoutSheet`-In-Viewport-Editor geöffnet werden können, um Plankopf-Elemente/Rahmen VISUELL darauf zu platzieren (statt nur über ein Formular mit festen Titelblock-Textfeldern). `LayoutSheet.tsx` arbeitet aktuell nur mit `Layout` (hat `viewports: LayoutViewport[]`); für Master fehlt sowohl das Öffnen im Viewport als auch ein Datenmodell für frei platzierbare Grafik-/Text-Elemente (Plankopf-Felder, Rahmen-Linien) auf dem Master. Scope/Umfang mit Nutzer klären, bevor gebaut wird: welche Elementarten (Text-Feld mit Platzhalter-Variable wie {ProjectName}/{Scale}/{Date}, Linie/Rechteck als Rahmen, Logo/Bild?), wie sie sich von den normalen `LayoutViewport`s unterscheiden (kein Ausschnitt-Bezug, rein grafisch), Doppelklick-Navigation vom `LayoutsPanel` aus zum Öffnen eines Masters im Viewport (analog `onOpenLayout`). +- [x] ~~**LayoutsPanel: Doppel-Name + zwei Buttons je Abschnitt + Master in Ordnern**~~ — **erledigt 2026-07-08:** Doppel-„LAYOUTS" behoben durch Umbenennung des ÄUSSEREN Panel-Titels auf „Mappe" (DE) / „Portfolio" (EN, neuer Key `layouts.panelTitle`); innere Abschnitte bleiben „Layouts"/„Masterlayouts". Kombiniertes „+"-Dropdown ersetzt durch zwei Icon-Buttons je Abschnitt: `FolderPlusIcon` (Ordner) + `PlusIcon` (Layout bzw. Masterlayout). Masterlayouts jetzt in eigenen Ordnern: `MasterLayout.folderId?` + `LayoutFolder.kind?:"layout"|"master"` (fehlt=layout, rückwärtskompatibel), getrennte Bäume (`buildMasterTree`, kind-Filter), eigener `activeMasterFolderId`. Bug gefixt: `deleteFolder`/`moveFolderToFolder` verloren `kind` beim Reparent → `stripParentId`. +6 Tests. PanelFrame unangetastet. tsc/vitest 538. **Bewusst offen:** Drag&Drop im Master-Baum (bestehende Master nur beim Anlegen in Ordner). **Noch uncommittet. Nutzer prüft visuell.** +- [x] ~~**Fixer, unlöschbarer „Masterlayout"-Ordner**~~ — **VERWORFEN 2026-07-08 (Nutzer-Kurskorrektur):** gebaut, dann sofort zurückgebaut — Nutzer sah die bestehende Lösung (Masterlayouts als separate Kategorie/Abschnitt ausserhalb der Ordnerstruktur, aus dem vorherigen Layouts-Agenten) live und wollte sie explizit BEHALTEN, keinen Ordner. Vollständig zurückgerollt (`layoutModel.ts`/`LayoutsPanel.tsx`/`App.tsx`/i18n), verifiziert tsc sauber + vitest exakt Baseline 519 (keine Regression). Masterlayouts bleiben separater Abschnitt. +- [x] ~~**`window.prompt`/`confirm` überall ersetzen (Tauri-WKWebView deaktiviert sie) — SWEEP**~~ — **erledigt 2026-07-08:** neue generische `src/ui/PromptDialog.tsx` (Muster ExportSaveDialog: Overlay+Dialog, Enter bestätigt, Esc schliesst) ersetzt ALLE verbliebenen `window.prompt`-Aufrufe: `ComboMenu` (TopBar, Ebenen-/Zeichnungskombination speichern), `LayoutMenu` (Arbeitsumgebung speichern), Text-Grösse „frei" (`TextGroup`), Massstab „frei" (`ViewRibbonTab`), sowie die Tastatur-Flows O/P (Array-Kopien/Verteilen-Anzahl, `App.tsx`: `promptCount()` entfernt, ersetzt durch `pendingCopyMode`-State + Dialog). `window.confirm` war bereits an allen Fundstellen vorher entfernt (ViewSnapshots/LayoutsPanel). Verifiziert: `grep -rn 'window\.prompt(\|window\.confirm('` über src/ liefert NICHTS mehr. tsc sauber, vitest 491/491. **Noch uncommittet.** +- [x] ~~**Layout im Viewport editierbar (wie ArchiCAD/VW/OpenCADStudio) — Phase 2b**~~ — **erledigt 2026-07-08 (Agent-verifiziert, NICHT visuell abgenommen):** Blatt jetzt erstklassiger Ansichtsmodus im Haupt-Viewport (`activeLayoutId` in App.tsx → `LayoutSheetView` statt Content), schwebendes `LayoutEditor`-Fenster ENTFERNT. Maus-Editing (`LayoutSheet.tsx`): Viewport aufziehen→Ausschnitt-Bindung (Inline-Dropdown, kein prompt), auswählen/verschieben/8-Griff-Resize (Commit bei Pointer-Up, Live-Ghost), löschen (Entf), Massstab-HUD; Pan (Mitte/Space)/Zoom-to-Cursor/Einpassen. Pure Transform+Hit-Test `layoutSheetMath.ts` (+26 Tests). `LayoutsPanel` window.prompt/confirm ebenfalls auf Inline umgestellt. +i18n `layouts.*`, CSS `.layout-sheet-*`. tsc/vitest 491, vite build OK. **NOCH VISUELL IN TAURI ZU PRÜFEN.** Offen: Einrasten an Kanten/Nachbarn, Ribbon-Massstab wirkt im Layout-Modus nicht (nicht deaktiviert). **Noch uncommittet.** +- [x] ~~**Layout-Blätter mit Masterlayout — Phase 2a**~~ (A3, setzt auf Ausschnitte auf) — **erledigt 2026-07-08:** Modell `Layout`/`LayoutViewport`/`MasterLayout` + `Project.layouts`/`masterLayouts` (im Dokument). `LayoutsPanel` (Layouts+Master anlegen/umbenennen/löschen, im Rechts-Dock, `LAYOUT_VERSION` 10→11). `LayoutEditor` (schwebendes Fenster wie ResourceManager): Blatt im mm-Seitenverhältnis (Zoom/Einpassen), Titelblock (Master-Vererbung via `resolveTitleBlock`, leere Felder → Defaults Projektname/Blattname/Massstab/Datum), und jeder Viewport rendert ECHT den Plan seines gebundenen Ausschnitts via `generatePlan`→`planToPrintSvg` (viewBox-mm = Viewport-mm, 100%-Einpassung + Clipping). Viewport hinzufügen (Ausschnitt-Picker)/entfernen, Position/Grösse/Massstab numerisch; Papier/Ausrichtung/Master pro Blatt. +16 Tests (Blatt-Geometrie, immutables CRUD, Master-Vererbung, mm-Geometrie). +i18n `layouts.*`. **Ehrliche Grenzen:** Live-Viewport-Render nur per Code korrekt, NICHT im Browser abgenommen (Nutzer prüft); Ausschnitte auf Schnitt/Ansicht (activeLevelId≠Geschoss) → leerer Plan (nur Grundriss-Ausschnitte füllen den Viewport); Titelblock-Font ≠ PDF-Helvetica (Phase-2b-PDF baut es separat). **Bewusst Phase 2b:** Multi-Page-PDF-Export, Maus-Drag/Resize der Viewports, „Alle aktualisieren", erweiterte Kamera-POV (Augen-/Zielhöhe). tsc/vitest 465. **Noch uncommittet.** +- [x] ~~**Ausschnitte / View-Snapshots — Phase 1**~~ (A2, Fundament) — **erledigt 2026-07-07:** `ViewSnapshot` (name/folder + viewType/view3d/fov/scaleDenominator/detail/activeLevelId + Ebenen-/Zeichnungs-Sichtbarkeit + aktive Override-Ids) am `Project.viewSnapshots` (im Dokument). Pure Capture/Apply-Logik `src/state/viewSnapshots.ts` (+8 Tests: Roundtrip, defensives Auffüllen, Override-Menge). App.tsx: Capture sammelt View-State + `snapshotLayer/DrawingVisibility` + aktive Overrides; Apply Reihenfolge Geschoss→View→Sichtbarkeit→Overrides, Massstab-Zoom per 1 rAF. Neues `ViewSnapshotsPanel` (Liste/Ordner, Speichern/Umbenennen/Löschen), registriert im Default-Rechts-Dock (`LAYOUT_VERSION` 9→10). +i18n `viewsnap.*`. **Bewusst offen Phase 2:** Layout-Blätter+Masterlayout (A3), Viewport-Platzierung + Ausschnitt-Bindung, „Alle aktualisieren", Multi-Page-PDF, erweiterte Kamera-POV (Augen-/Zielhöhe getrennt), Ordner-Drag&Drop; Apply erzeugt mehrere Undo-Schritte (bekannt). **Noch uncommittet.** +- [x] ~~**IFC-Wände nicht solid (oben/unten offen)**~~ — **erledigt 2026-07-07:** ECHTE Ursache war eine invertierte Wicklung, NICHT eine fehlende Fläche — der Y-up→Z-up-Achsen-Swap im IFC-Export (`(mx,my,mz)→(mx,mz,my−base)`) ist eine Reflexion (Determinante −1) und kehrte jedes Dreieck um → Normalen nach innen → Viewer cullt Vorderseiten → hohl. Fix: beim Swap Dreieck umdrehen (`i0,i2,i1`). `Closed=.T.` gesetzt, verifiziert über `isWatertight` (`wallMeshCut.ts`): NICHT der naive „jede Kante von 2 Dreiecken"-Test (der meldet die legitimen T-Stösse aus vollen Deckel/Boden-Streifen fälschlich als offen), sondern das T-Stoss-robuste Gauss-/Divergenz-Kriterium `∮n dA=0` + signiertes Volumen >0. STL/OBJ waren korrekt (kein Swap). +15 Tests (Wasserdicht/Winding, inkl. IFC-Face-Set-Volumen aus dem geparsten SPF). tsc/vitest 449, cargo check grün. **Noch uncommittet. Nutzer prüft im Viewer.** +- [x] ~~**Exporte an 3D angleichen: Öffnungen ausschneiden + Joins (STL/OBJ/IFC)**~~ (Nutzer: „es sollte so raus wie es im 3d ist") — **erledigt 2026-07-07:** neuer TS-Helfer `src/plan/wallMeshCut.ts` portiert `render3d/mesh.rs::extrude_layer_segment_with_holes` 1:1 (Koordinaten-Kompression `solidSubrects`, Langseiten/Deckel/Boden/Stirnkappen minus Loch-Intervalle, bis zu 4 Laibungsquads pro Loch, konsistentes Winding). STL/OBJ: Wände mit `holes` → ausgeschnittenes Mesh (sonst klassische Box); Joins kommen aus `pickGeometry`. IFC: Wände als `IfcTriangulatedFaceSet` (IFC4, gespeist aus demselben Loch-Mesh) → sichtbar wie 3D in JEDEM Viewer (behebt „Void nicht subtrahiert"); Tür/Fenster bleiben eigene `IfcDoor`/`IfcWindow`-Objekte, die das Loch füllen; IfcOpeningElement/Void/Fill entfallen (Loch steckt im Mesh). +12 Geometrie-Tests (Loch-Region hat keine Voll-Wand-Dreiecke, Laibungen vorhanden, dangling-refs grün). tsc/vitest 434. **Ehrliche Rest-Lücken:** echte Miter-Gehrungsflächen (Export nutzt die achsparallele pickGeometry-Näherung), Schicht-Farben (IFC-FaceSet ohne per-Vertex-Farbe/IfcStyledItem), Fensterglas/Rahmendetail. **Noch uncommittet. Nutzer prüft im Viewer.** +- [x] ~~**Layout-Auswahl in die Einstellungen, umbenannt „Arbeitsumgebung"**~~ — **erledigt 2026-07-07:** `LayoutMenu` (Fenster-/Dock-Layouts) aus der TopBar-Zeile in `SettingsDialog` verschoben (neue erste Sektion), `layoutMenu`-Prop von TopBar → SettingsDialog umgehängt (ReactNode-Import in TopBar entfernt, da sonst ungenutzt). i18n `layout.*` umbenannt (DE „Arbeitsumgebung", EN „Workspace") + neue `settings.section.workspace`. tsc/vitest 423. **Noch uncommittet.** +- [x] ~~**Export-Sammelmenü + Über-Dialog + Petrol-Punkt (TopBar-Feinschliff)**~~ — **erledigt 2026-07-07:** (1) Die 6 einzeln aufgereihten Export-Icons (PDF/DXF/CSV/IFC/OBJ/STL) zu EINEM „Export"-Knopf (`ExportMenu`, `ios_share`) mit Dropdown-Popover zusammengefasst — entschlackt die Chrome-Zeile; Import bleibt separat. (2) Klick auf die Wortmarke „dossier" öffnet einen In-App-„Über"-Dialog (`AboutDialog`: Marke, Version 0.1.0, Kurzbeschreibung, OSS-Lizenzen; Esc/Klick-ausserhalb schliesst). Wortmarke ist jetzt ` + + + {open && ( +
+
+ + host.onPatchMasterLayout(master.id, { + paper: p, + customWidthMm: undefined, + customHeightMm: undefined, + }) + } + onOrientation={(o) => + host.onPatchMasterLayout(master.id, { + orientation: o, + customWidthMm: undefined, + customHeightMm: undefined, + }) + } + /> + +
+ {field("projectName", t("layouts.tb.projectName"))} + {field("sheetName", t("layouts.tb.sheetName"))} + {field("scale", t("layouts.tb.scale"))} + {field("date", t("layouts.tb.date"))} + {field("author", t("layouts.tb.author"))} +
+ )} + + ); +} + +export function LayoutsPanel() { + const host = usePanelHost(); + const layouts = host.project.layouts ?? []; + const masters = host.project.masterLayouts ?? []; + const folders = host.project.layoutFolders ?? []; + // Getrennte Bäume je `kind` — Layout- und Master-Ordner werden NIE vermischt. + const layoutFolders = folders.filter((f) => f.kind !== "master"); + const masterFolders = folders.filter((f) => f.kind === "master"); + const tree = buildLayoutTree(layoutFolders, layouts); + const masterTree = buildMasterTree(masterFolders, masters); + + // Aktiver Ordner = Ziel neuer Elemente (Klick auf eine Ordnerzeile). Null = Wurzel. + const [activeFolderId, setActiveFolderId] = useState(null); + // Aktiver Master-Ordner (eigener Zustand — Ziel neuer Master/Master-Ordner). + const [activeMasterFolderId, setActiveMasterFolderId] = useState(null); + // Auf-/zugeklappte Ordner. + const [collapsed, setCollapsed] = useState>(new Set()); + // Inline-Rename (welche Zeile + Entwurf). + const [renaming, setRenaming] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + // Offener Erstell-Dialog. + const [dialog, setDialog] = useState(null); + // Drag&Drop: gezogenes Element + gerade überfahrenes Drop-Ziel (Ordner-Id + // oder "root" für die Wurzel-Ablagefläche). + const [dragItem, setDragItem] = useState<{ + kind: "layout" | "folder"; + id: string; + } | null>(null); + const [dragOverId, setDragOverId] = useState(null); + + const endDrag = () => { + setDragItem(null); + setDragOverId(null); + }; + const dropOnFolder = (targetFolderId: string) => { + if (!dragItem) return; + if (dragItem.kind === "layout") { + host.onMoveLayoutToFolder(dragItem.id, targetFolderId); + } else if (dragItem.id !== targetFolderId) { + // Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op). + host.onMoveFolderToFolder(dragItem.id, targetFolderId); + } + expandFolder(targetFolderId); + endDrag(); + }; + const dropOnRoot = () => { + if (!dragItem) return; + if (dragItem.kind === "layout") host.onMoveLayoutToFolder(dragItem.id, null); + else host.onMoveFolderToFolder(dragItem.id, null); + endDrag(); + }; + + const isExpanded = (id: string) => !collapsed.has(id); + const toggleFolder = (id: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const expandFolder = (id: string) => + setCollapsed((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + + const beginRename = (id: string, kind: RenameKind, currentName: string) => { + setRenaming({ id, kind }); + setRenameDraft(currentName); + }; + const commitRename = () => { + if (!renaming) return; + const name = renameDraft.trim(); + if (name) { + if (renaming.kind === "folder") host.onRenameLayoutFolder(renaming.id, name); + else if (renaming.kind === "layout") host.onRenameLayout(renaming.id, name); + else host.onRenameMasterLayout(renaming.id, name); + } + setRenaming(null); + }; + const cancelRename = () => setRenaming(null); + const renamingRow = (id: string, kind: RenameKind) => + renaming?.id === id && renaming.kind === kind; + + // ── Anlegen (zwei getrennte Buttons je Abschnitt) ───────────────────────── + const addFolder = () => { + const id = host.onAddLayoutFolder(activeFolderId ?? undefined); + if (activeFolderId) expandFolder(activeFolderId); + beginRename(id, "folder", t("layouts.folder.defaultName")); + }; + const addMasterFolder = () => { + const id = host.onAddMasterFolder(activeMasterFolderId ?? undefined); + if (activeMasterFolderId) expandFolder(activeMasterFolderId); + beginRename(id, "folder", t("layouts.folder.defaultName")); + }; + const createLayoutFromDialog = (start: NewLayoutStart) => { + const id = host.onCreateLayout({ + folderId: activeFolderId ?? undefined, + masterId: start.masterId, + paper: start.paper, + orientation: start.orientation, + customWidthMm: start.customWidthMm, + customHeightMm: start.customHeightMm, + }); + if (activeFolderId) expandFolder(activeFolderId); + setDialog(null); + beginRename(id, "layout", t("layouts.defaultName")); + }; + const createMasterFromDialog = (start: LayoutSizeStart) => { + const id = host.onCreateMasterLayout({ + folderId: activeMasterFolderId ?? undefined, + paper: start.paper, + orientation: start.orientation, + customWidthMm: start.customWidthMm, + customHeightMm: start.customHeightMm, + }); + if (activeMasterFolderId) expandFolder(activeMasterFolderId); + setDialog(null); + beginRename(id, "master", t("layouts.defaultMasterName")); + }; + + // ── Baum-Zeilen ───────────────────────────────────────────────────────── + const LayoutRow = (layout: Layout, depth: number) => ( +
{ + e.stopPropagation(); + setDragItem({ kind: "layout", id: layout.id }); + e.dataTransfer.effectAllowed = "move"; + }} + onDragEnd={endDrag} + onClick={() => + renamingRow(layout.id, "layout") ? undefined : host.onOpenLayout(layout.id) + } + onDoubleClick={() => host.onOpenLayout(layout.id)} + style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }} + > + {renamingRow(layout.id, "layout") ? ( + + ) : ( + + + {layout.name} + + )} + + {(layout.customWidthMm && layout.customHeightMm + ? `${Math.round(layout.customWidthMm)}×${Math.round(layout.customHeightMm)}` + : layout.paper.toUpperCase())}{" "} + · {layout.viewports.length} + + + +
+ ); + + const FolderRowTree = (node: FolderNode, depth: number): React.ReactNode => { + const { folder } = node; + const expanded = isExpanded(folder.id); + const active = activeFolderId === folder.id; + const empty = node.layouts.length === 0 && node.subfolders.length === 0; + const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id; + const dragOver = dragOverId === folder.id && !isDragged; + return ( +
+
{ + e.stopPropagation(); + setDragItem({ kind: "folder", id: folder.id }); + e.dataTransfer.effectAllowed = "move"; + }} + onDragEnd={endDrag} + onDragOver={(e) => { + if (!dragItem) return; + e.stopPropagation(); + if (isDragged) { + if (dragOverId !== null) setDragOverId(null); + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOverId !== folder.id) setDragOverId(folder.id); + }} + onDragLeave={() => { + if (dragOverId === folder.id) setDragOverId(null); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + dropOnFolder(folder.id); + }} + onClick={() => setActiveFolderId(active ? null : folder.id)} + style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }} + > + + {renamingRow(folder.id, "folder") ? ( + + ) : ( + + + {folder.name} + + )} + + + +
+ {expanded && ( + <> + {node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))} + {node.layouts.map((l) => LayoutRow(l, depth + 1))} + {empty && ( +
+ {t("layouts.folder.empty")} +
+ )} + + )} +
+ ); + }; + + // ── Master-Baum-Zeilen (eigener Baum; kein Drag&Drop, kein PDF-Export) ───── + const renderMasterRow = (m: MasterLayout, depth: number) => ( + { + e.stopPropagation(); + beginRename(m.id, "master", m.name); + }} + commitRename={commitRename} + cancelRename={cancelRename} + /> + ); + + const MasterFolderRowTree = (node: MasterFolderNode, depth: number): React.ReactNode => { + const { folder } = node; + const expanded = isExpanded(folder.id); + const active = activeMasterFolderId === folder.id; + const empty = node.masters.length === 0 && node.subfolders.length === 0; + return ( +
+
setActiveMasterFolderId(active ? null : folder.id)} + style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }} + > + + {renamingRow(folder.id, "folder") ? ( + + ) : ( + + + {folder.name} + + )} + + +
+ {expanded && ( + <> + {node.subfolders.map((sub) => MasterFolderRowTree(sub, depth + 1))} + {node.masters.map((m) => renderMasterRow(m, depth + 1))} + {empty && ( +
+ {t("layouts.folder.empty")} +
+ )} + + )} +
+ ); + }; + + const treeEmpty = tree.rootFolders.length === 0 && tree.rootLayouts.length === 0; + const masterTreeEmpty = + masterTree.rootFolders.length === 0 && masterTree.rootMasters.length === 0; + + return ( +
+
+ {t("layouts.title")} + + + + +
+ + {treeEmpty ? ( +
+ {t("layouts.tree.empty")} +
+ ) : ( +
{ + if (!dragItem) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOverId !== "root") setDragOverId("root"); + }} + onDragLeave={(e) => { + // Nur löschen, wenn der Zeiger den Container wirklich verlässt. + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + if (dragOverId === "root") setDragOverId(null); + } + }} + onDrop={(e) => { + e.preventDefault(); + dropOnRoot(); + }} + > + {tree.rootFolders.map((node) => FolderRowTree(node, 0))} + {tree.rootLayouts.map((l) => LayoutRow(l, 0))} +
+ )} + + {/* Master-Sektion — eigener Ordner-Baum (Master-Ordner, kind:"master"). */} +
+ {t("layouts.masters")} + + + + +
+ {masterTreeEmpty ? ( +
+ {t("layouts.noMasters")} +
+ ) : ( +
+ {masterTree.rootFolders.map((node) => MasterFolderRowTree(node, 0))} + {masterTree.rootMasters.map((m) => renderMasterRow(m, 0))} +
+ )} + + {dialog === "layout" && ( + setDialog(null)} + /> + )} + {dialog === "master" && ( + setDialog(null)} + /> + )} +
+ ); +} + +/** Tab-Symbol „Layouts" — gestapelte Blätter. */ +export function LayoutsTabIcon() { + return ( + + ); +} diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index 7fc956e..ab65ad5 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -19,6 +19,7 @@ import { t } from "../i18n"; import { formatM } from "../model/types"; import type { SiaCategory, + SliceTermination, StairShape, VerticalAnchor, WallReferenceLine, @@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown"; import { usePanelHost } from "./host"; import type { CeilingInfo, + ColumnInfo, + ExtrudedSolidInfo, OpeningInfo, RoomInfo, StairInfo, @@ -68,11 +71,24 @@ export function ObjectInfoPanel() { 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. + // X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit + // verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die + // eingegebene Koordinate wandert (Delta = neu − aktuell → generischer Move + // über den Host). Bei Elementarten, die der Transform-Kern nicht kennt + // (Decke/Öffnung/Treppe/Raum), ist der Host-Aufruf ein No-op — das Feld + // zeigt danach wieder den unveränderten Wert (wie bei Breite/Höhe oben). const px = bbox.minX + anchor.fx * width; const py = bbox.minY + anchor.fy * height; + // Rohe Drawing2D-Geometrie der Selektion (nur bei kind==="drawing2d") — für + // die Linien-Länge/Kreis-Radius-Felder unten; die Selection-Sicht trägt + // keine Geometrieform, daher direkter (Lese-)Zugriff aufs Projekt. + const rawDrawing = + sel.kind === "drawing2d" + ? host.project.drawings2d.find((d) => d.id === sel.id) + : undefined; + const drawingShape = rawDrawing?.geom.shape; + // ── Maß-Commit ────────────────────────────────────────────────────────────── // Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven // Anker. Negative/ungültige Werte werden ignoriert (kein Resize). @@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
- {/* Bezugspunkt-Würfel (3×3). */} + {/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte + rechts daneben (Nutzer-Wunsch). */}
{t("objinfo.refpoint")}
-
- {FRACTIONS.map((fy) => - FRACTIONS.map((fx) => { - const active = anchor.fx === fx && anchor.fy === fy; - return ( - - ); - }), - )} -
+
+
+ {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")} - - — - + {/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein + Dreh-Feld, als Spalte. */} +
+ host.onMoveSelectionBy(nx - px, 0)} + /> + host.onMoveSelectionBy(0, ny - 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")} + + — + +
+ {/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt — kein + persistenter Zustand, das Feld zeigt nach dem Commit wieder „0". */} + host.onRotateSelectionAround(px, py, deg)} /> +
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() { value={height} onCommit={(h) => commitSize(width, h)} /> + {/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine + Linie/ein Kreis ist — das Modell gibt das direkt her, andere Formen + bleiben ohne Zusatzfeld). */} + {drawingShape === "line" && ( + host.onSetDrawingLineLength(v)} + /> + )} + {drawingShape === "circle" && ( + host.onSetDrawingCircleRadius(v)} + /> + )} {/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert die exportierten *Section-Komponenten). */} @@ -228,6 +274,153 @@ export function RoomSection({ ); } +// ── Extrusions-Attribut-Abschnitt (truck-Integration) ─────────────────────── +// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only. +export function ExtrudedSolidSection({ + solid, + host, +}: { + solid: ExtrudedSolidInfo; + host: ReturnType; +}) { + return ( + <> +
+
{t("objinfo.extrudedSolid.section")}
+ + {/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */} +
+ {t("objinfo.extrudedSolid.height")} + { + const v = Number(e.target.value); + if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v); + }} + title={t("objinfo.extrudedSolid.height")} + /> +
+ + {/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */} +
+ {t("objinfo.extrudedSolid.taper")} + { + const v = Number(e.target.value); + if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v); + }} + title={t("objinfo.extrudedSolid.taper")} + /> +
+ + {/* Grundfläche (read-only, abgeleitet). */} +
+ {t("objinfo.extrudedSolid.area")} + {solid.area.toFixed(2)} m² +
+ + {/* Geschoss (read-only). */} +
+ {t("objinfo.extrudedSolid.floor")} + {solid.floorName} +
+ + ); +} + +// ── Stützen-Attribut-Abschnitt (Tragwerk) ─────────────────────────────────── +// Profil (Rechteck/Kreis) + Masse, Höhe, Drehung editierbar; Geschoss + UK/OK +// read-only. +export function ColumnSection({ + column, + host, +}: { + column: ColumnInfo; + host: ReturnType; +}) { + return ( + <> +
+
{t("objinfo.column.section")}
+ + {/* Profil-Dropdown (Rechteck/Kreis). */} +
+ {t("objinfo.column.profile")} + host.onSetColumnProfileKind(v as "rect" | "round")} + options={[ + { value: "rect", label: t("objinfo.column.profile.rect") }, + { value: "round", label: t("objinfo.column.profile.round") }, + ]} + title={t("objinfo.column.profile")} + width={130} + /> +
+ + {/* Editierbare Masse je Profil. */} + {column.profile.kind === "round" ? ( + host.onSetColumnRadius(v)} + /> + ) : ( + <> + host.onSetColumnWidth(v)} + /> + host.onSetColumnDepth(v)} + /> + + )} + + host.onSetColumnHeight(v)} + /> + + {/* Drehung (Grad). */} +
+ {t("objinfo.column.rotation")} + { + const v = Number(e.target.value); + if (Number.isFinite(v)) host.onSetColumnRotation(v); + }} + title={t("objinfo.column.rotation")} + /> +
+ + {/* Geschoss (read-only). */} +
+ {t("objinfo.column.floor")} + {column.floorName} +
+ + ); +} + // ── Treppen-Attribut-Abschnitt ─────────────────────────────────────────────── // Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete // Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only. @@ -243,6 +436,24 @@ export function StairSection({
{t("objinfo.stair.section")}
+ {/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */} +
+ {t("objinfo.stair.type")} + host.onSetStairType(v)} + options={[ + { value: "", label: t("objinfo.type.none") }, + ...(host.project.stairTypes ?? []).map((st) => ({ + value: st.id, + label: st.name, + })), + ]} + title={t("objinfo.stair.type")} + width={130} + /> +
+ {/* Grundform-Dropdown. */}
{t("objinfo.stair.shape")} @@ -360,6 +571,24 @@ export function OpeningSection({
{t("objinfo.opening.section")}
+ {/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. */} +
+ {t("objinfo.opening.type")} + host.onSetOpeningType(v)} + options={[ + { value: "", label: t("objinfo.type.none") }, + ...(isDoor + ? host.project.doorTypes ?? [] + : host.project.windowTypes ?? [] + ).map((ty) => ({ value: ty.id, label: ty.name })), + ]} + title={t("objinfo.opening.type")} + width={130} + /> +
+ {/* Art-Segment Fenster/Tür. */}
{t("objinfo.opening.kind")} @@ -596,11 +825,15 @@ export function CeilingSection({ {ceiling.floorName}
- {/* Fläche (read-only). */} + {/* Fläche + Volumen (read-only). */}
{t("objinfo.ceiling.area")} {ceiling.area.toFixed(2)} m²
+
+ {t("objinfo.volume")} + {ceiling.volume.toFixed(2)} m³ +
@@ -614,11 +847,16 @@ export function CeilingSection({ onChange={(a) => host.onSetCeilingTop(a)} /> - {/* UK = OK − Dicke (abgeleitet, read-only). */} -
- {t("objinfo.ceiling.bottom")} - {formatM(ceiling.zBottom)} -
+ {/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override + bleibt es beim heutigen Verhalten UK = OK − Dicke. */} + host.onSetCeilingBottom?.(a)} + />
{t("objinfo.ceiling.height")} {formatM(ceiling.zTop - ceiling.zBottom)} @@ -678,6 +916,23 @@ export function WallSection({ />
+ {/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand + an der Decke endet oder oberhalb wieder auftaucht. Default "both". */} +
+ {t("objinfo.wall.sliceTermination")} + host.onSetWallSliceTermination(v as SliceTermination)} + options={[ + { value: "both", label: t("objinfo.wall.sliceTermination.both") }, + { value: "below", label: t("objinfo.wall.sliceTermination.below") }, + { value: "above", label: t("objinfo.wall.sliceTermination.above") }, + ]} + title={t("objinfo.wall.sliceTermination")} + width={120} + /> +
+ {/* Aufbau-Typ-Segment. */}
{t("objinfo.wall.buildup")} @@ -730,6 +985,26 @@ export function WallSection({ {wall.floorName}
+ {/* Länge + Volumen (read-only). Volumen ist NETTO — Fenster/Türen + abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */} +
+ {t("objinfo.wall.length")} + {formatM(wall.length)} +
+
+ {t("objinfo.volume")} + 0 + ? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} m³ − ${wall.openingVolume.toFixed(2)} m³` + : undefined + } + > + {wall.netVolume.toFixed(2)} m³ + +
+ {/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */} ); } + +// ── Dreh-Feld (Delta-Winkel in Grad) ───────────────────────────────────────── +// Anders als DimensionField zeigt dieses Feld KEINEN persistenten Host-Wert — +// eine „aktuelle Rotation" gibt es für die Selektion generell nicht (Wand ist +// nur eine Achse, Drawing2D/Extrusion tragen keinen Rotationswinkel). Das Feld +// ist daher ein Delta: Eingabe = Drehung ab jetzt, danach zeigt es wieder „0". +function RotateField({ onCommit }: { onCommit: (deg: number) => void }) { + const [draft, setDraft] = useState(null); + const shown = draft ?? "0"; + + function commit() { + if (draft === null) return; + const v = Number(draft); + setDraft(null); + if (isFinite(v) && v !== 0) onCommit(v); + } + + return ( + + ); +} diff --git a/src/panels/RoomStampEditor.tsx b/src/panels/RoomStampEditor.tsx index dacecf5..4be5012 100644 --- a/src/panels/RoomStampEditor.tsx +++ b/src/panels/RoomStampEditor.tsx @@ -183,6 +183,25 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro /> + +
+ +
diff --git a/src/panels/SitePanel.tsx b/src/panels/SitePanel.tsx index f834c20..89c9e26 100644 --- a/src/panels/SitePanel.tsx +++ b/src/panels/SitePanel.tsx @@ -18,6 +18,7 @@ import { usePanelHost } from "./host"; import type { ContextObject } from "../model/types"; import { ContextImportDialog } from "../ui/ContextImportDialog"; import { Dropdown } from "../ui/Dropdown"; +import { useStore } from "../state/appStore"; /** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */ function typeBadgeKey(obj: ContextObject): TranslationKey { @@ -33,6 +34,7 @@ function typeBadgeKey(obj: ContextObject): TranslationKey { export function SitePanel() { const host = usePanelHost(); + const notify = useStore((s) => s.notify); const objects = host.contextObjects; const contourSets = objects.filter((o) => o.type === "contourSet"); @@ -50,9 +52,9 @@ export function SitePanel() { 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")); + // Kein sinnvolles TIN — ruhiger Hinweis als Toast (keine harte Fehlerbox; + // window.alert wäre im Tauri-WKWebView ohnehin wirkungslos). + notify(t("site.terrainFailed"), "error"); } }; diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index d9cbc00..6fbbdfa 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -124,6 +124,24 @@ export function ToolIcon({ id }: { id: ToolId }) { ); + case "text": + // Serifen-„A" als Text-Symbol. + return ( + + + + + ); + case "textbox": + // Spalten-Rahmen mit drei Textzeilen (Absatztext). + return ( + + + + + + + ); default: return null; } @@ -140,7 +158,10 @@ export function ToolsPanel() { {TOOL_ORDER.map((id) => { const tool = getTool(id); const active = host.activeTool === id; - const disabled = id !== "select" && !host.toolsEnabled; + // Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss + // gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf + // Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar). + const disabled = !!tool.floorOnly && !host.toolsEnabled; const title = disabled ? t("tool.floorOnlyDisabled") : t(tool.hintKey(tool.init())); diff --git a/src/panels/ViewSnapshotsPanel.tsx b/src/panels/ViewSnapshotsPanel.tsx new file mode 100644 index 0000000..7c4b6d7 --- /dev/null +++ b/src/panels/ViewSnapshotsPanel.tsx @@ -0,0 +1,667 @@ +// Inhalts-Panel „Ausschnitte" — benannte, gespeicherte Ansichten (View-Snapshots, +// DOSSIER A2, ROADMAP §2c/§11) als ORDNER-/BAUM-Struktur (spiegelbildlich zum +// Layouts-Panel). +// +// Ein Ausschnitt fängt den kompletten Darstellungszustand ein (Ansichtstyp, +// Kamera-Preset, Massstab, Detailgrad, Ebenen-/Zeichnungssichtbarkeit, aktive +// Overrides, aktives Geschoss) und stellt ihn per Klick wieder her. Das Panel +// zeigt Ordner (auf-/zuklappbar) mit Ausschnitten als Blättern; zwei Header- +// Buttons legen einen Ordner bzw. einen neuen Ausschnitt im AKTIVEN Ordner an +// (Klick auf eine Ordnerzeile = aktiver Ordner; ohne Auswahl = Wurzel). Neue +// Ordner werden sofort inline umbenannt (autofocus/Enter — KEIN window.prompt, +// im Tauri-WKWebView deaktiviert). Ausschnitte/Ordner lassen sich per HTML5- +// Drag&Drop zwischen Ordnern verschieben (Zyklus-Schutz im Host). +// +// Die Footer-Bar (Massstab/Kombis/Overrides/Umbenennen bei angewähltem +// Ausschnitt) und die Anwahl-Persistenz (`selectedViewSnapshotId`) bleiben +// unverändert erhalten — sie hängen unter dem Baum. +// +// Reine Verdrahtung über den Host-Context (usePanelHost); die Erfassungs-/ +// Anwendungslogik lebt in App.tsx (Setter/rAF) bzw. state/viewSnapshots.ts, die +// pure Baum-/Ordner-Logik in state/viewSnapshotFolders.ts. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useMemo, useState } from "react"; +import { usePanelHost } from "./host"; +import type { ViewSnapshot } from "../model/types"; +import { + buildViewSnapshotTree, + type ViewSnapshotFolderNode, +} from "../state/viewSnapshotFolders"; +import { t } from "../i18n"; + +// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ────── + +/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */ +function TreeIcon({ children }: { children: React.ReactNode }) { + return ( + + ); +} + +/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */ +function FolderIcon({ open }: { open: boolean }) { + return ( + + {open ? ( + + ) : ( + + )} + + ); +} + +/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */ +function FolderPlusIcon() { + return ( + + + + + + + ); +} + +/** „Erstellen" — reines Plus (neuer Ausschnitt). */ +function PlusIcon() { + return ( + + + + + ); +} + +/** Ausschnitt-Blatt — Kamera-Sucherrahmen (passt zum Tab-Symbol). */ +function SnapshotIcon() { + return ( + + + + + + ); +} + +/** Ein Inline-Rename-Input (autoFocus/Enter/Escape/Blur). */ +function RenameInput({ + value, + onChange, + onCommit, + onCancel, +}: { + value: string; + onChange: (v: string) => void; + onCommit: () => void; + onCancel: () => void; +}) { + return ( + e.stopPropagation()} + onChange={(e) => onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onCommit(); + else if (e.key === "Escape") onCancel(); + }} + onBlur={onCommit} + /> + ); +} + +/** Deep-Equal zweier `Record`-Maps (gleiche Keys UND Werte). */ +function boolMapEqual( + a: Record, + b: Record, +): boolean { + const ka = Object.keys(a); + if (ka.length !== Object.keys(b).length) return false; + for (const k of ka) { + if (a[k] !== b[k]) return false; + } + return true; +} + +/** + * Sucht unter den gespeicherten Kombinationen (`names` + `load`) diejenige, + * deren Map EXAKT `target` entspricht — liefert den Namen oder `null`. Grundlage + * der Footer-Anzeige „welche Ebenen-/Zeichnungskombi war aktiv". + */ +function matchingComboName( + names: string[], + load: (name: string) => Record | null, + target: Record, +): string | null { + for (const name of names) { + const map = load(name); + if (map && boolMapEqual(map, target)) return name; + } + return null; +} + +/** Welche Zeilenart gerade inline umbenannt wird (Commit unterscheidet sich). */ +type RenameKind = "folder" | "snapshot"; +interface Renaming { + id: string; + kind: RenameKind; +} + +export function ViewSnapshotsPanel() { + const host = usePanelHost(); + const snaps = host.project.viewSnapshots ?? []; + const folders = host.project.viewSnapshotFolders ?? []; + + const tree = useMemo( + () => buildViewSnapshotTree(folders, snaps), + [folders, snaps], + ); + + // In-App-Eingabe statt window.prompt (im Tauri-WKWebView deaktiviert → gäbe + // null zurück, nichts würde gespeichert). `naming` = neuer Ausschnitt. + const [naming, setNaming] = useState(false); + const [draft, setDraft] = useState(""); + // Inline-Rename einer Baumzeile (Ordner ODER Ausschnitt) + Entwurf. + const [renaming, setRenaming] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + // Inline-Umbenennen DIREKT in der Footer-Bar (eigener Draft, damit Liste und + // Footer sich nicht ins Gehege kommen). `footerRenaming` = Bearbeitung aktiv. + const [footerRenaming, setFooterRenaming] = useState(false); + const [footerDraft, setFooterDraft] = useState(""); + + // Aktiver Ordner = Ziel neuer Ausschnitte/Ordner (Klick auf Ordnerzeile). Null = Wurzel. + const [activeFolderId, setActiveFolderId] = useState(null); + // Auf-/zugeklappte Ordner. + const [collapsed, setCollapsed] = useState>(new Set()); + // Drag&Drop: gezogenes Element + überfahrenes Drop-Ziel (Ordner-Id oder "root"). + const [dragItem, setDragItem] = useState<{ + kind: "snapshot" | "folder"; + id: string; + } | null>(null); + const [dragOverId, setDragOverId] = useState(null); + + // Der aktuell „angewählte" Ausschnitt (bleibt markiert, bis App bei einer + // Abweichung `selectedViewSnapshotId` auf null setzt). Footer-Bar nur sichtbar, + // solange dieser existiert. + const selectedId = host.selectedViewSnapshotId; + const selectedSnap = useMemo( + () => (selectedId ? snaps.find((s) => s.id === selectedId) ?? null : null), + [selectedId, snaps], + ); + + // Footer-Auflösung: Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal auf + // die gespeicherte Map) und die Namen der aktiven Override-Regeln. + const footer = useMemo(() => { + if (!selectedSnap) return null; + const layerCombo = matchingComboName( + host.listLayerCombos(), + host.loadLayerCombo, + selectedSnap.layerVisibility, + ); + const drawingCombo = matchingComboName( + host.listDrawingCombos(), + host.loadDrawingCombo, + selectedSnap.drawingVisibility, + ); + const ids = new Set(selectedSnap.enabledOverrideRuleIds ?? []); + const overrideNames = (host.project.overrideRules ?? []) + .filter((r) => ids.has(r.id)) + .map((r) => r.name); + return { layerCombo, drawingCombo, overrideNames }; + }, [selectedSnap, host]); + + // ── Auf-/Zuklappen ──────────────────────────────────────────────────────── + const isExpanded = (id: string) => !collapsed.has(id); + const toggleFolder = (id: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const expandFolder = (id: string) => + setCollapsed((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + + // ── Inline-Rename (Baumzeilen) ────────────────────────────────────────────── + const beginRename = (id: string, kind: RenameKind, currentName: string) => { + setRenaming({ id, kind }); + setRenameDraft(currentName); + }; + const commitRename = () => { + if (!renaming) return; + const name = renameDraft.trim(); + if (name) { + if (renaming.kind === "folder") + host.onRenameViewSnapshotFolder(renaming.id, name); + else host.onRenameViewSnapshot(renaming.id, name); + } + setRenaming(null); + }; + const cancelRename = () => setRenaming(null); + const renamingRow = (id: string, kind: RenameKind) => + renaming?.id === id && renaming.kind === kind; + + // ── Anlegen (zwei Header-Buttons) ────────────────────────────────────────── + const addFolder = () => { + const id = host.onAddViewSnapshotFolder(activeFolderId ?? undefined); + if (activeFolderId) expandFolder(activeFolderId); + beginRename(id, "folder", t("viewsnap.folder.defaultName")); + }; + const commitSave = () => { + const name = draft.trim(); + if (name) host.onCaptureViewSnapshot(name, activeFolderId ?? undefined); + if (activeFolderId) expandFolder(activeFolderId); + setNaming(false); + setDraft(""); + }; + + // ── Drag&Drop ────────────────────────────────────────────────────────────── + const endDrag = () => { + setDragItem(null); + setDragOverId(null); + }; + const dropOnFolder = (targetFolderId: string) => { + if (!dragItem) return; + if (dragItem.kind === "snapshot") { + host.onMoveViewSnapshotToFolder(dragItem.id, targetFolderId); + } else if (dragItem.id !== targetFolderId) { + // Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op). + host.onMoveViewSnapshotFolder(dragItem.id, targetFolderId); + } + expandFolder(targetFolderId); + endDrag(); + }; + const dropOnRoot = () => { + if (!dragItem) return; + if (dragItem.kind === "snapshot") + host.onMoveViewSnapshotToFolder(dragItem.id, null); + else host.onMoveViewSnapshotFolder(dragItem.id, null); + endDrag(); + }; + + // ── Footer-Rename ────────────────────────────────────────────────────────── + const startFooterRename = () => { + if (!selectedSnap) return; + setFooterRenaming(true); + setFooterDraft(selectedSnap.name); + }; + const commitFooterRename = () => { + if (!selectedSnap) return; + const name = footerDraft.trim(); + if (name && name !== selectedSnap.name) + host.onRenameViewSnapshot(selectedSnap.id, name); + setFooterRenaming(false); + }; + + // ── Baum-Zeilen ──────────────────────────────────────────────────────────── + const SnapshotRow = (snap: ViewSnapshot, depth: number) => ( +
{ + e.stopPropagation(); + setDragItem({ kind: "snapshot", id: snap.id }); + e.dataTransfer.effectAllowed = "move"; + }} + onDragEnd={endDrag} + onClick={() => + renamingRow(snap.id, "snapshot") + ? undefined + : host.onApplyViewSnapshot(snap.id) + } + style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }} + > + {renamingRow(snap.id, "snapshot") ? ( + + ) : ( + + + {snap.name} + + )} + + +
+ ); + + const FolderRowTree = ( + node: ViewSnapshotFolderNode, + depth: number, + ): React.ReactNode => { + const { folder } = node; + const expanded = isExpanded(folder.id); + const active = activeFolderId === folder.id; + const empty = node.snapshots.length === 0 && node.subfolders.length === 0; + const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id; + const dragOver = dragOverId === folder.id && !isDragged; + return ( +
+
{ + e.stopPropagation(); + setDragItem({ kind: "folder", id: folder.id }); + e.dataTransfer.effectAllowed = "move"; + }} + onDragEnd={endDrag} + onDragOver={(e) => { + if (!dragItem) return; + e.stopPropagation(); + if (isDragged) { + if (dragOverId !== null) setDragOverId(null); + return; + } + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOverId !== folder.id) setDragOverId(folder.id); + }} + onDragLeave={() => { + if (dragOverId === folder.id) setDragOverId(null); + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + dropOnFolder(folder.id); + }} + onClick={() => setActiveFolderId(active ? null : folder.id)} + style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }} + > + + {renamingRow(folder.id, "folder") ? ( + + ) : ( + + + {folder.name} + + )} + + +
+ {expanded && ( + <> + {node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))} + {node.snapshots.map((s) => SnapshotRow(s, depth + 1))} + {empty && ( +
+ {t("viewsnap.folder.empty")} +
+ )} + + )} +
+ ); + }; + + const treeEmpty = + tree.rootFolders.length === 0 && tree.rootSnapshots.length === 0; + + return ( +
+
+ {t("viewsnap.title")} + + + + +
+ + {naming && ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitSave(); + else if (e.key === "Escape") { + setNaming(false); + setDraft(""); + } + }} + /> + +
+ )} + + {treeEmpty ? ( +
+ {t("viewsnap.empty")} +
+ ) : ( +
{ + if (!dragItem) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragOverId !== "root") setDragOverId("root"); + }} + onDragLeave={(e) => { + // Nur löschen, wenn der Zeiger den Container wirklich verlässt. + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + if (dragOverId === "root") setDragOverId(null); + } + }} + onDrop={(e) => { + e.preventDefault(); + dropOnRoot(); + }} + > + {tree.rootFolders.map((node) => FolderRowTree(node, 0))} + {tree.rootSnapshots.map((s) => SnapshotRow(s, 0))} +
+ )} + + {/* Footer-Bar: sichtbar, sobald ein Ausschnitt angewählt ist. Zeigt den + erfassten Massstab, die passende Ebenen-/Zeichnungskombi und die + aktiven Overrides und erlaubt das Umbenennen direkt hier. */} + {selectedSnap && footer && ( +
+
+ {footerRenaming ? ( + setFooterDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitFooterRename(); + else if (e.key === "Escape") setFooterRenaming(false); + }} + onBlur={commitFooterRename} + /> + ) : ( + <> + {selectedSnap.name} + + + )} +
+
+
{t("viewsnap.footer.scale")}
+
1:{selectedSnap.scaleDenominator}
+
{t("viewsnap.footer.layerCombo")}
+
{footer.layerCombo ?? t("viewsnap.footer.none")}
+
{t("viewsnap.footer.drawingCombo")}
+
{footer.drawingCombo ?? t("viewsnap.footer.none")}
+
{t("viewsnap.footer.overrides")}
+
+ {footer.overrideNames.length + ? footer.overrideNames.join(", ") + : t("viewsnap.footer.none")} +
+
+
+ )} +
+ ); +} + +/** Tab-Symbol „Ausschnitte" — Kamera-Blende / Sucherrahmen. */ +export function ViewSnapshotsTabIcon() { + return ( + + ); +} diff --git a/src/panels/builtinPanels.tsx b/src/panels/builtinPanels.tsx index 5885aa4..a442f10 100644 --- a/src/panels/builtinPanels.tsx +++ b/src/panels/builtinPanels.tsx @@ -22,6 +22,9 @@ import { AttributesPanel } from "./AttributesPanel"; import { ObjectInfoPanel } from "./ObjectInfoPanel"; import { SitePanel } from "./SitePanel"; import { RoomBalancePanel } from "./RoomBalancePanel"; +import { ElementTreePanel, ElementsTabIcon } from "./ElementTreePanel"; +import { ViewSnapshotsPanel, ViewSnapshotsTabIcon } from "./ViewSnapshotsPanel"; +import { LayoutsPanel, LayoutsTabIcon } from "./LayoutsPanel"; import { ToolsTabIcon, AttributesTabIcon, @@ -41,6 +44,9 @@ export const BUILTIN_PANEL_IDS = { objectInfo: "object-info", site: "site", roomBalance: "room-balance", + elements: "elements", + viewSnapshots: "view-snapshots", + layouts: "layouts", } as const; // Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil). @@ -105,3 +111,32 @@ registerPanel({ hasDisplayMode: false, render: () => , }); + +// Element-Baum (ROADMAP §11) — Geschoss → Bauteilklasse → Element, Suche + Klick-Selektion. +registerPanel({ + id: BUILTIN_PANEL_IDS.elements, + title: "elements.title", + icon: , + hasDisplayMode: false, + render: () => , +}); + +// Ausschnitte / View-Snapshots (DOSSIER A2, ROADMAP §2c/§11) — benannte, +// wiederherstellbare Ansichten (Darstellungszustand als Preset). +registerPanel({ + id: BUILTIN_PANEL_IDS.viewSnapshots, + title: "viewsnap.title", + icon: , + hasDisplayMode: false, + render: () => , +}); + +// Layouts / Masterlayouts (DOSSIER A3, ROADMAP §11) — Druck-/Plan-Blätter mit +// platzierten Viewports (an Ausschnitte gebunden), Editor als schwebendes Fenster. +registerPanel({ + id: BUILTIN_PANEL_IDS.layouts, + title: "layouts.panelTitle", + icon: , + hasDisplayMode: false, + render: () => , +}); diff --git a/src/panels/host.ts b/src/panels/host.ts index 0d4fea0..e8403a8 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -22,9 +22,13 @@ import type { DrawingLevel, HatchStyle, LayerCategory, + LayoutOrientation, + LayoutPaperFormat, LineStyle, + MasterLayout, Project, SiaCategory, + SliceTermination, StairShape, VerticalAnchor, WallReferenceLine, @@ -32,6 +36,7 @@ import type { } from "../model/types"; import type { SnapSettings, ToolId } from "../tools/types"; import type { Selection } from "../state/selectionInfo"; +import type { ScheduleKind } from "../export/exportSchedule"; // ── Darstellungsmodus-Steuerung je Dock-Inhalt ──────────────────────────── @@ -196,10 +201,38 @@ export interface PanelHostValue { h: number, anchor: { fx: number; fy: number }, ) => void; + /** + * Verschiebt die Selektion um (dx,dy) Meter — generischer Move über das + * Transform-System (analog dem Move-Werkzeug/-Befehl), deckt Wand/Drawing2D/ + * Extrusionskörper ab. Bei Decke/Öffnung/Treppe/Raum (Transform-Kern kennt + * diese Elementarten nicht) ein No-op. + */ + onMoveSelectionBy: (dx: number, dy: number) => void; + /** + * Dreht die Selektion um `deg` Grad um den Weltpunkt (cx,cy) — generischer + * Rotate über das Transform-System; deckt dieselben Selektionsarten ab wie + * {@link onMoveSelectionBy}. + */ + onRotateSelectionAround: (cx: number, cy: number, deg: number) => void; + /** + * Setzt die Länge einer Linien-Drawing2D (Endpunkt entlang der Richtung neu + * skaliert, Startpunkt bleibt fix). No-op, wenn die Selektion keine Linie ist. + */ + onSetDrawingLineLength: (length: number) => void; + /** + * Setzt den Radius einer Kreis-Drawing2D (Mittelpunkt bleibt fix). No-op, + * wenn die Selektion kein Kreis ist. + */ + onSetDrawingCircleRadius: (radius: 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; + /** + * Setzt die Terminierungs-Regel am Deckenanschluss (Zuschnitt, NICHT Priorität): + * "both" = heutiges Verhalten, "below"/"above" = die Wand endet an der Decke. + */ + onSetWallSliceTermination: (termination: SliceTermination) => void; /** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */ onSetWallType: (wallTypeId: string) => void; /** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */ @@ -216,6 +249,12 @@ export interface PanelHostValue { onSetCeilingThickness: (thickness: number) => void; /** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */ onSetCeilingTop: (anchor: VerticalAnchor | null) => void; + /** + * Setzt/entfernt die UK-Bindung der Decke (`null` = OK − Dicke). Optional, + * da bestehende Hosts (App.tsx) diesen Setter ggf. noch nicht verdrahtet + * haben — additive Erweiterung analog `onSetWallBottom`. + */ + onSetCeilingBottom?: (anchor: VerticalAnchor | null) => void; // ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─ /** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */ @@ -242,6 +281,8 @@ export interface PanelHostValue { onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void; /** Setzt die Sturzlinien-Darstellung der Tür. */ onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void; + /** Weist der Öffnung einen Tür-/Fenstertyp (Bibliothek) zu; "" löst die Zuweisung. */ + onSetOpeningType: (typeId: string) => void; // ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ─── /** Setzt die Grundform (gerade/L/Wendel). */ @@ -256,6 +297,8 @@ export interface PanelHostValue { onSetStairUp: (up: boolean) => void; /** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */ onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void; + /** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */ + onSetStairType: (typeId: string) => void; // ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ──────── /** Setzt den Raum-Namen. */ @@ -265,6 +308,26 @@ export interface PanelHostValue { /** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */ onEditRoomStamp: (roomId: string) => void; + // ── Extrusions-Attribute (truck-Integration; nur der selektierte Körper) ── + /** Setzt die Extrusionshöhe (Meter, > 0) — löst eine Re-Extrusion aus. */ + onSetExtrudedSolidHeight: (height: number) => void; + /** Setzt die Verjüngung (0..1) — löst eine Re-Extrusion aus. */ + onSetExtrudedSolidTaper: (taper: number) => void; + + // ── Stützen-Attribute (Tragwerk; nur die selektierte Stütze) ──────────── + /** Wechselt das Profil (Rechteck/Kreis); leitet ein sinnvolles Mass ab. */ + onSetColumnProfileKind: (kind: "rect" | "round") => void; + /** Setzt die Breite (X) eines Rechteckprofils (Meter). */ + onSetColumnWidth: (width: number) => void; + /** Setzt die Tiefe (Y) eines Rechteckprofils (Meter). */ + onSetColumnDepth: (depth: number) => void; + /** Setzt den Radius eines Kreisprofils (Meter). */ + onSetColumnRadius: (radius: number) => void; + /** Setzt die Höhe der Stütze (Meter, > 0). */ + onSetColumnHeight: (height: number) => void; + /** Setzt die Drehung der Stütze (Grad; intern in Radiant gespeichert). */ + onSetColumnRotation: (deg: number) => void; + // ── Site-/Kontext-Schicht (SitePanel) ─────────────────────────────────── /** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */ contextObjects: ContextObject[]; @@ -279,6 +342,166 @@ export interface PanelHostValue { onGenerateTerrain: (contourSetId: string) => string | null; /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ onImportDxf: () => void; + + // ── Element-Baum (Elemente-Panel) ─────────────────────────────────────── + /** + * Selektiert EIN Bauteil-Vorkommen aus der Elementliste (`scheduleRows`) im + * Baum: leert alle Auswahl-Kanäle und setzt genau dieses Element im + * passenden Kanal (Wand/Decke/Fenster+Öffnung/Treppe/Extrusion). Liegt das + * Element auf einem anderen Geschoss, wechselt zuerst die aktive + * Zeichnungsebene. Für Bauteilklassen ohne Auswahl-Kanal (aktuell nur die + * Legacy-„Tür"-Datensätze aus `project.doors`, die kein Werkzeug mehr + * anlegt) ein No-op. `opts.zoom` (Shift-Klick/Doppelklick in der Baumzeile) + * passt die Plan-Ansicht zusätzlich ein. + */ + onSelectScheduleRow: ( + kind: ScheduleKind, + id: string, + floorId: string | undefined, + opts?: { zoom?: boolean }, + ) => void; + + // ── Ausschnitte / View-Snapshots (Ausschnitte-Panel, DOSSIER A2) ───────── + // Die Liste selbst liest das Panel über `project.viewSnapshots`. Die + // Erfassungs-/Anwendungslogik (Setter/rAF) lebt in App.tsx; hier nur die + // immutablen Handler. + /** + * Erfasst den aktuellen Darstellungszustand als neuen, benannten Ausschnitt. + * `folderId` legt ihn direkt im angegebenen Ordner ab (sonst Wurzelebene) — + * additiv, Alt-Aufrufe ohne Ordner-Argument bleiben gültig. + */ + onCaptureViewSnapshot: (name: string, folderId?: string) => void; + /** Stellt alle Felder eines Ausschnitts wieder her (Geschoss/Sichtbarkeit/Overrides/Ansicht). */ + onApplyViewSnapshot: (id: string) => void; + /** Benennt einen Ausschnitt um. */ + onRenameViewSnapshot: (id: string, name: string) => void; + /** Löscht einen Ausschnitt. */ + onDeleteViewSnapshot: (id: string) => void; + + // ── Ausschnitte-Baum (Ordner, DOSSIER A2) ─────────────────────────────── + // Die Listen liest das Panel über `project.viewSnapshots`/ + // `project.viewSnapshotFolders`; pure CRUD in state/viewSnapshotFolders.ts. + /** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */ + onAddViewSnapshotFolder: (parentId?: string) => string; + /** Benennt einen Ausschnitte-Ordner um. */ + onRenameViewSnapshotFolder: (id: string, name: string) => void; + /** + * Löscht einen Ausschnitte-Ordner (enthaltene Ausschnitte + Unterordner wandern + * auf die Elternebene, kein Datenverlust). + */ + onDeleteViewSnapshotFolder: (id: string) => void; + /** + * Verschiebt einen Ausschnitt per Drag&Drop in einen Ordner (`folderId`) bzw. + * auf die Wurzelebene (`null`). + */ + onMoveViewSnapshotToFolder: (snapshotId: string, folderId: string | null) => void; + /** + * Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`) + * bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren) + * werden verworfen (No-op). + */ + onMoveViewSnapshotFolder: (folderId: string, parentId: string | null) => void; + + /** + * Id des aktuell „angewählten" Ausschnitts (Footer-Bar sichtbar) oder `null`. + * Bleibt gesetzt, solange der Live-Zustand exakt dem Ausschnitt entspricht; + * App löscht ihn, sobald der Nutzer eine erfasste Grösse ändert. + */ + selectedViewSnapshotId: string | null; + /** Namen aller gespeicherten Ebenen-Kombinationen (localStorage). */ + listLayerCombos: () => string[]; + /** Lädt die `codes`-Map einer Ebenen-Kombination (`null`, wenn unbekannt). */ + loadLayerCombo: (name: string) => Record | null; + /** Namen aller gespeicherten Zeichnungs-Kombinationen (localStorage). */ + listDrawingCombos: () => string[]; + /** Lädt die `ids`-Map einer Zeichnungs-Kombination (`null`, wenn unbekannt). */ + loadDrawingCombo: (name: string) => Record | null; + + // ── Layouts / Masterlayouts (Layouts-Panel, DOSSIER A3) ───────────────── + // Die Listen liest das Panel über `project.layouts`/`project.masterLayouts`. + // Die Mutationslogik lebt in App.tsx (setProject); pure CRUD in + // panels/layoutModel.ts. + /** Legt ein neues, leeres Layout an und öffnet den Editor. */ + onAddLayout: ( + name: string, + paper: LayoutPaperFormat, + orientation: LayoutOrientation, + ) => void; + /** Benennt ein Layout um. */ + onRenameLayout: (id: string, name: string) => void; + /** Löscht ein Layout (schliesst ggf. den offenen Editor). */ + onDeleteLayout: (id: string) => void; + /** Öffnet ein Layout im schwebenden Layout-Editor. */ + onOpenLayout: (id: string) => void; + /** Legt ein neues Masterlayout an. */ + onAddMasterLayout: (name: string) => void; + /** Benennt ein Masterlayout um. */ + onRenameMasterLayout: (id: string, name: string) => void; + /** Löscht ein Masterlayout (löst Bindungen der Layouts). */ + onDeleteMasterLayout: (id: string) => void; + /** Ändert Felder eines Masterlayouts (Titelblock/Papier/Rahmen). */ + onPatchMasterLayout: (id: string, patch: Partial) => void; + + // ── Layouts-Baum (Ordner) + Erstell-Dialoge (DOSSIER A3) ──────────────── + /** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */ + onAddLayoutFolder: (parentId?: string) => string; + /** + * Legt einen neuen MASTER-Ordner (`kind:"master"`) an (optional in `parentId`); + * liefert die neue Id. Analog {@link onAddLayoutFolder}, aber im Master-Baum. + */ + onAddMasterFolder: (parentId?: string) => string; + /** Benennt einen Ordner um (Layout- wie Master-Ordner, per Id). */ + onRenameLayoutFolder: (id: string, name: string) => void; + /** Löscht einen Ordner (Inhalt wandert auf die Elternebene, kein Datenverlust). */ + onDeleteLayoutFolder: (id: string) => void; + /** + * Löscht einen Master-Ordner (enthaltene Masterlayouts + Unterordner wandern + * auf die Elternebene, kein Datenverlust). Analog {@link onDeleteLayoutFolder}. + */ + onDeleteMasterFolder: (id: string) => void; + /** + * Legt ein Layout mit expliziten Startwerten (Master-Vorlage ODER freie + * Grösse) an und liefert dessen Id (das Panel versetzt es in Inline-Rename). + * Öffnet das Blatt NICHT automatisch (anders als {@link onAddLayout}). + */ + onCreateLayout: (opts: CreateLayoutOptions) => string; + /** Legt ein Masterlayout mit Grösse an und liefert dessen Id. */ + onCreateMasterLayout: (opts: CreateMasterLayoutOptions) => string; + /** Exportiert alle Layouts eines Ordners als EIN Mehrseiten-PDF. */ + onExportFolderPdf: (folderId: string) => void; + /** + * Verschiebt ein Layout per Drag&Drop in einen Ordner (`folderId`) bzw. auf + * die Wurzelebene (`null`). + */ + onMoveLayoutToFolder: (layoutId: string, folderId: string | null) => void; + /** + * Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`) + * bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren) + * werden verworfen (No-op). + */ + onMoveFolderToFolder: (folderId: string, parentId: string | null) => void; +} + +/** Startwerte eines neuen Layouts (aus dem „Neues Layout"-Dialog). */ +export interface CreateLayoutOptions { + /** Zielordner; ohne → Wurzelebene. */ + folderId?: string; + /** Gebundenes Masterlayout (erbt dessen Grösse); ohne → freie Grösse. */ + masterId?: string; + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + customWidthMm?: number; + customHeightMm?: number; +} + +/** Startwerte eines neuen Masterlayouts (aus dem „Neues Masterlayout"-Dialog). */ +export interface CreateMasterLayoutOptions { + /** Ziel-Master-Ordner; ohne → Wurzelebene des Master-Baums. */ + folderId?: string; + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + customWidthMm?: number; + customHeightMm?: number; } // Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so @@ -286,6 +509,7 @@ export interface PanelHostValue { export type { DrawingLevel, LayerCategory }; export type { SnapSettings, ToolId }; export type { Selection } from "../state/selectionInfo"; +export type { ScheduleKind, ScheduleRow } from "../export/exportSchedule"; // ── Hook ─────────────────────────────────────────────────────────────────── diff --git a/src/panels/layout.ts b/src/panels/layout.ts index e05ae55..322d9a4 100644 --- a/src/panels/layout.ts +++ b/src/panels/layout.ts @@ -34,7 +34,13 @@ import type { // v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) → // Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen // (Attribute volle linke Höhe statt Werkzeug-Palette oben). -export const LAYOUT_VERSION = 8; +// v9: Element-Baum ("elements", ROADMAP Paragraf 11) als weiterer Tab in der +// rechten unteren Gruppe (neben Zeichnungsebenen/Ebenen/Umgebung). +// v10: Ausschnitte-Panel ("view-snapshots", DOSSIER A2 / ROADMAP Paragraf 2c) +// als weiterer Tab in der rechten unteren Gruppe. +// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer +// Tab in der rechten unteren Gruppe. +export const LAYOUT_VERSION = 11; /** localStorage-Schlüssel des zuletzt benutzten Layouts. */ const CURRENT_KEY = "cad.layout"; @@ -68,7 +74,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [ const DEFAULT_RIGHT_GROUPS: DockGroup[] = [ { tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 }, { - tabs: ["drawing-levels", "layers", "site"], + tabs: ["drawing-levels", "layers", "site", "elements", "view-snapshots", "layouts"], activeTab: "drawing-levels", weight: 1.5, }, diff --git a/src/panels/layoutModel.test.ts b/src/panels/layoutModel.test.ts new file mode 100644 index 0000000..b79c438 --- /dev/null +++ b/src/panels/layoutModel.test.ts @@ -0,0 +1,654 @@ +// Tests der reinen Layout-Logik (DOSSIER A3): Blatt-Geometrie, Fabriken, +// immutables Viewport-CRUD, Master-Vererbung der Titelblock-Werte, +// Viewport→Ausschnitt-Auflösung + Massstab, Sichtbarkeits-Ableitung. + +import { describe, expect, it } from "vitest"; +import type { + Layout, + LayoutFolder, + MasterLayout, + Project, + ViewSnapshot, +} from "../model/types"; +import { + addAnnotation, + addViewport, + buildLayoutTree, + buildMasterTree, + collectFolderLayouts, + createAnnotation, + createLayout, + createLayoutFolder, + createMasterLayout, + createViewport, + deleteFolder, + deleteMasterFolder, + effectiveSheetSizeMm, + findMaster, + findSnapshot, + folderPdfPages, + isDescendant, + moveFolderToFolder, + moveLayoutToFolder, + PAPER_FORMATS, + patchAnnotation, + patchViewport, + removeAnnotation, + removeViewport, + resolveTitleBlock, + resolveViewportScale, + sheetSizeMm, + visibleCodesFromSnapshot, +} from "./layoutModel"; + +/** Minimaler Ausschnitt (nur die im Layout-System genutzten Felder tragen Werte). */ +function snap(id: string, patch?: Partial): ViewSnapshot { + return { + id, + name: `Snap ${id}`, + viewType: "grundriss", + view3d: "top", + scaleDenominator: 100, + detail: "mittel", + activeLevelId: "floor-0", + layerVisibility: { A: true, B: false, C: true }, + drawingVisibility: {}, + ...patch, + }; +} + +/** Minimales Projekt-Gerüst mit optionalen Layout-Feldern. */ +function project(patch?: Partial): Project { + return { + id: "p1", + name: "Villa Test", + lineStyles: [], + hatches: [], + components: [], + wallTypes: [], + drawingLevels: [], + layers: [], + walls: [], + doors: [], + drawings2d: [], + ...patch, + }; +} + +describe("sheetSizeMm", () => { + it("liefert A4/A3 in Hoch- und Querformat", () => { + expect(sheetSizeMm("a4", "portrait")).toEqual({ w: 210, h: 297 }); + expect(sheetSizeMm("a4", "landscape")).toEqual({ w: 297, h: 210 }); + expect(sheetSizeMm("a3", "portrait")).toEqual({ w: 297, h: 420 }); + expect(sheetSizeMm("a3", "landscape")).toEqual({ w: 420, h: 297 }); + }); +}); + +describe("Fabriken", () => { + it("createLayout: leer, ohne Master", () => { + const l = createLayout("l1", "Blatt 1", "a3", "landscape"); + expect(l).toEqual({ + id: "l1", + name: "Blatt 1", + paper: "a3", + orientation: "landscape", + viewports: [], + }); + expect(l.masterId).toBeUndefined(); + }); + + it("createMasterLayout: leerer Titelblock + Rahmen an", () => { + const m = createMasterLayout("m1", "Master", "a4", "portrait"); + expect(m.titleBlock).toEqual({}); + expect(m.border).toBe(true); + }); + + it("createViewport: Defaults + optionaler Massstab", () => { + const v = createViewport("v1", "s1"); + expect(v).toMatchObject({ id: "v1", snapshotId: "s1", xMm: 20, yMm: 20, widthMm: 120, heightMm: 90 }); + expect(v.scaleDenominator).toBeUndefined(); + const v2 = createViewport("v2", "s1", { xMm: 5, scaleDenominator: 50 }); + expect(v2.xMm).toBe(5); + expect(v2.scaleDenominator).toBe(50); + }); +}); + +describe("Viewport-CRUD (immutabel)", () => { + const base = addViewport(createLayout("l1", "L", "a4", "portrait"), createViewport("v1", "s1")); + + it("addViewport verändert das Original nicht", () => { + const before = createLayout("l1", "L", "a4", "portrait"); + const after = addViewport(before, createViewport("v1", "s1")); + expect(before.viewports).toHaveLength(0); + expect(after.viewports).toHaveLength(1); + expect(after).not.toBe(before); + }); + + it("removeViewport entfernt per Id", () => { + const two = addViewport(base, createViewport("v2", "s2")); + const one = removeViewport(two, "v1"); + expect(one.viewports.map((v) => v.id)).toEqual(["v2"]); + }); + + it("patchViewport ändert nur das Ziel, immutabel", () => { + const patched = patchViewport(base, "v1", { xMm: 42, scaleDenominator: 50 }); + expect(patched.viewports[0]).toMatchObject({ xMm: 42, scaleDenominator: 50 }); + expect(base.viewports[0].xMm).toBe(20); + // Unbekannte Id → No-op (gleiche Werte, neue Liste). + const noop = patchViewport(base, "nope", { xMm: 1 }); + expect(noop.viewports[0].xMm).toBe(20); + }); +}); + +// ── Annotationen (Linie/Rechteck/Text) ─────────────────────────────────────── + +describe("createAnnotation — Defaults je Art", () => { + it("line: Default-Geometrie ohne Farbe/Strichstärke", () => { + const a = createAnnotation("a1", "line"); + expect(a).toEqual({ id: "a1", kind: "line", x1Mm: 20, y1Mm: 20, x2Mm: 80, y2Mm: 20 }); + }); + it("line: init überschreibt einzelne Felder + optionale Farbe/Strichstärke", () => { + const a = createAnnotation("a1", "line", { x1Mm: 5, color: "#f00", weightMm: 0.5 }); + expect(a).toMatchObject({ x1Mm: 5, y1Mm: 20, color: "#f00", weightMm: 0.5 }); + }); + it("rect: Default-Grösse", () => { + const a = createAnnotation("a2", "rect"); + expect(a).toEqual({ id: "a2", kind: "rect", xMm: 20, yMm: 20, widthMm: 40, heightMm: 30 }); + }); + it("text: Default-Zeilenhöhe 3.5mm, leerer Text", () => { + const a = createAnnotation("a3", "text"); + expect(a).toEqual({ id: "a3", kind: "text", xMm: 20, yMm: 20, text: "", heightMm: 3.5 }); + }); + it("text: init setzt Inhalt + Höhe", () => { + const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 }); + expect(a).toMatchObject({ text: "EG", heightMm: 5 }); + }); +}); + +describe("Annotations-CRUD (immutabel)", () => { + const base = createLayout("l1", "L", "a4", "portrait"); + + it("addAnnotation verändert das Original nicht", () => { + const after = addAnnotation(base, createAnnotation("a1", "line")); + expect(base.annotations).toBeUndefined(); + expect(after.annotations).toHaveLength(1); + expect(after).not.toBe(base); + }); + + it("addAnnotation baut auf einer fehlenden `annotations`-Liste auf (Alt-Layout)", () => { + const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] }; + const after = addAnnotation(legacy, createAnnotation("a1", "rect")); + expect(after.annotations).toHaveLength(1); + expect(legacy.annotations).toBeUndefined(); + }); + + it("removeAnnotation entfernt per Id", () => { + let l = addAnnotation(base, createAnnotation("a1", "line")); + l = addAnnotation(l, createAnnotation("a2", "rect")); + const removed = removeAnnotation(l, "a1"); + expect(removed.annotations!.map((a) => a.id)).toEqual(["a2"]); + }); + + it("removeAnnotation auf Alt-Layout ohne `annotations` bleibt heil (No-op)", () => { + const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] }; + const r = removeAnnotation(legacy, "nope"); + expect(r.annotations).toEqual([]); + }); + + it("patchAnnotation ändert nur das Ziel, immutabel", () => { + const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" })); + const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" }); + expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" }); + expect(l.annotations![0].color).toBe("#111"); + }); + + it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => { + const l = addAnnotation(base, createAnnotation("a1", "line")); + const noop = patchAnnotation(l, "nope", { x1Mm: 1 }); + expect((noop.annotations![0] as { x1Mm: number }).x1Mm).toBe(20); + }); + + it("patchAnnotation auf einer Text-Annotation ändert den Inhalt", () => { + const l = addAnnotation(base, createAnnotation("t1", "text", { text: "EG" })); + const patched = patchAnnotation(l, "t1", { text: "1. OG" }); + expect(patched.annotations![0]).toMatchObject({ text: "1. OG" }); + }); +}); + +describe("resolveViewportScale", () => { + const s = snap("s1", { scaleDenominator: 200 }); + it("Viewport-Override gewinnt", () => { + expect(resolveViewportScale(createViewport("v", "s1", { scaleDenominator: 50 }), s)).toBe(50); + }); + it("sonst Ausschnitt-Massstab", () => { + expect(resolveViewportScale(createViewport("v", "s1"), s)).toBe(200); + }); + it("Fallback ohne beides", () => { + expect(resolveViewportScale(createViewport("v", "s1"), undefined, 100)).toBe(100); + }); +}); + +describe("findSnapshot / findMaster", () => { + const p = project({ + viewSnapshots: [snap("s1"), snap("s2")], + masterLayouts: [createMasterLayout("m1", "M1", "a4", "portrait")], + }); + it("findSnapshot", () => { + expect(findSnapshot(p, "s2")?.id).toBe("s2"); + expect(findSnapshot(p, "x")).toBeUndefined(); + }); + it("findMaster nur bei gesetzter masterId", () => { + const bound: Layout = { ...createLayout("l", "L", "a4", "portrait"), masterId: "m1" }; + expect(findMaster(p, bound)?.id).toBe("m1"); + expect(findMaster(p, createLayout("l2", "L2", "a4", "portrait"))).toBeUndefined(); + // Dangling masterId → undefined. + expect(findMaster(p, { ...bound, masterId: "gone" })).toBeUndefined(); + }); +}); + +describe("resolveTitleBlock — Master-Vererbung + Defaults", () => { + const p = project({ name: "Villa Test", viewSnapshots: [snap("s1", { scaleDenominator: 50 })] }); + + it("ohne Master: Defaults aus Projekt/Layout + Massstab aus erstem Viewport", () => { + let l = createLayout("l1", "EG Grundriss", "a4", "portrait"); + l = addViewport(l, createViewport("v1", "s1")); + const tb = resolveTitleBlock(p, l, undefined, "2026-07-07"); + expect(tb.projectName).toBe("Villa Test"); + expect(tb.sheetName).toBe("EG Grundriss"); + expect(tb.scale).toBe("1:50"); + expect(tb.date).toBe("2026-07-07"); + expect(tb.author).toBe(""); + }); + + it("Master-Werte übersteuern die Defaults", () => { + const master: MasterLayout = { + id: "m1", + name: "M", + paper: "a4", + orientation: "portrait", + titleBlock: { projectName: "Kundenprojekt", author: "GV", scale: "1:100" }, + }; + const l = createLayout("l1", "EG", "a4", "portrait"); + const tb = resolveTitleBlock(p, l, master, "2026-07-07"); + expect(tb.projectName).toBe("Kundenprojekt"); + expect(tb.author).toBe("GV"); + expect(tb.scale).toBe("1:100"); // Master-Wert schlägt den Viewport-Default + expect(tb.sheetName).toBe("EG"); // leer im Master → Layout-Name + }); + + it("leere/whitespace Master-Felder fallen auf Defaults zurück", () => { + const master: MasterLayout = { + id: "m1", + name: "M", + paper: "a4", + orientation: "portrait", + titleBlock: { projectName: " ", sheetName: "" }, + }; + const l = createLayout("l1", "OG", "a4", "portrait"); + const tb = resolveTitleBlock(p, l, master, "2026-07-07"); + expect(tb.projectName).toBe("Villa Test"); + expect(tb.sheetName).toBe("OG"); + expect(tb.scale).toBe("—"); // kein Viewport → kein Massstab + }); +}); + +describe("visibleCodesFromSnapshot", () => { + it("nur sichtbare Codes landen im Set", () => { + const codes = visibleCodesFromSnapshot(snap("s1")); + expect([...codes].sort()).toEqual(["A", "C"]); + expect(codes.has("B")).toBe(false); + }); +}); + +// ── Freie Grösse (custom übersteuert Papier) ────────────────────────────────── + +describe("sheetSizeMm / effectiveSheetSizeMm — freie Grösse", () => { + it("custom übersteuert paper/orientation, wenn beide positiv gesetzt", () => { + expect(sheetSizeMm("a4", "portrait", 500, 300)).toEqual({ w: 500, h: 300 }); + }); + it("fällt auf paper zurück, wenn custom fehlt oder nicht positiv ist", () => { + expect(sheetSizeMm("a4", "portrait", undefined, 300)).toEqual({ w: 210, h: 297 }); + expect(sheetSizeMm("a3", "landscape", 0, 300)).toEqual({ w: 420, h: 297 }); + }); + it("effectiveSheetSizeMm liest die Felder eines Layouts/Masters", () => { + expect( + effectiveSheetSizeMm({ paper: "a4", orientation: "portrait" }), + ).toEqual({ w: 210, h: 297 }); + expect( + effectiveSheetSizeMm({ + paper: "a4", + orientation: "portrait", + customWidthMm: 250, + customHeightMm: 250, + }), + ).toEqual({ w: 250, h: 250 }); + }); +}); + +describe("createMasterLayout — freie Grösse", () => { + it("übernimmt custom Breite/Höhe, wenn übergeben", () => { + const m = createMasterLayout("m", "M", "a4", "portrait", { + widthMm: 400, + heightMm: 250, + }); + expect(m.customWidthMm).toBe(400); + expect(m.customHeightMm).toBe(250); + }); +}); + +// ── Ordner-Baum (CRUD + Aufbau) ─────────────────────────────────────────────── + +function lay(id: string, folderId?: string): Layout { + return { + id, + name: id, + paper: "a4", + orientation: "portrait", + viewports: [], + ...(folderId ? { folderId } : {}), + }; +} + +describe("createLayoutFolder", () => { + it("Wurzelordner ohne parentId", () => { + expect(createLayoutFolder("f1", "Ordner")).toEqual({ id: "f1", name: "Ordner" }); + }); + it("Unterordner mit parentId", () => { + expect(createLayoutFolder("f2", "Sub", "f1")).toEqual({ + id: "f2", + name: "Sub", + parentId: "f1", + }); + }); + it("Master-Ordner trägt kind:\"master\"", () => { + expect(createLayoutFolder("m1", "Master-Ordner", undefined, "master")).toEqual({ + id: "m1", + name: "Master-Ordner", + kind: "master", + }); + }); + it("kind \"layout\"/undefined wird NICHT gesetzt (rückwärtskompatibel)", () => { + expect(createLayoutFolder("l1", "Layout-Ordner", undefined, "layout")).toEqual({ + id: "l1", + name: "Layout-Ordner", + }); + expect(createLayoutFolder("l2", "Layout-Ordner", "p", undefined)).toEqual({ + id: "l2", + name: "Layout-Ordner", + parentId: "p", + }); + }); +}); + +describe("buildLayoutTree", () => { + it("gruppiert Layouts in Ordner + Wurzel; Unterordner verschachtelt", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A"), + createLayoutFolder("B", "B", "A"), + ]; + const layouts = [lay("root1"), lay("inA", "A"), lay("inB", "B")]; + const tree = buildLayoutTree(folders, layouts); + expect(tree.rootLayouts.map((l) => l.id)).toEqual(["root1"]); + expect(tree.rootFolders).toHaveLength(1); + const a = tree.rootFolders[0]; + expect(a.folder.id).toBe("A"); + expect(a.layouts.map((l) => l.id)).toEqual(["inA"]); + expect(a.subfolders).toHaveLength(1); + expect(a.subfolders[0].folder.id).toBe("B"); + expect(a.subfolders[0].layouts.map((l) => l.id)).toEqual(["inB"]); + }); + + it("verwaiste Referenzen landen auf der Wurzel", () => { + const tree = buildLayoutTree( + [createLayoutFolder("orphan", "O", "missing")], + [lay("ghost", "gone")], + ); + // Ordner mit unbekanntem parent → Wurzel; Layout mit unbekanntem folder → Wurzel. + expect(tree.rootFolders.map((f) => f.folder.id)).toEqual(["orphan"]); + expect(tree.rootLayouts.map((l) => l.id)).toEqual(["ghost"]); + }); +}); + +// ── Master-Baum + Layout/Master-Trennung (kind) ────────────────────────────── + +function mas(id: string, folderId?: string): MasterLayout { + return { + id, + name: id, + paper: "a4", + orientation: "portrait", + titleBlock: {}, + ...(folderId ? { folderId } : {}), + }; +} + +describe("buildMasterTree", () => { + it("gruppiert Master in Master-Ordner + Wurzel; Unterordner verschachtelt", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A", undefined, "master"), + createLayoutFolder("B", "B", "A", "master"), + ]; + const masters = [mas("root1"), mas("inA", "A"), mas("inB", "B")]; + const tree = buildMasterTree(folders, masters); + expect(tree.rootMasters.map((m) => m.id)).toEqual(["root1"]); + expect(tree.rootFolders).toHaveLength(1); + const a = tree.rootFolders[0]; + expect(a.folder.id).toBe("A"); + expect(a.masters.map((m) => m.id)).toEqual(["inA"]); + expect(a.subfolders[0].masters.map((m) => m.id)).toEqual(["inB"]); + }); +}); + +describe("Layout-/Master-Ordner bleiben getrennt (kind-Filter)", () => { + it("Master-Ordner erscheint NICHT im Layout-Baum und umgekehrt", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("L", "Layout-Ordner"), // kind fehlt → Layout + createLayoutFolder("M", "Master-Ordner", undefined, "master"), + ]; + const layouts = [lay("l1", "L"), lay("lm", "M")]; // lm zeigt fälschlich auf Master-Ordner + const masters = [mas("m1", "M"), mas("ml", "L")]; // ml zeigt fälschlich auf Layout-Ordner + + // Wie das Panel filtert: Layout-Baum nur kind!=="master", Master-Baum nur kind==="master". + const layoutTree = buildLayoutTree( + folders.filter((f) => f.kind !== "master"), + layouts, + ); + const masterTree = buildMasterTree( + folders.filter((f) => f.kind === "master"), + masters, + ); + + // Layout-Baum kennt nur den Layout-Ordner; das auf M zeigende Layout fällt auf die Wurzel. + expect(layoutTree.rootFolders.map((f) => f.folder.id)).toEqual(["L"]); + expect(layoutTree.rootLayouts.map((l) => l.id)).toEqual(["lm"]); + // Master-Baum kennt nur den Master-Ordner; der auf L zeigende Master fällt auf die Wurzel. + expect(masterTree.rootFolders.map((f) => f.folder.id)).toEqual(["M"]); + expect(masterTree.rootMasters.map((m) => m.id)).toEqual(["ml"]); + expect(masterTree.rootFolders[0].masters.map((m) => m.id)).toEqual(["m1"]); + }); +}); + +describe("deleteMasterFolder", () => { + it("zieht Master + Unterordner auf die Elternebene und erhält kind:\"master\"", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A", undefined, "master"), + createLayoutFolder("B", "B", "A", "master"), + ]; + const masters = [mas("a1", "A"), mas("b1", "B")]; + const r = deleteMasterFolder("A", folders, masters); + expect(r.folders.map((f) => f.id)).toEqual(["B"]); + // B auf Wurzel hochgezogen, aber weiterhin Master-Ordner. + expect(r.folders[0].parentId).toBeUndefined(); + expect(r.folders[0].kind).toBe("master"); + expect(r.masters.find((m) => m.id === "a1")!.folderId).toBeUndefined(); + expect(r.masters.find((m) => m.id === "b1")!.folderId).toBe("B"); + }); +}); + +describe("collectFolderLayouts", () => { + it("liefert direkte Blätter zuerst, dann rekursiv Unterordner (PDF-Reihenfolge)", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A"), + createLayoutFolder("B", "B", "A"), + ]; + const layouts = [lay("root1"), lay("a1", "A"), lay("a2", "A"), lay("b1", "B")]; + expect(collectFolderLayouts("A", folders, layouts).map((l) => l.id)).toEqual([ + "a1", + "a2", + "b1", + ]); + expect(collectFolderLayouts("B", folders, layouts).map((l) => l.id)).toEqual([ + "b1", + ]); + }); + it("unbekannter Ordner → leer", () => { + expect(collectFolderLayouts("nope", [], [lay("x")])).toEqual([]); + }); +}); + +describe("deleteFolder", () => { + it("zieht Layouts + Unterordner auf die Elternebene (kein Datenverlust)", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A"), + createLayoutFolder("B", "B", "A"), + ]; + const layouts = [lay("a1", "A"), lay("b1", "B")]; + // A (Wurzel) löschen → a1 auf Wurzel, B verliert parent → Wurzel. + const r = deleteFolder("A", folders, layouts); + expect(r.folders.map((f) => f.id)).toEqual(["B"]); + expect(r.folders[0].parentId).toBeUndefined(); + expect(r.layouts.find((l) => l.id === "a1")!.folderId).toBeUndefined(); + expect(r.layouts.find((l) => l.id === "b1")!.folderId).toBe("B"); + }); + it("Unterordner löschen zieht Kinder zum Grosselter", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A"), + createLayoutFolder("B", "B", "A"), + ]; + const layouts = [lay("b1", "B")]; + const r = deleteFolder("B", folders, layouts); + expect(r.folders.map((f) => f.id)).toEqual(["A"]); + expect(r.layouts[0].folderId).toBe("A"); + }); +}); + +describe("folderPdfPages", () => { + it("plant Seiten in Baum-Reihenfolge mit effektiver Blattgrösse", () => { + const master: MasterLayout = createMasterLayout("m", "M", "a3", "landscape"); + const layouts: Layout[] = [ + { ...lay("p1", "F"), masterId: "m" }, // erbt A3 quer vom Master + { ...lay("p2", "F"), customWidthMm: 500, customHeightMm: 200 }, // freie Grösse + lay("p3", "F"), // A4 hoch (Default) + ]; + const p = project({ + layoutFolders: [createLayoutFolder("F", "F")], + layouts, + masterLayouts: [master], + }); + const pages = folderPdfPages(p, "F"); + expect(pages.map((x) => x.layout.id)).toEqual(["p1", "p2", "p3"]); + expect(pages[0]).toMatchObject({ widthMm: 420, heightMm: 297 }); // Master A3 quer + expect(pages[1]).toMatchObject({ widthMm: 500, heightMm: 200 }); // custom + expect(pages[2]).toMatchObject({ widthMm: 210, heightMm: 297 }); // A4 hoch + }); + it("leerer/unbekannter Ordner → keine Seiten", () => { + expect(folderPdfPages(project(), "nope")).toEqual([]); + }); +}); + +describe("PAGE_MM / Standard-Formate A0–A6 + B0–B6", () => { + it("PAPER_FORMATS listet 14 Formate (A-Reihe, dann B-Reihe)", () => { + expect(PAPER_FORMATS).toEqual([ + "a0", "a1", "a2", "a3", "a4", "a5", "a6", + "b0", "b1", "b2", "b3", "b4", "b5", "b6", + ]); + }); + it("liefert offizielle ISO-216-Masse (Hochformat, mm)", () => { + expect(sheetSizeMm("a0", "portrait")).toEqual({ w: 841, h: 1189 }); + expect(sheetSizeMm("a1", "portrait")).toEqual({ w: 594, h: 841 }); + expect(sheetSizeMm("a2", "portrait")).toEqual({ w: 420, h: 594 }); + expect(sheetSizeMm("a5", "portrait")).toEqual({ w: 148, h: 210 }); + expect(sheetSizeMm("a6", "portrait")).toEqual({ w: 105, h: 148 }); + expect(sheetSizeMm("b0", "portrait")).toEqual({ w: 1000, h: 1414 }); + expect(sheetSizeMm("b3", "portrait")).toEqual({ w: 353, h: 500 }); + expect(sheetSizeMm("b6", "portrait")).toEqual({ w: 125, h: 176 }); + }); + it("Querformat dreht die Basis (Beispiel A0)", () => { + expect(sheetSizeMm("a0", "landscape")).toEqual({ w: 1189, h: 841 }); + }); + it("jedes Format hat eine definierte Grösse", () => { + for (const f of PAPER_FORMATS) { + const s = sheetSizeMm(f, "portrait"); + expect(s.w).toBeGreaterThan(0); + expect(s.h).toBeGreaterThan(0); + } + }); +}); + +describe("isDescendant — Zyklus-Schutz", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A"), + createLayoutFolder("B", "B", "A"), + createLayoutFolder("C", "C", "B"), + createLayoutFolder("X", "X"), + ]; + it("ein Ordner ist sein eigener Nachfahre (self)", () => { + expect(isDescendant(folders, "A", "A")).toBe(true); + }); + it("erkennt direkte + tiefe Nachfahren", () => { + expect(isDescendant(folders, "B", "A")).toBe(true); + expect(isDescendant(folders, "C", "A")).toBe(true); + }); + it("nicht-verwandte Ordner sind keine Nachfahren", () => { + expect(isDescendant(folders, "X", "A")).toBe(false); + expect(isDescendant(folders, "A", "B")).toBe(false); + }); +}); + +describe("moveLayoutToFolder", () => { + const layouts = [lay("l1", "A"), lay("l2")]; + it("setzt folderId auf den Zielordner", () => { + const r = moveLayoutToFolder(layouts, "l2", "A"); + expect(r.find((l) => l.id === "l2")!.folderId).toBe("A"); + }); + it("null → zurück auf Wurzel (folderId entfernt)", () => { + const r = moveLayoutToFolder(layouts, "l1", null); + expect(r.find((l) => l.id === "l1")!.folderId).toBeUndefined(); + expect("folderId" in r.find((l) => l.id === "l1")!).toBe(false); + }); + it("unbekannte Id → unverändert", () => { + expect(moveLayoutToFolder(layouts, "nope", "A")).toEqual(layouts); + }); +}); + +describe("moveFolderToFolder", () => { + const folders: LayoutFolder[] = [ + createLayoutFolder("A", "A"), + createLayoutFolder("B", "B", "A"), + createLayoutFolder("X", "X"), + ]; + it("setzt parentId auf den Zielordner", () => { + const r = moveFolderToFolder(folders, "X", "A"); + expect(r.find((f) => f.id === "X")!.parentId).toBe("A"); + }); + it("null → zurück auf Wurzel (parentId entfernt)", () => { + const r = moveFolderToFolder(folders, "B", null); + expect(r.find((f) => f.id === "B")!.parentId).toBeUndefined(); + }); + it("kind bleibt beim Wurzel-Verschieben erhalten (Master-Ordner)", () => { + const withMaster: LayoutFolder[] = [ + createLayoutFolder("P", "P", undefined, "master"), + createLayoutFolder("Q", "Q", "P", "master"), + ]; + const r = moveFolderToFolder(withMaster, "Q", null); + const q = r.find((f) => f.id === "Q")!; + expect(q.parentId).toBeUndefined(); + expect(q.kind).toBe("master"); + }); + it("Zyklus (in eigenen Nachfahren) → No-op, Original zurück", () => { + expect(moveFolderToFolder(folders, "A", "B")).toEqual(folders); + }); + it("in sich selbst → No-op", () => { + expect(moveFolderToFolder(folders, "A", "A")).toEqual(folders); + }); +}); diff --git a/src/panels/layoutModel.ts b/src/panels/layoutModel.ts new file mode 100644 index 0000000..c63ec0c --- /dev/null +++ b/src/panels/layoutModel.ts @@ -0,0 +1,673 @@ +// Layout-Blätter mit Masterlayout (DOSSIER A3) — reine, testbare Logik. +// +// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf Layouts/Master/ +// Viewports, die Master-Vererbung der Titelblock-Werte, die Auflösung eines +// Viewports auf seinen gebundenen Ausschnitt und die Blatt-Geometrie (Papier-mm). +// Das UI-Wiring (App.tsx setProject, LayoutEditor-Rendering) baut hierauf auf. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { + Layout, + LayoutAnnotation, + LayoutAnnotationPatch, + LayoutFolder, + LayoutOrientation, + LayoutPaperFormat, + LayoutTitleBlock, + LayoutViewport, + MasterLayout, + Project, + ViewSnapshot, +} from "../model/types"; + +// ── Blatt-Geometrie ────────────────────────────────────────────────────────── + +/** + * Blattmasse je Format im Hochformat (mm) — offizielle ISO-216-Masse (auf ganze + * mm gerundet). A-Reihe A0–A6 und B-Reihe B0–B6. + */ +const PAGE_MM: Record = { + a0: { w: 841, h: 1189 }, + a1: { w: 594, h: 841 }, + a2: { w: 420, h: 594 }, + a3: { w: 297, h: 420 }, + a4: { w: 210, h: 297 }, + a5: { w: 148, h: 210 }, + a6: { w: 105, h: 148 }, + b0: { w: 1000, h: 1414 }, + b1: { w: 707, h: 1000 }, + b2: { w: 500, h: 707 }, + b3: { w: 353, h: 500 }, + b4: { w: 250, h: 353 }, + b5: { w: 176, h: 250 }, + b6: { w: 125, h: 176 }, +}; + +/** + * Auswahlreihenfolge der Standard-Formate für Dropdowns (A-Reihe, dann B-Reihe). + * Gruppierbar über {@link PAPER_FORMAT_GROUPS} (A/B) für optgroups. + */ +export const PAPER_FORMATS: LayoutPaperFormat[] = [ + "a0", "a1", "a2", "a3", "a4", "a5", "a6", + "b0", "b1", "b2", "b3", "b4", "b5", "b6", +]; + +/** Format-Liste je Reihe (A/B) — für gruppierte Dropdown-Anzeige (optgroup). */ +export const PAPER_FORMAT_GROUPS: { label: string; formats: LayoutPaperFormat[] }[] = [ + { label: "A", formats: ["a0", "a1", "a2", "a3", "a4", "a5", "a6"] }, + { label: "B", formats: ["b0", "b1", "b2", "b3", "b4", "b5", "b6"] }, +]; + +/** + * Blattmasse (mm) nach Format + Ausrichtung (Quer = Basis gedreht). Sind + * `customWidthMm`/`customHeightMm` (freie Grösse) gesetzt und positiv, gewinnen + * sie und `paper`/`orientation` werden ignoriert (additiv — Alt-Aufrufe ohne + * Custom-Argumente verhalten sich unverändert). + */ +export function sheetSizeMm( + paper: LayoutPaperFormat, + orientation: LayoutOrientation, + customWidthMm?: number, + customHeightMm?: number, +): { w: number; h: number } { + if ( + customWidthMm !== undefined && + customHeightMm !== undefined && + customWidthMm > 0 && + customHeightMm > 0 + ) { + return { w: customWidthMm, h: customHeightMm }; + } + const base = PAGE_MM[paper]; + return orientation === "landscape" ? { w: base.h, h: base.w } : base; +} + +/** + * Effektive Blattgrösse eines Layouts/Masters (mm) — respektiert die freie + * Grösse (`customWidthMm`/`customHeightMm`), sonst `paper`+`orientation`. EIN + * Ort für die „Custom übersteuert Papier"-Regel (Panel/PDF/Editor teilen ihn). + */ +export function effectiveSheetSizeMm(item: { + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + customWidthMm?: number; + customHeightMm?: number; +}): { w: number; h: number } { + return sheetSizeMm( + item.paper, + item.orientation, + item.customWidthMm, + item.customHeightMm, + ); +} + +// ── Fabriken ───────────────────────────────────────────────────────────────── + +/** Neues, leeres Layout (ohne Viewports, ohne Master). */ +export function createLayout( + id: string, + name: string, + paper: LayoutPaperFormat, + orientation: LayoutOrientation, +): Layout { + return { id, name, paper, orientation, viewports: [] }; +} + +/** Neues Masterlayout mit leerem Titelblock + gezeichnetem Rahmen. */ +export function createMasterLayout( + id: string, + name: string, + paper: LayoutPaperFormat, + orientation: LayoutOrientation, + custom?: { widthMm: number; heightMm: number }, +): MasterLayout { + return { + id, + name, + paper, + orientation, + titleBlock: {}, + border: true, + ...(custom ? { customWidthMm: custom.widthMm, customHeightMm: custom.heightMm } : {}), + }; +} + +/** + * Neuer, leerer Ordner für den Layouts- ODER Master-Baum. `kind:"master"` markiert + * einen Master-Ordner; `"layout"`/`undefined` bleibt ein (rückwärtskompatibler) + * Layout-Ordner — das Feld wird dann NICHT gesetzt (Alt-Projekte unverändert). + */ +export function createLayoutFolder( + id: string, + name: string, + parentId?: string, + kind?: "layout" | "master", +): LayoutFolder { + return { + id, + name, + ...(parentId ? { parentId } : {}), + ...(kind === "master" ? { kind } : {}), + }; +} + +// ── Layouts-Baum (Ordner + Blätter) ────────────────────────────────────────── + +/** Ein Ordner-Knoten im Baum: der Ordner, seine Unterordner und direkten Blätter. */ +export interface FolderNode { + folder: LayoutFolder; + subfolders: FolderNode[]; + layouts: Layout[]; +} + +/** Der aufgebaute Baum: Wurzel-Blätter (ohne Ordner) + Wurzel-Ordner. */ +export interface LayoutTree { + /** Layouts ohne (gültigen) `folderId` — auf Wurzelebene. */ + rootLayouts: Layout[]; + /** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */ + rootFolders: FolderNode[]; +} + +/** + * Baut aus der flachen Ordner-/Layout-Liste den verschachtelten Baum. Robust + * gegen verwaiste Referenzen: ein Layout mit unbekanntem `folderId` bzw. ein + * Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben + * Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten. + */ +export function buildLayoutTree( + folders: LayoutFolder[], + layouts: Layout[], +): LayoutTree { + const folderIds = new Set(folders.map((f) => f.id)); + const nodes = new Map(); + for (const f of folders) { + nodes.set(f.id, { folder: f, subfolders: [], layouts: [] }); + } + + const rootFolders: FolderNode[] = []; + for (const f of folders) { + const node = nodes.get(f.id)!; + const parent = + f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined; + if (parent) parent.subfolders.push(node); + else rootFolders.push(node); + } + + const rootLayouts: Layout[] = []; + for (const l of layouts) { + const node = + l.folderId && folderIds.has(l.folderId) ? nodes.get(l.folderId) : undefined; + if (node) node.layouts.push(l); + else rootLayouts.push(l); + } + + return { rootLayouts, rootFolders }; +} + +// ── Master-Baum (Ordner + Masterlayouts) ───────────────────────────────────── +// Eigener Baum NEBEN dem Layout-Baum: Master-Ordner (`kind:"master"`) tragen +// Masterlayouts als Blätter. Bewusst KEINE gemeinsame Struktur mit dem Layout- +// Baum, damit Layout- und Master-Ordner nie vermischt werden. + +/** Ein Master-Ordner-Knoten: der Ordner, seine Unterordner und direkten Master. */ +export interface MasterFolderNode { + folder: LayoutFolder; + subfolders: MasterFolderNode[]; + masters: MasterLayout[]; +} + +/** Der Master-Baum: Wurzel-Master (ohne Ordner) + Wurzel-Ordner. */ +export interface MasterTree { + /** Masterlayouts ohne (gültigen) `folderId` — auf Wurzelebene. */ + rootMasters: MasterLayout[]; + /** Master-Ordner ohne (gültigen) `parentId` — je mit Unterbaum. */ + rootFolders: MasterFolderNode[]; +} + +/** + * Baut aus der flachen Master-Ordner-/Master-Liste den verschachtelten Baum — + * analog {@link buildLayoutTree}, aber mit Masterlayouts als Blättern. Der + * Aufrufer übergibt bereits die NUR-Master-Ordner (kind:"master"); verwaiste + * Referenzen landen robust auf der Wurzelebene. + */ +export function buildMasterTree( + folders: LayoutFolder[], + masters: MasterLayout[], +): MasterTree { + const folderIds = new Set(folders.map((f) => f.id)); + const nodes = new Map(); + for (const f of folders) { + nodes.set(f.id, { folder: f, subfolders: [], masters: [] }); + } + + const rootFolders: MasterFolderNode[] = []; + for (const f of folders) { + const node = nodes.get(f.id)!; + const parent = + f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined; + if (parent) parent.subfolders.push(node); + else rootFolders.push(node); + } + + const rootMasters: MasterLayout[] = []; + for (const m of masters) { + const node = + m.folderId && folderIds.has(m.folderId) ? nodes.get(m.folderId) : undefined; + if (node) node.masters.push(m); + else rootMasters.push(m); + } + + return { rootMasters, rootFolders }; +} + +/** + * Alle Layouts eines Ordners in Baum-Reihenfolge — direkte Blätter zuerst, dann + * (rekursiv) die Blätter der Unterordner. Für den Ordner→Mehrseiten-PDF-Export + * (jedes Layout = eine Seite, deterministische Reihenfolge). + */ +export function collectFolderLayouts( + folderId: string, + folders: LayoutFolder[], + layouts: Layout[], +): Layout[] { + const tree = buildLayoutTree(folders, layouts); + const find = (nodes: FolderNode[]): FolderNode | undefined => { + for (const n of nodes) { + if (n.folder.id === folderId) return n; + const deep = find(n.subfolders); + if (deep) return deep; + } + return undefined; + }; + const node = find(tree.rootFolders); + if (!node) return []; + const out: Layout[] = []; + const walk = (n: FolderNode) => { + out.push(...n.layouts); + for (const sub of n.subfolders) walk(sub); + }; + walk(node); + return out; +} + +/** Eine geplante Ordner-PDF-Seite: das Layout + seine effektive Blattgrösse (mm). */ +export interface FolderPdfPage { + layout: Layout; + widthMm: number; + heightMm: number; +} + +/** + * Reine Seiten-Planung des Ordner→Mehrseiten-PDF (ohne DOM/jsPDF): welche + * Layouts in welcher Reihenfolge mit welcher Blattgrösse. Reihenfolge = + * Baum-Reihenfolge (siehe {@link collectFolderLayouts}). Die effektive Grösse + * respektiert die freie Grösse (Custom übersteuert Papier); ein gebundener + * Master erzwingt die Blattgrösse (das Blatt erbt Papier/Ausrichtung des Masters). + */ +export function folderPdfPages( + project: Project, + folderId: string, +): FolderPdfPage[] { + const folders = project.layoutFolders ?? []; + const layouts = project.layouts ?? []; + return collectFolderLayouts(folderId, folders, layouts).map((layout) => { + const master = findMaster(project, layout); + const { w, h } = effectiveSheetSizeMm(master ?? layout); + return { layout, widthMm: w, heightMm: h }; + }); +} + +/** + * Löscht einen Ordner (immutabel). Unterordner und Layouts des Ordners werden + * auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen, statt + * mitgelöscht zu werden — es gehen keine Blätter verloren. Liefert die neuen + * `folders`/`layouts`-Listen. + */ +export function deleteFolder( + folderId: string, + folders: LayoutFolder[], + layouts: Layout[], +): { folders: LayoutFolder[]; layouts: Layout[] } { + const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders); + const nextLayouts = layouts.map((l) => + l.folderId === folderId + ? grandParentId + ? { ...l, folderId: grandParentId } + : { ...l, folderId: undefined } + : l, + ); + return { folders: nextFolders, layouts: nextLayouts }; +} + +/** + * Löscht einen Master-Ordner (immutabel) — analog {@link deleteFolder}, aber die + * enthaltenen Masterlayouts (und Unterordner) werden auf die Elternebene + * hochgezogen statt mitgelöscht. Liefert die neuen `folders`/`masters`-Listen. + */ +export function deleteMasterFolder( + folderId: string, + folders: LayoutFolder[], + masters: MasterLayout[], +): { folders: LayoutFolder[]; masters: MasterLayout[] } { + const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders); + const nextMasters = masters.map((m) => + m.folderId === folderId + ? grandParentId + ? { ...m, folderId: grandParentId } + : { ...m, folderId: undefined } + : m, + ); + return { folders: nextFolders, masters: nextMasters }; +} + +/** + * Entfernt einen Ordner aus der flachen Liste und zieht dessen direkte + * Unterordner auf die Elternebene (`grandParentId`). Das `kind`-Feld bleibt dabei + * ERHALTEN (Spread statt Neu-Bau), damit ein hochgezogener Master-Ordner nicht + * versehentlich zum Layout-Ordner wird. Liefert die neue Liste + `grandParentId` + * (den die Blatt-Reparentierung der Aufrufer nutzt). + */ +function foldersAfterDelete( + folderId: string, + folders: LayoutFolder[], +): { folders: LayoutFolder[]; grandParentId?: string } { + const target = folders.find((f) => f.id === folderId); + const grandParentId = target?.parentId; + const nextFolders = folders + .filter((f) => f.id !== folderId) + .map((f) => + f.parentId === folderId + ? grandParentId + ? { ...f, parentId: grandParentId } + : stripParentId(f) + : f, + ); + return { folders: nextFolders, grandParentId }; +} + +/** Ordner auf die Wurzelebene setzen (parentId entfernen, `kind` bleibt erhalten). */ +function stripParentId(folder: LayoutFolder): LayoutFolder { + const next = { ...folder }; + delete next.parentId; + return next; +} + +// ── Baum-Umsortieren (Drag & Drop) ─────────────────────────────────────────── + +/** + * Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den + * Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich + * selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum). + * Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab). + */ +export function isDescendant( + folders: LayoutFolder[], + candidateId: string, + rootId: string, +): boolean { + if (candidateId === rootId) return true; + const byId = new Map(folders.map((f) => [f.id, f])); + const seen = new Set(); + let cur = byId.get(candidateId); + while (cur && cur.parentId && !seen.has(cur.id)) { + if (cur.parentId === rootId) return true; + seen.add(cur.id); + cur = byId.get(cur.parentId); + } + return false; +} + +/** + * Verschiebt ein Layout in einen anderen Ordner (immutabel). `folderId = null` + * → zurück auf die Wurzelebene. No-op, wenn die Layout-Id fehlt. + */ +export function moveLayoutToFolder( + layouts: Layout[], + layoutId: string, + folderId: string | null, +): Layout[] { + return layouts.map((l) => { + if (l.id !== layoutId) return l; + if (folderId) return { ...l, folderId }; + const next = { ...l }; + delete next.folderId; + return next; + }); +} + +/** + * Verschiebt einen Ordner unter einen neuen Elternordner (immutabel). + * `parentId = null` → Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in + * sich selbst oder einen seiner Nachfahren wandern (dann No-op — Original zurück). + */ +export function moveFolderToFolder( + folders: LayoutFolder[], + folderId: string, + parentId: string | null, +): LayoutFolder[] { + if (parentId && isDescendant(folders, parentId, folderId)) return folders; + return folders.map((f) => { + if (f.id !== folderId) return f; + if (parentId) return { ...f, parentId }; + return stripParentId(f); + }); +} + +/** + * Neuer Viewport (Standardgrösse/-position). `snapshotId` bindet ihn an einen + * Ausschnitt; Position/Grösse in Papier-mm setzt der Aufrufer per {@link patchViewport}. + */ +export function createViewport( + id: string, + snapshotId: string, + init?: Partial>, +): LayoutViewport { + return { + id, + snapshotId, + xMm: init?.xMm ?? 20, + yMm: init?.yMm ?? 20, + widthMm: init?.widthMm ?? 120, + heightMm: init?.heightMm ?? 90, + ...(init?.scaleDenominator !== undefined + ? { scaleDenominator: init.scaleDenominator } + : {}), + }; +} + +// ── Immutable CRUD (Viewports auf einem Layout) ────────────────────────────── + +/** Fügt einen Viewport an (immutabel). */ +export function addViewport(layout: Layout, viewport: LayoutViewport): Layout { + return { ...layout, viewports: [...layout.viewports, viewport] }; +} + +/** Entfernt einen Viewport per Id (immutabel). */ +export function removeViewport(layout: Layout, viewportId: string): Layout { + return { + ...layout, + viewports: layout.viewports.filter((v) => v.id !== viewportId), + }; +} + +/** Ändert Felder eines Viewports (immutabel). No-op, wenn die Id fehlt. */ +export function patchViewport( + layout: Layout, + viewportId: string, + patch: Partial>, +): Layout { + return { + ...layout, + viewports: layout.viewports.map((v) => + v.id === viewportId ? { ...v, ...patch } : v, + ), + }; +} + +// ── Fabrik + immutable CRUD (Annotationen auf einem Layout) ───────────────── +// Analog zu Viewports: Linie/Rechteck/Text als vom Viewport-Baum unabhängige +// Markup-Schicht in Papier-mm. `annotations ?? []` ist überall die Basis, damit +// Alt-Layouts (ohne das Feld) unverändert bleiben (additiv). + +/** + * Neue Annotation mit Default-Geometrie je Art (Linie/Rechteck: sichtbare + * Mindestgrösse; Text: 3.5 mm Zeilenhöhe, wie im Attribute-Editor üblich). + * `init` überschreibt einzelne Felder — der Aufrufer (Editor) übergibt i. d. R. + * die vollständige, aus dem Zeichnen gewonnene Geometrie. + */ +export function createAnnotation( + id: string, + kind: LayoutAnnotation["kind"], + init: LayoutAnnotationPatch = {}, +): LayoutAnnotation { + const colorPatch = init.color !== undefined ? { color: init.color } : {}; + if (kind === "line") { + return { + id, + kind: "line", + x1Mm: init.x1Mm ?? 20, + y1Mm: init.y1Mm ?? 20, + x2Mm: init.x2Mm ?? 80, + y2Mm: init.y2Mm ?? 20, + ...colorPatch, + ...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}), + }; + } + if (kind === "rect") { + return { + id, + kind: "rect", + xMm: init.xMm ?? 20, + yMm: init.yMm ?? 20, + widthMm: init.widthMm ?? 40, + heightMm: init.heightMm ?? 30, + ...colorPatch, + ...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}), + }; + } + return { + id, + kind: "text", + xMm: init.xMm ?? 20, + yMm: init.yMm ?? 20, + text: init.text ?? "", + heightMm: init.heightMm ?? 3.5, + ...colorPatch, + }; +} + +/** Fügt eine Annotation an (immutabel). */ +export function addAnnotation(layout: Layout, annotation: LayoutAnnotation): Layout { + return { ...layout, annotations: [...(layout.annotations ?? []), annotation] }; +} + +/** Entfernt eine Annotation per Id (immutabel). */ +export function removeAnnotation(layout: Layout, annotationId: string): Layout { + return { + ...layout, + annotations: (layout.annotations ?? []).filter((a) => a.id !== annotationId), + }; +} + +/** + * Ändert Felder einer Annotation (immutabel). No-op, wenn die Id fehlt. Der + * Patch ist bewusst art-unabhängig ({@link LayoutAnnotationPatch}) — es werden + * nur die tatsächlich übergebenen Felder gemergt. + */ +export function patchAnnotation( + layout: Layout, + annotationId: string, + patch: LayoutAnnotationPatch, +): Layout { + return { + ...layout, + annotations: (layout.annotations ?? []).map((a) => + a.id === annotationId ? ({ ...a, ...patch } as LayoutAnnotation) : a, + ), + }; +} + +// ── Auflösungen ────────────────────────────────────────────────────────────── + +/** Findet einen Ausschnitt (View-Snapshot) im Projekt. */ +export function findSnapshot( + project: Project, + snapshotId: string, +): ViewSnapshot | undefined { + return (project.viewSnapshots ?? []).find((s) => s.id === snapshotId); +} + +/** Findet das Masterlayout eines Layouts (falls gebunden). */ +export function findMaster( + project: Project, + layout: Layout, +): MasterLayout | undefined { + if (!layout.masterId) return undefined; + return (project.masterLayouts ?? []).find((m) => m.id === layout.masterId); +} + +/** + * Effektiver Massstab-Nenner eines Viewports: sein `scaleDenominator` übersteuert + * den des gebundenen Ausschnitts. Fällt auf `fallback` zurück, wenn weder + * Viewport noch Ausschnitt einen Wert tragen (defensiv, sollte nicht auftreten). + */ +export function resolveViewportScale( + viewport: LayoutViewport, + snapshot: ViewSnapshot | undefined, + fallback = 100, +): number { + return viewport.scaleDenominator ?? snapshot?.scaleDenominator ?? fallback; +} + +/** Aufgelöste Titelblock-Werte (alle Felder gefüllt) für die Darstellung. */ +export interface ResolvedTitleBlock { + projectName: string; + sheetName: string; + scale: string; + date: string; + author: string; +} + +/** + * Master-Vererbung der Titelblock-Werte: der Master liefert die Vorlagen-Texte; + * leere Felder werden aus Projekt/Layout mit sinnvollen Defaults gefüllt + * (Projektname ← `Project.name`, Blattname ← `Layout.name`, Datum ← `today`). + * Ohne Master bleibt der Titelblock trotzdem sinnvoll befüllt (nur Defaults). + * Der `scale`-Default ergibt sich aus dem ERSTEN Viewport des Layouts (falls + * vorhanden), damit ein Ein-Viewport-Blatt gleich seinen Massstab trägt. + */ +export function resolveTitleBlock( + project: Project, + layout: Layout, + master: MasterLayout | undefined, + today: string = new Date().toISOString().slice(0, 10), +): ResolvedTitleBlock { + const tb: LayoutTitleBlock = master?.titleBlock ?? {}; + const firstVp = layout.viewports[0]; + const firstSnap = firstVp ? findSnapshot(project, firstVp.snapshotId) : undefined; + const scaleN = firstVp + ? resolveViewportScale(firstVp, firstSnap) + : undefined; + const nonEmpty = (s: string | undefined): string | undefined => + s && s.trim() ? s.trim() : undefined; + return { + projectName: nonEmpty(tb.projectName) ?? project.name, + sheetName: nonEmpty(tb.sheetName) ?? layout.name, + scale: nonEmpty(tb.scale) ?? (scaleN ? `1:${scaleN}` : "—"), + date: nonEmpty(tb.date) ?? today, + author: nonEmpty(tb.author) ?? "", + }; +} + +/** + * Sichtbare Kategorie-Codes eines Ausschnitts als Set (für `generatePlan`). Aus + * der gespeicherten `layerVisibility`-Map (Code → sichtbar) abgeleitet. + */ +export function visibleCodesFromSnapshot(snap: ViewSnapshot): Set { + const out = new Set(); + for (const [code, visible] of Object.entries(snap.layerVisibility)) { + if (visible) out.add(code); + } + return out; +} diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 0cf702d..0fb5c54 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -133,6 +133,12 @@ export interface PlanViewHandle { pxPerMeter: () => number; /** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */ viewCenterModel: () => Vec2; + /** + * Auf eine beliebige Punktmenge (Modell-Meter) einpassen, z. B. die Bounding- + * Box neu importierter Kontext-Objekte (Auto-Zoom nach Geo-/DXF-Import). + * Leere Punktmenge → No-op (kein Fit, kein Crash). + */ + fitBounds: (pts: Vec2[]) => void; } /** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */ @@ -175,6 +181,17 @@ export interface PlanSelection { * sonst `null`. */ roomId: string | null; + /** + * ID des getroffenen extrudierten Körpers (truck-Integration, Footprint- + * Umriss), wenn der Klick ihn traf und KEIN Element höherer Priorität + * (Wand/2D/Decke/Öffnung/Treppe); sonst `null`. + */ + extrudedSolidId: string | null; + /** + * ID der getroffenen Stütze (Column, Grundriss-Querschnitt), wenn der Klick sie + * traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`. + */ + columnId: 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 @@ -263,6 +280,17 @@ export interface PlanViewProps { * Fläche JEDES dieser Räume mit einer Akzentkontur. */ selectedRoomIds?: string[]; + /** + * Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert + * von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit + * einer Akzentkontur. + */ + selectedExtrudedSolidIds?: string[]; + /** + * Aktuell ausgewählte Stützen-IDs (Tragwerk; kontrolliert von App). Die Ansicht + * markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur. + */ + selectedColumnIds?: string[]; /** * Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID. * PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick. @@ -273,6 +301,8 @@ export interface PlanViewProps { onStampMove?: (roomId: string, delta: Vec2) => void; /** Doppelklick auf einen Raum-Stempel: Editor öffnen. */ onStampEdit?: (roomId: string) => void; + /** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */ + onEditDrawingText?: (drawingId: string) => void; /** * Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach * der CAD-Konvention (siehe {@link MarqueeSelection}). Optional. @@ -348,7 +378,11 @@ export interface GripHandlers { * wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt * `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität). */ - onMoveBody(delta: Vec2, target?: { wallId?: string; drawingId?: string }): void; + onMoveBody( + delta: Vec2, + target?: { wallId?: string; drawingId?: string }, + mods?: ToolMods, + ): void; /** * Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht * (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe @@ -374,9 +408,12 @@ export const PlanView = forwardRef( selectedOpeningIds, selectedStairIds, selectedRoomIds, + selectedExtrudedSolidIds, + selectedColumnIds, roomStamp, onStampMove, onStampEdit, + onEditDrawingText, activeTool, toolInputActive, toolHandlers, @@ -638,6 +675,10 @@ export const PlanView = forwardRef( const v = viewRef.current; return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M }; }, + fitBounds: (pts) => { + if (pts.length === 0) return; // leerer Import → kein Fit, kein Crash + setView(fitBoxFor(pts, toScreenRef.current)); + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [], @@ -841,7 +882,34 @@ export const PlanView = forwardRef( bestId = p.drawingId; } } - return bestId; + if (bestId) return bestId; + // Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik). + return pickDrawingText(clientX, clientY); + }; + + // Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell- + // Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen); + // Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null. + const pickDrawingText = (clientX: number, clientY: number): string | null => { + const m = rawModelAt(clientX, clientY); + for (const p of plan.primitives) { + if (p.kind !== "drawingText" || !p.drawingId) continue; + const singleW = Math.max(1, p.text.length) * p.heightM * 0.6; + // Textspalte: Breite = wrapWidth, Höhe = geschätzte Zeilenzahl · Zeilenhöhe. + const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW; + const lines = + p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1; + const pad = p.heightM * 0.6; + const minX = p.at.x - pad; + const maxX = p.at.x + w + pad; + // Baseline am Anker; Text läuft nach unten (Zeilen) und etwas nach oben (Oberlängen). + const minY = p.at.y - lines * p.heightM * 1.25 - pad; + const maxY = p.at.y + p.heightM * 0.4 + pad; + if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) { + return p.drawingId; + } + } + return null; }; // Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit @@ -917,6 +985,35 @@ export const PlanView = forwardRef( return null; }; + // Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss + // ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon + // trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie). + const pickExtrudedSolid = (clientX: number, clientY: number): string | null => { + const v = clientToView(clientX, clientY, view); + const m = viewToModel(v.x, v.y); + for (let i = plan.primitives.length - 1; i >= 0; i--) { + const p = plan.primitives[i]; + if (p.kind === "polygon" && p.extrudedSolidId && pointInPolygon(m, p.pts)) { + return p.extrudedSolidId; + } + } + return null; + }; + + // Getroffene Stütze (Tragwerk): ihr Querschnitt ist eine gefüllte Poché + // (Polygon mit columnId) — per Punkt-in-Polygon als FLÄCHE anklickbar. + const pickColumn = (clientX: number, clientY: number): string | null => { + const v = clientToView(clientX, clientY, view); + const m = viewToModel(v.x, v.y); + for (let i = plan.primitives.length - 1; i >= 0; i--) { + const p = plan.primitives[i]; + if (p.kind === "polygon" && p.columnId && pointInPolygon(m, p.pts)) { + return p.columnId; + } + } + return null; + }; + // Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw. // 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das // Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER @@ -1188,7 +1285,7 @@ export const PlanView = forwardRef( if (md && md.pointerId === p.pointerId) { const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y }; if (delta.x !== 0 || delta.y !== 0) { - gripHandlers!.onMoveBody(delta, md.target); + gripHandlers!.onMoveBody(delta, md.target, mods); md.last = raw; } return; @@ -1402,28 +1499,46 @@ export const PlanView = forwardRef( const openingId = pickOpening(e.clientX, e.clientY); const wallId = openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null; - // Traf der Klick KEINE Wand/Öffnung: zuerst eine getroffene gefüllte 2D-Fläche - // (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe. + // Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché — + // ein direkter Klick auf sie trifft zuerst die Stütze (hit.columnId), sonst + // die Fläche per Punkt-in-Polygon. Priorität direkt nach Öffnung/Wand. + const polyColumnId = + hit && hit.kind === "polygon" ? hit.columnId ?? null : null; + const columnId = + openingId || wallId + ? null + : polyColumnId ?? pickColumn(e.clientX, e.clientY); + // Traf der Klick KEINE Wand/Öffnung/Stütze: 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 = - openingId || wallId + openingId || wallId || columnId ? null : polyDrawingId ?? pickDrawing(e.clientX, e.clientY); + // Extrudierter Körper (truck-Integration): sein Footprint-Umriss ersetzt + // die frühere Drawing2D-Fläche, also gleiche Priorität wie drawingId. + const polyExtrudedSolidId = + hit && hit.kind === "polygon" ? hit.extrudedSolidId ?? null : null; + const extrudedSolidId = + openingId || wallId || columnId || drawingId + ? null + : polyExtrudedSolidId ?? pickExtrudedSolid(e.clientX, e.clientY); // Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die // Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick // auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke. const hitCeilingId = hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null; - const ceilingId = openingId || wallId || drawingId ? null : hitCeilingId; + const ceilingId = + openingId || wallId || columnId || drawingId || extrudedSolidId ? null : hitCeilingId; // Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei). const stairId = - openingId || wallId || drawingId || ceilingId + openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId ? null : pickStair(e.clientX, e.clientY); // Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen. const roomId = - openingId || wallId || drawingId || ceilingId || stairId + openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId ? null : pickRoom(e.clientX, e.clientY); onSelect({ @@ -1435,6 +1550,8 @@ export const PlanView = forwardRef( openingId, stairId, roomId, + extrudedSolidId, + columnId, shift: e.shiftKey, }); } @@ -1490,6 +1607,14 @@ export const PlanView = forwardRef( toolHandlers!.onToolCommit(); return; } + // Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum). + if (onEditDrawingText) { + const tid = pickDrawingText(e.clientX, e.clientY); + if (tid) { + onEditDrawingText(tid); + return; + } + } // Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor. if (onStampEdit) { const rid = @@ -1614,6 +1739,33 @@ export const PlanView = forwardRef( ); }, [plan, selectedRoomIds]); + // Hervorhebung der selektierten extrudierten Körper (truck-Integration): der + // Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen + // — analog zur Raum-Auswahl. + const highlightExtrudedSolidPolys = useMemo(() => { + const ids = + selectedExtrudedSolidIds && selectedExtrudedSolidIds.length + ? new Set(selectedExtrudedSolidIds) + : null; + if (!ids) return []; + return plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.extrudedSolidId != null && ids.has(p.extrudedSolidId), + ); + }, [plan, selectedExtrudedSolidIds]); + + // Hervorhebung der selektierten Stützen (Tragwerk): der Querschnitt-Umriss + // (Polygon mit columnId) mit Akzentkontur überzeichnen — analog Extrusion/Raum. + const highlightColumnPolys = useMemo(() => { + const ids = + selectedColumnIds && selectedColumnIds.length ? new Set(selectedColumnIds) : null; + if (!ids) return []; + return plan.primitives.filter( + (p): p is Extract => + p.kind === "polygon" && p.columnId != null && ids.has(p.columnId), + ); + }, [plan, selectedColumnIds]); + // Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen // bündeln, damit sie als EIN / gezeichnet werden können — // so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die @@ -1819,6 +1971,30 @@ export const PlanView = forwardRef( pointerEvents="none" /> ))} + {/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */} + {highlightExtrudedSolidPolys.map((poly, i) => ( + `${s.x},${s.y}`) + .join(" ")} + pointerEvents="none" + /> + ))} + {/* Auswahl-Hervorhebung der gewählten Stützen (Tragwerk). */} + {highlightColumnPolys.map((poly, i) => ( + `${s.x},${s.y}`) + .join(" ")} + pointerEvents="none" + /> + ))} {/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */} {highlightOpeningPrims.map((p, i) => p.kind === "line" ? ( @@ -2024,7 +2200,7 @@ function DraftOverlay({ snapColor: string; }) { const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich) - const markSize = 4.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (gleich groß wie Stützpunkt) + const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar) return ( {/* Vorschau-Formen. */} @@ -3081,12 +3257,32 @@ function renderPrimitive( ); } case "drawingText": { - // Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox- - // Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). + // Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern → + // viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). const c = toScreen(p.at); const fontSize = p.heightM * PX_PER_M; // Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg. const deg = -(p.angle * 180) / Math.PI; + // Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite + // umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile. + const lineH = fontSize * 1.25; + const lines: string[] = []; + if (p.wrapWidth && p.wrapWidth > 0) { + const maxPx = p.wrapWidth * PX_PER_M; + const charPx = fontSize * 0.55; + const maxChars = Math.max(1, Math.floor(maxPx / charPx)); + for (const paragraph of p.text.split("\n")) { + let line = ""; + for (const word of paragraph.split(/\s+/)) { + if (line === "") line = word; + else if ((line.length + 1 + word.length) <= maxChars) line += " " + word; + else { lines.push(line); line = word; } + } + lines.push(line); + } + } else { + lines.push(p.text); + } return ( - {p.text} + {lines.length <= 1 ? ( + p.text + ) : ( + lines.map((ln, li) => ( + + {ln === "" ? "​" : ln} + + )) + )} ); } diff --git a/src/plan/column.test.ts b/src/plan/column.test.ts new file mode 100644 index 0000000..1ceb308 --- /dev/null +++ b/src/plan/column.test.ts @@ -0,0 +1,162 @@ +/** + * Unit-Tests für das Stützen-Bauteil (Column) — DOSSIER-Audit A4 Tragwerk: + * • 2D-Grundriss (generatePlan): eine Stütze erzeugt ein Poché-Polygon mit + * columnId an der richtigen Position; die Rotation dreht den Rechteck- + * Querschnitt. + * • 3D (projectToModel3d/emitColumns): eine Stütze wird zu einem Prisma-Mesh + * (Rechteck-Box bzw. Kreis-Prisma) mit der erwarteten vertikalen Ausdehnung. + * • Vertikale Lage (columnVerticalExtent): Geschoss-Default sowie UK/OK-Anker. + */ + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import { projectToModel3d } from "./toWalls3d"; +import { columnVerticalExtent } from "../model/wall"; +import { columnFootprint } from "../geometry/column"; +import type { Column, Project } from "../model/types"; + +/** Minimalprojekt (zwei Geschosse gestapelt) mit einer konfigurierbaren Stütze. */ +function projectWithColumn(column: Column): Project { + return { + id: "t", + name: "T", + lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [{ id: "a", name: "A", color: "#c8c8cc", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 0 }, + { id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 3 }, + ], + layers: [ + { code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }, + { code: "50", name: "Tragwerk", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }, + ], + walls: [], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + columns: [column], + drawings2d: [], + context: [], + }; +} + +const visible = new Set(["20", "50"]); + +/** Basis-Stütze: Rechteck 0.4×0.2 an (2,3), Höhe 3, keine Drehung. */ +const rectColumn: Column = { + id: "C1", + type: "column", + floorId: "eg", + categoryCode: "50", + position: { x: 2, y: 3 }, + profile: { kind: "rect", width: 0.4, depth: 0.2 }, + rotation: 0, + height: 3, +}; + +describe("Stütze — 2D-Grundriss (generatePlan)", () => { + it("erzeugt ein Poché-Polygon mit columnId am Rechteck-Querschnitt", () => { + const plan = generatePlan(projectWithColumn(rectColumn), "eg", visible); + const polys = plan.primitives.filter( + (p) => p.kind === "polygon" && p.columnId === "C1", + ); + expect(polys.length).toBe(1); + const poly = polys[0]; + if (poly.kind !== "polygon") throw new Error("kein Polygon"); + // Rechteck 0.4×0.2 um (2,3): Ecken bei x∈{1.8,2.2}, y∈{2.9,3.1}. + const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b); + const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b); + expect(poly.pts.length).toBe(4); + expect(xs[0]).toBeCloseTo(1.8, 6); + expect(xs[3]).toBeCloseTo(2.2, 6); + expect(ys[0]).toBeCloseTo(2.9, 6); + expect(ys[3]).toBeCloseTo(3.1, 6); + }); + + it("dreht den Querschnitt um 90° (Breite/Tiefe vertauscht)", () => { + const rotated: Column = { ...rectColumn, rotation: Math.PI / 2 }; + const plan = generatePlan(projectWithColumn(rotated), "eg", visible); + const poly = plan.primitives.find( + (p) => p.kind === "polygon" && p.columnId === "C1", + ); + if (!poly || poly.kind !== "polygon") throw new Error("kein Polygon"); + // Nach 90°-Drehung: X-Ausdehnung = ±depth/2 = ±0.1, Y = ±width/2 = ±0.2. + const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b); + const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b); + expect(xs[0]).toBeCloseTo(1.9, 6); + expect(xs[3]).toBeCloseTo(2.1, 6); + expect(ys[0]).toBeCloseTo(2.8, 6); + expect(ys[3]).toBeCloseTo(3.2, 6); + }); + + it("blendet die Stütze aus, wenn Kategorie 50 unsichtbar ist", () => { + const plan = generatePlan(projectWithColumn(rectColumn), "eg", new Set(["20"])); + expect(plan.primitives.some((p) => p.kind === "polygon" && p.columnId)).toBe(false); + }); +}); + +describe("Stütze — 3D (projectToModel3d / emitColumns)", () => { + it("erzeugt eine Rechteck-Box (8 Ecken) über die volle Höhe", () => { + const model = projectToModel3d(projectWithColumn(rectColumn)); + const meshes = model.meshes.filter((m) => m.kind === "extrusion"); + expect(meshes.length).toBe(1); + const mesh = meshes[0]; + // 4 Footprint-Punkte × (unten + oben) = 8 Vertices. + expect(mesh.positions.length).toBe(8 * 3); + // z-Werte (jedes 3. Element) liegen bei 0 (UK) und 3 (OK). + const zs = mesh.positions.filter((_, i) => i % 3 === 2); + expect(Math.min(...zs)).toBeCloseTo(0, 6); + expect(Math.max(...zs)).toBeCloseTo(3, 6); + }); + + it("erzeugt ein tesselliertes Kreis-Prisma (64 Ecken)", () => { + const round: Column = { + ...rectColumn, + profile: { kind: "round", radius: 0.15 }, + }; + const model = projectToModel3d(projectWithColumn(round)); + const mesh = model.meshes.find((m) => m.kind === "extrusion"); + if (!mesh) throw new Error("kein Prisma-Mesh"); + // 32-Eck × (unten + oben) = 64 Vertices. + expect(mesh.positions.length).toBe(64 * 3); + }); +}); + +describe("Stütze — vertikale Lage (columnVerticalExtent)", () => { + it("nutzt Geschoss-UK + Höhe als Default", () => { + const { zBottom, zTop } = columnVerticalExtent( + projectWithColumn(rectColumn), + rectColumn, + ); + expect(zBottom).toBeCloseTo(0, 6); + expect(zTop).toBeCloseTo(3, 6); + }); + + it("bindet UK/OK an Anker (custom + floor)", () => { + const anchored: Column = { + ...rectColumn, + bottom: { mode: "custom", z: 0.5 }, + top: { mode: "floor", floorId: "og" }, // baseElevation OG = 3 + }; + const { zBottom, zTop } = columnVerticalExtent(projectWithColumn(anchored), anchored); + expect(zBottom).toBeCloseTo(0.5, 6); + expect(zTop).toBeCloseTo(3, 6); + }); +}); + +describe("Stütze — Footprint-Helfer (columnFootprint)", () => { + it("tesselliert ein rundes Profil als 32-Eck um die Position", () => { + const round: Column = { ...rectColumn, profile: { kind: "round", radius: 0.15 } }; + const pts = columnFootprint(round); + expect(pts.length).toBe(32); + // Alle Punkte liegen im Radius 0.15 um (2,3). + for (const p of pts) { + const d = Math.hypot(p.x - 2, p.y - 3); + expect(d).toBeCloseTo(0.15, 6); + } + }); +}); diff --git a/src/plan/generatePlan.overrides.test.ts b/src/plan/generatePlan.overrides.test.ts new file mode 100644 index 0000000..a8c0aa8 --- /dev/null +++ b/src/plan/generatePlan.overrides.test.ts @@ -0,0 +1,181 @@ +// Integrations-Tests: der Grundriss-Pfad (generatePlan) wendet die grafischen +// Override-Regeln (Project.overrideRules) als OBERSTE Schicht über der +// By-Layer/By-Object-Auflösung an — als reines Rendering-Overlay (die +// Elementdaten bleiben unverändert). + +import { describe, it, expect } from "vitest"; +import { generatePlan } from "./generatePlan"; +import type { OverrideRule, Project, Wall } from "../model/types"; + +/** Minimalprojekt: eine Wand (Ebene „Wände"/20) + eine 2D-Linie (Ebene 60). */ +function project(overrideRules?: OverrideRule[]): Project { + const wall: Wall = { + id: "W1", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 0, y: 0 }, + end: { x: 4, y: 0 }, + wallTypeId: "aw", + height: 2.6, + }; + return { + id: "t", + name: "T", + lineStyles: [ + { id: "dashed", name: "Gestrichelt", weight: 0.13, color: "#111", dash: [2, 1] }, + ], + hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }], + components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "AW Beton", layers: [{ componentId: "a", thickness: 0.2 }] }], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + ], + layers: [ + { code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }, + { code: "60", name: "Zeichnung", color: "#0a0a0a", lw: 0.25, visible: true, locked: false }, + ], + walls: [wall], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [ + { + id: "D1", + type: "drawing2d", + levelId: "eg", + categoryCode: "60", + geom: { shape: "line", a: { x: 0, y: 1 }, b: { x: 2, y: 1 } }, + }, + ], + context: [], + overrideRules, + }; +} + +const visible = new Set(["20", "60"]); + +/** Alle Polygone der Wand W1 (grob = eine Sammelfläche mit Umriss). */ +function wallPolys(p: ReturnType) { + return p.primitives.filter( + (pr): pr is Extract => + pr.kind === "polygon" && pr.wallId === "W1", + ); +} + +/** Die Linien-Primitive des 2D-Elements D1. */ +function drawingLines(p: ReturnType) { + return p.primitives.filter( + (pr): pr is Extract => + pr.kind === "line" && pr.drawingId === "D1", + ); +} + +describe("generatePlan — grafische Overrides (Regel-Engine)", () => { + it("layer_name-Regel färbt die Wand-Umrisslinie um (Farbe-Aktion)", () => { + const rules: OverrideRule[] = [ + { + id: "r1", + name: "Wände rot", + enabled: true, + condition: { type: "layer_name", operator: "equals", value: "Wände" }, + actions: { color: "#ff0000" }, + }, + ]; + const plan = generatePlan(project(rules), "eg", visible, undefined, "grob"); + const polys = wallPolys(plan); + expect(polys.length).toBeGreaterThan(0); + expect(polys.every((p) => p.stroke === "#ff0000")).toBe(true); + }); + + it("Lineweight-Regel wirkt identisch zum expliziten Attribut-Override", () => { + const rules: OverrideRule[] = [ + { + id: "r1", + name: "Wände dick", + enabled: true, + condition: { type: "layer_name", operator: "equals", value: "20" }, + actions: { lineweight: 0.7 }, + }, + ]; + const withRule = generatePlan(project(rules), "eg", visible, undefined, "grob"); + // Referenz: dieselbe Strichstärke direkt als Element-Attribut gesetzt. + const ref = project(); + ref.walls[0].strokeWeight = 0.7; + const withAttr = generatePlan(ref, "eg", visible, undefined, "grob"); + expect(wallPolys(withRule).map((p) => p.strokeWidthMm)).toEqual( + wallPolys(withAttr).map((p) => p.strokeWidthMm), + ); + // Und unterscheidet sich vom Plan ohne Regel (Regel wirkt tatsächlich). + const base = generatePlan(project(), "eg", visible, undefined, "grob"); + expect(wallPolys(withRule)[0].strokeWidthMm).not.toBe( + wallPolys(base)[0].strokeWidthMm, + ); + }); + + it("deaktivierte Regel wirkt nicht (Standard-Strich bleibt)", () => { + const rules: OverrideRule[] = [ + { + id: "r1", + name: "Wände rot (aus)", + enabled: false, + condition: { type: "layer_name", operator: "equals", value: "Wände" }, + actions: { color: "#ff0000" }, + }, + ]; + const plan = generatePlan(project(rules), "eg", visible, undefined, "grob"); + expect(wallPolys(plan).some((p) => p.stroke === "#ff0000")).toBe(false); + }); + + it("Regel-Override gewinnt über den Per-Instanz-Farbwert (oberste Schicht)", () => { + const rules: OverrideRule[] = [ + { + id: "r1", + name: "Wände rot", + enabled: true, + condition: { type: "layer_name", operator: "equals", value: "Wände" }, + actions: { color: "#ff0000" }, + }, + ]; + const p = project(rules); + p.walls[0].color = "#00ff00"; // Instanz-Farbe — die Regel liegt darüber. + const plan = generatePlan(p, "eg", visible, undefined, "grob"); + expect(wallPolys(plan).every((pr) => pr.stroke === "#ff0000")).toBe(true); + }); + + it("object_name-Regel + Linienstil-Aktion: 2D-Linie erhält das Strichmuster", () => { + const rules: OverrideRule[] = [ + { + id: "r1", + name: "Linien gestrichelt", + enabled: true, + condition: { type: "object_name", operator: "equals", value: "line" }, + actions: { linetypeId: "dashed", color: "#ff00ff" }, + }, + ]; + const plan = generatePlan(project(rules), "eg", visible, undefined, "mittel"); + const lines = drawingLines(plan); + expect(lines.length).toBeGreaterThan(0); + expect(lines.every((l) => l.color === "#ff00ff")).toBe(true); + expect(lines.every((l) => Array.isArray(l.dash) && l.dash.length === 2)).toBe(true); + }); + + it("reines Rendering-Overlay: die Projektdaten bleiben unverändert", () => { + const rules: OverrideRule[] = [ + { + id: "r1", + name: "Alles rot", + enabled: true, + condition: { type: "layer_name", operator: "contains", value: "" }, + actions: { color: "#ff0000", lineweight: 1.0 }, + }, + ]; + const p = project(rules); + generatePlan(p, "eg", visible, undefined, "grob"); + expect(p.walls[0].color).toBeUndefined(); + expect(p.walls[0].strokeWeight).toBeUndefined(); + expect(p.drawings2d[0].lineStyleId).toBeUndefined(); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 83ec943..8752c80 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -8,11 +8,14 @@ import type { AttributeSource, Ceiling, + Column, Component, Drawing2D, + ExtrudedSolid, HatchPattern, LayerCategory, Opening, + OverrideActions, Project, Room, Stair, @@ -20,6 +23,8 @@ import type { Wall, } from "../model/types"; import { + columnLabel, + columnsOfFloor, flattenCategories, getCeilingType, getComponent, @@ -27,11 +32,15 @@ import { getLayerCategory, getLineStyle, getWallType, + openingLabel, openingsOfWall, roomsOfFloor, + stairLabel, stairsOfFloor, wallTypeThickness, } from "../model/types"; +import { columnFootprint } from "../geometry/column"; +import { effectiveOverrides, hasOverrides } from "../overrides/engine"; import { evaluateRoom, siaLabel } from "../geometry/roomArea"; import type { RichTextDoc } from "../text/richText"; import { docFromText } from "../text/richText"; @@ -42,6 +51,7 @@ import { stairGeometry, stairCut, stairOutline } from "../geometry/stair"; import type { SpiralOutlineSegments } from "../geometry/stair"; import { doorSymbol, + openingGapQuad, openingInterval, openingJambs, wallAxisFrame, @@ -224,6 +234,17 @@ export type Primitive = stairId?: string; /** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */ roomId?: string; + /** + * ID des extrudierten Körpers (truck-Integration), falls dieses Polygon der + * Grundriss-Footprint einer Extrusion ist (für eine spätere Links-Klick- + * Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet). + */ + extrudedSolidId?: string; + /** + * ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt + * (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche). + */ + columnId?: string; } | { kind: "line"; @@ -312,6 +333,12 @@ export type Primitive = /** Drehung in RADIANT (CCW im Modell). */ angle: number; color: string; + /** + * Optionale Spaltenbreite in Modell-Metern (Textspalte): ist sie gesetzt, + * bricht der Renderer den Text wortweise auf diese Breite um. Fehlt sie, + * bleibt es einzeiliger Text. + */ + wrapWidth?: number; /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */ drawingId?: string; greyed?: boolean; @@ -597,15 +624,6 @@ export function generatePlan( const primitives: Primitive[] = []; // Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen). const floor = project.drawingLevels.find((z) => z.id === floorId); - // 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); @@ -614,26 +632,92 @@ export function generatePlan( // "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur). const catByCode = categoryMap(project.layers); + // ── Grafische Overrides (Regel-Engine, src/overrides/engine.ts) ─────────── + // Effektive Aktionen je Element als OBERSTE Schicht ÜBER der regulären + // By-Layer/By-Object-Auflösung: die Elemente werden hier — rein zur + // Renderzeit — mit den Regel-Ergebnissen dekoriert (flache Kopien; das + // Projekt bleibt unverändert, jederzeit reversibel). `layer_name` matcht + // Ebenen-Name ODER -Code, `object_name` den Element-/Typ-Namen. + const overrideFor = ( + categoryCode: string, + objectName?: string, + ): OverrideActions => + effectiveOverrides(project.overrideRules, { + layerName: catByCode.get(categoryCode)?.name, + layerCode: categoryCode, + objectName, + }); + const wallTypeNameById = new Map(project.wallTypes.map((t) => [t.id, t.name])); + const ceilingTypeNameById = new Map( + (project.ceilingTypes ?? []).map((t) => [t.id, t.name]), + ); + + // 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, + ) + .map((w) => { + // Regel-Override gewinnt über den Per-Instanz-Wert (oberste Schicht): + // Farbe → Umriss-/Fugenfarbe (Wall.color), Strichstärke → strokeWeight + // (der topmost-Wert der bestehenden resolveStrokeWeight-Kette). + const ov = overrideFor(w.categoryCode, wallTypeNameById.get(w.wallTypeId)); + if (!hasOverrides(ov)) return w; + return { + ...w, + color: ov.color ?? w.color, + strokeWeight: ov.lineweight ?? w.strokeWeight, + }; + }); + // 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, - ); + const drawings = project.drawings2d + .filter( + (d) => + d.levelId === floorId && + visibleCodes.has(d.categoryCode) && + categoryDisplay(d.categoryCode).render, + ) + .map((d) => { + // `object_name` matcht bei freier 2D-Geometrie den Formnamen + // (line/polyline/rect/circle/arc/text). Linienstil-Override nur hier — + // Drawing2D ist das einzige Element mit LineStyle-Referenz. + const ov = overrideFor(d.categoryCode, d.geom.shape); + if (!hasOverrides(ov)) return d; + return { + ...d, + color: ov.color ?? d.color, + weightMm: ov.lineweight ?? d.weightMm, + lineStyleId: ov.linetypeId ?? d.lineStyleId, + }; + }); // Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien + // Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der // Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt, // UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben). - const ceilings = (project.ceilings ?? []).filter( - (c) => - c.floorId === floorId && - visibleCodes.has(c.categoryCode) && - categoryDisplay(c.categoryCode).render, - ); + const ceilings = (project.ceilings ?? []) + .filter( + (c) => + c.floorId === floorId && + visibleCodes.has(c.categoryCode) && + categoryDisplay(c.categoryCode).render, + ) + .map((c) => { + // Decken: nur der Farb-Override wirkt (der Umriss ist eine feste + // Überkopf-Haarlinie, siehe addCeilingPoche — keine Strichstärke). + const typeName = + (c.ceilingTypeId ? ceilingTypeNameById.get(c.ceilingTypeId) : undefined) ?? + wallTypeNameById.get(c.wallTypeId); + const ov = overrideFor(c.categoryCode, typeName); + return ov.color !== undefined ? { ...c, color: ov.color } : c; + }); // Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse: // die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort, // wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig, @@ -650,10 +734,17 @@ export function generatePlan( // Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel // (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände // lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob". - const rooms = roomsOfFloor(project, floorId).filter( - (r) => - visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render, - ); + const rooms = roomsOfFloor(project, floorId) + .filter( + (r) => + visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render, + ) + .map((r) => { + // Räume: `object_name` matcht den Raum-Namen; der Farb-Override färbt + // Fläche + Stempel (Room.color) um. + const ov = overrideFor(r.categoryCode, r.name); + return ov.color !== undefined ? { ...r, color: ov.color } : r; + }); for (const room of rooms) { const greyed = categoryDisplay(room.categoryCode).greyed; addRoomArea(primitives, room, greyed, detail); @@ -701,8 +792,13 @@ export function generatePlan( for (const o of openings) { const d = categoryDisplay(o.categoryCode); if (visibleCodes.has(o.categoryCode) && d.render) { - const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM; - addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm); + // Override-Regeln: `object_name` matcht das Öffnungs-Label + // („Tür 90×210" / „Fenster 120×140"). + const ov = overrideFor(o.categoryCode, openingLabel(o)); + const oLwMm = + ov.lineweight ?? lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM; + const effO = ov.color !== undefined ? { ...o, color: ov.color } : o; + addOpeningSymbol(primitives, project, wall, effO, d.greyed, detail, oLwMm); } } } @@ -717,8 +813,28 @@ export function generatePlan( ); for (const stair of stairs) { const greyed = categoryDisplay(stair.categoryCode).greyed; - const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM; - addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight); + // Override-Regeln: `object_name` matcht das Treppen-Label („Gerade 16 STG"). + const ov = overrideFor(stair.categoryCode, stairLabel(stair)); + const lwMm = + ov.lineweight ?? lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM; + const effStair = ov.color !== undefined ? { ...stair, color: ov.color } : stair; + addStairSymbol(primitives, project, effStair, greyed, lwMm, cutHeight); + } + + // Stützen (Tragwerk) dieses Geschosses: Querschnitt-Poché (Rechteck/Kreis), + // um `rotation` gedreht, auf Kategorie „50". Über der Wand-Poché gezeichnet. + const columns = columnsOfFloor(project, floorId).filter( + (col) => + visibleCodes.has(col.categoryCode) && categoryDisplay(col.categoryCode).render, + ); + for (const column of columns) { + const greyed = categoryDisplay(column.categoryCode).greyed; + const category = catByCode.get(column.categoryCode); + const ov = overrideFor(column.categoryCode, columnLabel(column)); + const lwMm = + ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM); + const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column; + addColumnPoche(primitives, project, effCol, greyed, lwMm, category); } // 2D-Zeichengeometrie zuletzt (über der Poché). @@ -727,6 +843,14 @@ export function generatePlan( addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode, catByCode); } + // Extrudierte Körper (truck-Integration): das Quell-Profil wurde beim + // Extrudieren aus `drawings2d` entfernt (s. `extrude.ts` appendExtrudedSolid) + // — im Grundriss erscheint stattdessen der Footprint-Umriss des Körpers + // selbst (Haarlinie, kein Poché/Schraffur; Farbe wie das 3D-Orange). + for (const solid of project.extrudedSolids ?? []) { + if (solid.levelId === floorId) addExtrudedSolidOutline(primitives, solid); + } + // 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. @@ -956,6 +1080,79 @@ function categoryMap(layers: LayerCategory[]): Map { return map; } +/** Footprint-Farbe der truck-Extrusionen im Grundriss (identisch zum 3D-Orange EXTRUSION_RGB in toWalls3d.ts). */ +const EXTRUSION_STROKE = "#d98c40"; + +/** + * Footprint-Umriss eines extrudierten Körpers (truck-Integration) im Grundriss: + * reine Haarlinie ohne Füllung/Schraffur — analog zur Ansichtslinie einer Wand + * unter der Schnitthöhe ({@link addWallPoche} `viewOnly`), da der Körper (noch) + * keine Schnitt-Poché/Kategorie-Zuordnung hat. + */ +function addExtrudedSolidOutline(out: Primitive[], solid: ExtrudedSolid): void { + out.push({ + kind: "polygon", + pts: solid.points, + fill: "none", + stroke: EXTRUSION_STROKE, + strokeWidthMm: SYMBOL_HAIRLINE_MM, + hatch: NO_HATCH, + extrudedSolidId: solid.id, + }); +} + +/** Fallback-Poché-Füllung einer Stütze ohne (auflösbares) Bauteil: neutrale Tinte. */ +const COLUMN_FALLBACK_FILL = "#3a3f47"; + +/** + * Grundriss-Querschnitt einer Stütze (Column) als Schnitt-Poché: gefülltes + * Polygon (Rechteck bzw. tesselliertes Kreisprofil), an `position` platziert und + * um `rotation` gedreht. Trägt — sofern ein Bauteil (`componentId`) auflösbar ist + * — dessen Füllung + Schraffur (dieselbe Auflösung wie Wand/Decke, {@link + * resolveWallSectionStyle}); sonst eine neutrale Vollton-Poché. `columnId` macht + * die Fläche links-klick-selektierbar (analog Extrusion/Raum). + */ +function addColumnPoche( + out: Primitive[], + project: Project, + column: Column, + greyed: boolean, + lwMm: number, + category: LayerCategory | undefined, +): void { + const pts = columnFootprint(column); + const stroke = column.color ?? category?.color ?? MONO_INK; + let fill = column.color ?? category?.color ?? COLUMN_FALLBACK_FILL; + let hatch: HatchRender = NO_HATCH; + if (column.componentId) { + try { + const comp = getComponent(project, column.componentId); + const hatchId = + resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId; + const resolved = resolveHatch( + project, + hatchId, + undefined, + resolveForeground(comp, undefined, category, undefined), + ); + hatch = resolved; + fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER; + } catch { + // Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen). + } + } + out.push({ + kind: "polygon", + pts, + fill, + stroke, + strokeWidthMm: lwMm, + hatch, + greyed, + columnId: column.id, + }); +} + /** * 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 @@ -1091,6 +1288,7 @@ function addDrawing2D( heightM: g.height, angle: g.angle, color, + ...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}), greyed, drawingId: d.id, }); @@ -1691,6 +1889,24 @@ function addOpeningSymbol( if (o.kind === "door") { const sym = doorSymbol(wall, o); if (!sym) return; + // Unsichtbares Pick-Polygon über der Öffnungslücke (Laibung × Wanddicke) — + // damit die Tür wie ein Fenster per Punkt-in-Polygon über die ganze + // Öffnungsfläche selektierbar ist (Türsymbol besteht sonst nur aus dünnem + // Blatt + Bogen, die man exakt treffen müsste). fill/stroke "none" ⇒ zeichnet + // nichts; `pickOpening` (PlanView) testet nur die `pts`. + const gap = openingGapQuad(project, wall, o); + if (gap) { + out.push({ + kind: "polygon", + pts: gap, + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + greyed, + openingId: o.id, + }); + } // Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN // Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der // ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet). @@ -1795,6 +2011,22 @@ function addOpeningSymbol( const glassCount = detail === "fein" ? 2 : 1; const sym = windowSymbol(project, wall, o, glassCount); if (!sym) return; + // Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über + // die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst + // schwer zu treffen). Siehe Tür-Zweig oben. + const wgap = openingGapQuad(project, wall, o); + if (wgap) { + out.push({ + kind: "polygon", + pts: wgap, + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + greyed, + openingId: o.id, + }); + } if (detail !== "grob") { // Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung). out.push({ diff --git a/src/plan/nativeSync.ts b/src/plan/nativeSync.ts index 43f28f3..f02ea83 100644 --- a/src/plan/nativeSync.ts +++ b/src/plan/nativeSync.ts @@ -12,7 +12,7 @@ import type { Plan } from "./generatePlan"; import type { Project } from "../model/types"; import { planToRenderScene } from "./toRenderScene"; -import { projectToModel3d } from "./toWalls3d"; +import { projectToModel3d, TRUCK_MESH_READY_EVENT } from "./toWalls3d"; /** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */ const DEBOUNCE_MS = 200; @@ -56,8 +56,21 @@ export function pushNativeScene(plan: Plan): void { */ export function pushNativeWalls(project: Project): void { if (!isTauri()) return; + lastProject = project; clearTimeout(wallsTimer); wallsTimer = setTimeout(() => { void invokeNative("push_native_walls", { walls: projectToModel3d(project) }); }, DEBOUNCE_MS); } + +/** Letztes an {@link pushNativeWalls} übergebenes Projekt (für den Re-Push unten). */ +let lastProject: Project | null = null; + +// truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids) laden WASM asynchron; +// ohne diesen Re-Push nach Abschluss bliebe eine fertige Extrusion im nativen +// Fenster unsichtbar, weil `pushNativeWalls` nur bei Projektänderungen läuft. +if (typeof window !== "undefined") { + window.addEventListener(TRUCK_MESH_READY_EVENT, () => { + if (lastProject) pushNativeWalls(lastProject); + }); +} diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index dcaa8db..108b893 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from "vitest"; import type { Project } from "../model/types"; import { sampleProject } from "../model/sampleProject"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d"; +import { resolveHatch } from "./generatePlan"; const RGB: [number, number, number] = [1, 0.5, 0.1]; const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b] @@ -25,14 +26,19 @@ describe("selectionHighlightLines", () => { expect(lines.length).toBe(0); }); - it("eine Wand ohne Öffnungen -> je Materiallage ein Quader (4·12 Kanten)", () => { + it("eine Wand ohne Öffnungen -> je Materiallage ein Quader, Kern-Band unter der Decke gespalten (5·12 Kanten)", () => { // W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3 // haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp // "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die - // TS-seitig je als eigene Box gestapelt werden (Option B) -> 4 Quader = - // 4·12 Kanten. (Früher: 1 Vollkörper = 12 Kanten.) + // TS-seitig je als eigene Box gestapelt werden (Option B). + // NEU (per-Schicht-Decken-Dominanz): W2 steht unter der Geschossdecke C1 + // (dg-massiv: screed 30 / insulation 20 / concrete 100). Der Backstein (50) + // wird NUR von der Beton-Schicht (100) im z-Band [2.3,2.5] gekappt und läuft + // darüber (bis Wandkopf 2.6) durch -> ZWEI Backstein-Boxen [0,2.3] + [2.5,2.6]. + // Die anderen Bänder bleiben je 1 Box (Aussenputz/Dämmung aussen: ungeschnitten; + // Innenputz 10 < alle Deckenschichten: bis 2.3). Also 5 Quader = 5·12 Kanten. const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); - expect(lines.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + expect(lines.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); }); it("eine Wand mit Farbwerten in jedem Vertex", () => { @@ -43,11 +49,14 @@ describe("selectionHighlightLines", () => { expect(lines[5]).toBeCloseTo(RGB[2], 6); }); - it("eine Decke (Slab) -> 3·n Kanten (n = Umriss-Eckenzahl)", () => { - // C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten. + it("eine Decke (Slab) -> je Schicht 3·n Kanten (geschichteter Bodenaufbau)", () => { + // C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten JE SCHICHT. + // Deckentyp "dg-massiv" hat 3 Schichten (Estrich/Dämmung/Beton) -> je ein + // eigener Slab, damit der 3D-Schnitt den Aufbau geschichtet zeigt (wie 2D). const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB); const n = 4; - expect(lines.length).toBe(3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + const layers = 3; // dg-massiv: screed + insulation + concrete + expect(lines.length).toBe(layers * 3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); }); it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => { @@ -63,16 +72,21 @@ describe("selectionHighlightLines", () => { expect(two.length).toBe(one.length * 2); }); - it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => { - // NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird - // NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro - // Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher - // genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele - // Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches - // durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.) - const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB); - const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB); - expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); + it("Öffnungen allein multiplizieren die Bänder NICHT (ein Körper je Schicht-Band mit Löchern)", () => { + // 3D-Pfad (layered=true): eine Wand mit Öffnungen wird NICHT in Pfeiler/ + // Brüstung/Sturz zerlegt, sondern als EIN Körper pro Schicht-Band mit echten + // Löchern emittiert — die Löcher fügen im Highlight keine Kanten hinzu. + // Isoliert von Verschneidungen getestet: OHNE die Querwand W9 (deren T-Stoss + // sonst die innerste Putzschicht von W1 span-cutout-splittet, s. eigener + // Test unten) hat W1 (Tür D1 + Fenster O1) dieselbe Bänder-Zahl wie die + // öffnungslose Wand W2 desselben Typs "aw" — die Löcher fügen keine Körper + // hinzu. Beide stehen unter der Geschossdecke C1, deren Beton-Schicht (100) + // den Backstein (50) im z-Band [2.3,2.5] spaltet -> 5 Körper (per-Schicht- + // Decken-Dominanz, s. Backstein-Boxen [0,2.3] + [2.5,2.6]). + const noW9: Project = { ...sampleProject, walls: sampleProject.walls.filter((w) => w.id !== "W9") }; + const w1 = selectionHighlightLines(noW9, ["W1"], [], RGB); + const w2 = selectionHighlightLines(noW9, ["W2"], [], RGB); + expect(w1.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); expect(w1.length).toBe(w2.length); }); }); @@ -114,23 +128,37 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t ], }; - it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => { + it("layered=true: BEIDE Fenster als holes je Band, Loch-Positionen bleiben nach Join-Rebase korrekt", () => { const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1"); - // Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen. - expect(w1.length).toBe(4); - for (const body of w1) { - // Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper). + // W1 ist Durchgangswand für die Querwand W9 (T-Stoss): die innerste Putz- + // schicht wird durch den Span-Cutout entlang der Achse in ZWEI Teilstücke + // gebrochen. NEU (per-Schicht-Decken-Dominanz): W1 steht unter der Decke C1, + // deren Beton (100) den Backstein (50) im z-Band [2.3,2.5] spaltet -> der + // Backstein kommt als ZWEI Boxen [0,2.3] + [2.5,2.6]. Die obere Backstein-Box + // liegt ÜBER beiden Fenstern (z ≤ 2.1) und trägt daher KEINE Löcher. + // Körper gesamt: render-ext(1) + insulation(1) + brick[0,2.3](1) + + // brick[2.5,2.6](1, ohne Loch) + render-int-Span(2) = 6. + expect(w1.length).toBe(6); + // Nur die Körper, die die Fenster-z-Bänder schneiden, tragen Löcher (5 Stück); + // die obere Backstein-Box (über den Fenstern) hat keine. + const withHoles = w1.filter((b) => b.holes && b.holes.length > 0); + expect(withHoles.length).toBe(5); + // W1-Achse: (0,0)->(5,0), u=(1,0) -> Box-Achsenstart = box.start[0]. Die Loch- + // Achsenkoordinaten sind je Körper auf DESSEN Box-Start rebased; die ABSOLUTE + // Position (Box-Start + from) muss wieder exakt auf den Öffnungs-Intervallen liegen. + for (const body of withHoles) { expect(body.holes).toBeDefined(); expect(body.holes!.length).toBe(2); + const axis0 = body.start[0]; const byFrom = [...body.holes!].sort((a, b) => a.from - b.from); - // Fenster A: u=[1,2], z=[0.9,2.1]. - expect(byFrom[0].from).toBeCloseTo(1.0, 6); - expect(byFrom[0].to).toBeCloseTo(2.0, 6); + // Fenster A: u=[1,2], z=[0.9,2.1] (absolut rekonstruiert). + expect(axis0 + byFrom[0].from).toBeCloseTo(1.0, 6); + expect(axis0 + byFrom[0].to).toBeCloseTo(2.0, 6); expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6); expect(byFrom[0].zTop).toBeCloseTo(2.1, 6); // Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer. - expect(byFrom[1].from).toBeCloseTo(1.5, 6); - expect(byFrom[1].to).toBeCloseTo(2.5, 6); + expect(axis0 + byFrom[1].from).toBeCloseTo(1.5, 6); + expect(axis0 + byFrom[1].to).toBeCloseTo(2.5, 6); expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6); expect(byFrom[1].zTop).toBeCloseTo(0.7, 6); } @@ -149,7 +177,12 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t // zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung. const p: Project = { ...sampleProject, openings: [] }; const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1"); - expect(w1.length).toBe(4); + // W1 ist Durchgangswand für W9 (T-Stoss) -> innerste Putzschicht span-cutout- + // gebrochen. Zusätzlich spaltet die Decke C1 (Beton 100) den Backstein (50) im + // z-Band [2.3,2.5] -> obere Backstein-Box [2.5,2.6] ohne Loch. Körper: render-ext + // + insulation + brick[0,2.3] + brick[2.5,2.6] + render-int-Span(2) = 6. + expect(w1.length).toBe(6); + // Der erste Körper (Aussenputz-Band, volle Achse) trägt die Tür als Loch bis UK. const door = w1[0].holes?.find((h) => h.zBottom < 1e-6); expect(door).toBeDefined(); expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK. @@ -161,10 +194,13 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => { // Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben: // render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff, // brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m). - // W2 ist öffnungslos -> genau ein Achsen-Teilquader, also 4 Schicht-Boxen. + // W2 ist öffnungslos, steht aber unter der Geschossdecke C1: die Beton-Schicht + // (100) spaltet den Backstein (50) im z-Band [2.3,2.5] -> der Backstein kommt + // als ZWEI Boxen [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz), die drei + // übrigen Bänder je 1 Box = 5 Boxen. Die Farbzuordnung je Schicht bleibt. const walls = projectToWalls3d(sampleProject); const w2 = walls.filter((w) => w.wallId === "W2"); - expect(w2.length).toBe(4); + expect(w2.length).toBe(5); // Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand) // und sind Vollkörper (layers leer -> render3d zeichnet in `color`). @@ -173,14 +209,18 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => { expect(seg.layers.length).toBe(0); } - // Die vier Materialfarben kommen genau einmal vor. + // Die vier Materialfarben kommen vor; der Backstein (#8c5544) zweimal (gespalten). const hex = (c: [number, number, number]) => c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join(""); const colors = w2.map((s) => hex(s.color)).sort(); - expect(colors).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]); + expect(colors).toEqual(["8c5544", "8c5544", "d8d2c7", "efece6", "ffffff"]); + expect([...new Set(colors)].sort()).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]); - // Die Schichtdicken summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps). - const totalT = w2.reduce((s, seg) => s + seg.thickness, 0); + // Die Schichtdicken (je EINDEUTIGEM Querversatz, damit die gespaltene Backstein- + // Box nur EINMAL zählt) summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps). + const byOffset = new Map(); + for (const seg of w2) byOffset.set(Math.round((seg.start[0] - 5) * 1e6) / 1e6, seg.thickness); + const totalT = [...byOffset.values()].reduce((s, t) => s + t, 0); expect(totalT).toBeCloseTo(0.345, 6); }); @@ -189,9 +229,14 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => { // wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach // Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel // symmetrisch um die Achse liegen: min = -T/2, max = +T/2. - const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); - const bands = w2 - .map((s) => ({ offset: s.start[0] - 5, t: s.thickness })) + // Nach EINDEUTIGEM Querversatz zusammenfassen: der unter der Decke gespaltene + // Backstein ([0,2.3] + [2.5,2.6]) liegt zweimal am SELBEN Versatz vor und darf + // die Dicken-Bilanz nicht doppelt zählen (per-Schicht-Decken-Dominanz). + const w2raw = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); + const byOffset = new Map(); + for (const s of w2raw) byOffset.set(Math.round((s.start[0] - 5) * 1e6) / 1e6, s.thickness); + const bands = [...byOffset.entries()] + .map(([offset, t]) => ({ offset, t })) .sort((a, b) => a.offset - b.offset); const T = bands.reduce((s, b) => s + b.t, 0); expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6); @@ -216,9 +261,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => { context: [], }; const { meshes } = projectToModel3d(p); + // `meshes` enthält jetzt auch die glatte Betontreppen-Laufplatte (ST1, Mesh); + // die Glasscheiben tragen die bläuliche Glasfarbe → danach filtern. + const glassMeshes = meshes.filter( + (m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85, + ); // Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe). - expect(meshes.length).toBe(1); - const glass = meshes[0]; + expect(glassMeshes.length).toBe(1); + const glass = glassMeshes[0]; // Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas. expect(glass.positions.length).toBe(24); expect(glass.indices.length).toBe(36); @@ -234,32 +284,73 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => { it("Türen bekommen keine Scheibe; je Fenster genau eine", () => { // Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer. + // Neben den Glasscheiben liegt jetzt auch das Betontreppen-Mesh in `meshes` + // → nach Glasfarbe filtern: genau 2 Scheiben (O1, O2), Tür ohne Scheibe. const { meshes } = projectToModel3d(sampleProject); - expect(meshes.length).toBe(2); + const glassMeshes = meshes.filter( + (m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85, + ); + expect(glassMeshes.length).toBe(2); }); }); -describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings)", () => { - // Sample-Projekt: W1-W4 (EG, Wandtyp "aw", dominante Schicht "brick" - // joinPriority 50) tragen exakt Wandhöhe 2.6 m = Geschosshöhe -> zTop 2.6. - // Decke C1 (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" - // joinPriority 100) hat OK bündig mit dem Wandkopf (zTop 2.6) und - // Gesamtdicke 0.3 m (screed .06 + insulation .04 + concrete .2) -> zBottom - // 2.3. Vor dem Trim überlappten sich Wand- und Deckenvolumen exakt über - // [2.3..2.6] im ganzen Wand-Footprint (Z-Fighting). Nach dem Trim endet die - // Wand an der Deckenunterkante, weil die Decke die höhere joinPriority hat. - it("Decke mit höherer joinPriority kappt die Wand an ihrer Unterkante", () => { +describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => { + // Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20, + // brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1 + // (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" joinPriority 100) + // hat OK bündig mit dem Wandkopf (zTop 2.6), Gesamtdicke 0.3 m -> zBottom 2.3. + // + // ENTSCHEIDEND (Nutzer-Bug „Kranz durch die Dämmung"): C1.outline ist der + // Raumgrundriss auf den WAND-ACHSEN ((0,0)-(5,0)-(5,4)-(0,4)) — die Decke liegt + // also nur auf dem INNEREN Halbquerschnitt jeder Aussenwand. Für W2 (Achse x=5) + // liegen die Bandmittellinien bei x = 5 − offset: + // render-ext x=5.1625 (AUSSEN, x>5) -> NICHT überdeckt -> läuft durch + // insulation x=5.0725 (AUSSEN) -> NICHT überdeckt -> läuft durch + // brick x=4.9175 (INNEN, x<5) -> überdeckt -> Decken-z-Schnitt + // render-int x=4.8350 (INNEN) -> überdeckt -> Decken-z-Schnitt + // Die Decke schneidet ein Band also nur dort, wo ihre Outline die Footprint- + // Mittellinie DIESES Bandes überdeckt (geometrischer Überlapp wie + // toSection.ts::subtractDominantBands) — NICHT mehr per wandweitem BBox-Test. + // (Frühere Tests erwarteten hier „ALLE Schichten geschnitten"; das kodierte das + // falsche BBox-Verhalten und ist die Ursache des 3D-Kranzes. Korrigiert.) + it("Decke schneidet nur die von ihrer Outline überdeckten (inneren) Bänder; Dämmung/Aussenputz laufen durch; Beton spaltet den Backstein PER SCHICHT", () => { const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); - expect(w2.length).toBeGreaterThan(0); - for (const seg of w2) { - expect(seg.baseElevation + seg.height).toBeCloseTo(2.3, 6); - } + // PER-SCHICHT-Decken-Dominanz (identisch zu toSection.ts::subtractDominantBands): + // jede Deckenschicht (screed 30 / insulation 20 / concrete 100) schneidet nur + // Wandbänder mit STRIKT niedrigerer Priorität in IHREM z-Band. Der Backstein (50) + // wird also NUR von der Beton-Schicht (100, z-Band [2.3,2.5]) gekappt und läuft + // darüber bis zum Wandkopf (2.6) durch -> ZWEI Boxen. So schneidet er im Schnitt + // die schwimmenden Deckenschichten Estrich/Dämmung über sich weg (Nutzer-Bug). + const zBoxes = (t: number) => + w2.filter((s) => Math.abs(s.thickness - t) < 1e-6) + .map((s) => [s.baseElevation, s.baseElevation + s.height] as const) + .sort((a, b) => a[0] - b[0]); + // brick: [0,2.3] (unter Beton) + [2.5,2.6] (über Beton, bis Wandkopf). + const brick = zBoxes(0.15); + expect(brick.length).toBe(2); + expect(brick[0][1]).toBeCloseTo(2.3, 6); // endet an Beton-UK + expect(brick[1][0]).toBeCloseTo(2.5, 6); // beginnt an Beton-OK + expect(brick[1][1]).toBeCloseTo(2.6, 6); // läuft bis Wandkopf durch + // render-int (10 < allen Deckenschichten): über [2.3,2.6] komplett gekappt -> [0,2.3]. + const renderInt = zBoxes(0.015); + expect(renderInt.length).toBe(1); + expect(renderInt[0][1]).toBeCloseTo(2.3, 6); + // AUSSEN liegende Schichten (Mittellinie ausserhalb der Decken-Outline): KEIN + // Schnitt -> volle Höhe (behobener „Kranz"-Bug, wie im 2D-Schnitt). + expect(zBoxes(0.16)).toEqual([[0, 2.6]]); // insulation läuft durch + expect(zBoxes(0.02)).toEqual([[0, 2.6]]); // render-ext (Aussenputz) läuft durch + expect(w2.length).toBe(5); // render-ext + insulation + brick(2) + render-int }); - it("kein Trim, wenn die Wand-Priorität >= Decken-Priorität ist", () => { - // Deckentyp-Priorität (concrete) auf 40 senken -> unter der Wand-Priorität - // (brick 50) -> die Decke gewinnt nicht mehr, die Wand bleibt unverändert - // auf voller Höhe (2.6 m, wie ohne Trim-Logik). + it("PER-SCHICHT: nur überdeckte Bänder mit < Decken-Priorität werden geschnitten (Kern >= Prio läuft durch)", () => { + // Deckentyp-Priorität (concrete) auf 40 senken. Von den ÜBERDECKTEN (inneren) + // Bändern läuft brick (50 > 40) jetzt bis zum Wandkopf (2.6) durch; render-int + // (10 < 40) wird im Decken-z-Band [2.3,2.6] geschnitten -> endet bei 2.3. Die + // AUSSEN liegenden Bänder (insulation, render-ext) sind ohnehin nicht überdeckt + // und laufen unabhängig von der Priorität durch (2.6). Priorität ALLEIN genügt + // also nicht — es braucht zusätzlich den geometrischen Überlapp (wie die + // „gestaffelte Kette" in toSection.boolean.test.ts, wo Beton die Dämmung trotz + // höherer Prio NICHT schneidet, weil sie sich nicht überlappen). const p: Project = { ...sampleProject, components: sampleProject.components.map((c) => @@ -267,13 +358,155 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings ), }; const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); - expect(w2.length).toBeGreaterThan(0); - for (const seg of w2) { - expect(seg.baseElevation + seg.height).toBeCloseTo(2.6, 6); - } + expect(w2.length).toBe(4); // je 1 Box pro Band (Wand ragt nicht über die Decke) + const top = (t: number) => { + const b = w2.find((s) => Math.abs(s.thickness - t) < 1e-6)!; + return b.baseElevation + b.height; + }; + expect(top(0.15)).toBeCloseTo(2.6, 6); // brick (überdeckt, 50 > 40) läuft durch + expect(top(0.015)).toBeCloseTo(2.3, 6); // render-int (überdeckt, 10 < 40) geschnitten + expect(top(0.16)).toBeCloseTo(2.6, 6); // insulation (nicht überdeckt) läuft durch + expect(top(0.02)).toBeCloseTo(2.6, 6); // render-ext (nicht überdeckt) läuft durch }); - it("kein Trim, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => { + it("Wand ragt über die Decke: überdecktes dominiertes Band spaltet vertikal, Aussenschichten bleiben ungeteilt", () => { + // W2 auf 3.0 m erhöhen (über Decken-OK 2.6) und concrete-Priorität auf 25 + // senken. Von den ÜBERDECKTEN Bändern spaltet render-int (10 < 25) vertikal in + // ZWEI Boxen [0,2.3] + [2.6,3.0] (Decken-z-Band [2.3,2.6] ausgespart); brick + // (50 > 25) läuft als EINE Box [0,3.0] durch. Die NICHT überdeckten Aussen- + // schichten (insulation, render-ext) bleiben je EINE volle Box [0,3.0] — sie + // werden NICHT vertikal aufgespalten (kein Kranz). + const p: Project = { + ...sampleProject, + walls: sampleProject.walls.map((w) => (w.id === "W2" ? { ...w, height: 3.0 } : w)), + components: sampleProject.components.map((c) => + c.id === "concrete" ? { ...c, joinPriority: 25 } : c, + ), + }; + const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); + // render-int 2 Boxen + brick/insulation/render-ext je 1 Box = 5 Boxen. + expect(w2.length).toBe(5); + const zRanges = (t: number) => + w2 + .filter((s) => Math.abs(s.thickness - t) < 1e-6) + .map((s) => [s.baseElevation, s.baseElevation + s.height] as const) + .sort((a, b) => a[0] - b[0]); + // brick (überdeckt, 50 > 25): EINE durchgehende Box. + const brick = zRanges(0.15); + expect(brick.length).toBe(1); + expect(brick[0][0]).toBeCloseTo(0, 6); + expect(brick[0][1]).toBeCloseTo(3.0, 6); + // render-int (überdeckt, 10 < 25): ZWEI Boxen unter/über der Decke. + const renderInt = zRanges(0.015); + expect(renderInt.length).toBe(2); + expect(renderInt[0][0]).toBeCloseTo(0, 6); + expect(renderInt[0][1]).toBeCloseTo(2.3, 6); + expect(renderInt[1][0]).toBeCloseTo(2.6, 6); + expect(renderInt[1][1]).toBeCloseTo(3.0, 6); + // insulation (nicht überdeckt): EINE volle Box, KEIN vertikaler Split. + const insul = zRanges(0.16); + expect(insul.length).toBe(1); + expect(insul[0][0]).toBeCloseTo(0, 6); + expect(insul[0][1]).toBeCloseTo(3.0, 6); + // render-ext (nicht überdeckt): ebenfalls EINE volle Box. + const renderExt = zRanges(0.02); + expect(renderExt.length).toBe(1); + expect(renderExt[0][0]).toBeCloseTo(0, 6); + expect(renderExt[0][1]).toBeCloseTo(3.0, 6); + }); + + it("Decke überdeckt die VOLLE Wanddicke -> jede Schicht PER SCHICHT gegen die Deckenschichten verschnitten", () => { + // Gegenprobe: verbreitert man C1 so, dass die Outline die ganze W2 überdeckt + // (Aussenkante x=5.1725 < 5.5), liegen ALLE Bandmittellinien innerhalb der Decke. + // PER-SCHICHT (statt pauschal): Deckenschichten screed(30, z[2.54,2.6]), + // insulation(20, z[2.5,2.54]), concrete(100, z[2.3,2.5]). + // • render-ext/render-int (Prio 10 < allen): über [2.3,2.6] komplett gekappt -> [0,2.3]. + // • brick (50): nur von concrete(100) gekappt -> [0,2.3] + [2.5,2.6]. + // • insulation-Wand (20): von concrete(100) und screed(30) gekappt, aber NICHT + // von der gleichrangigen Decken-insulation(20) -> [0,2.3] + [2.5,2.54] (Sliver, + // wo gleichrangiges Material koexistiert — exakt wie „gleiche Prio koexistiert"). + const p: Project = { + ...sampleProject, + ceilings: (sampleProject.ceilings ?? []).map((c) => + c.id === "C1" + ? { + ...c, + outline: [ + { x: 0, y: -0.5 }, + { x: 5.5, y: -0.5 }, + { x: 5.5, y: 4.5 }, + { x: 0, y: 4.5 }, + ], + } + : c, + ), + }; + const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); + expect(w2.length).toBe(6); + const zBoxes = (t: number) => + w2.filter((s) => Math.abs(s.thickness - t) < 1e-6) + .map((s) => [s.baseElevation, s.baseElevation + s.height] as const) + .sort((a, b) => a[0] - b[0]); + expect(zBoxes(0.02)).toEqual([[0, 2.3]]); // render-ext (10) voll gekappt + expect(zBoxes(0.015)).toEqual([[0, 2.3]]); // render-int (10) voll gekappt + const brick = zBoxes(0.15); // brick (50): nur Beton kappt + expect(brick.length).toBe(2); + expect(brick[0][1]).toBeCloseTo(2.3, 6); + expect(brick[1][0]).toBeCloseTo(2.5, 6); + expect(brick[1][1]).toBeCloseTo(2.6, 6); + const insul = zBoxes(0.16); // insulation-Wand (20): Sliver [2.5,2.54] bleibt + expect(insul.length).toBe(2); + expect(insul[0][1]).toBeCloseTo(2.3, 6); + expect(insul[1][0]).toBeCloseTo(2.5, 6); + expect(insul[1][1]).toBeCloseTo(2.54, 6); + }); + + it("Decke überdeckt nur EINEN TEIL der Wandlänge -> überdecktes Kern-Band spaltet ENTLANG DER ACHSE", () => { + // C1.outline auf die halbe Länge kürzen (y in [0,2] statt [0,4]): die Decke + // überdeckt die inneren W2-Bänder nur auf dem Achsenstück y in [0,2]. Das + // überdeckte Kern-Band (brick) wird dort z-geschnitten und läuft im Rest der + // Wandlänge (y > 2) über die volle Höhe durch — die Überdeckungsgrenze (y=2) + // wird aus der Bandmittellinien-Abtastung per Bisektion scharf ermittelt. Die + // Aussenschichten (nicht überdeckt) bleiben ganz. So wird auch eine Decke + // korrekt behandelt, die nur einen Teil der Wandlänge trägt. + const p: Project = { + ...sampleProject, + ceilings: (sampleProject.ceilings ?? []).map((c) => + c.id === "C1" + ? { ...c, outline: [{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 5, y: 2 }, { x: 0, y: 2 }] } + : c, + ), + }; + const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); + // Per-Schicht-Decken-Dominanz + Achsen-Überdeckung: der Backstein (50) wird nur + // von der Beton-Schicht (100) UND nur auf dem überdeckten Achsenstück y∈[~0.08,2] + // gekappt. Dort entstehen ZWEI z-Boxen [0,2.3] + [2.5,2.6]; das unbedeckte Stück + // y∈[2,~3.92] läuft als EINE volle Box [0,2.6] durch -> 3 Backstein-Boxen. + // render-int (10 < Beton) spaltet in [0,2.3] (überdeckt) + [0,2.6] (unbedeckt) = 2. + // Aussenschichten (insulation, render-ext) nicht überdeckt -> je 1 volle Box. + expect(w2.length).toBe(7); + const brick = w2 + .filter((s) => Math.abs(s.thickness - 0.15) < 1e-6) + .map((s) => ({ sy: s.start[1], zb: s.baseElevation, zt: s.baseElevation + s.height })) + .sort((a, b) => a.zb - b.zb || a.sy - b.sy); + expect(brick.length).toBe(3); + // Überdecktes Stück y∈[~0.08,2]: untere Box [0,2.3] + obere Box [2.5,2.6]. + const covered = brick.filter((b) => b.sy < 1.0); + expect(covered.length).toBe(2); + expect(covered[0].zt).toBeCloseTo(2.3, 6); // unter Beton + expect(covered[1].zb).toBeCloseTo(2.5, 6); // über Beton + expect(covered[1].zt).toBeCloseTo(2.6, 6); + // Unbedecktes Stück y∈[2,~3.92]: EINE volle Box [0,2.6]. + const uncovered = brick.filter((b) => b.sy > 1.5); + expect(uncovered.length).toBe(1); + expect(uncovered[0].zt).toBeCloseTo(2.6, 6); + // insulation (aussen) bleibt EINE ungeteilte volle Box. + const insul = w2.filter((s) => Math.abs(s.thickness - 0.16) < 1e-6); + expect(insul.length).toBe(1); + expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6); + }); + + it("keine Subtraktion, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => { // W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) -> // unverändertes Verhalten (volle wall.height). const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6"); @@ -284,15 +517,271 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings }); }); -describe("Bauteil-/Materialfarbe Decke", () => { - it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => { - // Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 -> - // dickste Schicht = concrete (#9aa0a6). +describe("Geschichteter Bodenaufbau Decke", () => { + it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => { + // Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff), + // concrete 0.2 (#9aa0a6) — Reihenfolge OK->UK. C1: zTop 2.6, Gesamt 0.3 -> + // zBottom 2.3. Proportional: Estrich 2.54..2.6, Dämmung 2.50..2.54, + // Beton 2.30..2.50. Der 3D-Schnitt zeigt so den Aufbau geschichtet (wie 2D). const { slabs } = projectToModel3d(sampleProject); - const c1 = slabs.find((s) => s.ceilingId === "C1"); - expect(c1).toBeDefined(); - expect(c1!.color[0]).toBeCloseTo(0x9a / 255, 6); - expect(c1!.color[1]).toBeCloseTo(0xa0 / 255, 6); - expect(c1!.color[2]).toBeCloseTo(0xa6 / 255, 6); + const c1 = slabs + .filter((s) => s.ceilingId === "C1") + .sort((a, b) => b.zTop - a.zTop); // oben zuerst + expect(c1.length).toBe(3); + + // Oberste Schicht = Estrich (#c9c2b3), bündig mit OK der Decke (2.6). + expect(c1[0].zTop).toBeCloseTo(2.6, 6); + expect(c1[0].color[0]).toBeCloseTo(0xc9 / 255, 6); + expect(c1[0].color[1]).toBeCloseTo(0xc2 / 255, 6); + expect(c1[0].color[2]).toBeCloseTo(0xb3 / 255, 6); + + // Mittlere Schicht = Dämmung (#ffffff). + expect(c1[1].color[0]).toBeCloseTo(1, 6); + expect(c1[1].color[1]).toBeCloseTo(1, 6); + expect(c1[1].color[2]).toBeCloseTo(1, 6); + + // Unterste Schicht = Betondecke (#9aa0a6), bündig mit UK der Decke (2.3). + expect(c1[2].zBottom).toBeCloseTo(2.3, 6); + expect(c1[2].color[0]).toBeCloseTo(0x9a / 255, 6); + expect(c1[2].color[1]).toBeCloseTo(0xa0 / 255, 6); + expect(c1[2].color[2]).toBeCloseTo(0xa6 / 255, 6); + + // Schichten schließen lückenlos aneinander (UK Schicht k == OK Schicht k+1). + expect(c1[0].zBottom).toBeCloseTo(c1[1].zTop, 6); + expect(c1[1].zBottom).toBeCloseTo(c1[2].zTop, 6); + }); +}); + +describe("sliceTermination: Wand endet an der Decke (Zuschnitt, nicht Priorität)", () => { + // W2 (EG, aw) steht unter der Decke C1 (dg-massiv). Ohne Terminierung ("both") + // spaltet der Beton (100) den Backstein (50) in [0,2.3] + [2.5,2.6] (heutiges, + // prioritäts-korrektes Verhalten). "below" clippt die Wand an der Decken-UK → + // nur die untere Backstein-Box [0,2.3] bleibt; "above" spiegelbildlich [2.5,2.6]. + const withTermination = (mode: "both" | "below" | "above") => ({ + ...sampleProject, + walls: sampleProject.walls.map((w) => + w.id === "W2" ? { ...w, sliceTermination: mode === "both" ? undefined : mode } : w, + ), + }); + const brickBoxes = (mode: "both" | "below" | "above") => + projectToWalls3d(withTermination(mode)) + .filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.15) < 1e-6) + .map((w) => [w.baseElevation, w.baseElevation + w.height] as const) + .sort((a, b) => a[0] - b[0]); + + it('Default/"both": Backstein bleibt als ZWEI Boxen [0,2.3] + [2.5,2.6] (heutiges Verhalten)', () => { + const brick = brickBoxes("both"); + expect(brick.length).toBe(2); + expect(brick[0][0]).toBeCloseTo(0, 6); + expect(brick[0][1]).toBeCloseTo(2.3, 6); + expect(brick[1][0]).toBeCloseTo(2.5, 6); + expect(brick[1][1]).toBeCloseTo(2.6, 6); + }); + + it('"below": NUR die untere Backstein-Box [0,2.3] (Wand endet an der Decke, kein Rest oben)', () => { + const brick = brickBoxes("below"); + expect(brick.length).toBe(1); + expect(brick[0][0]).toBeCloseTo(0, 6); + expect(brick[0][1]).toBeCloseTo(2.3, 6); + // Auch der Innenputz (render-int, überdeckt) endet einheitlich an der Decken-UK 2.3. + const renderInt = projectToWalls3d(withTermination("below")) + .filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.015) < 1e-6); + expect(renderInt.length).toBe(1); + expect(renderInt[0].baseElevation + renderInt[0].height).toBeCloseTo(2.3, 6); + // Aussenschichten (nicht überdeckt) laufen weiter voll durch (2.6). + const insul = projectToWalls3d(withTermination("below")) + .filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.16) < 1e-6); + expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6); + }); + + it('"above": NUR die obere Backstein-Box [2.5,2.6] (Brüstung/Attika von oben)', () => { + const brick = brickBoxes("above"); + expect(brick.length).toBe(1); + expect(brick[0][0]).toBeCloseTo(2.5, 6); + expect(brick[0][1]).toBeCloseTo(2.6, 6); + }); +}); + +describe("Schnitt-Schraffur-Orientierung: relativeToWall dreht bei Wand, nicht bei Decke (2D-Parität)", () => { + // 2D-Referenz (verbindlich): splitWallLayers ruft resolveHatch(…, 90, …) (Wand + // steht vertikal, relativeToWall-Muster drehen mit), splitSlabLayers ruft + // resolveHatch(…, undefined, …) (Decke liegt, absoluter Winkel). Der 3D-Pfad muss + // EXAKT diesen Endwinkel je Kontext liefern (statt des rohen Hatch-Winkels) — + // sonst läuft z. B. die Boden-Dämmung im 3D-Schnitt horizontal statt vertikal. + const wallInsul = () => + projectToModel3d(sampleProject).walls.find( + (w) => w.cut?.componentId === "insulation" && Math.abs(w.thickness - 0.16) < 1e-6, + )!; + const slabInsul = () => + projectToModel3d(sampleProject).slabs.find((s) => s.cut?.componentId === "insulation")!; + + it("WAND-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, 90)", () => { + const expected = resolveHatch(sampleProject, "sia-insulation", 90).angle; + expect(wallInsul().hatch?.angle).toBeCloseTo(expected, 6); + }); + + it("DECKEN-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, undefined) (absolut)", () => { + const expected = resolveHatch(sampleProject, "sia-insulation", undefined).angle; + expect(slabInsul().hatch?.angle).toBeCloseTo(expected, 6); + }); + + it("Wand- und Decken-Dämmung unterscheiden sich um den Wandachsenwinkel (relativeToWall dreht 90°)", () => { + // sia-insulation ist relativeToWall -> Wand-Winkel = roh − 90, Decke = roh. + // Die Differenz ist exakt 90° (der Boden-Aufbau läuft dadurch quer zur Wand). + const diff = (slabInsul().hatch!.angle - wallInsul().hatch!.angle + 360) % 360; + expect(diff).toBeCloseTo(90, 6); + }); + + it("WAND-Backstein (relativeToWall): 3D-Hatch-Winkel == resolveHatch(sia-brick, 90)", () => { + const brick = projectToModel3d(sampleProject).walls.find( + (w) => w.cut?.componentId === "brick" && Math.abs(w.thickness - 0.15) < 1e-6, + )!; + expect(brick.hatch?.angle).toBeCloseTo(resolveHatch(sampleProject, "sia-brick", 90).angle, 6); + }); + + it("Musterlinien-Stärke kommt aus dem LineStyle des Hatch durch (2D-Parität)", () => { + // sia-insulation nutzt lineStyleId "hatch-hair" (0.02 mm). Der 3D-Hatch muss die + // resolveHatch-lineWeight (aus dem LineStyle) tragen — für Wand UND Decke gleich. + const expected = resolveHatch(sampleProject, "sia-insulation", 90).lineWeight; + expect(wallInsul().hatch?.lineWeight).toBeCloseTo(expected, 6); + expect(slabInsul().hatch?.lineWeight).toBeCloseTo(expected, 6); + expect(expected).toBeCloseTo(0.02, 6); // hatch-hair + }); +}); + +describe("Wand-Joins im 3D (L-/T-Stösse, identisch zum 2D-Grundriss/Schnitt)", () => { + // Die per-Schicht-Cut-Distanzen aus computeJoins (model/joins.ts) werden im + // layered-3D-Pfad auf die Band-Achsenlängen angewandt — dieselben Zahlen, die + // generatePlan.ts über clippedBand auf die 2D-Polygone anwendet (Band-Mittellinie + // an der Gehrungs-/Anschluss-Linie geschnitten). Alle Erwartungswerte unten sind + // aus der Join-Geometrie des Sample-Projekts von Hand hergeleitet. + + it("(a) L-Ecke gleicher Wandtyp: jede Schicht individuell gehrt (aussen länger, Kern kürzer)", () => { + // W2 (5,0)->(5,4), Typ "aw", KEINE Öffnung/Querwand -> genau 4 Schicht-Bänder. + // Beide Enden sind rechtwinklige L-Ecken (mit W1 bei (5,0), W3 bei (5,4)): + // 45°-Gehrung x+y=5 (Start) bzw. y=x-1 (Ende). Die Mittellinie einer Schicht + // bei Querversatz `offset` trifft die Gehrung bei Achsenmeter `offset` (Start) + // bzw. `4-offset` (Ende). Aussenschichten (negativer offset) laufen ÜBER die + // Ecke hinaus (wrappen), Innenschichten (positiver offset) enden kürzer. + // Achse u=(0,1) -> Box-Achsenanfang/-ende = start[1]/end[1]. + // W2 steht unter der Decke C1: die Beton-Schicht (100) spaltet den Backstein (50) + // z-mässig in [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz) -> 5 Boxen. Die + // Gehrung ist z-unabhängig, beide Backstein-Boxen tragen denselben Achsenschnitt. + const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); + expect(w2.length).toBe(5); + const band = (t: number) => w2.find((s) => Math.abs(s.thickness - t) < 1e-9)!; + // offset je Schicht (aw, center): -0.1625 / -0.0725 / 0.0825 / 0.165. + const cases: Array<[number, number]> = [ + [0.02, -0.1625], // render-ext (aussen) -> from=-0.1625, to=4.1625 + [0.16, -0.0725], // insulation + [0.15, 0.0825], // brick (Kern) + [0.015, 0.165], // render-int (innen) -> from=0.165, to=3.835 (am kürzesten) + ]; + for (const [t, offset] of cases) { + const b = band(t); + expect(b.start[1]).toBeCloseTo(offset, 6); // Achsenanfang = offset + expect(b.end[1]).toBeCloseTo(4 - offset, 6); // Achsenende = 4-offset + } + // Der Kern (brick) ist kürzer als die Aussenschicht (render-ext): die + // per-Schicht-Gehrung ist also wirklich individuell (keine einheitliche Länge). + const len = (t: number) => band(t).end[1] - band(t).start[1]; + expect(len(0.15)).toBeLessThan(len(0.02)); + expect(len(0.15)).toBeCloseTo(3.835, 6); // 3.9175 - 0.0825 + expect(len(0.02)).toBeCloseTo(4.325, 6); // 4.1625 - (-0.1625) + }); + + it("(b) T-Stoss: Abzweig-Kern läuft bis zur Rückgrat-Nahfläche durch, Putz-Band stoppt an der Wandfläche", () => { + // Querwand W9 (2.4,0)->(2.4,4), Typ "iw" (render-int 0.015 / brick 0.12 / + // render-int 0.015), stösst mittig auf W1 (unten) und W3 (oben) -> zwei + // Mittelspannen-T-Stösse. W9-brick == W1/W3-Rückgrat (Komponente "brick", + // Prio 50) -> MERGE: der brick-Kern sticht durch den 0.015-Nah-Putz der + // Durchgangswand bis zu deren Rückgrat-Nahfläche, die render-int-Putzschichten + // stoppen an der Wandoberfläche. Achse u=(0,1) -> Achsenmeter = start[1]/end[1]. + // W9 (iw) steht ebenfalls unter der Decke C1: die Beton-Schicht (100) spaltet den + // Kern-Backstein (50) z-mässig in [0,2.3] + [2.5,2.6] -> 2 Kern-Boxen + 2 Putz = 4. + // Die Gehrung/T-Stoss-Achse ist z-unabhängig, beide Kern-Boxen identisch. + const w9 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W9"); + expect(w9.length).toBe(4); + const brick = w9.find((s) => Math.abs(s.thickness - 0.12) < 1e-9)!; + const renders = w9.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9); + expect(renders.length).toBe(2); + // Putz stoppt an W1-Oberfläche (y=0.1725) bzw. W3-Oberfläche (y=3.8275). + for (const r of renders) { + expect(r.start[1]).toBeCloseTo(0.1725, 6); + expect(r.end[1]).toBeCloseTo(3.8275, 6); + } + // Kern läuft 0.015 tiefer bis zur Rückgrat-Nahfläche (y=0.1575 bzw. 3.8425). + expect(brick.start[1]).toBeCloseTo(0.1575, 6); + expect(brick.end[1]).toBeCloseTo(3.8425, 6); + expect(brick.start[1]).toBeLessThan(renders[0].start[1]); // Kern durchgesteckt + }); + + it("(b) T-Stoss Durchgangswand: Nah-Putz wird über die Kernbreite aufgebrochen (Span-Cutout splittet das Band)", () => { + // W1 (Durchgangswand) verliert den Nah-Putz (innerstes render-int-Band) über + // die Achsen-Breite des durchstechenden W9-Kerns: das Band zerfällt entlang + // der Achse in zwei Teilstücke [0.165..2.34] und [2.46..4.835] (Lücke = die + // 0.12 m Kernbreite um x=2.4). W1-Achse u=(1,0) -> Achsenmeter = start[0]/end[0]. + const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1"); + const innerPlaster = w1 + .filter((s) => Math.abs(s.thickness - 0.015) < 1e-9) + .map((s) => [s.start[0], s.end[0]] as const) + .sort((a, b) => a[0] - b[0]); + expect(innerPlaster.length).toBe(2); // in zwei Teilstücke gebrochen + // Lücke = Kernbreite-Projektion [2.34, 2.46] um die Querwand-Achse x=2.4. + expect(innerPlaster[0][1]).toBeCloseTo(2.34, 6); // Ende Teilstück 1 + expect(innerPlaster[1][0]).toBeCloseTo(2.46, 6); // Anfang Teilstück 2 + }); + + it("(c) Join-verschobenes Band: Öffnungs-Löcher bleiben absolut korrekt positioniert (rebased)", () => { + // W1 trägt Fenster O1 (Achsenintervall [3.0,4.0]). Das Aussenputz-Band ist an + // den L-Ecken über die Achse hinaus verlängert (Achsenanfang < 0) — die Loch- + // `from`/`to` sind auf DIESEN Box-Start umgerechnet. Rekonstruiert man die + // absolute Achsenposition (Box-Achsenanfang + `from`), liegt das Fenster wieder + // exakt auf [3.0,4.0], unabhängig von der Join-Verschiebung. W1-Achse u=(1,0). + const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1"); + const outer = w1.find((s) => Math.abs(s.thickness - 0.02) < 1e-9)!; + expect(outer.start[0]).toBeLessThan(0); // Band über die Ecke hinaus verlängert + const window = (outer.holes ?? []) + .map((h) => [outer.start[0] + h.from, outer.start[0] + h.to] as const) + .sort((a, b) => a[0] - b[0]) + .find(([a]) => a > 2); // das Fenster O1 (das andere Loch ist die Tür D1) + expect(window).toBeDefined(); + expect(window![0]).toBeCloseTo(3.0, 6); + expect(window![1]).toBeCloseTo(4.0, 6); + }); + + it("Joins wirken NUR im layered-3D-Pfad, nicht im Schnitt-Pfad (layered=false unverändert)", () => { + // Der Schnitt-Pfad (computeSection, layeredWalls:false) darf KEINE Join-Cuts + // bekommen: W2 bleibt eine Voll-Box über die ganze Dicke, ungehrt (Achse + // 0..4), damit toSection.ts::subtractDominantBands seine eigene Dominanz auf + // Rohgeometrie anwendet. + const w2 = projectToWalls3d(sampleProject, false).filter((w) => w.wallId === "W2"); + expect(w2.length).toBe(1); // Voll-Box, keine Schicht-Bänder + expect(w2[0].start[1]).toBeCloseTo(0, 6); // ungehrt: Achse 0..4 + expect(w2[0].end[1]).toBeCloseTo(4, 6); + }); +}); + +describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => { + it("gerade Betontreppe -> Mesh mit 8 Ecken je Tritt, glatte schräge Untersicht", () => { + // Sample-Treppe ST1: Typ "stair-standard" (Tragart "beton"), gerade, + // stepCount 15 -> ein konvexes Prisma je Tritt = 8 Ecken. Das Mesh landet in + // model.meshes (kind "extrusion"). + const model = projectToModel3d(sampleProject); + const stairMesh = model.meshes.find((m) => m.positions.length / 3 === 8 * 15); + expect(stairMesh).toBeTruthy(); + const m = stairMesh!; + // Indizes bilden Dreiecke (6 Quads * 2 Tris * 3 = 36 Indizes je Tritt). + expect(m.indices.length).toBe(15 * 36); + expect(m.indices.length % 3).toBe(0); + // Keine NaN/Inf in der Geometrie. + expect(m.positions.every((v) => Number.isFinite(v))).toBe(true); + // Höhen (jede 3. Komponente) liegen zwischen Treppen-UK (~0) und -OK (~2.6). + const heights = m.positions.filter((_, i) => i % 3 === 2); + expect(Math.min(...heights)).toBeGreaterThanOrEqual(-1e-6); + expect(Math.max(...heights)).toBeLessThanOrEqual(2.6 + 1e-6); + // Alle Index-Werte referenzieren existierende Vertices. + const vcount = m.positions.length / 3; + expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true); }); }); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index abcf51d..41f7e56 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -32,16 +32,41 @@ // Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle // Dicke im neutralen Grundton (heutiges Fallback-Verhalten). -import type { Project, Wall, Ceiling, Layer } from "../model/types"; +import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination } from "../model/types"; import { getComponent, getWallType, getCeilingType, + getStairType, wallTypeThickness, openingsOfWall, + getFloor, + flattenCategories, } from "../model/types"; -import { wallVerticalExtent, ceilingVerticalExtent } from "../model/wall"; -import { openingInterval, openingVerticalExtent } from "../geometry/opening"; +import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall"; +import { columnFootprint } from "../geometry/column"; +import { pointInOutline } from "../geometry/ceiling"; +import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening"; +import { stairGeometry, stairBBox } from "../geometry/stair"; +import { extrudePolygon, extrudeCircle } from "../engine/truckSolid"; +import { computeJoins } from "../model/joins"; +import type { WallCuts } from "../model/joins"; +import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry"; +import type { Line } from "../model/geometry"; +import { resolveHatch } from "./generatePlan"; +import type { PickOpening, PickStair } from "../viewport/raycast3d"; + +/** + * Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell- + * Konvention, identisch zu `SECTION_WALL_AXIS_ANGLE_DEG` in toSection.ts): das + * Wand-Band steht vertikal (Achse entlang v, atan2(1,0) = 90°). Damit drehen + * `relativeToWall`-Muster (Dämmung, Backstein) im 3D-Schnitt genauso mit der Wand + * wie im 2D-Schnitt (`splitWallLayers` → `resolveHatch(…, 90, …)`). Für DECKEN gibt + * es keinen Wandachsen-Bezug — dort bleibt der Winkel absolut (`emitSlabs` ruft + * `resolveComponentHatch` ohne Achswinkel, wie `splitSlabLayers` → `resolveHatch(…, + * undefined, …)`), sodass die liegende Boden-Dämmung vertikal statt horizontal läuft. + */ +const SECTION_WALL_AXIS_ANGLE_DEG = 90; export type RVec2 = [number, number]; export type RRgb = [number, number, number]; @@ -59,6 +84,153 @@ export interface RLayer { color: RRgb; } +/** + * Schnitt-Schraffur eines Bauteil-Bandes für den 3D-Live-Schnitt — matcht 1:1 + * render3d::types::Hatch. Nur die drei Felder, die der prozedurale Cap-Shader + * (`CAP_WGSL`) auswertet: Muster-Id, Winkel (Grad), Skalierung. MONOCHROM (Tinte + * auf Papier, wie die 2D-Direktive `HATCH_INK`) — es wandert nur das MUSTER mit, + * KEINE Farbe. Muster-Id: 0 = none, 1 = solid, 2 = diagonal, 3 = crosshatch, + * 4 = insulation (Zickzack/Dämmung). Synchron zu halten mit `hatchPatternId` + * (unten), `types::Hatch` (Rust) und `CAP_WGSL`. + */ +export interface RHatch { + pattern: number; + angle: number; + scale: number; + /** + * Strichstärke der Schraffur-Musterlinien in mm-Papier (aus dem LineStyle des + * Hatch, `HatchRender.lineWeight`; Default 0.13). Matcht render3d::Hatch.lineWeight + * — der Cap-Shader setzt die Musterlinien-Breite daraus (dieselbe mm→Welt- + * Kalibrierung wie die Trennlinien), sodass die Schraffur-Stärke im 3D pro + * Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs. `thin` 0.13 mm). + */ + lineWeight?: number; +} + +/** Bildet ein 2D-{@link HatchPattern} auf die Muster-Id von {@link RHatch}/CAP_WGSL ab. */ +function hatchPatternId(p: HatchPattern): number { + switch (p) { + case "solid": + return 1; + case "diagonal": + return 2; + case "crosshatch": + return 3; + case "insulation": + return 4; + case "none": + default: + return 0; + } +} + +/** + * Löst die Schnitt-Schraffur eines Bauteils (per `Component.hatchId`) zu einem + * {@link RHatch} auf — dasselbe Muster/Winkel/Skalierung wie der 2D-Schnitt + * (`generatePlan.ts::resolveHatch`, hier bewusst NICHT aufgerufen: nur die drei + * render3d-relevanten Felder werden direkt aus dem {@link HatchStyle} gezogen, die + * Farbe bleibt monochrom im Shader). `undefined`, wenn das Bauteil/die Schraffur + * nicht auflösbar ist (der Cap-Shader fällt dann auf sein Diagonalmuster zurück). + */ +function resolveComponentHatch( + project: Project, + componentId: string, + axisAngleDeg?: number, +): RHatch | undefined { + try { + const comp = getComponent(project, componentId); + if (!comp.hatchId) return undefined; + // GENAU der 2D-Schnitt-Pfad (`splitWallLayers`/`splitSlabLayers` → + // `resolveHatch`): `axisAngleDeg` = 90 für WAND-Bänder (die geschnittene Wand + // steht vertikal, `relativeToWall`-Muster drehen mit), `undefined` für DECKEN + // (kein Wandachsen-Bezug → absoluter Winkel). Wir übernehmen den ENDWINKEL + // (`HatchRender.angle`, bereits relativeToWall-korrigiert) UND die Musterlinien- + // Stärke (`lineWeight`, aus dem LineStyle) — nicht mehr den rohen Hatch-Winkel. + const hr = resolveHatch(project, comp.hatchId, axisAngleDeg); + return { pattern: hatchPatternId(hr.pattern), angle: hr.angle, scale: hr.scale, lineWeight: hr.lineWeight }; + } catch { + return undefined; + } +} + +/** + * Schnitt-Schraffur der dicksten (dominanten) Schicht eines Schichtstapels. + * `axisAngleDeg` wie {@link resolveComponentHatch} (90 für Wand, undefined für Decke). + */ +function dominantLayerHatch( + layers: Layer[], + project: Project, + axisAngleDeg?: number, +): RHatch | undefined { + if (layers.length === 0) return undefined; + const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best)); + return resolveComponentHatch(project, dominant.componentId, axisAngleDeg); +} + +/** + * Geordnete Liste der EINDEUTIGEN Material-Farbkarten-URLs (`Component.material. + * color`), die von Wand-/Decken-Bauteilen des Projekts benutzt werden — in + * deterministischer First-Seen-Reihenfolge über `project.walls` (Wandtyp-Schichten) + * dann `project.ceilings`. Die POSITION in dieser Liste (+1) ist der Textur-Array- + * Index (`WallInput.materialIndex`, 1-basiert; Ebene 0 = Fallback-Schachbrett), den + * sowohl {@link resolveComponentMaterialLayer} (Zuweisung je Band) als auch der + * JS-Decoder ({@link import("../viewport/materialTextures")}) verwenden — so + * stimmen Band-Index und hochgeladene Textur-Ebene ohne separaten Map-Transport + * überein. Memoisiert je Projekt-REFERENZ (der Store ist immutabel — jede Änderung + * liefert ein neues Objekt, also kein Stale-Risiko), damit die per-Band-Auflösung + * nicht quadratisch wird. + */ +const materialMapCache = new WeakMap(); +export function wallMaterialColorMaps(project: Project): string[] { + const cached = materialMapCache.get(project); + if (cached) return cached; + const seen = new Set(); + const list: string[] = []; + const consider = (componentId: string): void => { + try { + const url = getComponent(project, componentId).material?.color; + if (url && !seen.has(url)) { + seen.add(url); + list.push(url); + } + } catch { + // Unauflösbares Bauteil überspringen. + } + }; + for (const w of project.walls) { + try { + for (const l of getWallType(project, w).layers) consider(l.componentId); + } catch { + // Kein auflösbarer Wandtyp. + } + } + for (const c of project.ceilings ?? []) { + try { + for (const l of getCeilingType(project, c).layers) consider(l.componentId); + } catch { + // Kein auflösbarer Deckentyp. + } + } + materialMapCache.set(project, list); + return list; +} + +/** + * Textur-Array-Ebene (1-basiert) der Material-Farbkarte eines Bauteils, oder + * `undefined`, wenn das Bauteil kein Material mit Farbkarte trägt. Ebene 0 ist das + * Fallback-Schachbrett — deshalb `indexOf + 1`. Siehe {@link wallMaterialColorMaps}. + */ +function resolveComponentMaterialLayer(project: Project, componentId: string): number | undefined { + try { + const url = getComponent(project, componentId).material?.color; + if (!url) return undefined; + const idx = wallMaterialColorMaps(project).indexOf(url); + return idx >= 0 ? idx + 1 : undefined; + } catch { + return undefined; + } +} + /** * Ein rechteckiges LOCH (Fenster/Tür) in EINEM Wandkörper — matcht 1:1 * render3d::types::Hole. KONVENTION: `from`/`to` sind Achsen-Meter ab @@ -77,6 +249,23 @@ export interface RHole { zTop: number; } +/** + * Schnitt-Verschneidungs-/Linienstil-Metadaten EINES geschnittenen Bauteil-Bandes + * für den 3D-Live-Schnitt — matcht 1:1 render3d::types::CutBandMeta. Trägt die + * `joinPriority` (Schnitt-Boolean-Dominanz: die stärkere Schicht schneidet die + * schwächere, `section_boolean::subtract_dominant_bands`), die `componentId` (MERGE- + * Regel gleichartiger Bänder) sowie Farbe/Stärke der Schnitt-Umrisslinie (P2, aus + * dem `jointLineStyleId`-LineStyle der Schicht). Alle Felder optional — fehlt eines, + * greift auf der Rust-Seite der jeweilige Default (keine Subtraktion / Default- + * Schnittkante). + */ +export interface RCutMeta { + joinPriority?: number; + componentId?: string; + lineColor?: RRgb; + lineWeight?: number; +} + export interface RWall { start: RVec2; end: RVec2; @@ -110,6 +299,28 @@ export interface RWall { * und die Segment-Zerlegung schliessen sich damit gegenseitig aus. */ holes?: RHole[]; + /** + * Schnitt-Schraffur dieses Wand-Bandes (siehe {@link RHatch}) — matcht + * render3d::WallInput.hatch. Im layered-3D-Pfad trägt jede Materiallage die + * Schraffur IHRES Bauteils, sodass der 3D-Live-Schnitt jede geschnittene + * Schicht in ihrem Muster (Beton/Dämmung/Backstein/…) zeichnet. Fehlt sie + * (unauflösbares Bauteil), zeichnet der Cap-Shader sein Diagonal-Fallback. + */ + hatch?: RHatch; + /** + * Material-Textur-Ebene dieses Wand-Bandes für den Stil `Textured` (matcht + * render3d::WallInput.materialIndex, 1-basiert; Ebene 0 = Fallback-Schachbrett). + * Aus `Component.material.color` über {@link wallMaterialColorMaps} aufgelöst; + * fehlt sie, zeigt render3d das Schachbrett. Der Schnitt/Serde-Pfad ignoriert es. + */ + materialIndex?: number; + /** + * Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe + * {@link RCutMeta}) — matcht render3d::WallInput.cut. Im layered-3D-Pfad trägt + * jede Materiallage ihre `joinPriority`/`componentId`/Schnittkanten-Stil, sodass + * der 3D-Live-Schnitt die Boolean-Dominanz + Fugenlinien wie der 2D-Schnitt macht. + */ + cut?: RCutMeta; } /** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */ @@ -124,10 +335,14 @@ export interface RSlab { * (kein `deny_unknown_fields`). */ ceilingId: string; + /** Schnitt-Schraffur der Decke (dominante Schicht, siehe {@link RHatch}). */ + hatch?: RHatch; + /** Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe {@link RCutMeta}). */ + cut?: RCutMeta; } /** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */ -export type RMeshKind = "terrain" | "imported"; +export type RMeshKind = "terrain" | "imported" | "extrusion"; /** * Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes @@ -158,10 +373,20 @@ const SLAB_RGB: RRgb = [0.86, 0.86, 0.88]; const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255]; /** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */ const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255]; +/** Warmes Orange für truck-Profil-Extrusionen (hebt sie klar von Wand/Decke/Kontext ab). */ +const EXTRUSION_RGB: RRgb = [0.85, 0.55, 0.25]; +/** Neutraler Beton-/Tragwerks-Grauton für Stützen (Fallback ohne Bauteil-Farbe). */ +const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72]; /** Helles, leicht bläuliches Fenster-Glas (Scheibe in der Öffnung). */ const GLASS_RGB: RRgb = [0.62, 0.76, 0.85]; /** Halbe Dicke der Fensterscheibe (Meter) — sehr dünn, mittig in der Wand. */ const GLASS_HALF_THICK = 0.01; +/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */ +const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255]; +/** Halbe Linienbreite der 2D-Boden-Spuren im 3D (Meter) — liest sich als dünne Linie. */ +const DRAWING_LINE_HALF_WIDTH = 0.008; +/** Höhen-Offset über der Geschossebene gegen Z-Fighting (wie addDrawing2DLines in Viewport3D.tsx). */ +const DRAWING_LINE_ELEVATION_EPS = 0.01; /** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */ const EPS = 1e-4; @@ -205,6 +430,64 @@ interface WallBand { thickness: number; color: RRgb; offset: number; + /** Schnitt-Schraffur des Band-Bauteils (siehe {@link RHatch}). */ + hatch?: RHatch; + /** Material-Textur-Ebene des Band-Bauteils (1-basiert, siehe {@link wallMaterialColorMaps}). */ + materialIndex?: number; + /** + * Verschneidungs-Rang dieser Materiallage (`Component.joinPriority`) — treibt + * die per-Schicht-Dominanz gegen Decken im layered-3D-Pfad (s. + * {@link collectCeilingCutters}/{@link emitWall}): eine Decke mit STRIKT höherer + * `joinPriority` schneidet nur die Bänder mit niedrigerem Rang aus ihrem + * z-Intervall (der Kern höherer Priorität läuft durch). `undefined` (kein + * auflösbarer WallType / synthetisches Fallback-Band) ⇒ keine Decken- + * Subtraktion (konservativ, wie das frühere Trim-Verhalten bei unbekanntem Typ). + */ + priority?: number; + /** + * Bauteil-Identität dieser Materiallage (`Component.id`) — treibt die MERGE-Regel + * der Schnitt-Boolean-Dominanz (gleiche Komponente + Priorität verschmelzen, keine + * innere Trennlinie). Reist als `RCutMeta.componentId` mit. + */ + componentId?: string; + /** Farbe der Schnitt-Umrisslinie dieser Schicht (aus `jointLineStyleId`, sonst Default). */ + cutLineColor?: RRgb; + /** Strichstärke der Schnitt-Umrisslinie in mm-Papier (aus `jointLineStyleId`, sonst Default). */ + cutLineWeight?: number; +} + +/** + * Löst den Schnitt-UMRISSLINIEN-Stil einer Schicht aus ihrem + * `Layer.jointLineStyleId` (LineStyle) auf: Farbe (hex→RGB 0..1) + Strichstärke + * (mm-Papier) — dieselbe Quelle wie die 2D-Schichtfuge (`generatePlan.ts`, Zeile + * ~1681). Fehlt der Verweis/LineStyle, `undefined` (der 3D-Cut fällt dann auf + * seinen Default-Schnittkanten-Stil zurück, siehe `section_fill::build_cut_cap_lines`). + * Bewusst tolerant (`find` statt `getLineStyle`), wie im 2D — ein verwaister Verweis + * darf den 3D-Push nicht werfen. + */ +function resolveCutLineStyle( + project: Project, + jointLineStyleId: string | undefined, +): { color?: RRgb; weight?: number } | undefined { + if (!jointLineStyleId) return undefined; + const ls = project.lineStyles.find((l) => l.id === jointLineStyleId); + if (!ls) return undefined; + return { color: hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB), weight: ls.weight }; +} + +/** + * Baut die {@link RCutMeta}-Nutzlast eines Wand-Bandes (Priorität/Identität/ + * Schnittkanten-Stil), oder `undefined`, wenn das Band keinerlei Schnitt-Metadaten + * trägt (z. B. das Fallback-Vollkörper-Band ohne auflösbaren WallType). Nur gesetzte + * Felder werden übernommen, damit `#[serde(default)]` auf der Rust-Seite greift. + */ +function bandCutMeta(b: WallBand): RCutMeta | undefined { + const meta: RCutMeta = {}; + if (b.priority !== undefined) meta.joinPriority = b.priority; + if (b.componentId !== undefined) meta.componentId = b.componentId; + if (b.cutLineColor !== undefined) meta.lineColor = b.cutLineColor; + if (b.cutLineWeight !== undefined) meta.lineWeight = b.cutLineWeight; + return Object.keys(meta).length > 0 ? meta : undefined; } /** @@ -229,13 +512,34 @@ function resolveWallBands(project: Project, wall: Wall, layered = true): WallBan // Zerlegung TS-seitig aus diesem einen Rechteck machen. (Der 3D-Viewer ruft // mit Default layered=true auf und bekommt unverändert die Schicht-Bänder.) if (!layered) { - return [{ thickness: total, color: dominantLayerColor(wt.layers, project, WALL_RGB), offset: 0 }]; + const dominant = wt.layers.reduce((best, l) => (l.thickness > best.thickness ? l : best)); + return [ + { + thickness: total, + color: dominantLayerColor(wt.layers, project, WALL_RGB), + offset: 0, + hatch: dominantLayerHatch(wt.layers, project, SECTION_WALL_AXIS_ANGLE_DEG), + materialIndex: resolveComponentMaterialLayer(project, dominant.componentId), + }, + ]; } const bands: WallBand[] = []; let prefix = 0; for (const l of wt.layers) { - const color = hexToRgb(getComponent(project, l.componentId).color); - bands.push({ thickness: l.thickness, color, offset: -total / 2 + prefix + l.thickness / 2 }); + const comp = getComponent(project, l.componentId); + const color = hexToRgb(comp.color); + const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId); + bands.push({ + thickness: l.thickness, + color, + offset: -total / 2 + prefix + l.thickness / 2, + priority: comp.joinPriority, + componentId: comp.id, + cutLineColor: lineStyle?.color, + cutLineWeight: lineStyle?.weight, + hatch: resolveComponentHatch(project, l.componentId, SECTION_WALL_AXIS_ANGLE_DEG), + materialIndex: resolveComponentMaterialLayer(project, l.componentId), + }); prefix += l.thickness; } return bands; @@ -274,7 +578,10 @@ function pushSegment( normalOffset: number, color: RRgb, wallId: string, + hatch?: RHatch, + materialIndex?: number, holes?: RHole[], + cut?: RCutMeta, ): void { if (to - from <= EPS) return; if (zTop - zBottom <= EPS) return; @@ -308,7 +615,10 @@ function pushSegment( color, layers: [], wallId, + ...(hatch ? { hatch } : {}), + ...(materialIndex != null ? { materialIndex } : {}), ...(holes && holes.length > 0 ? { holes } : {}), + ...(cut ? { cut } : {}), }); } @@ -372,30 +682,9 @@ function wallThicknessSafe(project: Project, wall: Wall): number { } /** - * Repräsentative `joinPriority` einer Wand (höchste ihrer Schichten) — analog - * `wallRepresentativePriority` in `toSection.ts` (2D-Schnitt-Dominanz), hier - * fürs 3D-Trim gegen überlappende Decken (s. {@link trimWallTopForCeilings}). - * `undefined`, wenn kein WallType/keine Schicht auflösbar ist (dann findet nie - * ein Trim statt — konservativ, kein Verhaltensbruch für unbekannte Typen). - */ -function wallDominantPriority(project: Project, wall: Wall): number | undefined { - try { - const wt = getWallType(project, wall); - let best: number | undefined; - for (const layer of wt.layers) { - const p = getComponent(project, layer.componentId).joinPriority; - if (best === undefined || p > best) best = p; - } - return best; - } catch { - return undefined; - } -} - -/** - * Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten, analog - * {@link wallDominantPriority}) — bestimmt, ob die Decke am Wand/Decke-Stoss - * "durchläuft" (gewinnt) und die Wand kappt. + * Repräsentative `joinPriority` einer Decke (höchste ihrer Schichten) — bestimmt, + * ob die Decke am Wand/Decke-Stoss "durchläuft" (gewinnt) und in die Wandschichten + * niedrigerer Priorität einschneidet (s. {@link collectCeilingCutters}). */ function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | undefined { try { @@ -412,54 +701,265 @@ function ceilingDominantPriority(project: Project, ceiling: Ceiling): number | u } /** - * Kappt `zTop` einer Wand an der Unterkante jeder überlappenden Decke mit - * HÖHERER `joinPriority` (Prioritäts-Join, Phase 3 — behebt das Wand/Decke- - * Z-Fighting aus `wallVerticalExtent`/`ceilingVerticalExtent`, s. Moduldoc): - * wächst `wall.height` bis zur Deckenoberkante (Standardfall, häufig bündig - * mit dem Geschoss-Wandkopf) und die Decke wächst vom selben Z nach unten, so - * überlappen sich beide Volumen exakt über die Deckendicke im Wand-Footprint. - * Gewinnt die Decke (höhere Priorität), endet die Wand VOR der Deckenunterkante - * statt sie zu durchdringen. - * - * Kriterien je Decke (alle müssen zutreffen, sonst kein Effekt): - * • dasselbe Geschoss (`ceiling.floorId === wall.floorId`), - * • vertikale Überlappung mit der (ungekappten) Wand-Ausdehnung, - * • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) — - * KEIN exakter Polygon-Test; eine Decke, deren Umriss die Wand nur knapp - * berührt/verfehlt, kann so fälschlich als überlappend gelten. Das ist eine - * bekannte Restlücke (siehe Moduldoc-Kommentar in dieser Datei): für den - * Regelfall (Decken-Umriss = Gebäude-/Raumgrundriss inkl. Wand-Footprints) - * ist die Bounding-Box exakt genug, um das Haupt-Z-Fighting zu beheben. - * • Decke hat eine HÖHERE (strikt) repräsentative `joinPriority` als die Wand. - * - * Bei mehreren qualifizierenden Decken gewinnt die NIEDRIGSTE Deckenunterkante - * (kappt am weitesten). Ohne auflösbare Wand-Priorität (unbekannter WallType) - * bleibt `zTop` unverändert (konservativ, wie heute). + * Ein Decken-„Cutter": vertikales z-Intervall + Dominanz-Rang + Grundriss-Umriss + * (s. {@link collectCeilingCutters}). Der `outline` erlaubt die PER-BAND-genaue + * Überdeckungsprüfung in {@link emitWall} (statt eines wandweiten BBox-Tests): eine + * Decke schneidet ein Schicht-Band nur dort, wo ihre Outline die Footprint- + * Mittellinie des Bandes im Grundriss überdeckt. */ -function trimWallTopForCeilings( +interface CeilingCutter { + zBottom: number; + zTop: number; + priority: number; + outline: Vec2[]; +} + +/** + * Sammelt die Decken, die eine Wand im layered-3D-Pfad PRO SCHICHT einschneiden — + * die 3D-Entsprechung der Schnitt-Boolean-Dominanz (`toSection.ts:: + * subtractDominantBands`): statt die GANZE Wand am Deckenanschluss zu kappen + * (früheres `trimWallTopForCeilings`), behält die Wand ihre volle Höhe und JEDES + * Schicht-Band verliert nur das z-Intervall der Decken mit STRIKT höherer + * `joinPriority` als das Band (der Kern höherer Priorität läuft durch die Decke). + * Ein Band kann dadurch vertikal in zwei Boxen zerfallen (unter Deckenunterkante + * + über Deckenoberkante, falls die Wand die Decke überragt). + * + * Kriterien je Decke (alle müssen zutreffen): + * • dasselbe Geschoss (`ceiling.floorId === wall.floorId`), + * • vertikale Überlappung mit der (vollen) Wand-Ausdehnung, + * • grober Footprint-Überlapp (Bounding-Box, s. {@link boundsOverlap}) — nur ein + * billiger Vor-Filter (keine Wand berührt, wenn die Wand-BBox die Decken-BBox + * nicht schneidet); die EXAKTE, per-Band geometrische Überdeckung entscheidet + * dann in {@link emitWall} (s. {@link bandCoverageIntervals}). + * • auflösbare Decken-`joinPriority` (`ceilingDominantPriority`). + * + * Das z-Intervall/die Priorität der Cutter ist PER DECKENSCHICHT (nicht die ganze + * Decke als EIN Block der dominanten Priorität): jede Deckenschicht wird zu einem + * eigenen Cutter mit IHREM z-Band (proportional auf [zBottom,zTop] verteilt, exakt + * wie {@link emitSlabs}) und IHRER `Component.joinPriority`. Das ist zwingend, damit + * der 3D-Cut EXAKT wie der 2D-Schnitt (`toSection.ts::subtractDominantBands`) + * verschneidet: dort trägt jede Schicht ihre eigene Priorität. Mit dem früheren + * EINEN Block (dominante Prio 100 über die volle Decke) verlor z. B. der Backstein + * (50) das GANZE Decken-z-Band [UK,OK] — obwohl er die schwimmenden Deckenschichten + * Estrich (30)/Dämmung (20) ÜBERRAGEN und schneiden soll; nur die Betonschicht (100) + * darf ihn kappen. So blieb im 3D (Solid UND Schnitt) über dem Backstein eine Lücke, + * durch die Estrich/Dämmung „rausliefen" (Nutzer-Bug). Per Schicht läuft der + * Backstein zwischen Beton-OK und Wandkopf durch und schneidet dort Estrich/Dämmung + * weg — identisch zum 2D-Schnitt. Der `outline` reist mit, damit {@link emitWall} + * PRO BAND prüft, ob die Decke die Footprint-Mittellinie dieses Bandes überdeckt + * (geometrischer Überlapp, kein wandweiter BBox-Test). Der Prioritätsvergleich pro + * Band passiert in {@link emitWall} (`cutter.priority > band.priority`) — eine + * Wand-Schicht mit ≥ Rang der jeweiligen Deckenschicht läuft durch deren z-Band durch. + * Ein-Schicht-/nicht auflösbare Deckentypen fallen auf EINEN Cutter über die volle + * Ausdehnung mit der dominanten Priorität zurück (Alt-Verhalten). + */ +function collectCeilingCutters( project: Project, wall: Wall, zBottom: number, zTop: number, -): number { - const wallPriority = wallDominantPriority(project, wall); - if (wallPriority === undefined) return zTop; +): CeilingCutter[] { const halfThickness = wallThicknessSafe(project, wall) / 2; const wallBounds = wallFootprintBounds(wall, halfThickness); - let top = zTop; + const cutters: CeilingCutter[] = []; for (const c of project.ceilings ?? []) { if (c.floorId !== wall.floorId) continue; if (!c.outline || c.outline.length < 3) continue; const cExtent = ceilingVerticalExtent(project, c); - // Keine vertikale Überlappung mit der (ungekappten) Wand-Ausdehnung -> Decke - // liegt komplett ausserhalb des Wandkörpers, betrifft ihn nicht. if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue; - const ceilingPriority = ceilingDominantPriority(project, c); - if (ceilingPriority === undefined || ceilingPriority <= wallPriority) continue; if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue; - if (cExtent.zBottom < top) top = cExtent.zBottom; + + // Per-Schicht-Cutter: je Deckenschicht ein eigenes z-Band + eigene Priorität + // (proportional auf die tatsächliche Decken-z-Spanne skaliert, oben→unten wie + // emitSlabs). Nur so schneidet Beton (100) den Backstein (50), Backstein aber + // Estrich (30)/Dämmung (20) — identisch zu subtractDominantBands. + let layers: Layer[] | undefined; + try { + layers = getCeilingType(project, c).layers; + } catch { + layers = undefined; + } + const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0; + if (layers && layers.length > 0 && total > EPS) { + const span = cExtent.zTop - cExtent.zBottom; + let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter) + for (const l of layers) { + const t = (l.thickness / total) * span; + const layerTop = cExtent.zTop - prefixTop; + const layerBottom = layerTop - t; + prefixTop += t; + let prio: number | undefined; + try { + prio = getComponent(project, l.componentId).joinPriority; + } catch { + prio = undefined; + } + if (prio === undefined) continue; + cutters.push({ zBottom: layerBottom, zTop: layerTop, priority: prio, outline: c.outline }); + } + } else { + const ceilingPriority = ceilingDominantPriority(project, c); + if (ceilingPriority === undefined) continue; + cutters.push({ zBottom: cExtent.zBottom, zTop: cExtent.zTop, priority: ceilingPriority, outline: c.outline }); + } } - return Math.max(zBottom, top); + return cutters; +} + +/** + * Achsen-Teilintervalle (Meter ab `wall.start` entlang `u`, geklemmt auf + * `[lo,hi]`), auf denen die MITTELLINIE eines Schicht-Bands (Querversatz + * `bandOffset` entlang `n = leftNormal(u)`) im Decken-Umriss `outline` liegt — + * die per-Band-Entsprechung des geometrischen Rechteck-Überlapps in + * `toSection.ts::subtractDominantBands`. Nur diese Teilintervalle bekommen später + * den Decken-z-Schnitt; unbedeckte Achsenabschnitte behalten die volle Höhe (so + * laufen Dämmung/Aussenputz, deren Mittellinie AUSSERHALB des Decken-Umrisses + * liegt, ununterbrochen durch — der Nutzer-Bug „Kranz durch die Dämmung"). + * + * Die Bandmittellinie wird entlang der Achse gerastert (~5-cm-Schritte, min. 32) + * und gegen {@link pointInOutline} geprüft; Innen/Aussen-Übergänge werden per + * Bisektion auf ~EPS verfeinert, sodass die Überdeckungsgrenzen scharf sind (auch + * eine Decke, die nur einen Teil der Wandlänge überdeckt, wird korrekt getrimmt). + */ +function bandCoverageIntervals( + wall: Wall, + u: Vec2, + n: Vec2, + bandOffset: number, + outline: Vec2[], + lo: number, + hi: number, +): Array<[number, number]> { + if (hi - lo <= EPS) return []; + const base = add(wall.start, scale(n, bandOffset)); + const inside = (t: number): boolean => pointInOutline(add(base, scale(u, t)), outline); + const steps = Math.max(32, Math.ceil((hi - lo) / 0.05)); + const dt = (hi - lo) / steps; + const intervals: Array<[number, number]> = []; + let prevInside = inside(lo); + let start: number | null = prevInside ? lo : null; + let prevT = lo; + for (let i = 1; i <= steps; i++) { + const t = i === steps ? hi : lo + i * dt; + const now = inside(t); + if (now !== prevInside) { + const cross = bisectBoundary(inside, prevT, t); + if (now) start = cross; + else if (start !== null) { + intervals.push([start, cross]); + start = null; + } + } + prevInside = now; + prevT = t; + } + if (start !== null) intervals.push([start, hi]); + return intervals; +} + +/** + * Bisektion des Innen/Aussen-Übergangs der Bandmittellinie zwischen `a` und `b` + * (verschiedene {@link pointInOutline}-Seiten) auf ~EPS-Genauigkeit — liefert die + * scharfe Achsen-Position der Überdeckungsgrenze. + */ +function bisectBoundary(inside: (t: number) => boolean, a: number, b: number): number { + const aInside = inside(a); + let lo = a; + let hi = b; + for (let i = 0; i < 40 && hi - lo > EPS * 0.01; i++) { + const mid = (lo + hi) / 2; + if (inside(mid) === aInside) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} + +/** + * Zieht die Cutter-Intervalle `holes` vom Intervall `[lo,hi]` ab und liefert die + * verbleibenden Teil-Intervalle (Meter). Löcher werden auf `[lo,hi]` geklemmt, + * sortiert und vereinigt; leeres `holes` ⇒ exakt `[[lo,hi]]` (unveränderter Fall). + * Dient sowohl der Achsen-Zerlegung durch Durchgangswand-Span-Cutouts als auch + * der vertikalen Decken-Subtraktion (identische 1D-Semantik wie + * `generatePlan.ts::subtractIntervals`, hier lokal gehalten — generatePlan bleibt + * unangetastet). + */ +function subtractSpans(lo: number, hi: number, holes: Array<[number, number]>): Array<[number, number]> { + const clipped = holes + .map(([a, b]): [number, number] => [Math.max(lo, Math.min(a, b)), Math.min(hi, Math.max(a, b))]) + .filter(([a, b]) => b > a + EPS) + .sort((p, q) => p[0] - q[0]); + const parts: Array<[number, number]> = []; + let cursor = lo; + for (const [a, b] of clipped) { + if (a > cursor + EPS) parts.push([cursor, a]); + if (b > cursor) cursor = b; + } + if (cursor < hi - EPS) parts.push([cursor, hi]); + return parts; +} + +/** + * Wendet die Decken-z-Cutter `cutters` (dominante horizontale Cut-Bänder [zBottom, + * zTop]) auf das Wandband-Intervall `[lo,hi]` an — je nach Terminierungs-Regel der + * Wand ({@link Wall.sliceTermination}). Bewusst als EIGENER, klar benannter Schritt + * neben der Priorität gehalten (Terminierung = Zuschnitt, KEINE Prioritätsregel): + * + * • "both"/undefined ⇒ heutiges Verhalten: `subtractSpans` stanzt jedes Cutter- + * z-Band aus (oben+unten Reste bleiben erhalten). Bit-identisch zu vorher. + * • "below" ⇒ die Wand ENDET an der Decke: alles AB der Decken-UNTERKANTE wird + * verworfen (kein über der Decke wieder auftauchender Rest). Clip-Grenze = das + * KLEINSTE `zBottom` aller dominanten Cutter dieses Bandes. Da die dominanteste + * (tragende) Deckenschicht — z. B. Beton (Prio 100) — jedes schwächere Band + * kappt UND zugleich die unterste Deckenlage ist, terminieren so ALLE Bänder + * einheitlich an der Deckenunterkante (kein Durchstoss/Staffel, keine + * Interpenetration mit Beton/Dämmung darüber). Für den Backstein, den nur der + * Beton kappt, ist das exakt „unter dem dominanten Cut" (dessen Unterkante). + * • "above" ⇒ spiegelbildlich: alles BIS zur Decken-OBERKANTE wird verworfen. + * Clip-Grenze = das GRÖSSTE `zTop` aller dominanten Cutter dieses Bandes. + * + * Ohne Cutter (`cutters` leer) bleibt `[lo,hi]` in allen Modi unverändert — ein + * Band ausserhalb des Decken-Umrisses (Aussenputz/Dämmung) läuft also weiter voll + * durch, unabhängig von der Terminierung. + */ +function terminateOrSubtractSpans( + lo: number, + hi: number, + cutters: Array<[number, number]>, + termination: SliceTermination | undefined, +): Array<[number, number]> { + if (cutters.length === 0) return [[lo, hi]]; + if (termination === "below") { + // Deckenunterkante = kleinstes zBottom der Cutter; die Wand endet dort. + let clipTop = Infinity; + for (const [cb] of cutters) clipTop = Math.min(clipTop, cb); + const top = Math.min(clipTop, hi); + return top > lo + EPS ? [[lo, top]] : []; + } + if (termination === "above") { + // Deckenoberkante = grösstes zTop der Cutter; die Wand beginnt dort. + let clipBottom = -Infinity; + for (const [, ct] of cutters) clipBottom = Math.max(clipBottom, ct); + const bottom = Math.max(clipBottom, lo); + return bottom < hi - EPS ? [[bottom, hi]] : []; + } + return subtractSpans(lo, hi, cutters); +} + +/** + * Achsen-Distanz (Meter ab `wall.start` entlang der Wandrichtung `u`), an der die + * MITTELLINIE eines Schicht-Bands (Querversatz `bandOffset` entlang `leftNormal(u)`) + * die Gehrungs-/Anschluss-Schnittlinie `cut` trifft — die 3D-Achsenbox-Entsprechung + * des 2D-`clippedBand`. Da die Box achsparallel endet (keine gekippte Stirnfläche), + * wird die Band-Länge auf den Schnittpunkt der MITTELLINIE gekürzt/verlängert; die + * beiden 2D-Bandkanten (bei ±thickness/2) treffen die Schnittlinie an leicht + * versetzten Achsenpunkten — der Box-Wert ist ihr Mittelwert. `null` bei + * (nahezu) parallelem Schnitt (kollineare Achsen) ⇒ Aufrufer behält das Bandende. + */ +function bandCutDistance(wall: Wall, u: Vec2, n: Vec2, bandOffset: number, cut: Line): number | null { + const base = add(wall.start, scale(n, bandOffset)); + const hit = lineIntersect(base, u, cut.point, cut.dir); + if (!hit) return null; + return dot(sub(hit, wall.start), u); } /** @@ -476,17 +976,15 @@ function trimWallTopForCeilings( * passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher * werden hier NICHT emittiert. */ -function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): void { +function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void { const bands = resolveWallBands(project, wall, layered); - const { zBottom, zTop: rawZTop } = wallVerticalExtent(project, wall); - // Decken-Trim (kappt die Wand an der Unterkante einer dominanteren Decke) NUR - // im 3D-Viewer-Pfad (layered=true): dort vermeidet das Kappen das Z-Fighting - // Wand/Decke am bündigen Anschluss. Der Schnitt-Pfad (layered=false) will die - // UNGEKAPPTE volle Wandhöhe, weil er seine EIGENE prioritätsbasierte Schicht- - // Dominanz (toSection.ts::subtractDominantBands) auf Rohgeometrie anwendet — - // ein vorab gekürzter Wandkopf würde dort Schichteinzug/Bodenaufbau am Wand- - // Decken-Anschluss verfälschen. - const zTop = layered ? trimWallTopForCeilings(project, wall, zBottom, rawZTop) : rawZTop; + const { zBottom, zTop } = wallVerticalExtent(project, wall); + // Anders als früher (`trimWallTopForCeilings` kappte im layered-Pfad die GANZE + // Wand an der Deckenunterkante) behält die Wand jetzt IN BEIDEN PFADEN ihre + // volle Höhe; die per-Schicht-Decken-Subtraktion (unten, nur layered) schneidet + // Bänder niedrigerer Priorität im Decken-z-Band aus — die 3D-Entsprechung der + // Schnitt-Boolean-Dominanz. Der Schnitt-Pfad (layered=false) bleibt unverändert + // (volle Rohgeometrie; seine Dominanz macht toSection.ts::subtractDominantBands). const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); if (axisLen < 1e-9 || zTop - zBottom <= EPS) return; @@ -495,7 +993,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v // Schicht die GANZE Wand selektiert und das Highlight alle Bänder umrandet. const emitSeg = (from: number, to: number, zb: number, zt: number): void => { for (const b of bands) { - pushSegment(out, wall, from, to, zb, zt, b.thickness, b.offset, b.color, wall.id); + pushSegment(out, wall, from, to, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, undefined, bandCutMeta(b)); } }; @@ -522,10 +1020,22 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v cutouts.sort((a, b) => a.from - b.from); // ── 3D-Viewer-Pfad (layered=true): EIN Körper je Schicht-Band über die volle - // Achse mit allen Öffnungen als echte Löcher. Die Segment-Zerlegung entfällt - // komplett — render3d stanzt die Löcher (Rechteck-Gitter + Laibungen). Türen - // sind Löcher bis zur Wand-UK (`oBottom == zBottom`), Fenster ab Brüstung. So - // bildet EIN Körper auch versetzt übereinanderliegende Fenster ab. + // Achse mit allen Öffnungen als echte Löcher (render3d stanzt sie), PLUS die + // materialbewussten Verschneidungen (identisch zum 2D-Grundriss/Schnitt): + // 1. End-Cuts/Merge an L-/T-Stössen: jede Schicht wird an ihrem Achsenende an + // der Gehrungs-/Anschluss-Schnittlinie individuell gekürzt/verlängert + // (`WallCuts.startCut`/`endCut`, bzw. pro Schicht `layerCuts.start/end` — + // merge: Kern läuft bis zur Rückgrat-Nahfläche durch; trim: Putz stoppt an + // der Wandoberfläche). Wie im 2D wird die Band-MITTELLINIE geschnitten. + // 2. Durchgangswand-Span-Cutouts: der Nah-Putz einer Durchgangswand wird über + // die Achsen-Breite des durchstechenden Abzweig-Kerns ausgeschnitten — ein + // Band zerfällt dabei entlang der Achse in mehrere Teilstücke. + // 3. Decken-Dominanz pro Schicht: Bänder niedrigerer `joinPriority` verlieren + // das z-Intervall überlappender Decken (vertikaler Split), der Kern läuft + // durch (s. {@link collectCeilingCutters}). + // Die Öffnungs-Löcher (`holes`, Achsen-Meter ab Wand-Start) werden je Teilstück + // auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum + // jeweiligen Box-Start korrekt stanzt. if (layered) { const holes: RHole[] = cutouts.map((c) => ({ from: c.from, @@ -533,8 +1043,109 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v zBottom: c.oBottom, zTop: c.oTop, })); - for (const b of bands) { - pushSegment(out, wall, 0, axisLen, zBottom, zTop, b.thickness, b.offset, b.color, wall.id, holes); + const startCut = cuts?.startCut ?? null; + const endCut = cuts?.endCut ?? null; + const layerCuts = cuts?.layerCuts; + const spanCutouts = cuts?.spanCutouts ?? []; + const ux = (wall.end.x - wall.start.x) / axisLen; + const uy = (wall.end.y - wall.start.y) / axisLen; + const u: Vec2 = { x: ux, y: uy }; + const n = leftNormal(u); // (-uy, ux) — dieselbe Konvention wie pushSegment/2D. + const ceilingCutters = collectCeilingCutters(project, wall, zBottom, zTop); + + for (let li = 0; li < bands.length; li++) { + const b = bands[li]; + // (1) Per-Schicht-Cuts an den Achsenden. `layerCuts` (materialbewusster + // T-Stoss) überschreibt — wo gesetzt — den einheitlichen start/endCut PRO + // Schicht; `null` an einem Index heisst „keine Kürzung an diesem Ende". + // Exakt die 2D-Wahl aus generatePlan.ts::addWallPoche (Zeile ~1523). + const sCut = layerCuts ? layerCuts.start[li] : startCut; + const eCut = layerCuts ? layerCuts.end[li] : endCut; + let from0 = 0; + let to0 = axisLen; + if (sCut) { + const d = bandCutDistance(wall, u, n, b.offset, sCut); + if (d !== null) from0 = d; + } + if (eCut) { + const d = bandCutDistance(wall, u, n, b.offset, eCut); + if (d !== null) to0 = d; + } + + // (2) Span-Cutouts, deren Quer-Offsetbereich dieses Band schneidet, brechen + // die Achse [from0,to0] auf (Nah-Putz-Durchstoss der Durchgangswand). + const bandLo = b.offset - b.thickness / 2; + const bandHi = b.offset + b.thickness / 2; + const axisHoles: Array<[number, number]> = []; + for (const c of spanCutouts) { + const cLo = Math.min(c.offA, c.offB); + const cHi = Math.max(c.offA, c.offB); + if (bandLo < cHi - EPS && bandHi > cLo + EPS) axisHoles.push([c.from, c.to]); + } + const axisPieces = subtractSpans(from0, to0, axisHoles); + + // (3) Decken-Subtraktion GEOMETRISCH pro Band (Replik der Schnitt-Boolean- + // Dominanz `toSection.ts::subtractDominantBands`): eine Decke mit STRIKT + // höherer Priorität schneidet dieses Band nur dort aus, wo ihr Grundriss- + // Umriss die Footprint-MITTELLINIE dieses Bandes überdeckt (nicht mehr der + // wandweite BBox-Test). Aussenschichten (Dämmung/Aussenputz), deren + // Mittellinie ausserhalb des Decken-Umrisses liegt, laufen ununterbrochen + // durch — nur der überdeckte Kern verliert das Decken-z-Intervall. Ohne + // auflösbare Band-Priorität bleibt die volle Höhe (konservativ). + const bandZCuts: Array<{ from: number; to: number; zBottom: number; zTop: number }> = []; + if (b.priority !== undefined) { + for (const cc of ceilingCutters) { + if (cc.priority <= b.priority) continue; + for (const [ca, cb] of bandCoverageIntervals(wall, u, n, b.offset, cc.outline, from0, to0)) { + bandZCuts.push({ from: ca, to: cb, zBottom: cc.zBottom, zTop: cc.zTop }); + } + } + } + + // Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start + // `a` umrechnen (render3d klemmt u/z selbst auf die Box). + const emitPiece = (a: number, bb: number, zb: number, zt: number): void => { + const segHoles = holes + .filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS) + .map((h) => ({ from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop })); + pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b)); + }; + + for (const [a, bb] of axisPieces) { + // Nur die Decken-z-Schnitte, die dieses Achsen-Teilstück wirklich treffen. + const local = bandZCuts + .map((c) => ({ from: Math.max(a, c.from), to: Math.min(bb, c.to), zBottom: c.zBottom, zTop: c.zTop })) + .filter((c) => c.to > c.from + EPS); + if (local.length === 0) { + // Kein Decken-Überlapp auf diesem Achsenstück ⇒ volle Höhe (Band läuft durch). + emitPiece(a, bb, zBottom, zTop); + continue; + } + // Achse [a,bb] an allen Überdeckungs-Grenzen in elementare Teilstücke + // zerlegen; je Teilstück nur die dort greifenden Decken-z-Intervalle + // subtrahieren (unbedeckte Teilstücke behalten die volle Höhe). + const bounds = new Set([a, bb]); + for (const c of local) { + bounds.add(c.from); + bounds.add(c.to); + } + const xs = [...bounds].sort((p, q) => p - q); + for (let i = 0; i < xs.length - 1; i++) { + const xa = xs[i]; + const xb = xs[i + 1]; + if (xb - xa <= EPS) continue; + const mid = (xa + xb) / 2; + const zCutters = local + .filter((c) => c.from <= mid && c.to >= mid) + .map((c): [number, number] => [c.zBottom, c.zTop]); + // Terminierung (Zuschnitt) statt reiner z-Subtraktion: bei "below"/"above" + // endet die Wand an der Decke, statt oben wieder aufzutauchen (s. + // {@link terminateOrSubtractSpans}). "both"/undefined = heutiges Verhalten. + for (const [zb, zt] of terminateOrSubtractSpans(zBottom, zTop, zCutters, wall.sliceTermination)) { + emitPiece(xa, xb, zb, zt); + } + } + } } return; } @@ -575,6 +1186,149 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true): v } } +/** Neutraler Beton-Grauton für Treppen (wie der Three.js-Pfad `addStairMeshes`). */ +const STAIR_RGB: RRgb = hexToRgb("#9aa0a6", SLAB_RGB); + +/** + * Emittiert Treppen als RSlabs (extrudierte Grundriss-Polygone) — der WASM/wgpu- + * Renderer kennt sonst KEINE Treppen (nur Wände/Decken/Meshes), sodass Treppen im + * Standard-3D unsichtbar blieben. Jeder Tritt ist ein extrudiertes Polygon; das + * ist exakt eine RSlab. Tragart über {@link getStairType}: + * • "massiv" / kein Typ — Vollblock je Tritt von der Treppen-UK bis zur Tritt-OK + * (gestuftes Massiv, Treppe auf Erdreich/Sockel). + * • "beton" — Ortbeton-Laufplatte: je Tritt ein Block von der SCHRÄGEN Untersicht + * (eine Stufe + Laufplattendicke unter der Tritt-OK, an der Treppen-UK gekappt) + * bis zur Tritt-OK. Die stufig gestapelten Blöcke bilden die schräge, UNTEN + * OFFENE Untersicht der klassischen Betontreppe. + * • "wange"/"aufgesattelt"/"spindel" — nur eine dünne Tritt-Platte der Dicke + * `treadThickness` an der Tritt-OK, darunter offen. + * Podest (L/Wendel) als Vollblock bis zur halben Steighöhe. Setzstufen/Nase/ + * Geländer sind Verfeinerungen (Three.js-Pfad) — hier zählt zuerst die SICHTBARKEIT. + * `ceilingId` trägt hier die Treppen-Id (nur TS-seitiges Tagging; render3d ignoriert + * das Feld, und der Pick läuft separat über {@link pickGeometry}). + */ +const CONCRETE_WAIST = 0.18; // Laufplattendicke einer Ortbetontreppe (Meter) + +function emitStairs(project: Project): RSlab[] { + const out: RSlab[] = []; + for (const stair of project.stairs ?? []) { + const { zBottom, zTop } = stairVerticalExtent(project, stair); + const totalRise = zTop - zBottom; + if (totalRise <= EPS) continue; + const geo = stairGeometry(stair, totalRise); + const st = getStairType(project, stair); + const structure = st?.structure ?? "massiv"; + // Gerade Betontreppe: die GLATTE Laufplatte kommt als echtes Mesh + // (emitStairMeshes) — hier keine Slab-Näherung emittieren (sonst doppelt). + if (structure === "beton" && stair.shape === "straight") continue; + const treadThickness = st?.treadThickness ?? 0.04; + const riser = geo.riserHeight; + for (const tr of geo.treads) { + if (tr.pts.length < 3) continue; + const top = zBottom + tr.topRise; + const bottom = + structure === "massiv" + ? zBottom // Vollblock bis zur Treppen-UK + : structure === "beton" + ? Math.max(zBottom, top - riser - CONCRETE_WAIST) // schräge Laufplatte + : top - treadThickness; // offene Tritte (Wange/aufgesattelt/Spindel) + if (top - bottom <= EPS) continue; + out.push({ + outline: tr.pts.map((p) => [p.x, p.y] as RVec2), + zBottom: bottom, + zTop: top, + color: STAIR_RGB, + ceilingId: stair.id, + }); + } + if (geo.landing && geo.landing.length >= 3) { + out.push({ + outline: geo.landing.map((p) => [p.x, p.y] as RVec2), + zBottom, + zTop: zBottom + totalRise * 0.5, + color: STAIR_RGB, + ceilingId: stair.id, + }); + } + } + return out; +} + +/** + * Glatte Ortbeton-Laufplatte (Tragart "beton") als echtes Dreiecks-Mesh — nur für + * GERADE Läufe. Anders als die stufige Slab-Näherung ({@link emitStairs}) entsteht + * eine SPIEGELGLATTE schräge Untersicht: je Tritt ein konvexes Prisma mit + * HORIZONTALER Oberkante (= Trittfläche) und SCHRÄGER Unterkante auf EINER + * gemeinsamen Soffit-Geraden. Weil alle Prismen-Unterkanten exakt auf dieser + * Geraden liegen, bildet die Vereinigung eine durchgehende glatte Schräge; die + * Höhensprünge der Oberkanten bleiben als sichtbare Setzstufen. Prismen sind + * konvex → triviale, robuste Triangulierung (kein Ear-Clipping). Positions in + * MODELL-Metern (x, y, z=Höhe); render3d bildet nach world (x, z, y) ab. + */ +function emitStairMeshes(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const stair of project.stairs ?? []) { + const st = getStairType(project, stair); + if (!st || st.structure !== "beton") continue; + if (stair.shape !== "straight") continue; // glattes Mesh nur für gerade Läufe + if (stair.runLength <= EPS) continue; + const { zBottom, zTop } = stairVerticalExtent(project, stair); + const totalRise = zTop - zBottom; + if (totalRise <= EPS) continue; + const geo = stairGeometry(stair, totalRise); + const u = normalize(stair.dir); // Laufrichtung (Grundriss) + const w = leftNormal(u); // Querrichtung (Breite) + const runTotal = stair.runLength; + // Laufplattendicke (vertikal): an die Steigung gekoppelt, geklemmt. + const waistV = Math.min(0.24, Math.max(0.14, geo.riserHeight * 1.2)); + // Soffit-Höhe entlang des Laufs: eine Gerade PARALLEL zur Trittkantenlinie + // (Steigung totalRise/runTotal), um `waistV` nach unten versetzt → KONSTANTE + // Plattendicke über den ganzen Lauf (sonst würde die Platte nach oben dicker). + // An der Treppen-UK gekappt, damit die unterste Stufe auf dem Boden aufsitzt + // statt darunter zu schweben. + const soffitH = (runS: number) => { + const s = Math.min(Math.max(runS, 0), runTotal); + return Math.max(zBottom, zBottom + (s / runTotal) * totalRise - waistV); + }; + const runOf = (p: Vec2) => (p.x - stair.start.x) * u.x + (p.y - stair.start.y) * u.y; + const sideOf = (p: Vec2) => (p.x - stair.start.x) * w.x + (p.y - stair.start.y) * w.y; + const positions: number[] = []; + const indices: number[] = []; + for (const tr of geo.treads) { + if (tr.pts.length < 4) continue; + // Vier Eckpunkte nach Lauf-Parameter trennen (2 kleinste = Startkante, 2 + // grösste = Endkante), je Kante nach Quer-Parameter sortieren (0 = links). + const byRun = [...tr.pts].sort((a, b) => runOf(a) - runOf(b)); + const startEdge = [byRun[0], byRun[1]].sort((a, b) => sideOf(a) - sideOf(b)); + const endEdge = [byRun[2], byRun[3]].sort((a, b) => sideOf(a) - sideOf(b)); + const runS = Math.min(runOf(byRun[0]), runOf(byRun[1])); + const runE = Math.max(runOf(byRun[2]), runOf(byRun[3])); + const topH = zBottom + tr.topRise; + const bS = soffitH(runS); + const bE = soffitH(runE); + if (topH - Math.max(bS, bE) <= EPS) continue; + const base = positions.length / 3; + const push = (p: Vec2, z: number) => positions.push(p.x, p.y, z); + // 0/1 Startkante unten (soffit), 2/3 Endkante unten, 4/5 Start oben, 6/7 End oben. + push(startEdge[0], bS); push(startEdge[1], bS); + push(endEdge[0], bE); push(endEdge[1], bE); + push(startEdge[0], topH); push(startEdge[1], topH); + push(endEdge[0], topH); push(endEdge[1], topH); + const quad = (a: number, b: number, c: number, d: number) => + indices.push(base + a, base + b, base + c, base + a, base + c, base + d); + quad(0, 1, 3, 2); // Untersicht (schräge Soffit-Fläche) + quad(4, 6, 7, 5); // Oberseite (Trittfläche) + quad(0, 4, 5, 1); // Startkante (Setzstufen-Front der Vorstufe sichtbar) + quad(2, 3, 7, 6); // Endkante + quad(0, 2, 6, 4); // linke Wange + quad(1, 5, 7, 3); // rechte Wange + } + if (indices.length === 0) continue; + out.push({ positions, indices, kind: "extrusion", color: STAIR_RGB }); + } + return out; +} + /** Emittiert die Deckenplatten eines Projekts (falls vorhanden). */ function emitSlabs(project: Project): RSlab[] { const out: RSlab[] = []; @@ -584,21 +1338,285 @@ function emitSlabs(project: Project): RSlab[] { // bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung). const { zBottom, zTop } = ceilingVerticalExtent(project, c); if (zTop - zBottom <= EPS) continue; - // Repräsentative Farbe = dickste Schicht des Deckentyps (analog Wände); - // kein auflösbarer Deckentyp ⇒ SLAB_RGB-Fallback. - let color: RRgb = SLAB_RGB; + const outline = c.outline.map((p) => [p.x, p.y] as RVec2); + + // Geschichteter Bodenaufbau: je Schicht des Deckentyps EIN eigener Slab, + // von oben (OK, erste Lage) nach unten (UK, letzte Lage) gestapelt und + // proportional auf die tatsächliche z-Spanne [zBottom, zTop] verteilt (wie + // die Lagen-Summe bei den Wänden). Jede Lage trägt ihre Bauteil-Schraffur + + // -Farbe — so zeigt der 3D-Schnitt den Bodenaufbau GESCHICHTET (Estrich/ + // Dämmung/Beton …), identisch zum 2D-Schnitt, statt eines Einkörpers in der + // dominanten Schicht. Kein auflösbarer Deckentyp / keine Lagen ⇒ EIN + // Vollkörper-Slab in SLAB_RGB (bisheriges Fallback-Verhalten). + let layers: Layer[] | undefined; try { - color = dominantLayerColor(getCeilingType(project, c).layers, project, SLAB_RGB); + layers = getCeilingType(project, c).layers; } catch { - color = SLAB_RGB; + layers = undefined; } - out.push({ - outline: c.outline.map((p) => [p.x, p.y] as RVec2), - zBottom, - zTop, - color, - ceilingId: c.id, - }); + const total = layers?.reduce((s, l) => s + l.thickness, 0) ?? 0; + if (layers && layers.length > 0 && total > EPS) { + const span = zTop - zBottom; + let prefixTop = 0; // von OK (zTop) nach unten aufsummiert (Meter) + for (const l of layers) { + const t = (l.thickness / total) * span; + const layerTop = zTop - prefixTop; + const layerBottom = layerTop - t; + prefixTop += t; + let color: RRgb = SLAB_RGB; + let cut: RCutMeta | undefined; + try { + const comp = getComponent(project, l.componentId); + color = hexToRgb(comp.color); + // Schnitt-Metadaten der Schicht: joinPriority + Identität (für die + // Boolean-Dominanz/MERGE) + Schnittkanten-Stil aus jointLineStyleId. + const lineStyle = resolveCutLineStyle(project, l.jointLineStyleId); + cut = { + joinPriority: comp.joinPriority, + componentId: comp.id, + ...(lineStyle?.color ? { lineColor: lineStyle.color } : {}), + ...(lineStyle?.weight != null ? { lineWeight: lineStyle.weight } : {}), + }; + } catch { + color = SLAB_RGB; + cut = undefined; + } + const hatch = resolveComponentHatch(project, l.componentId); + out.push({ + outline, + zBottom: layerBottom, + zTop: layerTop, + color, + ceilingId: c.id, + ...(hatch ? { hatch } : {}), + ...(cut ? { cut } : {}), + }); + } + } else { + out.push({ outline, zBottom, zTop, color: SLAB_RGB, ceilingId: c.id }); + } + } + return out; +} + +/** Event-Name für den "Extrusion(en) geladen"-Stoss (s. Wasm3DViewport.tsx/nativeSync.ts). */ +export const TRUCK_MESH_READY_EVENT = "dossier:truck-mesh-ready"; + +/** + * Extrudiert `project.extrudedSolids` (truck-Integration, `docs/design/ + * truck-plan.md`) zu Kontext-Meshes. `extrudePolygon` lädt WASM asynchron; + * `emitMeshes`/`emitExtrudedSolids` selbst bleiben synchron (Aufrufer wie + * `updateModel`/`pushNativeWalls` erwarten das) — daher ein modulweiter Cache + * je Solid-Id, der bei fehlendem/veraltetem Eintrag die Extrusion im + * Hintergrund anstösst und `TRUCK_MESH_READY_EVENT` feuert, sobald sie fertig + * ist (Wasm3DViewport/nativeSync hören darauf und re-pushen das Modell — ohne + * das Signal käme eine fertige Extrusion trotz Erfolgs NIE ins Bild, weil + * `updateModel` nur bei PROJEKT-Änderungen erneut läuft). Nur im Browser (kein + * `window` unter vitest/node) und mit stillem Fallback bei Fehlern. + */ +interface TruckCacheEntry { + /** Signatur (Punkte+Höhe) des Solids beim Laden — invalidiert den Eintrag bei Änderung. */ + signature: string; + /** null, solange die Extrusion noch lädt. */ + mesh: RMesh | null; +} +const truckMeshCache = new Map(); + +function extrudedSolidSignature(solid: ExtrudedSolid): string { + return `${solid.height}|${solid.taper ?? 0}|${solid.points.map((p) => `${p.x},${p.y}`).join(";")}`; +} + +function emitExtrudedSolids(project: Project): RMesh[] { + if (typeof window === "undefined") return []; + const out: RMesh[] = []; + // Cache-Einträge gelöschter/nicht mehr existierender Körper verwerfen (sonst + // wächst der Cache unbegrenzt über eine Session, z. B. nach Extrudieren → + // Kopieren → Löschen in Serie). + const liveIds = new Set((project.extrudedSolids ?? []).map((s) => s.id)); + for (const id of truckMeshCache.keys()) { + if (!liveIds.has(id)) truckMeshCache.delete(id); + } + for (const solid of project.extrudedSolids ?? []) { + if (solid.points.length < 3 || solid.height <= 0) continue; + const signature = extrudedSolidSignature(solid); + let entry = truckMeshCache.get(solid.id); + if (!entry || entry.signature !== signature) { + // Die bisherige Mesh (falls vorhanden) WEITER anzeigen, bis die neue + // Extrusion fertig geladen ist — sonst verschwindet der Körper kurz, + // während der Nutzer Höhe/Verjüngung im Attribute-Panel tippt (jeder + // Tastendruck feuert onChange → neue Signatur). + const staleMesh = entry?.mesh ?? null; + entry = { signature, mesh: staleMesh }; + truckMeshCache.set(solid.id, entry); + let baseZ = 0; + try { + baseZ = getFloor(project, solid.levelId).baseElevation ?? 0; + } catch { + // Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback). + } + // Kreis-Profil: echte runde truck-Extrusion (extrudeCircle) statt der + // 48-Eck-Tessellierung in `points` — glattes Zylinder-Mesh. + const taper = solid.taper ?? 0; + const extrusion = solid.circle + ? extrudeCircle(solid.circle.center.x, solid.circle.center.y, solid.circle.r, solid.height, taper) + : extrudePolygon(solid.points.flatMap((p) => [p.x, p.y]), solid.height, taper); + extrusion + .then((mesh) => { + // truck extrudiert immer ab z=0 — auf die Geschoss-UK anheben. + const positions = + baseZ === 0 + ? mesh.positions + : mesh.positions.map((v, i) => (i % 3 === 2 ? v + baseZ : v)); + const cur = truckMeshCache.get(solid.id); + if (!cur || cur.signature !== signature) return; // inzwischen veraltet + cur.mesh = { positions, indices: mesh.indices, kind: "extrusion", color: EXTRUSION_RGB }; + window.dispatchEvent(new Event(TRUCK_MESH_READY_EVENT)); + }) + .catch((e) => console.warn("truck-Extrusion fehlgeschlagen:", e)); + } + if (entry.mesh) out.push(entry.mesh); + } + return out; +} + +/** + * Strecken eines 2D-Zeichenelements für die Boden-Spur im 3D — exakt dieselbe + * Teilmenge wie `drawing2DSegments` im drei.js-Fallback (`Viewport3D.tsx`): + * line/polyline/rect. Kreis/Bogen/Text bleiben aussen vor (keine neue + * Fähigkeit gegenüber dem Fallback, nur Parität). + */ +function drawingSegments3d(d: Drawing2D): [Vec2, Vec2][] { + const g = d.geom; + const out: [Vec2, Vec2][] = []; + if (g.shape === "line") out.push([g.a, g.b]); + else if (g.shape === "polyline") { + for (let i = 0; i < g.pts.length - 1; i++) out.push([g.pts[i], g.pts[i + 1]]); + if (g.closed && g.pts.length > 2) out.push([g.pts[g.pts.length - 1], g.pts[0]]); + } else if (g.shape === "rect") { + const c1 = g.min, c3 = g.max; + const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y }; + out.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]); + } + return out; +} + +/** Modellfarbe einer 2D-Zeichnung (color → LineStyle → Kategorie), analog `drawing2DColor`. */ +function drawingColor3d(project: Project, d: Drawing2D): RRgb { + if (d.color) return hexToRgb(d.color, DRAWING_LINE_FALLBACK_RGB); + const ls = d.lineStyleId ? project.lineStyles.find((l) => l.id === d.lineStyleId) : undefined; + if (ls?.color) return hexToRgb(ls.color, DRAWING_LINE_FALLBACK_RGB); + const cat = flattenCategories(project.layers).find((c) => c.code === d.categoryCode); + return cat?.color ? hexToRgb(cat.color, DRAWING_LINE_FALLBACK_RGB) : DRAWING_LINE_FALLBACK_RGB; +} + +/** + * Emittiert je 2D-Zeichnung ein dünnes Ribbon-Mesh knapp über ihrer + * Geschossebene — die "2D-Referenz im 3D" (z. B. ein Boden-Raster), die der + * drei.js-Fallback (`addDrawing2DLines`) zeigt, bisher aber im nativen + * render3d/wgpu-Pfad fehlte (`RModel3d` kannte nur walls/slabs/meshes). + * `positions` in MODELL-Metern (x, y, z=Höhe) wie `emitOpeningGlass`; render3d + * rendert Kontext-Meshes ohnehin doppelseitig (`append_context_mesh`), daher + * genügt ein flaches Ribbon (kein Quader) pro Strecke. + */ +function emitDrawingLines(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const d of project.drawings2d) { + const segs = drawingSegments3d(d); + if (segs.length === 0) continue; + let baseZ = 0; + try { + baseZ = getFloor(project, d.levelId).baseElevation ?? 0; + } catch { + // Geschoss nicht (mehr) auflösbar: bei UK 0 zeichnen (Fallback). + } + const elev = baseZ + DRAWING_LINE_ELEVATION_EPS; + const positions: number[] = []; + const indices: number[] = []; + for (const [a, b] of segs) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) continue; + const nx = (-dy / len) * DRAWING_LINE_HALF_WIDTH; + const ny = (dx / len) * DRAWING_LINE_HALF_WIDTH; + const base = positions.length / 3; + positions.push( + a.x + nx, a.y + ny, elev, + a.x - nx, a.y - ny, elev, + b.x - nx, b.y - ny, elev, + b.x + nx, b.y + ny, elev, + ); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + if (positions.length === 0) continue; + out.push({ positions, indices, kind: "imported", color: drawingColor3d(project, d) }); + } + return out; +} + +/** + * 3D-Farbe einer Stütze: die Bauteil-Albedo (`Component.color`), falls ein + * `componentId` auflösbar ist, sonst der neutrale Tragwerks-Grauton {@link + * COLUMN_RGB}. Eine explizite `column.color` (Grundriss-Umrissfarbe) wirkt + * bewusst NICHT auf die 3D-Fläche (nur der 2D-Umriss), damit die 3D-Darstellung + * material-/neutral bleibt — analog Wand (Umrissfarbe ≠ 3D-Albedo). + */ +function columnColor3d(project: Project, col: Column): RRgb { + if (col.componentId) { + try { + return hexToRgb(getComponent(project, col.componentId).color, COLUMN_RGB); + } catch { + return COLUMN_RGB; + } + } + return COLUMN_RGB; +} + +/** + * Baut ein geschlossenes Prisma-Mesh aus einem konvexen Grundriss-Polygon + * `foot` (≥3 Punkte, CCW) zwischen `zBottom` und `zTop`. `positions` in MODELL- + * Metern (x, y, z=Höhe) wie {@link emitMeshes}; render3d rendert Kontext-Meshes + * doppelseitig, daher ist die Winding-Reihenfolge unerheblich. Die Deckel werden + * als Dreiecks-Fächer ab dem ersten Vertex tesselliert (konvex → korrekt). + */ +function prismMesh(foot: Vec2[], zBottom: number, zTop: number, color: RRgb): RMesh { + const n = foot.length; + const positions: number[] = []; + const indices: number[] = []; + // 0..n-1 = untere Ring-Vertices, n..2n-1 = obere Ring-Vertices. + for (const p of foot) positions.push(p.x, p.y, zBottom); + for (const p of foot) positions.push(p.x, p.y, zTop); + // Mantelflächen: je Kante ein Quad (2 Dreiecke). + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const b0 = i, b1 = j, t0 = i + n, t1 = j + n; + indices.push(b0, b1, t1, b0, t1, t0); + } + // Deckel (Fächer ab Vertex 0): unten + oben. + for (let i = 1; i < n - 1; i++) { + indices.push(0, i, i + 1); // unten + indices.push(n, n + i, n + i + 1); // oben + } + return { positions, indices, kind: "extrusion", color }; +} + +/** + * Extrudiert `project.columns` (Tragwerk) zu parametrischen Prisma-Meshes — + * Rechteck-Box bzw. tesselliertes Kreis-Prisma, je Profil. SYNCHRON (anders als + * die truck-Extrusion der `ExtrudedSolid`s, {@link emitExtrudedSolids}): die + * Kontur kommt aus {@link columnFootprint}, die vertikale Lage aus {@link + * columnVerticalExtent} (UK/OK-Anker bzw. Geschoss-UK + Höhe). Rust/render3d + * bleibt unverändert (Kontext-Mesh-Pfad). + */ +function emitColumns(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const col of project.columns ?? []) { + if (col.height <= 0) continue; + const foot = columnFootprint(col); + if (foot.length < 3) continue; + const { zBottom, zTop } = columnVerticalExtent(project, col); + if (zTop - zBottom <= EPS) continue; + out.push(prismMesh(foot, zBottom, zTop, columnColor3d(project, col))); } return out; } @@ -624,6 +1642,9 @@ function emitMeshes(project: Project): RMesh[] { }); } } + out.push(...emitExtrudedSolids(project)); + out.push(...emitColumns(project)); + out.push(...emitDrawingLines(project)); return out; } @@ -697,10 +1718,40 @@ function emitOpeningGlass(project: Project): RMesh[] { */ export function projectToWalls3d(project: Project, layered = true): RWall[] { const out: RWall[] = []; - for (const w of project.walls) emitWall(out, project, w, layered); + // Verschneidungs-Schnittlinien PRO GESCHOSS berechnen (wie generatePlan.ts sie + // je Grundriss auf der gefilterten Wandmenge bildet) — nur für den layered-3D- + // Pfad. Nach Geschoss gruppieren, damit deckungsgleiche Wände verschiedener + // Geschosse (z. B. EG/OG an identischen XY-Knoten) NICHT fälschlich verschneiden. + // Der Schnitt-Pfad (layered=false) bekommt KEINE Cuts (unverändert, seine + // Dominanz macht toSection.ts). Anders als im 2D wird hier NICHT nach + // Sichtbarkeit gefiltert — 3D zeigt alle Wände, alle nehmen an den Joins teil. + const joins = layered ? computeJoinsByFloor(project) : undefined; + for (const w of project.walls) emitWall(out, project, w, layered, joins?.get(w.id)); return out; } +/** + * Berechnet {@link computeJoins} getrennt je Geschoss (`wall.floorId`) und führt + * die Ergebnis-Maps zu einer flachen `wallId → WallCuts` zusammen. So bilden nur + * Wände desselben Geschosses gemeinsame Ecken/T-Stösse (identisch zur 2D-Semantik, + * wo `generatePlan` je Grundriss aufruft). + */ +function computeJoinsByFloor(project: Project): Map { + const byFloor = new Map(); + for (const w of project.walls) { + const key = w.floorId ?? ""; + const list = byFloor.get(key); + if (list) list.push(w); + else byFloor.set(key, [w]); + } + const merged = new Map(); + for (const walls of byFloor.values()) { + const cuts = computeJoins(project, walls); + for (const [id, wc] of cuts) merged.set(id, wc); + } + return merged; +} + /** * Optionen für {@link projectToModel3d}. `layeredWalls` steuert, ob eine * mehrschichtige Wand als eigene Box je Materiallage (Default, 3D-Viewer) oder @@ -720,11 +1771,18 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R const layered = opts.layeredWalls ?? true; return { walls: projectToWalls3d(project, layered), - slabs: emitSlabs(project), + // Decken + Treppen (Treppentritte als extrudierte Slabs) — so wird die Treppe + // auch im WASM/wgpu-Renderer sichtbar (der three.js-Pfad rendert sie separat). + slabs: [...emitSlabs(project), ...emitStairs(project)], // Kontext-Meshes (Terrain/Import) + Fenster-Glasscheiben (eigenes Mesh je - // Fensteröffnung) — pickGeometry/Highlight nutzen `meshes` bewusst NICHT, - // daher sind die Scheiben nicht selektierbar (man wählt Wand/Öffnung). - meshes: [...emitMeshes(project), ...emitOpeningGlass(project)], + // Fensteröffnung) + glatte Beton-Laufplatten (gerade Betontreppen als Mesh) + // — pickGeometry/Highlight nutzen `meshes` bewusst NICHT (man wählt Wand/ + // Öffnung/Treppe; Treppen-Pick läuft über die pickGeometry-Boxen). + meshes: [ + ...emitMeshes(project), + ...emitOpeningGlass(project), + ...emitStairMeshes(project), + ], }; } @@ -732,12 +1790,51 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R * Pick-Geometrie für den TS-seitigen 3D-Raycast (siehe viewport/raycast3d): die * Wand-Teilquader (mit `wallId`) und Decken-Prismen (mit `ceilingId`) desselben * geflachten Modells wie {@link projectToModel3d}, aber ohne die Kontext-Meshes - * (die sind für die Bauteil-Auswahl irrelevant). Reine TS-Nutzung — geht NICHT an - * render3d; die Records tragen dieselben Geometriefelder wie die Serde-Payload, - * plus die Ids fürs Picking. + * (die sind für die Bauteil-Auswahl irrelevant), PLUS die Öffnungs- (Fenster/ + * Türen) und Treppen-Boxen fürs Raycast-Picking. Reine TS-Nutzung — geht NICHT + * an render3d; die Records tragen dieselben Geometriefelder wie die Serde- + * Payload, plus die Ids fürs Picking. */ -export function pickGeometry(project: Project): { walls: RWall[]; slabs: RSlab[] } { - return { walls: projectToWalls3d(project), slabs: emitSlabs(project) }; +export function pickGeometry( + project: Project, +): { walls: RWall[]; slabs: RSlab[]; openings: PickOpening[]; stairs: PickStair[] } { + const openings: PickOpening[] = []; + for (const o of project.openings ?? []) { + const wall = project.walls.find((w) => w.id === o.hostWallId); + if (!wall) continue; + const jambs = openingJambs(wall, o); + if (!jambs) continue; + const thickness = wallTypeThickness(getWallType(project, wall)); + const { zBottom, zTop } = openingVerticalExtent(project, wall, o); + if (zTop - zBottom <= 0) continue; + openings.push({ + start: [jambs.jambStart.x, jambs.jambStart.y], + end: [jambs.jambEnd.x, jambs.jambEnd.y], + thickness, + height: zTop - zBottom, + baseElevation: zBottom, + openingId: o.id, + }); + } + const stairs: PickStair[] = []; + for (const st of project.stairs ?? []) { + const { zBottom, zTop } = stairVerticalExtent(project, st); + const totalRise = zTop - zBottom; + if (totalRise <= 0) continue; + const bbox = stairBBox(stairGeometry(st, totalRise)); + stairs.push({ + outline: [ + [bbox.minX, bbox.minY], + [bbox.maxX, bbox.minY], + [bbox.maxX, bbox.maxY], + [bbox.minX, bbox.maxY], + ], + zBottom, + zTop, + stairId: st.id, + }); + } + return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs }; } /** @@ -771,11 +1868,22 @@ export function selectionHighlightLines( wallIds: string[], ceilingIds: string[], rgb: RRgb, + openingIds: string[] = [], + stairIds: string[] = [], ): Float32Array { - if (wallIds.length === 0 && ceilingIds.length === 0) return new Float32Array(0); - const { walls, slabs } = pickGeometry(project); + if ( + wallIds.length === 0 && + ceilingIds.length === 0 && + openingIds.length === 0 && + stairIds.length === 0 + ) { + return new Float32Array(0); + } + const { walls, slabs, openings, stairs } = pickGeometry(project); const wallSet = new Set(wallIds); const ceilingSet = new Set(ceilingIds); + const openingSet = new Set(openingIds); + const stairSet = new Set(stairIds); const out: number[] = []; const pushEdge = (a: RVec3, b: RVec3) => { out.push(a[0], a[1], a[2], rgb[0], rgb[1], rgb[2]); @@ -788,21 +1896,28 @@ export function selectionHighlightLines( [0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3], [2, 6], [3, 7], [4, 5], [4, 6], [5, 7], [6, 7], ]; - for (const w of walls) { - if (!wallSet.has(w.wallId)) continue; - const ax = w.end[0] - w.start[0]; - const az = w.end[1] - w.start[1]; + // Orientierter Quader (Wand ODER Öffnung: gleiche Felder start/end/thickness/ + // height/baseElevation) → seine 12 Kanten. + const boxEdges = (b: { + start: readonly [number, number]; + end: readonly [number, number]; + thickness: number; + height: number; + baseElevation: number; + }) => { + const ax = b.end[0] - b.start[0]; + const az = b.end[1] - b.start[1]; const len = Math.hypot(ax, az); - if (len < EPS || w.height <= EPS || w.thickness <= EPS) continue; + if (len < EPS || b.height <= EPS || b.thickness <= EPS) return; const axis: RVec3 = [ax / len, 0, az / len]; const normal: RVec3 = [az / len, 0, -ax / len]; const halfLen = len / 2; - const halfHeight = w.height / 2; - const halfThick = w.thickness / 2; + const halfHeight = b.height / 2; + const halfThick = b.thickness / 2; const center: RVec3 = [ - (w.start[0] + w.end[0]) / 2, - w.baseElevation + w.height / 2, - (w.start[1] + w.end[1]) / 2, + (b.start[0] + b.end[0]) / 2, + b.baseElevation + b.height / 2, + (b.start[1] + b.end[1]) / 2, ]; const corners: RVec3[] = []; for (let i = 0; i < 8; i++) { @@ -816,18 +1931,22 @@ export function selectionHighlightLines( ]); } for (const [i, j] of BOX_EDGES) pushEdge(corners[i], corners[j]); - } - for (const s of slabs) { - if (!ceilingSet.has(s.ceilingId)) continue; - const n = s.outline.length; - if (n < 3 || s.zTop - s.zBottom <= EPS) continue; - const bottom: RVec3[] = s.outline.map((p) => [p[0], s.zBottom, p[1]]); - const top: RVec3[] = s.outline.map((p) => [p[0], s.zTop, p[1]]); + }; + // Vertikales Prisma (Decke ODER Treppen-Fussabdruck) → Umriss oben/unten + Ecken. + const prismEdges = (outline: ReadonlyArray, zBottom: number, zTop: number) => { + const n = outline.length; + if (n < 3 || zTop - zBottom <= EPS) return; + const bottom: RVec3[] = outline.map((p) => [p[0], zBottom, p[1]]); + const top: RVec3[] = outline.map((p) => [p[0], zTop, p[1]]); for (let i = 0, j = n - 1; i < n; j = i++) { pushEdge(bottom[j], bottom[i]); pushEdge(top[j], top[i]); } for (let i = 0; i < n; i++) pushEdge(bottom[i], top[i]); - } + }; + for (const w of walls) if (wallSet.has(w.wallId)) boxEdges(w); + for (const o of openings) if (openingSet.has(o.openingId)) boxEdges(o); + for (const s of slabs) if (ceilingSet.has(s.ceilingId)) prismEdges(s.outline, s.zBottom, s.zTop); + for (const st of stairs) if (stairSet.has(st.stairId)) prismEdges(st.outline, st.zBottom, st.zTop); return new Float32Array(out); } diff --git a/src/plan/wallMeshCut.test.ts b/src/plan/wallMeshCut.test.ts new file mode 100644 index 0000000..728e9c0 --- /dev/null +++ b/src/plan/wallMeshCut.test.ts @@ -0,0 +1,169 @@ +// Unit-Tests für den gemeinsamen Wand-Loch-Ausschnitt-Generator (wallMeshCut.ts, +// TS-Port von render3d/src/mesh.rs::extrude_layer_segment_with_holes). Prüft die +// geometrische Zerlegung (gleiche Rechteck-Anzahl wie die Rust-Referenz), nicht +// nur „läuft". + +import { describe, it, expect } from "vitest"; +import { isWatertight, solidSubrects, wallCutMesh } from "./wallMeshCut"; +import type { CutMesh } from "./wallMeshCut"; +import type { RWall } from "./toWalls3d"; + +/** + * Geschlossene-Fläche-Kennzahlen: Vektorfläche ∮ n dA (Σ (v1−v0)×(v2−v0)) und + * 6× signiertes Volumen (Σ v0·(v1×v2)). Dienen den Watertight-Tests direkt (statt + * eines naiven Kanten-Manifold-Zählers, der an den legitimen T-Stössen der vollen + * Deckel/Boden-Streifen scheitern würde — siehe isWatertight-Doc in wallMeshCut.ts). + */ +function surfaceIntegrals(m: CutMesh): { areaLen: number; vol6: number } { + const p = m.positions; + let ax = 0; + let ay = 0; + let az = 0; + let vol6 = 0; + for (let t = 0; t < m.indices.length; t += 3) { + const i0 = m.indices[t] * 3; + const i1 = m.indices[t + 1] * 3; + const i2 = m.indices[t + 2] * 3; + const e1 = [p[i1] - p[i0], p[i1 + 1] - p[i0 + 1], p[i1 + 2] - p[i0 + 2]]; + const e2 = [p[i2] - p[i0], p[i2 + 1] - p[i0 + 1], p[i2 + 2] - p[i0 + 2]]; + const cx = e1[1] * e2[2] - e1[2] * e2[1]; + const cy = e1[2] * e2[0] - e1[0] * e2[2]; + const cz = e1[0] * e2[1] - e1[1] * e2[0]; + ax += cx; + ay += cy; + az += cz; + vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz; + } + return { areaLen: Math.hypot(ax, ay, az), vol6 }; +} + +/** Ein RWall-Band mit optionalen Löchern (frei stehend, Achse entlang +x). */ +function band(holes?: RWall["holes"]): RWall { + return { + start: [0, 0], + end: [5, 0], + thickness: 0.4, + height: 2.6, + baseElevation: 0, + color: [0.8, 0.8, 0.8], + layers: [], + wallId: "W1", + ...(holes ? { holes } : {}), + }; +} + +describe("solidSubrects — Koordinaten-Kompression (Rust-Parität)", () => { + it("ohne Loch: genau ein Vollrechteck", () => { + expect(solidSubrects(0, 5, 0, 2.6, [])).toEqual([[0, 5, 0, 2.6]]); + }); + + it("ein voll im Inneren liegendes Loch → 3×3-Gitter minus 1 = 8 solide Teilrechtecke", () => { + const rects = solidSubrects(0, 5, 0, 2.6, [[2, 3, 0.9, 2.4]]); + expect(rects).toHaveLength(8); + // Keiner der Teilrechteck-Mittelpunkte liegt im Loch. + for (const [ua, ub, za, zb] of rects) { + const cu = (ua + ub) / 2; + const cz = (za + zb) / 2; + expect(cu > 2 && cu < 3 && cz > 0.9 && cz < 2.4).toBe(false); + } + }); + + it("randständiges Loch (Tür am Boden) → 3×2-Gitter minus 1 = 5 Teilrechtecke", () => { + expect(solidSubrects(0, 5, 0, 2.6, [[2, 3, 0, 2.1]])).toHaveLength(5); + }); + + it("zwei versetzt übereinanderliegende Löcher: keine überlappenden/entarteten Teilrechtecke", () => { + const rects = solidSubrects(0, 5, 0, 2.8, [ + [1, 2, 0.9, 1.4], + [3, 4, 1.6, 2.2], + ]); + // Alle Teilrechtecke haben echte Ausdehnung und liegen ausserhalb beider Löcher. + for (const [ua, ub, za, zb] of rects) { + expect(ub - ua).toBeGreaterThan(0); + expect(zb - za).toBeGreaterThan(0); + } + expect(rects.length).toBeGreaterThan(0); + }); +}); + +describe("wallCutMesh — Loch-Ausschnitt-Mesh", () => { + it("ohne Loch: 12 Dreiecke (volle Box-Äquivalenz)", () => { + const m = wallCutMesh(band()); + expect(m.indices.length / 3).toBe(12); + }); + + it("Fenster im Inneren: 48 Dreiecke, 4 Laibungen, Loch-Rand-Vertices vorhanden", () => { + const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }])); + expect(m.indices.length / 3).toBe(48); + // Vertices an allen vier Loch-Rändern (Brüstung/Sturz/links/rechts). + const has = (axis: number, val: number) => { + for (let i = 0; i < m.positions.length; i += 3) { + if (Math.abs(m.positions[i + axis] - val) < 1e-6) return true; + } + return false; + }; + expect(has(1, 0.9)).toBe(true); // y = Brüstungs-OK + expect(has(1, 2.4)).toBe(true); // y = Sturz-UK + expect(has(0, 2)).toBe(true); // x = linke Kante + expect(has(0, 3)).toBe(true); // x = rechte Kante + // KEIN Vertex liegt strikt im offenen Loch-Rechteck (die Fläche ist wirklich weg). + for (let i = 0; i < m.positions.length; i += 3) { + const x = m.positions[i]; + const y = m.positions[i + 1]; + const strictlyInside = x > 2 + 1e-6 && x < 3 - 1e-6 && y > 0.9 + 1e-6 && y < 2.4 - 1e-6; + expect(strictlyInside).toBe(false); + } + }); + + it("Tür am Boden: 36 Dreiecke (untere Laibung entfällt, Boden bekommt die Lücke)", () => { + const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }])); + expect(m.indices.length / 3).toBe(36); + }); + + it("alle Dreiecks-Indizes referenzieren existierende Vertices", () => { + const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }])); + const vcount = m.positions.length / 3; + for (const idx of m.indices) { + expect(idx).toBeGreaterThanOrEqual(0); + expect(idx).toBeLessThan(vcount); + } + }); +}); + +describe("wallCutMesh — Wasserdichtigkeit + Aussen-Orientierung (der hohl/offen-Bug)", () => { + it("volle Wand OHNE Loch: geschlossen (∮ n dA = 0), Normalen aussen (Volumen = L·T·H)", () => { + const m = wallCutMesh(band()); + const { areaLen, vol6 } = surfaceIntegrals(m); + expect(areaLen).toBeLessThan(1e-6); // ∮ n dA = 0 ⇒ kein Loch, kein invertiertes Dreieck + expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6, 6); // > 0 ⇒ aussen orientiert + expect(isWatertight(m)).toBe(true); + }); + + it("Wand mit Fenster: trotz T-Stösse geschlossen + aussen orientiert (Volumen = Wand − Fensterquader)", () => { + const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }])); + const { areaLen, vol6 } = surfaceIntegrals(m); + expect(areaLen).toBeLessThan(1e-6); + expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * (2.4 - 0.9), 6); + expect(isWatertight(m)).toBe(true); + }); + + it("eine EINZELNE invertierte Kappe würde erkannt (Regressionswächter gegen falsche Wicklung)", () => { + // Bau die volle Wand, kehre die Wicklung EINES Dreiecks um → nicht mehr dicht. + const m = wallCutMesh(band()); + const broken: CutMesh = { positions: m.positions.slice(), indices: m.indices.slice() }; + [broken.indices[1], broken.indices[2]] = [broken.indices[2], broken.indices[1]]; + expect(isWatertight(broken)).toBe(false); + }); + + it("Türwand ist ein GESCHLOSSENER Prisma-Körper (Querschnitt = П-Polygon, Türkerbe umlaufend)", () => { + // Die Türkerbe ist eine Einbuchtung der UNTEREN Umriss-Kante (kein Loch): der + // Querschnitt bleibt EIN einfaches, geschlossenes Polygon (П-Form), das über + // die Dicke extrudiert wieder einen geschlossenen Körper ergibt. Die fehlende + // untere Laibung und die Boden-Lücke heben sich in ∮ n dA exakt auf. + const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }])); + const { areaLen, vol6 } = surfaceIntegrals(m); + expect(areaLen).toBeLessThan(1e-6); + expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * 2.1, 6); + expect(isWatertight(m)).toBe(true); + }); +}); diff --git a/src/plan/wallMeshCut.ts b/src/plan/wallMeshCut.ts new file mode 100644 index 0000000..d04563f --- /dev/null +++ b/src/plan/wallMeshCut.ts @@ -0,0 +1,370 @@ +// Gemeinsamer Wand-Mesh-Generator MIT echtem Loch-Ausschnitt (Fenster/Türen). +// +// Portierung der Rust-Referenz `render3d/src/mesh.rs::extrude_layer_segment_ +// with_holes` nach TS: aus EINEM RWall-Band (Achse `start→end`, `thickness`, +// `baseElevation`, `height` + rechteckige `holes`) wird das Dreiecks-Mesh +// erzeugt, das die Öffnungen ECHT ausschneidet (statt der vollen Box). Damit +// sehen die Datei-Exporte (STL/OBJ/IFC) genauso aus wie die 3D-Ansicht: die +// Löcher sind sichtbar ausgestanzt, inklusive der Laibungs-/Jamb-Flächen. +// +// WICHTIG — WARUM KEINE GEHRUNG HIER: Die RWall-Bänder aus +// `toWalls3d.ts::pickGeometry` sind bereits an ihren Achsenden auf die +// Gehrungs-/Anschluss-Schnittlinie GEKÜRZT (die Box endet aber achsparallel, +// keine gekippte Stirnfläche — siehe `bandCutDistance` in toWalls3d.ts). Die +// Ecken-Verschneidung (Joins) steckt also schon in `start`/`end` jedes Bandes. +// Deshalb entfällt hier die `start_cut`/`end_cut`-Gehrungslogik der Rust-Funktion +// (im Rust-Pfad wird sie nur gebraucht, weil dort EIN ungekürztes Band mit +// Gehrungslinien extrudiert wird). Das Ergebnis: dieselbe Rechteck-Zerlegung der +// Langseiten + dieselben 4 Laibungsquads je Loch wie in Rust, nur mit rechten +// (ungekippten) Stirnkappen — exakt die Geometrie, die der Viewer schon zeigt. +// +// ACHSEN: Modell (x, y) → Welt (x, Höhe, y), Y-UP — identisch zu exportMesh.ts / +// selectionHighlightLines / dem 3D-Viewer. Ein Weltvertex (vx,vy,vz) ist also +// (Modell-x, Meter-Höhe, Modell-y). + +import type { RWall } from "./toWalls3d"; + +/** Rundungs-/Entartungsschwelle (1e-4 m), 1:1 wie `openings::MIN_SPAN` in Rust. */ +const MIN_SPAN = 1e-4; + +/** Ein Weltpunkt (x, Höhe, y). */ +type V3 = [number, number, number]; + +/** + * Ein Dreiecks-Mesh in LOKALEN (0-basierten) Indizes: `positions` flach + * (x,y,z, …) in Weltmetern (Y-up), `indices` je 3 = ein Dreieck. + */ +export interface CutMesh { + positions: number[]; + indices: number[]; +} + +/** + * Aussen-Normale eines planaren, CCW-von-aussen georderten Quads über die ersten + * drei Ecken: `normalize(cross(b-a, d-a))` — 1:1 wie `mesh.rs::quad_normal`. + * Nulllange Flächen liefern den Nullvektor (Aufrufer nutzt sie dann nicht). + */ +function quadNormal(a: V3, b: V3, d: V3): V3 { + const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const ad: V3 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]]; + const c: V3 = [ + ab[1] * ad[2] - ab[2] * ad[1], + ab[2] * ad[0] - ab[0] * ad[2], + ab[0] * ad[1] - ab[1] * ad[0], + ]; + const len = Math.max(Math.hypot(c[0], c[1], c[2]), 1e-9); + return [c[0] / len, c[1] / len, c[2] / len]; +} + +/** + * Hängt ein Dreieck an `mesh` an, dessen Winding an `wantNormal` ausgerichtet + * wird (robust gegen die Eck-Reihenfolge) — 1:1 wie `mesh.rs::push_tri_oriented`. + */ +function pushTriOriented(mesh: CutMesh, a: V3, b: V3, c: V3, wantNormal: V3): void { + const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const ac: V3 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; + const gn: V3 = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + const dot = gn[0] * wantNormal[0] + gn[1] * wantNormal[1] + gn[2] * wantNormal[2]; + const [v0, v1, v2] = dot < 0 ? [a, c, b] : [a, b, c]; + const base = mesh.positions.length / 3; + mesh.positions.push(v0[0], v0[1], v0[2], v1[0], v1[1], v1[2], v2[0], v2[1], v2[2]); + mesh.indices.push(base, base + 1, base + 2); +} + +/** Hängt ein Quad (vier Ecken als Ring) als zwei orientierte Dreiecke an. */ +function pushQuadOriented(mesh: CutMesh, a: V3, b: V3, c: V3, d: V3, wantNormal: V3): void { + pushTriOriented(mesh, a, b, c, wantNormal); + pushTriOriented(mesh, a, c, d, wantNormal); +} + +/** + * Zerlegt das Rechteck `[uLo,uHi]×[zLo,zHi]` MINUS `holes` (`[u0,u1,z0,z1]`) in + * achsparallele, disjunkte SOLIDE Teilrechtecke — Koordinaten-Kompression, 1:1 + * wie `mesh.rs::solid_subrects` (jede Gitterzelle, deren Mittelpunkt in keinem + * Loch liegt, ist ein solides Teilrechteck). Exportiert für die Zerlegungs-Tests + * (gleiche Rechteck-Anzahl wie die Rust-Zerlegung). + */ +export function solidSubrects( + uLo: number, + uHi: number, + zLo: number, + zHi: number, + holes: Array<[number, number, number, number]>, +): Array<[number, number, number, number]> { + const sortDedup = (v: number[]): number[] => { + const s = [...v].sort((a, b) => a - b); + const out: number[] = []; + for (const x of s) { + if (out.length === 0 || Math.abs(x - out[out.length - 1]) > MIN_SPAN) out.push(x); + } + return out; + }; + const usRaw = [uLo, uHi]; + const zsRaw = [zLo, zHi]; + for (const [a0, a1, hb, ht] of holes) { + usRaw.push(a0, a1); + zsRaw.push(hb, ht); + } + const us = sortDedup(usRaw); + const zs = sortDedup(zsRaw); + const out: Array<[number, number, number, number]> = []; + for (let i = 0; i < us.length - 1; i++) { + const ua = us[i]; + const ub = us[i + 1]; + if (ub - ua <= MIN_SPAN) continue; + for (let j = 0; j < zs.length - 1; j++) { + const za = zs[j]; + const zb = zs[j + 1]; + if (zb - za <= MIN_SPAN) continue; + const cu = (ua + ub) * 0.5; + const cz = (za + zb) * 0.5; + const inHole = holes.some(([a0, a1, hb, ht]) => cu > a0 && cu < a1 && cz > hb && cz < ht); + if (!inHole) out.push([ua, ub, za, zb]); + } + } + return out; +} + +/** + * Zieht die (ggf. überlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert + * die verbleibenden, sortierten, disjunkten Teilintervalle — 1:1 wie + * `mesh.rs::subtract_intervals` (für Deckel-/Boden-/Kappen-Lücken randständiger + * Löcher). + */ +function subtractIntervals(lo: number, hi: number, gaps: Array<[number, number]>): Array<[number, number]> { + const gs = gaps + .map(([a, b]): [number, number] => [Math.max(a, lo), Math.min(b, hi)]) + .filter(([a, b]) => b - a > MIN_SPAN) + .sort((p, q) => p[0] - q[0]); + const out: Array<[number, number]> = []; + let cursor = lo; + for (const [a, b] of gs) { + if (a > cursor + MIN_SPAN) out.push([cursor, a]); + cursor = Math.max(cursor, b); + } + if (hi > cursor + MIN_SPAN) out.push([cursor, hi]); + return out; +} + +/** + * Prüft, ob ein Mesh eine GESCHLOSSENE, KONSISTENT NACH AUSSEN gewundene Fläche + * ist (bounds ein Volumen ohne Löcher) — über das Gauss-/Divergenz-Kriterium + * statt eines Kanten-Manifold-Zählers: + * • ∮ n dA = 0 ⟺ Σ (v1−v0)×(v2−v0) = 0 über alle Dreiecke (jede offene + * Lücke ODER jedes falsch gewundene/fehlende Dreieck verletzt die Summe). + * • signiertes Volumen Σ v0·(v1×v2)/6 > 0 ⟺ Normalen zeigen NACH AUSSEN. + * + * BEWUSST NICHT der naive „jede ungerichtete Kante genau 2×"-Manifold-Check: + * dieses Mesh nutzt (wie die Rust-Referenz) VOLLE Deckel/Boden-Streifen über die + * ganze Breite, während die Langseiten an den Loch-Spalten unterteilt sind — das + * erzeugt legitime T-Stösse (eine lange Kante trifft mehrere kurze), die KEINE + * echten Lücken sind. Der naive Kantenzähler würde diese korrekte, wasserdichte + * Geometrie fälschlich als „offen" melden; das Gauss-Kriterium ist T-Stoss-robust + * und misst genau die Eigenschaft, auf die es ankommt (dichtes Volumen + Aussen- + * Orientierung — die Ursache des „hohl/offen"-Bugs war eine invertierte Wicklung + * im IFC-Export, nicht eine fehlende Fläche). + */ +export function isWatertight(mesh: CutMesh, eps = 1e-6): boolean { + const p = mesh.positions; + let ax = 0; + let ay = 0; + let az = 0; + let vol6 = 0; + for (let t = 0; t < mesh.indices.length; t += 3) { + const i0 = mesh.indices[t] * 3; + const i1 = mesh.indices[t + 1] * 3; + const i2 = mesh.indices[t + 2] * 3; + const e1x = p[i1] - p[i0]; + const e1y = p[i1 + 1] - p[i0 + 1]; + const e1z = p[i1 + 2] - p[i0 + 2]; + const e2x = p[i2] - p[i0]; + const e2y = p[i2 + 1] - p[i0 + 1]; + const e2z = p[i2 + 2] - p[i0 + 2]; + const cx = e1y * e2z - e1z * e2y; + const cy = e1z * e2x - e1x * e2z; + const cz = e1x * e2y - e1y * e2x; + ax += cx; + ay += cy; + az += cz; + // v0 · (v1 × v2) = v0 · (e1 × e2) (die reinen v0-Terme heben sich auf). + vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz; + } + return Math.hypot(ax, ay, az) < eps && vol6 > eps; +} + +/** + * Erzeugt das Wand-Mesh EINES RWall-Bandes mit ECHTEN Loch-Ausschnitten — der + * TS-Port von `mesh.rs::extrude_layer_segment_with_holes` (ohne Gehrung, siehe + * Moduldoc). Aufbau: + * • Langseiten (+n / −n): Flächen-Rechteck MINUS der Löcher, per {@link + * solidSubrects} in Teilrechtecke zerlegt (je 2 Dreiecke). + * • Deckel (y1) / Boden (y0): voller Streifen minus der u-Intervalle rand- + * ständiger Löcher (z. B. eine Tür am Boden). + * • Stirnkappen (a=0 / a=length): volle Höhe minus z-Intervalle randständiger + * Löcher. + * • Laibungen je Loch: bis zu vier Quads (links/rechts/unten/oben), nur für die + * im Wand-Inneren liegenden Seiten. + * + * Ohne `holes` (undefined/leer) liefert das die volle Box (Regressionsfall); + * Aufrufer, die für lochlose Bänder die klassische 8-Ecken-Box wollen, prüfen + * das selbst (siehe exportMesh.ts). + */ +export function wallCutMesh(w: RWall): CutMesh { + const mesh: CutMesh = { positions: [], indices: [] }; + const [sx, sy] = w.start; + const [ex, ey] = w.end; + const dx = ex - sx; + const dy = ey - sy; + const length = Math.hypot(dx, dy); + if (length < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return mesh; + const ux = dx / length; + const uy = dy / length; + // Links-Normale n = (−uy, ux) — dieselbe Konvention wie mesh.rs::left_normal. + const n: [number, number] = [-uy, ux]; + const y0 = w.baseElevation; + const y1 = w.baseElevation + w.height; + const offA = w.thickness / 2; + const offB = -w.thickness / 2; + + const off = (p: [number, number], s: number): [number, number] => [p[0] + n[0] * s, p[1] + n[1] * s]; + const axisPoint = (a: number): [number, number] => [sx + ux * a, sy + uy * a]; + // Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv` (keine Gehrung). + const gAt = (a: number, offv: number): [number, number] => off(axisPoint(a), offv); + const wpt = (g: [number, number], y: number): V3 => [g[0], y, g[1]]; + + // Löcher auf [0,length]×[y0,y1] klemmen, entartete verwerfen. + const hs: Array<[number, number, number, number]> = []; + for (const h of w.holes ?? []) { + const a0 = Math.max(h.from, 0); + const a1 = Math.min(h.to, length); + const zb = Math.max(h.zBottom, y0); + const zt = Math.min(h.zTop, y1); + if (a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN) hs.push([a0, a1, zb, zt]); + } + + // ── Langseiten (+n / −n): Rechteck-Gitter minus Löcher. ──────────────────── + const np: V3 = [n[0], 0, n[1]]; + const nn: V3 = [-n[0], 0, -n[1]]; + for (const [ua, ub, za, zb] of solidSubrects(0, length, y0, y1, hs)) { + pushQuadOriented( + mesh, + wpt(gAt(ua, offA), za), + wpt(gAt(ub, offA), za), + wpt(gAt(ub, offA), zb), + wpt(gAt(ua, offA), zb), + np, + ); + pushQuadOriented( + mesh, + wpt(gAt(ua, offB), za), + wpt(gAt(ub, offB), za), + wpt(gAt(ub, offB), zb), + wpt(gAt(ua, offB), zb), + nn, + ); + } + + // ── Deckel (y1) / Boden (y0): voller Streifen minus randständiger Löcher. ── + const topGaps = hs.filter(([, , , zt]) => zt >= y1 - MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]); + for (const [ua, ub] of subtractIntervals(0, length, topGaps)) { + pushQuadOriented( + mesh, + wpt(gAt(ua, offA), y1), + wpt(gAt(ub, offA), y1), + wpt(gAt(ub, offB), y1), + wpt(gAt(ua, offB), y1), + [0, 1, 0], + ); + } + const botGaps = hs.filter(([, , zb]) => zb <= y0 + MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]); + for (const [ua, ub] of subtractIntervals(0, length, botGaps)) { + pushQuadOriented( + mesh, + wpt(gAt(ua, offA), y0), + wpt(gAt(ub, offA), y0), + wpt(gAt(ub, offB), y0), + wpt(gAt(ua, offB), y0), + [0, -1, 0], + ); + } + + // ── Stirnkappen (a=0 / a=length): volle Höhe minus randständiger Löcher. ─── + const startNormal = quadNormal(wpt(gAt(0, offB), y0), wpt(gAt(0, offA), y0), wpt(gAt(0, offB), y1)); + const startGaps = hs.filter(([a0]) => a0 <= MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]); + for (const [za, zb] of subtractIntervals(y0, y1, startGaps)) { + pushQuadOriented( + mesh, + wpt(gAt(0, offB), za), + wpt(gAt(0, offA), za), + wpt(gAt(0, offA), zb), + wpt(gAt(0, offB), zb), + startNormal, + ); + } + const endNormal = quadNormal(wpt(gAt(length, offA), y0), wpt(gAt(length, offB), y0), wpt(gAt(length, offA), y1)); + const endGaps = hs.filter(([, a1]) => a1 >= length - MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]); + for (const [za, zb] of subtractIntervals(y0, y1, endGaps)) { + pushQuadOriented( + mesh, + wpt(gAt(length, offA), za), + wpt(gAt(length, offB), za), + wpt(gAt(length, offB), zb), + wpt(gAt(length, offA), zb), + endNormal, + ); + } + + // ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ────────── + for (const [a0, a1, zb, zt] of hs) { + // Linke Laibung (bei a0), Normale +u (in die Öffnung). + if (a0 > MIN_SPAN) { + pushQuadOriented( + mesh, + wpt(gAt(a0, offA), zb), + wpt(gAt(a0, offB), zb), + wpt(gAt(a0, offB), zt), + wpt(gAt(a0, offA), zt), + [ux, 0, uy], + ); + } + // Rechte Laibung (bei a1), Normale −u. + if (a1 < length - MIN_SPAN) { + pushQuadOriented( + mesh, + wpt(gAt(a1, offA), zb), + wpt(gAt(a1, offB), zb), + wpt(gAt(a1, offB), zt), + wpt(gAt(a1, offA), zt), + [-ux, 0, -uy], + ); + } + // Untere Laibung (bei zb = Brüstungsoberkante), Normale +Y. + if (zb > y0 + MIN_SPAN) { + pushQuadOriented( + mesh, + wpt(gAt(a0, offA), zb), + wpt(gAt(a1, offA), zb), + wpt(gAt(a1, offB), zb), + wpt(gAt(a0, offB), zb), + [0, 1, 0], + ); + } + // Obere Laibung (bei zt = Sturzunterkante), Normale −Y. + if (zt < y1 - MIN_SPAN) { + pushQuadOriented( + mesh, + wpt(gAt(a0, offA), zt), + wpt(gAt(a1, offA), zt), + wpt(gAt(a1, offB), zt), + wpt(gAt(a0, offB), zt), + [0, -1, 0], + ); + } + } + + return mesh; +} diff --git a/src/state/appStore.ts b/src/state/appStore.ts index e0be59c..af64cf4 100644 --- a/src/state/appStore.ts +++ b/src/state/appStore.ts @@ -19,6 +19,8 @@ import { createLayoutSlice } from "./layoutSlice"; import type { LayoutSlice } from "./layoutSlice"; import { createSiteSlice } from "./siteSlice"; import type { SiteSlice } from "./siteSlice"; +import { createNotifySlice } from "./notifySlice"; +import type { NotifySlice } from "./notifySlice"; /** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */ export type RootState = ProjectSlice & @@ -26,17 +28,23 @@ export type RootState = ProjectSlice & SelectionSlice & ViewSlice & LayoutSlice & - SiteSlice; + SiteSlice & + NotifySlice; 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. ProjectSlice bringt die // History (undo/redo) gleich mit (setProject ist der einzige Durchlauf- - // punkt jeder Projekt-Mutation, siehe projectSlice.ts). + // punkt jeder Projekt-Mutation, siehe projectSlice.ts) und braucht `notify` + // (Cross-Slice) für die Lösch-Schutz-Warnungen (statt window.alert, siehe + // notifySlice.ts). ...createProjectSlice( - api as unknown as StoreApi, + api as unknown as StoreApi< + ProjectSlice & HistorySlice & { activeLevelId: string } & Pick + >, ), + ...createNotifySlice(api as unknown as StoreApi), ...createSelectionSlice(api as unknown as StoreApi), ...createViewSlice(api as unknown as StoreApi), ...createLayoutSlice(api as unknown as StoreApi), diff --git a/src/state/notify.test.ts b/src/state/notify.test.ts new file mode 100644 index 0000000..75be223 --- /dev/null +++ b/src/state/notify.test.ts @@ -0,0 +1,61 @@ +/** + * Notify-Slice (`src/state/notifySlice.ts`) — nicht-blockierende + * Kurzmeldungen (Ersatz für `window.alert`, das im Tauri-WKWebView deaktiviert + * ist). Getestet wird nur der reine State (Hinzufügen/Entfernen); der + * Auto-Dismiss-Timer lebt bewusst in der Toast-Komponente (src/ui/Toast.tsx), + * nicht in der Slice, und ist hier daher kein Thema. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { getState, setState } from "./appStore"; + +beforeEach(() => { + setState({ notifications: [] }); +}); + +describe("notifySlice", () => { + it("notify fügt eine Meldung hinzu (Default-Art: info)", () => { + getState().notify("Hallo"); + + const notes = getState().notifications; + expect(notes.length).toBe(1); + expect(notes[0].message).toBe("Hallo"); + expect(notes[0].kind).toBe("info"); + expect(notes[0].id).toBeTruthy(); + }); + + it("notify übernimmt die übergebene Art", () => { + getState().notify("Achtung", "warning"); + expect(getState().notifications[0].kind).toBe("warning"); + }); + + it("mehrere notify()-Aufrufe erzeugen unterschiedliche ids, in Reihenfolge angehängt", () => { + getState().notify("A"); + getState().notify("B"); + + const notes = getState().notifications; + expect(notes.map((n) => n.message)).toEqual(["A", "B"]); + expect(new Set(notes.map((n) => n.id)).size).toBe(2); + }); + + it("dismissNotify entfernt genau die Meldung mit der gegebenen id", () => { + getState().notify("A"); + getState().notify("B"); + const [first, second] = getState().notifications; + + getState().dismissNotify(first.id); + + const remaining = getState().notifications; + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe(second.id); + }); + + it("dismissNotify bei unbekannter id ist ein No-Op", () => { + getState().notify("A"); + const before = getState().notifications; + + getState().dismissNotify("unbekannte-id"); + + expect(getState().notifications).toEqual(before); + }); +}); diff --git a/src/state/notifySlice.ts b/src/state/notifySlice.ts new file mode 100644 index 0000000..c7be84c --- /dev/null +++ b/src/state/notifySlice.ts @@ -0,0 +1,58 @@ +// Notify-Slice: nicht-blockierende Kurzmeldungen (Toasts). Ersetzt +// `window.alert`, das im Tauri-WKWebView deaktiviert ist (tut nichts) und +// Nutzer-Warnungen (z. B. „X wird noch verwendet") lautlos verschluckte. +// +// Reiner Zustand + zwei Actions — das Rendern (gestapelter Container) UND der +// Auto-Dismiss-Timer leben bewusst in der Toast-Komponente +// (src/ui/Toast.tsx), nicht hier: ein Timer pro GEMOUNTETER Meldung ist +// robuster als einer pro `notify()`-Aufruf (kein doppeltes Aufräumen bei +// Store-Reload/Tests, kein Timer, der eine Komponente überlebt, die es nie zu +// sehen bekam). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { StoreApi } from "./store"; + +/** Art der Meldung — steuert nur den Farbakzent im Toast. */ +export type NotifyKind = "info" | "warning" | "error"; + +/** Eine einzelne Toast-Meldung. */ +export interface Notification { + id: string; + message: string; + kind: NotifyKind; +} + +/** Felder/Actions, die diese Slice in den RootState beisteuert. */ +export interface NotifySlice { + notifications: Notification[]; + /** Fügt eine Meldung mit eindeutiger id hinzu (Default-Art: "info"). */ + notify: (message: string, kind?: NotifyKind) => void; + /** Entfernt eine Meldung per id (No-op bei unbekannter id). */ + dismissNotify: (id: string) => void; +} + +// Monoton wachsender Zähler zusätzlich zum Zeitstempel: verhindert doppelte +// ids, falls mehrere `notify()`-Aufrufe innerhalb derselben Millisekunde +// passieren (z. B. mehrere Löschversuche in einer Schleife). +let counter = 0; +function nextId(): string { + counter += 1; + return `notify-${Date.now()}-${counter}`; +} + +export function createNotifySlice(api: StoreApi): NotifySlice { + const { set } = api; + + return { + notifications: [], + + notify: (message, kind = "info") => + set((s) => ({ + notifications: [...s.notifications, { id: nextId(), message, kind }], + })), + + dismissNotify: (id) => + set((s) => ({ + notifications: s.notifications.filter((n) => n.id !== id), + })), + }; +} diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 4ac186b..7227aa6 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -30,6 +30,7 @@ import { t } from "../i18n"; import type { StoreApi } from "./store"; import { createHistorySlice, pushHistoryPatch } from "./historySlice"; import type { HistorySlice } from "./historySlice"; +import type { NotifyKind } from "./notifySlice"; /** Felder, die diese Slice in den RootState beisteuert. */ export interface ProjectSlice { @@ -286,6 +287,9 @@ export interface ProjectSlice { */ type ProjectSliceDeps = { activeLevelId: string; + /** Nicht-blockierende Kurzmeldung (Toast) — Ersatz für `window.alert`, das + * im Tauri-WKWebView deaktiviert ist (siehe notifySlice.ts). */ + notify: (message: string, kind?: NotifyKind) => void; } & HistorySlice; export function createProjectSlice( @@ -579,7 +583,7 @@ export function createProjectSlice( codes.has(o.categoryCode), ); if (usedByWall || usedByDoor || usedByOpening) { - window.alert(t("alert.layerInUse", { code: src.code, name: src.name })); + get().notify(t("alert.layerInUse", { code: src.code, name: src.name }), "warning"); return p; } return { ...p, layers: removeByCode(p.layers, code) }; @@ -664,7 +668,7 @@ export function createProjectSlice( ); if (used) { const name = p.components.find((c) => c.id === id)?.name ?? id; - window.alert(t("alert.componentInUse", { name })); + get().notify(t("alert.componentInUse", { name }), "warning"); return p; } return { ...p, components: p.components.filter((c) => c.id !== id) }; @@ -695,7 +699,7 @@ export function createProjectSlice( 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 })); + get().notify(t("alert.hatchInUse", { name }), "warning"); return p; } return { ...p, hatches: p.hatches.filter((h) => h.id !== id) }; @@ -726,7 +730,7 @@ export function createProjectSlice( 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 })); + get().notify(t("alert.lineStyleInUse", { name }), "warning"); return p; } return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) }; @@ -1373,6 +1377,8 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { { x: g.min.x, y: g.max.y }, ]; } + // Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip). + if (g.shape === "text") return [g.at]; return []; } @@ -1412,6 +1418,8 @@ function moveGrip( const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) }; return { ...d, geom: { ...g, min, max } }; } + // Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn. + if (g.shape === "text") return { ...d, geom: { ...g, at: pt } }; return d; }), }; diff --git a/src/state/projectStore.test.ts b/src/state/projectStore.test.ts new file mode 100644 index 0000000..40d4989 --- /dev/null +++ b/src/state/projectStore.test.ts @@ -0,0 +1,59 @@ +// Unit-Tests für projectStore.ts — Guard-Verhalten ohne IndexedDB. +// +// vitest läuft mit environment: "node" (siehe vitest.config.ts); dort +// existiert `indexedDB` nicht, und `fake-indexeddb` ist keine devDependency +// dieses Repos. Getestet wird daher primär, dass die API auch ohne +// IndexedDB sauber degradiert (siehe CONVENTIONS/Auftrag): `loadProject` +// liefert `null`, `listProjects` liefert `[]`, `saveProject`/`deleteProject` +// werfen nicht. + +import { describe, it, expect } from "vitest"; +import { saveProject, loadProject, listProjects, deleteProject } from "./projectStore"; +import type { Project } from "../model/types"; + +/** Minimalprojekt (analog model/wall.test.ts) — genügt der Project-Form. */ +function makeProject(overrides?: Partial): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [], + wallTypes: [], + drawingLevels: [], + layers: [], + walls: [], + doors: [], + ...overrides, + } as Project; +} + +describe("projectStore — Guard ohne IndexedDB", () => { + it("loadProject liefert null", async () => { + await expect(loadProject("irgendein-projekt")).resolves.toBeNull(); + }); + + it("listProjects liefert leere Liste", async () => { + await expect(listProjects()).resolves.toEqual([]); + }); + + it("saveProject wirft nicht (no-op)", async () => { + await expect(saveProject("p1", makeProject())).resolves.toBeUndefined(); + }); + + it("deleteProject wirft nicht (no-op)", async () => { + await expect(deleteProject("p1")).resolves.toBeUndefined(); + }); + + it("loadProject mit leerem Namen liefert null, ohne indexedDB zu berühren", async () => { + await expect(loadProject("")).resolves.toBeNull(); + }); + + it("saveProject mit leerem Namen ist ein No-op", async () => { + await expect(saveProject("", makeProject())).resolves.toBeUndefined(); + }); + + it("deleteProject mit leerem Namen ist ein No-op", async () => { + await expect(deleteProject("")).resolves.toBeUndefined(); + }); +}); diff --git a/src/state/projectStore.ts b/src/state/projectStore.ts new file mode 100644 index 0000000..3e93d69 --- /dev/null +++ b/src/state/projectStore.ts @@ -0,0 +1,184 @@ +// Projekt-Persistenz — IndexedDB (Browser-nativer Speicher-Kern). +// +// Speichert vollständige Projekte (inkl. Meshes/grosser JSON-Nutzlast) lokal +// im Browser. Analog zu visibilitySets.ts: reine Speicher-Helfer OHNE React- +// Abhängigkeit, sämtlicher Zugriff defensiv (SSR/Tests/Privatmodus) — hier +// mit `indexedDB` statt `localStorage`, da localStorage-Kontingente für +// Projekt-JSON (Wände, Meshes, …) zu klein sind. +// +// Hinweis: Dies ist nur der Speicher-KERN (CRUD-API). UI-Verdrahtung +// (Speichern/Öffnen-Menü, zuletzt-geöffnet-Liste, Auto-Save) ist NICHT Teil +// dieses Moduls — folgt als separates Item. + +import type { Project } from "../model/types"; + +/** Name der IndexedDB-Datenbank. */ +const DB_NAME = "dossier"; +/** Name des ObjectStores (keyPath „name"). */ +const STORE_NAME = "projects"; +/** DB-Schemaversion. */ +const DB_VERSION = 1; + +/** Ein gespeicherter Datensatz je Projekt. */ +interface ProjectRecord { + name: string; + savedAt: number; + project: Project; +} + +/** `true`, sobald die Fehlmeldung „IndexedDB nicht verfügbar" geloggt wurde (nur einmal). */ +let warnedUnavailable = false; + +/** Warnt einmalig auf der Konsole, dass IndexedDB fehlt (Tests/SSR/Privatmodus). */ +function warnUnavailableOnce(): void { + if (warnedUnavailable) return; + warnedUnavailable = true; + try { + // eslint-disable-next-line no-console + console.warn( + "[projectStore] IndexedDB nicht verfügbar — Projekt-Persistenz deaktiviert.", + ); + } catch { + // Konsole kann in exotischen Umgebungen fehlen — ignorieren. + } +} + +/** Liefert `true`, wenn `indexedDB` in dieser Umgebung nutzbar ist. */ +function isAvailable(): boolean { + try { + return typeof indexedDB !== "undefined"; + } catch { + // Zugriff kann werfen (z. B. strikte SSR-Sandboxen). + return false; + } +} + +/** Gecachtes Öffnen-Promise, damit `indexedDB.open` nur einmal läuft. */ +let dbPromise: Promise | null = null; + +/** Öffnet (bzw. erstellt) die Datenbank und liefert die geöffnete Verbindung. */ +function openDb(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + let request: IDBOpenDBRequest; + try { + request = indexedDB.open(DB_NAME, DB_VERSION); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "name" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("indexedDB.open fehlgeschlagen")); + }); + // Bei Fehlschlag den Cache zurücksetzen, damit ein späterer Aufruf erneut + // versuchen kann (statt dauerhaft am fehlgeschlagenen Promise zu hängen). + dbPromise.catch(() => { + dbPromise = null; + }); + return dbPromise; +} + +/** + * Speichert ein Projekt unter `name` (überschreibt einen vorhandenen + * Datensatz gleichen Namens). Ohne IndexedDB: no-op (wirft nicht). + */ +export async function saveProject(name: string, project: Project): Promise { + if (!name) return; + if (!isAvailable()) { + warnUnavailableOnce(); + return; + } + try { + const db = await openDb(); + const record: ProjectRecord = { name, savedAt: Date.now(), project }; + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen")); + tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen")); + }); + } catch { + // Kontingent überschritten, Zugriff verweigert o. Ä. — still ignorieren + // (analog writeJson in visibilitySets.ts). + } +} + +/** + * Lädt ein Projekt nach Namen. Liefert `null`, wenn es nicht existiert, die + * Umgebung IndexedDB nicht unterstützt oder der Zugriff fehlschlägt. + */ +export async function loadProject(name: string): Promise { + if (!name) return null; + if (!isAvailable()) { + warnUnavailableOnce(); + return null; + } + try { + const db = await openDb(); + const record = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const request = tx.objectStore(STORE_NAME).get(name); + request.onsuccess = () => resolve(request.result as ProjectRecord | undefined); + request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen")); + }); + return record?.project ?? null; + } catch { + return null; + } +} + +/** + * Listet alle gespeicherten Projekte (Name + Speicherzeitpunkt), alphabetisch + * nach Name sortiert. Ohne IndexedDB oder bei Fehler: leere Liste. + */ +export async function listProjects(): Promise<{ name: string; savedAt: number }[]> { + if (!isAvailable()) { + warnUnavailableOnce(); + return []; + } + try { + const db = await openDb(); + const records = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const request = tx.objectStore(STORE_NAME).getAll(); + request.onsuccess = () => resolve((request.result as ProjectRecord[]) ?? []); + request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen")); + }); + return records + .map((r) => ({ name: r.name, savedAt: r.savedAt })) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return []; + } +} + +/** + * Löscht ein gespeichertes Projekt. Ohne IndexedDB oder bei Fehler: no-op + * (wirft nicht). + */ +export async function deleteProject(name: string): Promise { + if (!name) return; + if (!isAvailable()) { + warnUnavailableOnce(); + return; + } + try { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(name); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen")); + tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen")); + }); + } catch { + // still ignorieren — analog removeKey in visibilitySets.ts. + } +} diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index abcd199..0e82637 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -21,20 +21,26 @@ import { import type { AttributeSource, Ceiling, + Column, + ColumnProfile, Drawing2D, Drawing2DGeom, + ExtrudedSolid, LayerCategory, Opening, Project, Room, SiaCategory, + SliceTermination, Stair, StairShape, VerticalAnchor, Wall, WallReferenceLine, } from "../model/types"; -import { evaluateRoom } from "../geometry/roomArea"; +import { columnVerticalExtent } from "../model/wall"; +import { columnFootprint } from "../geometry/column"; +import { evaluateRoom, polygonArea } from "../geometry/roomArea"; import { ceilingVerticalExtent, nextFloorAbove, @@ -70,6 +76,8 @@ export interface FloorChoice { */ export interface WallInfo { referenceLine: WallReferenceLine; + /** Terminierungs-Regel am Deckenanschluss (Default "both" = heutiges Verhalten). */ + sliceTermination: SliceTermination; wallTypeId: string; /** Aktuelle Gesamtdicke (Meter). */ thickness: number; @@ -91,6 +99,14 @@ export interface WallInfo { /** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */ zBottom: number; zTop: number; + /** Achslänge der Wand (Meter). */ + length: number; + /** Bruttovolumen = Länge × Dicke × Höhe (m³, ohne Öffnungsabzug). */ + grossVolume: number; + /** Summe der Öffnungsvolumina (Fenster/Türen) dieser Wand (m³). */ + openingVolume: number; + /** Nettovolumen = Brutto − Öffnungen (m³, ≥ 0). */ + netVolume: number; } /** @@ -106,6 +122,8 @@ export interface CeilingInfo { singleLayer: boolean; /** Grundfläche der Decke (m²). */ area: number; + /** Volumen = Fläche × Dicke (m³). */ + volume: number; /** Verfügbare Deckentyp-Presets (dediziert, `project.ceilingTypes`). */ ceilingTypes: WallTypeChoice[]; /** Geschoss, dem die Decke zugeordnet ist. */ @@ -115,6 +133,8 @@ export interface CeilingInfo { floors: FloorChoice[]; /** OK-Bindung (undefined = Geschoss-Oberkante). */ top?: VerticalAnchor; + /** UK-Bindung (undefined = OK − Dicke). */ + bottom?: VerticalAnchor; /** Aufgelöste absolute UK/OK (Meter). */ zBottom: number; zTop: number; @@ -146,6 +166,8 @@ export interface OpeningInfo { doorType?: "normal" | "wandoeffnung"; /** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */ lintelLines?: "keine" | "innen" | "aussen" | "beide"; + /** Zugewiesener Tür-/Fenstertyp (Bibliothek, `Opening.typeId`) oder undefined. */ + typeId?: string; /** Aufgelöste absolute UK/OK (Meter). */ zBottom: number; zTop: number; @@ -171,6 +193,8 @@ export interface StairInfo { up: boolean; /** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */ referenz?: "links" | "mitte" | "rechts"; + /** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */ + typeId?: string; /** Geschoss, dem die Treppe zugeordnet ist. */ floorId: string; floorName: string; @@ -198,6 +222,41 @@ export interface RoomInfo { floorName: string; } +/** + * Extrusions-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl + * genau eines extrudierten Körpers, truck-Integration, gesetzt). + */ +export interface ExtrudedSolidInfo { + /** Extrusionshöhe in Metern (editierbar). */ + height: number; + /** Verjüngung 0 (Prisma) … 1 (Spitze), editierbar. */ + taper: number; + /** Grundfläche des Profils (m²). */ + area: number; + /** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */ + floorId: string; + floorName: string; +} + +/** + * Stützen-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau + * einer Stütze gesetzt). Anzeigefertige Werte für die Panel-Felder. + */ +export interface ColumnInfo { + /** Profil (Rechteck/Kreis) — bestimmt, welche Masse editierbar sind. */ + profile: ColumnProfile; + /** Höhe (Meter, editierbar). */ + height: number; + /** Drehung (Grad, editierbar) — intern in Radiant gespeichert. */ + rotationDeg: number; + /** Geschoss, dem die Stütze zugeordnet ist. */ + floorId: string; + floorName: string; + /** Aufgelöste absolute UK/OK (Meter). */ + zBottom: number; + zTop: number; +} + /** Achsparallele Bounding-Box eines Elements in Modell-Metern. */ export interface SelectionBBox { minX: number; @@ -212,7 +271,16 @@ export interface SelectionBBox { * gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll. */ export interface Selection { - kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door"; + kind: + | "wall" + | "ceiling" + | "opening" + | "stair" + | "room" + | "drawing2d" + | "door" + | "extrudedSolid" + | "column"; id: string; /** Grafik-Kategorie (Ebene) des Elements. */ categoryCode: string; @@ -276,6 +344,10 @@ export interface Selection { stair?: StairInfo; /** Raum-Attribute (nur bei `kind === "room"`). */ room?: RoomInfo; + /** Extrusions-Attribute (nur bei `kind === "extrudedSolid"`). */ + extrudedSolid?: ExtrudedSolidInfo; + /** Stützen-Attribute (nur bei `kind === "column"`). */ + column?: ColumnInfo; } const WALL_FALLBACK_MM = 0.18; @@ -354,8 +426,18 @@ function wallSelection(project: Project, wall: Wall): Selection { const floor = project.drawingLevels.find((z) => z.id === wall.floorId); const { zBottom, zTop } = wallVerticalExtent(project, wall); const above = nextFloorAbove(project, wall); + // Volumen aus Achslänge × Gesamtdicke × Höhe, abzüglich der Öffnungen (jede + // Öffnung durchbricht die volle Wanddicke: Breite × Höhe × Dicke). + const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); + const wallHeight = Math.max(0, zTop - zBottom); + const grossVolume = length * thickness * wallHeight; + const openingVolume = (project.openings ?? []) + .filter((o) => o.hostWallId === wall.id) + .reduce((sum, o) => sum + o.width * o.height * thickness, 0); + const netVolume = Math.max(0, grossVolume - openingVolume); const wallInfo: WallInfo = { referenceLine: wall.referenceLine ?? "center", + sliceTermination: wall.sliceTermination ?? "both", wallTypeId: wall.wallTypeId, thickness, singleLayer: wt.layers.length <= 1, @@ -376,6 +458,10 @@ function wallSelection(project: Project, wall: Wall): Selection { top: wall.top, zBottom, zTop, + length, + grossVolume, + openingVolume, + netVolume, }; // bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt, // damit die Box der Achs-Geometrie folgt (genügt für die Paletten). @@ -415,6 +501,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection { thickness, singleLayer: wt.layers.length <= 1, area: ceilingArea(ceiling.outline), + volume: ceilingArea(ceiling.outline) * thickness, ceilingTypes: (project.ceilingTypes ?? []).map((t) => ({ id: t.id, name: t.name, @@ -428,6 +515,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection { .filter((z) => z.kind === "floor") .map((z) => ({ id: z.id, name: z.name })), top: ceiling.top, + bottom: ceiling.bottom, zBottom, zTop, }; @@ -494,6 +582,7 @@ function openingSelection(project: Project, o: Opening): Selection { wingCount: o.wingCount, doorType: o.doorType, lintelLines: o.lintelLines, + typeId: o.typeId, zBottom: ext.zBottom, zTop: ext.zTop, }; @@ -530,6 +619,7 @@ function stairSelection(project: Project, s: Stair): Selection { treadDepth: geo.treadDepth, up: s.up !== false, referenz: s.referenz, + typeId: s.typeId, floorId: s.floorId, floorName: floor?.name ?? s.floorId, zBottom, @@ -589,6 +679,84 @@ function roomSelection(project: Project, r: Room): Selection { }; } +/** Auswahl-Farbe der Extrusions-Footprints (identisch zum Grundriss-Umriss/3D-Orange). */ +const EXTRUSION_COLOR = "#d98c40"; + +/** Selektion für einen extrudierten Körper ableiten (truck-Integration). */ +function extrudedSolidSelection(project: Project, s: ExtrudedSolid): Selection { + const floor = project.drawingLevels.find((z) => z.id === s.levelId); + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const p of s.points) { + 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)) minX = minY = maxX = maxY = 0; + const info: ExtrudedSolidInfo = { + height: s.height, + taper: s.taper ?? 0, + area: polygonArea(s.points), + floorId: s.levelId, + floorName: floor?.name ?? s.levelId, + }; + return { + kind: "extrudedSolid", + id: s.id, + categoryCode: "", + color: EXTRUSION_COLOR, + weightMm: WALL_FALLBACK_MM, + fillHatchId: null, + fillColor: null, + closed: true, + bbox: { minX, minY, maxX, maxY }, + extrudedSolid: info, + }; +} + +/** Umriss-/Auswahlfarbe der Stützen-Poché (neutrales Tragwerks-Grau). */ +const COLUMN_COLOR = "#3a3f47"; + +/** Selektion für eine Stütze (Column) ableiten. */ +function columnSelection(project: Project, col: Column): Selection { + const floor = project.drawingLevels.find((z) => z.id === col.floorId); + const category = findCategory(project.layers, col.categoryCode); + const pts = columnFootprint(col); + 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)) minX = minY = maxX = maxY = 0; + const { zBottom, zTop } = columnVerticalExtent(project, col); + const info: ColumnInfo = { + profile: col.profile, + height: col.height, + rotationDeg: ((col.rotation ?? 0) * 180) / Math.PI, + floorId: col.floorId, + floorName: floor?.name ?? col.floorId, + zBottom, + zTop, + }; + return { + kind: "column", + id: col.id, + categoryCode: col.categoryCode, + color: col.color ?? category?.color ?? COLUMN_COLOR, + weightMm: WALL_FALLBACK_MM, + bbox: { minX, minY, maxX, maxY }, + column: info, + }; +} + /** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */ function drawingSelection(project: Project, d: Drawing2D): Selection { const ls = d.lineStyleId @@ -640,11 +808,21 @@ export function deriveSelection( selectedOpeningId: string | null = null, selectedStairId: string | null = null, selectedRoomId: string | null = null, + selectedExtrudedSolidId: string | null = null, + selectedColumnId: string | null = null, ): Selection | null { if (selectedDrawingId) { const d = project.drawings2d.find((x) => x.id === selectedDrawingId); return d ? drawingSelection(project, d) : null; } + if (selectedExtrudedSolidId) { + const s = (project.extrudedSolids ?? []).find((x) => x.id === selectedExtrudedSolidId); + return s ? extrudedSolidSelection(project, s) : null; + } + if (selectedColumnId) { + const c = (project.columns ?? []).find((x) => x.id === selectedColumnId); + return c ? columnSelection(project, c) : null; + } if (selectedOpeningId) { const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId); if (o) return openingSelection(project, o); diff --git a/src/state/selectionSlice.ts b/src/state/selectionSlice.ts index 08206d2..425da39 100644 --- a/src/state/selectionSlice.ts +++ b/src/state/selectionSlice.ts @@ -19,6 +19,10 @@ export interface SelectionSlice { selectedStairIds: string[]; // Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen. selectedRoomIds: string[]; + // Gewählte extrudierte Körper (truck-Integration) als MENGE von IDs — getrennt. + selectedExtrudedSolidIds: string[]; + // Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen. + selectedColumnIds: string[]; setSelectedWallIds: (ids: string[]) => void; setSelectedDrawingIds: (ids: string[]) => void; @@ -26,6 +30,8 @@ export interface SelectionSlice { setSelectedOpeningIds: (ids: string[]) => void; setSelectedStairIds: (ids: string[]) => void; setSelectedRoomIds: (ids: string[]) => void; + setSelectedExtrudedSolidIds: (ids: string[]) => void; + setSelectedColumnIds: (ids: string[]) => void; /** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */ clearSelection: () => void; } @@ -41,12 +47,16 @@ export function createSelectionSlice( selectedOpeningIds: [], selectedStairIds: [], selectedRoomIds: [], + selectedExtrudedSolidIds: [], + selectedColumnIds: [], setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }), setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }), setSelectedStairIds: (ids) => set({ selectedStairIds: ids }), setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }), + setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }), + setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }), clearSelection: () => set({ selectedWallIds: [], @@ -55,6 +65,8 @@ export function createSelectionSlice( selectedOpeningIds: [], selectedStairIds: [], selectedRoomIds: [], + selectedExtrudedSolidIds: [], + selectedColumnIds: [], }), }; } diff --git a/src/state/viewSnapshotFolders.test.ts b/src/state/viewSnapshotFolders.test.ts new file mode 100644 index 0000000..65887a7 --- /dev/null +++ b/src/state/viewSnapshotFolders.test.ts @@ -0,0 +1,158 @@ +// Pure Tests für den Ausschnitte-Baum (state/viewSnapshotFolders.ts): +// Aufbau, Reparent, Zyklus-Schutz, verwaiste Referenzen, Löschen ohne +// Datenverlust. Kein React/DOM. + +import { describe, it, expect } from "vitest"; +import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types"; +import { + buildViewSnapshotTree, + createViewSnapshotFolder, + deleteFolder, + isDescendant, + moveFolder, + moveSnapshotToFolder, +} from "./viewSnapshotFolders"; + +/** Minimaler ViewSnapshot-Stub (nur id/name/folderId sind hier relevant). */ +function snap(id: string, folderId?: string): ViewSnapshot { + return { + id, + name: id, + ...(folderId ? { folderId } : {}), + viewType: "grundriss", + view3d: "top", + scaleDenominator: 100, + detail: "mittel", + activeLevelId: "L0", + layerVisibility: {}, + drawingVisibility: {}, + } as ViewSnapshot; +} + +function folder(id: string, parentId?: string): ViewSnapshotFolder { + return createViewSnapshotFolder(id, id, parentId); +} + +describe("createViewSnapshotFolder", () => { + it("lässt parentId weg, wenn nicht gesetzt (Wurzelebene)", () => { + const f = createViewSnapshotFolder("f1", "Ordner"); + expect(f).toEqual({ id: "f1", name: "Ordner" }); + expect("parentId" in f).toBe(false); + }); + it("setzt parentId, wenn gegeben", () => { + expect(createViewSnapshotFolder("f2", "Sub", "f1").parentId).toBe("f1"); + }); +}); + +describe("buildViewSnapshotTree", () => { + it("verschachtelt Ordner + Ausschnitte", () => { + const folders = [folder("f1"), folder("f2", "f1")]; + const snaps = [snap("s0"), snap("s1", "f1"), snap("s2", "f2")]; + const tree = buildViewSnapshotTree(folders, snaps); + expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s0"]); + expect(tree.rootFolders).toHaveLength(1); + const f1 = tree.rootFolders[0]; + expect(f1.folder.id).toBe("f1"); + expect(f1.snapshots.map((s) => s.id)).toEqual(["s1"]); + expect(f1.subfolders).toHaveLength(1); + expect(f1.subfolders[0].folder.id).toBe("f2"); + expect(f1.subfolders[0].snapshots.map((s) => s.id)).toEqual(["s2"]); + }); + + it("hebt Ausschnitt mit unbekanntem folderId auf die Wurzelebene (verwaist)", () => { + const tree = buildViewSnapshotTree([folder("f1")], [snap("s1", "ghost")]); + expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s1"]); + expect(tree.rootFolders[0].snapshots).toHaveLength(0); + }); + + it("hebt Ordner mit unbekanntem parentId auf die Wurzelebene (verwaist)", () => { + const tree = buildViewSnapshotTree([folder("f2", "ghost")], []); + expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["f2"]); + }); + + it("bewahrt die Eingabe-Reihenfolge je Ebene", () => { + const folders = [folder("b"), folder("a")]; + const snaps = [snap("s2"), snap("s1")]; + const tree = buildViewSnapshotTree(folders, snaps); + expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["b", "a"]); + expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s2", "s1"]); + }); +}); + +describe("isDescendant", () => { + const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")]; + it("erkennt sich selbst", () => { + expect(isDescendant(folders, "f1", "f1")).toBe(true); + }); + it("erkennt echten Nachfahren (transitiv)", () => { + expect(isDescendant(folders, "f3", "f1")).toBe(true); + }); + it("verneint Nicht-Nachfahren", () => { + expect(isDescendant(folders, "f1", "f3")).toBe(false); + }); + it("bricht bei kaputter parentId-Kette ab (kein Absturz)", () => { + expect(isDescendant([folder("x", "ghost")], "x", "f1")).toBe(false); + }); +}); + +describe("moveSnapshotToFolder", () => { + it("setzt folderId", () => { + const out = moveSnapshotToFolder([snap("s1")], "s1", "f1"); + expect(out[0].folderId).toBe("f1"); + }); + it("entfernt folderId bei null (Wurzelebene)", () => { + const out = moveSnapshotToFolder([snap("s1", "f1")], "s1", null); + expect("folderId" in out[0]).toBe(false); + }); + it("no-op bei unbekannter Id", () => { + const input = [snap("s1", "f1")]; + expect(moveSnapshotToFolder(input, "ghost", null)).toEqual(input); + }); +}); + +describe("moveFolder", () => { + const folders = [folder("f1"), folder("f2", "f1"), folder("f3")]; + it("hängt Ordner unter neuen Elternordner", () => { + const out = moveFolder(folders, "f3", "f1"); + expect(out.find((f) => f.id === "f3")!.parentId).toBe("f1"); + }); + it("verschiebt auf Wurzelebene bei null", () => { + const out = moveFolder(folders, "f2", null); + expect("parentId" in out.find((f) => f.id === "f2")!).toBe(false); + }); + it("verhindert Zyklus: Ordner in eigenen Nachfahren (No-op)", () => { + expect(moveFolder(folders, "f1", "f2")).toEqual(folders); + }); + it("verhindert Ordner in sich selbst (No-op)", () => { + expect(moveFolder(folders, "f1", "f1")).toEqual(folders); + }); +}); + +describe("deleteFolder", () => { + it("zieht Unterordner + Ausschnitte auf die Elternebene (kein Datenverlust)", () => { + // f1 > f2 > f3; s1 in f2. f2 löschen → f3 & s1 hängen an f1. + const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")]; + const snaps = [snap("s1", "f2")]; + const out = deleteFolder("f2", folders, snaps); + expect(out.folders.map((f) => f.id).sort()).toEqual(["f1", "f3"]); + expect(out.folders.find((f) => f.id === "f3")!.parentId).toBe("f1"); + expect(out.snapshots[0].folderId).toBe("f1"); + }); + + it("zieht auf die Wurzelebene, wenn der gelöschte Ordner an der Wurzel lag", () => { + const folders = [folder("f1"), folder("f2", "f1")]; + const snaps = [snap("s1", "f1")]; + const out = deleteFolder("f1", folders, snaps); + expect(out.folders).toHaveLength(1); + expect("parentId" in out.folders[0]).toBe(false); // f2 an Wurzel + expect("folderId" in out.snapshots[0]).toBe(false); // s1 an Wurzel + }); + + it("no-op bei unbekannter Ordner-Id", () => { + const folders = [folder("f1")]; + const snaps = [snap("s1", "f1")]; + const out = deleteFolder("ghost", folders, snaps); + expect(out.folders).toEqual(folders); + expect(out.snapshots).toEqual(snaps); + }); +}); diff --git a/src/state/viewSnapshotFolders.ts b/src/state/viewSnapshotFolders.ts new file mode 100644 index 0000000..1c8213b --- /dev/null +++ b/src/state/viewSnapshotFolders.ts @@ -0,0 +1,183 @@ +// Ausschnitte-Baum (Ordner + Ausschnitte) — reine, testbare Logik (DOSSIER A2). +// +// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf ViewSnapshot- +// Ordnern und die Auflösung der flachen Listen zum verschachtelten Baum. Das +// UI-Wiring (App.tsx setProject, ViewSnapshotsPanel) baut hierauf auf. +// +// Spiegelbildlich zu `panels/layoutModel.ts` (Layouts-Baum), aber bewusst OHNE +// Import aus dort — Ausschnitte-spezifisch und entkoppelt gehalten. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types"; + +// ── Fabrik ─────────────────────────────────────────────────────────────────── + +/** Neuer, leerer Ausschnitte-Ordner (optional in `parentId`). */ +export function createViewSnapshotFolder( + id: string, + name: string, + parentId?: string, +): ViewSnapshotFolder { + return { id, name, ...(parentId ? { parentId } : {}) }; +} + +// ── Baum-Aufbau ────────────────────────────────────────────────────────────── + +/** Ein Ordner-Knoten: der Ordner, seine Unterordner und direkten Ausschnitte. */ +export interface ViewSnapshotFolderNode { + folder: ViewSnapshotFolder; + subfolders: ViewSnapshotFolderNode[]; + snapshots: ViewSnapshot[]; +} + +/** Der aufgebaute Baum: Wurzel-Ausschnitte (ohne Ordner) + Wurzel-Ordner. */ +export interface ViewSnapshotTree { + /** Ausschnitte ohne (gültigen) `folderId` — auf Wurzelebene. */ + rootSnapshots: ViewSnapshot[]; + /** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */ + rootFolders: ViewSnapshotFolderNode[]; +} + +/** + * Baut aus der flachen Ordner-/Ausschnitt-Liste den verschachtelten Baum. Robust + * gegen verwaiste Referenzen: ein Ausschnitt mit unbekanntem `folderId` bzw. ein + * Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben + * Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten. + */ +export function buildViewSnapshotTree( + folders: ViewSnapshotFolder[], + snapshots: ViewSnapshot[], +): ViewSnapshotTree { + const folderIds = new Set(folders.map((f) => f.id)); + const nodes = new Map(); + for (const f of folders) { + nodes.set(f.id, { folder: f, subfolders: [], snapshots: [] }); + } + + const rootFolders: ViewSnapshotFolderNode[] = []; + for (const f of folders) { + const node = nodes.get(f.id)!; + const parent = + f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined; + if (parent) parent.subfolders.push(node); + else rootFolders.push(node); + } + + const rootSnapshots: ViewSnapshot[] = []; + for (const s of snapshots) { + const node = + s.folderId && folderIds.has(s.folderId) ? nodes.get(s.folderId) : undefined; + if (node) node.snapshots.push(s); + else rootSnapshots.push(s); + } + + return { rootSnapshots, rootFolders }; +} + +// ── Baum-Umsortieren (Drag & Drop) + Löschen ──────────────────────────────── + +/** + * Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den + * Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich + * selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum). + * Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab). + */ +export function isDescendant( + folders: ViewSnapshotFolder[], + candidateId: string, + rootId: string, +): boolean { + if (candidateId === rootId) return true; + const byId = new Map(folders.map((f) => [f.id, f])); + const seen = new Set(); + let cur = byId.get(candidateId); + while (cur && cur.parentId && !seen.has(cur.id)) { + if (cur.parentId === rootId) return true; + seen.add(cur.id); + cur = byId.get(cur.parentId); + } + return false; +} + +/** + * Verschiebt einen Ausschnitt in einen anderen Ordner (immutabel). `folderId = + * null` → zurück auf die Wurzelebene. No-op, wenn die Ausschnitt-Id fehlt. + */ +export function moveSnapshotToFolder( + snapshots: ViewSnapshot[], + snapshotId: string, + folderId: string | null, +): ViewSnapshot[] { + return snapshots.map((s) => { + if (s.id !== snapshotId) return s; + if (folderId) return { ...s, folderId }; + const next = { ...s }; + delete next.folderId; + return next; + }); +} + +/** + * Verschiebt einen Ordner unter einen neuen Elternordner (immutabel). + * `parentId = null` → Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in + * sich selbst oder einen seiner Nachfahren wandern (dann No-op — Original zurück). + */ +export function moveFolder( + folders: ViewSnapshotFolder[], + folderId: string, + parentId: string | null, +): ViewSnapshotFolder[] { + if (parentId && isDescendant(folders, parentId, folderId)) return folders; + return folders.map((f) => { + if (f.id !== folderId) return f; + if (parentId) return { ...f, parentId }; + return stripParentId(f); + }); +} + +/** + * Löscht einen Ordner (immutabel). Unterordner und Ausschnitte des Ordners + * werden auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen, + * statt mitgelöscht zu werden — es gehen keine Ausschnitte verloren. Liefert die + * neuen `folders`/`snapshots`-Listen. + */ +export function deleteFolder( + folderId: string, + folders: ViewSnapshotFolder[], + snapshots: ViewSnapshot[], +): { folders: ViewSnapshotFolder[]; snapshots: ViewSnapshot[] } { + const target = folders.find((f) => f.id === folderId); + const grandParentId = target?.parentId; + const nextFolders = folders + .filter((f) => f.id !== folderId) + .map((f) => + f.parentId === folderId + ? grandParentId + ? { ...f, parentId: grandParentId } + : stripParentId(f) + : f, + ); + const nextSnapshots = snapshots.map((s) => + s.folderId === folderId + ? grandParentId + ? { ...s, folderId: grandParentId } + : stripFolderId(s) + : s, + ); + return { folders: nextFolders, snapshots: nextSnapshots }; +} + +/** Ordner auf die Wurzelebene setzen (parentId entfernen). */ +function stripParentId(folder: ViewSnapshotFolder): ViewSnapshotFolder { + const next = { ...folder }; + delete next.parentId; + return next; +} + +/** Ausschnitt auf die Wurzelebene setzen (folderId entfernen). */ +function stripFolderId(snap: ViewSnapshot): ViewSnapshot { + const next = { ...snap }; + delete next.folderId; + return next; +} diff --git a/src/state/viewSnapshots.test.ts b/src/state/viewSnapshots.test.ts new file mode 100644 index 0000000..0feb970 --- /dev/null +++ b/src/state/viewSnapshots.test.ts @@ -0,0 +1,196 @@ +// Unit-Tests der reinen Ausschnitt-Logik (src/state/viewSnapshots.ts): +// Capture aus einem Zustand → ViewSnapshot mit allen Feldern; Read → +// Kernzustand; Roundtrip capture→read; Override-Aktiv-Menge wiederherstellen; +// Robustheit gegen fehlende optionale Felder (Alt-/Fremd-Snapshots). + +import { describe, it, expect } from "vitest"; +import type { OverrideRule, ViewSnapshot } from "../model/types"; +import { + captureViewSnapshot, + readViewSnapshot, + applyOverrideRuleEnabled, + snapshotMatchesLiveState, + type ViewSnapshotState, +} from "./viewSnapshots"; + +/** Ein voll besetzter Beispiel-Kernzustand. */ +function sampleState(): ViewSnapshotState { + return { + viewType: "perspektive", + view3d: "iso", + fov: 42, + scaleDenominator: 50, + detail: "fein", + activeLevelId: "floor-2", + layerVisibility: { "20": true, "30": false }, + drawingVisibility: { "floor-1": true, "floor-2": false }, + enabledOverrideRuleIds: ["r1", "r3"], + }; +} + +describe("viewSnapshots — captureViewSnapshot", () => { + it("erfasst alle Felder + Meta (id/name)", () => { + const snap = captureViewSnapshot("vs-1", "Nord EG", sampleState()); + expect(snap).toMatchObject({ + id: "vs-1", + name: "Nord EG", + viewType: "perspektive", + view3d: "iso", + fov: 42, + scaleDenominator: 50, + detail: "fein", + activeLevelId: "floor-2", + layerVisibility: { "20": true, "30": false }, + drawingVisibility: { "floor-1": true, "floor-2": false }, + enabledOverrideRuleIds: ["r1", "r3"], + }); + expect(snap.folder).toBeUndefined(); + }); + + it("übernimmt einen (getrimmten) Ordner, ignoriert Leerstrings", () => { + expect(captureViewSnapshot("a", "n", sampleState(), " Pläne ").folder).toBe( + "Pläne", + ); + expect(captureViewSnapshot("a", "n", sampleState(), " ").folder).toBeUndefined(); + }); + + it("koppelt die Maps/Arrays vom Live-Zustand ab (Kopie, keine Referenz)", () => { + const st = sampleState(); + const snap = captureViewSnapshot("a", "n", st); + st.layerVisibility["20"] = false; + st.drawingVisibility["floor-1"] = false; + st.enabledOverrideRuleIds.push("r9"); + expect(snap.layerVisibility["20"]).toBe(true); + expect(snap.drawingVisibility["floor-1"]).toBe(true); + expect(snap.enabledOverrideRuleIds).toEqual(["r1", "r3"]); + }); +}); + +describe("viewSnapshots — readViewSnapshot", () => { + it("Roundtrip: capture → read ergibt denselben Kernzustand", () => { + const st = sampleState(); + const snap = captureViewSnapshot("vs-1", "Nord EG", st); + expect(readViewSnapshot(snap)).toEqual(st); + }); + + it("füllt fehlende optionale Felder defensiv auf (Alt-Snapshot ohne fov/overrides)", () => { + const legacy: ViewSnapshot = { + id: "old", + name: "Alt", + viewType: "grundriss", + view3d: "perspective", + scaleDenominator: 100, + detail: "mittel", + activeLevelId: "floor-1", + layerVisibility: {}, + drawingVisibility: {}, + // fov + enabledOverrideRuleIds absichtlich weggelassen + }; + const st = readViewSnapshot(legacy); + expect(st.fov).toBe(50); + expect(st.enabledOverrideRuleIds).toEqual([]); + }); +}); + +describe("viewSnapshots — applyOverrideRuleEnabled", () => { + const mk = (id: string, enabled: boolean): OverrideRule => ({ + id, + name: id, + enabled, + condition: { type: "layer_name", operator: "equals", value: id }, + actions: {}, + }); + + it("setzt enabled genau auf die gespeicherte Aktiv-Menge", () => { + const rules = [mk("r1", false), mk("r2", true), mk("r3", false)]; + const next = applyOverrideRuleEnabled(rules, ["r1", "r3"]); + expect(next.map((r) => [r.id, r.enabled])).toEqual([ + ["r1", true], + ["r2", false], + ["r3", true], + ]); + }); + + it("ignoriert unbekannte (inzwischen gelöschte) Ids", () => { + const rules = [mk("r1", false)]; + const next = applyOverrideRuleEnabled(rules, ["r1", "ghost"]); + expect(next[0].enabled).toBe(true); + expect(next).toHaveLength(1); + }); + + it("liefert dieselbe Referenz zurück, wenn nichts zu ändern ist (No-Op)", () => { + const rules = [mk("r1", true), mk("r2", false)]; + expect(applyOverrideRuleEnabled(rules, ["r1"])).toBe(rules); + }); +}); + +describe("viewSnapshots — snapshotMatchesLiveState", () => { + it("identischer Zustand → true", () => { + expect(snapshotMatchesLiveState(sampleState(), sampleState())).toBe(true); + }); + + it("Override-Ids in anderer Reihenfolge → weiterhin true (Menge zählt)", () => { + const live = { ...sampleState(), enabledOverrideRuleIds: ["r3", "r1"] }; + expect(snapshotMatchesLiveState(sampleState(), live)).toBe(true); + }); + + // Jedes einzelne abweichende Feld muss zu false führen. + it("viewType weicht ab → false", () => { + expect( + snapshotMatchesLiveState(sampleState(), { ...sampleState(), viewType: "grundriss" }), + ).toBe(false); + }); + it("view3d weicht ab → false", () => { + expect( + snapshotMatchesLiveState(sampleState(), { ...sampleState(), view3d: "perspective" }), + ).toBe(false); + }); + it("fov weicht ab → false", () => { + expect( + snapshotMatchesLiveState(sampleState(), { ...sampleState(), fov: 43 }), + ).toBe(false); + }); + it("scaleDenominator weicht ab → false", () => { + expect( + snapshotMatchesLiveState(sampleState(), { ...sampleState(), scaleDenominator: 100 }), + ).toBe(false); + }); + it("detail weicht ab → false", () => { + expect( + snapshotMatchesLiveState(sampleState(), { ...sampleState(), detail: "grob" }), + ).toBe(false); + }); + it("activeLevelId weicht ab → false", () => { + expect( + snapshotMatchesLiveState(sampleState(), { ...sampleState(), activeLevelId: "floor-9" }), + ).toBe(false); + }); + it("layerVisibility-Wert weicht ab → false", () => { + const live = { ...sampleState(), layerVisibility: { "20": true, "30": true } }; + expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false); + }); + it("layerVisibility-Key fehlt/kommt hinzu → false", () => { + const live = { ...sampleState(), layerVisibility: { "20": true } }; + expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false); + const live2 = { + ...sampleState(), + layerVisibility: { "20": true, "30": false, "40": true }, + }; + expect(snapshotMatchesLiveState(sampleState(), live2)).toBe(false); + }); + it("drawingVisibility weicht ab → false", () => { + const live = { + ...sampleState(), + drawingVisibility: { "floor-1": true, "floor-2": true }, + }; + expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false); + }); + it("enabledOverrideRuleIds weicht ab (eine Regel mehr) → false", () => { + const live = { ...sampleState(), enabledOverrideRuleIds: ["r1", "r3", "r4"] }; + expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false); + }); + it("enabledOverrideRuleIds weicht ab (andere Regel) → false", () => { + const live = { ...sampleState(), enabledOverrideRuleIds: ["r1", "r2"] }; + expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false); + }); +}); diff --git a/src/state/viewSnapshots.ts b/src/state/viewSnapshots.ts new file mode 100644 index 0000000..0f7f34c --- /dev/null +++ b/src/state/viewSnapshots.ts @@ -0,0 +1,160 @@ +// Ausschnitte / View-Snapshots — reine Erfassungs-/Wiederherstellungslogik. +// +// Ein „Ausschnitt" (View-Snapshot, DOSSIER A2) ist eine benannte, gespeicherte +// Ansicht, die den kompletten Darstellungszustand einfängt und wiederherstellt. +// KERN-PRINZIP: nichts neu erfinden — die Bausteine existieren schon (View- +// State, Sichtbarkeits-Snapshots wie `LayerCombo`/`DrawingCombo`, Overrides, +// aktives Geschoss) und werden hier nur KOMPONIERT + benannt gebündelt. +// +// Dieses Modul hält die eigentliche Logik als PURE Funktionen (testbar, ohne +// React/Store): Capture aus einem Zustandsobjekt → ViewSnapshot, und Read eines +// ViewSnapshots → wiederherstellbarer Kernzustand (den das Wiring in App.tsx auf +// die konkreten Setter abbildet). Das Setter-/rAF-Wiring bleibt in App.tsx. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { OverrideRule, ViewSnapshot } from "../model/types"; +import type { DetailLevel, View3d, ViewType } from "../ui/TopBar"; + +/** + * Der wiederherstellbare KERNZUSTAND eines Ausschnitts — alle Felder, die ein + * Snapshot einfängt, OHNE Meta (`id`/`name`/`folder`). Sowohl `captureViewSnapshot` + * (Eingang) als auch `readViewSnapshot` (Ausgang) sprechen diese Form; damit ist + * ein sauberer Roundtrip (capture → read) trivial prüfbar. + */ +export interface ViewSnapshotState { + viewType: ViewType; + view3d: View3d; + fov: number; + scaleDenominator: number; + detail: DetailLevel; + activeLevelId: string; + layerVisibility: Record; + drawingVisibility: Record; + /** Ids der aktiven (`enabled`) Override-Regeln zum Erfassungszeitpunkt. */ + enabledOverrideRuleIds: string[]; +} + +/** + * Baut aus einem Kernzustand einen benannten Ausschnitt. Pure — `id`/`name` + * kommen vom Aufrufer (App erzeugt die Id, der Nutzer den Namen); die + * Sichtbarkeits-/Overrides-Maps werden defensiv kopiert, damit der Snapshot vom + * Live-Zustand entkoppelt ist. `folder` optional (Phase 1: flach). + */ +export function captureViewSnapshot( + id: string, + name: string, + state: ViewSnapshotState, + folder?: string, +): ViewSnapshot { + const snap: ViewSnapshot = { + id, + name, + viewType: state.viewType, + view3d: state.view3d, + fov: state.fov, + scaleDenominator: state.scaleDenominator, + detail: state.detail, + activeLevelId: state.activeLevelId, + layerVisibility: { ...state.layerVisibility }, + drawingVisibility: { ...state.drawingVisibility }, + enabledOverrideRuleIds: [...state.enabledOverrideRuleIds], + }; + if (folder && folder.trim()) snap.folder = folder.trim(); + return snap; +} + +/** + * Liest den wiederherstellbaren Kernzustand aus einem Ausschnitt. Fehlende + * optionale Felder (aus hand-/altangelegten Snapshots) werden defensiv + * aufgefüllt (`fov` → 50, `enabledOverrideRuleIds` → []). Die Sichtbarkeits-Maps + * werden kopiert, damit der Aufrufer sie gefahrlos weiterreichen kann. + */ +export function readViewSnapshot(snap: ViewSnapshot): ViewSnapshotState { + return { + viewType: snap.viewType, + view3d: snap.view3d, + fov: snap.fov ?? 50, + scaleDenominator: snap.scaleDenominator, + detail: snap.detail, + activeLevelId: snap.activeLevelId, + layerVisibility: { ...snap.layerVisibility }, + drawingVisibility: { ...snap.drawingVisibility }, + enabledOverrideRuleIds: snap.enabledOverrideRuleIds + ? [...snap.enabledOverrideRuleIds] + : [], + }; +} + +/** Deep-Equal zweier `Record`-Maps: gleiche Keys UND Werte. */ +function boolMapEqual( + a: Record, + b: Record, +): boolean { + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (a[k] !== b[k]) return false; + } + return true; +} + +/** Mengengleichheit zweier Id-Listen (Reihenfolge egal, Duplikate ignoriert). */ +function idSetEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const sa = new Set(a); + for (const id of b) { + if (!sa.has(id)) return false; + } + return sa.size === new Set(b).size; +} + +/** + * Prüft, ob ein Ausschnitt-Kernzustand EXAKT dem aktuellen Live-Zustand + * entspricht — Grundlage der „angewählt bleibt bis zur Abweichung"-Logik im + * Ausschnitte-Panel. Vergleicht ALLE erfassten Felder feldweise (Enums/Zahlen + * strikt, Sichtbarkeits-Maps deep-equal, Override-Ids als Menge). Weicht auch + * nur EIN Feld ab → `false`. Pure/testbar; der Aufrufer (App.tsx) normalisiert + * den gespeicherten Snapshot vorher über `readViewSnapshot`, damit fehlende + * optionale Felder (fov/overrides) fair verglichen werden. + */ +export function snapshotMatchesLiveState( + snap: ViewSnapshotState, + live: ViewSnapshotState, +): boolean { + return ( + snap.viewType === live.viewType && + snap.view3d === live.view3d && + snap.fov === live.fov && + snap.scaleDenominator === live.scaleDenominator && + snap.detail === live.detail && + snap.activeLevelId === live.activeLevelId && + boolMapEqual(snap.layerVisibility, live.layerVisibility) && + boolMapEqual(snap.drawingVisibility, live.drawingVisibility) && + idSetEqual(snap.enabledOverrideRuleIds, live.enabledOverrideRuleIds) + ); +} + +/** + * Setzt bei jeder Override-Regel `enabled = enabledIds.includes(rule.id)` + * (immutabel). Reproduziert die Aktiv-Menge, die ein Ausschnitt gespeichert hat. + * Regeln, die es beim Erfassen noch nicht gab, bleiben unberührt deaktiviert; + * inzwischen gelöschte Regel-Ids werden schlicht ignoriert. Liefert eine NEUE + * Liste nur, wenn sich etwas ändert (sonst dieselbe Referenz → No-Op-freundlich + * für den setProject-History-Schutz). + */ +export function applyOverrideRuleEnabled( + rules: OverrideRule[], + enabledIds: string[], +): OverrideRule[] { + const active = new Set(enabledIds); + let changed = false; + const next = rules.map((r) => { + const enabled = active.has(r.id); + if (enabled === r.enabled) return r; + changed = true; + return { ...r, enabled }; + }); + return changed ? next : rules; +} diff --git a/src/styles.css b/src/styles.css index ada78b2..49946ee 100644 --- a/src/styles.css +++ b/src/styles.css @@ -561,6 +561,14 @@ body { line-height: 1; } .brand-word { + /* Als + {/* Hochgestellt */} + + + {/* Tiefgestellt */} + +
{/* Schriftgrösse */} diff --git a/src/theme/accents.ts b/src/theme/accents.ts index 92007ae..d3ce348 100644 --- a/src/theme/accents.ts +++ b/src/theme/accents.ts @@ -46,7 +46,7 @@ export const DEFAULT_MARQUEE_COLOR = "#CEB188"; * bleibt aber (anders als das sehr blasse Kori) auch auf hellem * Papier-Hintergrund gut sichtbar. Per Picker im Einstellungs-Fenster änderbar. */ -export const DEFAULT_SNAP_COLOR = "#5FA1C9"; +export const DEFAULT_SNAP_COLOR = "#E0BE5A"; /** Liefert einen Swatch nach `id`, oder `undefined`. */ export function findAccentSwatch(id: string): AccentSwatch | undefined { diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 6e0a583..3e095a3 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -554,6 +554,32 @@ const arcTool: Tool = { onCancel: (s) => [s, { draft: null, done: true }], }; +// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt → +// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element). +const textTool: Tool = { + id: "text", + labelKey: "tool.text", + hintKey: () => "tool.text.hint", + init: () => ({ phase: "idle" }), + onClick: (s) => [s, { draft: null }], + onMove: (s) => [s, { draft: null }], + onCommitGesture: (s) => [s, { draft: null, done: true }], + onCancel: (s) => [s, { draft: null, done: true }], +}; + +// Textspalte: Platzhalter-Werkzeug, gekoppelt an den `textbox`-Befehl (Anker → +// Spaltenbreite → getippte Zeile). Freies 2D-Element (nicht floorOnly). +const textboxTool: Tool = { + id: "textbox", + labelKey: "tool.textbox", + hintKey: () => "tool.textbox.hint", + init: () => ({ phase: "idle" }), + onClick: (s) => [s, { draft: null }], + onMove: (s) => [s, { draft: null }], + onCommitGesture: (s) => [s, { draft: null, done: true }], + onCancel: (s) => [s, { draft: null, done: true }], +}; + // ── Registry ───────────────────────────────────────────────────────────────── const TOOLS: Record = { @@ -569,6 +595,8 @@ const TOOLS: Record = { rect: rectTool, circle: circleTool, arc: arcTool, + text: textTool, + textbox: textboxTool, }; /** Liefert das Werkzeug zur ID. */ @@ -590,4 +618,6 @@ export const TOOL_ORDER: ToolId[] = [ "rect", "circle", "arc", + "text", + "textbox", ]; diff --git a/src/tools/transform.ts b/src/tools/transform.ts index 1ad64e3..9ee3351 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -10,14 +10,17 @@ // die App hält den State und wendet `commitTransform` immutabel an. import type { + Column, Drawing2D, Drawing2DGeom, + ExtrudedSolid, Project, Vec2, Wall, } from "../model/types"; import { wallTypeThickness } from "../model/types"; import { wallCorners } from "../model/geometry"; +import { columnFootprint } from "../geometry/column"; import type { DraftShape } from "./types"; import { uniqueId } from "./types"; @@ -28,6 +31,10 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O / export interface TransformSelection { wallIds: string[]; drawingId: string | null; + /** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */ + extrudedSolidId?: string | null; + /** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */ + columnId?: string | null; } /** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */ @@ -95,7 +102,15 @@ interface SelDrawingItem { kind: "drawing"; drawing: Drawing2D; } -type AnyItem = SelItem | SelDrawingItem; +interface SelExtrudedItem { + kind: "extrudedSolid"; + solid: ExtrudedSolid; +} +interface SelColumnItem { + kind: "column"; + column: Column; +} +type AnyItem = SelItem | SelDrawingItem | SelExtrudedItem | SelColumnItem; function gatherItems(project: Project, sel: TransformSelection): AnyItem[] { const items: AnyItem[] = []; @@ -105,9 +120,33 @@ function gatherItems(project: Project, sel: TransformSelection): AnyItem[] { const d = project.drawings2d.find((x) => x.id === sel.drawingId); if (d) items.push({ kind: "drawing", drawing: d }); } + if (sel.extrudedSolidId) { + const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId); + if (s) items.push({ kind: "extrudedSolid", solid: s }); + } + if (sel.columnId) { + const c = (project.columns ?? []).find((x) => x.id === sel.columnId); + if (c) items.push({ kind: "column", column: c }); + } return items; } +/** + * Transformiert eine Stütze unter einer affinen Punkt-Abbildung `fn` + * (Verschiebung/Drehung/Spiegelung): der Einfügepunkt wandert mit; die Profil- + * Drehung folgt der Abbildung, indem ein Richtungsvektor (Länge 1 unter dem + * aktuellen Winkel) mit-transformiert und der neue Winkel daraus gemessen wird + * — das ergibt für alle drei Operationen die korrekte neue Ausrichtung. + */ +function mvColumn(col: Column, fn: (p: Vec2) => Vec2): Column { + const base = col.position; + const rot = col.rotation ?? 0; + const tip = { x: base.x + Math.cos(rot), y: base.y + Math.sin(rot) }; + const nb = fn(base); + const nt = fn(tip); + return { ...col, position: nb, rotation: Math.atan2(nt.y - nb.y, nt.x - nb.x) }; +} + const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => { switch (geom.shape) { case "line": @@ -157,6 +196,12 @@ function itemPreview(project: Project, item: AnyItem, fn: (p: Vec2) => Vec2): Dr { kind: "line", a, b }, ]; } + if (item.kind === "extrudedSolid") { + return [{ kind: "poly", pts: item.solid.points.map(fn), closed: true }]; + } + if (item.kind === "column") { + return [{ kind: "poly", pts: columnFootprint(item.column).map(fn), closed: true }]; + } const g = mvGeom(item.drawing.geom, fn); switch (g.shape) { case "line": @@ -215,6 +260,8 @@ export function commitTransform( let walls = project.walls; let drawings2d = project.drawings2d; + let extrudedSolids = project.extrudedSolids ?? []; + let columns = project.columns ?? []; // U (move): Original an die Endlage (t=1) transformieren. if (movesOriginal(mode)) { @@ -225,20 +272,46 @@ export function commitTransform( d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d, ); } + if (sel.extrudedSolidId) { + extrudedSolids = extrudedSolids.map((s) => + s.id === sel.extrudedSolidId + ? { + ...s, + points: s.points.map(fn), + circle: s.circle ? { ...s.circle, center: fn(s.circle.center) } : undefined, + } + : s, + ); + } + if (sel.columnId) { + columns = columns.map((c) => (c.id === sel.columnId ? mvColumn(c, fn) : c)); + } } // Kopien anhängen (I/O/P bzw. mirror copy). const newWalls: Wall[] = []; const newDrawings: Drawing2D[] = []; + const newSolids: ExtrudedSolid[] = []; + const newColumns: Column[] = []; for (const t of copyTs(op, mode, count)) { const fn = transformFn(op, pts, t); for (const item of items) { if (item.kind === "wall") { const w = item.wall; newWalls.push({ ...w, id: uniqueId("W"), start: fn(w.start), end: fn(w.end) }); - } else { + } else if (item.kind === "drawing") { const d = item.drawing; newDrawings.push({ ...d, id: uniqueId("dr2d"), geom: mvGeom(d.geom, fn) }); + } else if (item.kind === "column") { + newColumns.push({ ...mvColumn(item.column, fn), id: uniqueId("column") }); + } else { + const s = item.solid; + newSolids.push({ + ...s, + id: uniqueId("extrude"), + points: s.points.map(fn), + circle: s.circle ? { ...s.circle, center: fn(s.circle.center) } : undefined, + }); } } } @@ -247,5 +320,7 @@ export function commitTransform( ...project, walls: newWalls.length ? [...walls, ...newWalls] : walls, drawings2d: newDrawings.length ? [...drawings2d, ...newDrawings] : drawings2d, + extrudedSolids: newSolids.length ? [...extrudedSolids, ...newSolids] : extrudedSolids, + columns: newColumns.length ? [...columns, ...newColumns] : columns, }; } diff --git a/src/tools/types.ts b/src/tools/types.ts index 2702355..7d1b45e 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -20,7 +20,9 @@ export type ToolId = | "polyline" | "rect" | "circle" - | "arc"; + | "arc" + | "text" + | "textbox"; // ── Snapping ─────────────────────────────────────────────────────────────── diff --git a/src/ui/ColorHexField.tsx b/src/ui/ColorHexField.tsx index df90c6b..bb60cf7 100644 --- a/src/ui/ColorHexField.tsx +++ b/src/ui/ColorHexField.tsx @@ -3,6 +3,7 @@ // Text-Anzeige des Hex-Werts, die nicht anklickbar/editierbar war. import { useEffect, useState } from "react"; +import { t } from "../i18n"; export function ColorHexField({ value, @@ -32,7 +33,7 @@ export function ColorHexField({ return ( -
diff --git a/src/ui/ExportSaveDialog.tsx b/src/ui/ExportSaveDialog.tsx new file mode 100644 index 0000000..33570d4 --- /dev/null +++ b/src/ui/ExportSaveDialog.tsx @@ -0,0 +1,125 @@ +// Schnellexport-Dialog (modal) für die Direkt-Formate CSV/IFC/OBJ/STL aus dem +// Topbar-Export-Menü: Format wählen + Dateiname festlegen, dann speichern. +// (PDF/DXF haben eigene Options-Dialoge.) Später bekommen „Ausschnitte" einen +// vordefinierten Namen + Speicherort; dies ist der interaktive Schnellweg. +// +// Muster wie ExportDxfDialog: abgedunkeltes Overlay, Klick auf Hintergrund + Esc +// schliessen. Rein gesteuert — meldet Format + Dateiname über `onSave` nach oben +// (App führt Erzeugung + Download aus). Bezeichner englisch, UI deutsch (t()). + +import { useEffect, useState } from "react"; +import { t } from "../i18n"; +import { Dropdown } from "./Dropdown"; + +/** Direkt-herunterladbare Exportformate (mit Datei-Endung). */ +export type ExportSaveFormat = "csv" | "ifc" | "obj" | "stl"; + +/** Datei-Endung je Format. */ +export const EXPORT_FORMAT_EXT: Record = { + csv: "csv", + ifc: "ifc", + obj: "obj", + stl: "stl", +}; + +export interface ExportSaveDialogProps { + /** Vorausgewähltes Format (aus dem angeklickten Menüeintrag). */ + initialFormat: ExportSaveFormat; + /** Basis-Dateiname OHNE Endung (i. d. R. Projektname). */ + baseName: string; + onSave: (format: ExportSaveFormat, filename: string) => void; + onClose: () => void; +} + +/** Endung sicher an den Basisnamen hängen (doppelte Endung vermeiden). */ +function withExt(base: string, ext: string): string { + const trimmed = base.trim() || "modell"; + const lower = trimmed.toLowerCase(); + return lower.endsWith(`.${ext}`) ? trimmed : `${trimmed}.${ext}`; +} + +export function ExportSaveDialog({ + initialFormat, + baseName, + onSave, + onClose, +}: ExportSaveDialogProps) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const [format, setFormat] = useState(initialFormat); + // Der Dateiname wird OHNE Endung editiert; die Endung folgt aus dem Format. + const [name, setName] = useState(baseName); + + const filename = withExt(name, EXPORT_FORMAT_EXT[format]); + const onConfirm = () => onSave(format, filename); + + return ( +
+
e.stopPropagation()} + > +
+ {t("exportSave.title")} + +
+ +
+
+
{t("exportSave.format")}
+ setFormat(v as ExportSaveFormat)} + title={t("exportSave.format")} + options={[ + { value: "csv", label: t("exportSave.fmt.csv") }, + { value: "ifc", label: t("exportSave.fmt.ifc") }, + { value: "obj", label: t("exportSave.fmt.obj") }, + { value: "stl", label: t("exportSave.fmt.stl") }, + ]} + /> +
+ +
+
{t("exportSave.filename")}
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onConfirm(); + }} + placeholder="modell" + /> +
+ {filename} · {t("exportSave.locationNote")} +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/ui/LayoutCreateDialogs.tsx b/src/ui/LayoutCreateDialogs.tsx new file mode 100644 index 0000000..4aefe27 --- /dev/null +++ b/src/ui/LayoutCreateDialogs.tsx @@ -0,0 +1,313 @@ +// Erstell-Dialoge für den Layouts-Baum (DOSSIER A3): „Neues Layout" und „Neues +// Masterlayout". Beide liefern beim Bestätigen die Startwerte (Papier/Custom- +// Grösse/Ausrichtung bzw. Master-Vorlage) nach oben; das Panel legt das Element +// an und versetzt es direkt in den Inline-Rename-Modus. +// +// Muster wie ExportSaveDialog: `imp-*`-Overlay/Dialog, Klick auf Hintergrund + +// Esc schliessen, Enter bestätigt. KEIN window.prompt/confirm (im Tauri-WKWebView +// deaktiviert). Bezeichner englisch, UI-Text deutsch via t() (CONVENTIONS.md). + +import { useEffect, useState } from "react"; +import { t } from "../i18n"; +import type { + LayoutOrientation, + LayoutPaperFormat, + MasterLayout, +} from "../model/types"; +import { PAPER_FORMAT_GROUPS } from "../panels/layoutModel"; + +/** Startwerte eines neuen Blatts/Masters (Grösse). */ +export interface LayoutSizeStart { + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + customWidthMm?: number; + customHeightMm?: number; +} + +/** Startwerte eines neuen Layouts: entweder Master-Vorlage ODER freie Grösse. */ +export interface NewLayoutStart extends LayoutSizeStart { + /** Gebundenes Masterlayout (erbt dessen Papier/Ausrichtung); sonst undefined. */ + masterId?: string; +} + +type SizeMode = LayoutPaperFormat | "custom"; + +/** Baut die Grösse-Startwerte aus dem Grössen-Auswahlzustand. */ +function sizeStart( + mode: SizeMode, + orientation: LayoutOrientation, + wMm: number, + hMm: number, +): LayoutSizeStart { + if (mode === "custom") { + return { + paper: "a4", + orientation, + customWidthMm: Math.max(1, Math.round(wMm)), + customHeightMm: Math.max(1, Math.round(hMm)), + }; + } + return { paper: mode, orientation }; +} + +/** Geteilte Grössen-Auswahl: Format (A4/A3) als Segment ODER freie mm-Felder + Ausrichtung. */ +function SizePicker({ + mode, + setMode, + orientation, + setOrientation, + wMm, + setWMm, + hMm, + setHMm, +}: { + mode: SizeMode; + setMode: (m: SizeMode) => void; + orientation: LayoutOrientation; + setOrientation: (o: LayoutOrientation) => void; + wMm: number; + setWMm: (v: number) => void; + hMm: number; + setHMm: (v: number) => void; +}) { + return ( + <> +
+
{t("layouts.size.format")}
+ +
+ + {mode === "custom" ? ( +
+
+ + × + +
+
+ ) : ( +
+
{t("layouts.orientation")}
+
+ {(["portrait", "landscape"] as const).map((o) => ( + + ))} +
+
+ )} + + ); +} + +/** Overlay-Rahmen (geteilt von beiden Dialogen). */ +function DialogFrame({ + title, + onClose, + onConfirm, + children, +}: { + title: string; + onClose: () => void; + onConfirm: () => void; + children: React.ReactNode; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + else if (e.key === "Enter") onConfirm(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose, onConfirm]); + + return ( +
+
e.stopPropagation()} + > +
+ {title} + +
+
{children}
+
+ + +
+
+
+ ); +} + +/** Dialog „Neues Masterlayout" — Format/freie Grösse + Ausrichtung. */ +export function NewMasterLayoutDialog({ + onCreate, + onClose, +}: { + onCreate: (start: LayoutSizeStart) => void; + onClose: () => void; +}) { + const [mode, setMode] = useState("a4"); + const [orientation, setOrientation] = useState("portrait"); + const [wMm, setWMm] = useState(210); + const [hMm, setHMm] = useState(297); + const confirm = () => onCreate(sizeStart(mode, orientation, wMm, hMm)); + + return ( + + + + ); +} + +/** + * Dialog „Neues Layout" — Masterlayout als Vorlage (erbt dessen Grösse) ODER + * freie Grösse (Format/Custom + Ausrichtung). + */ +export function NewLayoutDialog({ + masters, + onCreate, + onClose, +}: { + masters: MasterLayout[]; + onCreate: (start: NewLayoutStart) => void; + onClose: () => void; +}) { + const hasMasters = masters.length > 0; + const [source, setSource] = useState<"master" | "free">( + hasMasters ? "master" : "free", + ); + const [masterId, setMasterId] = useState(masters[0]?.id ?? ""); + const [mode, setMode] = useState("a4"); + const [orientation, setOrientation] = useState("portrait"); + const [wMm, setWMm] = useState(210); + const [hMm, setHMm] = useState(297); + + const confirm = () => { + if (source === "master" && masterId) { + // Grösse erbt der Aufrufer vom Master; wir liefern nur die Bindung + eine + // sinnvolle Rückfall-Grösse (A4 hoch), falls der Master unauflösbar ist. + onCreate({ masterId, paper: "a4", orientation: "portrait" }); + return; + } + onCreate(sizeStart(mode, orientation, wMm, hMm)); + }; + + return ( + +
+
{t("layouts.newLayout.source")}
+ + +
+ + {source === "master" ? ( +
+ {hasMasters ? ( + + ) : ( +
{t("layouts.newLayout.noMasters")}
+ )} +
+ ) : ( + + )} +
+ ); +} diff --git a/src/ui/LayoutSheet.tsx b/src/ui/LayoutSheet.tsx new file mode 100644 index 0000000..5a45c67 --- /dev/null +++ b/src/ui/LayoutSheet.tsx @@ -0,0 +1,1116 @@ +// Layout-Blatt als erstklassige Ansicht im HAUPT-Viewport (Phase 2b) — das Blatt +// liegt auf einem dunklen „Tisch", ist pan-/zoombar (Zoom-to-Cursor) und die +// platzierten Viewports lassen sich mit der Maus platzieren/verschieben/skalieren/ +// löschen (wie ArchiCAD/Vectorworks). Der Render-Kern (echter Plan je Viewport, +// Master-Titelblock) lebt hier zentral (ViewportPlan, TitleBlockSvg unten) und +// ersetzt das frühere schwebende Layout-Fenster (Phase 2a). +// +// Geometrie/Transform/Trefferlogik liegen rein + testbar in layoutSheetMath.ts. +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useEffect, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { + Layout, + LayoutAnnotation, + LayoutAnnotationPatch, + LayoutOrientation, + LayoutPaperFormat, + LayoutViewport, + Project, +} from "../model/types"; +import { generatePlan } from "../plan/generatePlan"; +import { planToPrintSvg } from "../export/planToPrintSvg"; +import { + effectiveSheetSizeMm, + findMaster, + findSnapshot, + resolveTitleBlock, + resolveViewportScale, + visibleCodesFromSnapshot, +} from "../panels/layoutModel"; +import { + annotationBoundsMm, + applyHandleDrag, + clampRectToSheet, + fitTransform, + handleAnchors, + handleCursor, + normalizeRect, + panBy, + pickAnnotation, + pickViewport, + screenToMm, + translateAnnotation, + vpRect, + zoomAtCursor, + type Handle, + type RectMm, + type SheetTransform, +} from "./layoutSheetMath"; +import { PromptDialog } from "./PromptDialog"; +import { t } from "../i18n"; + +// ── Geteilter Render-Kern (auch vom schwebenden LayoutEditor genutzt) ───────── + +/** + * Rendert den ECHTEN Plan eines Viewports: Ausschnitt auflösen → `generatePlan` + * → `planToPrintSvg` beim effektiven Viewport-Massstab, Blattgrösse = Viewport- + * mm-Grösse. Das SVG (viewBox in mm) wird per 100 % in den Rahmen skaliert + * (`overflow:hidden` → Clipping). Neu-Aufbau nur bei relevanten Änderungen + * (Projekt/Bindung/Grösse/Massstab), NICHT beim Verschieben (x/y) oder Zoomen. + */ +export function ViewportPlan({ + project, + viewport, +}: { + project: Project; + viewport: LayoutViewport; +}) { + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + const snap = findSnapshot(project, viewport.snapshotId); + if (!snap) { + el.replaceChildren(); + return; + } + try { + const codes = visibleCodesFromSnapshot(snap); + const plan = generatePlan( + project, + snap.activeLevelId, + codes, + undefined, + snap.detail, + false, + false, + ); + const scaleN = resolveViewportScale(viewport, snap); + const print = planToPrintSvg(plan, { + scaleDenominator: scaleN, + pageWidthMm: viewport.widthMm, + pageHeightMm: viewport.heightMm, + }); + const svg = print.svg; + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.style.display = "block"; + el.replaceChildren(svg); + } catch { + el.replaceChildren(); + } + }, [ + project, + viewport.snapshotId, + viewport.widthMm, + viewport.heightMm, + viewport.scaleDenominator, + ]); + return
; +} + +/** + * Schlichtes Schriftfeld unten rechts (mm-Koordinaten des Blatt-viewBox) mit den + * aufgelösten Master-Werten — dieselbe Anmutung wie der PDF-Titelblock. + */ +export function TitleBlockSvg({ + sheet, + tb, + paper, + orientation, +}: { + sheet: { w: number; h: number }; + tb: { projectName: string; sheetName: string; scale: string; date: string; author: string }; + paper: LayoutPaperFormat; + orientation: LayoutOrientation; +}) { + const boxW = 70; + const boxH = 30; + const pad = 6; + const x = sheet.w - boxW - pad; + const y = sheet.h - boxH - pad; + const fmt = `${paper.toUpperCase()} ${orientation === "landscape" ? "quer" : "hoch"}`; + return ( + + + + + + {clip(tb.projectName, 34)} + + + {clip(tb.sheetName, 36)} + + {tb.author && ( + + {clip(tb.author, 40)} + + )} + + {tb.scale} + + + {tb.date} · {fmt} + + + ); +} + +function clip(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +// ── In-Viewport-Layout-Ansicht ──────────────────────────────────────────────── + +export interface LayoutSheetHandlers { + /** Zurück zur Modell-Ansicht (Grundriss/3D). */ + onBack: () => void; + /** Ändert Position/Grösse/Massstab eines Viewports (mm). */ + onPatchViewport: ( + viewportId: string, + patch: Partial>, + ) => void; + /** Entfernt einen Viewport per Id. */ + onRemoveViewport: (viewportId: string) => void; + /** Fügt einen Viewport (gebunden an `snapshotId`) mit gegebener Geometrie an. */ + onAddViewport: ( + snapshotId: string, + init: Partial>, + ) => void; + /** Fügt eine neue Annotation (Linie/Rechteck/Text) mit gegebener Geometrie an. */ + onAddAnnotation: (kind: LayoutAnnotation["kind"], init: LayoutAnnotationPatch) => void; + /** Ändert Felder einer Annotation (Position/Farbe/Strichstärke/Text). */ + onPatchAnnotation: (annotationId: string, patch: LayoutAnnotationPatch) => void; + /** Entfernt eine Annotation per Id. */ + onRemoveAnnotation: (annotationId: string) => void; +} + +/** Fangzone der Resize-Griffe in Bildschirm-Pixeln (→ mm über den Massstab). */ +const HANDLE_TOL_PX = 7; +/** Mindest-Kantenlänge eines Viewports (mm). */ +const MIN_VP_MM = 8; +/** Mindest-Kantenlänge eines aufgezogenen Platzierungs-Rechtecks (mm). */ +const MIN_PLACE_MM = 6; +/** Mindestgrösse einer neu gezeichneten Linie/eines Rechtecks (mm) — sonst verworfen. */ +const MIN_ANNOT_MM = 3; +/** Default-Zeilenhöhe eines neuen Text-Markups (mm). */ +const DEFAULT_ANNOT_TEXT_MM = 3.5; + +/** Zeichenwerkzeug für die Annotations-Schicht (zusätzlich zum Viewport-„placing"). */ +type AnnotTool = "select" | "line" | "rect" | "text"; + +type Drag = + | { mode: "pan"; lastX: number; lastY: number } + | { mode: "move"; id: string; start: RectMm; grabX: number; grabY: number } + | { mode: "resize"; id: string; handle: Handle; start: RectMm; grabX: number; grabY: number } + | { mode: "place"; x0: number; y0: number } + | { mode: "annot-line"; x0: number; y0: number } + | { mode: "annot-rect"; x0: number; y0: number } + | { mode: "annot-move"; id: string; start: LayoutAnnotation; grabX: number; grabY: number } + | null; + +/** Annotation abzüglich `id`/`kind` als Patch — für den Commit nach Move/Draw. */ +function annotationPatch(a: LayoutAnnotation): LayoutAnnotationPatch { + const { id: _id, kind: _kind, ...rest } = a as LayoutAnnotation & Record; + return rest as LayoutAnnotationPatch; +} + +export function LayoutSheetView({ + project, + layout, + handlers, +}: { + project: Project; + layout: Layout; + handlers: LayoutSheetHandlers; +}) { + const stageRef = useRef(null); + const [canvas, setCanvas] = useState({ w: 800, h: 600 }); + const [transform, setTransform] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [hoverCursor, setHoverCursor] = useState("default"); + // Werkzeug-Modus „Viewport aufziehen" (armiert). Danach folgt der Ausschnitt-Picker. + const [placing, setPlacing] = useState(false); + // Live-Geometrie während eines Move/Resize/Place-Drags (Ghost), sonst null. + const [previewRect, setPreviewRect] = useState(null); + // Nach dem Aufziehen: das fertige Rechteck (mm) wartet auf die Ausschnitt-Bindung. + const [pendingRect, setPendingRect] = useState(null); + const [bindSnapId, setBindSnapId] = useState(""); + const dragRef = useRef(null); + const spaceDownRef = useRef(false); + + // ── Annotationen (Linie/Rechteck/Text) — eigenständige Zusatzschicht ──────── + // Werkzeug „select" = normale Auswahl/Verschieben von Viewports+Annotationen; + // line/rect/text armieren das jeweilige Zeichnen (analog `placing` für Viewports). + const [annotTool, setAnnotTool] = useState("select"); + const [selectedAnnotId, setSelectedAnnotId] = useState(null); + // Ghost-Geometrie: beim Aufziehen einer NEUEN Linie/eines Rechtecks (id="draft") + // ODER beim Verschieben einer bestehenden Annotation (id = deren echte Id). + const [annotPreview, setAnnotPreview] = useState(null); + // Textwerkzeug: nach dem Klick wartet die Position auf den PromptDialog-Text. + const [textPendingAt, setTextPendingAt] = useState<{ xMm: number; yMm: number } | null>(null); + // HUD „Text ändern" — öffnet den PromptDialog mit dem aktuellen Inhalt. + const [editingTextId, setEditingTextId] = useState(null); + + const master = findMaster(project, layout); + // Effektive Grösse respektiert freie Grösse (custom Width/Height) — bindet + // das Layout an ein Masterlayout, gewinnt dessen Grösse (wie im PDF-Export, + // `folderPdfPages`: `effectiveSheetSizeMm(master ?? layout)`). + const sheet = effectiveSheetSizeMm(master ?? layout); + const titleBlock = resolveTitleBlock(project, layout, master); + const drawBorder = master ? master.border ?? true : false; + const snapshots = project.viewSnapshots ?? []; + const selectedVp = layout.viewports.find((v) => v.id === selectedId) ?? null; + const annotations = layout.annotations ?? []; + const selectedAnnot = annotations.find((a) => a.id === selectedAnnotId) ?? null; + + // Verfügbare Fläche messen. + useEffect(() => { + const el = stageRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const e of entries) { + setCanvas({ w: e.contentRect.width, h: e.contentRect.height }); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Erst-Einpassung, sobald die Fläche gemessen ist (einmal). Bei Blattwechsel + // (Papier/Ausrichtung) neu einpassen. + useEffect(() => { + if (canvas.w <= 0 || canvas.h <= 0) return; + setTransform((cur) => + cur ?? fitTransform(sheet.w, sheet.h, canvas.w, canvas.h), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canvas.w, canvas.h]); + useEffect(() => { + if (canvas.w <= 0 || canvas.h <= 0) return; + setTransform(fitTransform(sheet.w, sheet.h, canvas.w, canvas.h)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sheet.w, sheet.h]); + + const fit = () => setTransform(fitTransform(sheet.w, sheet.h, canvas.w, canvas.h)); + const zoomStep = (factor: number) => + setTransform((t) => (t ? zoomAtCursor(t, factor, canvas.w / 2, canvas.h / 2) : t)); + + // Leertaste = Pan-Modifikator (wie in CAD). Delete/Backspace löscht die Auswahl. + useEffect(() => { + const down = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + const typing = + el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); + if (typing) return; + if (e.code === "Space") { + spaceDownRef.current = true; + e.preventDefault(); // kein Button-Reklick / Seiten-Scroll + return; + } + if (e.key === "Escape") { + if (placing) { + setPlacing(false); + setPreviewRect(null); + } else if (annotTool !== "select") { + setAnnotTool("select"); + setAnnotPreview(null); + } else if (pendingRect) { + setPendingRect(null); + } else if (textPendingAt) { + setTextPendingAt(null); + } else if (selectedAnnotId) { + setSelectedAnnotId(null); + } else { + setSelectedId(null); + } + } else if ((e.key === "Delete" || e.key === "Backspace") && selectedId) { + handlers.onRemoveViewport(selectedId); + setSelectedId(null); + e.preventDefault(); + } else if ((e.key === "Delete" || e.key === "Backspace") && selectedAnnotId) { + handlers.onRemoveAnnotation(selectedAnnotId); + setSelectedAnnotId(null); + e.preventDefault(); + } + }; + const up = (e: KeyboardEvent) => { + if (e.code === "Space") spaceDownRef.current = false; + }; + window.addEventListener("keydown", down); + window.addEventListener("keyup", up); + return () => { + window.removeEventListener("keydown", down); + window.removeEventListener("keyup", up); + }; + }, [ + placing, + pendingRect, + selectedId, + selectedAnnotId, + annotTool, + textPendingAt, + handlers, + ]); + + // Bildschirm-Pixel (canvas-lokal) eines Pointer-Events. + const localXY = (e: { clientX: number; clientY: number }) => { + const rect = stageRef.current?.getBoundingClientRect(); + return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) }; + }; + + // Zoom-to-Cursor am Rad: nativer, NICHT-passiver Listener (preventDefault muss + // greifen — React-onWheel ist passiv). Funktionales setTransform, damit der + // Listener nur einmal registriert werden muss. + useEffect(() => { + const el = stageRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = el.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + setTransform((t) => (t ? zoomAtCursor(t, factor, x, y) : t)); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + const onPointerDown = (e: ReactPointerEvent) => { + if (!transform) return; + // Klicks aus dem HUD/Popover (echte Bedienelemente) NICHT als Blatt-Geste werten. + if ((e.target as HTMLElement).closest("[data-sheet-ui]")) return; + const { x, y } = localXY(e); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + + // Pan: mittlere Maustaste oder Leertaste-Drag. + if (e.button === 1 || spaceDownRef.current) { + dragRef.current = { mode: "pan", lastX: x, lastY: y }; + return; + } + if (e.button !== 0) return; + + const mm = screenToMm(transform, x, y); + + // Platzierungs-Modus: Rechteck aufziehen. + if (placing) { + dragRef.current = { mode: "place", x0: mm.xMm, y0: mm.yMm }; + setPreviewRect({ xMm: mm.xMm, yMm: mm.yMm, widthMm: 0, heightMm: 0 }); + return; + } + + // Annotations-Werkzeuge: Linie/Rechteck aufziehen, Text sofort per Dialog. + if (annotTool === "text") { + setTextPendingAt({ xMm: mm.xMm, yMm: mm.yMm }); + return; + } + if (annotTool === "line") { + dragRef.current = { mode: "annot-line", x0: mm.xMm, y0: mm.yMm }; + setAnnotPreview({ + id: "draft", + kind: "line", + x1Mm: mm.xMm, + y1Mm: mm.yMm, + x2Mm: mm.xMm, + y2Mm: mm.yMm, + }); + return; + } + if (annotTool === "rect") { + dragRef.current = { mode: "annot-rect", x0: mm.xMm, y0: mm.yMm }; + setAnnotPreview({ id: "draft", kind: "rect", xMm: mm.xMm, yMm: mm.yMm, widthMm: 0, heightMm: 0 }); + return; + } + + const tolMm = HANDLE_TOL_PX / transform.scale; + const hit = pickViewport(layout.viewports, mm.xMm, mm.yMm, tolMm); + if (hit) { + setSelectedId(hit.id); + setSelectedAnnotId(null); + const vp = layout.viewports.find((v) => v.id === hit.id)!; + if (hit.handle === "body") { + dragRef.current = { + mode: "move", + id: hit.id, + start: vpRect(vp), + grabX: mm.xMm, + grabY: mm.yMm, + }; + } else { + dragRef.current = { + mode: "resize", + id: hit.id, + handle: hit.handle, + start: vpRect(vp), + grabX: mm.xMm, + grabY: mm.yMm, + }; + } + return; + } + + const annotId = pickAnnotation(annotations, mm.xMm, mm.yMm, tolMm); + if (annotId) { + setSelectedId(null); + setSelectedAnnotId(annotId); + const ann = annotations.find((a) => a.id === annotId)!; + dragRef.current = { mode: "annot-move", id: annotId, start: ann, grabX: mm.xMm, grabY: mm.yMm }; + } else { + setSelectedId(null); + setSelectedAnnotId(null); + } + }; + + const onPointerMove = (e: ReactPointerEvent) => { + if (!transform) return; + const { x, y } = localXY(e); + const drag = dragRef.current; + + if (!drag) { + // Nur Hover-Cursor aktualisieren (Griffe/Körper), wenn nicht am Ziehen. + const mm = screenToMm(transform, x, y); + const tolMm = HANDLE_TOL_PX / transform.scale; + const hit = pickViewport(layout.viewports, mm.xMm, mm.yMm, tolMm); + const annotHit = !hit ? pickAnnotation(annotations, mm.xMm, mm.yMm, tolMm) : null; + setHoverCursor( + placing || annotTool !== "select" + ? "crosshair" + : hit + ? handleCursor(hit.handle) + : annotHit + ? "move" + : "default", + ); + return; + } + + if (drag.mode === "pan") { + setTransform((t) => (t ? panBy(t, x - drag.lastX, y - drag.lastY) : t)); + dragRef.current = { ...drag, lastX: x, lastY: y }; + return; + } + + const mm = screenToMm(transform, x, y); + if (drag.mode === "place") { + setPreviewRect(normalizeRect(drag.x0, drag.y0, mm.xMm, mm.yMm)); + return; + } + if (drag.mode === "move") { + const dx = mm.xMm - drag.grabX; + const dy = mm.yMm - drag.grabY; + const moved = applyHandleDrag(drag.start, "body", dx, dy, MIN_VP_MM); + setPreviewRect(clampRectToSheet(moved, sheet.w, sheet.h)); + return; + } + if (drag.mode === "resize") { + const dx = mm.xMm - drag.grabX; + const dy = mm.yMm - drag.grabY; + setPreviewRect(applyHandleDrag(drag.start, drag.handle, dx, dy, MIN_VP_MM)); + return; + } + if (drag.mode === "annot-line") { + setAnnotPreview({ id: "draft", kind: "line", x1Mm: drag.x0, y1Mm: drag.y0, x2Mm: mm.xMm, y2Mm: mm.yMm }); + return; + } + if (drag.mode === "annot-rect") { + const r = normalizeRect(drag.x0, drag.y0, mm.xMm, mm.yMm); + setAnnotPreview({ id: "draft", kind: "rect", ...r }); + return; + } + if (drag.mode === "annot-move") { + const dx = mm.xMm - drag.grabX; + const dy = mm.yMm - drag.grabY; + setAnnotPreview(translateAnnotation(drag.start, dx, dy)); + return; + } + }; + + const endDrag = (e: ReactPointerEvent) => { + const el = e.currentTarget as HTMLElement; + if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); + const drag = dragRef.current; + dragRef.current = null; + if (!drag) return; + + if (drag.mode === "place") { + const r = previewRect; + setPreviewRect(null); + setPlacing(false); + if (r && r.widthMm >= MIN_PLACE_MM && r.heightMm >= MIN_PLACE_MM) { + const clamped = clampRectToSheet(r, sheet.w, sheet.h); + if (snapshots.length === 1) { + // Nur ein Ausschnitt → direkt binden. + handlers.onAddViewport(snapshots[0].id, clamped); + } else { + setPendingRect(clamped); + setBindSnapId(""); + } + } + return; + } + + if ((drag.mode === "move" || drag.mode === "resize") && previewRect) { + const r = clampRectToSheet(previewRect, sheet.w, sheet.h); + handlers.onPatchViewport(drag.id, { + xMm: round1(r.xMm), + yMm: round1(r.yMm), + widthMm: round1(Math.max(MIN_VP_MM, r.widthMm)), + heightMm: round1(Math.max(MIN_VP_MM, r.heightMm)), + }); + } + setPreviewRect(null); + + if (drag.mode === "annot-line") { + const p = annotPreview; + setAnnotPreview(null); + setAnnotTool("select"); + if (p && p.kind === "line") { + const len = Math.hypot(p.x2Mm - p.x1Mm, p.y2Mm - p.y1Mm); + if (len >= MIN_ANNOT_MM) { + handlers.onAddAnnotation("line", { + x1Mm: round1(p.x1Mm), + y1Mm: round1(p.y1Mm), + x2Mm: round1(p.x2Mm), + y2Mm: round1(p.y2Mm), + }); + } + } + return; + } + if (drag.mode === "annot-rect") { + const p = annotPreview; + setAnnotPreview(null); + setAnnotTool("select"); + if (p && p.kind === "rect" && p.widthMm >= MIN_ANNOT_MM && p.heightMm >= MIN_ANNOT_MM) { + const clamped = clampRectToSheet( + { xMm: p.xMm, yMm: p.yMm, widthMm: p.widthMm, heightMm: p.heightMm }, + sheet.w, + sheet.h, + ); + handlers.onAddAnnotation("rect", { + xMm: round1(clamped.xMm), + yMm: round1(clamped.yMm), + widthMm: round1(clamped.widthMm), + heightMm: round1(clamped.heightMm), + }); + } + return; + } + if (drag.mode === "annot-move") { + if (annotPreview) { + handlers.onPatchAnnotation(drag.id, annotationPatch(annotPreview)); + } + setAnnotPreview(null); + return; + } + }; + + const confirmBind = () => { + if (bindSnapId && pendingRect) { + handlers.onAddViewport(bindSnapId, pendingRect); + } + setPendingRect(null); + setBindSnapId(""); + }; + + // Blatt-Position (canvas-lokale Pixel) + Grösse. + const tf = transform ?? fitTransform(sheet.w, sheet.h, canvas.w, canvas.h); + const paperLeft = tf.tx; + const paperTop = tf.ty; + const paperW = sheet.w * tf.scale; + const paperH = sheet.h * tf.scale; + + // Effektives Rechteck eines Viewports (Ghost während seines Drags). + const displayRect = (vp: LayoutViewport): RectMm => + previewRect && + (dragRef.current?.mode === "move" || dragRef.current?.mode === "resize") && + dragRef.current?.id === vp.id + ? previewRect + : vpRect(vp); + + return ( +
+ {/* Werkzeugleiste (Blatt-Navigation + Platzieren + Zurück). */} +
+ + {layout.name} + + + + + + + + + +
+ + {/* Blatt-Tisch: dunkler Hintergrund, weisses Blatt zentriert. */} +
+ {/* Weisses Papier. */} +
+ {/* Master: Rahmen + Titelblock (read-only). */} + + {drawBorder && ( + + )} + {master && ( + + )} + + + {/* Platzierte Viewports (echter Plan, geklippt). */} + {layout.viewports.map((vp) => { + const r = displayRect(vp); + const snap = findSnapshot(project, vp.snapshotId); + const selected = vp.id === selectedId; + return ( +
+ {snap ? ( + + ) : ( +
{t("layouts.missingSnapshot")}
+ )} +
+ ); + })} + + {/* Annotationen (Linie/Rechteck/Text) — eigene Schicht ÜBER den Viewports, + in mm über dieselbe viewBox wie Rahmen/Titelblock. Während des + Zeichnens/Verschiebens ersetzt `annotPreview` die betroffene Annotation + (Ghost); ein neu gezeichnetes Element (id="draft") ist noch nicht in + `layout.annotations` und wird zusätzlich gerendert. */} + + {annotations.map((ann) => { + const a = annotPreview && annotPreview.id === ann.id ? annotPreview : ann; + return ; + })} + {annotPreview && annotPreview.id === "draft" && ( + + )} + {/* Gestrichelter Auswahl-Rahmen um die gewählte Annotation. */} + {selectedAnnot && + (() => { + const display = + annotPreview && annotPreview.id === selectedAnnot.id + ? annotPreview + : selectedAnnot; + const b = annotationBoundsMm(display); + return ( + + ); + })()} + + + {/* Auswahl-Griffe des gewählten Viewports. */} + {selectedVp && + handleAnchors(displayRect(selectedVp)).map((a) => ( +
+ ))} + + {/* Ghost-Rechteck beim Aufziehen (Platzieren). */} + {previewRect && dragRef.current?.mode === "place" && ( +
+ )} +
+ + {/* HUD: Eigenschaften des gewählten Viewports (Massstab/Grösse/Löschen). */} + {selectedVp && ( +
+
+ {findSnapshot(project, selectedVp.snapshotId)?.name ?? + t("layouts.missingSnapshot")} +
+ +
+ {Math.round(selectedVp.widthMm)} × {Math.round(selectedVp.heightMm)} mm +
+
+ {selectedVp.scaleDenominator !== undefined && ( + + )} + +
+
+ )} + + {/* HUD: Eigenschaften der gewählten Annotation (Farbe/Strichstärke/Text/Löschen). */} + {selectedAnnot && ( +
+
{t(`layouts.annot.${selectedAnnot.kind}`)}
+ + {selectedAnnot.kind !== "text" && ( + + )} + {selectedAnnot.kind === "text" && ( +
{clip(selectedAnnot.text, 40)}
+ )} +
+ {selectedAnnot.kind === "text" && ( + + )} + +
+
+ )} + + {/* Ausschnitt-Bindung nach dem Aufziehen (Inline-Dropdown, kein prompt). */} + {pendingRect && ( +
+
{t("layouts.bindSnapshot")}
+
+ + + +
+
+ )} + + {/* Leer-Hinweis (keine Ausschnitte / keine Viewports). */} + {layout.viewports.length === 0 && + annotations.length === 0 && + !previewRect && + !pendingRect && ( +
+ {snapshots.length === 0 + ? t("layouts.noSnapshots") + : placing + ? t("layouts.placeHint") + : t("layouts.emptyViewports")} +
+ )} +
+ + {/* Textwerkzeug: Position gesetzt, Inhalt kommt per Dialog (kein window.prompt). */} + {textPendingAt && ( + { + handlers.onAddAnnotation("text", { + xMm: round1(textPendingAt.xMm), + yMm: round1(textPendingAt.yMm), + text: value, + heightMm: DEFAULT_ANNOT_TEXT_MM, + }); + setTextPendingAt(null); + setAnnotTool("select"); + }} + onClose={() => { + setTextPendingAt(null); + setAnnotTool("select"); + }} + /> + )} + + {/* HUD „Text ändern" für eine ausgewählte Text-Annotation. */} + {editingTextId && + (() => { + const ann = annotations.find((a) => a.id === editingTextId); + if (!ann || ann.kind !== "text") return null; + return ( + { + handlers.onPatchAnnotation(editingTextId, { text: value }); + setEditingTextId(null); + }} + onClose={() => setEditingTextId(null)} + /> + ); + })()} +
+ ); +} + +/** + * Rendert EINE Annotation im mm-viewBox: Linie/Rechteck als Umriss (Default + * `#111111`/0.25 mm, überschreibbar), Text als Baseline-`` mit + * `font-size = heightMm`. Ausgewählte Annotationen werden blau eingefärbt; + * `ghost` (Zeichnen-Vorschau) wird gestrichelt + halbtransparent dargestellt. + */ +function AnnotationSvg({ + ann, + selected, + ghost, +}: { + ann: LayoutAnnotation; + selected?: boolean; + ghost?: boolean; +}) { + const selectedColor = "#2478ff"; + const dash = ghost ? "2 1.5" : undefined; + const opacity = ghost ? 0.7 : 1; + if (ann.kind === "line") { + return ( + + ); + } + if (ann.kind === "rect") { + return ( + + ); + } + return ( + + {ann.text} + + ); +} + +/** Auf 0.1 mm runden (weniger Rausch-Nachkommastellen beim Commit). */ +function round1(v: number): number { + return Math.round(v * 10) / 10; +} + diff --git a/src/ui/LockConflictDialog.tsx b/src/ui/LockConflictDialog.tsx new file mode 100644 index 0000000..d60e20c --- /dev/null +++ b/src/ui/LockConflictDialog.tsx @@ -0,0 +1,75 @@ +// Meldung, wenn eine Projektdatei bereits in einer anderen Instanz gesperrt ist +// (Lock-Konflikt, s. src/io/projectFile.ts acquireProjectLock). Ersetzt +// `window.confirm` (im Tauri-WKWebView deaktiviert) — gleiches Overlay-Muster +// wie PromptDialog (imp-overlay/imp-dialog/imp-head/imp-body/imp-foot/imp-btn). + +import { useEffect } from "react"; +import { t } from "../i18n"; +import type { LockInfo } from "../io/projectFile"; + +export interface LockConflictDialogProps { + /** Info aus der Lock-Sidecar-Datei (pid/hostname/timestamp) — kann fehlen. */ + info: LockInfo | null; + /** Projekt ohne Lock nur lesend öffnen (kein Schreibzugriff-Anspruch). */ + onReadOnly: () => void; + /** Lock ignorieren und trotzdem schreibend öffnen (Risiko: zwei Schreiber). */ + onForceOpen: () => void; + onCancel: () => void; +} + +export function LockConflictDialog({ + info, + onReadOnly, + onForceOpen, + onCancel, +}: LockConflictDialogProps) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onCancel]); + + return ( +
+
e.stopPropagation()} + > +
+ {t("lock.title")} + +
+ +
+
+
{t("lock.message")}
+ {info && ( +
+ {t("lock.messageInfo", { host: info.hostname, pid: String(info.pid) })} +
+ )} +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/src/ui/PromptDialog.tsx b/src/ui/PromptDialog.tsx new file mode 100644 index 0000000..11c41c8 --- /dev/null +++ b/src/ui/PromptDialog.tsx @@ -0,0 +1,92 @@ +// Generischer Text-Eingabe-Dialog — ersetzt `window.prompt` überall im Code. +// GRUND: `window.prompt`/`window.confirm` sind im Tauri-WKWebView deaktiviert +// (liefern sofort `null` zurück) — jede Stelle, die sie nutzt, war in der +// Desktop-App faktisch kaputt (kein Name kam je an). Dieser Dialog ist der +// einheitliche Ersatz: gleiches Muster wie ExportSaveDialog/SettingsDialog +// (abgedunkeltes Overlay, Klick auf Hintergrund + Esc schliessen), Enter +// bestätigt. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useEffect, useState } from "react"; +import { t } from "../i18n"; + +export interface PromptDialogProps { + /** Kopfzeile des Dialogs. */ + title: string; + /** Feld-Beschriftung/Frage (entspricht dem früheren `window.prompt`-Text). */ + label: string; + /** Vorbelegter Wert (entspricht dem früheren zweiten `window.prompt`-Arg). */ + defaultValue?: string; + /** Eingabetyp — "text" (Default) oder "number" für reine Zahlen-Prompts. */ + type?: "text" | "number"; + onConfirm: (value: string) => void; + onClose: () => void; +} + +export function PromptDialog({ + title, + label, + defaultValue = "", + type = "text", + onConfirm, + onClose, +}: PromptDialogProps) { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const commit = () => { + const v = value.trim(); + if (v) onConfirm(v); + }; + + return ( +
+
e.stopPropagation()} + > +
+ {title} + +
+ +
+
+
{label}
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + }} + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index d40db21..5fcca60 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -16,18 +16,24 @@ // Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch // (CONVENTIONS.md). -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { PointerEvent as ReactPointerEvent } from "react"; import type { CeilingType, Component, ComponentMaterial, + DoorType, HatchPattern, HatchStyle, Layer, LineStyle, + OverrideConditionType, + OverrideOperator, + OverrideRule, Project, + StairType, WallType, + WindowType, } from "../model/types"; import { PEN_WEIGHTS } from "../model/types"; import { parseLin } from "../io/linParser"; @@ -720,6 +726,95 @@ function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) { ); } +/** + * Baut aus einem zugewiesenen Bauteil-Material ein `MaterialAsset` für die + * PBR-Kugel-Vorschau (`requestMaterialPreview`): verweist es auf die + * Bibliothek (`libraryId`), wird deren Asset wiederverwendet (Name/Kategorie + * fürs Tooltip); sonst entsteht aus den hochgeladenen Karten ein Ad-hoc-Asset. + */ +function materialAssetOf(material: ComponentMaterial): MaterialAsset { + if (material.libraryId) { + const asset = MATERIAL_LIBRARY.find((a) => a.id === material.libraryId); + if (asset) return asset; + } + return { + id: material.libraryId ?? "custom", + name: t("material.uploaded"), + category: "", + maps: { + color: material.color, + normal: material.normal, + roughness: material.roughness, + metalness: material.metalness, + displacement: material.displacement, + ao: material.ao, + }, + }; +} + +/** + * PBR-Kugel-Vorschau eines zugewiesenen Bauteil-Materials — dieselbe + * Lazy-Render-Logik wie `MaterialLibraryTile` (IntersectionObserver + + * `requestMaterialPreview`, geteilter Renderer), nur ohne Klick-Interaktion, + * als Thumbnail-Ersatz für `ColorSwatch`/`HatchSwatch` sobald ein Bauteil ein + * Material trägt. + */ +function ComponentMaterialSphere({ + material, + size = 30, +}: { + material: ComponentMaterial; + size?: number; +}) { + const asset = useMemo( + () => materialAssetOf(material), + [ + material.libraryId, + material.color, + material.normal, + material.roughness, + material.metalness, + material.displacement, + material.ao, + ], + ); + const ref = useRef(null); + const [visible, setVisible] = useState(false); + const [previewUrl, setPreviewUrl] = useState(undefined); + + useEffect(() => { + const el = ref.current; + if (!el || visible) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) setVisible(true); + }, + { rootMargin: "200px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [visible]); + + useEffect(() => { + if (!visible) return; + const handleReady = (url: string) => setPreviewUrl(url); + const cached = requestMaterialPreview(asset, handleReady); + if (cached) setPreviewUrl(cached); + return () => cancelMaterialPreview(asset, handleReady); + }, [visible, asset]); + + return ( + + {previewUrl && } + + ); +} + /** Detail-Panel EINES Bauteils (rechte Seite des Master-Detail-Layouts). */ function ComponentDetail({ component, @@ -755,7 +850,9 @@ function ComponentDetail({
- {previewHatch ? ( + {component.material ? ( + + ) : previewHatch ? ( ) : ( @@ -863,7 +960,9 @@ function ComponentsTab({ onClick={() => setSelectedId(c.id)} > - {hatch ? ( + {c.material ? ( + + ) : hatch ? ( ) : ( @@ -1859,6 +1958,424 @@ function patToHatches(text: string): Omit[] { })); } +// ── Bauteil-Typen (Tür/Fenster/Treppe) ───────────────────────────────────── +// Formular-basierte Bibliotheken (im Gegensatz zu Wand-/Deckenstilen, die +// schichtbasiert über LayeredStylesTab laufen): jeder Typ ist ein flaches +// Feld-Bündel (Bauart, Maße, …), daher genügt ein generischer Master-Detail- +// Rumpf ({@link TypeLibraryTab}), der die konkreten Felder je Typ als Render- +// Funktion einreicht — analog zum Bauteile-Tab (Liste links, Editor rechts). + +const DOOR_KIND_OPTIONS: { value: DoorType["kind"]; labelKey: string }[] = [ + { value: "dreh", labelKey: "resources.doorKind.dreh" }, + { value: "schiebe", labelKey: "resources.doorKind.schiebe" }, + { value: "wandoeffnung", labelKey: "resources.doorKind.wandoeffnung" }, +]; + +const DOOR_LEAF_STYLE_OPTIONS: { value: DoorType["leafStyle"]; labelKey: string }[] = [ + { value: "glatt", labelKey: "resources.leafStyle.glatt" }, + { value: "kassette", labelKey: "resources.leafStyle.kassette" }, + { value: "glas", labelKey: "resources.leafStyle.glas" }, +]; + +const WINDOW_KIND_OPTIONS: { value: WindowType["kind"]; labelKey: string }[] = [ + { value: "dreh", labelKey: "resources.windowKind.dreh" }, + { value: "kipp", labelKey: "resources.windowKind.kipp" }, + { value: "drehkipp", labelKey: "resources.windowKind.drehkipp" }, + { value: "fest", labelKey: "resources.windowKind.fest" }, + { value: "schiebe", labelKey: "resources.windowKind.schiebe" }, +]; + +const WINDOW_GLAZING_OPTIONS: { value: WindowType["glazing"]; labelKey: string }[] = [ + { value: "einfach", labelKey: "resources.glazing.einfach" }, + { value: "zweifach", labelKey: "resources.glazing.zweifach" }, + { value: "dreifach", labelKey: "resources.glazing.dreifach" }, +]; + +const WINDOW_SILL_BOARD_OPTIONS: { value: NonNullable; labelKey: string }[] = [ + { value: "keine", labelKey: "resources.sillBoard.keine" }, + { value: "innen", labelKey: "resources.sillBoard.innen" }, + { value: "aussen", labelKey: "resources.sillBoard.aussen" }, + { value: "beide", labelKey: "resources.sillBoard.beide" }, +]; + +const STAIR_STRUCTURE_OPTIONS: { value: StairType["structure"]; labelKey: string }[] = [ + { value: "massiv", labelKey: "resources.structure.massiv" }, + { value: "beton", labelKey: "resources.structure.beton" }, + { value: "wange", labelKey: "resources.structure.wange" }, + { value: "aufgesattelt", labelKey: "resources.structure.aufgesattelt" }, + { value: "spindel", labelKey: "resources.structure.spindel" }, +]; + +const STAIR_RAILING_OPTIONS: { value: NonNullable; labelKey: string }[] = [ + { value: "keine", labelKey: "resources.railing.keine" }, + { value: "links", labelKey: "resources.railing.links" }, + { value: "rechts", labelKey: "resources.railing.rechts" }, + { value: "beide", labelKey: "resources.railing.beide" }, +]; + +/** + * Generischer Rumpf für formular-basierte Typ-Bibliotheken (Tür-/Fenster-/ + * Treppentypen): Liste links (Name je Zeile, „+ Neu" im Fuß), Editor rechts + * (Umbenennen-Feld + Löschen-Knopf im Kopf, darunter die typspezifischen + * Felder aus `renderFields`). Handler sind optional (nur gesetzt, wenn die + * einbettende App sie bereitstellt) — Aufrufe laufen daher über `?.()`. + */ +function TypeLibraryTab({ + types, + hintKey, + emptyKey, + placeholderKey, + deleteKeyPrefix, + onAdd, + onPatch, + onDelete, + renderFields, +}: { + types: T[]; + hintKey: string; + emptyKey: string; + placeholderKey: string; + deleteKeyPrefix: string; + onAdd?: () => void; + onPatch?: (id: string, patch: Partial) => void; + onDelete?: (id: string) => void; + renderFields: (item: T, onPatch: (patch: Partial) => void) => React.ReactNode; +}) { + const [selectedId, setSelectedId] = useState(null); + const selected = types.find((x) => x.id === selectedId) ?? types[0]; + + return ( +
+
{t(hintKey)}
+
+
+ {types.map((item) => ( + + ))} + {types.length === 0 && ( +
{t(emptyKey)}
+ )} +
+ +
+
+
+ {selected ? ( +
+
+ + onPatch?.(selected.id, { name: e.target.value } as Partial) + } + /> + onDelete?.(selected.id)} + title={t(deleteKeyPrefix, { name: selected.name })} + /> +
+
+ {renderFields(selected, (patch) => onPatch?.(selected.id, patch))} +
+
+ ) : ( +
{t(emptyKey)}
+ )} +
+
+
+ ); +} + +/** Tab „Türtypen" — Bauart, Blattausführung/-zahl, Zarge und Standardmaße. */ +function DoorStylesTab({ + project, + onAddDoorType, + onPatchDoorType, + onDeleteDoorType, +}: { + project: Project; + onAddDoorType?: () => void; + onPatchDoorType?: (id: string, patch: Partial) => void; + onDeleteDoorType?: (id: string) => void; +}) { + return ( + ( + <> + + patch({ kind })} + options={DOOR_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + patch({ leafCount: (v === "2" ? 2 : 1) as 1 | 2 })} + options={[ + { value: "1", label: t("resources.leafCount.1") }, + { value: "2", label: t("resources.leafCount.2") }, + ]} + /> + + + patch({ leafStyle })} + options={DOOR_LEAF_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + {door.leafStyle === "glas" && ( + + patch({ glazingRatio })} + step={0.01} + min={0} + max={1} + /> + + )} + + patch({ frameThickness })} + step={0.01} + min={0} + /> + + + patch({ frameDepth })} + step={0.01} + min={0} + /> + + + patch({ defaultWidth })} + step={0.01} + min={0} + /> + + + patch({ defaultHeight })} + step={0.01} + min={0} + /> + + + patch({ threshold: e.target.checked })} + /> + + + )} + /> + ); +} + +/** Tab „Fenstertypen" — Öffnungsart, Flügelzahl, Verglasung, Zarge, Brüstung. */ +function WindowStylesTab({ + project, + onAddWindowType, + onPatchWindowType, + onDeleteWindowType, +}: { + project: Project; + onAddWindowType?: () => void; + onPatchWindowType?: (id: string, patch: Partial) => void; + onDeleteWindowType?: (id: string) => void; +}) { + return ( + ( + <> + + patch({ kind })} + options={WINDOW_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + patch({ wingCount: Math.round(wingCount) })} + step={1} + min={1} + max={4} + /> + + + patch({ glazing })} + options={WINDOW_GLAZING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + patch({ frameThickness })} + step={0.01} + min={0} + /> + + + patch({ frameDepth })} + step={0.01} + min={0} + /> + + + patch({ defaultSillHeight })} + step={0.01} + min={0} + /> + + + patch({ defaultWidth })} + step={0.01} + min={0} + /> + + + patch({ defaultHeight })} + step={0.01} + min={0} + /> + + + patch({ sillBoard })} + options={WINDOW_SILL_BOARD_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + )} + /> + ); +} + +/** Tab „Treppentypen" — Tragart, Stufenausbildung, Geländer und Laufbreite. */ +function StairStylesTab({ + project, + onAddStairType, + onPatchStairType, + onDeleteStairType, +}: { + project: Project; + onAddStairType?: () => void; + onPatchStairType?: (id: string, patch: Partial) => void; + onDeleteStairType?: (id: string) => void; +}) { + return ( + ( + <> + + patch({ structure })} + options={STAIR_STRUCTURE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + patch({ closedRisers: e.target.checked })} + /> + + + patch({ treadThickness })} + step={0.01} + min={0} + /> + + + patch({ nosing })} + step={0.01} + min={0} + /> + + + patch({ railing })} + options={STAIR_RAILING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + patch({ defaultWidth })} + step={0.01} + min={0} + /> + + + )} + /> + ); +} + // ── Materialien (PBR-Bibliothek durchsuchen) ─────────────────────────────── // Eigenständiger Tab, der die eingebaute Materialbibliothek (MATERIAL_LIBRARY) // als Kachel-Grid mit gerenderter PBR-Kugel-Vorschau zeigt — Name/Kategorie @@ -2025,6 +2542,313 @@ function MaterialsTab() { ); } +// ── Grafische Overrides (Regel-Engine, A1) ───────────────────────────────── +// Master-Detail wie Linien/Wandstile: Liste links = Regeln in PRIORITÄTS- +// Reihenfolge (oben gewinnt pro Aktions-Feld) mit Aktiv-Checkbox, Auf/Ab und +// Löschen; Detail rechts = Bedingung (Kriterium/Operator/Wert) + optionale +// Aktionen (Farbe/Strichstärke/Linienstil). Reines Rendering-Overlay — die +// Auswertung liegt in src/overrides/engine.ts, der Einhängepunkt in +// plan/generatePlan.ts; Elementdaten bleiben unverändert. + +/** Bedingungs-Typen (UI-Beschriftung übersetzt, Werte englisch). */ +const OVERRIDE_TYPE_OPTIONS: { value: OverrideConditionType; labelKey: string }[] = [ + { value: "layer_name", labelKey: "resources.overrides.condType.layer" }, + { value: "object_name", labelKey: "resources.overrides.condType.object" }, +]; + +/** Vergleichs-Operatoren (UI-Beschriftung übersetzt, Werte englisch). */ +const OVERRIDE_OP_OPTIONS: { value: OverrideOperator; labelKey: string }[] = [ + { value: "equals", labelKey: "resources.overrides.op.equals" }, + { value: "contains", labelKey: "resources.overrides.op.contains" }, + { value: "starts_with", labelKey: "resources.overrides.op.startsWith" }, + { value: "not_equals", labelKey: "resources.overrides.op.notEquals" }, +]; + +/** Kompakte Bedingungs-Zusammenfassung für die Listenzeile. */ +function overrideConditionSummary(rule: OverrideRule): string { + const type = OVERRIDE_TYPE_OPTIONS.find((o) => o.value === rule.condition.type); + const op = OVERRIDE_OP_OPTIONS.find((o) => o.value === rule.condition.operator); + return `${type ? t(type.labelKey) : rule.condition.type} ${ + op ? t(op.labelKey) : rule.condition.operator + } „${rule.condition.value}"`; +} + +/** Optionale Aktion: Checkbox (an/aus) + Steuerelement, solange sie an ist. */ +function OptionalAction({ + label, + active, + onToggle, + children, +}: { + label: string; + active: boolean; + onToggle: (on: boolean) => void; + children?: React.ReactNode; +}) { + return ( +
+ + + + + {active ? children : } + +
+ ); +} + +/** Detail-Panel EINER Override-Regel (rechte Seite des Master-Detail-Layouts). */ +function OverrideRuleDetail({ + rule, + project, + onPatch, + onDelete, +}: { + rule: OverrideRule; + project: Project; + onPatch: (patch: Partial) => void; + onDelete: () => void; +}) { + const patchCondition = (patch: Partial) => + onPatch({ condition: { ...rule.condition, ...patch } }); + const patchActions = (patch: Partial) => + onPatch({ actions: { ...rule.actions, ...patch } }); + // Sentinel „—": kein Linienstil-Override. + const NONE = "__none__"; + const lineOptions = [ + { value: NONE, label: t("resources.overrides.linetype.none") }, + ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), + ]; + + return ( +
+
+ onPatch({ name: e.target.value })} + /> + +
+ +
{t("resources.overrides.condition")}
+ + patchCondition({ type })} + options={OVERRIDE_TYPE_OPTIONS.map((o) => ({ + value: o.value, + label: t(o.labelKey), + }))} + /> + + + patchCondition({ operator })} + options={OVERRIDE_OP_OPTIONS.map((o) => ({ + value: o.value, + label: t(o.labelKey), + }))} + /> + + + patchCondition({ value })} + /> + + +
{t("resources.overrides.actions")}
+ {/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */} + + {PEN_WEIGHTS.map((w) => ( + + patchActions({ color: on ? "#808080" : undefined })} + > + patchActions({ color })} + /> + + patchActions({ lineweight: on ? 0.35 : undefined })} + > + patchActions({ lineweight: Math.max(0, lineweight) })} + step={0.05} + min={0} + list="ov-pen-weights" + /> + + + patchActions({ + linetypeId: on ? project.lineStyles[0]?.id : undefined, + }) + } + > + + patchActions({ linetypeId: v === NONE ? undefined : v }) + } + options={lineOptions} + /> + +
+ ); +} + +/** Tab „Overrides": Regelliste (Priorität von oben nach unten) + Detail. */ +function OverridesTab({ + project, + onSetOverrideRules, +}: { + project: Project; + onSetOverrideRules?: (rules: OverrideRule[]) => void; +}) { + const rules = project.overrideRules ?? []; + const [selectedId, setSelectedId] = useState(null); + const selected = rules.find((r) => r.id === selectedId) ?? rules[0]; + + const setRules = (next: OverrideRule[]) => onSetOverrideRules?.(next); + const patchRule = (id: string, patch: Partial) => + setRules(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); + const addRule = () => { + const rule: OverrideRule = { + id: `ov-${Date.now()}`, + name: t("resources.overrides.defaultName"), + enabled: true, + condition: { type: "layer_name", operator: "contains", value: "" }, + actions: {}, + }; + setRules([...rules, rule]); + setSelectedId(rule.id); + }; + const deleteRule = (id: string) => setRules(rules.filter((r) => r.id !== id)); + const moveRule = (id: string, dir: -1 | 1) => { + const i = rules.findIndex((r) => r.id === id); + const j = i + dir; + if (i < 0 || j < 0 || j >= rules.length) return; + const next = rules.slice(); + [next[i], next[j]] = [next[j], next[i]]; + setRules(next); + }; + + return ( +
+
{t("resources.overrides.hint")}
+
+
+ {rules.map((r) => ( +
setSelectedId(r.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setSelectedId(r.id); + }} + > + e.stopPropagation()} + onChange={(e) => patchRule(r.id, { enabled: e.target.checked })} + /> + + {r.name || t("resources.overrides.placeholder")} + + {overrideConditionSummary(r)} + + + + +
+ ))} + {rules.length === 0 && ( +
{t("resources.overrides.empty")}
+ )} +
+ +
+
+
+ {selected ? ( + patchRule(selected.id, patch)} + onDelete={() => deleteRule(selected.id)} + /> + ) : ( +
{t("resources.overrides.empty")}
+ )} +
+
+
+ ); +} + // ── Gemeinsame kleine Bausteine ──────────────────────────────────────────── function EmptyState({ text }: { text: string }) { @@ -2039,7 +2863,11 @@ type TabId = | "lines" | "wallStyles" | "ceilingStyles" - | "materials"; + | "doorStyles" + | "windowStyles" + | "stairStyles" + | "materials" + | "overrides"; const TABS: { id: TabId; labelKey: string }[] = [ { id: "components", labelKey: "resources.tab.components" }, @@ -2047,7 +2875,11 @@ const TABS: { id: TabId; labelKey: string }[] = [ { id: "lines", labelKey: "resources.tab.lines" }, { id: "wallStyles", labelKey: "resources.tab.wallStyles" }, { id: "ceilingStyles", labelKey: "resources.tab.ceilingStyles" }, + { id: "doorStyles", labelKey: "resources.tab.doorStyles" }, + { id: "windowStyles", labelKey: "resources.tab.windowStyles" }, + { id: "stairStyles", labelKey: "resources.tab.stairStyles" }, { id: "materials", labelKey: "resources.tab.materials" }, + { id: "overrides", labelKey: "resources.tab.overrides" }, ]; /** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */ @@ -2072,6 +2904,26 @@ export interface ResourceManagerHandlers { /** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */ onImportLineStyles: (styles: Omit[]) => void; onImportHatches: (hatches: Omit[]) => void; + /** + * Grafische Override-Regeln: ersetzt die GANZE Regelliste (Reihenfolge = + * Priorität). Optional, damit bestehende Einbettungen (z. B. ResourcesPanel) + * ohne Änderung weiter kompilieren — ohne Handler ist der Tab nur lesend. + */ + onSetOverrideRules?: (rules: OverrideRule[]) => void; + /** + * Bauteil-Typen (Tür/Fenster/Treppe) — Bibliotheks-CRUD, analog Wand-/ + * Deckentypen. Optional, damit bestehende Einbettungen (z. B. ResourcesPanel) + * ohne Änderung weiter kompilieren; ohne Handler bleiben die Tabs verborgen. + */ + onAddDoorType?: () => void; + onPatchDoorType?: (id: string, patch: Partial) => void; + onDeleteDoorType?: (id: string) => void; + onAddWindowType?: () => void; + onPatchWindowType?: (id: string, patch: Partial) => void; + onDeleteWindowType?: (id: string) => void; + onAddStairType?: () => void; + onPatchStairType?: (id: string, patch: Partial) => void; + onDeleteStairType?: (id: string) => void; } // ── Schwebendes Fenster (nicht-modal) ────────────────────────────────────── @@ -2265,7 +3117,37 @@ export function ResourceManager({ onDeleteCeilingType={handlers.onDeleteCeilingType} /> )} + {tab === "doorStyles" && ( + + )} + {tab === "windowStyles" && ( + + )} + {tab === "stairStyles" && ( + + )} {tab === "materials" && } + {tab === "overrides" && ( + + )}
{/* Greifer unten rechts (Größe ändern). */} diff --git a/src/ui/SettingsDialog.tsx b/src/ui/SettingsDialog.tsx index fdc92cf..6345eb2 100644 --- a/src/ui/SettingsDialog.tsx +++ b/src/ui/SettingsDialog.tsx @@ -10,6 +10,7 @@ // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; import { t } from "../i18n"; import { ACCENT_SWATCHES } from "../theme/accents"; import type { Project } from "../model/types"; @@ -32,6 +33,8 @@ export interface SettingsDialogProps { * Live-Remount der Renderer-Hooks). */ onRendererModeChange: (mode: RendererMode) => void; + /** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */ + layoutMenu: ReactNode; onClose: () => void; } @@ -45,6 +48,7 @@ export function SettingsDialog({ rendererMode, webGpuAvailable, onRendererModeChange, + layoutMenu, onClose, }: SettingsDialogProps) { useEffect(() => { @@ -98,6 +102,11 @@ export function SettingsDialog({
+
+
{t("settings.section.workspace")}
+ {layoutMenu} +
+
{t("status.renderer")}
diff --git a/src/ui/Toast.tsx b/src/ui/Toast.tsx new file mode 100644 index 0000000..4bfdb90 --- /dev/null +++ b/src/ui/Toast.tsx @@ -0,0 +1,81 @@ +// Toast — nicht-blockierende Kurzmeldungen (Ersatz für `window.alert`, das im +// Tauri-WKWebView deaktiviert ist und lautlos nichts tut, siehe +// state/notifySlice.ts). Liest `notifications` aus dem Store und rendert +// einen gestapelten Container oben rechts; jede Meldung verschwindet +// automatisch nach ~4s oder per Klick sofort. +// +// Auto-Dismiss-Timer sitzt HIER (nicht in der Slice): jede gemountete Meldung +// bekommt so GENAU einen Timer, sauber an ihren Lebenszyklus gebunden (kein +// Doppel-Timer bei erneutem `notify()`-Aufruf mit identischer Nachricht). +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useEffect } from "react"; +import { useStore } from "../state/appStore"; +import { t } from "../i18n"; +import type { NotifyKind } from "../state/notifySlice"; + +const AUTO_DISMISS_MS = 4000; + +/** Top-level Toast-Stack — einmal in App.tsx gerendert. */ +export function Toast() { + const notifications = useStore((s) => s.notifications); + const dismissNotify = useStore((s) => s.dismissNotify); + + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((n) => ( + + ))} +
+ ); +} + +function ToastItem({ + id, + message, + kind, + onDismiss, +}: { + id: string; + message: string; + kind: NotifyKind; + onDismiss: (id: string) => void; +}) { + // Auto-Dismiss: EIN Timer pro gemounteter Meldung, an ihre id gebunden. + useEffect(() => { + const timer = window.setTimeout(() => onDismiss(id), AUTO_DISMISS_MS); + return () => window.clearTimeout(timer); + // onDismiss ist referenzstabil (Store-Action) — bewusst nur auf `id` reagieren. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + return ( +
onDismiss(id)} + role="alert" + > + {message} + +
+ ); +} diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 9be63af..b1de018 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -8,15 +8,18 @@ // aus App, Aktionen melden sich über Callbacks zurück. Bezeichner englisch, // UI-Text/Kommentare deutsch (CONVENTIONS.md). -import type { ReactNode } from "react"; import type { Project } from "../model/types"; import { t } from "../i18n"; import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Dropdown } from "./Dropdown"; import type { DropdownItem, DropdownOption } from "./Dropdown"; +import { PromptDialog } from "./PromptDialog"; import { WindowControls } from "./WindowControls"; import { RibbonTabs } from "./ribbon/RibbonBar"; import type { RibbonTabId } from "./ribbon/ribbonItems"; +import { getCommand } from "../commands/registry"; +import { CommandIcon } from "./ribbon/CommandIcon"; import { applyMark, applyPreset, @@ -58,11 +61,25 @@ export type RenderMode = | "hidden"; /** - * Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front", - * „top", „side", „iso" springen auf die jeweilige Standardansicht; „perspective" - * ist der freie 3/4-Blick. Wirkt nur in der Perspektive. + * Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front"/ + * „back"/„side"/„left" springen auf die vier Kardinalrichtungen (Vorne/Hinten/ + * Rechts/Links — KEINE Himmelsrichtungs-Labels, da es noch keinen Nordwinkel + * gibt); „top" auf die Draufsicht. „iso"/„isoFrontLeft"/„isoBackRight"/ + * „isoBackLeft" sind die vier oberen Iso-Oktanten (untere vier bleiben im + * Hochbau ungenutzt) — „iso" (vorne-oben-rechts) ist der bestehende Default. + * „perspective" ist der freie 3/4-Blick. Wirkt nur in der Perspektive. */ -export type View3d = "front" | "top" | "side" | "iso" | "perspective"; +export type View3d = + | "front" + | "back" + | "side" + | "left" + | "top" + | "iso" + | "isoFrontLeft" + | "isoBackRight" + | "isoBackLeft" + | "perspective"; /** * Farb-Modus des Grundrisses: „color" zeigt Kategorie-/Element-Farben (heutiges @@ -103,6 +120,21 @@ export interface TopBarProps { onExportDxf: () => void; /** Lädt die Bauteilliste des Projekts als CSV herunter. */ onExportSchedule: () => void; + /** Lädt das Projekt als IFC4-STEP-Datei herunter. */ + onExportIfc: () => void; + onExportObj: () => void; + onExportStl: () => void; + + // Kamera-Settings (FOV), ZUSÄTZLICH zur bestehenden im „Ansichten"-Ribbon- + // Tab (ViewRibbonTab) und im 3D-Ribbon-Tab (Ribbon3dTab) — hier direkt neben + // dem CSV-Export in der schmalen Chrome-Zeile, damit sie immer erreichbar + // ist (Nutzer-Wunsch). Dieselben Felder/Handler wie dort. + viewType: ViewType; + fov: number; + onFov: (n: number) => void; + /** Presets nur sinnvoll, solange ein Geschoss aktiv ist (wie `viewToggleEnabled` + * in {@link ViewRibbonTabProps}). */ + cameraEnabled: boolean; // Ressourcen-Fenster. resourcesOpen: boolean; @@ -112,7 +144,6 @@ export interface TopBarProps { onOpenSettings: () => void; /** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */ - layoutMenu: ReactNode; // ── Datei-Menü (Burger, ganz rechts) ─────────────────────────────────────── /** Öffnet den DXF/DWG-Import-Datei-Dialog. */ @@ -200,14 +231,17 @@ function ComboMenu({ // Dropdown gesteuert ist, halten wir die aktuelle Liste im State und // aktualisieren sie beim Trigger-Klick (siehe `items`-Aufbau unten). const [names, setNames] = useState([]); + // Name-Erfassung über PromptDialog statt window.prompt (im Tauri-WKWebView + // deaktiviert — liefert sofort null, nichts würde je gespeichert). + const [saving, setSaving] = useState(false); const refresh = () => setNames(list()); - const save = () => { - const name = window.prompt(savePrompt); - if (!name) return; + const save = () => setSaving(true); + const commitSave = (name: string) => { onSave(name.trim()); refresh(); + setSaving(false); }; // Aktions-Menü (analog ContextMenu-Items): „Aktuelle speichern" oben, dann je @@ -240,6 +274,14 @@ function ComboMenu({ placeholder={t("combo.placeholder")} title={title} /> + {saving && ( + setSaving(false)} + /> + )}
); } @@ -326,6 +368,133 @@ function CameraMenu({ ); } +/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */ +const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"]; + +/** Häkchen vor der aktiven Iso-Zeile (gleiche Glyphe wie `Dropdown`s Check). */ +function IsoCheck() { + return ( + + ); +} + +/** + * Iso-Oktanten als Split-Button (Vectorworks-Stil): Klick auf das Würfel-Icon + * springt direkt auf die zuletzt gewählte Iso-Ansicht (Default „iso" = + * vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover + * mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). + * Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`), + * Positionierung wie `CameraMenu` (fixed, kein Portal — entkommt dem + * `overflow:hidden` der Oberleiste). + */ +function IsoMenu({ + view3d, + onView3d, + viewType, + onViewType, + disabled, +}: { + view3d: View3d; + onView3d: (v: View3d) => void; + viewType: ViewType; + onViewType: (v: ViewType) => void; + disabled: boolean; +}) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); + const wrapRef = useRef(null); + const caretRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso"; + const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d); + + const select = (v: View3d) => { + onView3d(v); + if (viewType !== "perspektive") onViewType("perspektive"); + setOpen(false); + }; + + const toggle = () => { + const next = !open; + if (next && caretRef.current) { + const r = caretRef.current.getBoundingClientRect(); + setPos({ left: r.left, top: r.bottom + 6 }); + } + setOpen(next); + }; + + return ( +
+ + + {open && !disabled && ( +
+ {ISO_VARIANTS.map((v) => ( + + ))} +
+ )} +
+ ); +} + /** * Parst eine Massstab-Eingabe „1:35" ODER „35" zum Nenner N (gerundet, > 0). * Liefert null bei ungültiger/leerer Eingabe. @@ -365,9 +534,19 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) { ); case "front": + // Kardinal-Quartett (front/back/side/left): Quadrat + kleiner Steg an der + // Aussenkante zeigt, von welcher Seite die Kamera blickt (Vorne = unten). return ( + + + ); + case "back": + return ( + + + ); case "top": @@ -381,7 +560,14 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) { return ( - + + + ); + case "left": + return ( + + + ); case "perspective": @@ -515,6 +701,8 @@ export function TextGroup({ const [defBold, setDefBold] = useState(false); const [defItalic, setDefItalic] = useState(false); const [defUnderline, setDefUnderline] = useState(false); + // Freie Grösse über PromptDialog statt window.prompt (Tauri-WKWebView). + const [sizePromptOpen, setSizePromptOpen] = useState(false); // Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das // ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen). @@ -624,17 +812,17 @@ export function TextGroup({ const onSizeChange = (value: string) => { if (value === "__custom__") { - const input = window.prompt( - t("text.size.customPrompt"), - String(curSize > 0 ? curSize : 12), - ); - const n = input == null ? NaN : Math.round(Number(input.trim())); - if (Number.isFinite(n) && n > 0) setSizeValue(n); + setSizePromptOpen(true); return; } if (value === "__current__") return; setSizeValue(Number(value)); }; + const onSizePromptConfirm = (input: string) => { + const n = Math.round(Number(input.trim())); + if (Number.isFinite(n) && n > 0) setSizeValue(n); + setSizePromptOpen(false); + }; return (
@@ -763,6 +951,16 @@ export function TextGroup({
+ {sizePromptOpen && ( + 0 ? curSize : 12)} + type="number" + onConfirm={onSizePromptConfirm} + onClose={() => setSizePromptOpen(false)} + /> + )}
); } @@ -904,6 +1102,200 @@ function QaSep() { * Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile. * Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t). */ +/** + * Kompakte Kamera-Gruppe der Quick-Access-Leiste: NUR das Kamera-Settings- + * Popover (FOV), damit es immer erreichbar ist, ohne die Ansichts-Presets + * (die bleiben unverändert im „Ansichten"-Tab und im 3D-Ribbon-Tab). + */ +function QaCameraGroup({ + viewType, + fov, + onFov, + cameraEnabled, +}: { + viewType: ViewType; + fov: number; + onFov: (n: number) => void; + cameraEnabled: boolean; +}) { + return ( +
+ +
+ ); +} + +/** App-Version (spiegelt src-tauri/tauri.conf.json). */ +const APP_VERSION = "0.1.0"; + +/** + * Export-Sammelmenü: EIN „Export"-Knopf in der Quick-Access-Leiste öffnet ein + * Dropdown (wie ein Kontextmenü) mit allen Export-Formaten — ersetzt die früher + * einzeln aufgereihten Export-Icons. Popover fixed positioniert (die Oberleiste + * hat overflow:hidden), Muster wie {@link CameraMenu}. + */ +function ExportMenu({ + onExportPdf, + onExportDxf, + onExportSchedule, + onExportIfc, + onExportObj, + onExportStl, + exportEnabled, +}: { + onExportPdf: () => void; + onExportDxf: () => void; + onExportSchedule: () => void; + onExportIfc: () => void; + onExportObj: () => void; + onExportStl: () => void; + exportEnabled: boolean; +}) { + const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); + const wrapRef = useRef(null); + const btnRef = useRef(null); + // Das Popover hängt per Portal an body (NICHT in wrapRef) — der Outside-Handler + // muss es daher separat als „innen" behandeln, sonst schliesst der mousedown + // das Menü vor dem Klick. + const popRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (wrapRef.current?.contains(target)) return; + if (popRef.current?.contains(target)) return; + setOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + const toggle = () => { + const next = !open; + if (next && btnRef.current) { + const r = btnRef.current.getBoundingClientRect(); + setPos({ left: r.left, top: r.bottom + 6 }); + } + setOpen(next); + }; + const pick = (fn: () => void) => { + setOpen(false); + fn(); + }; + + const items: { icon: string; label: string; onClick: () => void; disabled?: boolean }[] = [ + { icon: "picture_as_pdf", label: t("file.exportPdf"), onClick: onExportPdf, disabled: !exportEnabled }, + { icon: "output", label: t("file.exportDxf"), onClick: onExportDxf, disabled: !exportEnabled }, + { icon: "table_view", label: t("file.exportSchedule"), onClick: onExportSchedule }, + { icon: "deployed_code", label: t("file.exportIfc"), onClick: onExportIfc }, + { icon: "view_in_ar", label: t("file.exportObj"), onClick: onExportObj }, + { icon: "landscape", label: t("file.exportStl"), onClick: onExportStl }, + ]; + + return ( +
+ + {open && + createPortal( +
+ {items.map((it) => ( + + ))} +
, + document.body, + )} +
+ ); +} + +/** + * „Über"-Dialog (In-App, dunkel) — App-Marke, Version, Kurzbeschreibung und die + * Lizenzen der wichtigsten Open-Source-Bausteine. Öffnet per Klick auf die + * Wortmarke. Klick ausserhalb / Esc schliesst. + */ +function AboutDialog({ onClose }: { onClose: () => void }) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > +
+ dossier. +
+
+ {t("about.version")} {APP_VERSION} +
+

{t("about.desc")}

+
+
{t("about.copyright")}
+
{t("about.license")}
+
{t("about.suite")}
+
+
{t("about.licenses")}
+
    +
  • React · Vite — MIT
  • +
  • Tauri — MIT / Apache-2.0
  • +
  • wgpu (render3d) — MIT / Apache-2.0
  • +
  • truck · csgrs — Apache-2.0
  • +
  • OpenCascade.js — LGPL-2.1
  • +
  • LibreDWG — GPL-3.0
  • +
  • acadrust — MPL-2.0
  • +
+ +
+
+ ); +} + function QuickAccess({ onOpenProject, onSaveProject, @@ -911,10 +1303,17 @@ function QuickAccess({ onExportPdf, onExportDxf, onExportSchedule, + onExportIfc, + onExportObj, + onExportStl, onToggleResources, onOpenSettings, resourcesOpen, exportEnabled, + viewType, + fov, + onFov, + cameraEnabled, }: { onOpenProject: () => void; onSaveProject: () => void; @@ -922,10 +1321,17 @@ function QuickAccess({ onExportPdf: () => void; onExportDxf: () => void; onExportSchedule: () => void; + onExportIfc: () => void; + onExportObj: () => void; + onExportStl: () => void; onToggleResources: () => void; onOpenSettings: () => void; resourcesOpen: boolean; exportEnabled: boolean; + viewType: ViewType; + fov: number; + onFov: (n: number) => void; + cameraEnabled: boolean; }) { return (
@@ -933,22 +1339,21 @@ function QuickAccess({ - - - + void; view3d: View3d; onView3d: (v: View3d) => void; - fov: number; - onFov: (n: number) => void; detail: DetailLevel; onDetail: (d: DetailLevel) => void; detailEnabled: boolean; @@ -1009,8 +1412,6 @@ export function ViewRibbonTab({ onViewType, view3d, onView3d, - fov, - onFov, detail, onDetail, detailEnabled, @@ -1030,20 +1431,22 @@ export function ViewRibbonTab({ combos, textTarget, }: ViewRibbonTabProps) { - // „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35". + // „frei" wird über einen In-App-Dialog erfasst (window.prompt ist im Tauri- + // WKWebView deaktiviert); akzeptiert „1:35" ODER „35". + const [scalePromptOpen, setScalePromptOpen] = useState(false); const onScaleChange = (value: string) => { if (value === "__custom__") { - const input = window.prompt( - t("topbar.scale.prompt"), - `1:${scaleDenominator}`, - ); - const n = parseScaleInput(input); - if (n != null) onApplyScale(n); + setScalePromptOpen(true); return; } const n = Number(value); if (Number.isFinite(n) && n > 0) onApplyScale(n); }; + const onScalePromptConfirm = (input: string) => { + const n = parseScaleInput(input); + if (n != null) onApplyScale(n); + setScalePromptOpen(false); + }; const inPresets = SCALE_PRESETS.includes(scaleDenominator); const scaleOptions: DropdownOption[] = [ ...(inPresets @@ -1080,35 +1483,43 @@ export function ViewRibbonTab({ ["perspective", t("view3d.perspective")], ["front", t("view3d.front")], ["side", t("view3d.side")], + ["back", t("view3d.back")], + ["left", t("view3d.left")], ] as [View3d, string][] - ).map(([key, label]) => ( - - ))} - + ).map(([key, label]) => + key === "iso" ? ( + + ) : ( + + ), + )}
@@ -1140,8 +1551,9 @@ export function ViewRibbonTab({
- {/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */} -
+ {/* Detailgrad (grob/mittel/fein) + Darstellungsart übereinander gestapelt + (Nutzer-Wunsch) — gleiches Stapel-Muster wie die Kombinationen. */} +
+
@@ -1221,43 +1661,20 @@ export function ViewRibbonTab({
- {/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */} -
- -
- {/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe, formatiert das aktuelle Text-Ziel (Raumstempel) live. */} + {scalePromptOpen && ( + setScalePromptOpen(false)} + /> + )}
); } @@ -1268,11 +1685,11 @@ export interface Ribbon3dTabProps { onViewType: (v: ViewType) => void; view3d: View3d; onView3d: (v: View3d) => void; - fov: number; - onFov: (n: number) => void; renderMode: RenderMode; onRenderMode: (m: RenderMode) => void; renderModeEnabled: boolean; + onRunCommand: (name: string) => void; + activeCommand: string | null; } /** 3D-Tab des Ribbons — Kamera-Presets + Darstellungsart. Kein Duplikat des @@ -1283,11 +1700,11 @@ export function Ribbon3dTab({ onViewType, view3d, onView3d, - fov, - onFov, renderMode, onRenderMode, renderModeEnabled, + onRunCommand, + activeCommand, }: Ribbon3dTabProps) { return (
@@ -1309,27 +1726,35 @@ export function Ribbon3dTab({ ["perspective", t("view3d.perspective")], ["front", t("view3d.front")], ["side", t("view3d.side")], + ["back", t("view3d.back")], + ["left", t("view3d.left")], ] as [View3d, string][] - ).map(([key, label]) => ( - - ))} - + ).map(([key, label]) => + key === "iso" ? ( + + ) : ( + + ), + )}
@@ -1351,6 +1776,26 @@ export function Ribbon3dTab({ />
+ +
+ {(() => { + const cmd = getCommand("extrude"); + const label = cmd ? t(cmd.labelKey) : "extrude"; + const active = activeCommand === "extrude"; + return ( + + ); + })()} +
); } @@ -1363,10 +1808,16 @@ export function TopBar({ onExportPdf, onExportDxf, onExportSchedule, + onExportIfc, + onExportObj, + onExportStl, + viewType, + fov, + onFov, + cameraEnabled, resourcesOpen, onToggleResources, onOpenSettings, - layoutMenu, onImport, onSaveProject, onOpenProject, @@ -1393,6 +1844,8 @@ export function TopBar({ // Unter Tauri (native macOS-Ampeln oben links via titleBarStyle "Overlay") // braucht die Zeile links Platz, damit `dossier.` nicht darunter liegt. + const [aboutOpen, setAboutOpen] = useState(false); + const tauriNativeTitlebar = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -1415,10 +1868,17 @@ export function TopBar({ Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
- +
+ {aboutOpen && setAboutOpen(false)} />} @@ -1439,11 +1906,10 @@ export function TopBar({ Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */} - {/* Rechte Gruppe: Layout-Menü · Projektname. Die Fensterknöpfe sitzen jetzt - ganz links (macOS-Titelleisten-Konvention); Datei-/Export-Aktionen in - der Quick-Access-Leiste (kein Burger). */} + {/* Rechte Gruppe: Projektname. Die Fensterknöpfe sitzen ganz links (macOS- + Titelleisten-Konvention); Datei-/Export-Aktionen in der Quick-Access- + Leiste; die Arbeitsumgebung (Layout) liegt jetzt in den Einstellungen. */}
- {layoutMenu} {project.name}
diff --git a/src/ui/layoutSheetMath.test.ts b/src/ui/layoutSheetMath.test.ts new file mode 100644 index 0000000..1eb052f --- /dev/null +++ b/src/ui/layoutSheetMath.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; +import type { LayoutAnnotation, LayoutViewport } from "../model/types"; +import { + annotationBoundsMm, + applyHandleDrag, + clampRectToSheet, + distanceToSegment, + fitTransform, + handleAt, + mmToScreen, + normalizeRect, + panBy, + pickAnnotation, + pickViewport, + screenToMm, + translateAnnotation, + vpRect, + zoomAtCursor, + type RectMm, + type SheetTransform, +} from "./layoutSheetMath"; + +const vp = ( + id: string, + xMm: number, + yMm: number, + widthMm: number, + heightMm: number, +): LayoutViewport => ({ id, snapshotId: "s", xMm, yMm, widthMm, heightMm }); + +describe("mm ↔ screen", () => { + const t: SheetTransform = { scale: 2, tx: 100, ty: 50 }; + it("mmToScreen applies scale + translate", () => { + expect(mmToScreen(t, { xMm: 10, yMm: 20 })).toEqual({ x: 120, y: 90 }); + }); + it("screenToMm is the inverse", () => { + const p = screenToMm(t, 120, 90); + expect(p.xMm).toBeCloseTo(10); + expect(p.yMm).toBeCloseTo(20); + }); + it("roundtrips arbitrary points", () => { + const s = mmToScreen(t, { xMm: 37.5, yMm: -12.25 }); + const m = screenToMm(t, s.x, s.y); + expect(m.xMm).toBeCloseTo(37.5); + expect(m.yMm).toBeCloseTo(-12.25); + }); +}); + +describe("fitTransform", () => { + it("centres an A4 portrait sheet in a wide canvas", () => { + const t = fitTransform(210, 297, 1000, 600, 0.92); + // Höhe ist der Engpass: scale = 600*0.92/297. + expect(t.scale).toBeCloseTo((600 * 0.92) / 297); + // Zentriert: linker Rand = (1000 - 210*scale)/2. + expect(t.tx).toBeCloseTo((1000 - 210 * t.scale) / 2); + expect(t.ty).toBeCloseTo((600 - 297 * t.scale) / 2); + }); + it("falls back to scale 1 on degenerate canvas", () => { + const t = fitTransform(210, 297, 0, 0); + expect(t.scale).toBe(1); + }); +}); + +describe("zoomAtCursor", () => { + it("keeps the mm point under the cursor fixed", () => { + const t: SheetTransform = { scale: 2, tx: 100, ty: 50 }; + const cursor = { x: 300, y: 220 }; + const before = screenToMm(t, cursor.x, cursor.y); + const zt = zoomAtCursor(t, 1.25, cursor.x, cursor.y); + expect(zt.scale).toBeCloseTo(2.5); + const after = screenToMm(zt, cursor.x, cursor.y); + expect(after.xMm).toBeCloseTo(before.xMm); + expect(after.yMm).toBeCloseTo(before.yMm); + }); + it("clamps scale to bounds", () => { + const t: SheetTransform = { scale: 30, tx: 0, ty: 0 }; + const zt = zoomAtCursor(t, 4, 0, 0); + expect(zt.scale).toBeLessThanOrEqual(40); + }); +}); + +describe("panBy", () => { + it("shifts translation, keeps scale", () => { + const t = panBy({ scale: 3, tx: 10, ty: 20 }, 5, -7); + expect(t).toEqual({ scale: 3, tx: 15, ty: 13 }); + }); +}); + +describe("handleAt", () => { + const r: RectMm = { xMm: 20, yMm: 20, widthMm: 100, heightMm: 60 }; + it("detects corners with priority over edges", () => { + expect(handleAt(r, 20, 20, 3)).toBe("nw"); + expect(handleAt(r, 120, 80, 3)).toBe("se"); + }); + it("detects edges", () => { + expect(handleAt(r, 70, 20, 3)).toBe("n"); + expect(handleAt(r, 120, 50, 3)).toBe("e"); + expect(handleAt(r, 20, 50, 3)).toBe("w"); + expect(handleAt(r, 70, 80, 3)).toBe("s"); + }); + it("detects the body inside", () => { + expect(handleAt(r, 70, 50, 3)).toBe("body"); + }); + it("returns null well outside", () => { + expect(handleAt(r, 5, 5, 3)).toBe(null); + expect(handleAt(r, 200, 200, 3)).toBe(null); + }); +}); + +describe("pickViewport", () => { + const vps = [vp("a", 0, 0, 100, 100), vp("b", 40, 40, 100, 100)]; + it("returns the topmost (last) viewport in overlap", () => { + const hit = pickViewport(vps, 60, 60, 2); + expect(hit?.id).toBe("b"); + expect(hit?.handle).toBe("body"); + }); + it("returns the lower one where only it is hit", () => { + const hit = pickViewport(vps, 10, 10, 2); + expect(hit?.id).toBe("a"); + }); + it("returns null on empty space", () => { + expect(pickViewport(vps, 300, 300, 2)).toBe(null); + }); +}); + +describe("applyHandleDrag", () => { + const start: RectMm = { xMm: 20, yMm: 20, widthMm: 100, heightMm: 60 }; + it("moves the whole rect for body", () => { + expect(applyHandleDrag(start, "body", 10, -5)).toEqual({ + xMm: 30, + yMm: 15, + widthMm: 100, + heightMm: 60, + }); + }); + it("drags the east edge (width only)", () => { + const r = applyHandleDrag(start, "e", 25, 0); + expect(r).toEqual({ xMm: 20, yMm: 20, widthMm: 125, heightMm: 60 }); + }); + it("drags the west edge (x + width, right edge fixed)", () => { + const r = applyHandleDrag(start, "w", 30, 0); + expect(r.xMm).toBe(50); + expect(r.widthMm).toBe(70); + expect(r.xMm + r.widthMm).toBe(120); // rechte Kante unverändert + }); + it("enforces min width when dragging west past the limit", () => { + const r = applyHandleDrag(start, "w", 200, 0, 5); + expect(r.widthMm).toBe(5); + expect(r.xMm + r.widthMm).toBe(120); + }); + it("drags a corner on both axes", () => { + const r = applyHandleDrag(start, "se", 10, 20); + expect(r).toEqual({ xMm: 20, yMm: 20, widthMm: 110, heightMm: 80 }); + }); + it("enforces min height dragging north past the limit", () => { + const r = applyHandleDrag(start, "n", 0, 200, 5); + expect(r.heightMm).toBe(5); + expect(r.yMm + r.heightMm).toBe(80); + }); +}); + +describe("normalizeRect", () => { + it("orders corners into min + positive size", () => { + expect(normalizeRect(120, 90, 20, 20)).toEqual({ + xMm: 20, + yMm: 20, + widthMm: 100, + heightMm: 70, + }); + }); +}); + +describe("clampRectToSheet", () => { + it("keeps an in-bounds rect unchanged", () => { + const r: RectMm = { xMm: 10, yMm: 10, widthMm: 50, heightMm: 50 }; + expect(clampRectToSheet(r, 210, 297)).toEqual(r); + }); + it("pushes an overhanging rect back inside", () => { + const r: RectMm = { xMm: 200, yMm: 280, widthMm: 50, heightMm: 50 }; + const c = clampRectToSheet(r, 210, 297); + expect(c.xMm).toBe(160); + expect(c.yMm).toBe(247); + expect(c.widthMm).toBe(50); + }); + it("shrinks a rect larger than the sheet", () => { + const r: RectMm = { xMm: -20, yMm: -20, widthMm: 400, heightMm: 500 }; + const c = clampRectToSheet(r, 210, 297); + expect(c).toEqual({ xMm: 0, yMm: 0, widthMm: 210, heightMm: 297 }); + }); +}); + +describe("vpRect", () => { + it("projects a viewport to its rect", () => { + expect(vpRect(vp("x", 5, 6, 7, 8))).toEqual({ + xMm: 5, + yMm: 6, + widthMm: 7, + heightMm: 8, + }); + }); +}); + +// ── Annotationen (Linie/Rechteck/Text) ────────────────────────────────────── + +const line = (id: string, x1: number, y1: number, x2: number, y2: number): LayoutAnnotation => ({ + id, + kind: "line", + x1Mm: x1, + y1Mm: y1, + x2Mm: x2, + y2Mm: y2, +}); +const rect = (id: string, x: number, y: number, w: number, h: number): LayoutAnnotation => ({ + id, + kind: "rect", + xMm: x, + yMm: y, + widthMm: w, + heightMm: h, +}); +const text = (id: string, x: number, y: number, t: string, h = 3.5): LayoutAnnotation => ({ + id, + kind: "text", + xMm: x, + yMm: y, + text: t, + heightMm: h, +}); + +describe("distanceToSegment", () => { + it("returns perpendicular distance for a point beside the segment", () => { + expect(distanceToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(3); + }); + it("clamps to the nearest endpoint beyond the segment", () => { + expect(distanceToSegment(-4, 0, 0, 0, 10, 0)).toBeCloseTo(4); + expect(distanceToSegment(14, 0, 0, 0, 10, 0)).toBeCloseTo(4); + }); + it("degenerates to point distance for a zero-length segment", () => { + expect(distanceToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(5); + }); +}); + +describe("annotationBoundsMm", () => { + it("rect: its own rectangle", () => { + expect(annotationBoundsMm(rect("r", 10, 20, 30, 40))).toEqual({ + xMm: 10, + yMm: 20, + widthMm: 30, + heightMm: 40, + }); + }); + it("line: normalized bounding rect of both endpoints", () => { + expect(annotationBoundsMm(line("l", 30, 30, 10, 10))).toEqual({ + xMm: 10, + yMm: 10, + widthMm: 20, + heightMm: 20, + }); + }); + it("text: box sits above the baseline, width grows with character count", () => { + const b = annotationBoundsMm(text("t", 10, 20, "AB", 4)); + expect(b.xMm).toBe(10); + expect(b.yMm).toBe(16); // 20 - heightMm + expect(b.heightMm).toBeCloseTo(5); // heightMm * 1.25 + expect(b.widthMm).toBeGreaterThan(0); + }); +}); + +describe("pickAnnotation", () => { + it("hits a line within tolerance of the segment", () => { + expect(pickAnnotation([line("l", 0, 0, 10, 0)], 5, 1, 2)).toBe("l"); + expect(pickAnnotation([line("l", 0, 0, 10, 0)], 5, 5, 2)).toBe(null); + }); + it("hits a rect on its border and its interior", () => { + const r = rect("r", 20, 20, 10, 10); + expect(pickAnnotation([r], 20, 20, 1)).toBe("r"); // corner + expect(pickAnnotation([r], 25, 25, 1)).toBe("r"); // interior + expect(pickAnnotation([r], 100, 100, 1)).toBe(null); + }); + it("hits a text annotation near its baseline box", () => { + const tx = text("t", 10, 20, "Hallo", 4); + expect(pickAnnotation([tx], 12, 18, 1)).toBe("t"); + expect(pickAnnotation([tx], 200, 200, 1)).toBe(null); + }); + it("returns the topmost (last) annotation in overlap, like pickViewport", () => { + const overlapping = [rect("a", 0, 0, 50, 50), rect("b", 10, 10, 50, 50)]; + expect(pickAnnotation(overlapping, 30, 30, 1)).toBe("b"); + }); +}); + +describe("translateAnnotation", () => { + it("moves both endpoints of a line", () => { + const moved = translateAnnotation(line("l", 0, 0, 10, 10), 5, -5); + expect(moved).toMatchObject({ x1Mm: 5, y1Mm: -5, x2Mm: 15, y2Mm: 5 }); + }); + it("moves the origin of a rect", () => { + const moved = translateAnnotation(rect("r", 10, 10, 20, 20), 3, 4); + expect(moved).toMatchObject({ xMm: 13, yMm: 14, widthMm: 20, heightMm: 20 }); + }); + it("moves the anchor point of a text annotation", () => { + const moved = translateAnnotation(text("t", 10, 10, "Hi"), 1, 2); + expect(moved).toMatchObject({ xMm: 11, yMm: 12, text: "Hi" }); + }); +}); diff --git a/src/ui/layoutSheetMath.ts b/src/ui/layoutSheetMath.ts new file mode 100644 index 0000000..cc453c0 --- /dev/null +++ b/src/ui/layoutSheetMath.ts @@ -0,0 +1,429 @@ +// Reine, testbare Geometrie für die In-Viewport-Layout-Ansicht (Phase 2b): +// Bildschirm↔Blatt-mm-Transform (Pan/Zoom-to-Cursor/Einpassen), Viewport- +// Trefferlogik (Body + 8 Resize-Griffe), Resize-Mathe mit Mindestgrösse, +// Rechteck-Normalisierung (Aufziehen) und Clamp an die Blattkanten. +// +// KEINE React-/DOM-Abhängigkeit — nur Zahlen. Das UI (LayoutSheet.tsx) baut +// darauf auf. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { LayoutAnnotation, LayoutViewport } from "../model/types"; + +/** + * Affine Blatt-Transform: Bildschirm-Pixel = mm · scale + translate. + * `scale` ist px pro mm (immer > 0), `tx/ty` die Blatt-Ursprungslage (mm 0,0) + * in Canvas-Pixeln. + */ +export interface SheetTransform { + /** Pixel pro Millimeter (> 0). */ + scale: number; + /** Bildschirm-X des Blatt-Ursprungs (mm x=0) in Canvas-Pixeln. */ + tx: number; + /** Bildschirm-Y des Blatt-Ursprungs (mm y=0) in Canvas-Pixeln. */ + ty: number; +} + +/** Ein Punkt in Blatt-Millimetern. */ +export interface Mm { + xMm: number; + yMm: number; +} + +/** Ein achsparalleles Rechteck in Blatt-Millimetern. */ +export interface RectMm { + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; +} + +/** Resize-Griff (8 Ränder) oder Körper (verschieben) eines Viewports. */ +export type Handle = + | "nw" + | "n" + | "ne" + | "e" + | "se" + | "s" + | "sw" + | "w" + | "body"; + +export const MIN_SCALE = 0.05; +export const MAX_SCALE = 40; + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +// ── Transform ──────────────────────────────────────────────────────────────── + +/** Blatt-mm → Canvas-Pixel. */ +export function mmToScreen(t: SheetTransform, p: Mm): { x: number; y: number } { + return { x: p.xMm * t.scale + t.tx, y: p.yMm * t.scale + t.ty }; +} + +/** Canvas-Pixel → Blatt-mm. */ +export function screenToMm(t: SheetTransform, sx: number, sy: number): Mm { + return { xMm: (sx - t.tx) / t.scale, yMm: (sy - t.ty) / t.scale }; +} + +/** + * Einpass-Transform: Blatt (mm) zentriert in die Canvas-Fläche (px), mit + * `margin` (Anteil, z. B. 0.92 = 8 % Rand). Degenerierte Eingaben → scale=1. + */ +export function fitTransform( + sheetWMm: number, + sheetHMm: number, + canvasWPx: number, + canvasHPx: number, + margin = 0.92, +): SheetTransform { + const sx = (canvasWPx * margin) / sheetWMm; + const sy = (canvasHPx * margin) / sheetHMm; + let scale = Math.min(sx, sy); + if (!Number.isFinite(scale) || scale <= 0) scale = 1; + scale = clamp(scale, MIN_SCALE, MAX_SCALE); + const tx = (canvasWPx - sheetWMm * scale) / 2; + const ty = (canvasHPx - sheetHMm * scale) / 2; + return { scale, tx, ty }; +} + +/** + * Zoom um den Cursor: der mm-Punkt unter (sx,sy) bleibt fix. `factor` > 1 zoomt + * rein, < 1 raus; der Massstab wird auf [MIN_SCALE, MAX_SCALE] geklemmt. + */ +export function zoomAtCursor( + t: SheetTransform, + factor: number, + sx: number, + sy: number, +): SheetTransform { + const newScale = clamp(t.scale * factor, MIN_SCALE, MAX_SCALE); + // mm-Punkt unter dem Cursor VOR dem Zoom. + const m = screenToMm(t, sx, sy); + // Neue Translation, damit derselbe mm-Punkt wieder unter (sx,sy) liegt. + return { scale: newScale, tx: sx - m.xMm * newScale, ty: sy - m.yMm * newScale }; +} + +/** Verschiebt die Transform um (dxPx,dyPx) Canvas-Pixel (Pan). */ +export function panBy(t: SheetTransform, dxPx: number, dyPx: number): SheetTransform { + return { scale: t.scale, tx: t.tx + dxPx, ty: t.ty + dyPx }; +} + +// ── Viewport-Rechtecke / Treffer ───────────────────────────────────────────── + +/** Rechteck-Sicht eines Viewports (nur die Blattgeometrie). */ +export function vpRect(vp: LayoutViewport): RectMm { + return { xMm: vp.xMm, yMm: vp.yMm, widthMm: vp.widthMm, heightMm: vp.heightMm }; +} + +/** Punkt-in-Rechteck (mm), inklusive Rand. */ +export function rectContains(r: RectMm, xMm: number, yMm: number): boolean { + return ( + xMm >= r.xMm && + xMm <= r.xMm + r.widthMm && + yMm >= r.yMm && + yMm <= r.yMm + r.heightMm + ); +} + +/** + * Griff (oder Körper) an einem mm-Punkt für EINEN Viewport. `tolMm` ist die + * Fangzone der Ecken/Kanten (in mm). Rückgabe null, wenn ausserhalb des + * Rechtecks (+Toleranz). Ecken haben Vorrang vor Kanten, Kanten vor Körper. + */ +export function handleAt( + r: RectMm, + xMm: number, + yMm: number, + tolMm: number, +): Handle | null { + const x0 = r.xMm; + const y0 = r.yMm; + const x1 = r.xMm + r.widthMm; + const y1 = r.yMm + r.heightMm; + // Ausserhalb des um tol vergrösserten Rechtecks → kein Treffer. + if (xMm < x0 - tolMm || xMm > x1 + tolMm || yMm < y0 - tolMm || yMm > y1 + tolMm) { + return null; + } + const nearL = Math.abs(xMm - x0) <= tolMm; + const nearR = Math.abs(xMm - x1) <= tolMm; + const nearT = Math.abs(yMm - y0) <= tolMm; + const nearB = Math.abs(yMm - y1) <= tolMm; + // Innerhalb der vertikalen/horizontalen Spanne (für Kanten-Treffer). + const inX = xMm >= x0 - tolMm && xMm <= x1 + tolMm; + const inY = yMm >= y0 - tolMm && yMm <= y1 + tolMm; + // Ecken zuerst. + if (nearL && nearT) return "nw"; + if (nearR && nearT) return "ne"; + if (nearR && nearB) return "se"; + if (nearL && nearB) return "sw"; + // Kanten. + if (nearT && inX) return "n"; + if (nearB && inX) return "s"; + if (nearL && inY) return "w"; + if (nearR && inY) return "e"; + // Körper (echt innen). + if (rectContains(r, xMm, yMm)) return "body"; + return null; +} + +/** + * Topmost-Treffer über eine Viewport-Liste (letzte = oberste, wie Z-Order der + * Darstellung). Liefert Id + Griff des ERSTEN Treffers von oben, sonst null. + */ +export function pickViewport( + viewports: LayoutViewport[], + xMm: number, + yMm: number, + tolMm: number, +): { id: string; handle: Handle } | null { + for (let i = viewports.length - 1; i >= 0; i--) { + const vp = viewports[i]; + const h = handleAt(vpRect(vp), xMm, yMm, tolMm); + if (h) return { id: vp.id, handle: h }; + } + return null; +} + +// ── Resize / Move ──────────────────────────────────────────────────────────── + +/** + * Wendet einen Griff-Drag auf ein Start-Rechteck an: `handle` bestimmt, welche + * Kanten mit (dxMm,dyMm) wandern. Körper verschiebt das ganze Rechteck. Die + * Mindestbreite/-höhe `minMm` wird eingehalten (die feste Kante bleibt fix). + */ +export function applyHandleDrag( + start: RectMm, + handle: Handle, + dxMm: number, + dyMm: number, + minMm = 5, +): RectMm { + if (handle === "body") { + return { ...start, xMm: start.xMm + dxMm, yMm: start.yMm + dyMm }; + } + let { xMm, yMm, widthMm, heightMm } = start; + const movesLeft = handle === "nw" || handle === "w" || handle === "sw"; + const movesRight = handle === "ne" || handle === "e" || handle === "se"; + const movesTop = handle === "nw" || handle === "n" || handle === "ne"; + const movesBottom = handle === "sw" || handle === "s" || handle === "se"; + + if (movesLeft) { + // Rechte Kante fix bei start.xMm + start.widthMm. + const right = start.xMm + start.widthMm; + let nx = start.xMm + dxMm; + if (right - nx < minMm) nx = right - minMm; + xMm = nx; + widthMm = right - nx; + } else if (movesRight) { + let nw = start.widthMm + dxMm; + if (nw < minMm) nw = minMm; + widthMm = nw; + } + + if (movesTop) { + const bottom = start.yMm + start.heightMm; + let ny = start.yMm + dyMm; + if (bottom - ny < minMm) ny = bottom - minMm; + yMm = ny; + heightMm = bottom - ny; + } else if (movesBottom) { + let nh = start.heightMm + dyMm; + if (nh < minMm) nh = minMm; + heightMm = nh; + } + + return { xMm, yMm, widthMm, heightMm }; +} + +/** + * Normalisiert ein aufgezogenes Rechteck aus zwei Eckpunkten (mm) — Min-Ecke + + * absolute Breite/Höhe. Für die „Rechteck aufziehen"-Platzierung. + */ +export function normalizeRect( + x0: number, + y0: number, + x1: number, + y1: number, +): RectMm { + return { + xMm: Math.min(x0, x1), + yMm: Math.min(y0, y1), + widthMm: Math.abs(x1 - x0), + heightMm: Math.abs(y1 - y0), + }; +} + +/** + * Klemmt ein Rechteck so, dass es ganz auf dem Blatt (0..sheet) liegt. Ist das + * Rechteck breiter/höher als das Blatt, wird es auf Blattgrösse gestutzt und an + * (0,0) gelegt. Sonst bleibt die Grösse erhalten und nur die Position wandert. + */ +export function clampRectToSheet( + r: RectMm, + sheetWMm: number, + sheetHMm: number, +): RectMm { + let { xMm, yMm, widthMm, heightMm } = r; + if (widthMm > sheetWMm) { + widthMm = sheetWMm; + xMm = 0; + } else { + xMm = clamp(xMm, 0, sheetWMm - widthMm); + } + if (heightMm > sheetHMm) { + heightMm = sheetHMm; + yMm = 0; + } else { + yMm = clamp(yMm, 0, sheetHMm - heightMm); + } + return { xMm, yMm, widthMm, heightMm }; +} + +/** CSS-Cursor je Resize-Griff (Körper = move). */ +export function handleCursor(handle: Handle): string { + switch (handle) { + case "nw": + case "se": + return "nwse-resize"; + case "ne": + case "sw": + return "nesw-resize"; + case "n": + case "s": + return "ns-resize"; + case "e": + case "w": + return "ew-resize"; + default: + return "move"; + } +} + +/** + * Die 8 Griff-Anker eines Rechtecks in mm (für die Darstellung der Griffe). + * Reihenfolge passend zu {@link Handle} (ohne „body"). + */ +export function handleAnchors(r: RectMm): { handle: Handle; xMm: number; yMm: number }[] { + const { xMm: x, yMm: y, widthMm: w, heightMm: h } = r; + const cx = x + w / 2; + const cy = y + h / 2; + return [ + { handle: "nw", xMm: x, yMm: y }, + { handle: "n", xMm: cx, yMm: y }, + { handle: "ne", xMm: x + w, yMm: y }, + { handle: "e", xMm: x + w, yMm: cy }, + { handle: "se", xMm: x + w, yMm: y + h }, + { handle: "s", xMm: cx, yMm: y + h }, + { handle: "sw", xMm: x, yMm: y + h }, + { handle: "w", xMm: x, yMm: cy }, + ]; +} + +// ── Annotationen (Linie/Rechteck/Text) ────────────────────────────────────── +// Annotationen tragen KEINE Resize-Griffe (nur verschieben + löschen) — daher +// reicht ein einfacher Treffer-Test (kein Handle-Ergebnis wie bei Viewports). + +/** Kürzester Abstand (mm) eines Punkts zu einem Liniensegment. */ +export function distanceToSegment( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number, +): number { + const dx = x2 - x1; + const dy = y2 - y1; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(px - x1, py - y1); + const t = clamp(((px - x1) * dx + (py - y1) * dy) / lenSq, 0, 1); + const cx = x1 + t * dx; + const cy = y1 + t * dy; + return Math.hypot(px - cx, py - cy); +} + +/** + * Umhüllendes Rechteck (mm) einer Annotation — für Text nur eine GROBE Box + * (kein echtes Font-Measuring im SVG; Breite aus Zeichenzahl geschätzt), reicht + * aber für Trefferlogik + Auswahl-Rahmen. `yMm` einer Text-Annotation ist die + * Baseline, die Box liegt daher oberhalb davon. + */ +export function annotationBoundsMm(ann: LayoutAnnotation): RectMm { + if (ann.kind === "rect") { + return { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm }; + } + if (ann.kind === "line") { + return normalizeRect(ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm); + } + const widthMm = Math.max(ann.heightMm, ann.text.length * ann.heightMm * 0.6); + return { + xMm: ann.xMm, + yMm: ann.yMm - ann.heightMm, + widthMm, + heightMm: ann.heightMm * 1.25, + }; +} + +/** + * Trefferlogik (mm) für EINE Annotation: Linie → Abstand zum Segment ≤ tolMm; + * Rechteck → Rahmen ODER Innenfläche (wiederverwendet {@link handleAt}, jeder + * Nicht-null-Rückgabewert zählt als Treffer); Text → Bounding-Box + Toleranz. + */ +export function annotationHit( + ann: LayoutAnnotation, + xMm: number, + yMm: number, + tolMm: number, +): boolean { + if (ann.kind === "line") { + return distanceToSegment(xMm, yMm, ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm) <= tolMm; + } + if (ann.kind === "rect") { + const r: RectMm = { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm }; + return handleAt(r, xMm, yMm, tolMm) !== null; + } + const b = annotationBoundsMm(ann); + return ( + xMm >= b.xMm - tolMm && + xMm <= b.xMm + b.widthMm + tolMm && + yMm >= b.yMm - tolMm && + yMm <= b.yMm + b.heightMm + tolMm + ); +} + +/** + * Topmost-Treffer über eine Annotations-Liste (letzte = oberste, wie + * {@link pickViewport}). Liefert die Id des ERSTEN Treffers von oben, sonst null. + */ +export function pickAnnotation( + annotations: LayoutAnnotation[], + xMm: number, + yMm: number, + tolMm: number, +): string | null { + for (let i = annotations.length - 1; i >= 0; i--) { + const a = annotations[i]; + if (annotationHit(a, xMm, yMm, tolMm)) return a.id; + } + return null; +} + +/** Verschiebt eine Annotation um ein mm-Delta — alle ihre Punkte wandern mit. */ +export function translateAnnotation( + ann: LayoutAnnotation, + dxMm: number, + dyMm: number, +): LayoutAnnotation { + if (ann.kind === "line") { + return { + ...ann, + x1Mm: ann.x1Mm + dxMm, + y1Mm: ann.y1Mm + dyMm, + x2Mm: ann.x2Mm + dxMm, + y2Mm: ann.y2Mm + dyMm, + }; + } + return { ...ann, xMm: ann.xMm + dxMm, yMm: ann.yMm + dyMm }; +} diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx index c85c03b..72dfe08 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -36,6 +36,26 @@ export function CommandIcon({ name }: { name: string }) { ); + case "measure": + // Lineal/Massband: Strecke mit Endmarken + kleiner Skala. + return ( + + + + + + + + ); + case "rotate": + // Kreisbogen mit Pfeil (Drehung) um einen Mittelpunkt. + return ( + + + + + + ); case "mirror": // Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken. return ( @@ -71,6 +91,14 @@ export function CommandIcon({ name }: { name: string }) { ); + case "column": + // Stütze: Quadrat-Querschnitt mit vertikalen Kanten (Pfeiler). + return ( + + + + + ); default: return null; } diff --git a/src/ui/ribbon/RibbonBar.tsx b/src/ui/ribbon/RibbonBar.tsx index 94bd360..7d62f00 100644 --- a/src/ui/ribbon/RibbonBar.tsx +++ b/src/ui/ribbon/RibbonBar.tsx @@ -87,9 +87,11 @@ function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) { ); } - // Befehls-Element: Label aus der Befehls-Registry ableiten. + // Befehls-Element: Label aus der Befehls-Registry ableiten. Für Aktionen ohne + // eigenes Engine-Kommando (z. B. „rotate", das über die interaktive + // Transformation läuft) auf den i18n-Schlüssel `cmd..label` zurückfallen. const cmd = getCommand(item.name); - const label = cmd ? t(cmd.labelKey) : item.name; + const label = cmd ? t(cmd.labelKey) : t(`cmd.${item.name}.label`); const active = ctx.activeCommand === item.name; return (