From 1d939a939a236f552dd9500a76972b8058a6198f 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. --- 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 + 129 files changed, 26324 insertions(+), 842 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/README.md b/README.md index 312dec3..5fc94b7 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # DOSSIER Standalone -Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus -semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne — -ohne Revit. +Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude +aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne — +ohne Revit. Als Desktop-App und im Browser zugänglich; die Desktop-App ist die +vollständige Fassung. Das ist die eigenständige Standalone-Variante des Rhino-Plugins [DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als -React/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz. -Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine -(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview -kleinster gemeinsamer Nenner. +React-App mit eigener Rendering-Engine (Rust/WASM/WebGPU) statt als Rhino-Aufsatz. +Als Desktop-App (Tauri) läuft sie im eigenen Fenster mit voller Engine-Leistung; +browserseitig ist derselbe Kern zugänglich, die App ist die vollständige Fassung. ## Grundgedanke @@ -151,7 +151,17 @@ src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WAS - [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte - [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …) +## Lizenz + +Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der +**GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)** — +siehe [LICENSE](LICENSE). DOSSIER ist Teil der **openbureau**-Suite. + +Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf. +geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten +behalten ihre jeweiligen Lizenzen (siehe „Über"-Dialog in der App). + --- -Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, -Swisstopo-Geodaten) sind als Differenzierer eingeplant. +Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten) +sind als Differenzierer eingeplant. diff --git a/package-lock.json b/package-lock.json index 1ca506a..3e2892f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@mlightcad/libredwg-web": "^0.7.7", "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "delaunator": "^5.1.0", "dxf-parser": "^1.1.2", "jspdf": "^4.2.1", @@ -1842,6 +1844,24 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", diff --git a/package.json b/package.json index 1eacc0e..bf67ff4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "private": true, "version": "0.0.0", "type": "module", + "license": "AGPL-3.0-or-later", + "author": "Karim Gabriele Varano", "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -19,11 +21,14 @@ "build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web", "build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web", "build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web", - "build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web" + "build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web", + "build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web" }, "dependencies": { "@mlightcad/libredwg-web": "^0.7.7", "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "delaunator": "^5.1.0", "dxf-parser": "^1.1.2", "jspdf": "^4.2.1", @@ -48,5 +53,12 @@ "vite": "^5.4.8", "vitest": "^4.1.9", "wasm-pack": "^0.15.0" + }, + "allowScripts": { + "core-js@3.49.0": true, + "esbuild@0.21.5": true, + "fsevents@2.3.3": true, + "fsevents@2.3.2": true, + "wasm-pack@0.15.0": true } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 211ee98..59874da 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -384,6 +384,7 @@ dependencies = [ name = "cad-tauri" version = "0.1.0" dependencies = [ + "fs4", "geometry", "pollster", "render2d", @@ -392,6 +393,8 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", "wgpu", "winit", ] @@ -982,7 +985,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -1304,6 +1307,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1690,7 +1703,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2892,6 +2905,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3705,6 +3719,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -4538,6 +4576,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation 0.3.2", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -5946,6 +6042,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5979,13 +6084,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -6025,6 +6147,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6037,6 +6165,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6049,12 +6183,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6067,6 +6213,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6079,6 +6231,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6091,6 +6249,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6103,6 +6267,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.30.13" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ea33125..fc9d8c3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,7 +8,7 @@ members = ["."] # wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat). # kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts), # per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace. -exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"] +exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"] [package] name = "cad-tauri" @@ -37,9 +37,15 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" geometry = { path = "geometry" } +# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei +# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch +# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig). +fs4 = { version = "0.9", default-features = false, features = ["sync"] } # --- M2-Spike (nur mit Feature "native2d") ----------------------------------- # render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 26b0bbb..784a713 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,8 @@ "core:window:allow-toggle-maximize", "core:window:allow-is-maximized", "core:window:allow-close", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "dialog:allow-save", + "fs:allow-write-text-file" ] } diff --git a/src-tauri/kernel2d/src/lib.rs b/src-tauri/kernel2d/src/lib.rs index 0b8bdc7..532811f 100644 --- a/src-tauri/kernel2d/src/lib.rs +++ b/src-tauri/kernel2d/src/lib.rs @@ -2470,6 +2470,223 @@ pub fn room_from_point_inside_faces( best } +// --- Oeffnungs-Geometrie (Port von src/geometry/opening.ts, Slice 4) -------- +// `Wall`/`Opening`/`Project`-Kopplung durch Vec2/number-Parameter ersetzt +// (PORT_PLAN §1 "Teilweise im Scope"): `thickness`/`refOffset` werden hier als +// bereits aufgeloeste Zahlen uebergeben (TS: `wallTypeThickness(getWallType(...))` +// bzw. `wallReferenceOffset(wall, total)` gegen ein `Project`). Nur +// `openingVerticalExtent` bleibt TS (haengt an `Project.drawingLevels` via +// `wallVerticalExtent`) und wird hier NICHT portiert. +// +// `DEG` (Grad→Radiant, Port des lokalen `DEG` in opening.ts) ist bereits als +// modul-weite Konstante aus dem stair-Slice vorhanden (identischer Wert). + +/// Punkt entlang der Strecke p1→p2 im Abstand d vom Start (Port von `along` +/// aus src/model/geometry.ts). +pub fn along(p1: Vec2, p2: Vec2, d: f64) -> Vec2 { + add(p1, scale(normalize(sub(p2, p1)), d)) +} + +/// Achslaenge einer Wand (Port von `wallAxisLength`; Wand → start/end). +pub fn wall_axis_length(start: Vec2, end: Vec2) -> f64 { + (end.x - start.x).hypot(end.y - start.y) +} + +/// Oeffnungs-Intervall entlang der Achse (Port von `openingInterval`). +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Interval { + pub from: f64, + pub to: f64, +} + +pub fn opening_interval(start: Vec2, end: Vec2, position: f64, width: f64) -> Option { + let axis = wall_axis_length(start, end); + let from = position.min(axis).max(0.0); + let to = (position + width).min(axis).max(from); + if to - from < 1e-4 { + return None; // entartet: Breite <= 0 oder vollstaendig ausserhalb der Achse + } + Some(Interval { from, to }) +} + +/// Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse (Port von +/// `wallAxisFrame`). +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +pub struct AxisFrame { + pub u: Vec2, + pub n: Vec2, +} + +pub fn wall_axis_frame(start: Vec2, end: Vec2) -> AxisFrame { + let u = normalize(sub(end, start)); + AxisFrame { u, n: left_normal(u) } +} + +/// Die beiden Pfosten-Punkte einer Oeffnung im Grundriss (Port von `openingJambs`). +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Jambs { + pub jamb_start: Vec2, + pub jamb_end: Vec2, +} + +pub fn opening_jambs(start: Vec2, end: Vec2, position: f64, width: f64) -> Option { + let iv = opening_interval(start, end, position, width)?; + Some(Jambs { + jamb_start: along(start, end, iv.from), + jamb_end: along(start, end, iv.to), + }) +} + +/// Luecken-Rechteck-Ecken ueber die volle Wanddicke (Port von `openingGapQuad`). +/// Reihenfolge: jambStart(+n/2) → jambEnd(+n/2) → jambEnd(−n/2) → jambStart(−n/2). +pub fn opening_gap_quad( + start: Vec2, + end: Vec2, + position: f64, + width: f64, + thickness: f64, + ref_offset: f64, +) -> Option> { + let jambs = opening_jambs(start, end, position, width)?; + let n = wall_axis_frame(start, end).n; + let outer = ref_offset + thickness / 2.0; + let inner = ref_offset - thickness / 2.0; + Some(vec![ + add(jambs.jamb_start, scale(n, outer)), + add(jambs.jamb_end, scale(n, outer)), + add(jambs.jamb_end, scale(n, inner)), + add(jambs.jamb_start, scale(n, inner)), + ]) +} + +/// Mittelpunkt einer Oeffnung im Grundriss (Port von `openingCenter`). +pub fn opening_center(start: Vec2, end: Vec2, position: f64, width: f64) -> Option { + let iv = opening_interval(start, end, position, width)?; + Some(along(start, end, (iv.from + iv.to) / 2.0)) +} + +/// Aufgeloeste Tuer-Symbol-Geometrie im Grundriss (Port von `DoorSymbol`). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DoorSymbol { + pub hinge: Vec2, + pub open_end: Vec2, + pub closed_end: Vec2, + pub radius: f64, + pub jamb_start: Vec2, + pub jamb_end: Vec2, + pub normal: Vec2, +} + +/// Port von `doorSymbol`. `swing`/`opening_dir`/`hinge_side` folgen der TS- +/// `??`-Fallback-Semantik (`None` → TS-Default); `swing_angle` `None` → 90°. +pub fn door_symbol( + start: Vec2, + end: Vec2, + position: f64, + width: f64, + swing: Option<&str>, + opening_dir: Option<&str>, + hinge_side: Option<&str>, + swing_angle: Option, +) -> Option { + let jambs = opening_jambs(start, end, position, width)?; + let frame = wall_axis_frame(start, end); + let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end); + // TS berechnet die Breite NEU aus den geklemmten Pfosten (nicht das rohe + // `o.width`) — bewusst repliziert. + let w = (jamb_end.x - jamb_start.x).hypot(jamb_end.y - jamb_start.y); + + let swing_sign = if swing.unwrap_or("left") == "left" { 1.0 } else { -1.0 }; + let dir_sign = if opening_dir.unwrap_or("in") == "in" { 1.0 } else { -1.0 }; + let swing_dir = scale(frame.n, swing_sign * dir_sign); + + let hinge_at_start = hinge_side.unwrap_or("start") == "start"; + let hinge = if hinge_at_start { jamb_start } else { jamb_end }; + let closed_dir = if hinge_at_start { frame.u } else { scale(frame.u, -1.0) }; + + let angle = swing_angle.unwrap_or(90.0) * DEG; + let open_dir = Vec2 { + x: closed_dir.x * angle.cos() + swing_dir.x * angle.sin(), + y: closed_dir.y * angle.cos() + swing_dir.y * angle.sin(), + }; + Some(DoorSymbol { + hinge, + open_end: add(hinge, scale(open_dir, w)), + closed_end: add(hinge, scale(closed_dir, w)), + radius: w, + jamb_start, + jamb_end, + normal: frame.n, + }) +} + +/// Aufgeloeste Fenster-Linien im Grundriss (Port von `WindowSymbol`). +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WindowSymbol { + pub frame: Vec, + pub glass_lines: Vec<(Vec2, Vec2)>, + pub mullion_lines: Vec<(Vec2, Vec2)>, +} + +/// Port von `windowSymbol`. `thickness`/`ref_offset` sind bereits aufgeloeste +/// Werte (siehe Modul-Kommentar); `wing_count` `None` → TS-Default `1`. +pub fn window_symbol( + start: Vec2, + end: Vec2, + position: f64, + width: f64, + thickness: f64, + ref_offset: f64, + glass_count: f64, + wing_count: Option, +) -> Option { + let jambs = opening_jambs(start, end, position, width)?; + let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end); + let n = wall_axis_frame(start, end).n; + let outer = ref_offset + thickness / 2.0; + let inner = ref_offset - thickness / 2.0; + let frame_quad = vec![ + add(jamb_start, scale(n, outer)), + add(jamb_end, scale(n, outer)), + add(jamb_end, scale(n, inner)), + add(jamb_start, scale(n, inner)), + ]; + + let mut glass_lines: Vec<(Vec2, Vec2)> = Vec::new(); + let count = glass_count.max(1.0); + let mut i: i64 = 0; + while (i as f64) < count { + let spread = thickness / 8.0; + let off = if count == 1.0 { + ref_offset + } else { + ref_offset + ((i as f64) - (count - 1.0) / 2.0) * spread * 2.0 + }; + glass_lines.push((add(jamb_start, scale(n, off)), add(jamb_end, scale(n, off)))); + i += 1; + } + + let mut mullion_lines: Vec<(Vec2, Vec2)> = Vec::new(); + let wings = wing_count.unwrap_or(1.0).round().min(4.0).max(1.0); + if wings > 1.0 { + let post_depth = thickness * 0.6; + let p_outer = ref_offset + post_depth / 2.0; + let p_inner = ref_offset - post_depth / 2.0; + let wings_n = wings as i64; + for k in 1..wings_n { + let t = k as f64 / wings; + let on_axis = add(jamb_start, scale(sub(jamb_end, jamb_start), t)); + mullion_lines.push((add(on_axis, scale(n, p_outer)), add(on_axis, scale(n, p_inner)))); + } + } + + Some(WindowSymbol { frame: frame_quad, glass_lines, mullion_lines }) +} + // --- Batch-Fassaden: roomArea / ceiling (Slice 1) ---------------------------- #[cfg(feature = "web")] @@ -2741,6 +2958,183 @@ pub fn rb_point_in_polygon_batch_json(input_json: &str) -> Result Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec = qs.iter().map(|q| wall_axis_length(q.start, q.end)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpeningQuery { + start: Vec2, + end: Vec2, + position: f64, + width: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn opening_interval_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| opening_interval(q.start, q.end, q.position, q.width)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn wall_axis_frame_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec = qs.iter().map(|q| wall_axis_frame(q.start, q.end)).collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn opening_jambs_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| opening_jambs(q.start, q.end, q.position, q.width)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpeningGapQuadQuery { + start: Vec2, + end: Vec2, + position: f64, + width: f64, + thickness: f64, + ref_offset: f64, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn opening_gap_quad_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec>> = qs + .iter() + .map(|q| opening_gap_quad(q.start, q.end, q.position, q.width, q.thickness, q.ref_offset)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn opening_center_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| opening_center(q.start, q.end, q.position, q.width)) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DoorSymbolQuery { + start: Vec2, + end: Vec2, + position: f64, + width: f64, + #[serde(default)] + swing: Option, + #[serde(default)] + opening_dir: Option, + #[serde(default)] + hinge: Option, + #[serde(default)] + swing_angle: Option, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn door_symbol_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| { + door_symbol( + q.start, + q.end, + q.position, + q.width, + q.swing.as_deref(), + q.opening_dir.as_deref(), + q.hinge.as_deref(), + q.swing_angle, + ) + }) + .collect(); + to_js(&out) +} + +#[cfg(feature = "web")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WindowSymbolQuery { + start: Vec2, + end: Vec2, + position: f64, + width: f64, + thickness: f64, + ref_offset: f64, + glass_count: f64, + #[serde(default)] + wing_count: Option, +} + +#[cfg(feature = "web")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn window_symbol_batch_json(input_json: &str) -> Result { + console_error_panic_hook::set_once(); + let qs: Vec = from_js(input_json)?; + let out: Vec> = qs + .iter() + .map(|q| { + window_symbol( + q.start, + q.end, + q.position, + q.width, + q.thickness, + q.ref_offset, + q.glass_count, + q.wing_count, + ) + }) + .collect(); + to_js(&out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/render3d/examples/section_svg.rs b/src-tauri/render3d/examples/section_svg.rs index b9f5dbf..6d44482 100644 --- a/src-tauri/render3d/examples/section_svg.rs +++ b/src-tauri/render3d/examples/section_svg.rs @@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec, Vec) { }], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }, WallInput { start: [0.0, 3.0], @@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec, Vec) { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // also groessere Tiefe von der Betrachter-Ebene aus), genau im @@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec, Vec) { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }, ]; let slabs = vec![SlabInput { @@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec, Vec) { z_bottom: -0.2, z_top: 0.0, color: [0.86, 0.86, 0.88], + hatch: None, + cut: None, }]; (walls, slabs) } diff --git a/src-tauri/render3d/src/bin/spike3d.rs b/src-tauri/render3d/src/bin/spike3d.rs index 848037a..6b18b5b 100644 --- a/src-tauri/render3d/src/bin/spike3d.rs +++ b/src-tauri/render3d/src/bin/spike3d.rs @@ -40,6 +40,9 @@ fn demo_walls() -> Vec { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; // Raum 6 x 4 m. vec![ diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 756af82..7080555 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -15,12 +15,17 @@ use wgpu::util::DeviceExt; use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX}; use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX}; use crate::math::{view_projection, Mat4}; -use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh}; +use crate::mesh::{ + build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured, +}; use crate::section::SectionPlane; -use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX}; +use crate::section_fill::{ + build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX, +}; use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL}; use crate::types::{ - Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, + Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX, + TEXTURED_FLOATS_PER_VERTEX, }; /// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/ @@ -142,6 +147,10 @@ struct MeshBuffers { /// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet. struct TexturedMeshBuffers { vbo: wgpu::Buffer, + /// Zweiter Vertex-Buffer: EIN f32 (Material-Textur-Ebene) je Vertex, + /// @location(3) im `MESH_TEXTURED_WGSL`. Immer gesetzt (Fallback: `-1` je + /// Vertex), damit das Pipeline-Layout stets zwei Vertex-Buffer erwartet. + layer_vbo: wgpu::Buffer, ibo: wgpu::Buffer, index_count: u32, } @@ -225,21 +234,39 @@ pub struct Renderer { /// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene /// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None). cap_pipeline: wgpu::RenderPipeline, + /// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): TriangleList von + /// Ribbon-Quads (`section_fill::build_cut_cap_lines`), Vertex-Layout [pos vec3, + /// color vec3] wie die Grid/Highlight-LineList — teilt darum das `grid_module` + /// (GRID_WGSL). KEIN Tiefentest (`depth_compare: Always`, kein Write) wie das + /// Highlight: die Fugenlinien liegen (leicht zur Kamera versetzt) sichtbar auf + /// den Cut-Caps. Sie werden NICHT an der Schnittebene weggeclippt (sie SIND die + /// Schnittflaeche) — der Kamera-Lift in `build_cut_cap_lines` haelt sie vor der + /// `depth > 0`-Kappung von GRID_WGSL. + cut_line_pipeline: wgpu::RenderPipeline, /// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList, /// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet /// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel /// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur /// Shader/Layout unterscheiden sich. textured_pipeline: wgpu::RenderPipeline, - /// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler. - /// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende). + /// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler. + /// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt + /// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett. texture_bind_group: wgpu::BindGroup, - /// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim - /// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine - /// Queue bekommt. + /// Das Textur-Array selbst. Start: 1 Ebene (Schachbrett). `set_material_textures` + /// ersetzt es durch ein Array `[Schachbrett, Material0, Material1, …]`. texture: wgpu::Texture, - /// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim - /// ersten `render` einmalig gesetzt (lazy Upload). + /// Bind-Group-Layout (group 1), festgehalten fuer den Neubau der Bind-Group + /// beim Hochladen von Material-Texturen. + texture_bind_group_layout: wgpu::BindGroupLayout, + /// Sampler (Repeat/Linear), festgehalten fuer den Bind-Group-Neubau. + texture_sampler: wgpu::Sampler, + /// Anzahl gueltiger Ebenen im Textur-Array (>= 1). 1 = nur Schachbrett; nach + /// `set_material_textures` = 1 + Anzahl Material-Karten. Wandert je Frame nach + /// `mode.z` (Klemmung der Ebenen-Auswahl im Shader). + material_layer_count: u32, + /// Ob die Schachbrett-Texel (Ebene 0) schon hochgeladen wurden. Beim ersten + /// `render` einmalig gesetzt (lazy Upload, weil `new` keine Queue bekommt). texture_uploaded: bool, bind_group: wgpu::BindGroup, uniform: wgpu::Buffer, @@ -264,6 +291,10 @@ pub struct Renderer { /// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht, /// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight. caps: Option, + /// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList + /// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber + /// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps. + cut_lines: Option, /// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false — /// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter. grid_visible: bool, @@ -471,6 +502,52 @@ impl Renderer { }, ); + // Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): dasselbe schlanke + // LineList-Vertex-Layout [pos vec3, color vec3] und `grid_module` (GRID_WGSL) + // wie Grid/Highlight, aber als TriangleList (die Kanten sind Ribbon-Quads, + // damit die Strichstaerke als Welt-Breite durchkommt). Depth wie Highlight + // (Always, kein Write) -> immer sichtbar auf den Caps. + let cut_line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("cut_line.pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &grid_module, + entry_point: Some("vs_main"), + buffers: &[grid_vertex_layout.clone()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &grid_module, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + // Ribbons sind flach in der Schnittebene, aus beiden Seiten sichtbar. + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + // Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes // schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe // Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben. @@ -478,10 +555,14 @@ impl Renderer { label: Some("cap.wgsl"), source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()), }); + // Vertex-Layout: [pos vec3, uv vec2, hatch vec4(pattern, angle_rad, scale, + // line_weight_mm)], stride 9*4 (`CAP_FLOATS_PER_VERTEX`). Das dritte Attribut + // treibt die musterspezifische Schraffur im Cap-Fragment-Shader (CAP_WGSL); + // `line_weight_mm` setzt die Musterlinien-Breite je Schraffur. let cap_vertex_layout = wgpu::VertexBufferLayout { array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64, step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2], + attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4], }; let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("cap.pipeline"), @@ -547,7 +628,7 @@ impl Renderer { visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, + view_dimension: wgpu::TextureViewDimension::D2Array, multisampled: false, }, count: None, @@ -579,6 +660,14 @@ impl Renderer { step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2], }; + // ZWEITER Vertex-Buffer: die Material-Textur-Ebene je Vertex (@location(3), + // 1 f32). Separat gehalten, damit das Haupt-`[pos,normal,uv]`-Layout (und + // die Geometrie-Paritaet zum Shaded-Pfad) unveraendert bleibt. + let textured_layer_layout = wgpu::VertexBufferLayout { + array_stride: 4, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![3 => Float32], + }; // WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format, // Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline) // — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der @@ -590,7 +679,7 @@ impl Renderer { vertex: wgpu::VertexState { module: &textured_module, entry_point: Some("vs_main"), - buffers: &[textured_vertex_layout], + buffers: &[textured_vertex_layout, textured_layer_layout], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { @@ -632,8 +721,10 @@ impl Renderer { // `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue // bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs, // spike3d) unveraendert. + // Textur-ARRAY (D2Array). Start: EINE Ebene (Fallback-Schachbrett). + // `set_material_textures` ersetzt das Array spaeter durch mehrere Ebenen. let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("checker.texture"), + label: Some("materials.texture_array"), size: wgpu::Extent3d { width: TEXTURE_SIZE, height: TEXTURE_SIZE, @@ -646,10 +737,13 @@ impl Renderer { usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); // Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung). - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("checker.sampler"), + let texture_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("materials.sampler"), address_mode_u: wgpu::AddressMode::Repeat, address_mode_v: wgpu::AddressMode::Repeat, address_mode_w: wgpu::AddressMode::Repeat, @@ -659,7 +753,7 @@ impl Renderer { ..Default::default() }); let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("texture.bind"), + label: Some("materials.bind"), layout: &texture_bind_group_layout, entries: &[ wgpu::BindGroupEntry { @@ -668,7 +762,7 @@ impl Renderer { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), + resource: wgpu::BindingResource::Sampler(&texture_sampler), }, ], }); @@ -694,9 +788,13 @@ impl Renderer { grid_pipeline, highlight_pipeline, cap_pipeline, + cut_line_pipeline, textured_pipeline, texture_bind_group, texture, + texture_bind_group_layout, + texture_sampler, + material_layer_count: 1, texture_uploaded: false, bind_group, uniform, @@ -706,6 +804,7 @@ impl Renderer { grid: None, highlight: None, caps: None, + cut_lines: None, grid_visible: false, style: RenderStyle::Shaded, depth: None, @@ -724,7 +823,7 @@ impl Renderer { /// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch. pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) { - self.upload_mesh(device, build_walls_mesh(walls)); + self.upload_mesh(device, build_walls_mesh(walls), build_walls_mesh_textured(walls)); } /// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes @@ -736,13 +835,17 @@ impl Renderer { slabs: &[SlabInput], meshes: &[MeshInput], ) { - self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes)); + self.upload_mesh( + device, + build_scene_mesh(walls, slabs, meshes), + build_scene_mesh_textured(walls, slabs, meshes), + ); } /// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und /// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer /// die Stile wireframe/hidden — einmalig hier, nicht je Frame. - fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) { + fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) { if mesh.indices.is_empty() { self.mesh = None; self.textured_mesh = None; @@ -782,11 +885,10 @@ impl Renderer { index_count: mesh.indices.len() as u32, }); - // Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je - // Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten - // Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad, - // und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit. - let tex_mesh = textured_from_mesh(&mesh); + // Texturiertes Pendant (planare Welt-UV je Vertex + Material-Ebenen-Puffer, + // siehe `build_scene_mesh_textured`) in zwei Vertex-Puffer hochladen. Die + // Geometrie bleibt bitgleich zum Alt-Pfad; der Stil `Textured` hat ohne + // Re-Meshing seine Puffer bereit. let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mesh.textured.vbo"), contents: bytemuck::cast_slice(&tex_mesh.verts), @@ -797,8 +899,22 @@ impl Renderer { contents: bytemuck::cast_slice(&tex_mesh.indices), usage: wgpu::BufferUsages::INDEX, }); + // Material-Ebenen-Puffer (1 f32 je Vertex). Fehlt er (kein Material-Bau), + // mit `-1` je Vertex fuellen -> alle Vertices auf das Fallback-Schachbrett. + let vertex_count = tex_mesh.verts.len() / TEXTURED_FLOATS_PER_VERTEX; + let layer_data: Vec = if tex_mesh.layers.len() == vertex_count { + tex_mesh.layers + } else { + vec![-1.0; vertex_count] + }; + let tlvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh.textured.layer.vbo"), + contents: bytemuck::cast_slice(&layer_data), + usage: wgpu::BufferUsages::VERTEX, + }); self.textured_mesh = Some(TexturedMeshBuffers { vbo: tvbo, + layer_vbo: tlvbo, ibo: tibo, index_count: tex_mesh.indices.len() as u32, }); @@ -918,6 +1034,27 @@ impl Renderer { }); } + /// Setzt/loescht die Schnitt-Umrisslinien (Cut-Cap-Kanten, P2). `verts` = + /// interleaved `[px,py,pz, r,g,b, ...]` (world-Meter + Farbe, TriangleList von + /// Ribbon-Quads, `CUT_LINE_FLOATS_PER_VERTEX`), wie `section_fill:: + /// build_cut_cap_lines` liefert. Leeres Slice loescht die Linien. Wirkt erst + /// beim naechsten `render`. + pub fn set_cut_lines(&mut self, device: &wgpu::Device, verts: &[f32]) { + if verts.is_empty() { + self.cut_lines = None; + return; + } + let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("cut_lines.vbo"), + contents: bytemuck::cast_slice(verts), + usage: wgpu::BufferUsages::VERTEX, + }); + self.cut_lines = Some(LineBuffers { + vbo, + vertex_count: (verts.len() / CUT_LINE_FLOATS_PER_VERTEX) as u32, + }); + } + /// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten) /// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und /// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` + @@ -936,11 +1073,110 @@ impl Renderer { let plane = SectionPlane::new(point, normal); let caps = build_cut_caps(&plane, walls, slabs); self.set_cut_caps(device, &caps); + // P2: die Schnitt-Umrisslinien (Fugen-/Materiallinien) je ueberlebendem + // Cut-Cap-Rechteck aus DERSELBEN, dominanz-verschnittenen Rechteck-Menge. + let lines = build_cut_cap_lines(&plane, walls, slabs); + self.set_cut_lines(device, &lines); } else { self.set_cut_caps(device, &[]); + self.set_cut_lines(device, &[]); } } + /// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-ARRAY hoch. + /// `rgba` = dicht gepackte RGBA8-Bytes von `layer_count` Ebenen, jede exakt + /// `TEXTURE_SIZE × TEXTURE_SIZE` (der Aufrufer skaliert/dekodiert die Bilder + /// JS-seitig, siehe `web::set_material_textures`/`Wasm3DViewport`). Baut ein + /// neues Array `[Schachbrett (Ebene 0), Material0 (Ebene 1), …]`, laedt es hoch + /// und ersetzt die group-1-Bind-Group. Ein Wand-Band verweist per + /// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne + /// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder + /// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck. + pub fn set_material_textures( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + rgba: &[u8], + layer_count: u32, + ) { + let bytes_per_layer = (TEXTURE_SIZE * TEXTURE_SIZE * 4) as usize; + // Ungueltige/leere Eingabe -> reines Schachbrett-Array (1 Ebene). + let valid = layer_count > 0 && rgba.len() >= layer_count as usize * bytes_per_layer; + let material_layers = if valid { layer_count } else { 0 }; + let total_layers = material_layers + 1; // Ebene 0 = Schachbrett + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("materials.texture_array"), + size: wgpu::Extent3d { + width: TEXTURE_SIZE, + height: TEXTURE_SIZE, + depth_or_array_layers: total_layers, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + // Ebene 0: Schachbrett-Fallback. + let checker = build_checker_texture(); + let write_layer = |layer: u32, data: &[u8]| { + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * TEXTURE_SIZE), + rows_per_image: Some(TEXTURE_SIZE), + }, + wgpu::Extent3d { + width: TEXTURE_SIZE, + height: TEXTURE_SIZE, + depth_or_array_layers: 1, + }, + ); + }; + write_layer(0, &checker); + for i in 0..material_layers { + let start = i as usize * bytes_per_layer; + write_layer(i + 1, &rgba[start..start + bytes_per_layer]); + } + + let view = texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + self.texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("materials.bind"), + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.texture_sampler), + }, + ], + }); + self.texture = texture; + self.material_layer_count = total_layers; + // Ebene 0 (Schachbrett) ist bereits geladen -> den Lazy-Upload ueberspringen. + self.texture_uploaded = true; + } + /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert. fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h); @@ -1019,6 +1255,8 @@ impl Renderer { // `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/ // Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal. self.globals.mode[0] = self.style.mesh_mode(); + // `mode.z` = Anzahl gueltiger Textur-Array-Ebenen (Klemmung im Textur-Shader). + self.globals.mode[2] = self.material_layer_count as f32; queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals)); // Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine @@ -1100,6 +1338,7 @@ impl Renderer { pass.set_bind_group(0, &self.bind_group, &[]); pass.set_bind_group(1, &self.texture_bind_group, &[]); pass.set_vertex_buffer(0, t.vbo.slice(..)); + pass.set_vertex_buffer(1, t.layer_vbo.slice(..)); pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..t.index_count, 0, 0..1); } @@ -1128,6 +1367,18 @@ impl Renderer { pass.draw(0..c.vertex_count, 0..1); } + // 1c) Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) NACH den Caps: die + // Material-/Fugenlinien je ueberlebendem Cut-Rechteck (Ribbon- + // TriangleList, eigener Stil je Schicht). Ohne Tiefentest (Always) + // liegen sie sichtbar auf den Caps; der Kamera-Lift in + // `build_cut_cap_lines` haelt sie vor der Ebenen-Kappung. + if let Some(cl) = &self.cut_lines { + pass.set_pipeline(&self.cut_line_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_vertex_buffer(0, cl.vbo.slice(..)); + pass.draw(0..cl.vertex_count, 0..1); + } + // 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid). // Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten) // Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index b89b78f..81764c2 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -20,6 +20,7 @@ pub mod math; pub mod mesh; pub(crate) mod openings; pub mod section; +pub mod section_boolean; pub mod section_fill; pub mod shaders; pub mod types; @@ -72,6 +73,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, } } @@ -171,6 +175,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }, ]); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); @@ -250,6 +257,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let mut mesh = Mesh::default(); extrude_wall(&mut mesh, &w); @@ -272,6 +282,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let mesh = build_walls_mesh(&[w]); assert_eq!(mesh.vertex_count(), 0); @@ -486,6 +499,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let b = WallInput { start: [0.0, 0.0], @@ -497,6 +513,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let mesh = build_walls_mesh(&[a, b]); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader"); @@ -557,6 +576,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let walls = vec![ mk([0.0, 0.0], [3.0, 0.0]), @@ -586,6 +608,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let walls = vec![ mk([0.0, 0.0], [3.0, 0.0], 0.0), @@ -675,6 +700,9 @@ mod tests { openings: vec![], layers: layers(), holes: vec![], + material_index: None, + hatch: None, + cut: None, }; let mut b = a.clone(); b.end = [0.0, 3.0]; @@ -705,6 +733,8 @@ mod tests { z_bottom, z_top, color: [0.86, 0.86, 0.88], + hatch: None, + cut: None, } } @@ -761,6 +791,8 @@ mod tests { z_bottom: 0.0, z_top: 0.3, color: [0.8, 0.8, 0.8], + hatch: None, + cut: None, }; let mut mesh = Mesh::default(); extrude_slab(&mut mesh, &slab); @@ -789,7 +821,7 @@ mod tests { fn entarteter_slab_erzeugt_nichts() { let mut mesh = Mesh::default(); // <3 Ecken. - extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] }); + extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None }); assert_eq!(mesh.vertex_count(), 0); // Nullhoehe. extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6)); @@ -962,6 +994,51 @@ mod tests { assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel"); } + #[test] + fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() { + use super::math::preset_camera; + use super::types::{CameraPreset, Projection}; + let t = [0.0, 0.0, 0.0]; + let front = preset_camera(CameraPreset::Front, t, 10.0); + let back = preset_camera(CameraPreset::Back, t, 10.0); + let side = preset_camera(CameraPreset::Side, t, 10.0); + let left = preset_camera(CameraPreset::Left, t, 10.0); + assert_eq!(back.projection, Projection::Orthographic); + assert_eq!(left.projection, Projection::Orthographic); + // Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse). + assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)"); + assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)"); + } + + #[test] + fn presets_iso_oktanten_sind_orthografisch_gleich_gross() { + use super::math::preset_camera; + use super::types::{CameraPreset, Projection}; + let t = [0.0, 0.0, 0.0]; + let dist = 10.0; + let iso = preset_camera(CameraPreset::Iso, t, dist); + let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist); + let br = preset_camera(CameraPreset::IsoBackRight, t, dist); + let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist); + for cam in [&iso, &fl, &br, &bl] { + assert_eq!(cam.projection, Projection::Orthographic); + // Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand + // (Modell erscheint in jedem Oktanten gleich gross). + let d = ((cam.eye[0] - t[0]).powi(2) + + (cam.eye[1] - t[1]).powi(2) + + (cam.eye[2] - t[2]).powi(2)) + .sqrt(); + assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist"); + // Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt. + assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel"); + } + // Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z). + assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts + assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links + assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts + assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links + } + /// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt /// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga /// sonst nicht mitgebaut wird (Muster: render2d). diff --git a/src-tauri/render3d/src/math.rs b/src-tauri/render3d/src/math.rs index 573da13..8aa9ef2 100644 --- a/src-tauri/render3d/src/math.rs +++ b/src-tauri/render3d/src/math.rs @@ -185,15 +185,26 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3] ] } -/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target` -/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso -/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp -/// ist perspektivisch. +/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11). +/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets +/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel, +/// die Iso-Varianten diagonal); nur Persp ist perspektivisch. +/// +/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx): +/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von +/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt +/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ +/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen). pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera { let mut cam = Camera { target, ..Camera::default() }; + // Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der + // tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist, + // wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten + // das Modell gleich gross zeigen. + let iso_d = dist / 3.0_f32.sqrt(); match preset { // Blick entlang -Z (von vorne), orthografisch. CameraPreset::Front => { @@ -202,6 +213,13 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer cam.up = [0.0, 1.0, 0.0]; cam.ortho_half_height = dist * 0.5; } + // Blick entlang +Z (von hinten) — Gegenrichtung von Front. + CameraPreset::Back => { + cam.projection = Projection::Orthographic; + cam.eye = [target[0], target[1], target[2] - dist]; + cam.up = [0.0, 1.0, 0.0]; + cam.ortho_half_height = dist * 0.5; + } // Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit // Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht). CameraPreset::Top => { @@ -210,24 +228,48 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer cam.up = [0.0, 0.0, -1.0]; cam.ortho_half_height = dist * 0.5; } - // Blick entlang -X (von der Seite), orthografisch. + // Blick entlang -X (von der rechten Seite), orthografisch. CameraPreset::Side => { cam.projection = Projection::Orthographic; cam.eye = [target[0] + dist, target[1], target[2]]; cam.up = [0.0, 1.0, 0.0]; cam.ortho_half_height = dist * 0.5; } - // Isometrischer Ueberblick (orthografisch, Blick von schraeg oben). + // Blick entlang +X (von der linken Seite) — Gegenrichtung von Side. + CameraPreset::Left => { + cam.projection = Projection::Orthographic; + cam.eye = [target[0] - dist, target[1], target[2]]; + cam.up = [0.0, 1.0, 0.0]; + cam.ortho_half_height = dist * 0.5; + } + // Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso). // Echte Isometrie ist per Definition parallelprojiziert (keine // perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu). CameraPreset::Iso => { cam.projection = Projection::Orthographic; - let d = dist / 3.0_f32.sqrt(); - cam.eye = [target[0] + d, target[1] + d, target[2] + d]; + cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d]; + cam.up = [0.0, 1.0, 0.0]; + cam.ortho_half_height = dist * 0.5; + } + // Isometrischer Ueberblick von vorne-oben-links. + CameraPreset::IsoFrontLeft => { + cam.projection = Projection::Orthographic; + cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d]; + cam.up = [0.0, 1.0, 0.0]; + cam.ortho_half_height = dist * 0.5; + } + // Isometrischer Ueberblick von hinten-oben-rechts. + CameraPreset::IsoBackRight => { + cam.projection = Projection::Orthographic; + cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d]; + cam.up = [0.0, 1.0, 0.0]; + cam.ortho_half_height = dist * 0.5; + } + // Isometrischer Ueberblick von hinten-oben-links. + CameraPreset::IsoBackLeft => { + cam.projection = Projection::Orthographic; + cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d]; cam.up = [0.0, 1.0, 0.0]; - // eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt - // `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort, - // damit das Modell im Bild aehnlich gross erscheint. cam.ortho_half_height = dist * 0.5; } // Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts). diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index 1605f69..d5d5b5a 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -940,7 +940,10 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh { /// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie /// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst. pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh { - textured_from_mesh(&build_walls_mesh(walls)) + // Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch + // die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu + // `textured_from_mesh(&build_walls_mesh(walls))` bleiben. + build_scene_mesh_textured(walls, &[], &[]) } /// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh` @@ -980,9 +983,62 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh { TexturedMesh { verts, indices: mesh.indices.clone(), + // Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten + // `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices). + layers: Vec::new(), } } +/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie +/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/ +/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag +/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`), +/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges +/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen — +/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die +/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung +/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`). +pub fn build_scene_mesh_textured( + walls: &[WallInput], + slabs: &[SlabInput], + meshes: &[MeshInput], +) -> TexturedMesh { + let mut mesh = Mesh::default(); + let mut layers: Vec = Vec::new(); + let fill = |mesh: &Mesh, layers: &mut Vec, before: usize, layer: f32| { + let added = mesh.vertex_count().saturating_sub(before); + layers.extend(std::iter::repeat(layer).take(added)); + }; + + let miters = compute_wall_miters(walls); + for (w, m) in walls.iter().zip(miters.iter()) { + let before = mesh.vertex_count(); + extrude_wall_with_miters(&mut mesh, w, *m); + // Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett). + let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0); + fill(&mesh, &mut layers, before, layer); + } + for s in slabs { + let before = mesh.vertex_count(); + extrude_slab(&mut mesh, s); + fill(&mesh, &mut layers, before, -1.0); + } + for m in meshes { + let before = mesh.vertex_count(); + append_context_mesh(&mut mesh, m); + fill(&mesh, &mut layers, before, -1.0); + } + + let mut tex = textured_from_mesh(&mesh); + debug_assert_eq!( + layers.len(), + tex.vertex_count(), + "ein Ebenen-Eintrag je Vertex" + ); + tex.layers = layers; + tex +} + /// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten /// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt /// vorne (deterministische Zaehlung fuer die Wand-Tests). diff --git a/src-tauri/render3d/src/section.rs b/src-tauri/render3d/src/section.rs index f6a2b1a..ae4c15c 100644 --- a/src-tauri/render3d/src/section.rs +++ b/src-tauri/render3d/src/section.rs @@ -133,7 +133,7 @@ use serde::{Deserialize, Serialize}; use crate::math; -use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput}; +use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput}; /// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe). const EPS: f32 = 1e-4; @@ -268,8 +268,19 @@ pub struct ComponentRef { pub struct CutPolygon { pub component: ComponentRef, pub color: Rgb, + /// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None` + /// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten). + #[serde(default)] + pub hatch: Option, /// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen). pub pts: Vec<[f32; 2]>, + /// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe + /// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean:: + /// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere) + /// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None` + /// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten). + #[serde(default)] + pub cut: Option, } /// Ein Liniensegment in Schnittkoordinaten (u, v), Meter. @@ -314,9 +325,16 @@ struct Prism { z0: f32, z1: f32, color: Rgb, + /// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die + /// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen. + hatch: Option, /// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer /// Decken (Slabs kennen keine Oeffnungen). wall_axis: Option, + /// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der + /// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte + /// `CutPolygon` weitergereicht. + cut: Option, } /// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut- @@ -399,12 +417,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option { z0: wall.base_elevation, z1: wall.base_elevation + wall.height, color: wall.color, + hatch: wall.hatch, wall_axis: Some(WallAxis { start: wall.start, dir, length, openings: wall.openings.clone(), }), + cut: wall.cut.clone(), }) } @@ -425,7 +445,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option { z0, z1, color: slab.color, + hatch: slab.hatch, wall_axis: None, + cut: slab.cut.clone(), }) } @@ -974,7 +996,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput index: prism.index, }, color: prism.color, + hatch: prism.hatch, pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]], + cut: prism.cut.clone(), }); } } @@ -1017,6 +1041,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }, WallInput { start: [0.0, 3.0], @@ -1028,6 +1055,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }, ]; let slabs = vec![SlabInput { @@ -1035,6 +1065,8 @@ mod tests { z_bottom: -0.2, z_top: 0.0, color: [0.86, 0.86, 0.88], + hatch: None, + cut: None, }]; (walls, slabs) } @@ -1285,6 +1317,9 @@ mod tests { }], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, } } @@ -1371,6 +1406,9 @@ mod tests { openings: vec![], layers: None, holes: vec![], + material_index: None, + hatch: None, + cut: None, }; // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. let plane = SectionPlane::looking_plus_y(-2.0); diff --git a/src-tauri/render3d/src/section_boolean.rs b/src-tauri/render3d/src/section_boolean.rs new file mode 100644 index 0000000..125b647 --- /dev/null +++ b/src-tauri/render3d/src/section_boolean.rs @@ -0,0 +1,516 @@ +//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von +//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust. +//! +//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er +//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele +//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst +//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden +//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau +//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich +//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer +//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht. +//! +//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`, +//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares +//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert. + +use crate::section::CutPolygon; + +/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und +/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter). +/// Identisch zu `RECT_EPS` in `toSection.ts`. +const RECT_EPS: f32 = 1e-6; + +/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in +/// `toSection.ts`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub u_min: f32, + pub u_max: f32, + pub v_min: f32, + pub v_max: f32, +} + +/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3 +/// Ecken (analog `rectOfBand`). +pub fn rect_of_poly(poly: &CutPolygon) -> Option { + if poly.pts.len() < 3 { + return None; + } + let mut u_min = f32::INFINITY; + let mut u_max = f32::NEG_INFINITY; + let mut v_min = f32::INFINITY; + let mut v_max = f32::NEG_INFINITY; + for p in &poly.pts { + u_min = u_min.min(p[0]); + u_max = u_max.max(p[0]); + v_min = v_min.min(p[1]); + v_max = v_max.max(p[1]); + } + Some(Rect { + u_min, + u_max, + v_min, + v_max, + }) +} + +/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit +/// `cutter`, zerlegt in 0–4 disjunkte Rest-Rechtecke (links/rechts ueber die volle +/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter +/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige +/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen. +/// 1:1 aus `toSection.ts::subtractRect`. +pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec { + let o_min_u = base.u_min.max(cutter.u_min); + let o_max_u = base.u_max.min(cutter.u_max); + let o_min_v = base.v_min.max(cutter.v_min); + let o_max_v = base.v_max.min(cutter.v_max); + // Kein (nennenswerter) Ueberlapp — `base` unveraendert. + if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS { + return vec![base]; + } + let mut out = Vec::new(); + let mut push = |r: Rect| { + if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS { + out.push(r); + } + }; + // Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe. + push(Rect { + u_min: base.u_min, + u_max: o_min_u, + v_min: base.v_min, + v_max: base.v_max, + }); + push(Rect { + u_min: o_max_u, + u_max: base.u_max, + v_min: base.v_min, + v_max: base.v_max, + }); + // Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest). + push(Rect { + u_min: o_min_u, + u_max: o_max_u, + v_min: base.v_min, + v_max: o_min_v, + }); + push(Rect { + u_min: o_min_u, + u_max: o_max_u, + v_min: o_max_v, + v_max: base.v_max, + }); + out +} + +/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`. +fn priority_of(poly: &CutPolygon) -> Option { + poly.cut.as_ref().and_then(|c| c.join_priority) +} + +/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`. +fn component_of(poly: &CutPolygon) -> Option<&str> { + poly.cut.as_ref().and_then(|c| c.component_id.as_deref()) +} + +/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von +/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs` +/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant). +fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon { + CutPolygon { + component: src.component, + color: src.color, + hatch: src.hatch, + cut: src.cut.clone(), + pts: vec![ + [r.u_min, r.v_max], + [r.u_max, r.v_max], + [r.u_max, r.v_min], + [r.u_min, r.v_min], + ], + } +} + +/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER +/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend — +/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10 +/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche). +/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und +/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands` +/// (inklusive der abschliessenden MERGE-Phase). +pub fn subtract_dominant_bands(bands: Vec) -> Vec { + let rects: Vec> = bands.iter().map(rect_of_poly).collect(); + let prios: Vec> = bands.iter().map(priority_of).collect(); + let mut out: Vec = Vec::new(); + + for i in 0..bands.len() { + // Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen. + let (base, prio) = match (rects[i], prios[i]) { + (Some(b), Some(p)) => (b, p), + _ => { + out.push(bands[i].clone()); + continue; + } + }; + let mut pieces = vec![base]; + for j in 0..bands.len() { + if pieces.is_empty() { + break; + } + if j == i { + continue; + } + let cutter = match (rects[j], prios[j]) { + (Some(c), Some(op)) if op > prio => c, + _ => continue, + }; + let mut next = Vec::new(); + for p in &pieces { + next.extend(subtract_rect(*p, cutter)); + } + pieces = next; + } + for p in pieces { + out.push(poly_from_rect(&bands[i], p)); + } + } + merge_same_component_bands(out) +} + +/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder +/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung +/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender +/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`. +fn rect_union_if_rect(a: Rect, b: Rect) -> Option { + const E: f32 = RECT_EPS; + // Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck. + let a_in_b = b.u_min - a.u_min <= E + && a.u_max - b.u_max <= E + && b.v_min - a.v_min <= E + && a.v_max - b.v_max <= E; + if a_in_b { + return Some(b); + } + let b_in_a = a.u_min - b.u_min <= E + && b.u_max - a.u_max <= E + && a.v_min - b.v_min <= E + && b.v_max - a.v_max <= E; + if b_in_a { + return Some(a); + } + let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E; + let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E; + // Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS). + let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E; + let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E; + if (u_aligned && v_touch) || (v_aligned && u_touch) { + return Some(Rect { + u_min: a.u_min.min(b.u_min), + u_max: a.u_max.max(b.u_max), + v_min: a.v_min.min(b.v_min), + v_max: a.v_max.max(b.v_max), + }); + } + None +} + +/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND +/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen +/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band +/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch +/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne +/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus +/// `toSection.ts::mergeSameComponentBands`. +fn merge_same_component_bands(bands: Vec) -> Vec { + let mut out = bands; + let mut merged = true; + while merged { + merged = false; + 'outer: for i in 0..out.len() { + let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) { + (Some(c), Some(p)) => (c.to_string(), p), + _ => continue, + }; + let ri = match rect_of_poly(&out[i]) { + Some(r) => r, + None => continue, + }; + for j in (i + 1)..out.len() { + if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) { + continue; + } + let rj = match rect_of_poly(&out[j]) { + Some(r) => r, + None => continue, + }; + if let Some(u) = rect_union_if_rect(ri, rj) { + out[i] = poly_from_rect(&out[i], u); + out.remove(j); + merged = true; // Fixpunkt-Neustart + break 'outer; + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::section::{ComponentKind, ComponentRef, CutPolygon}; + use crate::types::CutBandMeta; + + /// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) — + /// Ecken wie `section.rs` (Rechteck). + fn band( + u_min: f32, + u_max: f32, + v_min: f32, + v_max: f32, + join_priority: i32, + component_id: Option<&str>, + ) -> CutPolygon { + CutPolygon { + component: ComponentRef { + kind: ComponentKind::Wall, + index: 0, + }, + color: [0.5, 0.5, 0.5], + hatch: None, + pts: vec![ + [u_min, v_max], + [u_max, v_max], + [u_max, v_min], + [u_min, v_min], + ], + cut: Some(CutBandMeta { + join_priority: Some(join_priority), + component_id: component_id.map(|s| s.to_string()), + line_color: None, + line_weight: None, + }), + } + } + + fn rect_of(p: &CutPolygon) -> Rect { + rect_of_poly(p).unwrap() + } + + fn area(bands: &[CutPolygon]) -> f32 { + bands + .iter() + .map(|b| { + let r = rect_of(b); + (r.u_max - r.u_min) * (r.v_max - r.v_min) + }) + .sum() + } + + fn approx(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-5 + } + + // --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------ + + #[test] + fn subtract_rect_kein_overlap_base_unveraendert() { + let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 }; + let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 }; + assert_eq!(subtract_rect(base, cut), vec![base]); + } + + #[test] + fn subtract_rect_kantenberuehrung_base_unveraendert() { + let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 }; + let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 }; + assert_eq!(subtract_rect(base, cut), vec![base]); + } + + #[test] + fn subtract_rect_cutter_enthaelt_base_leer() { + let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 }; + let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 }; + assert!(subtract_rect(base, cut).is_empty()); + } + + #[test] + fn subtract_rect_cutter_mittig_vier_reste() { + let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 }; + let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 }; + let rest = subtract_rect(base, cut); + assert_eq!(rest.len(), 4); + let a: f32 = rest + .iter() + .map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min)) + .sum(); + assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}"); + } + + #[test] + fn subtract_rect_eck_ueberlappung_zwei_reste() { + // cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2]. + let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 }; + let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 }; + let rest = subtract_rect(base, cut); + let a: f32 = rest + .iter() + .map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min)) + .sum(); + assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}"); + } + + // --- subtractDominantBands: die geometrische Kernaussage ------------------ + + #[test] + fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() { + // Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2], + // volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2], + // v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand + // im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll. + let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick")); + let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete")); + let out = subtract_dominant_bands(vec![brick, concrete]); + + // Beton unveraendert (kein staerkeres Band). + let concrete_out: Vec<&CutPolygon> = out + .iter() + .filter(|b| component_of(b) == Some("concrete")) + .collect(); + assert_eq!(concrete_out.len(), 1); + assert!(approx( + (rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32, + 0.2 + )); + + // Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und + // [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall. + let brick_out: Vec<&CutPolygon> = out + .iter() + .filter(|b| component_of(b) == Some("brick")) + .collect(); + assert!(!brick_out.is_empty()); + for b in &brick_out { + let r = rect_of(b); + // Kein Backstein-Rest ueberlappt (2.6, 2.8) echt. + let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4; + assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall"); + } + // Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56. + assert!( + approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::>()), 0.56), + "Backstein-Restflaeche" + ); + } + + #[test] + fn wand_50_schneidet_estrich_30() { + // Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand + // (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test. + let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed")); + let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3] + let out = subtract_dominant_bands(vec![screed, brick]); + + // Brick voll. + assert!(approx( + area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::>()), + 4.0 + )); + // Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2]. + let screed_out: Vec<&CutPolygon> = out + .iter() + .filter(|b| component_of(b) == Some("screed")) + .collect(); + assert_eq!(screed_out.len(), 1); + let r = rect_of(screed_out[0]); + assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}"); + } + + /// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke. + /// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]), + /// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in + /// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] + + /// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken- + /// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND + /// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20); + /// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als + /// kaputt meldete ("dämmung und zementboden laufen raus"). + #[test] + fn sample_analog_backstein_schneidet_estrich_und_daemmung() { + let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog) + let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite + let bands = vec![ + band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")), + band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")), + band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")), + band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")), + band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")), + ]; + let out = subtract_dominant_bands(bands); + + // Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT. + for name in ["screed", "insulation-ceiling"] { + for b in out.iter().filter(|b| component_of(b) == Some(name)) { + let r = rect_of(b); + let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4; + assert!( + !u_overlap, + "{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)" + ); + } + } + // Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0. + assert!(approx( + area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::>()), + 1.0 + )); + // Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt). + assert!(approx( + area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::>()), + 0.15 * 2.3 + 0.15 * 0.1 + )); + } + + #[test] + fn gleiche_prioritaet_koexistiert() { + // Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten: + // schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll). + let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand")); + let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3] + let out = subtract_dominant_bands(vec![wand, decke]); + assert_eq!(out.len(), 2); + assert!(approx( + area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::>()), + 3.0 + )); + assert!(approx( + area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::>()), + 2.0 + )); + } + + #[test] + fn ohne_prioritaet_keine_teilnahme() { + let stark = band(0.0, 2.0, 0.0, 2.0, 100, None); + let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None); + ohne.cut = None; // keine Prioritaet + let out = subtract_dominant_bands(vec![stark, ohne]); + // Beide unveraendert (4 + 4). + assert_eq!(out.len(), 2); + assert!(approx(area(&out), 8.0)); + } + + #[test] + fn merge_gleiche_komponente_und_prioritaet() { + // Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId + + // Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante). + let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton")); + let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton")); + let out = subtract_dominant_bands(vec![wand, decke]); + assert_eq!(out.len(), 1); + let r = rect_of(&out[0]); + assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}"); + } +} diff --git a/src-tauri/render3d/src/section_fill.rs b/src-tauri/render3d/src/section_fill.rs index 12964ae..0cad882 100644 --- a/src-tauri/render3d/src/section_fill.rs +++ b/src-tauri/render3d/src/section_fill.rs @@ -21,19 +21,68 @@ //! //! ## Ausgabeformat //! -//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32, -//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als -//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges, -//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im -//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken, -//! siehe `section.rs`) — Fan-Triangulierung genuegt. +//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je +//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern +//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein +//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet +//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der +//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader +//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je +//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das +//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps +//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes +//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt. -use crate::section::{cut_section, SectionPlane}; +use crate::section::{cut_section, CutPolygon, SectionPlane}; +use crate::section_boolean::{rect_of_poly, subtract_dominant_bands}; use crate::types::{SlabInput, WallInput}; -/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts -/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs). -pub const CAP_FLOATS_PER_VERTEX: usize = 5; +/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale, +/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des +/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien- +/// Breite (mm-Papier -> Welt) je Schraffur. +pub const CAP_FLOATS_PER_VERTEX: usize = 9; + +/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch` +/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default. +const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13; + +/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie +/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne +/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen. +pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6; + +/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen +/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie). +const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10]; + +/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn +/// ein Band keinen eigenen `line_weight` traegt. +const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35; + +/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien. +/// +/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig). +/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon +/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig, +/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest). +/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge +/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine +/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe +/// Schlussbericht/Moduldoc). +const CUT_LINE_MM_TO_WORLD: f32 = 0.01; + +/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der +/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von +/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b) +/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen). +const CUT_LINE_LIFT: f32 = 0.002; + +/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal +/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest +/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne +/// aufgeloeste Bauteil-Schraffur). +const FALLBACK_PATTERN: f32 = 2.0; /// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale /// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die @@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5; /// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die /// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt /// nicht von der Normale ab. +/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean- +/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht +/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt +/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen +/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide +/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten. +fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec { + let out = cut_section(plane, walls, slabs); + subtract_dominant_bands(out.cut_polygons) +} + pub fn build_cut_caps( plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput], ) -> Vec { - let out = cut_section(plane, walls, slabs); + let polys = cut_cap_polys(plane, walls, slabs); let point = plane.point; let u_axis = plane.u_axis(); // (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v. @@ -62,14 +122,28 @@ pub fn build_cut_caps( }; let mut verts: Vec = Vec::new(); - for poly in &out.cut_polygons { + for poly in &polys { let pts = &poly.pts; if pts.len() < 3 { continue; } + // Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback- + // Diagonale). Winkel von Grad in Radiant, damit der Shader direkt + // cos/sin rechnen kann. + let (pattern, angle_rad, scale, line_weight) = match poly.hatch { + Some(h) => ( + h.pattern as f32, + h.angle.to_radians(), + h.scale.max(0.05), + h.line_weight.max(0.0), + ), + None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM), + }; let mut push_vertex = |uv: [f32; 2]| { let w = to_world(uv); - verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]); + verts.extend_from_slice(&[ + w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight, + ]); }; // Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1). for i in 1..pts.len() - 1 { @@ -80,3 +154,429 @@ pub fn build_cut_caps( } verts } + +/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck +/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt- +/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`). +/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`, +/// TriangleList), leer wenn die Ebene das Modell nicht schneidet. +/// +/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes +/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default- +/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD` +/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste +/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad +/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur +/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit +/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen. +pub fn build_cut_cap_lines( + plane: &SectionPlane, + walls: &[WallInput], + slabs: &[SlabInput], +) -> Vec { + let polys = cut_cap_polys(plane, walls, slabs); + let point = plane.point; + let u_axis = plane.u_axis(); + let normal = plane.normal; + // Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell). + let lift = [ + -normal[0] * CUT_LINE_LIFT, + -normal[1] * CUT_LINE_LIFT, + -normal[2] * CUT_LINE_LIFT, + ]; + let to_world = |uv: [f32; 2]| -> [f32; 3] { + [ + point[0] + uv[0] * u_axis[0] + lift[0], + uv[1] + lift[1], + point[2] + uv[0] * u_axis[2] + lift[2], + ] + }; + + let mut verts: Vec = Vec::new(); + for poly in &polys { + let Some(r) = rect_of_poly(poly) else { + continue; + }; + // Stil aus den Band-Metadaten (oder Default). + let (color, weight_mm) = match &poly.cut { + Some(meta) => ( + meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR), + meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM), + ), + None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM), + }; + let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4); + + // Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon. + let corners = [ + [r.u_min, r.v_min], + [r.u_max, r.v_min], + [r.u_max, r.v_max], + [r.u_min, r.v_max], + ]; + for k in 0..4 { + let a = to_world(corners[k]); + let b = to_world(corners[(k + 1) % 4]); + push_line_ribbon(&mut verts, a, b, normal, half_w, color); + } + } + verts +} + +/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die +/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber +/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`. +fn push_line_ribbon( + out: &mut Vec, + a: [f32; 3], + b: [f32; 3], + normal: [f32; 3], + half_w: f32, + color: [f32; 3], +) { + let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt(); + if len < 1e-9 { + return; + } + let d = [dir[0] / len, dir[1] / len, dir[2] / len]; + // In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und + // steht senkrecht auf der Kante. + let mut perp = [ + normal[1] * d[2] - normal[2] * d[1], + normal[2] * d[0] - normal[0] * d[2], + normal[0] * d[1] - normal[1] * d[0], + ]; + let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt(); + if plen < 1e-9 { + return; + } + perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w]; + let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]]; + let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]]; + let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]]; + let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]]; + let mut push = |p: [f32; 3]| { + out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]); + }; + // Zwei Dreiecke: p0,p1,p2 und p0,p2,p3. + push(p0); + push(p1); + push(p2); + push(p0); + push(p2); + push(p3); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Hatch; + + /// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten: + /// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM + /// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8). + #[test] + fn hatch_muster_id_landet_in_den_cap_vertices() { + let wall = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.3, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + holes: vec![], + material_index: None, + hatch: Some(Hatch { + pattern: 3, // crosshatch + angle: 90.0, + scale: 2.0, + line_weight: 0.5, + }), + cut: None, + }; + let plane = SectionPlane::looking_plus_x(1.5); + let verts = build_cut_caps(&plane, &[wall], &[]); + + assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie"); + assert_eq!( + verts.len() % CAP_FLOATS_PER_VERTEX, + 0, + "Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})" + ); + let n = verts.len() / CAP_FLOATS_PER_VERTEX; + for i in 0..n { + let base = i * CAP_FLOATS_PER_VERTEX; + assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex"); + assert!( + (verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5, + "angle in Radiant" + ); + assert_eq!(verts[base + 7], 2.0, "scale je Vertex"); + assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex"); + } + } + + /// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2, + /// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster. + #[test] + fn ohne_hatch_fallback_auf_diagonale() { + let wall = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.3, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + holes: vec![], + material_index: None, + hatch: None, + cut: None, + }; + let plane = SectionPlane::looking_plus_x(1.5); + let verts = build_cut_caps(&plane, &[wall], &[]); + assert!(!verts.is_empty()); + assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)"); + assert_eq!(verts[6], 0.0, "Fallback-Winkel 0"); + assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1"); + assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm"); + } + + use crate::types::CutBandMeta; + + /// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band + /// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die + /// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand- + /// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf + /// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft"). + #[test] + fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() { + // Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50. + let wall = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.4, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + holes: vec![], + material_index: None, + hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }), + cut: Some(CutBandMeta { + join_priority: Some(50), + component_id: Some("brick".into()), + line_color: None, + line_weight: None, + }), + }; + // Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt, + // z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand). + let slab = SlabInput { + outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]], + z_bottom: 2.0, + z_top: 2.4, + color: [0.8, 0.8, 0.8], + hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }), + cut: Some(CutBandMeta { + join_priority: Some(100), + component_id: Some("concrete".into()), + line_color: None, + line_weight: None, + }), + }; + let plane = SectionPlane::looking_plus_x(1.5); + let verts = build_cut_caps(&plane, &[wall], &[slab]); + assert!(!verts.is_empty()); + + // Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1 + // (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y, + // Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen. + let n = verts.len() / CAP_FLOATS_PER_VERTEX; + let mut wall_verts = 0; + let mut concrete_verts = 0; + for i in 0..n { + let base = i * CAP_FLOATS_PER_VERTEX; + let y = verts[base + 1]; + let pattern = verts[base + 5]; + if (pattern - 2.0).abs() < 0.5 { + wall_verts += 1; + assert!( + y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3, + "Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)" + ); + } else if (pattern - 1.0).abs() < 0.5 { + concrete_verts += 1; + } + } + assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)"); + assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)"); + } + + /// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND + /// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung + /// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die + /// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs + /// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt. + /// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr + /// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant). + #[test] + fn wand_schneidet_schwaechere_decken_schichten() { + // Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2], + // volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal). + let wall = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.4, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + holes: vec![], + material_index: None, + hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }), + cut: Some(CutBandMeta { + join_priority: Some(50), + component_id: Some("brick".into()), + line_color: None, + line_weight: None, + }), + }; + // Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die + // Wand in u ab (Umriss ueber y in [-1,1]). + let screed = SlabInput { + outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]], + z_bottom: 1.0, + z_top: 1.06, + color: [0.8, 0.8, 0.8], + hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }), + cut: Some(CutBandMeta { + join_priority: Some(30), + component_id: Some("screed".into()), + line_color: None, + line_weight: None, + }), + }; + // Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung. + let insulation = SlabInput { + outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]], + z_bottom: 0.96, + z_top: 1.0, + color: [0.8, 0.8, 0.8], + hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }), + cut: Some(CutBandMeta { + join_priority: Some(20), + component_id: Some("insulation".into()), + line_color: None, + line_weight: None, + }), + }; + let plane = SectionPlane::looking_plus_x(1.5); + let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]); + assert!(!verts.is_empty()); + + // Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder + // Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat + // der Backstein die schwaechere Schicht dort NICHT weggeschnitten. + let n = verts.len() / CAP_FLOATS_PER_VERTEX; + let mut weak_verts = 0; + for i in 0..n { + let base = i * CAP_FLOATS_PER_VERTEX; + let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X) + let pattern = verts[base + 5]; + if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 { + weak_verts += 1; + assert!( + u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3, + "Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \ + [-0.2,0.2] (muesste weggeschnitten sein)" + ); + } + } + assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)"); + } + + /// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon- + /// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe + + /// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber + /// die Ribbon-Breite messbar). + #[test] + fn build_cut_cap_lines_kantenzahl_und_stil() { + let color = [0.2, 0.4, 0.9]; + let wall = WallInput { + start: [0.0, 0.0], + end: [3.0, 0.0], + thickness: 0.4, + height: 2.5, + base_elevation: 0.0, + color: [0.8, 0.8, 0.8], + openings: vec![], + layers: None, + holes: vec![], + material_index: None, + hatch: None, + cut: Some(CutBandMeta { + join_priority: Some(50), + component_id: Some("brick".into()), + line_color: Some(color), + line_weight: Some(2.0), + }), + }; + let plane = SectionPlane::looking_plus_x(1.5); + let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]); + assert!(!verts.is_empty()); + assert_eq!( + verts.len() % CUT_LINE_FLOATS_PER_VERTEX, + 0, + "Vielfaches des Cut-Linien-Strides" + ); + // EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices. + assert_eq!( + verts.len() / CUT_LINE_FLOATS_PER_VERTEX, + 24, + "4 Kanten * 6 Vertices je Ribbon" + ); + // Farbe je Vertex aus dem Stil (Index 3/4/5). + let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX; + for i in 0..n { + let base = i * CUT_LINE_FLOATS_PER_VERTEX; + assert!((verts[base + 3] - color[0]).abs() < 1e-6); + assert!((verts[base + 4] - color[1]).abs() < 1e-6); + assert!((verts[base + 5] - color[2]).abs() < 1e-6); + } + + // Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon. + // Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die + // world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der + // gesamten Geometrie) im Vergleich duenn vs. dick. + let bbox_y = |vs: &[f32]| -> f32 { + let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX; + let mut lo = f32::INFINITY; + let mut hi = f32::NEG_INFINITY; + for i in 0..n { + let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1]; + lo = lo.min(y); + hi = hi.max(y); + } + hi - lo + }; + let mut thin = wall.clone(); + thin.cut = Some(CutBandMeta { + join_priority: Some(50), + component_id: Some("brick".into()), + line_color: Some(color), + line_weight: Some(0.2), + }); + let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]); + assert!( + bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4, + "dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons" + ); + } +} diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index cb7b21f..c669dc5 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -144,15 +144,20 @@ struct Globals { }; @group(0) @binding(0) var globals : Globals; -// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) -> -// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen. -@group(1) @binding(0) var tex : texture_2d; +// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe +// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen. +// Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures` +// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN +// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen. +@group(1) @binding(0) var tex : texture_2d_array; @group(1) @binding(1) var samp : sampler; struct VsIn { @location(0) position : vec3, @location(1) normal : vec3, @location(2) uv : vec2, + // Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett). + @location(3) layer : f32, }; struct VsOut { @@ -160,6 +165,7 @@ struct VsOut { @location(0) world_normal : vec3, @location(1) uv : vec2, @location(2) world_pos : vec3, + @location(3) @interpolate(flat) layer : f32, }; @vertex @@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut { out.world_normal = in.normal; out.uv = in.uv; out.world_pos = in.position; + out.layer = in.layer; return out; } @@ -195,8 +202,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb * vec3(0.24, 0.27, 0.30); - // EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe. - let albedo = textureSample(tex, samp, in.uv).rgb; + // Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex: + // `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den + // gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige + // Indizes, falls die Material-Menge kleiner ist als erwartet). + let max_layer = max(i32(globals.mode.z) - 1, 0); + let li = clamp(i32(round(in.layer)), 0, max_layer); + let albedo = textureSample(tex, samp, in.uv, li).rgb; var shaded = albedo * (ambient + diffuse + fill); @@ -261,11 +273,16 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { "#; /// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline). -/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2], +/// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3], /// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps -/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale -/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern — -/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention. +/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE +/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus +/// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe +/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier, +/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die +/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton), +/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie +/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). pub const CAP_WGSL: &str = r#" struct Globals { view_proj : mat4x4, @@ -281,11 +298,18 @@ struct Globals { struct VsIn { @location(0) position : vec3, @location(1) uv : vec2, + // hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien- + // Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe + // section_fill.rs). + @location(2) hatch : vec4, }; struct VsOut { @builtin(position) clip_pos : vec4, @location(0) uv : vec2, + // flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht + // interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl). + @location(1) @interpolate(flat) hatch : vec4, }; @vertex @@ -293,27 +317,85 @@ fn vs_main(in : VsIn) -> VsOut { var out : VsOut; out.clip_pos = globals.view_proj * vec4(in.position, 1.0); out.uv = in.uv; + out.hatch = in.hatch; return out; } +// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von +// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig. +fn line_band(s : f32, half_s : f32) -> f32 { + let frac = fract(s); + let dist = min(frac, 1.0 - frac); + let aa = max(fwidth(s), 1e-5); + return 1.0 - smoothstep(half_s, half_s + aa, dist); +} + @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { - // 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen - // Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten - // von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm- - // groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel). - let spacing = 0.08; // Linienabstand ~8 cm - let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm) let ink = vec3(0.06, 0.06, 0.06); let paper = vec3(0.94, 0.94, 0.94); - let s = (in.uv.x + in.uv.y) / spacing; - let frac = fract(s); - let dist = min(frac, 1.0 - frac); // 0 auf der Linie - let half_s = half / spacing; - let aa = max(fwidth(s), 1e-5); - let line = 1.0 - smoothstep(half_s, half_s + aa, dist); - let color = mix(paper, ink, line); + let pattern = i32(round(in.hatch.x)); + let angle = in.hatch.y; + let scale = max(in.hatch.z, 0.05); + + // none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D. + if (pattern == 0) { + return vec4(paper, 1.0); + } + // solid (1): Vollton (Poché) — z. B. Beton. + if (pattern == 1) { + return vec4(ink, 1.0); + } + + // Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention + // wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt + // HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den + // Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv` + // ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das + // Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.) + let ca = cos(angle); + let sa = sin(angle); + let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung + let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase) + + let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung + // Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle + // des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin` + // 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien + // (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005), + // damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor, + // damit eine Haarlinie nicht ganz verschwindet. + let half = max(in.hatch.w * 0.005, 0.0001); + let half_s = half / spacing; + + var line = 0.0; + if (pattern == 2) { + // diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 → + // horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D. + line = line_band(dv / spacing, half_s); + } else if (pattern == 3) { + // crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei + // angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D. + line = max( + line_band(dv / spacing, half_s), + line_band(du / spacing, half_s) + ); + } else { + // insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer + // Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack. + let cell = spacing * 2.0; + let a = du / cell; + let b = dv / cell; + let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle + let fb = fract(b); + let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0))); + let aa = max(fwidth(fb) + fwidth(tri), 1e-5); + let hw = half / cell; + line = 1.0 - smoothstep(hw, hw + aa, d); + } + + let color = mix(paper, ink, clamp(line, 0.0, 1.0)); return vec4(color, 1.0); } "#; diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index 032184e..30c4597 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -20,6 +20,79 @@ pub type Point2 = [f32; 2]; /// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht. pub type Rgb = [f32; 3]; +/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges +/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder, +/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id, +/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier, +/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit. +/// +/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°), +/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist +/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Hatch { + /// Muster-Id (siehe oben). Default 0 (none). + #[serde(default)] + pub pattern: u32, + /// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0. + #[serde(default)] + pub angle: f32, + /// Grundmassstab (1 = Standardteilung). Default 1. + #[serde(default = "default_hatch_scale")] + pub scale: f32, + /// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch, + /// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster- + /// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur- + /// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs. + /// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie). + #[serde(default = "default_hatch_line_weight", rename = "lineWeight")] + pub line_weight: f32, +} + +fn default_hatch_scale() -> f32 { + 1.0 +} + +fn default_hatch_line_weight() -> f32 { + 0.13 +} + +/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den +/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts` +/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv +/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut == +/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion +/// teil). +/// +/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box +/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE +/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung +/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten +/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CutBandMeta { + /// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer + /// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck- + /// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme. + #[serde(default)] + pub join_priority: Option, + /// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente + + /// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None` + /// -> keine Verschmelzung. + #[serde(default)] + pub component_id: Option, + /// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem + /// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil. + #[serde(default)] + pub line_color: Option, + /// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle). + /// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich + /// dokumentierte) mm->Welt-Umsetzung. + #[serde(default)] + pub line_weight: Option, +} + /// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke, /// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh /// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`). @@ -82,6 +155,30 @@ pub struct WallInput { /// extrudiert den vollen Achsen-Abschnitt mit Loechern. #[serde(default)] pub holes: Vec, + /// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine + /// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte + /// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da + /// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert + /// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material- + /// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom + /// Schnitt/Serde-Pfad (`section.rs`) ignoriert. + #[serde(default, rename = "materialIndex")] + pub material_index: Option, + /// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der + /// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die + /// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen + /// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der + /// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert + /// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so + /// ihre eigene Schraffur. + #[serde(default)] + pub hatch: Option, + /// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe + /// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`) + /// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz- + /// Subtraktion, Default-Schnittkante). + #[serde(default)] + pub cut: Option, } /// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe @@ -162,6 +259,13 @@ pub struct SlabInput { /// Albedo-Farbe (RGB 0..1). Default heller Deckenton. #[serde(default = "default_slab_color")] pub color: Rgb, + /// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`). + #[serde(default)] + pub hatch: Option, + /// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe + /// `CutBandMeta`/`WallInput::cut`). + #[serde(default)] + pub cut: Option, } fn default_slab_color() -> Rgb { @@ -179,6 +283,9 @@ pub enum MeshKind { /// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau. #[default] Imported, + /// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper — + /// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt. + Extrusion, } impl MeshKind { @@ -191,6 +298,8 @@ impl MeshKind { MeshKind::Terrain => [0.663, 0.690, 0.635], // 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes. MeshKind::Imported => [0.624, 0.651, 0.682], + // Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis). + MeshKind::Extrusion => [0.85, 0.55, 0.25], } } } @@ -308,14 +417,24 @@ impl Default for Camera { } } -/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind -/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch). +/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei +/// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch. +/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/ +/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da +/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/ +/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im +/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CameraPreset { Front, + Back, Top, Side, + Left, Iso, + IsoFrontLeft, + IsoBackRight, + IsoBackLeft, Persp, } @@ -345,6 +464,14 @@ pub struct TexturedMesh { pub verts: Vec, /// Dreiecks-Indizes (0-basiert auf die Vertices). pub indices: Vec, + /// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur- + /// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material" + /// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut + /// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten + /// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts` + /// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die + /// Geometrie-Paritaets-Tests) bitgleich bleiben. + pub layers: Vec, } /// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV. diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index a2559a0..69263b2 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -15,12 +15,13 @@ // (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion // (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention), // damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt. -// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete -// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/ -// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte -// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung -// nicht leisten kann. Nach einem Praeset-Sprung bleibt die -// weitere Navigation frei ueber `set_camera` (kein Lock). +// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/ +// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt +// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT +// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne +// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten +// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber +// `set_camera` (kein Lock). // // MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch // zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts). @@ -330,19 +331,25 @@ impl WebModelRenderer { }; } - /// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht - /// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf - /// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend - /// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch, + /// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht + /// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/ + /// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle + /// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der + /// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch, /// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim /// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles /// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx). pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> { let preset = match preset { "front" => CameraPreset::Front, + "back" => CameraPreset::Back, "top" => CameraPreset::Top, "side" => CameraPreset::Side, + "left" => CameraPreset::Left, "iso" => CameraPreset::Iso, + "isoFrontLeft" => CameraPreset::IsoFrontLeft, + "isoBackRight" => CameraPreset::IsoBackRight, + "isoBackLeft" => CameraPreset::IsoBackLeft, "perspective" => CameraPreset::Persp, other => { return Err(JsValue::from_str(&format!( @@ -377,8 +384,8 @@ impl WebModelRenderer { /// - `"shaded"` — beleuchtete Bauteilfarben (Default). /// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet /// (identisch zum bisherigen `set_render_mode_white(true)`). - /// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"` - /// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht. + /// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array + /// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material. /// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen. /// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth- /// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare). @@ -431,6 +438,20 @@ impl WebModelRenderer { Ok(()) } + /// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch + /// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von + /// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert + /// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt + /// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen + /// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0` + /// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim + /// naechsten `render`. + pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> { + self.renderer + .set_material_textures(&self.device, &self.queue, rgba, layer_count); + Ok(()) + } + /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer). pub fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index daf0c61..6599b5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,32 @@ #[cfg(any(feature = "native2d", feature = "native3d"))] mod native; +// Lock-Datei gegen gleichzeitiges Oeffnen derselben Projektdatei aus zwei +// App-Instanzen (OS-Advisory-Lock via `fs4`, siehe lock.rs). +mod lock; + +use std::path::PathBuf; +use tauri::State; + +/// Fordert den exklusiven Lock fuer eine Projektdatei an. `Ok(())` wenn frei +/// (oder schon von uns gehalten), sonst `Err(Some(LockInfo))` mit Angaben zur +/// haltenden Instanz (pid/hostname/timestamp), oder `Err(None)` falls die +/// Sidecar-Datei gesperrt, aber ihr Info-Block nicht lesbar war. +#[tauri::command] +fn acquire_project_lock( + path: String, + state: State, +) -> Result<(), Option> { + lock::acquire(&state, &PathBuf::from(path)) +} + +/// Gibt einen von dieser Instanz gehaltenen Lock frei. No-op, wenn der Pfad +/// nicht gelockt war. +#[tauri::command] +fn release_project_lock(path: String, state: State) { + lock::release(&state, &PathBuf::from(path)); +} + /// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend. #[tauri::command] async fn compute_joins( @@ -40,7 +66,15 @@ fn push_native_walls(walls: serde_json::Value) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri::Builder::default().setup(|_app| { + let builder = tauri::Builder::default() + // Native „Speichern unter"-Dialog + Datei-Schreiben aus der Webview. + // Immer aktiv (unabhaengig von native2d/native3d), damit Datei-Exporte + // im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden. + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + // Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs). + .manage(lock::LockState::default()) + .setup(|_app| { // Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen // Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt. #[cfg(any(feature = "native2d", feature = "native3d"))] @@ -52,10 +86,16 @@ pub fn run() { let builder = builder.invoke_handler(tauri::generate_handler![ compute_joins, push_native_scene, - push_native_walls + push_native_walls, + acquire_project_lock, + release_project_lock ]); #[cfg(not(any(feature = "native2d", feature = "native3d")))] - let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]); + let builder = builder.invoke_handler(tauri::generate_handler![ + compute_joins, + acquire_project_lock, + release_project_lock + ]); builder .run(tauri::generate_context!()) diff --git a/src-tauri/src/lock.rs b/src-tauri/src/lock.rs new file mode 100644 index 0000000..11ac439 --- /dev/null +++ b/src-tauri/src/lock.rs @@ -0,0 +1,145 @@ +// Lock-Datei gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei +// App-Instanzen. Prinzip: ein OS-Advisory-Lock (Crate `fs4`, gepflegter +// `fs2`-Nachfolger) auf einer Sidecar-Datei `.lock`, gehalten +// über ein offenes `File`-Handle im Tauri-`State`. Der OS-Lock fällt beim +// Prozessende AUTOMATISCH weg (auch bei Absturz) — kein manuelles Aufräumen +// nötig, keine verwaiste Sperre blockiert dauerhaft: ohne aktiven OS-Lock kann +// die nächste Instanz dieselbe Sidecar-Datei problemlos wieder exklusiv locken, +// auch wenn die Datei selbst liegen bleibt. +// +// Zusätzlich schreiben wir einen kleinen JSON-Info-Block (pid/hostname/ +// timestamp) in die Sidecar-Datei, damit eine zweite Instanz bei einem +// Konflikt eine freundliche Meldung zeigen kann ("bereits geöffnet auf …"). + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use fs4::fs_std::FileExt; +use serde::{Deserialize, Serialize}; + +/// Info-Block in der Lock-Sidecar-Datei — wer hält den Lock gerade. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockInfo { + pub pid: u32, + pub hostname: String, + /// Unix-Zeitstempel (Sekunden) als String — reicht für eine Anzeige, + /// keine Notwendigkeit für eine Datumsbibliothek. + pub timestamp: String, +} + +impl LockInfo { + fn current() -> Self { + LockInfo { + pid: std::process::id(), + hostname: hostname(), + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()), + } + } +} + +/// Bester-Versuch-Hostname ohne zusätzliche Abhängigkeit: erst Umgebungs- +/// variablen (Windows/Unix), sonst das `hostname`-Kommando, sonst "unbekannt". +fn hostname() -> String { + for var in ["COMPUTERNAME", "HOSTNAME"] { + if let Ok(h) = std::env::var(var) { + if !h.is_empty() { + return h; + } + } + } + if let Ok(out) = std::process::Command::new("hostname").output() { + if out.status.success() { + if let Ok(s) = String::from_utf8(out.stdout) { + let s = s.trim(); + if !s.is_empty() { + return s.to_string(); + } + } + } + } + "unbekannt".to_string() +} + +/// Alle von DIESER Instanz gehaltenen Locks: Projektdatei-Pfad → offenes +/// File-Handle auf die Sidecar-Lock-Datei. Das Handle hält den OS-Lock, solange +/// es lebt (Drop/Prozessende gibt ihn automatisch frei). +#[derive(Default)] +pub struct LockState(pub Mutex>); + +fn sidecar_path(project_path: &Path) -> PathBuf { + let mut s = project_path.as_os_str().to_os_string(); + s.push(".lock"); + PathBuf::from(s) +} + +/// Fordert den exklusiven Lock für `project_path` an. +/// +/// - Bereits von UNS gehalten (dieselbe Datei nochmal geöffnet) → `Ok(())`. +/// - Frei → OS-Lock holen, eigenen Info-Block reinschreiben, Handle im State +/// behalten, `Ok(())`. +/// - Von einer ANDEREN Instanz gehalten → `Err(Some(info))` mit deren +/// Info-Block (falls lesbar), sonst `Err(None)`. +pub fn acquire(state: &LockState, project_path: &Path) -> Result<(), Option> { + let mut held = state.0.lock().expect("lock state poisoned"); + + if held.contains_key(project_path) { + return Ok(()); + } + + let sidecar = sidecar_path(project_path); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&sidecar) + .map_err(|_| None)?; + + match FileExt::try_lock_exclusive(&file) { + Ok(()) => { + write_info(&file); + held.insert(project_path.to_path_buf(), file); + Ok(()) + } + Err(_) => { + // Gesperrt von einer anderen Instanz -> deren Info-Block lesen (best effort). + Err(read_info(&sidecar)) + } + } +} + +/// Gibt den von uns gehaltenen Lock für `project_path` frei (schliesst das +/// File-Handle → OS-Lock fällt weg). No-op, wenn wir diesen Pfad nicht halten. +pub fn release(state: &LockState, project_path: &Path) { + let mut held = state.0.lock().expect("lock state poisoned"); + held.remove(project_path); +} + +/// Schreibt den aktuellen Info-Block (pid/hostname/timestamp dieser Instanz) +/// in die bereits gelockte Sidecar-Datei. Fehler hier sind nicht fatal — der +/// Lock selbst (das OS-Lock) steht unabhängig davon schon. +fn write_info(file: &File) { + let info = LockInfo::current(); + let Ok(json) = serde_json::to_string(&info) else { + return; + }; + let mut f = file; + let _ = f.set_len(0); + let _ = f.seek(SeekFrom::Start(0)); + let _ = f.write_all(json.as_bytes()); + let _ = f.flush(); +} + +/// Liest den Info-Block aus einer (von einer anderen Instanz gesperrten) +/// Sidecar-Datei. `None` wenn die Datei fehlt/kein gültiges JSON enthält. +fn read_info(sidecar: &Path) -> Option { + let mut contents = String::new(); + File::open(sidecar).ok()?.read_to_string(&mut contents).ok()?; + serde_json::from_str(&contents).ok() +} diff --git a/src-tauri/src/native.rs b/src-tauri/src/native.rs index bdbc908..2b70d25 100644 --- a/src-tauri/src/native.rs +++ b/src-tauri/src/native.rs @@ -344,6 +344,12 @@ fn demo_walls() -> Vec { color: grey, openings: vec![], layers: None, + // Additive Felder (Loecher/Material/Schnitt) — die Demo-Waende nutzen sie + // nicht; Defaults wie im Serde-Pfad. + holes: vec![], + material_index: None, + hatch: None, + cut: None, }; vec![ mk([0.0, 0.0], [6.0, 0.0]), diff --git a/src-tauri/trucksolid/Cargo.lock b/src-tauri/trucksolid/Cargo.lock new file mode 100644 index 0000000..2affb57 --- /dev/null +++ b/src-tauri/trucksolid/Cargo.lock @@ -0,0 +1,1488 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45403b49e3954a4b8428a0ac21a4b7afadccf92bfd96273f1a58cd4812496ae0" +dependencies = [ + "generic-array 0.12.4", + "generic-array 0.13.3", + "generic-array 0.14.9", + "stable_deref_trait", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "boolmesh" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede2e53d77b54cd1aae2a8641693c4c65b0ecd0d423194ca4ef78278b53313a" +dependencies = [ + "glam 0.30.10", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx 0.4.0", + "num-traits", + "serde", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "csgrs" +version = "0.21.0" +source = "git+https://github.com/timschmidt/csgrs?rev=5e7a37a8803d4e56617734687edc9b98f4ebeed7#5e7a37a8803d4e56617734687edc9b98f4ebeed7" +dependencies = [ + "boolmesh", + "doc-image-embed", + "earcutr 0.5.0", + "either", + "geo", + "hashbrown 0.15.5", + "nalgebra", + "parry3d-f64", + "rapier3d-f64", + "robust", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "doc-image-embed" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1c7e5bbb92b417588a5e733e095416c27c06eedc051065cc955a0a8c8389d9" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools 0.11.0", + "num-traits", +] + +[[package]] +name = "earcutr" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc28d89ac6cb489f679fcf51aaa62b4667bb0fb73a30f1d4d23949069707c07" +dependencies = [ + "itertools 0.14.0", + "num-traits", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f797e67af32588215eaaab8327027ee8e71b9dd0b2b26996aedf20c030fce309" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" +dependencies = [ + "earcutr 0.4.3", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar 0.12.2", + "serde", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx 0.4.0", + "num-traits", + "rstar 0.10.0", + "rstar 0.11.0", + "rstar 0.12.2", + "rstar 0.8.4", + "rstar 0.9.3", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" +dependencies = [ + "libm", +] + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +dependencies = [ + "libm", +] + +[[package]] +name = "hash32" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4041af86e63ac4298ce40e5cca669066e75b6f1aa3390fe2561ffa5e1d9f4cc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634bd4d29cbf24424d0a4bfcbf80c6960129dc24424752a7d1d1390607023422" +dependencies = [ + "as-slice", + "generic-array 0.14.9", + "hash32 0.1.1", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "i_float" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" + +[[package]] +name = "i_overlay" +version = "4.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", +] + +[[package]] +name = "i_shape" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "nalgebra" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +dependencies = [ + "approx 0.5.1", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "serde", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parry3d-f64" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38fe282b81b60a2aee7f24db25ea73b3c82f6451888eeb5936b621adb87aa653" +dependencies = [ + "approx 0.5.1", + "arrayvec", + "bitflags", + "downcast-rs", + "either", + "ena", + "foldhash 0.2.0", + "hashbrown 0.16.1", + "log", + "nalgebra", + "num-derive", + "num-traits", + "ordered-float", + "rstar 0.12.2", + "simba", + "slab", + "smallvec", + "spade", + "thiserror 2.0.18", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pdqselect" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec91767ecc0a0bbe558ce8c9da33c068066c57ecc8bb8477ef8c1ad3ef77c27" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rapier3d-f64" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8a807527ff2f1f2486f24a24058bdf7edd5f51d96a75b7a2060907bfbfc88b" +dependencies = [ + "approx 0.5.1", + "arrayvec", + "bit-vec", + "bitflags", + "downcast-rs", + "log", + "nalgebra", + "num-derive", + "num-traits", + "ordered-float", + "parry3d-f64", + "profiling", + "rustc-hash 2.1.3", + "simba", + "static_assertions", + "thiserror 2.0.18", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rstar" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a45c0e8804d37e4d97e55c6f258bc9ad9c5ee7b07437009dd152d764949a27c" +dependencies = [ + "heapless 0.6.1", + "num-traits", + "pdqselect", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40f1bfe5acdab44bc63e6699c28b74f75ec43afb59f3eda01e145aff86a25fa" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f39465655a1e3d8ae79c6d9e007f4953bfc5d55297602df9dc38f9ae9f1359a" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73111312eb7a2287d229f06c00ff35b51ddee180f017ab6dec1f69d62ac098d6" +dependencies = [ + "heapless 0.7.17", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx 0.5.1", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "truck-base" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3cc98d706bbced101c2ea45553191ca7b62420b5fc0d5dda37cba4c1f4587d" +dependencies = [ + "cgmath", + "serde", +] + +[[package]] +name = "truck-geometry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4c68556e343a2882ea9e272a70d58477329d8ca7bd8b88cd6d343727c5996" +dependencies = [ + "derive_more", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "truck-geotrait" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5997cc03284cadd023103792fd75dfe749484667ecc12b7fc24a49260912b31" +dependencies = [ + "getrandom", + "rand", + "thiserror 1.0.69", + "truck-base", +] + +[[package]] +name = "truck-modeling" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b8cb8f412cff93ababbc2bfb309cde3c07fdad3534d7addba01cc58a460a85d" +dependencies = [ + "derive_more", + "rustc-hash 1.1.0", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geometry", + "truck-geotrait", + "truck-polymesh", + "truck-topology", +] + +[[package]] +name = "truck-polymesh" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43325f9c53cda943a325d55cdf5cbc4dd1ee1c56c26e73dae616d757e8bc33fe" +dependencies = [ + "bytemuck", + "rustc-hash 1.1.0", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "truck-topology" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "770605757e8373c38d610e3be82afd1fc6bb7550012b8ae688186ff532106902" +dependencies = [ + "rustc-hash 1.1.0", + "serde", + "thiserror 1.0.69", + "truck-base", + "truck-geotrait", +] + +[[package]] +name = "trucksolid" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "csgrs", + "nalgebra", + "serde", + "serde_json", + "truck-modeling", + "wasm-bindgen", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src-tauri/trucksolid/Cargo.toml b/src-tauri/trucksolid/Cargo.toml new file mode 100644 index 0000000..338ce75 --- /dev/null +++ b/src-tauri/trucksolid/Cargo.toml @@ -0,0 +1,42 @@ +[workspace] + +[package] +name = "trucksolid" +version = "0.1.0" +edition = "2021" +description = "Profil-Extrusion via truck B-Rep → tesselliertes Mesh für render3d" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = [] +web = [ + "dep:wasm-bindgen", + "dep:serde_json", + "dep:console_error_panic_hook", +] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +console_error_panic_hook = { version = "0.1", optional = true } +# truck-modeling bringt truck-geometry/topology/polymesh transitiv mit (alle +# auf derselben kompatiblen Version). Nur truck-modeling explizit pinnen — +# keine separaten geometry/topology-Einträge, die einen Versions-Konflikt erzeugen. +truck-modeling = "0.3" +# Mesh-Ebenen-CSG (Boolesche Operationen) — bewusst NICHT truck-modeling-Booleans +# (instabil bei koinzidenten Flächen, siehe PENDENZEN.md truck-Integration Phase 4). +# csgrs arbeitet auf Dreiecks-Ebene (BSP-Baum), robust genau in diesem Fall. +# Gepinnt auf einen Git-Commit (crates.io-Veröffentlichungen 0.16–0.20.1 sind alle +# wegen einer harten, zurückgezogenen core2-Abhängigkeit nicht installierbar; auf +# dem unveröffentlichten main-Branch bereits behoben). Nutzerautorisiert (Spike). +csgrs = { git = "https://github.com/timschmidt/csgrs", rev = "5e7a37a8803d4e56617734687edc9b98f4ebeed7", default-features = false, features = ["f64", "bmesh", "mesh", "earcut"] } +nalgebra = "0.34" + +[dev-dependencies] +serde_json = "1" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/src-tauri/trucksolid/src/boolean.rs b/src-tauri/trucksolid/src/boolean.rs new file mode 100644 index 0000000..dc6b539 --- /dev/null +++ b/src-tauri/trucksolid/src/boolean.rs @@ -0,0 +1,212 @@ +// Boolesche Operationen (Union/Differenz/Schnitt) zwischen zwei Dreiecks-Meshes. +// Mesh-Ebenen-CSG via csgrs (BSP-Baum) statt truck-modeling-Booleans — letztere +// sind bei koinzidenten/tangentialen Flächen instabil (siehe PENDENZEN.md, +// truck-Integration Phase 4, monstertruck-solid-Spike). csgrs erwies sich im +// Spike an genau diesem Fall (Extrusion bündig/eingebunden in eine Wand, +// deckungsgleicher Querschnitt) als exakt korrekt. + +use csgrs::csg::CSG; +use csgrs::mesh::Mesh as CsgMesh; +use csgrs::polygon::Polygon; +use csgrs::triangulated::Triangulated3D; +use csgrs::vertex::Vertex; +use nalgebra::Point3; +use serde::{Deserialize, Serialize}; + +/// Eingabe-Mesh für eine boolesche Operation: flache f64-Positionen (Modell- +/// Meter) + Dreiecks-Indizes. Getrennt von `MeshOutput` (dort f32, da Render- +/// Ausgabe) — hier f64, da Eingabe aus Modelldaten und Präzision für die BSP- +/// Klassifikation an koinzidenten Flächen zählt. +#[derive(Deserialize)] +pub struct BooleanMeshInput { + pub a_positions: Vec, + pub a_indices: Vec, + pub b_positions: Vec, + pub b_indices: Vec, + /// "union" | "difference" | "intersection" + pub op: String, +} + +#[derive(Serialize)] +pub struct BooleanMeshOutput { + pub positions: Vec, + pub indices: Vec, + /// Ob das Ergebnis leer ist (z. B. Schnitt zweier sich nur berührender + /// Körper) — csgrs liefert das als Trimesh-Fehler statt eines leeren + /// Meshes zurück, hier auf einen sauberen Fall normalisiert. + pub empty: bool, +} + +fn mesh_from_triangles(positions: &[f64], indices: &[u32]) -> Result, String> { + if indices.len() % 3 != 0 { + return Err("indices müssen Dreiecke sein (Vielfaches von 3)".into()); + } + let n_verts = positions.len() / 3; + let mut polygons = Vec::with_capacity(indices.len() / 3); + for tri in indices.chunks(3) { + let mut pts = [Point3::origin(); 3]; + for (k, &i) in tri.iter().enumerate() { + let idx = i as usize; + if idx >= n_verts { + return Err(format!("Index {idx} außerhalb der Positions-Liste")); + } + let o = idx * 3; + pts[k] = Point3::new(positions[o], positions[o + 1], positions[o + 2]); + } + let normal = (pts[1] - pts[0]).cross(&(pts[2] - pts[0])); + let verts = vec![ + Vertex::new(pts[0], normal), + Vertex::new(pts[1], normal), + Vertex::new(pts[2], normal), + ]; + polygons.push(Polygon::new(verts, ())); + } + Ok(CsgMesh::from_polygons(&polygons, ())) +} + +fn triangles_from_mesh(m: &CsgMesh<()>) -> (Vec, Vec) { + let mut positions: Vec = Vec::new(); + let mut indices: Vec = Vec::new(); + let mut next = 0u32; + m.visit_triangles(|tri| { + for v in &tri { + positions.push(v.position.x as f32); + positions.push(v.position.y as f32); + positions.push(v.position.z as f32); + } + indices.push(next); + indices.push(next + 1); + indices.push(next + 2); + next += 3; + }); + (positions, indices) +} + +pub fn boolean_mesh_core( + a_positions: &[f64], + a_indices: &[u32], + b_positions: &[f64], + b_indices: &[u32], + op: &str, +) -> Result { + let a = mesh_from_triangles(a_positions, a_indices)?; + let b = mesh_from_triangles(b_positions, b_indices)?; + let result = match op { + "union" => a.union(&b), + "difference" => a.difference(&b), + "intersection" => a.intersection(&b), + other => return Err(format!("unbekannte boolesche Operation: {other}")), + }; + let (positions, indices) = triangles_from_mesh(&result); + Ok(BooleanMeshOutput { + empty: indices.is_empty(), + positions, + indices, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cuboid(x: f64, y: f64, z: f64, w: f64, l: f64, h: f64) -> (Vec, Vec) { + let corners = [ + (x, y, z), + (x + w, y, z), + (x + w, y + l, z), + (x, y + l, z), + (x, y, z + h), + (x + w, y, z + h), + (x + w, y + l, z + h), + (x, y + l, z + h), + ]; + let mut positions = Vec::with_capacity(24); + for (cx, cy, cz) in corners { + positions.push(cx); + positions.push(cy); + positions.push(cz); + } + // 12 Dreiecke, konsistent nach außen orientiert (Rechte-Hand-Regel). + let indices: Vec = vec![ + 0, 2, 1, 0, 3, 2, // unten (-z) + 4, 5, 6, 4, 6, 7, // oben (+z) + 0, 5, 4, 0, 1, 5, // -y + 1, 6, 5, 1, 2, 6, // +x + 2, 7, 6, 2, 3, 7, // +y + 3, 4, 7, 3, 0, 4, // -x + ]; + (positions, indices) + } + + fn volume_of(positions: &[f32], indices: &[u32]) -> f64 { + // Divergenztheorem (Tetraeder vom Ursprung), robust für beliebige geschlossene Dreiecksmeshes. + let mut vol = 0.0f64; + for tri in indices.chunks(3) { + let mut p = [[0.0f64; 3]; 3]; + for (k, &i) in tri.iter().enumerate() { + let o = i as usize * 3; + p[k] = [ + positions[o] as f64, + positions[o + 1] as f64, + positions[o + 2] as f64, + ]; + } + vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2]) + - p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2]) + + p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1])) + / 6.0; + } + vol.abs() + } + + #[test] + fn union_of_overlapping_cuboids() { + let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0); + let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "union").unwrap(); + assert!(!r.empty); + assert!((volume_of(&r.positions, &r.indices) - 1.875).abs() < 1e-6); + } + + #[test] + fn intersection_of_overlapping_cuboids() { + let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0); + let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "intersection").unwrap(); + assert!(!r.empty); + assert!((volume_of(&r.positions, &r.indices) - 0.125).abs() < 1e-6); + } + + /// Praxisfall: Extrusion 0.1m in eine Wand eingebunden, deckungsgleicher + /// Querschnitt — genau der Fall, an dem monstertruck-solid scheiterte. + #[test] + fn wall_minus_embedded_extrusion_matching_cross_section() { + let wall = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let embedded = cuboid(0.9, 0.0, 0.0, 1.0, 1.0, 1.0); + let r = boolean_mesh_core(&wall.0, &wall.1, &embedded.0, &embedded.1, "difference").unwrap(); + assert!(!r.empty); + assert!((volume_of(&r.positions, &r.indices) - 0.9).abs() < 1e-6); + } + + #[test] + fn flush_touching_union_is_exact() { + let a = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let b = cuboid(1.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let r = boolean_mesh_core(&a.0, &a.1, &b.0, &b.1, "union").unwrap(); + assert!(!r.empty); + assert!((volume_of(&r.positions, &r.indices) - 2.0).abs() < 1e-6); + } + + #[test] + fn rejects_non_triangle_indices() { + let (ap, _) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0); + let bad_indices = vec![0u32, 1, 2, 3]; + assert!(boolean_mesh_core(&ap, &bad_indices, &ap, &bad_indices, "union").is_err()); + } + + #[test] + fn rejects_unknown_op() { + let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0); + assert!(boolean_mesh_core(&ap, &ai, &ap, &ai, "xor").is_err()); + } +} diff --git a/src-tauri/trucksolid/src/lib.rs b/src-tauri/trucksolid/src/lib.rs new file mode 100644 index 0000000..43d2d37 --- /dev/null +++ b/src-tauri/trucksolid/src/lib.rs @@ -0,0 +1,546 @@ +// Profil-Extrusion via truck B-Rep. Erzeugt ein tesselliertes Mesh aus einem +// 2D-Polygon-Querschnitt (XY-Ebene, Modell-Meter) durch lineare Extrusion +// entlang +Z. Ausgabe ist kompatibel mit render3d::types::MeshInput. +// +// truck-Architektur: +// truck_modeling::builder — Vertex/Edge/Wire/Face bauen + tsweep (Validierung) +// Tessellierung — direkt aus den Eingangskoordinaten (Prismen-Geometrie) +// Bewusst NICHT genutzt: truck-modeling Booleans (instabil upstream). +// truck-polymesh wird nicht benötigt (keine Tess.-API für Solids in 0.3). + +use serde::{Deserialize, Serialize}; +use truck_modeling::*; + +mod boolean; +pub use boolean::{boolean_mesh_core, BooleanMeshInput, BooleanMeshOutput}; + +/// Flaches [x0,y0, x1,y1, …] Array + Höhe → Extrusion. +#[derive(Deserialize)] +pub struct ExtrudePolyInput { + pub points: Vec, + pub height: f64, + /// Verjüngung 0.0 (Prisma, Default) … 1.0 (Spitze/Kegel-Pyramide). Fehlt + /// das Feld (ältere Aufrufer), greift serde-Default 0.0 — unverändertes + /// Prisma-Verhalten. + #[serde(default)] + pub taper: f64, +} + +/// Tesselliertes Mesh, kompatibel mit render3d::types::MeshInput. +/// positions: flat [x,y,z, …] in Modell-Metern; indices: Dreiecks-Indizes. +#[derive(Serialize)] +pub struct MeshOutput { + pub positions: Vec, + pub indices: Vec, +} + +/// Extrudiert ein geschlossenes Polygon (≥3 Punkte) um `height` Meter nach +Z, +/// optional verjüngt (`taper` 0.0 Prisma … 1.0 Spitze/Kegel-Pyramide — linear +/// zum Schwerpunkt skaliert). Nutzt truck::builder zur Validierung der +/// Grundfläche; tesselliert dann direkt aus den Koordinaten. +pub fn extrude_polygon_core( + pts: &[(f64, f64)], + height: f64, + taper: f64, +) -> std::result::Result { + if pts.len() < 3 { + return Err("min 3 Punkte".into()); + } + if height <= 0.0 { + return Err("height muss > 0 sein".into()); + } + if !(0.0..=1.0).contains(&taper) { + return Err("taper muss zwischen 0 und 1 liegen".into()); + } + + // Letzten Punkt entfernen falls er den ersten wiederholt (geschlossener Ring). + let ring: Vec<(f64, f64)> = { + let mut r = pts.to_vec(); + if r.len() >= 2 { + let first = r[0]; + let last = *r.last().unwrap(); + if (first.0 - last.0).abs() < 1e-10 && (first.1 - last.1).abs() < 1e-10 { + r.pop(); + } + } + r + }; + if ring.len() < 3 { + return Err("min 3 eindeutige Punkte".into()); + } + let n = ring.len(); + + // ── truck B-Rep: try_attach_plane validiert Planarität und Degenerierung ── + let verts: Vec = ring + .iter() + .map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0))) + .collect(); + let edges: Vec = (0..n) + .map(|i| builder::line(&verts[i], &verts[(i + 1) % n])) + .collect(); + let wire = Wire::from_iter(edges); + // Gibt Err zurück bei degeneriertem / nicht-ebenem Profil. + let face = builder::try_attach_plane(&[wire]).map_err(|e| format!("{e}"))?; + // tsweep erzeugt den Solid — wir nutzen ihn zur Vollständigkeit, auch wenn + // wir ihn für die Tessellierung nicht direkt traversieren (nur fürs + // ungetaperte Prisma sinnvoll validiert; Verjüngung ist reine Tessellierung). + let _solid: Solid = builder::tsweep(&face, Vector3::new(0.0, 0.0, height)); + + let centroid = polygon_centroid(&ring); + + // Deckel-/Boden-Triangulierung fuer BELIEBIGE (auch konkave) Profile per + // Ohr-Clipping — NICHT per Fächer-ab-Vertex-0 (der nur fuer konvexe bzw. + // von Vertex 0 aus sternfoermige Polygone korrekt ist; bei einem T-Traeger + // o.ae. erzeugt ein Fächer Phantom-Dreiecke quer durch die konkave Kerbe). + // `cap_tris` ist bereits in der Konvention orientiert, die der bisherige + // "Fächer ab Vertex 0" fuer KONVEXE Ringe erzeugt hat (a,b,c mit + // aufsteigendem Index), sodass die Deck-/Boden-Zuordnung unten unveraendert + // bleibt. + let cap_tris = ear_triangulate(&ring); + + // Volle Verjüngung (taper ≈ 1): Spitze statt entartetem Deck-Ring — Kegel/ + // Pyramide als n Boden-Vertices + 1 Spitzen-Vertex, sonst gäbe es + // Nulldreiecke (Deckfläche + halbe Seitenflächen) mit Kreuzprodukt-Normale + // (0,0,0) am Deck. + if taper >= 1.0 - 1e-9 { + let mut positions: Vec = Vec::with_capacity((n + 1) * 3); + for &(x, y) in &ring { + positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]); + } + positions.extend_from_slice(&[centroid.0 as f32, centroid.1 as f32, height as f32]); + let apex = n as u32; + + let mut indices: Vec = Vec::with_capacity((cap_tris.len() + n) * 3); + // Bodenfläche: wie im Prisma-Fall die zu "oben" umgekehrte Wicklung + // (CCW von unten). + for &(a, b, c) in &cap_tris { + indices.extend_from_slice(&[a as u32, c as u32, b as u32]); + } + // Seitenflächen: ein Dreieck je Kante zur Spitze. + for i in 0..n as u32 { + let j = (i + 1) % n as u32; + indices.extend_from_slice(&[i, j, apex]); + } + return Ok(MeshOutput { positions, indices }); + } + + // ── Tessellierung (Prisma bei taper=0, sonst Pyramidenstumpf) ── + // Für ein n-Eck ergeben sich: + // Bodenfläche: len(cap_tris) Dreiecke (Ohr-Clipping, konkav-sicher) + // Deckfläche: len(cap_tris) Dreiecke + // Seitenflächen: n * 2 Dreiecke (je Kante ein Rechteck → 2 Dreiecke) + + let mut positions: Vec = Vec::with_capacity(2 * n * 3); + let mut indices: Vec = Vec::with_capacity((cap_tris.len() * 2 + n * 2) * 3); + + // Vertices: Boden (0..n-1) gefolgt von Dach (n..2n-1) — Dach zum + // Schwerpunkt hin um (1-taper) skaliert (taper=0 ⇒ Prisma, unveränderte + // Kontur; 0 f64 { + let n = ring.len(); + let mut a = 0.0; + let mut j = n - 1; + for i in 0..n { + a += ring[j].0 * ring[i].1 - ring[i].0 * ring[j].1; + j = i; + } + a * 0.5 +} + +/// Flächen-gewichteter Schwerpunkt eines einfachen Polygons (nicht der reine +/// Vertex-Mittelwert, der bei asymmetrischen/konkaven Profilen verzerrt ist). +/// Fallback auf Vertex-Mittelwert bei (nahezu) entarteter Fläche. +fn polygon_centroid(ring: &[(f64, f64)]) -> (f64, f64) { + let n = ring.len(); + let a = signed_area_f64(ring); + if a.abs() < 1e-12 { + let (sx, sy) = ring.iter().fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y)); + return (sx / n as f64, sy / n as f64); + } + let mut cx = 0.0; + let mut cy = 0.0; + for i in 0..n { + let (x0, y0) = ring[i]; + let (x1, y1) = ring[(i + 1) % n]; + let cross = x0 * y1 - x1 * y0; + cx += (x0 + x1) * cross; + cy += (y0 + y1) * cross; + } + (cx / (6.0 * a), cy / (6.0 * a)) +} + +/// Kreuzprodukt (b-a) x (c-a) im Grundriss. +#[inline] +fn cross2_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) +} + +/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert). +fn point_in_tri_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64), p: (f64, f64)) -> bool { + let d1 = cross2_f64(a, b, p); + let d2 = cross2_f64(b, c, p); + let d3 = cross2_f64(c, a, p); + let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(has_neg && has_pos) +} + +/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Rings — robust fuer +/// konvexe UND konkave Profile (z. B. T-Traeger, L-Profil, Freiform). Portiert +/// von `render3d::mesh::triangulate` (dort fuer Decken/Slabs genutzt) auf f64. +/// O(n^2), fuer Extrusionsprofile mit wenigen Ecken voellig ausreichend. +/// Liefert Dreiecke als (a,b,c)-Indextripel (0-basiert auf `ring`), in der +/// Wicklung des (intern auf CCW normalisierten) Rings. +fn ear_triangulate(ring: &[(f64, f64)]) -> Vec<(usize, usize, usize)> { + let n = ring.len(); + if n < 3 { + return Vec::new(); + } + let mut idx: Vec = (0..n).collect(); + if signed_area_f64(ring) < 0.0 { + idx.reverse(); + } + + let mut tris: Vec<(usize, usize, usize)> = Vec::new(); + let mut guard = 0usize; + let max_guard = n * n + 16; + + while idx.len() > 3 && guard < max_guard { + guard += 1; + let mut clipped = false; + let m = idx.len(); + for i in 0..m { + let i_prev = idx[(i + m - 1) % m]; + let i_cur = idx[i]; + let i_next = idx[(i + 1) % m]; + let a = ring[i_prev]; + let b = ring[i_cur]; + let c = ring[i_next]; + if cross2_f64(a, b, c) <= 0.0 { + continue; // konkav/kollinear -> kein Ohr + } + let mut contains = false; + for &vi in &idx { + if vi == i_prev || vi == i_cur || vi == i_next { + continue; + } + if point_in_tri_f64(a, b, c, ring[vi]) { + contains = true; + break; + } + } + if contains { + continue; + } + tris.push((i_prev, i_cur, i_next)); + idx.remove(i); + clipped = true; + break; + } + if !clipped { + break; + } + } + if idx.len() == 3 { + tris.push((idx[0], idx[1], idx[2])); + } + tris +} + +/// Zylinder-Extrusion (Kreis-Querschnitt), optional verjüngt (Kegel bei +/// `taper=1.0`). Tesselliert den Kreis in N Segmente und ruft +/// `extrude_polygon_core` auf. +pub fn extrude_circle_core( + cx: f64, + cy: f64, + r: f64, + height: f64, + taper: f64, +) -> std::result::Result { + if r <= 0.0 { + return Err("Radius muss > 0 sein".into()); + } + let n = ((2.0 * std::f64::consts::PI * r / 0.02).ceil() as usize).max(16); + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64; + (cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + extrude_polygon_core(&pts, height, taper) +} + +// ── WASM-Bindings (nur mit Feature "web") ──────────────────────────────────── + +#[cfg(feature = "web")] +mod web { + use super::*; + use wasm_bindgen::prelude::*; + + #[wasm_bindgen(start)] + pub fn init() { + console_error_panic_hook::set_once(); + } + + /// Extrudiert ein Polygon-Profil. + /// Input JSON: `{ "points": [x0,y0,…], "height": 2.5 }` + /// Output JSON: `{ "positions": […], "indices": […] }` oder JsError. + #[wasm_bindgen] + pub fn extrude_polygon(input_json: &str) -> std::result::Result { + let input: ExtrudePolyInput = + serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?; + if input.points.len() % 2 != 0 { + return Err(JsError::new("points muss x/y-Paare enthalten")); + } + let pts: Vec<(f64, f64)> = input + .points + .chunks(2) + .map(|c| (c[0], c[1])) + .collect(); + let mesh = extrude_polygon_core(&pts, input.height, input.taper) + .map_err(|e| JsError::new(&e.to_string()))?; + serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string())) + } + + /// Extrudiert einen Kreis-Querschnitt (Zylinder, oder Kegel bei `taper=1`). + /// Input JSON: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0, "taper": 0.0 }` + #[wasm_bindgen] + pub fn extrude_circle(input_json: &str) -> std::result::Result { + #[derive(serde::Deserialize)] + struct In { + cx: f64, + cy: f64, + r: f64, + height: f64, + #[serde(default)] + taper: f64, + } + let i: In = + serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?; + let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height, i.taper) + .map_err(|e| JsError::new(&e.to_string()))?; + serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string())) + } + + /// Boolesche Operation zwischen zwei Dreiecks-Meshes (Mesh-Ebenen-CSG via + /// csgrs). Input JSON: `{ "a_positions": […], "a_indices": […], + /// "b_positions": […], "b_indices": […], "op": "union"|"difference"|"intersection" }` + /// Output JSON: `{ "positions": […], "indices": […], "empty": bool }` + #[wasm_bindgen] + pub fn boolean_mesh(input_json: &str) -> std::result::Result { + let input: BooleanMeshInput = + serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?; + let mesh = boolean_mesh_core( + &input.a_positions, + &input.a_indices, + &input.b_positions, + &input.b_indices, + &input.op, + ) + .map_err(|e| JsError::new(&e))?; + serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string())) + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quad_extrusion_vertex_count() { + let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]; + let m = extrude_polygon_core(&pts, 2.0, 0.0).unwrap(); + // 2 * 4 = 8 Vertices → 24 Floats + assert_eq!(m.positions.len(), 8 * 3, "Vertex-Anzahl"); + assert_eq!(m.indices.len() % 3, 0, "unvollständige Dreiecke"); + // Quader: 2*(4-2) + 4*2 = 4 + 8 = 12 Dreiecke + assert_eq!(m.indices.len(), 12 * 3, "Dreieck-Anzahl für Quader"); + } + + #[test] + fn l_profile_extrusion() { + let pts = vec![ + (0.0, 0.0), + (0.3, 0.0), + (0.3, 0.1), + (0.1, 0.1), + (0.1, 0.3), + (0.0, 0.3), + ]; + let m = extrude_polygon_core(&pts, 3.0, 0.0).unwrap(); + assert_eq!(m.indices.len() % 3, 0); + assert!(!m.positions.is_empty()); + } + + #[test] + fn cylinder_extrusion() { + let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0, 0.0).unwrap(); + assert_eq!(m.indices.len() % 3, 0); + assert!(!m.positions.is_empty()); + } + + #[test] + fn rejects_too_few_points() { + assert!(extrude_polygon_core(&[(0.0, 0.0), (1.0, 0.0)], 1.0, 0.0).is_err()); + } + + #[test] + fn rejects_zero_height() { + let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]; + assert!(extrude_polygon_core(&pts, 0.0, 0.0).is_err()); + } + + #[test] + fn rejects_taper_out_of_range() { + let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]; + assert!(extrude_polygon_core(&pts, 1.0, 1.5).is_err()); + assert!(extrude_polygon_core(&pts, 1.0, -0.1).is_err()); + } + + /// Kegel-Volumen (taper=1, Kreis-Querschnitt) via Divergenzsatz gegen die + /// analytische Formel V = π·r²·h/3 geprüft. + #[test] + fn cone_taper_one_has_correct_volume() { + let r = 0.5; + let h = 2.0; + let m = extrude_circle_core(0.0, 0.0, r, h, 1.0).unwrap(); + // Spitze ist der letzte Vertex — Boden-Vertices sind alle bei z=0, kein + // separater Deck-Ring mehr (kein Nulldreieck-Risiko). + assert_eq!(m.positions.len() % 3, 0); + let n_verts = m.positions.len() / 3; + // Boden-N-Eck + 1 Spitze. + let apex_z = m.positions[(n_verts - 1) * 3 + 2]; + assert!((apex_z as f64 - h).abs() < 1e-6, "Spitze muss bei z=height liegen"); + + let mut vol = 0.0f64; + for tri in m.indices.chunks(3) { + let mut p = [[0.0f64; 3]; 3]; + for (k, &i) in tri.iter().enumerate() { + let o = i as usize * 3; + p[k] = [m.positions[o] as f64, m.positions[o + 1] as f64, m.positions[o + 2] as f64]; + } + vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2]) + - p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2]) + + p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1])) + / 6.0; + } + let expected = std::f64::consts::PI * r * r * h / 3.0; + // Kreis ist n-eck-tesselliert (nicht analytisch exakt) → grobe Toleranz. + assert!((vol.abs() - expected).abs() / expected < 0.01, "Kegel-Volumen: {} vs {}", vol.abs(), expected); + } + + /// Pyramidenstumpf (taper=0.5, Quadrat-Querschnitt): Deckfläche muss auf + /// die Hälfte der Kantenlänge geschrumpft sein, zentriert um den + /// Schwerpunkt der Grundfläche. + #[test] + fn frustum_taper_half_shrinks_top_toward_centroid() { + let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]; + let m = extrude_polygon_core(&pts, 1.0, 0.5).unwrap(); + // Prisma-Topologie bleibt erhalten (2n Vertices, kein Spitzen-Sonderfall). + assert_eq!(m.positions.len(), 8 * 3); + // Deck-Ring: Vertices 4..7. Schwerpunkt der Grundfläche ist (1,1). + // scale=0.5 ⇒ Ecke (0,0)→(1,1)+0.5*((0,0)-(1,1))=(0.5,0.5). + let top0 = (m.positions[4 * 3] as f64, m.positions[4 * 3 + 1] as f64); + assert!((top0.0 - 0.5).abs() < 1e-6 && (top0.1 - 0.5).abs() < 1e-6, "Deck-Ecke: {:?}", top0); + } + + /// Punkt-in-Polygon (Ray-Casting) für die Korrektheits-Prüfung unten. + fn point_in_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool { + let n = poly.len(); + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = poly[i]; + let (xj, yj) = poly[j]; + if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) { + inside = !inside; + } + j = i; + } + inside + } + + /// T-Träger-Querschnitt (konkav, NICHT von Vertex 0 aus sternförmig) — + /// genau das Zielprofil aus PENDENZEN/truck-plan.md. Eine Fächer-Triangu- + /// lierung ab Vertex 0 erzeugt hier Phantom-Dreiecke quer durch die + /// konkave Kerbe (nachgerechnet: 2 von 6 Fächer-Dreiecken liegen mit ihrem + /// Schwerpunkt ausserhalb des Profils). Regressionstest fürs Ohr-Clipping. + #[test] + fn t_beam_caps_stay_inside_polygon() { + let pts = vec![ + (1.0, 0.0), + (2.0, 0.0), + (2.0, 1.0), + (3.0, 1.0), + (3.0, 2.0), + (0.0, 2.0), + (0.0, 1.0), + (1.0, 1.0), + ]; + let m = extrude_polygon_core(&pts, 1.0, 0.0).unwrap(); + let mut checked = 0; + for tri in m.indices.chunks(3) { + let p: Vec<[f32; 3]> = tri + .iter() + .map(|&i| { + let o = i as usize * 3; + [m.positions[o], m.positions[o + 1], m.positions[o + 2]] + }) + .collect(); + // Nur Deckel/Boden (alle 3 Ecken auf derselben Höhe) prüfen, nicht + // die Seitenwand-Quads (die spannen z zwischen 0 und height). + if (p[0][2] - p[1][2]).abs() > 1e-6 || (p[1][2] - p[2][2]).abs() > 1e-6 { + continue; + } + let centroid = ( + (p[0][0] + p[1][0] + p[2][0]) as f64 / 3.0, + (p[0][1] + p[1][1] + p[2][1]) as f64 / 3.0, + ); + assert!( + point_in_polygon(centroid, &pts), + "Deckel-/Boden-Dreieck-Schwerpunkt {:?} liegt ausserhalb des T-Profils", + centroid + ); + checked += 1; + } + assert!(checked > 0, "kein Deckel-/Boden-Dreieck gefunden"); + } +} diff --git a/src/App.tsx b/src/App.tsx index 77ec77f..5a90914 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,17 +25,24 @@ import { wallTypeThickness, } from "./model/types"; import { wallCorners } from "./model/geometry"; -import { leftNormal, normalize, scale, sub } from "./model/geometry"; +import { add, dot, leftNormal, normalize, scale, sub } from "./model/geometry"; import type { CeilingType, + ContextObject, + DoorType, Drawing2D, DrawingLevel, EdgeGrip, LayerCategory, + Layout, + OverrideRule, Project, + Stair, + StairType, Vec2, Wall, WallType, + WindowType, } from "./model/types"; import { generatePlan, generateSectionPlan } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan"; @@ -55,7 +62,25 @@ import { ExportDxfDialog } from "./ui/ExportDxfDialog"; import type { ExportDxfDecision } from "./ui/ExportDxfDialog"; import { savePlanDxf } from "./export/exportDxf"; import { exportScheduleCsv } from "./export/exportSchedule"; +import { exportIfcSpf } from "./export/exportIfc"; +import { exportObj, exportStl } from "./export/exportMesh"; +import type { ScheduleKind } from "./export/exportSchedule"; import { SettingsDialog } from "./ui/SettingsDialog"; +import { ExportSaveDialog } from "./ui/ExportSaveDialog"; +import type { ExportSaveFormat } from "./ui/ExportSaveDialog"; +import { PromptDialog } from "./ui/PromptDialog"; +import { LockConflictDialog } from "./ui/LockConflictDialog"; +import { Toast } from "./ui/Toast"; +import { saveTextFile } from "./io/saveFile"; +import { + saveProjectFile, + openProjectFileNative, + acquireProjectLock, + releaseProjectLock, + registerLockAutoRelease, + isTauriRuntime, +} from "./io/projectFile"; +import type { LockInfo } from "./io/projectFile"; import { PlanView } from "./plan/PlanView"; import type { PlanViewHandle, @@ -68,6 +93,25 @@ import type { DisplayResolver, ViewportContextInfo } from "./viewport/Viewport3D import type { Pick3dHit } from "./viewport/Wasm3DViewport"; import { ResourceManager } from "./ui/ResourceManager"; import type { ResourceManagerHandlers } from "./ui/ResourceManager"; +import { LayoutSheetView } from "./ui/LayoutSheet"; +import { + createLayout, + createLayoutFolder, + createMasterLayout, + createViewport, + createAnnotation, + deleteFolder as deleteLayoutFolder, + deleteMasterFolder, + moveLayoutToFolder, + moveFolderToFolder, + addViewport as addViewportToLayout, + removeViewport as removeViewportFromLayout, + patchViewport as patchViewportInLayout, + addAnnotation as addAnnotationToLayout, + removeAnnotation as removeAnnotationFromLayout, + patchAnnotation as patchAnnotationInLayout, +} from "./panels/layoutModel"; +import { saveFolderPdf } from "./export/layoutPdf"; import { HatchSwatch } from "./ui/hatchPreview"; import { TopBar, ViewRibbonTab, Ribbon3dTab } from "./ui/TopBar"; import { RibbonBar } from "./ui/ribbon/RibbonBar"; @@ -164,6 +208,19 @@ import { saveDrawingCombo, saveLayerCombo, } from "./state/visibilitySets"; +import { + applyOverrideRuleEnabled, + captureViewSnapshot, + readViewSnapshot, + snapshotMatchesLiveState, + type ViewSnapshotState, +} from "./state/viewSnapshots"; +import { + createViewSnapshotFolder, + deleteFolder as deleteViewSnapshotFolder, + moveSnapshotToFolder, + moveFolder as moveViewSnapshotFolder, +} from "./state/viewSnapshotFolders"; // Seiteneffekt: registriert die zwei dockbaren Kern-Panels in der Registry. import { BUILTIN_PANEL_IDS } from "./panels/builtinPanels"; import { DrawingTabs, deriveDrawings } from "./DrawingTabs"; @@ -216,20 +273,14 @@ const TOOL_COMMAND: Partial> = { rect: "rect", circle: "circle", arc: "arc", + text: "text", + textbox: "textbox", }; /** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */ function coupledCommandFor(id: ToolId): string | null { return TOOL_COMMAND[id] ?? null; } -/** Fragt die Kopienanzahl (≥1) für die Modi O/P ab; null bei Abbruch/ungültig. */ -function promptCount(): number | null { - const s = window.prompt(t("transform.countPrompt"), "3"); - if (s == null) return null; - const n = Math.round(Number(s)); - return Number.isFinite(n) && n >= 1 ? n : null; -} - /** localStorage-Schlüssel der modularen Custom-Bar (Ribbon-Tab „Eigene"). */ const RIBBON_CUSTOM_KEY = "dossier.ribbon.customItems"; @@ -277,6 +328,33 @@ function drawingsBBox( return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null; } +/** + * Bounding-Box (Modell-Meter) neu importierter Kontext-Objekte (Konturen/ + * Meshes/Terrain, z. B. aus dem Standort-Import-Dialog), als zwei Eckpunkte + * für `PlanViewHandle.fitBounds` (Auto-Zoom auf Import). Ignoriert die + * Z-Achse (reine 2D-Projektion, wie die Plan-Darstellung der Kontext- + * Konturen in generatePlan.ts). Leere/degenerierte Eingabe → null. + */ +function contextObjectsBounds(objs: ContextObject[]): Vec2[] | null { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + const acc = (x: number, y: number) => { + minX = Math.min(minX, x); minY = Math.min(minY, y); + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + }; + for (const obj of objs) { + if (obj.type === "contourSet") { + for (const c of obj.contours) for (const p of c.pts) acc(p.x, p.y); + } else { + // importedMesh / terrainMesh: flaches (x,y,z…)-Array, Stride 3. + const pos = obj.positions; + for (let i = 0; i + 1 < pos.length; i += 3) acc(pos[i], pos[i + 1]); + } + } + return Number.isFinite(minX) + ? [{ x: minX, y: minY }, { x: maxX, y: maxY }] + : null; +} + /** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D { const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy }); @@ -292,6 +370,21 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D { } } +/** + * Baut eine TransformSelection für GENAU EIN Element (Object-Info: Verschieben/ + * Drehen per Zahlenfeld, siehe onMoveSelectionBy/onRotateSelectionAround unten). + * Deckt nur die vom Transform-Kern (tools/transform.ts) unterstützten Arten ab + * — Decke/Öffnung/Treppe/Raum kennt er nicht, dort liefert die Funktion `null` + * (Aufrufer macht dann No-op, wie bei onResizeSelection). + */ +function singleTransformSel(kind: string, id: string): TransformSelection | null { + if (kind === "wall") return { wallIds: [id], drawingId: null }; + if (kind === "drawing2d") return { wallIds: [], drawingId: id }; + if (kind === "extrudedSolid") return { wallIds: [], drawingId: null, extrudedSolidId: id }; + if (kind === "column") return { wallIds: [], drawingId: null, columnId: id }; + return null; +} + export default function App() { // Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache // umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t). @@ -302,6 +395,7 @@ export default function App() { // Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil. const project = useStore((s) => s.project); const setProject = useStore((s) => s.setProject); + const notify = useStore((s) => s.notify); const undo = useStore((s) => s.undo); const redo = useStore((s) => s.redo); const activeLevelId = useStore((s) => s.activeLevelId); @@ -314,6 +408,12 @@ export default function App() { // Klick / Esc geschlossen. const [resourcesOpen, setResourcesOpen] = useState(false); + // Aktives Layout-Blatt im HAUPT-Viewport (DOSSIER A3, Phase 2b): Id des offenen + // Layouts oder null (= normale Modell-Ansicht). Transienter UI-State; das + // Layout selbst lebt im Projekt (project.layouts). Ist es gesetzt, zeigt der + // Haupt-Viewport das Blatt (LayoutSheetView) statt Grundriss/3D. + const [activeLayoutId, setActiveLayoutId] = useState(null); + // Modaler Import-Dialog (DXF/DWG). Offen, sobald eine Datei gewählt/fallen- // gelassen wurde; hält Dateiname, geparstes Ergebnis (null bei DWG) und das // DWG-Flag (kein Parser). null = kein Dialog offen. Transienter UI-State. @@ -329,6 +429,8 @@ export default function App() { const [exportDxfOpen, setExportDxfOpen] = useState(false); // Modales Einstellungs-Fenster (Darstellungsfarben + Projekt-Felder). const [settingsOpen, setSettingsOpen] = useState(false); + // Offener Schnellexport-Dialog: Format (aus dem Menü) oder null = geschlossen. + const [exportSaveFormat, setExportSaveFormat] = useState(null); // Visuelles Drop-Feedback (dezenter Rahmen), solange eine Datei über die App // gezogen wird. Zählt dragenter/dragleave aus, um Flackern bei Kind-Elementen // zu vermeiden. @@ -364,6 +466,12 @@ export default function App() { // LIVE Papier-Massstab-Nenner aus der View-Transform (PlanView meldet ihn); // null = keine Plan-Ansicht aktiv. Transienter UI-State, bleibt lokal. const [liveScale, setLiveScale] = useState(null); + // „Angewählter" Ausschnitt (View-Snapshot): bleibt gesetzt, solange der + // Live-Zustand EXAKT dem Ausschnitt entspricht (siehe Effekt weiter unten). + // Steuert die Footer-Bar + die Aktiv-Markierung im Ausschnitte-Panel. + const [selectedViewSnapshotId, setSelectedViewSnapshotId] = useState< + string | null + >(null); // Cursor in Modell-Metern (aus der Plan-Ansicht) bzw. null (nicht über Plan). const [cursor, setCursor] = useState(null); // Live-Zoom in Prozent RELATIV zum angewandten Papier-Massstab: 100 % = @@ -417,6 +525,19 @@ export default function App() { // Letzter gesnappter Cursor der laufenden Transformation — für das Live-Feld // (Winkel/Distanz) der Befehlszeile (analog gripDragInfoRef beim Vertex-Drag). const transformDragInfoRef = useRef<{ cursor: Vec2 } | null>(null); + // Kopienanzahl für array/distribute wird über einen PromptDialog erfasst + // (window.prompt ist im Tauri-WKWebView deaktiviert) — `pendingCopyMode` + // hält den Modus, während der Dialog offen ist. + const [pendingCopyMode, setPendingCopyMode] = useState<"array" | "distribute" | null>(null); + const requestCopyCount = (mode: "array" | "distribute") => setPendingCopyMode(mode); + const commitCopyCount = (input: string) => { + const n = Math.round(Number(input)); + if (Number.isFinite(n) && n >= 1 && pendingCopyMode) { + const mode = pendingCopyMode; + setActiveTransform((t) => (t ? { ...t, copyMode: mode, count: n } : t)); + } + setPendingCopyMode(null); + }; // ── Auswahl (Store-Slice) ───────────────────────────────────────────────── const selectedWallIds = useStore((s) => s.selectedWallIds); @@ -456,6 +577,14 @@ export default function App() { const setSelectedRoomIds = useStore((s) => s.setSelectedRoomIds); const selectedRoomId = selectedRoomIds.length === 1 ? selectedRoomIds[0] : null; + const selectedExtrudedSolidIds = useStore((s) => s.selectedExtrudedSolidIds); + const setSelectedExtrudedSolidIds = useStore((s) => s.setSelectedExtrudedSolidIds); + const selectedExtrudedSolidId = + selectedExtrudedSolidIds.length === 1 ? selectedExtrudedSolidIds[0] : null; + const selectedColumnIds = useStore((s) => s.selectedColumnIds); + const setSelectedColumnIds = useStore((s) => s.setSelectedColumnIds); + const selectedColumnId = + selectedColumnIds.length === 1 ? selectedColumnIds[0] : null; // ── Kontextmenüs (transienter UI-State, bleibt lokal) ───────────────────── // Eigenschaften-Zwischenablage des Ebenen-Kontextmenüs (Farbe + Linienstärke). @@ -469,6 +598,8 @@ export default function App() { const [editor, setEditor] = useState(null); // Offener Raum-Stempel-Editor (Rich-Text) — per Doppelklick/Button geöffnet. const [stampEditorRoomId, setStampEditorRoomId] = useState(null); + // Text-Inhalt-Editor: Id des per Doppelklick bearbeiteten Text-2D-Elements. + const [editTextId, setEditTextId] = useState(null); // Beim Geschosswechsel die Wand-Auswahl + offene Overlays zurücksetzen (die // Wände eines anderen Geschosses sind eine andere Menge). Der Ansichtswechsel @@ -481,6 +612,8 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); setMenu(null); setEditor(null); }, [activeLevelId]); @@ -500,7 +633,27 @@ export default function App() { // liest die Datei als Text und reicht sie durch parseDxf → addContextObjects. const dxfInputRef = useRef(null); // Verstecktes Datei-Input zum Laden eines Projekts (Datei-Burger „Öffnen"). + // Wird NUR im Browser-Pfad benutzt (ohne Tauri) — unter Tauri geht „Öffnen" + // über den nativen Dialog (openProjectFileNative), der einen echten Pfad für + // den Lock liefert (File-API im Browser hat keinen echten Dateipfad). const projectFileInputRef = useRef(null); + // Pfad + Lock-Status der aktuell offenen .obp/.json-Datei (nur Tauri; sonst + // bleibt beides null/false — Browser-Blob-Pfad hat keinen echten Pfad). + const openedProjectPathRef = useRef(null); + const projectLockOwnedRef = useRef(false); + // Konflikt-Zustand: Datei ist in einer anderen Instanz gesperrt → Dialog mit + // „Schreibgeschützt öffnen"/„Trotzdem erzwingen"/„Abbrechen" (kein window.confirm!). + const [lockConflict, setLockConflict] = useState<{ + path: string; + content: string; + info: LockInfo | null; + } | null>(null); + + // Best-effort Lock-Freigabe beim Fenster-Schliessen (sauberes Timing; der + // OS-Lock fällt bei Prozessende/Absturz ohnehin automatisch weg). No-op ohne Tauri. + useEffect(() => { + registerLockAutoRelease(() => openedProjectPathRef.current); + }, []); // Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt // über den Modul-Hook). Beim Unmount wieder abmelden. @@ -629,9 +782,14 @@ export default function App() { }; // ── Transformation (Bewegen/Spiegeln/Drehen) auf der Auswahl ────────────── + // Trägt ALLE von der Transform-Engine unterstützten Elementarten (Wände, 2D- + // Zeichnungen, extrudierte Körper, Stützen) — sonst wirken die Kürzel m/s/d nur + // auf Wände/2D, obwohl `commitTransform` auch Extrude/Stütze bewegt. const transformSel: TransformSelection = { wallIds: selectedWallIds, drawingId: selectedDrawingId, + extrudedSolidId: selectedExtrudedSolidId, + columnId: selectedColumnId, }; const snapForTransform = (raw: Vec2, pxPerMeter: number, mods: ToolMods, points: Vec2[]) => computeSnap({ @@ -752,6 +910,8 @@ export default function App() { selectedWallIds, selectedDrawingId, selectedDrawingIds, + selectedExtrudedSolidId, + selectedColumnId, setProject, setDraft, }); @@ -766,6 +926,8 @@ export default function App() { selectedWallIds, selectedDrawingId, selectedDrawingIds, + selectedExtrudedSolidId, + selectedColumnId, setProject, setDraft, }; @@ -788,6 +950,8 @@ export default function App() { wallIds: c.selectedWallIds, drawingId: c.selectedDrawingId, drawingIds: c.selectedDrawingIds, + extrudedSolidId: c.selectedExtrudedSolidId, + columnId: c.selectedColumnId, }, }; }, @@ -857,7 +1021,32 @@ export default function App() { // eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des // Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv- // Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView). + // Interaktive Transformation (Bewegen/Spiegeln/Drehen) auf der Auswahl starten — + // derselbe U/I/O/P-Pfad (`activeTransform`) wie die Kürzel m/s/d, nur von Button/ + // Befehlszeile ausgelöst. Ohne transformierbare Auswahl ein kurzer Hinweis. + const startTransformOp = (op: TransformOp) => { + const hasSel = + selectedWallIds.length > 0 || + !!selectedDrawingId || + !!selectedExtrudedSolidId || + !!selectedColumnId; + if (!hasSel) { + notify(t("transform.needSelection"), "info"); + return; + } + setActiveTool("select"); + setActiveTransform({ op, copyMode: "move", count: 1, points: [] }); + setDraft(null); + requestAnimationFrame(() => commandLineRef.current?.focus()); + }; const onRunCommand = (name: string) => { + // „rotate" (Drehen) läuft über die interaktive Transformation mit U/I/O/P — + // nicht über die Befehls-Engine (die kennt kein Rotate-Kommando). So ist der + // Ribbon-Button der Einstieg in denselben Fluss wie die Taste „d". + if (name === "rotate") { + startTransformOp("rotate"); + return; + } engine.start(name); setActiveTool("select"); requestAnimationFrame(() => commandLineRef.current?.focus()); @@ -1050,7 +1239,34 @@ export default function App() { : null, [selectedRoom], ); + // Bewegpunkt (Move-Handle) einer Treppe: liegt auf der Treppe (Mitte des + // ersten Laufs bei gerade/L, Zentrum bei Wendel) und verschiebt beim Ziehen + // die GANZE Treppe (moveStairBy mit dem Delta). Ein einzelner, gut sichtbarer + // Griff — analog zum Öffnungs-Mittelpunkt. + const stairMoveHandle = (st: Stair): Vec2 => { + if (st.shape === "spiral") return st.center ?? st.start; + const d = normalize(st.dir); + return add(st.start, scale(d, st.runLength / 2)); + }; const grips: Vec2[] = useMemo(() => { + // Öffnung (Fenster/Tür): EIN Mittelpunkt-Griff auf der Wandachse. Ziehen + // verschiebt die Öffnung entlang der Wirts-Wand (siehe applyGrip → + // `position`). Nur bei genau einer selektierten Öffnung (Auswahl exklusiv). + if (selectedOpeningId) { + const op = (project.openings ?? []).find((o) => o.id === selectedOpeningId); + const w = op && project.walls.find((x) => x.id === op.hostWallId); + if (op && w) { + const axis = normalize(sub(w.end, w.start)); + return [add(w.start, scale(axis, op.position + op.width / 2))]; + } + return []; + } + // Treppe: EIN Bewegpunkt (siehe stairMoveHandle); Ziehen verschiebt die + // ganze Treppe (applyGrip → moveStairBy). Auswahl exklusiv. + if (selectedStairId) { + const st = (project.stairs ?? []).find((s) => s.id === selectedStairId); + return st ? [stairMoveHandle(st)] : []; + } if (selectedDrawing) return drawingVertices(selectedDrawing); if (selectedWallIds.length === 1 && !selectedDrawingId) { const w = project.walls.find((x) => x.id === selectedWallIds[0]); @@ -1074,9 +1290,13 @@ export default function App() { selectedDrawing, selectedCeiling, selectedRoom, + selectedOpeningId, + selectedStairId, selectedWallIds, selectedDrawingId, project.walls, + project.openings, + project.stairs, ]); // ── Kanten-/Seiten-Griffe (Edge Grips) ─────────────────────────────────── @@ -1347,6 +1567,33 @@ export default function App() { // Editier-Dispatch: bei gewählter Decke auf die Decken-Aktionen umleiten, sonst // auf die Wand/2D-Aktionen. So teilen Plan- UND 3D-Griffe denselben Pfad. const applyGrip = (index: number, pt: Vec2) => { + // Öffnung: den gezogenen Punkt auf die Wandachse projizieren → neue + // `position` (Abstand des ersten Pfostens vom Wand-Start), sodass die + // Öffnung entlang der Wand gleitet. Griff sitzt in der Öffnungsmitte, daher + // width/2 abziehen; auf [0, Wandlänge − Breite] klemmen (Öffnung bleibt drin). + if (selectedOpeningId) { + const op = (project.openings ?? []).find((o) => o.id === selectedOpeningId); + const w = op && project.walls.find((x) => x.id === op.hostWallId); + if (op && w) { + const d = sub(w.end, w.start); + const len = Math.hypot(d.x, d.y); + if (len > 1e-9) { + const axis = scale(d, 1 / len); + const t = dot(sub(pt, w.start), axis); + const pos = Math.max(0, Math.min(len - op.width, t - op.width / 2)); + updateOpening(op.id, { position: pos }); + } + } + return; + } + // Treppe: den Bewegpunkt verfolgen → ganze Treppe um das Delta (Ziel − + // aktueller Handle) verschieben. Handle wird identisch zu grips gebildet, + // sodass er nach dem Move exakt unter dem Cursor sitzt. + if (selectedStairId) { + const st = (project.stairs ?? []).find((s) => s.id === selectedStairId); + if (st) moveStairBy(st.id, sub(pt, stairMoveHandle(st))); + return; + } if (selectedRoomId) moveRoomGrip(selectedRoomId, index, pt); else if (selectedCeilingId) moveCeilingGrip(selectedCeilingId, index, pt); else moveGripOf(selectedDrawingId, selectedWallId, index, pt); @@ -1406,7 +1653,20 @@ export default function App() { applyGrip(index, pt); setDraft({ preview: [], vertices: [], snap: snapResult }); }, - onMoveBody: (delta: Vec2, target?: { wallId?: string; drawingId?: string }) => { + onMoveBody: ( + delta: Vec2, + target?: { wallId?: string; drawingId?: string }, + mods?: ToolMods, + ) => { + // Shift = Ortho: das Delta auf die dominante Achse (H/V) zwingen — + // dieselbe Regel wie beim freien Kanten-Zug (onEdgeMove) und beim + // Vertex-Griff (onGripMove). Bisher wirkte Shift beim Körper-Verschieben + // (Wand/2D-Element/Decke/Treppe/Raum als Ganzes) gar nicht (Nutzer-Report + // 2026-07-06) — `mods` kam von PlanView schlicht nicht bis hierher. + if (mods?.shift) { + if (Math.abs(delta.x) >= Math.abs(delta.y)) delta = { x: delta.x, y: 0 }; + else delta = { x: 0, y: delta.y }; + } // Decke: ganzen Umriss verschieben (kein Multi-Koinzidenz-Handling nötig). if (selectedCeilingId && !target) { moveCeilingBy(selectedCeilingId, delta); @@ -1544,18 +1804,28 @@ export default function App() { const roomIds = (project.rooms ?? []) .filter((r) => r.floorId === activeLevel.id) .map((r) => r.id); + const extrudedSolidIds = (project.extrudedSolids ?? []) + .filter((s) => s.levelId === activeLevel.id) + .map((s) => s.id); + const columnIds = (project.columns ?? []) + .filter((c) => c.floorId === activeLevel.id) + .map((c) => c.id); if ( wallIds.length || drawingIds.length || ceilingIds.length || stairIds.length || - roomIds.length + roomIds.length || + extrudedSolidIds.length || + columnIds.length ) { setSelectedWallIds(wallIds); setSelectedDrawingIds(drawingIds); setSelectedCeilingIds(ceilingIds); setSelectedStairIds(stairIds); setSelectedRoomIds(roomIds); + setSelectedExtrudedSolidIds(extrudedSolidIds); + setSelectedColumnIds(columnIds); e.preventDefault(); } return; @@ -1568,7 +1838,9 @@ export default function App() { selectedCeilingIds.length || selectedOpeningIds.length || selectedStairIds.length || - selectedRoomIds.length + selectedRoomIds.length || + selectedExtrudedSolidIds.length || + selectedColumnIds.length ) { const dids = new Set(selectedDrawingIds); const wids = new Set(selectedWallIds); @@ -1576,6 +1848,8 @@ export default function App() { const oids = new Set(selectedOpeningIds); const stids = new Set(selectedStairIds); const rmids = new Set(selectedRoomIds); + const exids = new Set(selectedExtrudedSolidIds); + const colids = new Set(selectedColumnIds); setProject((p) => ({ ...p, drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)), @@ -1583,6 +1857,8 @@ export default function App() { ceilings: (p.ceilings ?? []).filter((c) => !cids.has(c.id)), stairs: (p.stairs ?? []).filter((s) => !stids.has(s.id)), rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)), + extrudedSolids: (p.extrudedSolids ?? []).filter((s) => !exids.has(s.id)), + columns: (p.columns ?? []).filter((c) => !colids.has(c.id)), doors: p.doors.filter((dr) => !wids.has(dr.hostWallId)), // Öffnungen: eigene Auswahl löschen UND alle, deren Host-Wand entfällt. openings: (p.openings ?? []).filter( @@ -1595,6 +1871,8 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); e.preventDefault(); } }; @@ -1608,10 +1886,14 @@ export default function App() { selectedCeilingIds, selectedOpeningIds, selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, project.walls, project.drawings2d, project.ceilings, project.stairs, + project.extrudedSolids, + project.columns, activeLevel.id, ]); @@ -1812,11 +2094,9 @@ export default function App() { if (k === "u") setActiveTransform((t) => (t ? { ...t, copyMode: "move" } : t)); else if (k === "i") setActiveTransform((t) => (t ? { ...t, copyMode: "copy" } : t)); else if (k === "o") { - const n = promptCount(); - if (n) setActiveTransform((t) => (t ? { ...t, copyMode: "array", count: n } : t)); + requestCopyCount("array"); } else if (k === "p") { - const n = promptCount(); - if (n) setActiveTransform((t) => (t ? { ...t, copyMode: "distribute", count: n } : t)); + requestCopyCount("distribute"); } else if (e.key === "Escape") { setActiveTransform(null); setDraft(null); @@ -1824,7 +2104,21 @@ export default function App() { e.preventDefault(); return; } - const hasSel = selectedWallIds.length > 0 || !!selectedDrawingId; + // „q" = Messen (Distanz/Winkel) — braucht KEINE Auswahl, daher vor hasSel. + if (e.key.toLowerCase() === "q") { + onRunCommand("measure"); + e.preventDefault(); + return; + } + // Transformierbar sind Wände, 2D-Zeichnungen, extrudierte Körper und Stützen + // (dieselben Arten wie transformSel). Öffnungen/Treppen laufen bewusst NICHT + // hierüber, sondern über ihre eigenen Bewegpunkte (Grips) — sie sind wand- + // bzw. geschossgebunden. + const hasSel = + selectedWallIds.length > 0 || + !!selectedDrawingId || + !!selectedExtrudedSolidId || + !!selectedColumnId; if (!hasSel) return; const k = e.key.toLowerCase(); // Bewegen/Spiegeln/Drehen laufen ALLE über die interaktive Transformation @@ -1842,7 +2136,15 @@ export default function App() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [activeTool, activeTransform, selectedWallIds, selectedDrawingId, engine]); + }, [ + activeTool, + activeTransform, + selectedWallIds, + selectedDrawingId, + selectedExtrudedSolidId, + selectedColumnId, + engine, + ]); // ── Projekt-Mutationen (aus dem Store-Slice) ───────────────────────────── // Sämtliche immutablen Projekt-Mutationen liegen jetzt in projectSlice. App @@ -1886,6 +2188,11 @@ export default function App() { ...p, wallTypes: p.wallTypes.map((wt) => (wt.id === id ? { ...wt, ...patch } : wt)), })); + // Grafische Override-Regeln: ersetzt die ganze Regelliste (Reihenfolge = + // Priorität, oben gewinnt). Läuft wie die übrigen Ressourcen-Mutationen + // direkt über setProject — persistiert damit automatisch im Projekt-JSON. + const setOverrideRules = (rules: OverrideRule[]) => + setProject((p) => ({ ...p, overrideRules: rules })); // Deckenstile: immutable Patch eines Deckentyps — analog `patchWallType`. const patchCeilingType = (id: string, patch: Partial) => setProject((p) => ({ @@ -1911,7 +2218,7 @@ export default function App() { const used = p.walls.some((w) => w.wallTypeId === id); if (used) { const name = p.wallTypes.find((wt) => wt.id === id)?.name ?? id; - window.alert(t("alert.wallTypeInUse", { name })); + notify(t("alert.wallTypeInUse", { name }), "warning"); return p; } return { ...p, wallTypes: p.wallTypes.filter((wt) => wt.id !== id) }; @@ -1933,7 +2240,7 @@ export default function App() { const used = (p.ceilings ?? []).some((c) => c.ceilingTypeId === id); if (used) { const name = (p.ceilingTypes ?? []).find((ct) => ct.id === id)?.name ?? id; - window.alert(t("alert.ceilingTypeInUse", { name })); + notify(t("alert.ceilingTypeInUse", { name }), "warning"); return p; } return { @@ -1941,10 +2248,120 @@ export default function App() { ceilingTypes: (p.ceilingTypes ?? []).filter((ct) => ct.id !== id), }; }); + // ── Bauteil-Typen: Tür / Fenster / Treppe (Bibliothek) ──────────────────── + // Anlegen/Patchen/Löschen analog Wand-/Deckentyp — direkt über setProject + // (kein eigener Slice). Löschen ist geschützt, solange ein Element den Typ + // per `typeId` referenziert. Defaults orientieren sich an den Element-Inline- + // Werten (Tür 0.9×2.0, Fenster 1.2×1.4 Brüstung 0.9). + const addDoorType = () => + setProject((p) => { + const dt: DoorType = { + id: `dt-${Date.now()}`, + name: t("default.doorTypeName"), + kind: "dreh", + leafCount: 1, + leafStyle: "glatt", + frameThickness: 0.05, + defaultWidth: 0.9, + defaultHeight: 2.0, + }; + return { ...p, doorTypes: [...(p.doorTypes ?? []), dt] }; + }); + const patchDoorType = (id: string, patch: Partial) => + setProject((p) => ({ + ...p, + doorTypes: (p.doorTypes ?? []).map((dt) => + dt.id === id ? { ...dt, ...patch } : dt, + ), + })); + const deleteDoorType = (id: string) => + setProject((p) => { + const used = (p.openings ?? []).some((o) => o.typeId === id); + if (used) { + const name = (p.doorTypes ?? []).find((dt) => dt.id === id)?.name ?? id; + notify(t("alert.doorTypeInUse", { name }), "warning"); + return p; + } + return { ...p, doorTypes: (p.doorTypes ?? []).filter((dt) => dt.id !== id) }; + }); + const addWindowType = () => + setProject((p) => { + const wt: WindowType = { + id: `ft-${Date.now()}`, + name: t("default.windowTypeName"), + kind: "drehkipp", + wingCount: 1, + glazing: "zweifach", + frameThickness: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.2, + defaultHeight: 1.4, + }; + return { ...p, windowTypes: [...(p.windowTypes ?? []), wt] }; + }); + const patchWindowType = (id: string, patch: Partial) => + setProject((p) => ({ + ...p, + windowTypes: (p.windowTypes ?? []).map((wt) => + wt.id === id ? { ...wt, ...patch } : wt, + ), + })); + const deleteWindowType = (id: string) => + setProject((p) => { + const used = (p.openings ?? []).some((o) => o.typeId === id); + if (used) { + const name = (p.windowTypes ?? []).find((wt) => wt.id === id)?.name ?? id; + notify(t("alert.windowTypeInUse", { name }), "warning"); + return p; + } + return { + ...p, + windowTypes: (p.windowTypes ?? []).filter((wt) => wt.id !== id), + }; + }); + const addStairType = () => + setProject((p) => { + const st: StairType = { + id: `st-${Date.now()}`, + name: t("default.stairTypeName"), + structure: "massiv", + closedRisers: true, + treadThickness: 0.04, + nosing: 0.03, + railing: "keine", + defaultWidth: 1.0, + }; + return { ...p, stairTypes: [...(p.stairTypes ?? []), st] }; + }); + const patchStairType = (id: string, patch: Partial) => + setProject((p) => ({ + ...p, + stairTypes: (p.stairTypes ?? []).map((st) => + st.id === id ? { ...st, ...patch } : st, + ), + })); + const deleteStairType = (id: string) => + setProject((p) => { + const used = (p.stairs ?? []).some((s) => s.typeId === id); + if (used) { + const name = (p.stairTypes ?? []).find((st) => st.id === id)?.name ?? id; + notify(t("alert.stairTypeInUse", { name }), "warning"); + return p; + } + return { ...p, stairTypes: (p.stairTypes ?? []).filter((st) => st.id !== id) }; + }); const importLineStyles = useStore((s) => s.importLineStyles); const importHatches = useStore((s) => s.importHatches); // Site-/Kontext-Schicht (siteSlice): Import/Entfernen/Gelände. const addContextObjects = useStore((s) => s.addContextObjects); + // Wie addContextObjects, aber passt die Plan-Ansicht danach auf die Bounding- + // Box der NEU importierten Objekte ein (Auto-Zoom auf Import, ROADMAP §11) — + // sonst muss man nach Standort-/Geo-Import manuell zur Geometrie suchen. + const addContextObjectsAndFit = (objs: ContextObject[]) => { + addContextObjects(objs); + const pts = contextObjectsBounds(objs); + if (pts) planViewRef.current?.fitBounds(pts); + }; const removeContextObject = useStore((s) => s.removeContextObject); const generateTerrain = useStore((s) => s.generateTerrain); const moveGripOf = useStore((s) => s.moveGripOf); @@ -2098,8 +2515,20 @@ export default function App() { selectedOpeningId, selectedStairId, selectedRoomId, + selectedExtrudedSolidId, + selectedColumnId, ), - [project, selectedWallIds, selectedDrawingId, selectedCeilingId, selectedOpeningId, selectedStairId, selectedRoomId], + [ + project, + selectedWallIds, + selectedDrawingId, + selectedCeilingId, + selectedOpeningId, + selectedStairId, + selectedRoomId, + selectedExtrudedSolidId, + selectedColumnId, + ], ); // ── DXF/DWG-Import (Dialog) ─────────────────────────────────────────────── @@ -2170,39 +2599,113 @@ export default function App() { }; // Projekt als Datei speichern/laden (Datei-Burger-Menü, TopBar/AppMenu). - // Reines JSON des ProjectSlice-Zustands (gleiche Blob+Anchor-Download-Technik - // wie savePlanDxf/savePlanPdf); Laden liest die Datei als Text, parst sie und - // ersetzt das Projekt — nur ein grobes Shape-Sanity-Check (walls/layers als - // Array), damit ein fremdes/kaputtes JSON nicht die App zerlegt. + // Reines JSON des ProjectSlice-Zustands. Eigene Endung „.obp" (openbureau + // project) statt generischem „.json" — unter Tauri über den nativen + // Speichern-/Öffnen-Dialog mit passendem Datei-Filter (saveProjectFile/ + // openProjectFileNative in io/projectFile.ts), im Browser Blob-Download wie + // bisher. Laden parst die Datei und ersetzt das Projekt — nur ein grobes + // Shape-Sanity-Check (walls/layers als Array), damit ein fremdes/kaputtes + // JSON nicht die App zerlegt. Alte „.json"-Projekte bleiben ladbar (Filter + // erlaubt beide Endungen). const onSaveProject = () => { const json = JSON.stringify(project, null, 2); - const blob = new Blob([json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${project.name || "projekt"}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + void saveProjectFile(json, project.name); }; - // Bauteilliste (Bauteil-Schedule) als CSV herunterladen — gleiche - // Blob+Anchor-Download-Technik wie onSaveProject/savePlanDxf. Der CSV-Inhalt - // kommt aus dem reinen Kern (export/exportScheduleCsv); BOM voranstellen, - // damit Excel UTF-8 (Umlaute/„m²") korrekt liest (wie der Raum-CSV). - const onExportSchedule = () => { - const csv = exportScheduleCsv(project); - const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "bauteilliste.csv"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + // Gibt den Lock der zuletzt geöffneten Projektdatei frei (falls wir ihn + // halten) und vergisst den Pfad — vor dem Wechsel auf ein anderes Projekt. + const releaseCurrentProjectLock = () => { + if (openedProjectPathRef.current && projectLockOwnedRef.current) { + void releaseProjectLock(openedProjectPathRef.current); + } + openedProjectPathRef.current = null; + projectLockOwnedRef.current = false; + }; + // Übernimmt ein per Pfad geöffnetes Projekt (Tauri-Fluss): parst, prüft grob + // die Form, gibt einen evtl. vorher gehaltenen Lock frei und merkt sich Pfad + // + ob WIR den Schreib-Lock für die neue Datei halten. + const applyOpenedProjectFile = (path: string, content: string, lockOwned: boolean) => { + try { + const parsed = JSON.parse(content) as Partial; + if (!Array.isArray(parsed.walls) || !Array.isArray(parsed.layers)) { + console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen)."); + return; + } + releaseCurrentProjectLock(); + setProject(parsed as Project); + openedProjectPathRef.current = path; + projectLockOwnedRef.current = lockOwned; + } catch (err) { + console.error("Projekt-Import fehlgeschlagen:", err); + } + }; + // Öffnen unter Tauri: nativer Dialog (echter Pfad) → Lock versuchen. Klappt + // er → Projekt übernehmen. Schon gesperrt → Konflikt-Dialog (kein + // window.confirm!) mit „Schreibgeschützt öffnen"/„Trotzdem erzwingen"/ + // „Abbrechen". Beide „öffnen"-Optionen übernehmen das Projekt ohne eigenen + // Lock (die andere Instanz hält ihn) — „erzwingen" bewusst mit Warn-Hinweis + // im Dialog, „schreibgeschützt" als sichere Standardwahl. + const openProjectViaNativeDialog = async () => { + let opened: Awaited>; + try { + opened = await openProjectFileNative(); + } catch (err) { + console.error(t("file.openFailed"), err); + return; + } + if (!opened) return; // Nutzer hat den Öffnen-Dialog abgebrochen + const lock = await acquireProjectLock(opened.path); + if (lock.ok) { + applyOpenedProjectFile(opened.path, opened.content, true); + } else { + setLockConflict({ path: opened.path, content: opened.content, info: lock.info }); + } + }; + // Datei speichern: unter Tauri nativer „Speichern unter"-Dialog (Ordner + Name), + // im Browser Blob-Download-Fallback — die gesamte Logik (inkl. WKWebView- + // Härtung: octet-stream-MIME + verzögertes revoke) steckt in saveTextFile. + const downloadTextFile = ( + content: string, + filename: string, + mime = "application/octet-stream", + ) => { + void saveTextFile(content, filename, mime); + }; + const projectFileName = () => project.name || "modell"; + // Führt den gewählten Direkt-Export (CSV/IFC/OBJ/STL) mit dem im Dialog + // gewählten Dateinamen aus. Erzeugung aus dem jeweiligen reinen Kern, Download + // über den robusten Helfer. + const runExport = (format: ExportSaveFormat, filename: string) => { + switch (format) { + case "csv": + downloadTextFile("" + exportScheduleCsv(project), filename, "text/csv;charset=utf-8;"); + break; + case "ifc": + downloadTextFile(exportIfcSpf(project), filename); + break; + case "obj": + downloadTextFile(exportObj(project), filename); + break; + case "stl": + downloadTextFile(exportStl(project), filename); + break; + } + }; + // Schnellexport aus dem Topbar-Menü öffnet den Format-/Dateiname-Dialog + // (vorausgewähltes Format). PDF/DXF haben eigene Options-Dialoge. + const onExportSchedule = () => setExportSaveFormat("csv"); + const onExportIfc = () => setExportSaveFormat("ifc"); + const onExportObj = () => setExportSaveFormat("obj"); + const onExportStl = () => setExportSaveFormat("stl"); + // Unter Tauri: nativer Öffnen-Dialog (echter Pfad → Lock möglich). Im + // Browser: verstecktes wie bisher (kein echter Pfad, also + // ohne Lock — File-API im Browser liefert keinen Dateisystempfad). + const onOpenProject = () => { + if (isTauriRuntime()) { + void openProjectViaNativeDialog(); + } else { + projectFileInputRef.current?.click(); + } }; - const onOpenProject = () => projectFileInputRef.current?.click(); const onProjectFileChosen = (file: File) => { const r = new FileReader(); r.onload = () => { @@ -2212,6 +2715,9 @@ export default function App() { console.warn("Projekt-Datei hat kein gültiges Projekt-Format (walls/layers fehlen)."); return; } + // Browser-Pfad hat keinen echten Dateipfad → kein Lock für die neue + // Datei, aber ein evtl. vorher (unter Tauri) gehaltener Lock wird frei. + releaseCurrentProjectLock(); setProject(parsed as Project); } catch (err) { console.error("Projekt-Import fehlgeschlagen:", err); @@ -2413,11 +2919,61 @@ export default function App() { if (selection?.kind === "wall" || selection?.kind === "drawing2d") resizeElement(selection.kind, selection.id, w, h, anchor); }, + onMoveSelectionBy: (dx, dy) => { + if (!selection) return; + if (!isFinite(dx) || !isFinite(dy) || (dx === 0 && dy === 0)) return; + const sel2 = singleTransformSel(selection.kind, selection.id); + if (!sel2) return; + commitTransformOn(sel2, "move", [{ x: 0, y: 0 }, { x: dx, y: dy }], "move", 1); + }, + onRotateSelectionAround: (cx, cy, deg) => { + if (!selection) return; + if (!isFinite(deg) || deg === 0) return; + const sel2 = singleTransformSel(selection.kind, selection.id); + if (!sel2) return; + const rad = (deg * Math.PI) / 180; + commitTransformOn( + sel2, + "rotate", + [ + { x: cx, y: cy }, + { x: cx + 1, y: cy }, + { x: cx + Math.cos(rad), y: cy + Math.sin(rad) }, + ], + "move", + 1, + ); + }, + onSetDrawingLineLength: (length) => { + if (selection?.kind !== "drawing2d" || !isFinite(length) || length <= 0) return; + const d = project.drawings2d.find((x) => x.id === selection.id); + if (!d || d.geom.shape !== "line") return; + const { a, b } = d.geom; + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len < 1e-9) return; + const ux = (b.x - a.x) / len, uy = (b.y - a.y) / len; + updateDrawing2D(selection.id, { + geom: { ...d.geom, b: { x: a.x + ux * length, y: a.y + uy * length } }, + }); + }, + onSetDrawingCircleRadius: (radius) => { + if (selection?.kind !== "drawing2d" || !isFinite(radius) || radius <= 0) return; + const d = project.drawings2d.find((x) => x.id === selection.id); + if (!d || d.geom.shape !== "circle") return; + updateDrawing2D(selection.id, { geom: { ...d.geom, r: radius } }); + }, // ── Wand-Attribute (nur bei selektierter Wand) ───────────────────────── onSetWallReferenceLine: (ref) => { if (selection?.kind === "wall") updateWall(selection.id, { referenceLine: ref }); }, + onSetWallSliceTermination: (termination) => { + if (selection?.kind === "wall") + // "both" ist der Default ⇒ als undefined speichern (Alt-Projekt-Parität). + updateWall(selection.id, { + sliceTermination: termination === "both" ? undefined : termination, + }); + }, onSetWallType: (wallTypeId) => { if (selection?.kind === "wall") updateWall(selection.id, { wallTypeId }); @@ -2447,6 +3003,10 @@ export default function App() { if (selection?.kind === "ceiling") updateCeiling(selection.id, { top: anchor ?? undefined }); }, + onSetCeilingBottom: (anchor) => { + if (selection?.kind === "ceiling") + updateCeiling(selection.id, { bottom: anchor ?? undefined }); + }, // ── Öffnungs-Attribute (nur bei selektierter Öffnung) ────────────────── onSetOpeningKind: (kind) => { if (selection?.kind === "opening") @@ -2496,6 +3056,12 @@ export default function App() { onSetOpeningLintelLines: (lintelLines) => { if (selection?.kind === "opening") updateOpening(selection.id, { lintelLines }); }, + // Typ-Zuweisung (Bibliothek): Tür-/Fenstertyp per typeId; leerer String löst + // die Zuweisung wieder (Inline-Verhalten). + onSetOpeningType: (typeId) => { + if (selection?.kind === "opening") + updateOpening(selection.id, { typeId: typeId || undefined }); + }, // ── Treppen-Attribute (nur bei selektierter Treppe) ──────────────────── onSetStairShape: (shape) => { if (selection?.kind === "stair") updateStair(selection.id, { shape }); @@ -2518,6 +3084,12 @@ export default function App() { onSetStairReferenz: (referenz) => { if (selection?.kind === "stair") updateStair(selection.id, { referenz }); }, + // Typ-Zuweisung (Bibliothek): Treppentyp per typeId; leerer String löst die + // Zuweisung (Inline-Verhalten). Treibt u. a. die 3D-Tragart (massiv/offen). + onSetStairType: (typeId) => { + if (selection?.kind === "stair") + updateStair(selection.id, { typeId: typeId || undefined }); + }, // ── Raum-Attribute (nur bei selektiertem Raum) ───────────────────────── onSetRoomName: (name) => { if (selection?.kind === "room") updateRoom(selection.id, { name }); @@ -2526,12 +3098,481 @@ export default function App() { if (selection?.kind === "room") updateRoom(selection.id, { siaCategory: category }); }, onEditRoomStamp: (roomId) => onOpenStampEditor(roomId), + // ── Extrusions-Attribute (truck-Integration; nur bei selektiertem Körper) ── + onSetExtrudedSolidHeight: (height) => { + if (selection?.kind !== "extrudedSolid" || !(height > 0)) return; + const id = selection.id; + setProject((p) => ({ + ...p, + extrudedSolids: (p.extrudedSolids ?? []).map((s) => + s.id === id ? { ...s, height } : s, + ), + })); + }, + onSetExtrudedSolidTaper: (taper) => { + if (selection?.kind !== "extrudedSolid") return; + const clamped = Math.min(1, Math.max(0, taper)); + const id = selection.id; + setProject((p) => ({ + ...p, + extrudedSolids: (p.extrudedSolids ?? []).map((s) => + s.id === id ? { ...s, taper: clamped } : s, + ), + })); + }, + // ── Stützen-Attribute (Tragwerk; nur bei selektierter Stütze) ────────── + onSetColumnProfileKind: (kind) => { + if (selection?.kind !== "column") return; + const id = selection.id; + setProject((p) => ({ + ...p, + columns: (p.columns ?? []).map((c) => { + if (c.id !== id) return c; + if (kind === c.profile.kind) return c; + // Profil-Typ wechseln: sinnvolles Mass aus dem bisherigen ableiten. + if (kind === "round") { + const r = + c.profile.kind === "rect" + ? Math.max(c.profile.width, c.profile.depth) / 2 + : c.profile.radius; + return { ...c, profile: { kind: "round", radius: r } }; + } + const side = c.profile.kind === "round" ? c.profile.radius * 2 : 0.3; + return { ...c, profile: { kind: "rect", width: side, depth: side } }; + }), + })); + }, + onSetColumnWidth: (width) => { + if (selection?.kind !== "column" || !(width > 0)) return; + const id = selection.id; + setProject((p) => ({ + ...p, + columns: (p.columns ?? []).map((c) => + c.id === id && c.profile.kind === "rect" + ? { ...c, profile: { ...c.profile, width } } + : c, + ), + })); + }, + onSetColumnDepth: (depth) => { + if (selection?.kind !== "column" || !(depth > 0)) return; + const id = selection.id; + setProject((p) => ({ + ...p, + columns: (p.columns ?? []).map((c) => + c.id === id && c.profile.kind === "rect" + ? { ...c, profile: { ...c.profile, depth } } + : c, + ), + })); + }, + onSetColumnRadius: (radius) => { + if (selection?.kind !== "column" || !(radius > 0)) return; + const id = selection.id; + setProject((p) => ({ + ...p, + columns: (p.columns ?? []).map((c) => + c.id === id && c.profile.kind === "round" + ? { ...c, profile: { ...c.profile, radius } } + : c, + ), + })); + }, + onSetColumnHeight: (height) => { + if (selection?.kind !== "column" || !(height > 0)) return; + const id = selection.id; + setProject((p) => ({ + ...p, + columns: (p.columns ?? []).map((c) => (c.id === id ? { ...c, height } : c)), + })); + }, + onSetColumnRotation: (deg) => { + if (selection?.kind !== "column" || !Number.isFinite(deg)) return; + const id = selection.id; + const rotation = (deg * Math.PI) / 180; + setProject((p) => ({ + ...p, + columns: (p.columns ?? []).map((c) => (c.id === id ? { ...c, rotation } : c)), + })); + }, // ── Site-/Kontext-Schicht ────────────────────────────────────────────── contextObjects: project.context ?? [], - onAddContextObjects: addContextObjects, + onAddContextObjects: addContextObjectsAndFit, onRemoveContextObject: removeContextObject, onGenerateTerrain: generateTerrain, onImportDxf: () => dxfInputRef.current?.click(), + // ── Element-Baum (Elemente-Panel) ──────────────────────────────────── + onSelectScheduleRow: ( + kind: ScheduleKind, + id: string, + floorId: string | undefined, + opts?: { zoom?: boolean }, + ) => { + const applySelection = () => { + setSelectedWallIds([]); + setSelectedDrawingId(null); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + switch (kind) { + case "Wand": + setSelectedWallIds([id]); + break; + case "Decke": + setSelectedCeilingIds([id]); + break; + case "Fenster": + case "Öffnung": + setSelectedOpeningIds([id]); + break; + case "Treppe": + setSelectedStairIds([id]); + break; + case "Extrusion": + setSelectedExtrudedSolidIds([id]); + break; + default: + // z. B. Legacy-„Tür"-Datensätze (project.doors) — kein Auswahl-Kanal. + return; + } + if (opts?.zoom) requestAnimationFrame(() => planViewRef.current?.fit()); + }; + if (floorId && floorId !== activeLevelId) { + // Der Geschosswechsel-Effekt (oben, deps [activeLevelId]) leert die + // Auswahl NACH diesem Render — die neue Auswahl daher einen Tick + // später anwenden, sonst überschreibt der Effekt sie sofort wieder. + setActiveLevelId(floorId); + requestAnimationFrame(applySelection); + } else { + applySelection(); + } + }, + + // ── Ausschnitte / View-Snapshots (DOSSIER A2) ───────────────────────── + // Erfassen: aktuellen Darstellungszustand einsammeln (View-State + + // Sichtbarkeits-Snapshots + aktive Overrides) → pure captureViewSnapshot → + // an project.viewSnapshots anhängen (im Dokument, nicht localStorage). + onCaptureViewSnapshot: (name: string, folderId?: string) => { + const trimmed = name.trim(); + if (!trimmed) return; + const st: ViewSnapshotState = { + viewType, + view3d, + fov, + scaleDenominator, + detail, + activeLevelId, + layerVisibility: snapshotLayerVisibility(), + drawingVisibility: snapshotDrawingVisibility(), + enabledOverrideRuleIds: (project.overrideRules ?? []) + .filter((r) => r.enabled) + .map((r) => r.id), + }; + const snap = captureViewSnapshot(`vs-${Date.now()}`, trimmed, st); + // Neuer Ausschnitt landet im aktiven Ordner (falls das Panel einen + // mitgibt), sonst auf der Wurzelebene — additiv (folderId optional). + const full = folderId ? { ...snap, folderId } : snap; + setProject((p) => ({ + ...p, + viewSnapshots: [...(p.viewSnapshots ?? []), full], + })); + }, + + // Wiederherstellen: ALLE Felder zurückspielen. Reihenfolge: Geschoss ZUERST + // (setActiveLevelId setzt viewType provisorisch aus viewTypeByLevel), danach + // die Ansicht (überschreibt es synchron), dann Sichtbarkeit + Overrides. + // Die Store-Setter laufen synchron im selben Tick; nur den imperativen + // Massstab-Zoom (Plan muss für den neuen View gemountet sein) via rAF + // nachziehen — analog zum BIM-Tree-Geschosswechsel. + onApplyViewSnapshot: (id: string) => { + const snap = (project.viewSnapshots ?? []).find((s) => s.id === id); + if (!snap) return; + const st = readViewSnapshot(snap); + setActiveLevelId(st.activeLevelId); + setViewType(st.viewType); + setView3d(st.view3d); + setFov(st.fov); + setDetail(st.detail); + setScaleDenominator(st.scaleDenominator); + applyLayerVisibility(st.layerVisibility); + applyDrawingVisibility(st.drawingVisibility); + setProject((p) => ({ + ...p, + overrideRules: applyOverrideRuleEnabled( + p.overrideRules ?? [], + st.enabledOverrideRuleIds, + ), + })); + requestAnimationFrame(() => + planViewRef.current?.applyScale(st.scaleDenominator), + ); + // Klick = Anwenden UND Anwählen: der Ausschnitt bleibt markiert, bis der + // Nutzer eine erfasste Grösse ändert (Abweichungs-Effekt löscht ihn dann). + setSelectedViewSnapshotId(id); + }, + + onRenameViewSnapshot: (id: string, name: string) => { + const trimmed = name.trim(); + if (!trimmed) return; + setProject((p) => ({ + ...p, + viewSnapshots: (p.viewSnapshots ?? []).map((s) => + s.id === id ? { ...s, name: trimmed } : s, + ), + })); + }, + + onDeleteViewSnapshot: (id: string) => { + setProject((p) => ({ + ...p, + viewSnapshots: (p.viewSnapshots ?? []).filter((s) => s.id !== id), + })); + if (selectedViewSnapshotId === id) setSelectedViewSnapshotId(null); + }, + + // ── Ausschnitte-Baum (Ordner, DOSSIER A2) ───────────────────────────── + // Immutable CRUD über pure Helfer (state/viewSnapshotFolders.ts). Löschen + // zieht Inhalt auf die Elternebene hoch (kein Datenverlust). + onAddViewSnapshotFolder: (parentId) => { + const id = `vsf-${Date.now()}`; + const folder = createViewSnapshotFolder( + id, + t("viewsnap.folder.defaultName"), + parentId, + ); + setProject((p) => ({ + ...p, + viewSnapshotFolders: [...(p.viewSnapshotFolders ?? []), folder], + })); + return id; + }, + onRenameViewSnapshotFolder: (id, name) => { + const trimmed = name.trim(); + if (!trimmed) return; + setProject((p) => ({ + ...p, + viewSnapshotFolders: (p.viewSnapshotFolders ?? []).map((f) => + f.id === id ? { ...f, name: trimmed } : f, + ), + })); + }, + onDeleteViewSnapshotFolder: (id) => { + setProject((p) => { + const { folders, snapshots } = deleteViewSnapshotFolder( + id, + p.viewSnapshotFolders ?? [], + p.viewSnapshots ?? [], + ); + return { ...p, viewSnapshotFolders: folders, viewSnapshots: snapshots }; + }); + }, + onMoveViewSnapshotToFolder: (snapshotId, folderId) => { + setProject((p) => ({ + ...p, + viewSnapshots: moveSnapshotToFolder( + p.viewSnapshots ?? [], + snapshotId, + folderId, + ), + })); + }, + onMoveViewSnapshotFolder: (folderId, parentId) => { + setProject((p) => ({ + ...p, + viewSnapshotFolders: moveViewSnapshotFolder( + p.viewSnapshotFolders ?? [], + folderId, + parentId, + ), + })); + }, + + // Footer-Bar des Ausschnitte-Panels: die aktuell „angewählte" Id plus + // Lesezugriff auf die benannten Sichtbarkeits-Kombinationen (localStorage), + // damit der Footer den Kombi-NAMEN zu den gespeicherten Maps auflösen kann. + selectedViewSnapshotId, + listLayerCombos, + loadLayerCombo, + listDrawingCombos, + loadDrawingCombo, + + // ── Layouts / Masterlayouts (DOSSIER A3) ────────────────────────────── + // Immutable CRUD über pure Helfer (panels/layoutModel.ts); das Blatt öffnet + // als erstklassige Ansicht IM HAUPT-Viewport (activeLayoutId, Phase 2b). + // Anlegen öffnet es direkt; Löschen schliesst es, falls es das offene trifft. + onAddLayout: (name, paper, orientation) => { + const lay = createLayout(`lay-${Date.now()}`, name, paper, orientation); + setProject((p) => ({ ...p, layouts: [...(p.layouts ?? []), lay] })); + setActiveLayoutId(lay.id); + }, + onRenameLayout: (id, name) => { + setProject((p) => ({ + ...p, + layouts: (p.layouts ?? []).map((l) => + l.id === id ? { ...l, name } : l, + ), + })); + }, + onDeleteLayout: (id) => { + setProject((p) => ({ + ...p, + layouts: (p.layouts ?? []).filter((l) => l.id !== id), + })); + setActiveLayoutId((cur) => (cur === id ? null : cur)); + }, + onOpenLayout: (id) => setActiveLayoutId(id), + onAddMasterLayout: (name) => { + const m = createMasterLayout(`mas-${Date.now()}`, name, "a4", "portrait"); + setProject((p) => ({ + ...p, + masterLayouts: [...(p.masterLayouts ?? []), m], + })); + }, + onRenameMasterLayout: (id, name) => { + setProject((p) => ({ + ...p, + masterLayouts: (p.masterLayouts ?? []).map((m) => + m.id === id ? { ...m, name } : m, + ), + })); + }, + onDeleteMasterLayout: (id) => { + // Master löschen UND Bindungen der Layouts lösen (masterId → undefined). + setProject((p) => ({ + ...p, + masterLayouts: (p.masterLayouts ?? []).filter((m) => m.id !== id), + layouts: (p.layouts ?? []).map((l) => + l.masterId === id ? { ...l, masterId: undefined } : l, + ), + })); + }, + onPatchMasterLayout: (id, patch) => { + setProject((p) => ({ + ...p, + masterLayouts: (p.masterLayouts ?? []).map((m) => + m.id === id ? { ...m, ...patch } : m, + ), + })); + }, + + // ── Layouts-Baum (Ordner) + Erstell-Dialoge (DOSSIER A3) ──────────── + onAddLayoutFolder: (parentId) => { + const id = `folder-${Date.now()}`; + const folder = createLayoutFolder(id, t("layouts.folder.defaultName"), parentId); + setProject((p) => ({ + ...p, + layoutFolders: [...(p.layoutFolders ?? []), folder], + })); + return id; + }, + onAddMasterFolder: (parentId) => { + const id = `mfolder-${Date.now()}`; + const folder = createLayoutFolder( + id, + t("layouts.folder.defaultName"), + parentId, + "master", + ); + setProject((p) => ({ + ...p, + layoutFolders: [...(p.layoutFolders ?? []), folder], + })); + return id; + }, + onDeleteMasterFolder: (id) => { + setProject((p) => { + const { folders, masters } = deleteMasterFolder( + id, + p.layoutFolders ?? [], + p.masterLayouts ?? [], + ); + return { ...p, layoutFolders: folders, masterLayouts: masters }; + }); + }, + onRenameLayoutFolder: (id, name) => { + setProject((p) => ({ + ...p, + layoutFolders: (p.layoutFolders ?? []).map((f) => + f.id === id ? { ...f, name } : f, + ), + })); + }, + onDeleteLayoutFolder: (id) => { + setProject((p) => { + const { folders, layouts } = deleteLayoutFolder( + id, + p.layoutFolders ?? [], + p.layouts ?? [], + ); + return { ...p, layoutFolders: folders, layouts }; + }); + }, + onCreateLayout: (opts) => { + const id = `lay-${Date.now()}`; + // Bei gebundenem Master erbt das Blatt dessen Papier/Ausrichtung/Grösse. + const master = opts.masterId + ? (project.masterLayouts ?? []).find((m) => m.id === opts.masterId) + : undefined; + const paper = master?.paper ?? opts.paper; + const orientation = master?.orientation ?? opts.orientation; + const customWidthMm = master ? master.customWidthMm : opts.customWidthMm; + const customHeightMm = master ? master.customHeightMm : opts.customHeightMm; + const lay = createLayout(id, t("layouts.defaultName"), paper, orientation); + const full = { + ...lay, + ...(opts.masterId ? { masterId: opts.masterId } : {}), + ...(opts.folderId ? { folderId: opts.folderId } : {}), + ...(customWidthMm !== undefined && customHeightMm !== undefined + ? { customWidthMm, customHeightMm } + : {}), + }; + setProject((p) => ({ ...p, layouts: [...(p.layouts ?? []), full] })); + return id; + }, + onCreateMasterLayout: (opts) => { + const id = `mas-${Date.now()}`; + const custom = + opts.customWidthMm !== undefined && opts.customHeightMm !== undefined + ? { widthMm: opts.customWidthMm, heightMm: opts.customHeightMm } + : undefined; + const m = createMasterLayout( + id, + t("layouts.defaultMasterName"), + opts.paper, + opts.orientation, + custom, + ); + const full = opts.folderId ? { ...m, folderId: opts.folderId } : m; + setProject((p) => ({ + ...p, + masterLayouts: [...(p.masterLayouts ?? []), full], + })); + return id; + }, + onExportFolderPdf: (folderId) => { + const folder = (project.layoutFolders ?? []).find((f) => f.id === folderId); + const name = folder?.name?.trim() || "layouts"; + void saveFolderPdf(project, folderId, name); + }, + onMoveLayoutToFolder: (layoutId, folderId) => { + setProject((p) => ({ + ...p, + layouts: moveLayoutToFolder(p.layouts ?? [], layoutId, folderId), + })); + }, + onMoveFolderToFolder: (folderId, parentId) => { + setProject((p) => ({ + ...p, + layoutFolders: moveFolderToFolder( + p.layoutFolders ?? [], + folderId, + parentId, + ), + })); + }, }; return value; // setProject-Closures sind stabil genug; Neuberechnung bei project/active. @@ -2544,7 +3585,61 @@ export default function App() { activeWallTypeId, snap, viewType, + view3d, + fov, + detail, + scaleDenominator, selection, + selectedViewSnapshotId, + ]); + + // ── Ausschnitt-Anwahl: „bleibt bis zur Abweichung" (DOSSIER A2) ─────────── + // Sobald ein Ausschnitt angewählt ist, vergleicht dieser Effekt bei JEDER + // Änderung einer ERFASSTEN Grösse (Ansicht/Kamera/Massstab/Detail/Geschoss + + // Ebenen-/Zeichnungssichtbarkeit + aktive Overrides) den Live-Zustand gegen + // den gespeicherten Kernzustand (pure `snapshotMatchesLiveState`). Weicht auch + // nur EIN Feld ab → Auswahl löschen (Footer verschwindet). Der Effekt reagiert + // NUR auf die snapshot-relevanten Projekt-Teile (layers/drawingLevels/ + // overrideRules), NICHT auf `project` als Ganzes — eine Wand verschieben o. Ä. + // lässt die Auswahl unberührt. + useEffect(() => { + if (!selectedViewSnapshotId) return; + const snap = (project.viewSnapshots ?? []).find( + (s) => s.id === selectedViewSnapshotId, + ); + if (!snap) { + setSelectedViewSnapshotId(null); + return; + } + const live: ViewSnapshotState = { + viewType, + view3d, + fov, + scaleDenominator, + detail, + activeLevelId, + layerVisibility: snapshotLayerVisibility(), + drawingVisibility: snapshotDrawingVisibility(), + enabledOverrideRuleIds: (project.overrideRules ?? []) + .filter((r) => r.enabled) + .map((r) => r.id), + }; + if (!snapshotMatchesLiveState(readViewSnapshot(snap), live)) { + setSelectedViewSnapshotId(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + selectedViewSnapshotId, + viewType, + view3d, + fov, + scaleDenominator, + detail, + activeLevelId, + project.viewSnapshots, + project.layers, + project.drawingLevels, + project.overrideRules, ]); // ── Dock-/Float-Handler (aus dem Layout-Slice) ──────────────────────────── @@ -2628,6 +3723,15 @@ export default function App() { onPatchCeilingType: patchCeilingType, onAddCeilingType: addCeilingType, onDeleteCeilingType: deleteCeilingType, + onAddDoorType: addDoorType, + onPatchDoorType: patchDoorType, + onDeleteDoorType: deleteDoorType, + onAddWindowType: addWindowType, + onPatchWindowType: patchWindowType, + onDeleteWindowType: deleteWindowType, + onAddStairType: addStairType, + onPatchStairType: patchStairType, + onDeleteStairType: deleteStairType, onAddComponent: addComponent, onDeleteComponent: deleteComponent, onPatchHatch: patchHatch, @@ -2638,8 +3742,24 @@ export default function App() { onDeleteLineStyle: deleteLineStyle, onImportLineStyles: importLineStyles, onImportHatches: importHatches, + onSetOverrideRules: setOverrideRules, }; + // ── Layout-Blatt (DOSSIER A3, Phase 2b): das offene Blatt + Viewport-Handler ─ + // Das offene Layout wird live aus dem Projekt gelesen (Single Source of Truth); + // wurde es zwischenzeitlich gelöscht, ist es undefined → der Haupt-Viewport + // zeigt wieder die Modell-Ansicht. Die Handler mutieren immutabel über die + // pure CRUD-Ebene (panels/layoutModel.ts). + const activeLayout = + activeLayoutId !== null + ? (project.layouts ?? []).find((l) => l.id === activeLayoutId) + : undefined; + const patchOpenLayout = (id: string, fn: (l: Layout) => Layout) => + setProject((p) => ({ + ...p, + layouts: (p.layouts ?? []).map((l) => (l.id === id ? fn(l) : l)), + })); + // ── Effektive Darstellung für die Hauptansicht ──────────────────────────── // 3D: Geschosse nach dem Modus des Zeichnungsebenen-Panels, Kategorien nach // dem Modus des Ebenen-Panels. Grundriss: Kategorien nach dem Ebenen-Panel. @@ -2841,8 +3961,7 @@ export default function App() { // Modusleiste-Wechsel: array/distribute fragen die Kopienanzahl ab. const onTransformMode = (mode: CopyMode) => { if (mode === "array" || mode === "distribute") { - const n = promptCount(); - if (n) setActiveTransform((tr) => (tr ? { ...tr, copyMode: mode, count: n } : tr)); + requestCopyCount(mode); } else { setActiveTransform((tr) => (tr ? { ...tr, copyMode: mode } : tr)); } @@ -2951,8 +4070,41 @@ export default function App() { setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); setStampEditorRoomId(roomId); }; + // Doppelklick auf ein Text-2D-Element: selektieren + Inhalts-Editor öffnen. + const onEditDrawingText = (drawingId: string) => { + const d = project.drawings2d.find((x) => x.id === drawingId); + if (!d || d.geom.shape !== "text") return; + setSelectedDrawingId(drawingId); + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + setEditTextId(drawingId); + }; + // Bearbeiteten Text-Inhalt übernehmen (immutabel; nur bei Text-Geometrie). + const commitTextEdit = (value: string) => { + const id = editTextId; + if (!id) return; + const text = value.trim(); + if (text !== "") { + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.map((d) => + d.id === id && d.geom.shape === "text" + ? { ...d, geom: { ...d.geom, text } } + : d, + ), + })); + } + setEditTextId(null); + }; const onPlanSelect = (sel: PlanSelection) => { // Shift+Klick: Mehrfach-Auswahl — getroffenes Element zu-/abschalten, den // Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl @@ -3000,6 +4152,20 @@ export default function App() { ? selectedRoomIds.filter((r) => r !== id) : [...selectedRoomIds, id], ); + } else if (sel.extrudedSolidId) { + const id = sel.extrudedSolidId; + setSelectedExtrudedSolidIds( + selectedExtrudedSolidIds.includes(id) + ? selectedExtrudedSolidIds.filter((x) => x !== id) + : [...selectedExtrudedSolidIds, id], + ); + } else if (sel.columnId) { + const id = sel.columnId; + setSelectedColumnIds( + selectedColumnIds.includes(id) + ? selectedColumnIds.filter((x) => x !== id) + : [...selectedColumnIds, id], + ); } return; } @@ -3013,6 +4179,8 @@ export default function App() { setSelectedCeilingIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } else if (sel.wallId) { setSelectedWallIds([sel.wallId]); setSelectedDrawingId(null); @@ -3020,6 +4188,17 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + } else if (sel.columnId) { + setSelectedColumnIds([sel.columnId]); + setSelectedWallIds([]); + setSelectedDrawingId(null); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); } else if (sel.drawingId) { setSelectedDrawingId(sel.drawingId); setSelectedWallIds([]); @@ -3027,6 +4206,17 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + } else if (sel.extrudedSolidId) { + setSelectedExtrudedSolidIds([sel.extrudedSolidId]); + setSelectedWallIds([]); + setSelectedDrawingId(null); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedColumnIds([]); } else if (sel.ceilingId) { setSelectedCeilingIds([sel.ceilingId]); setSelectedWallIds([]); @@ -3034,6 +4224,8 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } else if (sel.stairId) { setSelectedStairIds([sel.stairId]); setSelectedWallIds([]); @@ -3041,6 +4233,8 @@ export default function App() { setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } else if (sel.roomId) { setSelectedRoomIds([sel.roomId]); setSelectedWallIds([]); @@ -3048,6 +4242,8 @@ export default function App() { setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } else { setSelectedWallIds([]); setSelectedDrawingId(null); @@ -3055,6 +4251,8 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } }; // Links-Drag (Marquee) im Grundriss: die getroffene Wand-MENGE übernehmen @@ -3062,11 +4260,14 @@ export default function App() { const onPlanMarquee = (sel: MarqueeSelection) => { setSelectedWallIds(sel.wallIds); setSelectedDrawingIds(sel.drawingIds ?? []); - // Decken/Öffnungen/Treppen/Räume sind (noch) nicht Teil des Marquee → beim Aufziehen abwählen. + // Decken/Öffnungen/Treppen/Räume/Extrusionen sind (noch) nicht Teil des + // Marquee → beim Aufziehen abwählen. setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); }; // Rechts-Klick im Grundriss: Wand-Kontextmenü. Trifft der Klick eine Wand, die // NICHT bereits in der Auswahl ist, wird die Auswahl auf sie gesetzt (damit die @@ -3090,6 +4291,8 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } }; // Links-Klick im 3D: per Raycast getroffene Treppe wählen (Einzelauswahl). @@ -3101,6 +4304,8 @@ export default function App() { setSelectedCeilingIds([]); setSelectedOpeningIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } }; // Links-Klick im 3D: per Raycast getroffene Decke wählen (Einzelauswahl). @@ -3112,6 +4317,8 @@ export default function App() { setSelectedStairIds([]); setSelectedOpeningIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } }; // Links-Klick im 3D: per Raycast getroffene Öffnung wählen (Einzelauswahl). @@ -3123,6 +4330,8 @@ export default function App() { setSelectedCeilingIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); } }; // Links-KLICK im Nordstern-3D-Viewport (WASM, TS-Raycast): getroffenes Bauteil @@ -3139,6 +4348,33 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + return; + } + // Öffnung (Fenster/Tür) und Treppe sind jetzt auch im WASM-Viewport pickbar + // (raycast3d/pickGeometry). Jede Art ersetzt die Auswahl exklusiv; nur Wände + // unterstützen additive Mehrfachauswahl (wie bisher). + if (hit.kind === "opening") { + setSelectedOpeningIds([hit.id]); + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedDrawingId(null); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + return; + } + if (hit.kind === "stair") { + setSelectedStairIds([hit.id]); + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedDrawingId(null); + setSelectedOpeningIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); return; } if (hit.kind === "wall") { @@ -3156,6 +4392,8 @@ export default function App() { setSelectedOpeningIds([]); setSelectedStairIds([]); setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); }; // Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition. const onViewportContextMenu = (info: ViewportContextInfo) => { @@ -3225,7 +4463,7 @@ export default function App() { { const f = e.target.files?.[0]; @@ -3244,18 +4482,19 @@ export default function App() { onExportPdf={() => setExportOpen(true)} onExportDxf={() => setExportDxfOpen(true)} onExportSchedule={onExportSchedule} + onExportIfc={onExportIfc} + onExportObj={onExportObj} + onExportStl={onExportStl} + viewType={viewType} + fov={fov} + onFov={setFov} + cameraEnabled={activeLevel.kind === "floor"} resourcesOpen={resourcesOpen} onToggleResources={() => setResourcesOpen((v) => !v)} onOpenSettings={() => setSettingsOpen(true)} onImport={() => dxfInputRef.current?.click()} onSaveProject={onSaveProject} onOpenProject={onOpenProject} - layoutMenu={ - setLayout(reconcileLayout(l))} - /> - } /> {/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten). Der „Ansichten"-Tab trägt @@ -3278,8 +4517,6 @@ export default function App() { onViewType={setViewType} view3d={view3d} onView3d={setView3d} - fov={fov} - onFov={setFov} detail={detail} onDetail={setDetail} detailEnabled={planActive} @@ -3327,11 +4564,11 @@ export default function App() { onViewType={setViewType} view3d={view3d} onView3d={setView3d} - fov={fov} - onFov={setFov} renderMode={renderMode} onRenderMode={setRenderMode} renderModeEnabled={viewType === "perspektive"} + onRunCommand={onRunCommand} + activeCommand={engineView.commandName} /> ), }} @@ -3361,6 +4598,50 @@ export default function App() { Befehlszeile nur so breit wie der Viewport (nicht die ganze App), und die Docks nehmen die volle Body-Höhe ein (inkl. der Zeile). */}
+ {activeLayout ? ( + /* Layout-Modus (Phase 2b): das Blatt füllt den Haupt-Viewport (statt + Grundriss/3D) und ist direkt mit der Maus editierbar — platzieren/ + verschieben/skalieren/löschen der Viewports, Pan/Zoom, mm↔Bildschirm. + Die Befehlszeile entfällt (kein Zeichnen auf dem Blatt). */ + setActiveLayoutId(null), + onPatchViewport: (viewportId, patch) => + patchOpenLayout(activeLayout.id, (l) => + patchViewportInLayout(l, viewportId, patch), + ), + onRemoveViewport: (viewportId) => + patchOpenLayout(activeLayout.id, (l) => + removeViewportFromLayout(l, viewportId), + ), + onAddViewport: (snapshotId, init) => + patchOpenLayout(activeLayout.id, (l) => + addViewportToLayout( + l, + createViewport(`vp-${Date.now()}`, snapshotId, init), + ), + ), + onAddAnnotation: (kind, init) => + patchOpenLayout(activeLayout.id, (l) => + addAnnotationToLayout( + l, + createAnnotation(`ann-${Date.now()}`, kind, init), + ), + ), + onPatchAnnotation: (annotationId, patch) => + patchOpenLayout(activeLayout.id, (l) => + patchAnnotationInLayout(l, annotationId, patch), + ), + onRemoveAnnotation: (annotationId) => + patchOpenLayout(activeLayout.id, (l) => + removeAnnotationFromLayout(l, annotationId), + ), + }} + /> + ) : ( + <> {/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan- Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt das Zoom-Handle entgegen. */} @@ -3392,10 +4673,13 @@ export default function App() { selectedCeilingIds={selectedCeilingIds} selectedOpeningIds={selectedOpeningIds} selectedStairIds={selectedStairIds} + selectedExtrudedSolidIds={selectedExtrudedSolidIds} + selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} roomStamp={roomStamp} onStampMove={moveRoomStamp} onStampEdit={onOpenStampEditor} + onEditDrawingText={onEditDrawingText} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -3459,6 +4743,13 @@ export default function App() { if (isFinite(v)) submitTransformValue(v); return; } + // Getipptes „rotate"/„drehen" startet die interaktive Drehung (U/I/O/P), + // analog zum Ribbon-Button und der Taste „d". + const cmdWord = text.trim().toLowerCase(); + if (cmdWord === "rotate" || cmdWord === "drehen") { + startTransformOp("rotate"); + return; + } engine.submitText(text); }} onOption={(id) => @@ -3472,6 +4763,8 @@ export default function App() { } suggest={(prefix) => engine.suggest(prefix)} /> + + )}
{/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes @@ -3620,10 +4913,76 @@ export default function App() { rendererMode={rendererMode} webGpuAvailable={webGpuAvailable} onRendererModeChange={setRendererMode} + layoutMenu={ + setLayout(reconcileLayout(l))} + /> + } onClose={() => setSettingsOpen(false)} /> )} + {exportSaveFormat && ( + { + runExport(fmt, filename); + setExportSaveFormat(null); + }} + onClose={() => setExportSaveFormat(null)} + /> + )} + + {lockConflict && ( + setLockConflict(null)} + onReadOnly={() => { + applyOpenedProjectFile(lockConflict.path, lockConflict.content, false); + setLockConflict(null); + }} + onForceOpen={() => { + // Kein eigener Lock (die andere Instanz hält ihn) — bewusste + // Risiko-Übernahme, im Dialog als Warnung gekennzeichnet. + applyOpenedProjectFile(lockConflict.path, lockConflict.content, false); + setLockConflict(null); + }} + /> + )} + + {pendingCopyMode && ( + setPendingCopyMode(null)} + /> + )} + + {/* Text-Inhalt-Editor: per Doppelklick auf ein Text-2D-Element geöffnet. + Einzeilige Bearbeitung des Textinhalts (Absätze der Textspalte per „\n" + werden dabei zu einer Zeile zusammengezogen — für mehrzeilige Spalten + bleibt die Ersteingabe massgeblich). */} + {editTextId && + (() => { + const d = project.drawings2d.find((x) => x.id === editTextId); + if (!d || d.geom.shape !== "text") return null; + return ( + setEditTextId(null)} + /> + ); + })()} + {/* Text-Editor (schwebendes Fenster): Doppelklick auf einen Raumstempel bzw. Button in der Objekt-Info öffnet ihn. „Übernehmen" schreibt das bearbeitete Rich-Text-Dokument über setRoomStampDoc. Die Formatier- @@ -3853,6 +5212,10 @@ export default function App() { ); })()} + + {/* Nicht-blockierende Kurzmeldungen (Ersatz für window.alert, siehe + notifySlice.ts) — top-level, damit sie über allen Docks/Modals liegen. */} + ); } @@ -4330,14 +5693,17 @@ function LayoutMenu({ onApply: (layout: LayoutState) => void; }) { 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(listNamedLayouts()); - const onSave = () => { - const name = window.prompt(t("layout.savePrompt")); - if (!name) return; + const onSave = () => setSaving(true); + const commitSave = (name: string) => { saveNamedLayout(name.trim(), layout); refresh(); + setSaving(false); }; // Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben, @@ -4372,6 +5738,14 @@ function LayoutMenu({ return ( + {saving && ( + setSaving(false)} + /> + )} ); } @@ -4407,10 +5781,13 @@ function Content({ selectedCeilingIds, selectedOpeningIds, selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, selectedRoomIds, roomStamp, onStampMove, onStampEdit, + onEditDrawingText, onPlanSelect, onPlanMarquee, onSegmentCut, @@ -4477,10 +5854,13 @@ function Content({ selectedCeilingIds: string[]; selectedOpeningIds: string[]; selectedStairIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; selectedRoomIds: string[]; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; + onEditDrawingText?: (drawingId: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; @@ -4535,15 +5915,26 @@ function Content({ // hervor. Leere Auswahl → null (kein Highlight). const highlightLines = useMemo( () => - selectedWallIds.length === 0 && selectedCeilingIds.length === 0 + selectedWallIds.length === 0 && + selectedCeilingIds.length === 0 && + selectedOpeningIds.length === 0 && + selectedStairIds.length === 0 ? null : selectionHighlightLines( project, selectedWallIds, selectedCeilingIds, HIGHLIGHT_RGB, + selectedOpeningIds, + selectedStairIds, ), - [project, selectedWallIds, selectedCeilingIds], + [ + project, + selectedWallIds, + selectedCeilingIds, + selectedOpeningIds, + selectedStairIds, + ], ); if (level.kind === "section" || level.kind === "elevation") { @@ -4568,10 +5959,13 @@ function Content({ selectedCeilingIds={selectedCeilingIds} selectedOpeningIds={selectedOpeningIds} selectedStairIds={selectedStairIds} + selectedExtrudedSolidIds={selectedExtrudedSolidIds} + selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} + onEditDrawingText={onEditDrawingText} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -4619,10 +6013,13 @@ function Content({ selectedCeilingIds={selectedCeilingIds} selectedOpeningIds={selectedOpeningIds} selectedStairIds={selectedStairIds} + selectedExtrudedSolidIds={selectedExtrudedSolidIds} + selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} + onEditDrawingText={onEditDrawingText} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -4705,10 +6102,13 @@ function Content({ selectedCeilingIds={selectedCeilingIds} selectedOpeningIds={selectedOpeningIds} selectedStairIds={selectedStairIds} + selectedExtrudedSolidIds={selectedExtrudedSolidIds} + selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} + onEditDrawingText={onEditDrawingText} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -4746,10 +6146,13 @@ function LevelPlanView({ selectedCeilingIds, selectedOpeningIds, selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, selectedRoomIds, roomStamp, onStampMove, onStampEdit, + onEditDrawingText, onPlanSelect, onPlanMarquee, onSegmentCut, @@ -4780,10 +6183,13 @@ function LevelPlanView({ selectedCeilingIds: string[]; selectedOpeningIds: string[]; selectedStairIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; selectedRoomIds: string[]; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; + onEditDrawingText?: (drawingId: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; @@ -4853,10 +6259,13 @@ function LevelPlanView({ selectedCeilingIds={selectedCeilingIds} selectedOpeningIds={selectedOpeningIds} selectedStairIds={selectedStairIds} + selectedExtrudedSolidIds={selectedExtrudedSolidIds} + selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} + onEditDrawingText={onEditDrawingText} onSelect={onPlanSelect} onMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -4902,10 +6311,13 @@ function SectionPlanView({ selectedCeilingIds, selectedOpeningIds, selectedStairIds, + selectedExtrudedSolidIds, + selectedColumnIds, selectedRoomIds, roomStamp, onStampMove, onStampEdit, + onEditDrawingText, onPlanSelect, onPlanMarquee, onSegmentCut, @@ -4932,10 +6344,13 @@ function SectionPlanView({ selectedCeilingIds: string[]; selectedOpeningIds: string[]; selectedStairIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; selectedRoomIds: string[]; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; + onEditDrawingText?: (drawingId: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; @@ -5015,10 +6430,13 @@ function SectionPlanView({ selectedCeilingIds={selectedCeilingIds} selectedOpeningIds={selectedOpeningIds} selectedStairIds={selectedStairIds} + selectedExtrudedSolidIds={selectedExtrudedSolidIds} + selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} + onEditDrawingText={onEditDrawingText} onSelect={onPlanSelect} onMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} diff --git a/src/commands/cmds/column.ts b/src/commands/cmds/column.ts new file mode 100644 index 0000000..787421f --- /dev/null +++ b/src/commands/cmds/column.ts @@ -0,0 +1,152 @@ +// Stütze (Column) als Engine-Befehl — DOSSIER-Audit A4 Tragwerk. Ablauf: +// 1) „Stütze setzen:" → Klick setzt den Einfügepunkt (Profil-Mitte) → commit. +// +// Optionen (togglebar, IMMER sichtbar): Profil Rechteck/Rund. Eine getippte Zahl +// setzt das Leitmass (Rechteck: Seitenlänge → quadratisch; Rund: Durchmesser) +// PERSISTENT über Aufrufe — analog `offset`/`extrude`, wo der pick-Schritt auch +// eine Zahl annimmt. Höhe = Geschosshöhe des aktiven Geschosses (Default). +// +// Herkunft der Stützen-Felder (CommandContext): +// • floorId ← ctx.level.id (aktives Geschoss) +// • categoryCode← "50" (Tragwerk; Fallback ctx.defaultCategoryCode) +// • height ← Geschosshöhe (ctx.level.floorHeight), Fallback DEF_HEIGHT +// +// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). + +import type { Column, ColumnProfile } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import { columnFootprint } from "../../geometry/column"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + CmdOption, + Project, + Vec2, +} from "../types"; + +/** Default-Ebene (Kategorie) für Stützen: „50" Tragwerk (Fallback: aktive). */ +const COLUMN_CATEGORY = "50"; +/** Default-Kantenlänge eines Rechteckprofils (Meter). */ +const DEF_SIZE = 0.3; +/** Fallback-Höhe, falls das Geschoss keine floorHeight trägt (Meter). */ +const DEF_HEIGHT = 2.6; + +// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ──── +const columnDefaults: { kind: "rect" | "round"; size: number } = { + kind: "rect", + size: DEF_SIZE, +}; + +/** Baut das aktuelle Default-Profil aus `columnDefaults`. */ +function currentProfile(): ColumnProfile { + if (columnDefaults.kind === "round") { + return { kind: "round", radius: columnDefaults.size / 2 }; + } + return { kind: "rect", width: columnDefaults.size, depth: columnDefaults.size }; +} + +/** Fügt die Stütze immutabel an `project.columns` an. */ +function appendColumn( + p: Project, + position: Vec2, + ctx: CommandContext, +): Project { + const height = + ctx.level.floorHeight != null && ctx.level.floorHeight > 0 + ? ctx.level.floorHeight + : DEF_HEIGHT; + const column: Column = { + id: uniqueId("column"), + type: "column", + floorId: ctx.level.id, + categoryCode: COLUMN_CATEGORY, + position, + profile: currentProfile(), + rotation: 0, + height, + }; + return { ...p, columns: [...(p.columns ?? []), column] }; +} + +interface ColState extends CommandState { + phase: "place" | "done"; +} + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as ColState, + { draft: null, done: true }, +]; + +/** Footprint-Vorschau des aktuellen Default-Profils am Cursor. */ +function previewAt(pt: Vec2): CommandResult { + const ghost: Column = { + id: "_preview", + type: "column", + floorId: "", + categoryCode: COLUMN_CATEGORY, + position: pt, + profile: currentProfile(), + rotation: 0, + height: 0, + }; + const size = + columnDefaults.kind === "round" + ? `Ø${(columnDefaults.size * 100).toFixed(0)}` + : `${(columnDefaults.size * 100).toFixed(0)}×${(columnDefaults.size * 100).toFixed(0)}`; + return { + draft: { + preview: [{ kind: "poly", pts: columnFootprint(ghost), closed: true }], + vertices: [], + hud: { at: pt, text: size }, + }, + }; +} + +export const columnCommand: Command = { + name: "column", + labelKey: "cmd.column.label", + prompt: () => "cmd.column.place", + accepts: () => ["point", "number", "option"], + options: (): CmdOption[] => [ + { + id: "profile", + labelKey: "cmd.column.profile", + value: columnDefaults.kind === "round" ? "round" : "rect", + }, + ], + floorOnly: true, + + init: (): ColState => ({ phase: "place", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as ColState; + if (s.phase === "done") return finish(); + if (input.kind === "number") { + const v = Math.abs(input.value); + if (v > 1e-4) columnDefaults.size = v; + return [s, { draft: null }]; + } + if (input.kind === "option" && input.id === "profile") { + columnDefaults.kind = columnDefaults.kind === "round" ? "rect" : "round"; + return [s, { draft: null }]; + } + if (input.kind === "point") { + return [ + { phase: "done", lastPoint: null } as ColState, + { draft: null, done: true, commit: (p) => appendColumn(p, input.point, ctx) }, + ]; + } + return [s, { draft: null }]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as ColState; + if (s.phase !== "place") return [s, { draft: null }]; + return [s, previewAt(point)]; + }, + + onConfirm: (): [CommandState, CommandResult] => finish(), + onCancel: (): [CommandState, CommandResult] => finish(), +}; diff --git a/src/commands/cmds/copy.ts b/src/commands/cmds/copy.ts index 3cef59a..021004c 100644 --- a/src/commands/cmds/copy.ts +++ b/src/commands/cmds/copy.ts @@ -42,10 +42,20 @@ function targetFromFields(base: Vec2, locks: Record, cursor: Vec } function hasSelection(sel: CommandSelection): boolean { - return sel.wallIds.length > 0 || sel.drawingId !== null; + return ( + sel.wallIds.length > 0 || + sel.drawingId !== null || + !!sel.extrudedSolidId || + !!sel.columnId + ); } function toTransformSel(sel: CommandSelection): TransformSelection { - return { wallIds: sel.wallIds, drawingId: sel.drawingId }; + return { + wallIds: sel.wallIds, + drawingId: sel.drawingId, + extrudedSolidId: sel.extrudedSolidId, + columnId: sel.columnId, + }; } interface CopyBase extends CommandState { diff --git a/src/commands/cmds/extrude.ts b/src/commands/cmds/extrude.ts new file mode 100644 index 0000000..96e19ab --- /dev/null +++ b/src/commands/cmds/extrude.ts @@ -0,0 +1,287 @@ +// Extrudieren — truck-Integration Phase 3 (docs/design/truck-plan.md, PENDENZEN +// "truck-Integration"). Erzeugt aus einer geschlossenen 2D-Kurve (Polylinie-Ring, +// Rechteck oder Kreis) einen 3D-Körper: Schritte: +// 1) Profil bestimmen: ist genau EIN passendes geschlossenes Drawing2D +// selektiert, wird es genommen; sonst „Profil wählen:" → Klick auf einen Ring. +// 2) „Höhe:" → Zahl tippen (PERSISTENT als Default über Aufrufe) ODER Enter +// bestätigt den zuletzt genutzten Default. +// 3) „Verjüngung:" → Zahl 0..1 tippen (0=Prisma/Default, 1=Spitze — Kegel bei +// Kreis-, Pyramide bei Polygon-Profil) ODER Enter für den zuletzt +// genutzten Default (persistent, wie Höhe). → commit neues ExtrudedSolid. +// +// Die eigentliche Extrusion (truck-WASM) läuft asynchron erst in toWalls3d.ts +// (emitExtrudedSolids); dieser Befehl legt nur die Rohdaten (Punkte + Höhe + +// Verjüngung) im Projekt ab. + +import type { Drawing2D, ExtrudedSolid } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import { polylineEdges, pointSegmentDistance } from "../../geometry/kernel2d"; +import type { + Command, + CommandContext, + CommandResult, + CommandSelection, + CommandState, + Project, + Vec2, +} from "../types"; + +const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Profil-Wahl + +/** Persistente Extrusionshöhe über Aufrufe hinweg (Default: Geschosshöhe). */ +let lastHeight = 2.6; +/** Persistente Verjüngung über Aufrufe hinweg (Default: Prisma, kein Kegel/Pyramide). */ +let lastTaper = 0; + +// ── Profil-Abstraktion: geschlossene polyline/rect/circle → Punktliste ───── + +/** Kreis-Tessellierung für Footprint/Auswahl/bbox (48-Eck, wie circle.ts). */ +function circlePts(center: Vec2, r: number): Vec2[] { + const N = 48; + const pts: Vec2[] = []; + for (let i = 0; i < N; i++) { + const t = (i / N) * Math.PI * 2; + pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) }); + } + return pts; +} + +/** Ein extrudierfähiges Profil: Tessellierung + optional die echte Kreis-Geometrie. */ +interface Profile { + pts: Vec2[]; + circle?: { center: Vec2; r: number }; +} + +/** Wandelt ein extrudierfähiges (geschlossenes) Drawing2D in ein Profil. */ +function geomToProfile(d: Drawing2D): Profile | null { + const g = d.geom; + if (g.shape === "polyline" && g.closed && g.pts.length >= 3) return { pts: g.pts }; + if (g.shape === "rect") { + return { pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }] }; + } + if (g.shape === "circle") { + return { pts: circlePts(g.center, g.r), circle: { center: g.center, r: g.r } }; + } + return null; +} + +/** Genau ein passendes Drawing2D vorselektiert? Dann liefere sein Profil. */ +function singleSelectedProfile( + project: Project, + sel: CommandSelection, +): ({ d: Drawing2D } & Profile) | null { + if (!sel.drawingId || sel.wallIds.length > 0) return null; + const d = project.drawings2d.find((x) => x.id === sel.drawingId); + if (!d) return null; + const profile = geomToProfile(d); + return profile ? { d, ...profile } : null; +} + +/** Nächstes extrudierfähige Drawing2D zum Klickpunkt (innerhalb Toleranz). */ +function pickProfileAt( + project: Project, + levelId: string, + pt: Vec2, +): ({ d: Drawing2D } & Profile) | null { + let best: ({ d: Drawing2D } & Profile) | null = null; + let bestD = HIT_TOL; + for (const d of project.drawings2d) { + if (d.levelId !== levelId) continue; + const profile = geomToProfile(d); + if (!profile) continue; + for (const [a, b] of polylineEdges(profile.pts, true)) { + const dd = pointSegmentDistance(pt, a, b); + if (dd < bestD) { + bestD = dd; + best = { d, ...profile }; + } + } + } + return best; +} + +/** + * Hängt den neuen Körper an UND entfernt das Quell-Profil (`sourceId`) aus + * `drawings2d` — die Polylinie/das Rechteck WIRD zur Extrusion, sie bleibt + * nicht als unabhängiges Duplikat liegen (sonst zwei Objekte am selben Ort, + * eines davon im Grundriss unsichtbar verwaist). + */ +function appendExtrudedSolid( + p: Project, + profile: Profile, + height: number, + taper: number, + sourceId: string, + ctx: CommandContext, +): Project { + const solid: ExtrudedSolid = { + id: uniqueId("extrude"), + type: "extrudedSolid", + levelId: ctx.level.id, + points: profile.pts, + height, + circle: profile.circle, + ...(taper > 0 ? { taper } : {}), + }; + return { + ...p, + drawings2d: p.drawings2d.filter((d) => d.id !== sourceId), + extrudedSolids: [...(p.extrudedSolids ?? []), solid], + }; +} + +// ── Zustand ─────────────────────────────────────────────────────────────────── + +interface ExPick extends CommandState { + phase: "pick"; +} +interface ExHeight extends CommandState { + phase: "height"; + profile: Profile; + /** Id des Quell-Drawing2D, das beim Commit ersetzt (entfernt) wird. */ + sourceId: string; + cursor: Vec2 | null; +} +interface ExTaper extends CommandState { + phase: "taper"; + profile: Profile; + sourceId: string; + height: number; + cursor: Vec2 | null; +} +interface ExDone extends CommandState { + phase: "done"; +} +type ExState = ExPick | ExHeight | ExTaper | ExDone; + +const finish = (): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as ExDone, + { draft: null, done: true }, +]; + +/** Höhe entgegengenommen → Verjüngungs-Schritt betreten (HUD zeigt Default). */ +const enterTaperPhase = ( + profile: Profile, + height: number, + sourceId: string, +): [CommandState, CommandResult] => [ + { phase: "taper", profile, sourceId, height, cursor: null } as ExTaper, + { draft: null }, +]; + +const commitTaper = ( + profile: Profile, + height: number, + taper: number, + sourceId: string, + ctx: CommandContext, +): [CommandState, CommandResult] => [ + { phase: "done", lastPoint: null } as ExDone, + { draft: null, done: true, commit: (p) => appendExtrudedSolid(p, profile, height, taper, sourceId, ctx) }, +]; + +export const extrudeCommand: Command = { + name: "extrude", + labelKey: "cmd.extrude.label", + prompt: (s) => { + const phase = (s as ExState).phase; + if (phase === "height") return "cmd.extrude.height"; + if (phase === "taper") return "cmd.extrude.taper"; + return "cmd.extrude.pick"; + }, + // Auch der „pick"-Schritt nimmt eine Zahl an, damit eine getippte Höhe sofort + // greift, wenn ein Profil vorselektiert ist (dann wird „pick" live wie + // „height" behandelt — analog offset.ts). + accepts: () => ["point", "number"], + options: () => [], + floorOnly: true, + + init: (): ExPick => ({ phase: "pick", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as ExState; + if (s.phase === "done") return finish(); + + if (s.phase === "pick") { + const pre = singleSelectedProfile(ctx.project, ctx.selection); + if (pre) { + if (input.kind === "number") { + const h = Math.abs(input.value); + if (h < 1e-6) return [s, { draft: null }]; + lastHeight = h; + return enterTaperPhase(pre, h, pre.d.id); + } + // Punkt im vorselektierten Fall: Höhen-Schritt betreten (HUD zeigt Default). + const ns: ExHeight = { phase: "height", profile: pre, sourceId: pre.d.id, cursor: null, lastPoint: null }; + return [ns, { draft: null }]; + } + if (input.kind === "point") { + const hit = pickProfileAt(ctx.project, ctx.level.id, input.point); + if (!hit) return [s, { draft: null }]; + const ns: ExHeight = { phase: "height", profile: hit, sourceId: hit.d.id, cursor: null, lastPoint: null }; + return [ns, { draft: null }]; + } + return [s, { draft: null }]; + } + + if (s.phase === "height") { + if (input.kind === "number") { + const h = Math.abs(input.value); + if (h < 1e-6) return [s, { draft: null }]; + lastHeight = h; + return enterTaperPhase(s.profile, h, s.sourceId); + } + return [s, { draft: null }]; + } + + // phase === "taper" + const ts = s as ExTaper; + if (input.kind === "number") { + const t = Math.min(1, Math.max(0, input.value)); + lastTaper = t; + return commitTaper(ts.profile, ts.height, t, ts.sourceId, ctx); + } + return [ts, { draft: null }]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as ExState; + if (s.phase === "height") { + const ns: ExHeight = { ...s, cursor: point }; + return [ + ns, + { + draft: { + preview: [{ kind: "poly", pts: s.profile.pts, closed: true }], + vertices: [], + hud: { at: point, text: `${lastHeight.toFixed(2)} m` }, + }, + }, + ]; + } + if (s.phase === "taper") { + const ns: ExTaper = { ...s, cursor: point }; + return [ + ns, + { + draft: { + preview: [{ kind: "poly", pts: s.profile.pts, closed: true }], + vertices: [], + hud: { at: point, text: lastTaper.toFixed(2) }, + }, + }, + ]; + } + return [s, { draft: null }]; + }, + + // Enter/Rechtsklick: Höhen-Schritt → Default-Höhe übernehmen + Verjüngungs- + // Schritt betreten; Verjüngungs-Schritt → Default-Verjüngung übernehmen + committen. + onConfirm: (state, ctx): [CommandState, CommandResult] => { + const s = state as ExState; + if (s.phase === "height") return enterTaperPhase(s.profile, lastHeight, s.sourceId); + if (s.phase === "taper") return commitTaper(s.profile, s.height, lastTaper, s.sourceId, ctx); + return finish(); + }, + onCancel: (): [CommandState, CommandResult] => finish(), +}; diff --git a/src/commands/cmds/measure.ts b/src/commands/cmds/measure.ts new file mode 100644 index 0000000..8a2240b --- /dev/null +++ b/src/commands/cmds/measure.ts @@ -0,0 +1,74 @@ +// Messen — schnelles, unverbindliches Distanz-/Winkel-Messwerkzeug (kein Element +// wird erzeugt). Schritte: +// 1) „Messen von:" → Punkt (Startpunkt) +// 2) „bis:" → Live-Vorschau (Messlinie + Distanz·Winkel im HUD); Klick +// setzt einen neuen Startpunkt (fortlaufend messen). +// Esc/Enter beendet. Rein visuell: `commit` bleibt immer leer, das Modell ändert +// sich nie — nur der Entwurf (Linie + HUD) zeigt das Mass. + +import type { + Command, + CommandResult, + CommandState, + ToolDraft, + Vec2, +} from "../types"; + +const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); +const segAngleDeg = (a: Vec2, b: Vec2): number => + (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; + +interface MeasureStart extends CommandState { + phase: "start"; +} +interface MeasureTo extends CommandState { + phase: "to"; + from: Vec2; + cursor: Vec2 | null; +} +type MeasureState = MeasureStart | MeasureTo; + +/** Vorschau: Messlinie from→cursor + Distanz·Winkel im HUD am Cursor. */ +function measureDraft(from: Vec2, cursor: Vec2): ToolDraft { + const dist = segLen(from, cursor); + const ang = ((segAngleDeg(from, cursor) % 360) + 360) % 360; + return { + preview: [{ kind: "line", a: from, b: cursor }], + vertices: [from, cursor], + hud: { at: cursor, text: `${dist.toFixed(3)} m · ${ang.toFixed(1)}°` }, + }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "start", lastPoint: null } as MeasureStart, + { draft: null, done: true }, +]; + +export const measureCommand: Command = { + name: "measure", + labelKey: "cmd.measure.label", + prompt: (s) => ((s as MeasureState).phase === "to" ? "cmd.measure.to" : "cmd.measure.start"), + accepts: () => ["point"], + options: () => [], + init: (): MeasureStart => ({ phase: "start", lastPoint: null }), + + onInput: (state, input): [CommandState, CommandResult] => { + const s = state as MeasureState; + if (input.kind !== "point") { + return [s, { draft: s.phase === "to" && s.cursor ? measureDraft(s.from, s.cursor) : null }]; + } + // Jeder Klick setzt den (neuen) Startpunkt — fortlaufendes Messen, bis Esc/Enter. + const ns: MeasureTo = { phase: "to", from: input.point, cursor: input.point, lastPoint: input.point }; + return [ns, { draft: measureDraft(input.point, input.point) }]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as MeasureState; + if (s.phase !== "to") return [s, { draft: null }]; + const ns: MeasureTo = { ...s, cursor: point }; + return [ns, { draft: measureDraft(s.from, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/cmds/mirror.ts b/src/commands/cmds/mirror.ts index 1f24f00..31af7ba 100644 --- a/src/commands/cmds/mirror.ts +++ b/src/commands/cmds/mirror.ts @@ -32,12 +32,22 @@ const EPS = 1e-6; /** Hat die Auswahl überhaupt etwas Spiegelbares? */ function hasSelection(sel: CommandSelection): boolean { - return sel.wallIds.length > 0 || sel.drawingId !== null; + return ( + sel.wallIds.length > 0 || + sel.drawingId !== null || + !!sel.extrudedSolidId || + !!sel.columnId + ); } /** Auswahl → TransformSelection (gleiche Form). */ function toTransformSel(sel: CommandSelection): TransformSelection { - return { wallIds: sel.wallIds, drawingId: sel.drawingId }; + return { + wallIds: sel.wallIds, + drawingId: sel.drawingId, + extrudedSolidId: sel.extrudedSolidId, + columnId: sel.columnId, + }; } // Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot). diff --git a/src/commands/cmds/move.ts b/src/commands/cmds/move.ts index dc198e0..f955af8 100644 --- a/src/commands/cmds/move.ts +++ b/src/commands/cmds/move.ts @@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record, cursor: Vec /** Hat die Auswahl überhaupt etwas Verschiebbares? */ function hasSelection(sel: CommandSelection): boolean { - return sel.wallIds.length > 0 || sel.drawingId !== null; + return ( + sel.wallIds.length > 0 || + sel.drawingId !== null || + !!sel.extrudedSolidId || + !!sel.columnId + ); } /** Auswahl → TransformSelection (gleiche Form). */ function toTransformSel(sel: CommandSelection): TransformSelection { - return { wallIds: sel.wallIds, drawingId: sel.drawingId }; + return { + wallIds: sel.wallIds, + drawingId: sel.drawingId, + extrudedSolidId: sel.extrudedSolidId, + columnId: sel.columnId, + }; } // Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start). diff --git a/src/commands/cmds/text.ts b/src/commands/cmds/text.ts new file mode 100644 index 0000000..69c399b --- /dev/null +++ b/src/commands/cmds/text.ts @@ -0,0 +1,76 @@ +// Text — modellverankerter Einzeltext als 2D-Element. Schritte: +// 1) „Ankerpunkt:" → Punkt (Position im Modell) +// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"} +// +// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die +// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der +// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import. + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + Project, + Vec2, +} from "../types"; + +/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */ +const DEFAULT_TEXT_HEIGHT_M = 0.25; + +interface TextIdle extends CommandState { + phase: "point"; +} +interface TextLabel extends CommandState { + phase: "label"; + at: Vec2; +} +type TextState = TextIdle | TextLabel; + +function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project { + const label = text.trim(); + if (label === "") return p; + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "point", lastPoint: null }, + { draft: null, done: true }, +]; + +export const textCommand: Command = { + name: "text", + labelKey: "cmd.text.label", + prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"), + accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]), + options: () => [], + init: (): TextIdle => ({ phase: "point", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as TextState; + if (s.phase !== "label") { + if (input.kind !== "point") return [s, { draft: null }]; + const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point }; + return [ns, { draft: null }]; + } + if (input.kind !== "text") return [s, { draft: null }]; + const at = s.at; + return [ + { phase: "point", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) }, + ]; + }, + + onMove: (state): [CommandState, CommandResult] => [state, { draft: null }], + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/cmds/textbox.ts b/src/commands/cmds/textbox.ts new file mode 100644 index 0000000..934a68c --- /dev/null +++ b/src/commands/cmds/textbox.ts @@ -0,0 +1,127 @@ +// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite: +// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte: +// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte) +// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite; +// Live-Vorschau der Breitenlinie) +// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width} +// +// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion, +// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu. + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + Project, + ToolDraft, + Vec2, +} from "../types"; + +/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */ +const DEFAULT_TEXT_HEIGHT_M = 0.25; +/** Kleinste sinnvolle Spaltenbreite (Meter). */ +const MIN_WIDTH_M = 0.2; + +interface TbPoint extends CommandState { + phase: "point"; +} +interface TbWidth extends CommandState { + phase: "width"; + at: Vec2; + cursor: Vec2 | null; +} +interface TbLabel extends CommandState { + phase: "label"; + at: Vec2; + width: number; +} +type TbState = TbPoint | TbWidth | TbLabel; + +/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */ +function widthOf(at: Vec2, cursor: Vec2): number { + return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x)); +} + +/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */ +function widthDraft(at: Vec2, cursor: Vec2): ToolDraft { + const w = widthOf(at, cursor); + const b: Vec2 = { x: at.x + w, y: at.y }; + return { + preview: [{ kind: "line", a: at, b }], + vertices: [at, b], + hud: { at: b, text: `${w.toFixed(2)} m` }, + }; +} + +function appendTextbox( + p: Project, + at: Vec2, + width: number, + text: string, + ctx: CommandContext, +): Project { + const label = text.trim(); + if (label === "") return p; + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "point", lastPoint: null } as TbPoint, + { draft: null, done: true }, +]; + +export const textboxCommand: Command = { + name: "textbox", + labelKey: "cmd.textbox.label", + prompt: (s) => { + const ts = s as TbState; + if (ts.phase === "label") return "cmd.textbox.enter"; + if (ts.phase === "width") return "cmd.textbox.width"; + return "cmd.textbox.point"; + }, + accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]), + options: () => [], + init: (): TbPoint => ({ phase: "point", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as TbState; + if (s.phase === "point") { + if (input.kind !== "point") return [s, { draft: null }]; + const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point }; + return [ns, { draft: widthDraft(input.point, input.point) }]; + } + if (s.phase === "width") { + if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }]; + const width = widthOf(s.at, input.point); + const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point }; + return [ns, { draft: null }]; + } + // label + if (input.kind !== "text") return [s, { draft: null }]; + const { at, width } = s; + return [ + { phase: "point", lastPoint: null } as TbPoint, + { draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as TbState; + if (s.phase !== "width") return [s, { draft: null }]; + const ns: TbWidth = { ...s, cursor: point }; + return [ns, { draft: widthDraft(s.at, point) }]; + }, + + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/engine.ts b/src/commands/engine.ts index a74a340..b24633f 100644 --- a/src/commands/engine.ts +++ b/src/commands/engine.ts @@ -328,6 +328,14 @@ export class CommandEngine { } } + // Freitext-Schritt (z. B. Text-Platzierung): der Schritt nimmt beliebigen + // Text als Label — die komplette getippte Zeile geht 1:1 durch (auch Zahlen/ + // Kommas), bevor die numerische/Koordinaten-Auflösung greift. + if (accepts.includes("text")) { + this.feed({ kind: "text", text }); + return; + } + const parsed = parseInput(text); // Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als diff --git a/src/commands/registry.ts b/src/commands/registry.ts index b3cebdf..1cfbc41 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -10,18 +10,23 @@ import { wallCommand } from "./cmds/wall"; import { ceilingCommand } from "./cmds/ceiling"; import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening"; import { stairCommand } from "./cmds/stair"; +import { columnCommand } from "./cmds/column"; import { roomCommand } from "./cmds/room"; import { rectCommand } from "./cmds/rect"; import { circleCommand } from "./cmds/circle"; import { arcCommand } from "./cmds/arc"; +import { textCommand } from "./cmds/text"; +import { textboxCommand } from "./cmds/textbox"; import { moveCommand } from "./cmds/move"; import { mirrorCommand } from "./cmds/mirror"; import { joinCommand } from "./cmds/join"; import { copyCommand } from "./cmds/copy"; import { offsetCommand } from "./cmds/offset"; +import { extrudeCommand } from "./cmds/extrude"; import { trimCommand } from "./cmds/trim"; import { importCommand } from "./cmds/import"; import { terrainCommand } from "./cmds/terrain"; +import { measureCommand } from "./cmds/measure"; /** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */ export const COMMANDS: Record = { @@ -31,20 +36,25 @@ export const COMMANDS: Record = { fenster: fensterCommand, tuer: tuerCommand, stair: stairCommand, + column: columnCommand, room: roomCommand, line: lineCommand, polyline: polylineCommand, rect: rectCommand, circle: circleCommand, arc: arcCommand, + text: textCommand, + textbox: textboxCommand, move: moveCommand, mirror: mirrorCommand, join: joinCommand, copy: copyCommand, offset: offsetCommand, + extrude: extrudeCommand, trim: trimCommand, import: importCommand, terrain: terrainCommand, + measure: measureCommand, }; /** @@ -63,6 +73,8 @@ export const ALIASES: Record = { oe: "opening", treppe: "stair", tp: "stair", + stuetze: "column", + stütze: "column", raum: "room", rm: "room", l: "line", @@ -71,6 +83,9 @@ export const ALIASES: Record = { c: "circle", a: "arc", bogen: "arc", + tx: "text", + txt: "text", + beschriftung: "text", m: "move", s: "mirror", spiegeln: "mirror", @@ -78,6 +93,7 @@ export const ALIASES: Record = { verbinden: "join", cp: "copy", o: "offset", + ex: "extrude", tr: "trim", imp: "import", ter: "terrain", diff --git a/src/commands/types.ts b/src/commands/types.ts index c68ee5c..d541eb1 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -19,7 +19,7 @@ export type { DraftShape, SnapResult, ToolDraft } from "../tools/types"; // ── Eingabe-Arten ──────────────────────────────────────────────────────────── /** Arten von Eingaben, die ein Schritt annehmen kann. */ -export type AcceptKind = "point" | "number" | "option" | "selection"; +export type AcceptKind = "point" | "number" | "option" | "text" | "selection"; /** * Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der @@ -67,6 +67,10 @@ export interface CommandSelection { * `drawingId` (falls gesetzt) als einziges Element. */ drawingIds?: string[]; + /** Gewählter extrudierter Körper (truck-Integration), falls einer selektiert ist. */ + extrudedSolidId?: string | null; + /** Gewählte Stütze (Tragwerk), falls eine selektiert ist. */ + columnId?: string | null; } /** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */ diff --git a/src/engine/truckSolid.ts b/src/engine/truckSolid.ts new file mode 100644 index 0000000..6e9b9bd --- /dev/null +++ b/src/engine/truckSolid.ts @@ -0,0 +1,70 @@ +let modulePromise: Promise | null = null; + +async function getModule(): Promise { + if (!modulePromise) { + modulePromise = import("./pkgTruck/trucksolid.js").then(async (m: any) => { + await m.default(); + return m; + }); + } + return modulePromise; +} + +export interface ExtrudedMesh { + positions: number[]; + indices: number[]; +} + +/** `taper` 0 (Prisma, Default) … 1 (Spitze/Kegel-Pyramide) — linear zum + * Profil-Schwerpunkt skaliert. */ +export async function extrudePolygon( + points: number[], + height: number, + taper = 0, +): Promise { + const m = await getModule(); + const json = m.extrude_polygon(JSON.stringify({ points, height, taper })); + return JSON.parse(json) as ExtrudedMesh; +} + +export async function extrudeCircle( + cx: number, + cy: number, + r: number, + height: number, + taper = 0, +): Promise { + const m = await getModule(); + const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height, taper })); + return JSON.parse(json) as ExtrudedMesh; +} + +export type BooleanOp = "union" | "difference" | "intersection"; + +export interface BooleanMeshResult { + positions: number[]; + indices: number[]; + empty: boolean; +} + +/** Boolesche Operation zweier Dreiecks-Meshes (Mesh-Ebenen-CSG via csgrs, + * truck-Integration Phase 4). Beide Meshes müssen konsistent nach außen + * gewundene Dreiecke haben (Rechte-Hand-Regel) — sonst liefert csgrs ein + * falsches Ergebnis, ohne das zu erkennen. */ +export async function booleanMesh( + a: ExtrudedMesh, + b: ExtrudedMesh, + op: BooleanOp, +): Promise { + const m = await getModule(); + const json = m.boolean_mesh( + JSON.stringify({ + a_positions: a.positions, + a_indices: a.indices, + b_positions: b.positions, + b_indices: b.indices, + op, + }), + ); + return JSON.parse(json) as BooleanMeshResult; +} diff --git a/src/export/exportDxf.ts b/src/export/exportDxf.ts index 8e5cfc1..bf97833 100644 --- a/src/export/exportDxf.ts +++ b/src/export/exportDxf.ts @@ -28,6 +28,10 @@ const LAYER_HATCH = "HATCH"; const LAYER_SYMBOLS = "SYMBOLS"; const LAYER_CONTEXT = "CONTEXT"; const LAYER_DEFAULT = "PLAN"; +/** Sammel-Layer für Extrusions-Footprints (truck-Integration) — die Quell- + * Drawing2D wird beim Extrudieren entfernt, es gibt also keine categoryCode + * mehr nachzuschlagen; eigener Layer statt Absturz in LAYER_DEFAULT. */ +const LAYER_EXTRUSION = "EXTRUSION"; /** * Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space). @@ -60,6 +64,8 @@ export function buildPlanDxf( dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 }); dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 }); dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 }); + // Warmes Orange, identisch zum 3D-/Grundriss-Footprint der Extrusionen (EXTRUSION_STROKE/EXTRUSION_RGB). + dxf.addLayer({ name: LAYER_EXTRUSION, color: aciFromHex("#d98c40"), trueColor: rgbFromHex("#d98c40"), lineWeight: 13 }); // 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im // Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing- @@ -68,11 +74,15 @@ export function buildPlanDxf( for (const w of project.walls) codeByWall.set(w.id, w.categoryCode); const codeByDrawing = new Map(); for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode); + const codeByColumn = new Map(); + for (const c of project.columns ?? []) codeByColumn.set(c.id, c.categoryCode); const layerFor = (p: Primitive): string => { if (p.kind === "polygon") { if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT; if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT; + if (p.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT; + if (p.extrudedSolidId) return LAYER_EXTRUSION; return LAYER_DEFAULT; } if (p.kind === "line") { diff --git a/src/export/exportIfc.test.ts b/src/export/exportIfc.test.ts new file mode 100644 index 0000000..f444bf7 --- /dev/null +++ b/src/export/exportIfc.test.ts @@ -0,0 +1,327 @@ +// Unit-Tests für den IFC4-Export (exportIfcSpf) — erste vollständige Scheibe. +// • Strukturelle Validität: KEINE dangling references (jede #N-Referenz ist +// definiert), KEINE doppelten Entity-IDs — der wichtigste Test. +// • Header/FILE_SCHEMA('IFC4') vorhanden. +// • Je "floor"-Geschoss genau ein IfcBuildingStorey. +// • Wandanzahl → IfcWall-Anzahl. +// • Eine Öffnung ⇒ IfcOpeningElement + IfcRelVoidsElement (+ IfcDoor/ +// IfcWindow + IfcRelFillsElement). +// • GUID-Format (22 Zeichen, gültiger Zeichensatz), deterministisch. +// • Leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash). +// +// Fixture-Muster gespiegelt von exportSchedule.test.ts. + +import { describe, it, expect } from "vitest"; +import { exportIfcSpf, ifcGuid } from "./exportIfc"; +import type { + Ceiling, + ExtrudedSolid, + Opening, + Project, + Stair, + Wall, +} from "../model/types"; + +/** + * Minimalprojekt: 2 Geschosse (EG + OG, EG auch ein "section"-Level, das + * NICHT zu einem Storey werden darf) + 2 Wände (Wandtyp T=0.4) + 1 Decke + * (Deckentyp T=0.2) + 1 Tür + 1 Fenster (an W1 gehostet) + 1 Treppe + + * 1 Extrusion. + */ +function fixtureProject(): Project { + const walls: Wall[] = [ + { + id: "W1", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 0, y: 0 }, + end: { x: 5, y: 0 }, + wallTypeId: "aw", + height: 2.6, + }, + { + id: "W2", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 5, y: 0 }, + end: { x: 5, y: 4 }, + wallTypeId: "aw", + height: 2.6, + }, + ]; + const ceilings: Ceiling[] = [ + { + id: "D1", + type: "ceiling", + floorId: "eg", + categoryCode: "30", + outline: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 5, y: 4 }, + { x: 0, y: 4 }, + ], + wallTypeId: "dt", + ceilingTypeId: "dt", + }, + ]; + const openings: Opening[] = [ + { + id: "T1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind: "door", + position: 1, + width: 0.9, + height: 2.1, + sillHeight: 0, + }, + { + id: "F1", + type: "opening", + hostWallId: "W2", + categoryCode: "21", + kind: "window", + position: 1, + width: 1.2, + height: 1.5, + sillHeight: 0.9, + }, + ]; + const stairs: Stair[] = [ + { + id: "S1", + type: "stair", + floorId: "eg", + categoryCode: "40", + shape: "straight", + start: { x: 0, y: 0 }, + dir: { x: 1, y: 0 }, + runLength: 3, + width: 1.2, + totalRise: 2.6, + stepCount: 16, + }, + ]; + const extrudedSolids: ExtrudedSolid[] = [ + { + id: "E1", + type: "extrudedSolid", + levelId: "eg", + points: [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], + height: 2.5, + }, + ]; + return { + id: "t", + name: "Testprojekt", + lineStyles: [], + hatches: [], + components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }], + ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + { id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 2.6 }, + { id: "schnitt-a", name: "Schnitt A", kind: "section", visible: true, locked: false, linePoints: [{ x: 0, y: 0 }, { x: 1, y: 0 }], directionSign: 1 }, + ], + layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls, + doors: [], + openings, + ceilings, + stairs, + extrudedSolids, + rooms: [], + drawings2d: [], + context: [], + } as Project; +} + +/** Sammelt alle definierten Entity-IDs (`#N=`) und alle referenzierten `#N`. */ +function collectIds(spf: string): { defined: Set; referenced: Set; duplicates: number[] } { + const defined = new Set(); + const duplicates: number[] = []; + const referenced = new Set(); + for (const line of spf.split("\n")) { + const defMatch = /^#(\d+)=/.exec(line); + if (defMatch) { + const id = Number(defMatch[1]); + if (defined.has(id)) duplicates.push(id); + defined.add(id); + } + const refs = line.matchAll(/#(\d+)/g); + for (const r of refs) { + // Der erste Treffer je Zeile ist ggf. die Definition selbst — trotzdem + // harmlos mitgezählt, da sie ja in `defined` steht (Selbstreferenz-Check + // unten prüft nur: JEDE referenzierte ID muss iRGENDWO definiert sein). + referenced.add(Number(r[1])); + } + } + return { defined, referenced, duplicates }; +} + +describe("exportIfcSpf — IFC4-Export", () => { + it("erzeugt keine dangling references und keine doppelten Entity-IDs", () => { + const spf = exportIfcSpf(fixtureProject()); + const { defined, referenced, duplicates } = collectIds(spf); + expect(duplicates).toEqual([]); + const dangling = [...referenced].filter((id) => !defined.has(id)); + expect(dangling).toEqual([]); + expect(defined.size).toBeGreaterThan(0); + }); + + it("trägt einen gültigen IFC4-Header", () => { + const spf = exportIfcSpf(fixtureProject()); + expect(spf).toContain("ISO-10303-21;"); + expect(spf).toContain("FILE_SCHEMA(('IFC4'));"); + expect(spf).toContain("END-ISO-10303-21;"); + }); + + it("erzeugt je 'floor'-Geschoss genau ein IfcBuildingStorey (Schnitte NICHT)", () => { + const spf = exportIfcSpf(fixtureProject()); + const storeyLines = spf.split("\n").filter((l) => l.includes("=IFCBUILDINGSTOREY(")); + expect(storeyLines).toHaveLength(2); // EG + OG, NICHT "Schnitt A" + expect(storeyLines.some((l) => l.includes("'EG'"))).toBe(true); + expect(storeyLines.some((l) => l.includes("'OG'"))).toBe(true); + }); + + it("bildet jede Wand auf genau ein IfcWall ab", () => { + const spf = exportIfcSpf(fixtureProject()); + const wallLines = spf.split("\n").filter((l) => l.includes("=IFCWALL(")); + expect(wallLines).toHaveLength(2); + }); + + it("Tür/Fenster bleiben eigene Objekte (IfcDoor/IfcWindow); das Loch steckt im Wand-Mesh (kein IfcOpeningElement/Void/Fill)", () => { + const spf = exportIfcSpf(fixtureProject()); + const lines = spf.split("\n"); + // Tür + Fenster als eigene Objekte erhalten. + expect(lines.filter((l) => l.includes("=IFCDOOR("))).toHaveLength(1); + expect(lines.filter((l) => l.includes("=IFCWINDOW("))).toHaveLength(1); + // Bewusste Abwägung: Wand ist jetzt ein Face-Set mit ausgeschnittenem Loch — + // die frühere IfcOpeningElement-Void/Fill-Semantik entfällt (siehe Dateikopf). + expect(lines.filter((l) => l.includes("=IFCOPENINGELEMENT("))).toHaveLength(0); + expect(lines.filter((l) => l.includes("=IFCRELVOIDSELEMENT("))).toHaveLength(0); + expect(lines.filter((l) => l.includes("=IFCRELFILLSELEMENT("))).toHaveLength(0); + }); + + it("bildet Wände als IfcTriangulatedFaceSet (IfcCartesianPointList3D + CoordIndex) statt Profil-Extrusion ab", () => { + const spf = exportIfcSpf(fixtureProject()); + const lines = spf.split("\n"); + // Genau ein Face-Set + eine Punktliste je Wand (2 Wände). + expect(lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("))).toHaveLength(2); + expect(lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("))).toHaveLength(2); + // Wand-Shape ist als Tessellation deklariert (nicht mehr SweptSolid). + expect(spf).toContain("'Tessellation'"); + // Face-Set-CoordIndex referenziert 1-basierte Punkt-Indizes (Tripel-Listen). + const fs = lines.find((l) => l.includes("=IFCTRIANGULATEDFACESET(")); + expect(fs).toMatch(/\(\(\d+,\d+,\d+\)/); + }); + + it("bildet Decke, Treppe und Extrusion auf die erwarteten Entity-Typen ab", () => { + const spf = exportIfcSpf(fixtureProject()); + const lines = spf.split("\n"); + expect(lines.filter((l) => l.includes("=IFCSLAB("))).toHaveLength(1); + expect(lines.filter((l) => l.includes("=IFCSTAIR("))).toHaveLength(1); + expect(lines.filter((l) => l.includes("=IFCBUILDINGELEMENTPROXY("))).toHaveLength(1); + }); + + it("das Wand-Face-Set enthält das ausgeschnittene Fenster-Loch (Loch-Vertices + volle Dreieckszahl, keine dangling refs)", () => { + // W2 hat das Fenster F1 (position 1, width 1.2, sill 0.9, height 1.5) → das + // Loch liegt voll im Wand-Inneren; das Face-Set der Wand hat exakt die + // Rust-Zerlegung: 8 Langseiten-Teilrechtecke ×2×2 + 8 (Deckel/Boden/Kappen) + // + 8 (4 Laibungen) = 48 Dreiecke, 144 Punkte. + const spf = exportIfcSpf(fixtureProject()); + const lines = spf.split("\n"); + const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET(")); + // Das Loch-Face-Set hat 48 CoordIndex-Tripel. + const triCounts = faceSets.map((l) => (l.match(/\(\d+,\d+,\d+\)/g) ?? []).length); + expect(triCounts).toContain(48); + // Zugehörige Punktliste hat 144 Punkte (3er-Koordinaten-Tupel). + const pointLists = lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D(")); + const ptCounts = pointLists.map((l) => (l.match(/\([^()]*,[^()]*,[^()]*\)/g) ?? []).length); + expect(ptCounts).toContain(144); + // Keine dangling refs (der Kern-Invarianten-Check gilt auch mit Face-Sets). + const { defined, referenced } = collectIds(spf); + expect([...referenced].filter((id) => !defined.has(id))).toEqual([]); + }); + + it("Wand-Face-Sets sind NACH AUSSEN orientiert (positives Volumen) — Regression gegen die Reflexions-Wicklung, die die Wand hohl machte", () => { + // Der IFC-Achsen-Swap (x,y,z)→(x,z,y) ist eine Reflexion und kehrte die + // Dreiecks-Wicklung um → Normalen zeigten nach INNEN → Viewer cullten die + // Vorderseiten → Wand wirkte oben/unten offen. Nach dem Wicklungs-Ausgleich + // muss das signierte Volumen jedes Wandkörpers POSITIV sein (aussen orientiert). + const spf = exportIfcSpf(fixtureProject()); + const lines = spf.split("\n"); + const byId = new Map(); + for (const l of lines) { + const m = /^#(\d+)=/.exec(l); + if (m) byId.set(Number(m[1]), l); + } + const parsePoints = (line: string): number[][] => + [...line.matchAll(/\(([-\d.]+),([-\d.]+),([-\d.]+)\)/g)].map((m) => [ + Number(m[1]), + Number(m[2]), + Number(m[3]), + ]); + const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET(")); + expect(faceSets.length).toBeGreaterThan(0); + let closedCount = 0; + for (const fs of faceSets) { + const ptListId = Number(/=IFCTRIANGULATEDFACESET\(#(\d+),/.exec(fs)![1]); + const closed = /=IFCTRIANGULATEDFACESET\(#\d+,\$,([^,]+),/.exec(fs)![1]; + if (closed === ".T.") closedCount++; + const pts = parsePoints(byId.get(ptListId)!); + const tris = [...fs.matchAll(/\((\d+),(\d+),(\d+)\)/g)].map((m) => [ + Number(m[1]) - 1, + Number(m[2]) - 1, + Number(m[3]) - 1, + ]); + // 6× signiertes Volumen Σ v0·(v1×v2): > 0 ⇒ Normalen zeigen nach aussen. + let vol6 = 0; + for (const [a, b, c] of tris) { + const [ax, ay, az] = pts[a]; + const [bx, by, bz] = pts[b]; + const [cx, cy, cz] = pts[c]; + vol6 += ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx); + } + expect(vol6).toBeGreaterThan(0); + } + // Beide Wände sind geschlossene Prisma-Körper (Fenster = Durchgangsloch, + // Tür = umlaufende П-Kerbe) → Closed=.T. bei beiden. + expect(closedCount).toBe(2); + }); + + it("GUIDs sind 22 Zeichen lang, nutzen den gültigen IFC-Zeichensatz und sind deterministisch", () => { + const guid = ifcGuid("W1"); + expect(guid).toHaveLength(22); + expect(guid).toMatch(/^[0-9A-Za-z_$]{22}$/); + expect(ifcGuid("W1")).toBe(guid); // stabil über Re-Export + expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs + }); + + it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => { + const proj = fixtureProject(); + proj.walls = []; + proj.ceilings = []; + proj.openings = []; + proj.stairs = []; + proj.extrudedSolids = []; + proj.drawingLevels = []; + const spf = exportIfcSpf(proj); + expect(spf).toContain("=IFCPROJECT("); + expect(spf).toContain("=IFCSITE("); + expect(spf).toContain("=IFCBUILDING("); + const { defined, referenced, duplicates } = collectIds(spf); + expect(duplicates).toEqual([]); + expect([...referenced].filter((id) => !defined.has(id))).toEqual([]); + }); +}); diff --git a/src/export/exportIfc.ts b/src/export/exportIfc.ts new file mode 100644 index 0000000..c6fbc09 --- /dev/null +++ b/src/export/exportIfc.ts @@ -0,0 +1,687 @@ +// IFC4-Export (STEP Physical File / ISO-10303-21) des semantischen Modells. +// Reiner Rechen-/Serialisierungskern: keine UI, kein Datei-IO, kein WASM, keine +// neue Dependency — IFC wird direkt als Text geschrieben (analog exportDxf.ts/ +// exportSchedule.ts). Der Download (Blob+Anchor) passiert im App-Layer. +// +// Abbildung (erste vollständige Scheibe): +// Project → IfcProject → IfcSite → IfcBuilding → je "floor"-Geschoss ein +// IfcBuildingStorey (IfcRelAggregates-Kette). Bauteile hängen über +// IfcRelContainedInSpatialStructure am jeweiligen Geschoss (verwaiste +// Geschossreferenzen fallen defensiv auf IfcBuilding zurück). +// +// Decken/Treppen/Extrusionen als IfcExtrudedAreaSolid (unser Modell IST +// Extrusion): Profil = IfcArbitraryClosedProfileDef(IfcPolyline) in der +// XY-Ebene, extrudiert entlang +Z. Die horizontale Objekt-Platzierungskette +// (Site/Building/Storey/Element) trägt bewusst NUR die Z-Verschiebung +// (Geschoss-Elevation); die Profilpunkte tragen direkt die Welt-X/Y-Koordinaten. +// +// • Wand → IfcWall mit ÖFFNUNGSGENAUEM Dreiecks-Mesh (IfcTriangulatedFace +// Set, IFC4: IfcCartesianPointList3D + CoordIndex) statt einer Profil- +// Extrusion. Gespeist aus DEMSELBEN Loch-Ausschnitt-Mesh wie STL/OBJ +// (`pickGeometry` → `plan/wallMeshCut.ts`): Joins/Gehrungen UND ausgeschnittene +// Fenster/Türen (inkl. Laibungen) sind im Körper enthalten. ABWÄGUNG (bewusst, +// Nutzer-Priorität "so wie im 3D"): dadurch verliert die Wand die parametrische +// IfcWall-Profil-Extrusion + IfcOpeningElement-Void-Semantik zugunsten +// VISUELLER PARITÄT in JEDEM Viewer (der Loch schon im Mesh sieht, ohne eine +// Boolean-Subtraktion ausführen zu müssen — genau der Bug des Nutzers: "das +// Fenster ist als Objekt da im IFC, aber die Löcher sind nicht da"). +// • Decke → IfcSlab (outline-Polygon, PredefinedType FLOOR). +// • Öffnung → KEIN IfcOpeningElement/Void mehr (das Loch steckt im Wand-Mesh); +// Tür/Fenster bleiben als eigenes Objekt IfcDoor/IfcWindow mit eigener Box- +// Geometrie erhalten (füllt das ausgeschnittene Loch, "sieht aus wie 3D"). +// • Extrusion → IfcBuildingElementProxy aus points+height. +// • Treppe → IfcStair, GEOMETRISCH bewusst vereinfacht auf einen +// extrudierten Bounding-Footprint (Lauf-Rechteck bei "straight"; Achsen- +// ausgerichtete Bounding-Box der Kontrollpunkte bei "L"/"spiral") — die +// echte Stufengeometrie ist ausgelassen (siehe stairFootprint()). +// +// Material-Layer (IfcMaterialLayerSet/-Usage) sind NICHT enthalten — die +// korrekte Direction/Offset-Semantik von IfcMaterialLayerSetUsage ließ sich +// ohne Gegenprüfung an einem echten Viewer nicht mit ausreichender Sicherheit +// umsetzen; Geometrie/Hierarchie hatten Vorrang (siehe Bericht/PENDENZEN). +// +// GUIDs: deterministisch aus der Element-ID über einen 128-Bit-Hash (zwei +// FNV-1a-64-Läufe) + Standard-IFC-GUID-Kompression (Base64-Variante, +// Zeichensatz 0-9,A-Z,a-z,_,$) — stabil über Re-Exporte hinweg. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER. + +import type { + Opening, + Project, + Stair, + Vec2, + Wall, +} from "../model/types"; +import { + getCeilingType, + getWallType, + openingLabel, + wallTypeThickness, +} from "../model/types"; +import { + ceilingVerticalExtent, + stairVerticalExtent, + wallReferenceOffset, + wallVerticalExtent, +} from "../model/wall"; +import { pickGeometry } from "../plan/toWalls3d"; +import type { RWall } from "../plan/toWalls3d"; +import { isWatertight, wallCutMesh } from "../plan/wallMeshCut"; + +// ── IFC-GUID (Base64-Kompression, 22 Zeichen) ─────────────────────────────── +// Standard-Kompressionsalgorithmus (IfcOpenShell guid.compress): das erste +// Byte des 128-Bit-Werts wird auf 2 Zeichen abgebildet, die restlichen 15 +// Byte in 5 Dreiergruppen zu je 4 Zeichen — macht 2 + 5×4 = 22 Zeichen. + +const IFC_GUID_CHARS = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$"; + +/** Kodiert `v` big-endian in `len` IFC-GUID-Zeichen (Basis 64). */ +function ifcGuidB64(v: number, len: number): string { + let out = ""; + for (let i = len - 1; i >= 0; i--) { + out += IFC_GUID_CHARS[Math.floor(v / 64 ** i) % 64]; + } + return out; +} + +/** Komprimiert einen 128-Bit-Wert (32 Hex-Zeichen) zur 22-stelligen IFC-GUID. */ +function compressGuidHex(hex32: string): string { + const bytes: number[] = []; + for (let i = 0; i < 32; i += 2) bytes.push(parseInt(hex32.slice(i, i + 2), 16)); + let out = ifcGuidB64(bytes[0], 2); + for (let i = 1; i < 16; i += 3) { + const v = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2]; + out += ifcGuidB64(v, 4); + } + return out; +} + +/** FNV-1a-64 (BigInt) — reines Determinismus-/Streuungs-Werkzeug, keine Kryptografie. */ +function fnv1a64(str: string, seed: bigint): bigint { + const prime = 0x100000001b3n; + const mask = 0xffffffffffffffffn; + let hash = seed & mask; + for (let i = 0; i < str.length; i++) { + hash ^= BigInt(str.charCodeAt(i)); + hash = (hash * prime) & mask; + } + return hash; +} + +/** Leitet aus einer stabilen Element-ID einen deterministischen 128-Bit-Hex-Wert ab. */ +function idToHex32(id: string): string { + const h1 = fnv1a64(id, 0xcbf29ce484222325n); + const h2 = fnv1a64(`${id}salt`, 0x9e3779b97f4a7c15n); + return h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0"); +} + +/** Deterministische 22-stellige IFC-GUID aus einer beliebigen Element-ID. */ +export function ifcGuid(id: string): string { + return compressGuidHex(idToHex32(id)); +} + +// ── STEP-Formatierung ─────────────────────────────────────────────────────── + +/** STEP-String-Literal ('…', Apostroph verdoppelt, Backslash verdoppelt). */ +function S(s: string): string { + const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "''"); + return `'${escaped}'`; +} + +/** STEP-REAL-Literal — immer mit Dezimalpunkt, ohne unnötige Nachkommastellen. */ +function R(x: number): string { + const v = Object.is(x, -0) ? 0 : x; + let s = v.toFixed(6); + s = s.replace(/0+$/, ""); + if (s.endsWith(".")) s += "0"; + if (!s.includes(".")) s += ".0"; + return s; +} + +/** STEP-Enumerationswert `.WERT.`. */ +function ENUM(v: string): string { + return `.${v}.`; +} + +/** STEP-Liste `(a,b,c)`. */ +function LIST(items: string[]): string { + return `(${items.join(",")})`; +} + +// ── STEP-Writer ────────────────────────────────────────────────────────────── + +class StepWriter { + private lines: string[] = []; + private nextId = 1; + + /** Schreibt eine neue Entity-Zeile und liefert ihre `#id`. */ + add(type: string, params: string): number { + const id = this.nextId++; + this.lines.push(`#${id}=${type}(${params});`); + return id; + } + + get entityLines(): readonly string[] { + return this.lines; + } +} + +// ── Geometrie-Helfer (reines 2D-Vec2-Rechnen, Welt-Meter) ─────────────────── + +function sub(a: Vec2, b: Vec2): Vec2 { + return { x: a.x - b.x, y: a.y - b.y }; +} +function normalize(v: Vec2): Vec2 { + const len = Math.hypot(v.x, v.y) || 1; + return { x: v.x / len, y: v.y / len }; +} +function leftNormal(u: Vec2): Vec2 { + return { x: -u.y, y: u.x }; +} +function addScaled(p: Vec2, d: Vec2, s: number): Vec2 { + return { x: p.x + d.x * s, y: p.y + d.y * s }; +} + +/** Rechteck-Footprint einer Öffnung im Wandloch (volle Wanddicke tief), CCW. */ +function openingFootprint(project: Project, wall: Wall, opening: Opening): Vec2[] { + const u = normalize(sub(wall.end, wall.start)); + const n = leftNormal(u); + const t = wallTypeThickness(getWallType(project, wall)); + const off = wallReferenceOffset(wall, t); + const inner = -t / 2 + off; + const outer = t / 2 + off; + const a = addScaled(wall.start, u, opening.position); + const b = addScaled(wall.start, u, opening.position + opening.width); + return [ + addScaled(a, n, inner), + addScaled(b, n, inner), + addScaled(b, n, outer), + addScaled(a, n, outer), + ]; +} + +/** + * Vereinfachter Bounding-Footprint einer Treppe (bewusst NICHT die echte + * Stufen-/Podestkontur, siehe Dateikopf): + * • "straight" — echtes, ausgerichtetes Lauf-Rechteck (Länge × Breite). + * • "L"/"spiral" — achsenausgerichtete Bounding-Box der Kontrollpunkte + * (Start/Eckpunkt/Ende bzw. Wendel-Zentrum±Radius), um die halbe + * Laufbreite erweitert. + */ +function stairFootprint(stair: Stair): Vec2[] { + const halfW = Math.max(stair.width, 0) / 2; + if (stair.shape === "straight") { + const u = normalize(stair.dir); + const n = leftNormal(u); + const end = addScaled(stair.start, u, stair.runLength); + return [ + addScaled(stair.start, n, -halfW), + addScaled(end, n, -halfW), + addScaled(end, n, halfW), + addScaled(stair.start, n, halfW), + ]; + } + const pts: Vec2[] = [stair.start]; + const u = normalize(stair.dir); + const corner = addScaled(stair.start, u, stair.runLength); + pts.push(corner); + if (stair.shape === "L" && stair.run2Length && stair.turn) { + const n = leftNormal(u); + const turnDir: Vec2 = { x: n.x * stair.turn, y: n.y * stair.turn }; + pts.push(addScaled(corner, turnDir, stair.run2Length)); + } + if (stair.shape === "spiral" && stair.center) { + const r = (stair.radius ?? 0) + halfW; + const c = stair.center; + return [ + { x: c.x - r, y: c.y - r }, + { x: c.x + r, y: c.y - r }, + { x: c.x + r, y: c.y + r }, + { x: c.x - r, y: c.y + r }, + ]; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let 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); + } + minX -= halfW; + minY -= halfW; + maxX += halfW; + maxY += halfW; + return [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]; +} + +// ── IFC4-Export ────────────────────────────────────────────────────────────── + +interface StoreyRef { + entityId: number; + placementId: number; + baseElevation: number; +} + +interface Structure { + entityId: number; + placementId: number; + baseElevation: number; +} + +/** + * Baut aus einem Projekt einen vollständigen IFC4-SPF-String (STEP Physical + * File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal- + * IFC (Project/Site/Building, kein Crash). + */ +export function exportIfcSpf(project: Project): string { + const w = new StepWriter(); + + // Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts- + // Placement (Position aller ExtrudedAreaSolid — Profile tragen direkt + // Welt-X/Y, siehe Dateikopf). + const originPoint = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)])); + const extrudeDir = w.add("IFCDIRECTION", LIST([R(0), R(0), R(1)])); + const identityAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`); + + // Owner-History (minimal, aber vorhanden — manche Importer verlangen sie). + const org = w.add("IFCORGANIZATION", `$,${S("dossier")},$,$,$`); + const person = w.add( + "IFCPERSON", + `${S("dossier")},$,$,$,$,$,$,$`, + ); + const personOrg = w.add("IFCPERSONANDORGANIZATION", `#${person},#${org},$`); + const app = w.add( + "IFCAPPLICATION", + `#${org},${S("1.0")},${S("dossier")},${S("dossier")}`, + ); + const ownerHistory = w.add( + "IFCOWNERHISTORY", + `#${personOrg},#${app},$,${ENUM("ADDED")},$,$,$,${Math.floor(Date.now() / 1000)}`, + ); + + // Einheiten (Meter, Radiant, m², m³). + const lenUnit = w.add("IFCSIUNIT", `*,${ENUM("LENGTHUNIT")},$,${ENUM("METRE")}`); + const areaUnit = w.add("IFCSIUNIT", `*,${ENUM("AREAUNIT")},$,${ENUM("SQUARE_METRE")}`); + const volUnit = w.add("IFCSIUNIT", `*,${ENUM("VOLUMEUNIT")},$,${ENUM("CUBIC_METRE")}`); + const angleUnit = w.add( + "IFCSIUNIT", + `*,${ENUM("PLANEANGLEUNIT")},$,${ENUM("RADIAN")}`, + ); + const unitAssignment = w.add( + "IFCUNITASSIGNMENT", + LIST([`#${lenUnit}`, `#${areaUnit}`, `#${volUnit}`, `#${angleUnit}`]), + ); + + // Geometrischer Kontext (3D, Precision 1e-5). + const context = w.add( + "IFCGEOMETRICREPRESENTATIONCONTEXT", + `$,${S("Model")},3,${R(0.00001)},#${identityAxis},$`, + ); + + // Räumliche Hierarchie: Project → Site → Building → Storeys. + const siteAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`); + const sitePlacement = w.add("IFCLOCALPLACEMENT", `$,#${siteAxis}`); + const buildingAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`); + const buildingPlacement = w.add( + "IFCLOCALPLACEMENT", + `#${sitePlacement},#${buildingAxis}`, + ); + + const projectId = w.add( + "IFCPROJECT", + `${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`, + ); + const siteId = w.add( + "IFCSITE", + `${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,$,$,$`, + ); + const buildingId = w.add( + "IFCBUILDING", + `${S(ifcGuid(`${project.id}:building`))},#${ownerHistory},${S(project.name || "Gebäude")},$,$,#${buildingPlacement},$,$,${ENUM("ELEMENT")},$,$,$`, + ); + w.add( + "IFCRELAGGREGATES", + `${S(ifcGuid(`${project.id}:agg-site`))},#${ownerHistory},$,$,#${projectId},${LIST([`#${siteId}`])}`, + ); + w.add( + "IFCRELAGGREGATES", + `${S(ifcGuid(`${project.id}:agg-building`))},#${ownerHistory},$,$,#${siteId},${LIST([`#${buildingId}`])}`, + ); + + // Je "floor"-Geschoss ein IfcBuildingStorey (Elevation = baseElevation). + const floors = project.drawingLevels.filter((l) => l.kind === "floor"); + const storeyByFloorId = new Map(); + const storeyEntityIds: number[] = []; + for (const floor of floors) { + const base = floor.baseElevation ?? 0; + const pt = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(base)])); + const axis = w.add("IFCAXIS2PLACEMENT3D", `#${pt},$,$`); + const placementId = w.add("IFCLOCALPLACEMENT", `#${buildingPlacement},#${axis}`); + const entityId = w.add( + "IFCBUILDINGSTOREY", + `${S(ifcGuid(`${floor.id}:storey`))},#${ownerHistory},${S(floor.name)},$,$,#${placementId},$,$,${ENUM("ELEMENT")},${R(base)}`, + ); + storeyByFloorId.set(floor.id, { entityId, placementId, baseElevation: base }); + storeyEntityIds.push(entityId); + } + if (storeyEntityIds.length > 0) { + w.add( + "IFCRELAGGREGATES", + `${S(ifcGuid(`${project.id}:agg-storeys`))},#${ownerHistory},$,$,#${buildingId},${LIST(storeyEntityIds.map((id) => `#${id}`))}`, + ); + } + + /** Geschoss → Trägerstruktur (Storey), oder defensiv das Gebäude (verwaiste floorId). */ + const resolveStructure = (floorId: string): Structure => { + const s = storeyByFloorId.get(floorId); + if (s) return { entityId: s.entityId, placementId: s.placementId, baseElevation: s.baseElevation }; + return { entityId: buildingId, placementId: buildingPlacement, baseElevation: 0 }; + }; + + // Räumliche Eingliederung sammelt sich je Trägerstruktur (Storey/Building) + // und wird am Ende in EINE IfcRelContainedInSpatialStructure je Struktur + // gebündelt (Öffnungen NICHT — die hängen nur über RelVoidsElement an ihrer + // Wand, wie in IFC üblich). + const containment = new Map(); + const addToContainment = (structureId: number, elementId: number): void => { + const arr = containment.get(structureId); + if (arr) arr.push(elementId); + else containment.set(structureId, [elementId]); + }; + + /** Baut Profil+Extrusion+Shape+Placement für einen geschlossenen Footprint. */ + const emitBoxProduct = ( + footprint: Vec2[], + zBottomRel: number, + depth: number, + placementRelTo: number, + ): { placementId: number; shapeId: number } => { + const closed = [...footprint, footprint[0]]; + const ptIds = closed.map((p) => w.add("IFCCARTESIANPOINT", LIST([R(p.x), R(p.y)]))); + const polylineId = w.add("IFCPOLYLINE", LIST(ptIds.map((id) => `#${id}`))); + const profileId = w.add( + "IFCARBITRARYCLOSEDPROFILEDEF", + `${ENUM("AREA")},$,#${polylineId}`, + ); + const solidId = w.add( + "IFCEXTRUDEDAREASOLID", + `#${profileId},#${identityAxis},#${extrudeDir},${R(Math.max(depth, 0.001))}`, + ); + const shapeRepId = w.add( + "IFCSHAPEREPRESENTATION", + `#${context},${S("Body")},${S("SweptSolid")},${LIST([`#${solidId}`])}`, + ); + const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`); + const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(zBottomRel)])); + const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`); + const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`); + return { placementId, shapeId }; + }; + + /** + * Baut aus einem Dreiecks-Mesh (`positions` flach x,y,z in IFC-Koordinaten — + * bereits Z-up und relativ zur `placementRelTo`-Herkunft, `indices` je 3 = + * 1-basiert-1 CoordIndex-Tripel) ein IfcTriangulatedFaceSet + Shape + Placement. + * IFC4-Tessellierung: IfcCartesianPointList3D (CoordList) + IfcTriangulatedFace + * Set (CoordIndex, 1-basiert). Das Element-Placement sitzt im Ursprung der + * Trägerstruktur (die Punkte tragen die Geometrie bereits absolut in deren Frame). + */ + const emitTriangulatedProduct = ( + positions: number[], + indices: number[], + placementRelTo: number, + closed: boolean, + ): { placementId: number; shapeId: number } => { + const coords: string[] = []; + for (let i = 0; i < positions.length; i += 3) { + coords.push(`(${R(positions[i])},${R(positions[i + 1])},${R(positions[i + 2])})`); + } + const pointListId = w.add("IFCCARTESIANPOINTLIST3D", `(${coords.join(",")})`); + const tris: string[] = []; + for (let i = 0; i < indices.length; i += 3) { + tris.push(`(${indices[i] + 1},${indices[i + 1] + 1},${indices[i + 2] + 1})`); + } + // Closed=.T. NUR wenn das Mesh nachweislich ein dichtes, aussen orientiertes + // Volumen ist (siehe isWatertight): Wände sind extrudierte Querschnitts- + // Polygone (Fenster = Durchgangsloch, Tür = umlaufende П-Kerbe) → geschlossene + // Körper → `.T.` (Viewer rendern sie als Solid statt als offene Fläche). Der + // Guard fängt echte Defekte ab (invertierte Wicklung → Volumen < 0 → `$`). + // Normals=$ (Viewer leitet sie aus der — jetzt aussen orientierten — Wicklung ab). + const closedFlag = closed ? ".T." : "$"; + const faceSetId = w.add("IFCTRIANGULATEDFACESET", `#${pointListId},$,${closedFlag},(${tris.join(",")}),$`); + const shapeRepId = w.add( + "IFCSHAPEREPRESENTATION", + `#${context},${S("Body")},${S("Tessellation")},${LIST([`#${faceSetId}`])}`, + ); + const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`); + const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)])); + const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`); + const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`); + return { placementId, shapeId }; + }; + + /** Baut ein IfcBuildingElement-Subtyp mit dem üblichen 9-Attribut-Flatten. */ + const emitBuildingElement = ( + type: string, + guid: string, + name: string, + placementId: number, + shapeId: number, + predefinedType: string | null, + ): number => + w.add( + type, + `${S(guid)},#${ownerHistory},${S(name)},$,$,#${placementId},#${shapeId},$,${predefinedType ?? "$"}`, + ); + + // ── Wände (öffnungsgenaues Dreiecks-Mesh statt Profil-Extrusion) ──────── + // Die Wand-Schicht-Bänder kommen aus DEMSELBEN geflachten Modell wie STL/OBJ + // (`pickGeometry`, Joins/Gehrungen + Öffnungs-`holes` bereits aufgelöst). Alle + // Bänder einer Wand-Id werden zu EINEM Face-Set vereint. IFC-Koordinaten: das + // Mesh liegt in Welt (Modell-x, Höhe, Modell-y) mit Y-up → IFC (x, y, z=Höhe) + // mit Z-up, also (mx, mz, my); Z relativ zur Geschoss-UK, damit die Storey- + // Placement-Elevation nicht doppelt zählt. + // + // WICHTIG — WICKLUNG: der Achsen-Swap (x,y,z)→(x,z,y) ist eine REFLEXION + // (Determinante −1) und KEHRT die Dreiecks-Wicklung UM → aus aussen orientierten + // würden innen orientierte Normalen, der Viewer cullt dann die Vorderseiten und + // die Wand wirkt HOHL/offen (genau der gemeldete Bug). Deshalb wird beim Swap + // die Wicklung jedes Dreiecks umgedreht (i0,i2,i1), damit die Aussen-Normalen + // aussen bleiben. Watertightness (isWatertight) misst das anschliessend am + // fertigen IFC-Mesh → treibt das Closed-Flag des Face-Sets. + const bandsByWallId = new Map(); + for (const band of pickGeometry(project).walls) { + const list = bandsByWallId.get(band.wallId); + if (list) list.push(band); + else bandsByWallId.set(band.wallId, [band]); + } + const wallEntityIdByWallId = new Map(); + for (const wall of project.walls ?? []) { + const bands = bandsByWallId.get(wall.id); + if (!bands || bands.length === 0) continue; // degenerierte Wand / keine Geometrie + const structure = resolveStructure(wall.floorId); + const positions: number[] = []; + const indices: number[] = []; + for (const band of bands) { + const cut = wallCutMesh(band); + const base = positions.length / 3; + for (let i = 0; i < cut.positions.length; i += 3) { + positions.push( + cut.positions[i], + cut.positions[i + 2], + cut.positions[i + 1] - structure.baseElevation, + ); + } + // Wicklung umkehren (Reflexions-Ausgleich, s. o.): (a,b,c) → (a,c,b). + for (let i = 0; i < cut.indices.length; i += 3) { + indices.push(base + cut.indices[i], base + cut.indices[i + 2], base + cut.indices[i + 1]); + } + } + if (indices.length === 0) continue; + const closed = isWatertight({ positions, indices }); + const { placementId, shapeId } = emitTriangulatedProduct(positions, indices, structure.placementId, closed); + let name = wall.id; + try { + name = getWallType(project, wall).name; + } catch { + /* verwaister Wandtyp — Roh-ID als Name */ + } + const entityId = emitBuildingElement("IFCWALL", ifcGuid(wall.id), name, placementId, shapeId, null); + wallEntityIdByWallId.set(wall.id, entityId); + addToContainment(structure.entityId, entityId); + } + + // ── Decken ───────────────────────────────────────────────────────────── + for (const ceiling of project.ceilings ?? []) { + if (ceiling.outline.length < 3) continue; + const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling); + const structure = resolveStructure(ceiling.floorId); + const { placementId, shapeId } = emitBoxProduct( + ceiling.outline, + zBottom - structure.baseElevation, + zTop - zBottom, + structure.placementId, + ); + let name: string = ceiling.id; + try { + name = getCeilingType(project, ceiling).name; + } catch { + /* verwaister Deckentyp — Roh-ID als Name */ + } + const entityId = emitBuildingElement( + "IFCSLAB", + ifcGuid(ceiling.id), + name, + placementId, + shapeId, + ENUM("FLOOR"), + ); + addToContainment(structure.entityId, entityId); + } + + // ── Fenster/Türen als eigene Objekte (IfcDoor/IfcWindow) ──────────────── + // KEIN IfcOpeningElement/IfcRelVoidsElement/IfcRelFillsElement mehr: das Loch + // steckt bereits im Wand-Face-Set (s. o.). Eine Void-Relation beschriebe eine + // Boolean-Subtraktion gegen einen Swept-Solid, den es nicht mehr gibt — sie + // brächte in den Viewern nur Verwirrung (der Nutzer-Bug war genau, dass die + // Void nicht subtrahiert wurde). Tür/Fenster bleiben als EIGENES Objekt mit + // eigener Box-Geometrie erhalten, die das ausgeschnittene Loch füllt ("sieht + // aus wie 3D"): kein Blatt-/Rahmendetail (bewusste Vereinfachung). + for (const opening of project.openings ?? []) { + const wall = (project.walls ?? []).find((wl) => wl.id === opening.hostWallId); + if (!wall) continue; // verwaiste Wirtswand — keine Geometrie ableitbar + if (!wallEntityIdByWallId.has(wall.id)) continue; // Wirtswand übersprungen (degeneriert) + + const wallExtent = wallVerticalExtent(project, wall); + const structure = resolveStructure(wall.floorId); + const footprint = openingFootprint(project, wall, opening); + const zSillAbs = wallExtent.zBottom + opening.sillHeight; + + const { placementId: fillPlacement, shapeId: fillShape } = emitBoxProduct( + footprint, + zSillAbs - structure.baseElevation, + opening.height, + structure.placementId, + ); + const fillGuid = ifcGuid(`${opening.id}:fill`); + const fillName = openingLabel(opening); + const fillEntityId = + opening.kind === "door" + ? w.add( + "IFCDOOR", + `${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("DOOR")},$,$`, + ) + : w.add( + "IFCWINDOW", + `${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("WINDOW")},$,$`, + ); + addToContainment(structure.entityId, fillEntityId); + } + + // ── Treppen (vereinfachter Bounding-Footprint, siehe Dateikopf) ──────── + for (const stair of project.stairs ?? []) { + const footprint = stairFootprint(stair); + if (footprint.length < 3) continue; + const { zBottom, zTop } = stairVerticalExtent(project, stair); + const structure = resolveStructure(stair.floorId); + const { placementId, shapeId } = emitBoxProduct( + footprint, + zBottom - structure.baseElevation, + zTop - zBottom, + structure.placementId, + ); + const predefinedType = + stair.shape === "straight" + ? ENUM("STRAIGHT_RUN_STAIR") + : stair.shape === "spiral" + ? ENUM("SPIRAL_STAIR") + : ENUM("QUARTER_TURN_STAIR"); + const entityId = emitBuildingElement( + "IFCSTAIR", + ifcGuid(stair.id), + "Treppe", + placementId, + shapeId, + predefinedType, + ); + addToContainment(structure.entityId, entityId); + } + + // ── Extrudierte Körper (truck-Integration) → IfcBuildingElementProxy ─── + for (const solid of project.extrudedSolids ?? []) { + if (solid.points.length < 3) continue; + const floor = floors.find((f) => f.id === solid.levelId); + const base = floor?.baseElevation ?? 0; + const structure = resolveStructure(solid.levelId); + const { placementId, shapeId } = emitBoxProduct( + solid.points, + base - structure.baseElevation, + solid.height, + structure.placementId, + ); + const entityId = emitBuildingElement( + "IFCBUILDINGELEMENTPROXY", + ifcGuid(solid.id), + "Extrusion", + placementId, + shapeId, + null, + ); + addToContainment(structure.entityId, entityId); + } + + // ── Räumliche Eingliederung (gebündelt je Trägerstruktur) ────────────── + for (const [structureId, elementIds] of containment) { + w.add( + "IFCRELCONTAINEDINSPATIALSTRUCTURE", + `${S(ifcGuid(`contain:${structureId}`))},#${ownerHistory},$,$,${LIST(elementIds.map((id) => `#${id}`))},#${structureId}`, + ); + } + + // ── Kopf + Zusammenbau ─────────────────────────────────────────────────── + const iso = new Date().toISOString().replace(/\.\d+Z$/, ""); + const fileName = `${project.name || "modell"}.ifc`; + const header = [ + "ISO-10303-21;", + "HEADER;", + `FILE_DESCRIPTION(${LIST([S("")])},${S("2;1")});`, + `FILE_NAME(${S(fileName)},${S(iso)},${LIST([S("dossier")])},${LIST([S("dossier")])},${S("dossier")},${S("dossier")},${S("")});`, + "FILE_SCHEMA(('IFC4'));", + "ENDSEC;", + "", + "DATA;", + ]; + const footer = ["ENDSEC;", "END-ISO-10303-21;"]; + + return [...header, ...w.entityLines, ...footer].join("\n") + "\n"; +} diff --git a/src/export/exportMesh.test.ts b/src/export/exportMesh.test.ts new file mode 100644 index 0000000..4ba8b29 --- /dev/null +++ b/src/export/exportMesh.test.ts @@ -0,0 +1,306 @@ +// Unit-Tests für den STL-/OBJ-Mesh-Export (reines Modul, siehe exportMesh.ts). +// • OBJ: ≥1 v/f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich. +// • STL: solid/endsolid-Rahmen, Facettenzahl = Dreieckszahl, je Facette genau 3 vertex-Zeilen. +// • Dreieckszahl-Plausibilität: isolierte Wand-Box (12) und isoliertes N-Eck-Prisma (4N-4). +// • leeres Projekt ⇒ gültige leere Datei (kein Crash). + +import { describe, it, expect } from "vitest"; +import { exportObj, exportStl } from "./exportMesh"; +import type { Project, Wall, Ceiling, ExtrudedSolid, Opening } from "../model/types"; + +/** Gemeinsame Ressourcen-Basis (Komponente/Wandtyp/Deckentyp/Geschoss/Ebene). */ +function baseProject(): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }], + wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }], + ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", 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 }], + walls: [], + doors: [], + openings: [], + ceilings: [], + stairs: [], + extrudedSolids: [], + rooms: [], + drawings2d: [], + context: [], + } as Project; +} + +/** Eine einzelne, frei stehende Wand (kein Nachbar ⇒ kein Gehrungs-/Anschlussschnitt). */ +function projectWithOneWall(): Project { + const p = baseProject(); + const wall: Wall = { + id: "W1", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 0, y: 0 }, + end: { x: 5, y: 0 }, + wallTypeId: "aw", + height: 2.6, + }; + p.walls = [wall]; + return p; +} + +/** + * Eine frei stehende Wand mit EINER Öffnung (Fenster ODER Tür). Das Fenster + * (sillHeight>0) liegt vollständig im Wand-Inneren (vier Laibungen); die Tür + * (sillHeight 0) berührt die Wand-UK (keine untere Laibung). + */ +function projectWithOpening(kind: "window" | "door"): Project { + const p = projectWithOneWall(); + const opening: Opening = { + id: kind === "window" ? "F1" : "T1", + type: "opening", + hostWallId: "W1", + categoryCode: "21", + kind, + position: 2, + width: 1, + height: kind === "window" ? 1.5 : 2.1, + sillHeight: kind === "window" ? 0.9 : 0, + }; + p.openings = [opening]; + return p; +} + +/** Eine einzelne Extrusion mit N-Eck-Profil (kein Wand-/Deckenkontext). */ +function projectWithPolygonExtrusion(pts: { x: number; y: number }[], height = 2.5): Project { + const p = baseProject(); + const solid: ExtrudedSolid = { + id: "E1", + type: "extrudedSolid", + levelId: "eg", + points: pts, + height, + }; + p.extrudedSolids = [solid]; + return p; +} + +/** Kombiniertes Fixture-Projekt: 2 Wände (Eckstoss), 1 Decke, 1 Extrusion. */ +function fixtureProject(): Project { + const p = baseProject(); + const walls: Wall[] = [ + { + id: "W1", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 0, y: 0 }, + end: { x: 5, y: 0 }, + wallTypeId: "aw", + height: 2.6, + }, + { + id: "W2", + type: "wall", + floorId: "eg", + categoryCode: "20", + start: { x: 5, y: 0 }, + end: { x: 5, y: 4 }, + wallTypeId: "aw", + height: 2.6, + }, + ]; + const ceilings: Ceiling[] = [ + { + id: "D1", + type: "ceiling", + floorId: "eg", + categoryCode: "30", + outline: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 5, y: 4 }, + { x: 0, y: 4 }, + ], + wallTypeId: "dt", + ceilingTypeId: "dt", + }, + ]; + const extrudedSolids: ExtrudedSolid[] = [ + { + id: "E1", + type: "extrudedSolid", + levelId: "eg", + points: [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], + height: 2.5, + }, + ]; + p.walls = walls; + p.ceilings = ceilings; + p.extrudedSolids = extrudedSolids; + return p; +} + +/** Parst die `v`-Zeilen eines OBJ-Strings zu Koordinaten-Tripeln. */ +function parseObjVertices(obj: string): number[][] { + return obj + .split("\n") + .filter((l) => l.startsWith("v ")) + .map((l) => l.slice(2).trim().split(/\s+/).map(Number)); +} + +/** Parst die `f`-Zeilen eines OBJ-Strings zu 1-basierten Index-Tripeln. */ +function parseObjFaces(obj: string): number[][] { + return obj + .split("\n") + .filter((l) => l.startsWith("f ")) + .map((l) => l.slice(2).trim().split(/\s+/).map(Number)); +} + +describe("exportObj — Wavefront-OBJ-Export", () => { + it("liefert ≥1 v und ≥1 f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich", () => { + const obj = exportObj(fixtureProject()); + const verts = parseObjVertices(obj); + const faces = parseObjFaces(obj); + expect(verts.length).toBeGreaterThan(0); + expect(faces.length).toBeGreaterThan(0); + for (const v of verts) { + expect(v).toHaveLength(3); + for (const c of v) expect(Number.isFinite(c)).toBe(true); + } + for (const f of faces) { + expect(f).toHaveLength(3); + for (const idx of f) { + expect(idx).toBeGreaterThanOrEqual(1); + expect(idx).toBeLessThanOrEqual(verts.length); + } + } + }); + + it("gruppiert Bauteil-Vorkommen als o-Objekte (Wand/Decke/Extrusion)", () => { + const obj = exportObj(fixtureProject()); + expect(obj).toContain("o Wand_W1"); + expect(obj).toContain("o Wand_W2"); + expect(obj).toContain("o Decke_D1"); + expect(obj).toContain("o Extrusion_E1"); + }); + + it("leeres Projekt ⇒ gültige Datei ohne v/f (kein Crash)", () => { + const obj = exportObj(baseProject()); + expect(obj.split("\n").some((l) => l.startsWith("v "))).toBe(false); + expect(obj.split("\n").some((l) => l.startsWith("f "))).toBe(false); + expect(obj.length).toBeGreaterThan(0); + }); +}); + +describe("exportStl — ASCII-STL-Export", () => { + it("hat solid/endsolid-Rahmen, je Facette genau 3 vertex-Zeilen, Facettenzahl = Dreieckszahl", () => { + const stl = exportStl(fixtureProject()); + const lines = stl.split("\n").filter((l) => l.length > 0); + expect(lines[0]).toBe("solid dossier"); + expect(lines[lines.length - 1]).toBe("endsolid dossier"); + const facetCount = lines.filter((l) => l.startsWith("facet normal")).length; + const loopCount = lines.filter((l) => l === "outer loop").length; + const endloopCount = lines.filter((l) => l === "endloop").length; + const endfacetCount = lines.filter((l) => l === "endfacet").length; + const vertexCount = lines.filter((l) => l.startsWith("vertex ")).length; + expect(facetCount).toBeGreaterThan(0); + expect(loopCount).toBe(facetCount); + expect(endloopCount).toBe(facetCount); + expect(endfacetCount).toBe(facetCount); + expect(vertexCount).toBe(facetCount * 3); + }); + + it("leeres Projekt ⇒ gültiges leeres solid-Gerüst (kein Crash)", () => { + const stl = exportStl(baseProject()); + expect(stl).toBe("solid dossier\nendsolid dossier\n"); + }); + + it("Dreieckszahl-Plausibilität: eine frei stehende Wand ergibt genau 1 Box (12 Dreiecke, 8 Ecken)", () => { + const stl = exportStl(projectWithOneWall()); + const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length; + expect(facetCount).toBe(12); + const obj = exportObj(projectWithOneWall()); + expect(parseObjVertices(obj)).toHaveLength(8); + }); + + it("Dreieckszahl-Plausibilität: ein N-Eck-Profil ergibt ein Prisma mit 4N-4 Dreiecken (2N Ecken)", () => { + // Rechteck (N=4): 2 Kappen à 2 Dreiecke + 4 Seitenquads à 2 Dreiecke = 12 = 4·4−4. + const rectStl = exportStl( + projectWithPolygonExtrusion([ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 1 }, + { x: 0, y: 1 }, + ]), + ); + expect(rectStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(12); + + // Konvexes Fünfeck (N=5): 4·5−4 = 16 Dreiecke, 10 Ecken. + const pentagon = [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2.5, y: 1.5 }, + { x: 1, y: 2.5 }, + { x: -0.5, y: 1.5 }, + ]; + const pentaStl = exportStl(projectWithPolygonExtrusion(pentagon)); + expect(pentaStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(16); + const pentaObj = exportObj(projectWithPolygonExtrusion(pentagon)); + expect(parseObjVertices(pentaObj)).toHaveLength(10); + }); + + it("Fenster wird als echtes Loch ausgeschnitten (Gitter-Zerlegung + 4 Laibungen, 48 Dreiecke statt 12)", () => { + // Fenster [x 2..3] × [y 0.9..2.4] vollständig im Wand-Inneren. Erwartung + // (= Rust-Zerlegung `extrude_layer_segment_with_holes`): 8 solide Langseiten- + // Teilrechtecke (3×3-Gitter minus Loch) × 2 Seiten × 2 Dreiecke = 32, + // + Deckel/Boden/2 Stirnkappen = 8, + 4 Laibungen × 2 = 8 → 48 Dreiecke. + const stl = exportStl(projectWithOpening("window")); + const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length; + expect(facetCount).toBe(48); + + // Die volle Box hätte NUR Ecken bei y∈{0,2.6}, x∈{0,5}. Der Ausschnitt + // führt echte Vertices an den Loch-Rändern ein — Beweis, dass das Loch + // wirklich in der Fläche steckt (nicht bloss eine übergelegte Scheibe). + const verts = parseObjVertices(exportObj(projectWithOpening("window"))); + const near = (a: number, b: number) => Math.abs(a - b) < 1e-6; + expect(verts.some((v) => near(v[1], 0.9))).toBe(true); // Brüstungs-OK + expect(verts.some((v) => near(v[1], 2.4))).toBe(true); // Sturz-UK + expect(verts.some((v) => near(v[0], 2))).toBe(true); // linke Loch-Kante + expect(verts.some((v) => near(v[0], 3))).toBe(true); // rechte Loch-Kante + // Deutlich mehr Vertices als die 8-Ecken-Vollbox. + expect(verts.length).toBe(144); + }); + + it("Tür berührt die Wand-UK: keine untere Laibung (36 Dreiecke, keine Brüstungs-Zerlegung)", () => { + // Tür [x 2..3] × [y 0..2.1]: Boden bekommt die Lücke [2,3] (statt einer + // unteren Laibung) → 5 Langseiten-Teilrechtecke × 2 × 2 = 20, Deckel(1)=2, + // Boden(2 Streifen)=4, 2 Stirnkappen=4, 3 Laibungen (links/rechts/oben)=6 → 36. + const stl = exportStl(projectWithOpening("door")); + const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length; + expect(facetCount).toBe(36); + const verts = parseObjVertices(exportObj(projectWithOpening("door"))); + const near = (a: number, b: number) => Math.abs(a - b) < 1e-6; + // Sturz-UK bei y=2.1 vorhanden, aber KEIN Brüstungsvertex (Tür sitzt am Boden). + expect(verts.some((v) => near(v[1], 2.1))).toBe(true); + expect(verts.every((v) => v[1] >= -1e-6 && v[1] <= 2.6 + 1e-6)).toBe(true); + }); + + it("facet-Normalen sind Einheitsvektoren aus dem Kreuzprodukt der Dreiecksecken", () => { + const stl = exportStl(projectWithOneWall()); + const facetLines = stl.split("\n").filter((l) => l.startsWith("facet normal")); + for (const line of facetLines) { + const [nx, ny, nz] = line.slice("facet normal ".length).trim().split(/\s+/).map(Number); + const len = Math.hypot(nx, ny, nz); + expect(len).toBeGreaterThan(0.99); + expect(len).toBeLessThan(1.01); + } + }); +}); diff --git a/src/export/exportMesh.ts b/src/export/exportMesh.ts new file mode 100644 index 0000000..37c0a82 --- /dev/null +++ b/src/export/exportMesh.ts @@ -0,0 +1,353 @@ +// OBJ-/STL-Export des 3D-Gebäudemodells als reines Dreiecks-Mesh. Pures Modul +// (kein Datei-IO, kein WASM) — Architektur wie exportDxf.ts/exportSchedule.ts: +// buildGroups() liest das Projekt und liefert reine Zahlen-Arrays, exportObj()/ +// exportStl() serialisieren sie synchron zu Text. +// +// TRIANGEL-QUELLE (bewusste v1-Entscheidung, siehe PENDENZEN): +// • Wände: pickGeometry() aus toWalls3d.ts liefert dieselben Schicht-Bänder, +// die auch der 3D-Viewer zeichnet (EIN RWall je Materiallage/Achsen- +// Teilstück, bereits mit End-Cuts/Gehrungen/Decken-Dominanz aufgelöst). +// Bänder mit Öffnungs-`holes` werden über den gemeinsamen Loch-Ausschnitt- +// Generator (`plan/wallMeshCut.ts`, TS-Port von render3ds Gitter-Zerlegung +// `extrude_layer_segment_with_holes`) ÖFFNUNGSGENAU trianguliert — die +// Fenster/Türen sind sichtbar ausgestanzt inkl. Laibungsflächen, genau wie +// im 3D-Viewer. Lochlose Bänder bleiben die klassische geteilte 8-Ecken-Box. +// • Decken: pickGeometry() liefert den Umriss + zBottom/zTop je Decke; +// der Umriss wird mit dem vorhandenen Ear-Clipping-Triangulator +// (`triangulate` aus plan/glPlan/glPlanCompile.ts, bereits getestet und +// konkav-fähig) zu Boden-/Deckenkappen trianguliert, dazwischen ein +// Seitenmantel — ein einfaches Umriss-Prisma. +// • ExtrudedSolids (truck-Profile): `src/engine/truckSolid.ts` liefert zwar +// "echte" truck-Dreiecke, ABER nur asynchron über WASM +// (`extrudePolygon`/`extrudeCircle` geben Promises zurück). Das würde die +// geforderte SYNCHRONE, WASM-freie Signatur `exportObj(project): string` +// brechen. Deshalb nutzt dieser Export stattdessen DIESELBE Prisma- +// Triangulierung wie die Decken (Umriss aus `solid.points` — bei +// Kreisprofilen bereits eine 48-Eck-Tessellierung, siehe +// `ExtrudedSolid.points`-Doc) inkl. `taper`-Verjüngung (linear zum +// Schwerpunkt skaliert, wie die truck-Extrusion es tut). Das ist eine +// bewusste Abweichung vom ursprünglichen Plan ("truckSolid-Dreiecke") zu- +// gunsten der pure-Modul-Architektur — ehrlich dokumentiert, kein +// Stillschweigen. +// +// ACHSEN-KONVENTION: identisch zur bestehenden 3D-Pick-/Highlight-Geometrie +// (siehe `selectionHighlightLines` in toWalls3d.ts): Modell (x, y) → Welt +// (x, Höhe, y), Y-UP, rechtshändig. Ein Weltvertex (vx,vy,vz) entspricht also +// (Modell-x, Meter-Höhe, Modell-y) — exakt wie im 3D-Viewer. + +import type { Project, Vec2 } from "../model/types"; +import { getFloor } from "../model/types"; +import { pickGeometry } from "../plan/toWalls3d"; +import type { RWall } from "../plan/toWalls3d"; +import { wallCutMesh } from "../plan/wallMeshCut"; +import { triangulate } from "../plan/glPlan/glPlanCompile"; + +/** + * Eine Mesh-Gruppe (ein Bauteil-Vorkommen: Wand/Decke/Extrusion) mit LOKALEN + * (0-basierten) Vertex-/Dreiecks-Indizes. `exportObj` verschiebt die Indizes + * beim Serialisieren auf die globale, 1-basierte OBJ-Nummerierung; `exportStl` + * braucht gar keine Indizes über Gruppen hinweg (STL ist unindiziert). + */ +interface MeshGroup { + name: string; + /** Flaches Weltkoordinaten-Array (x,y,z, x,y,z, …), Meter, Y-up. */ + positions: number[]; + /** Dreiecks-Indizes (3 je Dreieck), 0-basiert relativ zu `positions`. */ + indices: number[]; +} + +function newGroup(name: string): MeshGroup { + return { name, positions: [], indices: [] }; +} + +/** OBJ-Gruppennamen dürfen keine Whitespaces/Sonderzeichen enthalten. */ +function sanitizeName(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/g, "_") || "x"; +} + +/** + * Die 6 Quader-Seiten als Eckpunkt-Quadrupel (Index-Bits: bit0=Achse −/+, + * bit1=Höhe −/+, bit2=Dicke −/+ — dieselbe Konvention wie `emitOpeningGlass`/ + * `selectionHighlightLines` in toWalls3d.ts), in nach AUSSEN gewundener + * Reihenfolge (für ein rechtshändiges Dreibein Achse×Höhe=Dicke-Normale, wie + * es die Wandbox-Konstruktion unten aufspannt). Jedes Quadrupel wird zu 2 + * Dreiecken (0,1,2)+(0,2,3). + */ +const BOX_QUADS: Array<[number, number, number, number]> = [ + [4, 6, 7, 5], // Dicke + + [1, 3, 2, 0], // Dicke − + [2, 3, 7, 6], // Höhe + (Wandkopf) + [4, 5, 1, 0], // Höhe − (Wand-UK) + [1, 5, 7, 3], // Achse + (Wandende) + [2, 6, 4, 0], // Achse − (Wandanfang) +]; + +/** + * Hängt EIN Wand-Schicht-Band (`RWall`, siehe toWalls3d.ts) an eine Mesh-Gruppe + * an: hat das Band echte Öffnungs-`holes`, wird das öffnungsgenaue Loch-Ausschnitt- + * Mesh (siehe {@link wallCutMesh}) verwendet — die Fenster/Türen sind sichtbar + * ausgestanzt inkl. Laibungen, exakt wie im 3D-Viewer. Ohne Löcher bleibt es die + * klassische, geteilte 8-Ecken-Box ({@link pushWallBox}) — bitgleich zum + * bisherigen Verhalten (Joins/Gehrungen stecken schon in `start`/`end`). + */ +function pushWall(group: MeshGroup, w: RWall): void { + if (w.holes && w.holes.length > 0) { + const cut = wallCutMesh(w); + const base = group.positions.length / 3; + for (const p of cut.positions) group.positions.push(p); + for (const idx of cut.indices) group.indices.push(base + idx); + return; + } + pushWallBox(group, w); +} + +/** + * Hängt EINEN vollen Wand-Schicht-Quader (`RWall`) an eine Mesh-Gruppe an — Box + * über Achse×Höhe×Dicke, OHNE Öffnungs-Ausschnitt (nur für lochlose Bänder, siehe + * {@link pushWall}). Degenerierte Bänder (Länge/Höhe/Dicke ≤ 0) werden übersprungen. + */ +function pushWallBox(group: MeshGroup, w: RWall): void { + const [sx, sy] = w.start; + const [ex, ey] = w.end; + const dx = ex - sx; + const dz = ey - sy; + const len = Math.hypot(dx, dz); + if (len < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return; + const ax = dx / len; + const az = dz / len; + // Normale = Welt-Hoch × Achse (rechtshändig), dieselbe Konvention wie + // `selectionHighlightLines`: normal = (az, 0, −ax). + const nx = az; + const nz = -ax; + const halfLen = len / 2; + const halfHeight = w.height / 2; + const halfThick = w.thickness / 2; + const cx = (sx + ex) / 2; + const cz = (sy + ey) / 2; + const cy = w.baseElevation + w.height / 2; + const base = group.positions.length / 3; + for (let i = 0; i < 8; i++) { + const sL = i & 1 ? 1 : -1; + const sH = i & 2 ? 1 : -1; + const sT = i & 4 ? 1 : -1; + group.positions.push( + cx + sL * halfLen * ax + sT * halfThick * nx, + cy + sH * halfHeight, + cz + sL * halfLen * az + sT * halfThick * nz, + ); + } + for (const [a, b, c, d] of BOX_QUADS) { + group.indices.push(base + a, base + b, base + c, base + a, base + c, base + d); + } +} + +/** Signierte Fläche eines Modell-XY-Umrisses (Shoelace); ≥0 = CCW. */ +function signedAreaXY(pts: Vec2[]): number { + let a = 0; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + a += pts[j].x * pts[i].y - pts[i].x * pts[j].y; + } + return a / 2; +} + +/** + * Hängt ein extrudiertes Umriss-Prisma (Decke ODER truck-Profil-Extrusion, + * siehe Moduldoc) an eine Mesh-Gruppe an: Boden-/Deckkappe (Ear-Clipping- + * Triangulierung via `triangulate`) + Seitenmantel. `taper` (0..1) verjüngt + * den OBEREN Ring linear zum Flächenschwerpunkt — dieselbe Semantik wie + * `ExtrudedSolid.taper`/die truck-Extrusion (0 = Prisma, 1 = Spitze/Pyramide). + * Der Umriss wird intern auf CCW (Modell-XY) normalisiert, damit die + * Seitenmantel-Normalen unabhängig von der Eingabe-Wicklung nach außen zeigen. + */ +function pushPrism( + group: MeshGroup, + outline: Vec2[], + zBottom: number, + zTop: number, + taper = 0, +): void { + if (outline.length < 3 || zTop - zBottom <= 1e-9) return; + const ccw = signedAreaXY(outline) >= 0 ? outline : [...outline].reverse(); + const n = ccw.length; + let cx = 0; + let cyModel = 0; + for (const p of ccw) { + cx += p.x; + cyModel += p.y; + } + cx /= n; + cyModel /= n; + const t = Math.min(1, Math.max(0, taper)); + const base = group.positions.length / 3; + // Unterer Ring (base + 0..n-1), oberer Ring (base + n..2n-1). + for (const p of ccw) group.positions.push(p.x, zBottom, p.y); + for (const p of ccw) { + const tx = cx + (p.x - cx) * (1 - t); + const ty = cyModel + (p.y - cyModel) * (1 - t); + group.positions.push(tx, zTop, ty); + } + // Kappen: `triangulate` liefert bei CCW-Eingabe stets CCW-in-Modell-XY- + // Dreiecke — eingebettet in die Weltebene (x, const, y) entspricht das einer + // nach UNTEN (−Y) zeigenden Flächennormale (Kreuzprodukt-Herleitung siehe + // Moduldoc-Kommentar oben). Die Bodenkappe nutzt diese Wicklung direkt + // (Normale zeigt nach unten = nach außen), die Deckkappe braucht die + // umgekehrte Wicklung (Normale nach oben). + const capTris = triangulate(ccw); + for (let i = 0; i < capTris.length; i += 3) { + const a = capTris[i]; + const b = capTris[i + 1]; + const c = capTris[i + 2]; + group.indices.push(base + a, base + b, base + c); + group.indices.push(base + n + a, base + n + c, base + n + b); + } + // Seitenmantel: je Umrisskante ein Quad (2 Dreiecke), Wicklung so, dass die + // Normale nach außen zeigt (siehe Moduldoc-Herleitung). + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + const bi = base + i; + const bj = base + j; + const ti = base + n + i; + const tj = base + n + j; + group.indices.push(bi, ti, tj); + group.indices.push(bi, tj, bj); + } +} + +/** Baut alle Mesh-Gruppen (Wände, Decken, Extrusionen) des Projekts. */ +function buildGroups(project: Project): MeshGroup[] { + const groups: MeshGroup[] = []; + const { walls, slabs } = pickGeometry(project); + + // Wände: alle Schicht-Bänder DERSELBEN Wand-Id in EINE Gruppe (mehrere + // Materiallagen ergeben mehrere Boxen im selben Bauteil-Vorkommen). + const wallGroups = new Map(); + for (const w of walls) { + let g = wallGroups.get(w.wallId); + if (!g) { + g = newGroup(`Wand_${sanitizeName(w.wallId)}`); + wallGroups.set(w.wallId, g); + groups.push(g); + } + pushWall(g, w); + } + + // Decken: ein Prisma je Decke. + for (const s of slabs) { + const g = newGroup(`Decke_${sanitizeName(s.ceilingId)}`); + const outline: Vec2[] = s.outline.map(([x, y]) => ({ x, y })); + pushPrism(g, outline, s.zBottom, s.zTop); + groups.push(g); + } + + // Extrudierte Profile (truck-Integration): eigenes Prisma je Körper (siehe + // Moduldoc — bewusst NICHT die truck-WASM-Dreiecke, aus Sync-/Pure-Gründen). + for (const solid of project.extrudedSolids ?? []) { + if (solid.points.length < 3 || solid.height <= 0) continue; + let baseZ = 0; + try { + baseZ = getFloor(project, solid.levelId).baseElevation ?? 0; + } catch { + // Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback, wie toWalls3d). + } + const g = newGroup(`Extrusion_${sanitizeName(solid.id)}`); + pushPrism(g, solid.points, baseZ, baseZ + solid.height, solid.taper ?? 0); + groups.push(g); + } + + return groups.filter((g) => g.indices.length > 0); +} + +/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */ +function fmt(v: number): string { + if (!Number.isFinite(v)) return "0.0"; + const s = v.toFixed(6); + return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0"); +} + +/** + * Baut einen vollständigen Wavefront-OBJ-String des 3D-Gebäudemodells (Meter, + * rechtshändig, Y-up — siehe Moduldoc). Ein `o`-Objekt je Bauteil-Vorkommen + * (Wand/Decke/Extrusion). Leeres Projekt ⇒ gültige Datei nur mit Kopf- + * Kommentaren, kein `v`/`f` (kein Crash). + */ +export function exportObj(project: Project): string { + const groups = buildGroups(project); + const lines: string[] = [ + "# DOSSIER OBJ-Export - Dreiecks-Mesh des 3D-Gebaeudemodells", + "# Achsen: rechtshaendig, Y-up (Y = Hoehe ueber OKFF), wie der 3D-Viewer.", + "# Waende: Schicht-Baender mit oeffnungsgenau ausgeschnittenen Loechern (inkl. Laibungen).", + "# Decken/Extrusionen: Umriss-Prisma (Ear-Clipping-Triangulierung).", + ]; + let offset = 0; + for (const g of groups) { + const vertCount = g.positions.length / 3; + if (vertCount === 0) continue; + lines.push(`o ${g.name}`); + for (let i = 0; i < g.positions.length; i += 3) { + lines.push(`v ${fmt(g.positions[i])} ${fmt(g.positions[i + 1])} ${fmt(g.positions[i + 2])}`); + } + for (let i = 0; i < g.indices.length; i += 3) { + const a = g.indices[i] + 1 + offset; + const b = g.indices[i + 1] + 1 + offset; + const c = g.indices[i + 2] + 1 + offset; + lines.push(`f ${a} ${b} ${c}`); + } + offset += vertCount; + } + return lines.join("\n") + "\n"; +} + +/** + * Baut einen vollständigen ASCII-STL-String des 3D-Gebäudemodells (Meter, + * dieselbe Achsen-Konvention wie {@link exportObj}). Jedes Dreieck trägt eine + * aus dem Kreuzprodukt berechnete Facettennormale (konsistente Winding, siehe + * `pushWallBox`/`pushPrism`). Leeres Projekt ⇒ gültiges leeres `solid`-Gerüst. + */ +export function exportStl(project: Project): string { + const groups = buildGroups(project); + const lines: string[] = ["solid dossier"]; + for (const g of groups) { + for (let i = 0; i < g.indices.length; i += 3) { + const ia = g.indices[i] * 3; + const ib = g.indices[i + 1] * 3; + const ic = g.indices[i + 2] * 3; + const ax = g.positions[ia]; + const ay = g.positions[ia + 1]; + const az = g.positions[ia + 2]; + const bx = g.positions[ib]; + const by = g.positions[ib + 1]; + const bz = g.positions[ib + 2]; + const cx = g.positions[ic]; + const cy = g.positions[ic + 1]; + const cz = g.positions[ic + 2]; + const ux = bx - ax; + const uy = by - ay; + const uz = bz - az; + const vx = cx - ax; + const vy = cy - ay; + const vz = cz - az; + let nx = uy * vz - uz * vy; + let ny = uz * vx - ux * vz; + let nz = ux * vy - uy * vx; + const len = Math.hypot(nx, ny, nz); + if (len > 1e-12) { + nx /= len; + ny /= len; + nz /= len; + } else { + nx = 0; + ny = 0; + nz = 0; + } + lines.push(`facet normal ${fmt(nx)} ${fmt(ny)} ${fmt(nz)}`); + lines.push("outer loop"); + lines.push(`vertex ${fmt(ax)} ${fmt(ay)} ${fmt(az)}`); + lines.push(`vertex ${fmt(bx)} ${fmt(by)} ${fmt(bz)}`); + lines.push(`vertex ${fmt(cx)} ${fmt(cy)} ${fmt(cz)}`); + lines.push("endloop"); + lines.push("endfacet"); + } + } + lines.push("endsolid dossier"); + return lines.join("\n") + "\n"; +} diff --git a/src/export/exportSchedule.test.ts b/src/export/exportSchedule.test.ts index 8fbd871..7a64adc 100644 --- a/src/export/exportSchedule.test.ts +++ b/src/export/exportSchedule.test.ts @@ -1,14 +1,18 @@ -// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6). -// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke). +// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2). +// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände, 1 Decke, 1 Tür, +// 1 Fenster, 1 Treppe, 1 Extrusion). // • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet. // • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen. // • leeres Projekt ⇒ nur Kopfzeile (kein Crash). import { describe, it, expect } from "vitest"; import { exportScheduleCsv, scheduleRows } from "./exportSchedule"; -import type { Project, Wall, Ceiling } from "../model/types"; +import type { Project, Wall, Ceiling, Door, Opening, Stair, ExtrudedSolid } from "../model/types"; -/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */ +/** + * Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2) + + * 1 Tür + 1 Fenster (je an W1 gehostet) + 1 Treppe + 1 Extrusion. + */ function fixtureProject(): Project { const walls: Wall[] = [ { @@ -48,6 +52,61 @@ function fixtureProject(): Project { ceilingTypeId: "dt", }, ]; + const doors: Door[] = [ + { + id: "T1", + type: "door", + hostWallId: "W1", + categoryCode: "21", + position: 1, + width: 0.9, + height: 2.1, // Fläche 1.89 + swing: "left", + hinge: "start", + }, + ]; + const openings: Opening[] = [ + { + id: "F1", + type: "opening", + hostWallId: "W2", + categoryCode: "21", + kind: "window", + position: 1, + width: 1.2, + height: 1.5, // Fläche 1.80 + sillHeight: 0.9, + }, + ]; + const stairs: Stair[] = [ + { + id: "S1", + type: "stair", + floorId: "eg", + categoryCode: "40", + shape: "straight", + start: { x: 0, y: 0 }, + dir: { x: 1, y: 0 }, + runLength: 3, // Länge 3 (kein 2. Lauf) + width: 1.2, + totalRise: 2.6, + stepCount: 16, + }, + ]; + const extrudedSolids: ExtrudedSolid[] = [ + { + id: "E1", + type: "extrudedSolid", + levelId: "eg", + points: [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 3 }, + { x: 0, y: 3 }, + ], // 2×3 = 6 m² + height: 2.5, + }, + ]; return { id: "t", name: "T", @@ -61,10 +120,11 @@ function fixtureProject(): Project { ], layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], walls, - doors: [], - openings: [], + doors, + openings, ceilings, - stairs: [], + stairs, + extrudedSolids, rooms: [], drawings2d: [], context: [], @@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => { const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false }); const lines = csv.split("\r\n"); - // Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke). - expect(lines).toHaveLength(4); + // Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion). + expect(lines).toHaveLength(8); expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]"); // Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13. @@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => { expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40"); // Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20. expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00"); + // Tür T1: Breite 0.9, Höhe 2.1, keine Dicke, Fläche 1.89. + expect(lines[4]).toBe("Tür;T1;Tür;EG;0.90;2.10;;1.89"); + // Fenster F1: Breite 1.2, Höhe 1.5, keine Dicke, Fläche 1.80. + expect(lines[5]).toBe("Fenster;F1;Fenster;EG;1.20;1.50;;1.80"); + // Treppe S1: Lauflänge 3 (kein 2. Lauf), Steighöhe 2.6, keine Dicke/Fläche. + expect(lines[6]).toBe("Treppe;S1;Treppe;EG;3.00;2.60;;"); + // Extrusion E1: keine Länge, Höhe 2.5, keine Dicke, Grundrissfläche 6. + expect(lines[7]).toBe("Extrusion;E1;Extrusion;EG;;2.50;;6.00"); }); it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => { const csv = exportScheduleCsv(fixtureProject()); const lines = csv.split("\r\n"); - // Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8. - expect(lines).toHaveLength(8); - expect(lines[4]).toBe(""); - expect(lines[5]).toContain("Zusammenfassung"); + // Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16. + expect(lines).toHaveLength(16); + expect(lines[8]).toBe(""); + expect(lines[9]).toContain("Zusammenfassung"); // Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40. - expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40"); + expect(lines[10]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40"); // Decken-Aggregat: 1 Stk, keine Länge, Fläche 20. - expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00"); + expect(lines[11]).toBe("Decke;;Betondecke;1 Stk;;;;20.00"); + // Tür-Aggregat: 1 Stk, Gesamtbreite 0.9, Fläche 1.89. + expect(lines[12]).toBe("Tür;;Tür;1 Stk;0.90;;;1.89"); + // Fenster-Aggregat: 1 Stk, Gesamtbreite 1.2, Fläche 1.80. + expect(lines[13]).toBe("Fenster;;Fenster;1 Stk;1.20;;;1.80"); + // Treppen-Aggregat: 1 Stk, Gesamtlänge 3, keine bekannte Fläche (leer statt 0.00). + expect(lines[14]).toBe("Treppe;;Treppe;1 Stk;3.00;;;"); + // Extrusions-Aggregat: 1 Stk, keine Länge, Fläche 6. + expect(lines[15]).toBe("Extrusion;;Extrusion;1 Stk;;;;6.00"); }); it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => { @@ -114,6 +190,10 @@ describe("exportScheduleCsv — Bauteilliste", () => { const proj = fixtureProject(); proj.walls = []; proj.ceilings = []; + proj.doors = []; + proj.openings = []; + proj.stairs = []; + proj.extrudedSolids = []; const csv = exportScheduleCsv(proj); expect(csv.split("\r\n")).toHaveLength(1); expect(scheduleRows(proj)).toHaveLength(0); diff --git a/src/export/exportSchedule.ts b/src/export/exportSchedule.ts index 89afc2e5ecc07f005451fe68d6d2efc11bc9a2cb..e7f52e781accfd6635291a9119521c9eb4fafb52 100644 GIT binary patch literal 11349 zcmd5?OK%%T629wKREQuubjYC-EEWrC6+^M;SgV&IDK=o)fJdArIpJ`IJ2Mn5IRbLp zy$8V__mp3-hn#X+{S*I^{i^yg&6lirb6CSRXQsQWyX)~)byasd^mR0?RFbw&dqWjZ zQ$>OKk|t%Pn)LpMpXl-F^LF>`BrhsbNu~-rD4O=sCrzi>fMQi{bUO5`$iGvgs-$4& z+ugl0+WAw9c8^a__qr$TXHk(RRt5<&1YGf zM=^Go<`?ui?PR@SUNmVEl{|Ar$(VK~llDP$rHU3sS(N4jx}J{dB$|%f-~y8g1}Yz` zs<`SDc|P7LR3!8BOL>+*E-#xj&0-m5n9=_0>G4tfbfOaMw#Y(IVM5;&Q8rBtPd2H$ zchseq5QU=el;v>y`SHpA@!?_jh=LbU7K53pH&QJ+yZseQe4;W4pb&Dm|LpfOa1U*T znoQ8A`?e~kWs+yi@Y|oKV?||xrCq4mv@9z++I#%;j8s+)qqI_)a5YX84WkQ{@d}e+ zfzK)#s0_cJ7JQfoT4IWJr;{`pMHN=T`=;XwKSn9_^K?*A96{jN0qYg*35Y6YoLx<1^w;W|naC=rvH=vPH*C=XmYbHH{d_#eUZO%l7pE1PD0qB)^t^j?ws(AV z8jfSIp_^nwl~kK_*gfl>gc}>lSk$_@nkag=L0W%%RSS|EOPN+o?ug0{pE8#GIbm@ZWj=P-k`3h6;-qd(1hta;QcFP|hJ8(c=|RBciz zn9;Nkq6A5wg3fuP^(uQ+Y`yM;3?;gMvnd^j3RO*ujNUXpeJ6xM*jyU*RM2U3V1j>W zH2%=MX}sBjyl(K%beFDM8#j7~FQZ{fz)<>rq?uHQ>X`~a3zAAlM!?^YH@+uNq=_w)(q zU~rg}PZFT1a@#jMX+Trlwt~Z`8iqxV33NYtKwmt3*rbm9gjN0|d8^{!%ckDDQ?Pw( zX$ItN?_8uXOZFo{G%aDkV}tD$M^pg`#)3nv6bi6?A1%|2-sug?^!Dd$pp5@wD5c;~ z0ePCVO*Fx4=eP1~?Z7&E+@ zUau_I7A?gtb0*F6BD_$mM&N$Xy04Pstb%(l#^ zFfO7QaPUA~0)a#5RvZM^;OJL$9VRjKY1@)(J_=>+j~>}C&J0YvF-qZv;bwR%eh4CK zS33F-n6{D1KKrt5@cMgdus|F7Q5>XsW-n+zq;xWyObDIK&8i^F_v>Yhb zRVa;d(~B#mz<1nv!3Hh zZ{`4-p}=LT*!@BJ6hN^qIF4ZRf=%f;i4rGNJBXD43@`0mZ8#pN6LS-+?g%S0JT>X! zdKTJVT2hq54?`DqaDahkiRiAFu2mpAm(|xcH=4epYYQE*AHav}&HI5?Ba-lRt$m{U z3V3*pC@qfJJy9Q`ru{rC5$1rc8N2|t7L}S@S93=p2F(-#Mg+Aw`ZAAdCPGSi1qdJu0IDK5QGvOP0(KWP z%SkU0YX&5iC3mMt;8#b>KnkK%(9I#5b|F@hPj7z#cRrFb!mq)wI$T2#L%QiV-QqM_ziPEJ z`+K0N*4=NMU-eZ`ko@=5L32>C^7rBgb3EC!=OzRYA5ca!BD4n^QAt9iU>mHmXhp4c z(o^X9vy)v`%aUk1HM~o|oWJum%<0uCrP0BLmJ7RAyW-!jICkV3-C1bXGin`R2+}82 zBBw<4C#OAbb+yfzIId=suVLPqF|gC;(~84k(d=5&tg|+eDLbx#LTt*hQbxx20_Zp# zbDpnBx4-i7gfY?iyLqq^UL5zIg3^oCn8%ZF)4F)opcZ-n32fBL>SUwUnMA8I8?Dy# z1Sj|)_z8D!+JL_9uthj1J8-JE;OUObA-Qe^gL+|t7bIESK|!{9Czv?92J)%7_(-1z z?#cBQ$zfrA2vhH5g)sKwj~0h_{uFRuI7-}4D33y8AC4P7%CUZ}2f6v}up{Mkj)e*j z!D4*glw8jW>{^8aa?GeffX73j^aqPko7C{S$u2f^Ot;L@772-3*A_jAVZ!}KTBH!V zvm~yDdUXyj^n?I(yFQ1As~qg@KjF-$8VGo)?Xn!iPICIPoX zc)3o^ZQ2~&3K42hV7CuYF#SXQiUVr{C)zllHRusF?4gk$J-f~Z2Qy1rP)pwuTvh;{ zLihWS_$`f((tx#ttrFM{tMtIO)d2v>_jMvi1`=fs3d&f@#XxvY;VK2Ied^l0+kpoOuc!A9=n#k!CV4=m9RLmk2;M^WAgm>6k459^5n+kR=J7A&UcDVh@_l<+l!Y(=Qqxa zu4Zj573p0Y?wxk;$A+a0oa8XQQkLN4NFD3gqFzeb zT>E1CDpG4S(jg~B#p6^UaMbmA$+~p?K^npHFUr_m;6R&;O(s)R4d^{J){H2~BIRMP6 z8@LiI6Nf^OKHsJ-=gUum+C;jlNw{ii=~D=2Ee%oA0WAG=+iS^1xPu zg6`X%O53~LcE3=l=WrbaPK%312pipv24mH@z`@rbRIyZkLCgs&Gqhn3J9k}TK}nX& zJ8Yr{Tra{MqoKwcGf$HB(rK(1ilX8QlU?L_s-n!I^1aBz=x{mn)I|-=MbKl$>io4_T&eKxCbFBYi@Y)#V~gr453?>&w+w^OmJlxmTPQ$ zc3X(*R@cEeHTOX3r!zTEY~f^UE-vxRcM6$fG#<_OH0nLj`+xihhognRkAM^Pe72N44sxUqBOQ7 z)1kFwUN507X0mXh0pr*p7}WGYhH`XgZ^O3>Pw;9(69Glti*w3YxL%wKLP10tbcCrD zl|e1AcX*oNcQ|zf64Rlx7iuoK5swgh@*mcB{l}6Ev=cDLC~osw5_`N__->uh>QQob z^P0>B9gFR&TFilLRg+7^tZHOW;J&}MoYBWVVKwj{r7ifu1(|*JnU3R)yZH(ScF~VH z?l&4uNqDnTYt0p0tqhJ`;%1dK3gN5A?{mk+#!C}u^l{ZgF8g?v3g~Gznz+r?SohCu z%D7Wc-teCsIN(Qa5c(mDyRtR6A1C};4Los!j>H*`pDc7N%#B*;yFv-P9tc~Qp!ob7 zBzs10=IC;p@B1MD0jWDXNr$YO(y MSH-IgI)W(UzeMUU`v3p{ delta 783 zcmaJ<&ui2`6n3|I*d?_sRV~<3XxTcrCp)*R}U-nAhVmgUOb66>B;}VnanOO2;OGi``-J$_rCY-(Hr~yabW>^hfgfn z!2JN57Th~venb%|w_5zLxH=C;7g2{jJ!s_!DqbdkTXZhK{W#jkeb#_3BBTMgu}^&J zLbf+mJHuB>&9W?1Rop3UuG#SO=%tM55IH!Ozr3YxL9JB=2z=_;pew{P&{dKTOM9~u z5|x0R@|G!hse}4nLWZxYi@~R`gBgw~7&IjWde~P}@j30vvIKj>kKX@qf!1D5Zn-ATeTW&3gW(M7=Hd>=X#(G>ooEE9!mzJw6UG*8@}jBdb6QK z3E)cUKM&JEL|kkGjuQsLHkpP|Gh-tP(hyS?|3wP54x%)N zj<{YOFBbU8rC+oEIQ0Xrn{s{14`D-YhbfGHF2;$+JS;{oro2_Ek8DOGbTS-xjZBFp&r3L>xsS4Z0V|d`r065chk^7u4~Dofp)t+zMEShqby>G fY-8#&59BHd;ZU#eX8r8>iP{{sF1Bs*=|<-d{^bqg diff --git a/src/export/layoutPdf.ts b/src/export/layoutPdf.ts new file mode 100644 index 0000000..25acbbc --- /dev/null +++ b/src/export/layoutPdf.ts @@ -0,0 +1,181 @@ +// Ordner → Mehrseiten-PDF (DOSSIER A3): exportiert alle Layouts eines Ordners +// als EIN gemeinsames PDF, jedes Layout = eine Seite. Das Rendering je Seite +// spiegelt exakt `ui/LayoutSheet.tsx` (Master-Rahmen + Titelblock + echte +// Viewport-Pläne via `generatePlan`→`planToPrintSvg`) und komponiert die Seiten +// per jsPDF (`addPage` je Layout) analog `export/exportPdf.ts` (buildPlanPdf). +// +// Trennung: die Seiten-Sammlung/-Reihenfolge + effektive Blattgrösse ist reine, +// testbare Logik (`folderPdfPages`, layoutModel); das SVG/jsPDF-Rendering +// (`buildFolderPdf`) braucht ein DOM und läuft nur im Browser/Tauri. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { jsPDF } from "jspdf"; +import "svg2pdf.js"; +import type { MasterLayout, Project } from "../model/types"; +import { generatePlan } from "../plan/generatePlan"; +import { planToPrintSvg } from "./planToPrintSvg"; +import { saveBinaryFile } from "../io/saveFile"; +import { + findMaster, + findSnapshot, + folderPdfPages, + resolveTitleBlock, + resolveViewportScale, + visibleCodesFromSnapshot, + type FolderPdfPage, + type ResolvedTitleBlock, +} from "../panels/layoutModel"; + +export { folderPdfPages, type FolderPdfPage }; + +/** Titelblock (mm-Vektor) auf die aktuelle jsPDF-Seite — Anmutung wie LayoutSheet. */ +function drawTitleBlock( + doc: jsPDF, + sheetW: number, + sheetH: number, + tb: ResolvedTitleBlock, + paper: string, + orientation: string, +): void { + const boxW = 70; + const boxH = 30; + const pad = 6; + const x = sheetW - boxW - pad; + const y = sheetH - boxH - pad; + const fmt = `${paper.toUpperCase()} ${orientation === "landscape" ? "quer" : "hoch"}`; + + doc.setFillColor(255, 255, 255); + doc.setDrawColor(17, 17, 17); + doc.setLineWidth(0.35); + doc.rect(x, y, boxW, boxH, "FD"); + doc.setLineWidth(0.2); + doc.line(x, y + 8, x + boxW, y + 8); + doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7); + + doc.setTextColor(17, 17, 17); + doc.setFont("helvetica", "bold"); + doc.setFontSize(9); + doc.text(clip(tb.projectName, 34), x + 3, y + 5.6); + + doc.setFont("helvetica", "normal"); + doc.setFontSize(8); + doc.text(clip(tb.sheetName, 36), x + 3, y + 13); + if (tb.author) { + doc.setFontSize(7); + doc.setTextColor(68, 68, 68); + doc.text(clip(tb.author, 40), x + 3, y + 18); + doc.setTextColor(17, 17, 17); + } + doc.setFontSize(8); + doc.text(tb.scale, x + 3, y + boxH - 2.4); + doc.setFontSize(7); + doc.text(`${tb.date} · ${fmt}`, x + boxW - 3, y + boxH - 2.4, { align: "right" }); +} + +function clip(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +/** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */ +async function renderPage( + doc: jsPDF, + project: Project, + page: FolderPdfPage, + master: MasterLayout | undefined, +): Promise { + const { layout, widthMm, heightMm } = page; + + // Echte Viewport-Pläne (wie ViewportPlan in LayoutSheet.tsx). + for (const vp of layout.viewports) { + const snap = findSnapshot(project, vp.snapshotId); + if (!snap) continue; + try { + const codes = visibleCodesFromSnapshot(snap); + const plan = generatePlan( + project, + snap.activeLevelId, + codes, + undefined, + snap.detail, + false, + false, + ); + const scaleN = resolveViewportScale(vp, snap); + const print = planToPrintSvg(plan, { + scaleDenominator: scaleN, + pageWidthMm: vp.widthMm, + pageHeightMm: vp.heightMm, + }); + await doc.svg(print.svg, { + x: vp.xMm, + y: vp.yMm, + width: vp.widthMm, + height: vp.heightMm, + }); + } catch { + // Einzelner Viewport, der nicht rendert, darf die Seite nicht killen. + } + } + + // Master: Rahmen + Titelblock (read-only, wie im In-Viewport-Editor). + if (master) { + if (master.border ?? true) { + doc.setDrawColor(17, 17, 17); + doc.setLineWidth(0.35); + doc.rect(5, 5, widthMm - 10, heightMm - 10); + } + const tb = resolveTitleBlock(project, layout, master); + drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation); + } +} + +/** + * Baut das Mehrseiten-PDF eines Ordners und liefert das jsPDF-Dokument. Jede + * Seite = ein Layout in Baum-Reihenfolge. Gibt `null`, wenn der Ordner keine + * Layouts enthält (der Aufrufer meldet das dann in der UI). + */ +export async function buildFolderPdf( + project: Project, + folderId: string, +): Promise { + const pages = folderPdfPages(project, folderId); + if (pages.length === 0) return null; + + const orientOf = (p: FolderPdfPage) => + p.widthMm > p.heightMm ? "landscape" : "portrait"; + + const first = pages[0]; + const doc = new jsPDF({ + unit: "mm", + format: [first.widthMm, first.heightMm], + orientation: orientOf(first), + compress: true, + }); + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + if (i > 0) doc.addPage([page.widthMm, page.heightMm], orientOf(page)); + const master = findMaster(project, page.layout); + await renderPage(doc, project, page, master); + } + + return doc; +} + +/** + * Baut das Ordner-PDF und speichert es (nativer Tauri-Dialog bzw. + * Browser-Download). Liefert `false`, wenn nichts zu exportieren war oder der + * Speichern-Dialog abgebrochen wurde. + */ +export async function saveFolderPdf( + project: Project, + folderId: string, + fileName: string, +): Promise { + const doc = await buildFolderPdf(project, folderId); + if (!doc) return false; + const bytes = doc.output("arraybuffer"); + const name = fileName.replace(/\.pdf$/i, ""); + return saveBinaryFile(new Uint8Array(bytes), `${name}.pdf`, "application/pdf"); +} diff --git a/src/geometry/column.ts b/src/geometry/column.ts new file mode 100644 index 0000000..c73c19d --- /dev/null +++ b/src/geometry/column.ts @@ -0,0 +1,46 @@ +// Stützen-Geometrie: leitet aus einer Stütze (Column) ihren Grundriss-Querschnitt +// (Poché-/Footprint-Polygon) ab. Reine Funktion, keine Modell-Mutation — sowohl +// der 2D-Grundriss (generatePlan) als auch die 3D-Extrusion (toWalls3d) und die +// Auswahl-/Transform-Vorschau (transform.ts) nutzen dieselbe Kontur. +// +// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { Column, Vec2 } from "../model/types"; + +/** Tessellierungs-Segmentzahl eines runden Stützenprofils (Grundriss + 3D-Prisma). */ +export const COLUMN_ROUND_SEGMENTS = 32; + +/** + * Grundriss-Querschnitt einer Stütze als geschlossenes Polygon (Modell-Meter, + * CCW), um `position` platziert und (bei Rechteck) um `rotation` gedreht. Ein + * rundes Profil wird als regelmäßiges {@link COLUMN_ROUND_SEGMENTS}-Eck + * tesselliert; der Schlusspunkt wird NICHT dupliziert. + */ +export function columnFootprint(col: Column): Vec2[] { + const { x: cx, y: cy } = col.position; + if (col.profile.kind === "round") { + const r = col.profile.radius; + const pts: Vec2[] = []; + for (let i = 0; i < COLUMN_ROUND_SEGMENTS; i++) { + const t = (i / COLUMN_ROUND_SEGMENTS) * Math.PI * 2; + pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); + } + return pts; + } + // Rechteck: lokale Ecken ±w/2 (X) × ±d/2 (Y), um `rotation` gedreht. + const hw = col.profile.width / 2; + const hd = col.profile.depth / 2; + const rot = col.rotation ?? 0; + const cos = Math.cos(rot); + const sin = Math.sin(rot); + const local: Vec2[] = [ + { x: -hw, y: -hd }, + { x: +hw, y: -hd }, + { x: +hw, y: +hd }, + { x: -hw, y: +hd }, + ]; + return local.map((p) => ({ + x: cx + p.x * cos - p.y * sin, + y: cy + p.x * sin + p.y * cos, + })); +} diff --git a/src/geometry/kernel2d.parity.test.ts b/src/geometry/kernel2d.parity.test.ts index a6490d9..16c404a 100644 --- a/src/geometry/kernel2d.parity.test.ts +++ b/src/geometry/kernel2d.parity.test.ts @@ -60,7 +60,7 @@ import { stairGeometry, type StairGeometry as TSStairGeometry, } from "./stair"; -import type { Stair } from "../model/types"; +import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types"; import { detectRooms, pointInPolygon as rbPointInPolygon, @@ -69,6 +69,19 @@ import { type WallSegment, type WallFace, } from "./roomBoundary"; +import { wallReferenceOffset } from "../model/wall"; +import { + doorSymbol, + openingCenter, + openingGapQuad, + openingInterval, + openingJambs, + wallAxisFrame, + wallAxisLength, + windowSymbol, + type DoorSymbol, + type WindowSymbol, +} from "./opening"; type Poly = { pts: Vec2[]; closed: boolean }; @@ -912,6 +925,317 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBou }); }); +// ── Slice 4: opening ────────────────────────────────────────────────────────── +// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch +// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert +// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port — +// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`, +// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf). + +/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */ +function genWall(rng: () => number): Wall { + const start = v(rng); + const angle = rng() * Math.PI * 2; + const length = 0.5 + rng() * 15; + const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length }; + const refs = ["left", "center", "right"] as const; + return { + id: "w1", + type: "wall", + floorId: "f1", + categoryCode: "20", + start, + end, + wallTypeId: "wt1", + height: 2.4, + referenceLine: refs[Math.floor(rng() * 3)], + }; +} + +/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */ +function genWallType(rng: () => number): WallType { + return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] }; +} + +/** Minimales Project — `getWallType` liest nur `wallTypes`. */ +function makeProject(wallType: WallType): Project { + return { wallTypes: [wallType] } as unknown as Project; +} + +/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse + * oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */ +function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening { + const axis = wallAxisLength(wall); + const position = (rng() * 1.6 - 0.3) * axis; + const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis); + const swings = ["left", "right"] as const; + const hinges = ["start", "end"] as const; + const dirs = ["in", "out"] as const; + return { + id: "o1", + type: "opening", + hostWallId: wall.id, + categoryCode: "21", + kind, + position, + width, + height: 1.2, + sillHeight: kind === "door" ? 0 : 0.8, + wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4), + hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)], + swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)], + swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120, + openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)], + }; +} + +describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => { + it("wallAxisLength (Zufallswaende, rel 1e-9)", () => { + const rng = mulberry32(50); + const walls = Array.from({ length: N }, () => genWall(rng)); + const qs = walls.map((wl) => ({ start: wl.start, end: wl.end })); + const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[]; + walls.forEach((wl, i) => { + expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true); + }); + }); + + it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => { + const rng = mulberry32(51); + const cases = Array.from({ length: N }, () => { + const wall = genWall(rng); + return { wall, o: genOpening(rng, wall, "window") }; + }); + const qs = cases.map(({ wall, o }) => ({ + start: wall.start, + end: wall.end, + position: o.position, + width: o.width, + })); + const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as ( + | { from: number; to: number } + | null + )[]; + cases.forEach(({ wall, o }, i) => { + const t = openingInterval(wall, o); + expect(w[i] === null, `iv-null#${i}`).toBe(t === null); + if (t && w[i]) { + expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true); + expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true); + } + }); + }); + + it("wallAxisFrame (u/n, rel 1e-9)", () => { + const rng = mulberry32(52); + const walls = Array.from({ length: N }, () => genWall(rng)); + const qs = walls.map((wl) => ({ start: wl.start, end: wl.end })); + const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[]; + walls.forEach((wl, i) => { + const t = wallAxisFrame(wl); + expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true); + expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true); + }); + }); + + it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => { + const rng = mulberry32(53); + const cases = Array.from({ length: N }, () => { + const wall = genWall(rng); + return { wall, o: genOpening(rng, wall, "window") }; + }); + const qs = cases.map(({ wall, o }) => ({ + start: wall.start, + end: wall.end, + position: o.position, + width: o.width, + })); + const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as ( + | { jambStart: Vec2; jambEnd: Vec2 } + | null + )[]; + const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[]; + cases.forEach(({ wall, o }, i) => { + const tJ = openingJambs(wall, o); + expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null); + if (tJ && wJ[i]) { + expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true); + expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true); + } + const tC = openingCenter(wall, o); + expect(wC[i] === null, `center-null#${i}`).toBe(tC === null); + if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true); + }); + }); + + it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => { + const rng = mulberry32(54); + const cases = Array.from({ length: 150 }, () => { + const wall = genWall(rng); + const wallType = genWallType(rng); + wall.wallTypeId = wallType.id; + const project = makeProject(wallType); + const o = genOpening(rng, wall, "window"); + const total = wallTypeThickness(wallType); + const refOffset = wallReferenceOffset(wall, total); + const glassCount = 1 + Math.floor(rng() * 3); + return { wall, o, project, total, refOffset, glassCount }; + }); + + const qsGap = cases.map((c) => ({ + start: c.wall.start, + end: c.wall.end, + position: c.o.position, + width: c.o.width, + thickness: c.total, + refOffset: c.refOffset, + })); + const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[]; + cases.forEach((c, i) => { + const t = openingGapQuad(c.project, c.wall, c.o); + expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null); + if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true); + }); + + const qsWin = cases.map((c) => ({ + start: c.wall.start, + end: c.wall.end, + position: c.o.position, + width: c.o.width, + thickness: c.total, + refOffset: c.refOffset, + glassCount: c.glassCount, + wingCount: c.o.wingCount, + })); + const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[]; + cases.forEach((c, i) => { + const t = windowSymbol(c.project, c.wall, c.o, c.glassCount); + expect(wWin[i] === null, `win-null#${i}`).toBe(t === null); + if (t && wWin[i]) { + expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true); + expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true); + expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true); + } + }); + }); + + it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => { + const rng = mulberry32(55); + const cases = Array.from({ length: 150 }, () => { + const wall = genWall(rng); + return { wall, o: genOpening(rng, wall, "door") }; + }); + const qs = cases.map(({ wall, o }) => ({ + start: wall.start, + end: wall.end, + position: o.position, + width: o.width, + swing: o.swing, + openingDir: o.openingDir, + hinge: o.hinge, + swingAngle: o.swingAngle, + })); + const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[]; + cases.forEach(({ wall, o }, i) => { + const t = doorSymbol(wall, o); + expect(w[i] === null, `door-null#${i}`).toBe(t === null); + if (t && w[i]) { + expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true); + expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true); + expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true); + expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true); + expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true); + expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true); + expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true); + } + }); + }); + + it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => { + const wall: Wall = { + id: "w", + type: "wall", + floorId: "f", + categoryCode: "20", + start: { x: 0, y: 0 }, + end: { x: 5, y: 0 }, + wallTypeId: "wt", + height: 2.4, + referenceLine: "center", + }; + const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] }; + const project = makeProject(wallType); + + // Breite 0 → entartet. + const oZero: Opening = { + id: "o", + type: "opening", + hostWallId: "w", + categoryCode: "21", + kind: "window", + position: 2, + width: 0, + height: 1.2, + sillHeight: 0.8, + }; + const ivZero = JSON.parse( + K.opening_interval_batch_json( + JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]), + ), + ) as ({ from: number; to: number } | null)[]; + expect(ivZero[0] === null).toBe(true); + expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null); + + // Vollstaendig ausserhalb der Achse. + const oOut: Opening = { ...oZero, position: 10, width: 1 }; + const ivOut = JSON.parse( + K.opening_interval_batch_json( + JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]), + ), + ) as ({ from: number; to: number } | null)[]; + expect(ivOut[0] === null).toBe(true); + expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null); + + // wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten. + const total = wallTypeThickness(wallType); + const refOffset = wallReferenceOffset(wall, total); + const oWings: Opening = { + id: "o", + type: "opening", + hostWallId: "w", + categoryCode: "21", + kind: "window", + position: 1, + width: 2, + height: 1.2, + sillHeight: 0.8, + wingCount: 9, + }; + const wWin = JSON.parse( + K.window_symbol_batch_json( + JSON.stringify([ + { + start: wall.start, + end: wall.end, + position: oWings.position, + width: oWings.width, + thickness: total, + refOffset, + glassCount: 1, + wingCount: oWings.wingCount, + }, + ]), + ), + ) as (WindowSymbol | null)[]; + const tWin = windowSymbol(project, wall, oWings, 1); + expect(tWin).not.toBeNull(); + expect(wWin[0]).not.toBeNull(); + expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length); + expect(wWin[0]!.mullionLines.length).toBe(3); + expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).toBe(true); + }); +}); + describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => { it("parallele/kollineare Strecken → null (beide)", () => { const qs = [ diff --git a/src/geometry/roomArea.test.ts b/src/geometry/roomArea.test.ts new file mode 100644 index 0000000..81ae4ee --- /dev/null +++ b/src/geometry/roomArea.test.ts @@ -0,0 +1,102 @@ +// Tests für die SIA-416-Flächenbilanz (roomArea.ts): Roll-up-Hierarchie, +// insbesondere die Einhängung der Kategorie AGF (Aussengeschossfläche). + +import { describe, expect, it } from "vitest"; +import { balance, roomsToCsv, SIA_CATEGORIES, siaLabel } from "./roomArea"; + +describe("SIA_CATEGORIES", () => { + it("enthält AGF als Blatt-Kategorie", () => { + expect(SIA_CATEGORIES).toContain("AGF"); + }); + + it("liefert das deutsche Label für AGF", () => { + expect(siaLabel("AGF")).toBe("Aussengeschossfläche"); + }); +}); + +describe("balance() — Roll-up-Hierarchie", () => { + it("rollt HNF/NNF/VF/FF/KGF wie bisher auf (Referenzfall ohne AGF)", () => { + const bal = balance([ + { category: "HNF", area: 20 }, + { category: "NNF", area: 5 }, + { category: "VF", area: 8 }, + { category: "FF", area: 2 }, + { category: "KGF", area: 10 }, + ]); + expect(bal.byKey.NF).toBeCloseTo(25); + expect(bal.byKey.NGF).toBeCloseTo(35); + expect(bal.byKey.GF).toBeCloseTo(45); + expect(bal.byKey.AGF).toBe(0); + }); + + it("zählt AGF zur GF, aber NICHT zu NF/NGF", () => { + const bal = balance([ + { category: "HNF", area: 20 }, + { category: "AGF", area: 12 }, + ]); + // NGF bleibt unverändert (nur HNF trägt bei) — AGF fliesst hier nicht ein. + expect(bal.byKey.NF).toBeCloseTo(20); + expect(bal.byKey.NGF).toBeCloseTo(20); + // GF = NGF + KGF + AGF = 20 + 0 + 12 + expect(bal.byKey.AGF).toBeCloseTo(12); + expect(bal.byKey.GF).toBeCloseTo(32); + expect(bal.total).toBeCloseTo(32); + }); + + it("GF = NGF + KGF + AGF bei gemischter Belegung", () => { + const bal = balance([ + { category: "HNF", area: 40 }, + { category: "NNF", area: 6 }, + { category: "VF", area: 9 }, + { category: "FF", area: 3 }, + { category: "KGF", area: 15 }, + { category: "AGF", area: 7.5 }, + ]); + expect(bal.byKey.NF).toBeCloseTo(46); + expect(bal.byKey.NGF).toBeCloseTo(58); + expect(bal.byKey.KGF).toBeCloseTo(15); + expect(bal.byKey.AGF).toBeCloseTo(7.5); + expect(bal.byKey.GF).toBeCloseTo(bal.byKey.NGF + bal.byKey.KGF + bal.byKey.AGF); + expect(bal.byKey.GF).toBeCloseTo(80.5); + expect(bal.total).toBeCloseTo(80.5); + }); + + it("führt eine eigene Bilanzzeile für AGF, positioniert nach KGF und vor GF", () => { + const bal = balance([{ category: "AGF", area: 4 }]); + const keys = bal.rows.map((r) => r.key); + expect(keys).toEqual(["HNF", "NNF", "NF", "VF", "FF", "NGF", "KGF", "AGF", "GF"]); + const agfRow = bal.rows.find((r) => r.key === "AGF"); + expect(agfRow).toBeDefined(); + expect(agfRow?.aggregate).toBe(false); + expect(agfRow?.area).toBeCloseTo(4); + expect(agfRow?.count).toBe(1); + }); +}); + +describe("roomsToCsv() — Bilanzblock mit AGF", () => { + it("weist einen Raum mit Kategorie AGF in Raumliste und Bilanz korrekt aus", () => { + const csv = roomsToCsv([ + { number: "R.01", name: "Wohnen", category: "HNF", area: 20 }, + { number: "R.02", name: "Balkon", category: "AGF", area: 6 }, + ]); + const lines = csv.split("\r\n"); + + // Raumliste enthält den AGF-Raum mit Kategorie + Label. + const roomLine = lines.find((l) => l.startsWith("R.02")); + expect(roomLine).toBeDefined(); + expect(roomLine).toContain("AGF"); + expect(roomLine).toContain("Aussengeschossfläche"); + expect(roomLine).toContain("6.00"); + + // Bilanzblock enthält eine AGF-Zeile mit der Fläche des Raums. + const balanceAgfLine = lines.find((l) => l.includes(";AGF;")); + expect(balanceAgfLine).toBeDefined(); + expect(balanceAgfLine).toContain("Aussengeschossfläche"); + expect(balanceAgfLine).toContain("6.00"); + + // GF-Summe berücksichtigt AGF (20 HNF + 6 AGF = 26). + const gfLine = lines.find((l) => l.includes(";GF;")); + expect(gfLine).toBeDefined(); + expect(gfLine).toContain("26.00"); + }); +}); diff --git a/src/geometry/roomArea.ts b/src/geometry/roomArea.ts index 821a780..c083081 100644 --- a/src/geometry/roomArea.ts +++ b/src/geometry/roomArea.ts @@ -92,6 +92,7 @@ export function centroid(pts: Vec2[]): Vec2 { // // GF Geschossfläche // ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …) +// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …) // └── NGF Nettogeschossfläche // ├── NF Nutzfläche // │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck) @@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 { // └── FF Funktionsfläche (technische Anlagen, Haustechnik) // // Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind -// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF. +// HNF, NNF, VF, FF, KGF und AGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF+AGF = GF. +// AGF zählt zur Geschossfläche, ist aber ausserhalb der Gebäudehülle (Aussen- +// bereich) und fliesst daher NICHT in NGF (bzw. NF) ein. /** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */ -export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF"; +export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF"; /** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */ export type SiaAggregate = "NF" | "NGF" | "GF"; @@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [ "VF", "FF", "KGF", + "AGF", ] as const; /** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */ @@ -134,6 +138,7 @@ const SIA_LABELS: Record = { VF: "Verkehrsfläche", FF: "Funktionsfläche", KGF: "Konstruktionsfläche", + AGF: "Aussengeschossfläche", NF: "Nutzfläche", NGF: "Nettogeschossfläche", GF: "Geschossfläche", @@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string { /** * Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu * NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF. - * Wird für den Bilanz-Roll-up genutzt. + * AGF (Aussengeschossfläche) trägt nur zu GF bei — sie liegt ausserhalb der + * Gebäudehülle und zählt NICHT zu NF/NGF. Wird für den Bilanz-Roll-up genutzt. */ const CATEGORY_ROLLUP: Record = { HNF: ["NF", "NGF", "GF"], @@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record = { VF: ["NGF", "GF"], FF: ["NGF", "GF"], KGF: ["GF"], + AGF: ["GF"], }; // ── Raum-Flächenergebnis ───────────────────────────────────────────────────── @@ -242,14 +249,14 @@ export interface Balance { * anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}). * * Die zurückgegebenen `rows` sind in kanonischer Reihenfolge: - * HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ) + * HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, AGF, GF(=Σ) * — passend für die Bilanz-Tabelle und den CSV-Export. */ export function balance( rooms: { category: SiaCategory; area: number }[], ): Balance { - const leaf: Record = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 }; - const leafCount: Record = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 }; + const leaf: Record = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 }; + const leafCount: Record = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 }; const agg: Record = { NF: 0, NGF: 0, GF: 0 }; const aggCount: Record = { NF: 0, NGF: 0, GF: 0 }; @@ -269,6 +276,7 @@ export function balance( VF: leaf.VF, FF: leaf.FF, KGF: leaf.KGF, + AGF: leaf.AGF, NF: agg.NF, NGF: agg.NGF, GF: agg.GF, @@ -297,6 +305,7 @@ export function balance( mkLeaf("FF"), mkAgg("NGF"), mkLeaf("KGF"), + mkLeaf("AGF"), mkAgg("GF"), ]; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 9881d85..e6ef921 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -15,16 +15,28 @@ export const de = { "topbar.perspektive": "Perspektive", "view3d.group": "Ansicht 3D", - "view3d.front": "Front", + "view3d.front": "Vorne", "view3d.front.hint": "Frontalansicht von vorne", + "view3d.back": "Hinten", + "view3d.back.hint": "Rückansicht von hinten", "view3d.top": "Oben", "view3d.top.hint": "Draufsicht (senkrecht von oben)", - "view3d.side": "Seite", - "view3d.side.hint": "Seitenansicht (90° zur Front)", + "view3d.side": "Rechts", + "view3d.side.hint": "Rechte Seitenansicht (90° zur Front)", + "view3d.left": "Links", + "view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)", "view3d.perspective": "Perspektive", "view3d.perspective.hint": "Freie Perspektive (3/4-Blick)", "view3d.iso": "Isometrie", "view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)", + "view3d.isoFrontLeft": "Vorne-Links", + "view3d.isoFrontLeft.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-links)", + "view3d.isoBackRight": "Hinten-Rechts", + "view3d.isoBackRight.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-rechts)", + "view3d.isoBackLeft": "Hinten-Links", + "view3d.isoBackLeft.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-links)", + "view3d.iso.menu": "Weitere Iso-Ansichten", + "view3d.iso.menu.hint": "Einen der vier oberen Iso-Oktanten wählen", "view3d.camera": "Kamera", "view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)", "view3d.fov": "Sichtwinkel", @@ -86,12 +98,12 @@ export const de = { "topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden", // ── Layout-Menü (Topbar) ───────────────────────────────────────────────── - "layout.title": "Layout", - "layout.placeholder": "Layout …", - "layout.saveCurrent": "+ Aktuelles speichern …", + "layout.title": "Arbeitsumgebung", + "layout.placeholder": "Arbeitsumgebung …", + "layout.saveCurrent": "+ Aktuelle speichern …", "layout.loadGroup": "Laden", "layout.deleteGroup": "Löschen", - "layout.savePrompt": "Layout speichern als:", + "layout.savePrompt": "Arbeitsumgebung speichern als:", // ── Sichtbarkeits-Kombinationen (Topbar) ───────────────────────────────── "combo.layers": "Ebenen-Komb.", @@ -120,6 +132,10 @@ export const de = { "tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius", "tool.arc": "Bogen", "tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)", + "tool.text": "Text", + "tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen", + "tool.textbox": "Textspalte", + "tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen", // Ribbon-Oberleiste (Tabs + Gruppen) "ribbon.tab.2d": "2D", "ribbon.tab.3d": "3D", @@ -234,6 +250,7 @@ export const de = { "attr.fill": "Füllung", "attr.fillColor": "Füllfarbe", "attr.clearFill": "Füllfarbe entfernen", + "attr.pickColor": "Farbe wählen", "attr.foreground": "Vordergrund", "attr.background": "Hintergrund", "attr.bySystem": "Nach System", @@ -266,12 +283,29 @@ export const de = { "objinfo.kind.opening": "Öffnung", "objinfo.kind.stair": "Treppe", "objinfo.kind.room": "Raum", + "objinfo.kind.extrudedSolid": "Extrusion", + "objinfo.kind.column": "Stütze", + // ── Stützen-Attribute (Object-Info-Panel) ─────────────────────────────── + "objinfo.column.section": "Stütze", + "objinfo.column.profile": "Profil", + "objinfo.column.profile.rect": "Rechteck", + "objinfo.column.profile.round": "Rund", + "objinfo.column.width": "Breite", + "objinfo.column.depth": "Tiefe", + "objinfo.column.radius": "Radius", + "objinfo.column.height": "Höhe", + "objinfo.column.rotation": "Drehung (°)", + "objinfo.column.floor": "Geschoss", // ── Wand-Attribute (Object-Info-Panel) ────────────────────────────────── "objinfo.wall.section": "Wand", "objinfo.wall.refLine": "Referenzlinie", "objinfo.wall.refLine.left": "Außen (links)", "objinfo.wall.refLine.center": "Mitte", "objinfo.wall.refLine.right": "Innen (rechts)", + "objinfo.wall.sliceTermination": "An Decke enden", + "objinfo.wall.sliceTermination.both": "Aus", + "objinfo.wall.sliceTermination.below": "Unten", + "objinfo.wall.sliceTermination.above": "Oben", "objinfo.wall.buildup": "Aufbau", "objinfo.wall.single": "Solid", "objinfo.wall.multi": "Mehrschichtig", @@ -284,6 +318,11 @@ export const de = { "objinfo.wall.top": "Oberkante (OK)", "objinfo.wall.anchorFloor": "Gebunden", "objinfo.wall.anchorCustom": "Eigene", + "objinfo.anchor.refFloor": "Referenzgeschoss", + "objinfo.anchor.customHeight": "Eigene Höhe", + "objinfo.wall.length": "Länge", + "objinfo.wall.volumeGross": "Brutto", + "objinfo.volume": "Volumen", "objinfo.wall.height": "Höhe", // ── Decken-Attribute (Object-Info-Panel) ──────────────────────────────── "objinfo.ceiling.section": "Decke", @@ -299,6 +338,9 @@ export const de = { "objinfo.ceiling.anchorCustom": "Eigene", // ── Öffnungs-Attribute (Object-Info-Panel) ────────────────────────────── "objinfo.opening.section": "Öffnung", + "objinfo.opening.type": "Typ", + "objinfo.stair.type": "Typ", + "objinfo.type.none": "(kein Typ)", "objinfo.opening.kind": "Art", "objinfo.opening.kind.window": "Fenster", "objinfo.opening.kind.door": "Tür", @@ -359,6 +401,15 @@ export const de = { "objinfo.room.perimeter": "Umfang", "objinfo.room.refFloor": "Referenzgeschoss", "objinfo.room.editStamp": "Stempel bearbeiten …", + // ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ───────── + "objinfo.extrudedSolid.section": "Extrusion", + "objinfo.extrudedSolid.height": "Höhe", + "objinfo.extrudedSolid.taper": "Verjüngung", + "objinfo.extrudedSolid.area": "Grundfläche", + "objinfo.extrudedSolid.floor": "Geschoss", + "objinfo.rotate": "Drehen", + "objinfo.drawing.length": "Länge", + "objinfo.drawing.radius": "Radius", "objinfo.wall.floorAboveNone": "Kein oberes Geschoss", "levels.addFloor": "+ Geschoss", "levels.addSection": "+ Schnitt", @@ -489,6 +540,9 @@ export const de = { "default.ceilingTypeSingle": "Decke {thickness} m", "default.wallTypeName": "Neuer Wandtyp", "default.ceilingTypeName": "Neuer Deckentyp", + "default.doorTypeName": "Neuer Türtyp", + "default.windowTypeName": "Neuer Fenstertyp", + "default.stairTypeName": "Neuer Treppentyp", // ── Warnungen (Lösch-Schutz) ───────────────────────────────────────────── "alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.", @@ -497,6 +551,9 @@ export const de = { "alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.", "alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.", "alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.", + "alert.doorTypeInUse": "Türtyp „{name}\" wird von einer Tür verwendet und kann nicht gelöscht werden.", + "alert.windowTypeInUse": "Fenstertyp „{name}\" wird von einem Fenster verwendet und kann nicht gelöscht werden.", + "alert.stairTypeInUse": "Treppentyp „{name}\" wird von einer Treppe verwendet und kann nicht gelöscht werden.", // ── Ressourcen-Manager ─────────────────────────────────────────────────── "resources.title": "Ressourcen", @@ -507,7 +564,38 @@ export const de = { "resources.tab.lines": "Linien", "resources.tab.wallStyles": "Wandstile", "resources.tab.ceilingStyles": "Deckenstile", + "resources.tab.doorStyles": "Türtypen", + "resources.tab.windowStyles": "Fenstertypen", + "resources.tab.stairStyles": "Treppentypen", "resources.tab.materials": "Materialien", + "resources.tab.overrides": "Overrides", + + // Grafische Overrides (Regel-Engine). + "resources.overrides.hint": + "Regeln übersteuern Farbe/Strichstärke/Linienstil beim Rendern (Elementdaten bleiben unverändert). Reihenfolge = Priorität: pro Feld gewinnt die oberste aktive Regel.", + "resources.overrides.empty": "Noch keine Override-Regeln.", + "resources.overrides.placeholder": "Regel", + "resources.overrides.defaultName": "Neue Regel", + "resources.overrides.delete": "Regel löschen", + "resources.overrides.moveUp": "Regel nach oben (höhere Priorität)", + "resources.overrides.moveDown": "Regel nach unten (tiefere Priorität)", + "resources.overrides.enabled": "Regel aktiv", + "resources.overrides.condition": "Bedingung", + "resources.overrides.condType": "Kriterium", + "resources.overrides.condType.layer": "Ebene (Name/Code)", + "resources.overrides.condType.object": "Objektname", + "resources.overrides.operator": "Operator", + "resources.overrides.op.equals": "ist gleich", + "resources.overrides.op.contains": "enthält", + "resources.overrides.op.startsWith": "beginnt mit", + "resources.overrides.op.notEquals": "ist ungleich", + "resources.overrides.value": "Wert", + "resources.overrides.value.placeholder": "z. B. Wände oder 20", + "resources.overrides.actions": "Aktionen", + "resources.overrides.action.color": "Farbe", + "resources.overrides.action.lineweight": "Strichstärke (mm)", + "resources.overrides.action.linetype": "Linienstil", + "resources.overrides.linetype.none": "— (unverändert)", "resources.wallStyles.hint": "Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.", @@ -517,6 +605,75 @@ export const de = { "resources.ceilingStyles.empty": "Noch keine Deckentypen.", "resources.wallStyles.placeholder": "Wandtyp", "resources.ceilingStyles.placeholder": "Deckentyp", + + // Bauteil-Typen (Tür/Fenster/Treppe) — Formular-Bibliotheken. + "resources.doorTypes.hint": + "Wiederverwendbare Türtypen mit Bauart, Blattausführung und Standardmaßen für neu platzierte Türen.", + "resources.doorTypes.empty": "Noch keine Türtypen.", + "resources.doorTypes.placeholder": "Türtyp", + "resources.windowTypes.hint": + "Wiederverwendbare Fenstertypen mit Öffnungsart, Verglasung und Standardmaßen für neu platzierte Fenster.", + "resources.windowTypes.empty": "Noch keine Fenstertypen.", + "resources.windowTypes.placeholder": "Fenstertyp", + "resources.stairTypes.hint": + "Wiederverwendbare Treppentypen mit Tragart, Stufenausbildung und Geländer für neu platzierte Treppen.", + "resources.stairTypes.empty": "Noch keine Treppentypen.", + "resources.stairTypes.placeholder": "Treppentyp", + "resources.delete.doorType": "Türtyp „{name}\" löschen", + "resources.delete.windowType": "Fenstertyp „{name}\" löschen", + "resources.delete.stairType": "Treppentyp „{name}\" löschen", + + "resources.field.doorKind": "Bauart", + "resources.field.leafCount": "Flügelzahl", + "resources.field.leafStyle": "Blattausführung", + "resources.field.glazingRatio": "Glasanteil", + "resources.field.frameThickness": "Zargenstärke (m)", + "resources.field.frameDepth": "Zargentiefe (m)", + "resources.field.defaultWidth": "Lichtbreite (m)", + "resources.field.defaultHeight": "Lichthöhe (m)", + "resources.field.threshold": "Bodenschwelle", + "resources.field.windowKind": "Öffnungsart", + "resources.field.wingCount": "Flügelzahl", + "resources.field.glazing": "Verglasung", + "resources.field.defaultSillHeight": "Brüstungshöhe (m)", + "resources.field.sillBoard": "Fensterbank", + "resources.field.structure": "Tragart", + "resources.field.closedRisers": "Setzstufen geschlossen", + "resources.field.treadThickness": "Trittstufendicke (m)", + "resources.field.nosing": "Trittkanten-Überstand (m)", + "resources.field.railing": "Geländer", + "resources.field.stairWidth": "Laufbreite (m)", + + "resources.doorKind.dreh": "Drehflügel", + "resources.doorKind.schiebe": "Schiebe", + "resources.doorKind.wandoeffnung": "Wandöffnung", + "resources.leafCount.1": "1-flügelig", + "resources.leafCount.2": "2-flügelig", + "resources.leafStyle.glatt": "Glatt", + "resources.leafStyle.kassette": "Kassette", + "resources.leafStyle.glas": "Glas", + "resources.windowKind.dreh": "Dreh", + "resources.windowKind.kipp": "Kipp", + "resources.windowKind.drehkipp": "Dreh-Kipp", + "resources.windowKind.fest": "Fest", + "resources.windowKind.schiebe": "Schiebe", + "resources.glazing.einfach": "Einfach", + "resources.glazing.zweifach": "Zweifach", + "resources.glazing.dreifach": "Dreifach", + "resources.sillBoard.keine": "Keine", + "resources.sillBoard.innen": "Innen", + "resources.sillBoard.aussen": "Außen", + "resources.sillBoard.beide": "Beide", + "resources.structure.massiv": "Massiv", + "resources.structure.beton": "Betontreppe (Laufplatte)", + "resources.structure.wange": "Wange", + "resources.structure.aufgesattelt": "Aufgesattelt", + "resources.structure.spindel": "Spindel", + "resources.railing.keine": "Keine", + "resources.railing.links": "Links", + "resources.railing.rechts": "Rechts", + "resources.railing.beide": "Beide", + "resources.col.layer": "Schicht", "resources.col.component": "Bauteil", "resources.col.thickness": "Dicke (mm)", @@ -698,6 +855,9 @@ export const de = { "cmd.opening.dir": "Richtung", "cmd.opening.sillField": "Brüstung", "cmd.opening.swingField": "Winkel", + "cmd.column.label": "Stütze", + "cmd.column.place": "Stütze setzen:", + "cmd.column.profile": "Profil", "cmd.stair.label": "Treppe", "cmd.stair.start": "Startpunkt der Treppe:", "cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):", @@ -732,11 +892,23 @@ export const de = { "cmd.arc.center": "Mittelpunkt:", "cmd.arc.start": "Startpunkt (Radius + Startwinkel):", "cmd.arc.end": "Endpunkt (Endwinkel):", + "cmd.text.label": "Text", + "cmd.text.point": "Ankerpunkt:", + "cmd.text.enter": "Text eingeben:", + "cmd.textbox.label": "Textspalte", + "cmd.textbox.point": "Ankerpunkt:", + "cmd.textbox.width": "Spaltenbreite (zweiter Punkt):", + "cmd.textbox.enter": "Text eingeben:", // Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl. "cmd.move.label": "Verschieben", "cmd.move.base": "Basispunkt:", "cmd.move.target": "Zielpunkt:", "cmd.move.empty": "Nichts gewählt (erst Objekte wählen).", + "cmd.rotate.label": "Drehen", + "cmd.measure.label": "Messen", + "cmd.measure.start": "Messen von:", + "cmd.measure.to": "bis (Distanz · Winkel):", + "transform.needSelection": "Erst Objekte wählen, dann drehen.", "cmd.mirror.label": "Spiegeln", "cmd.mirror.first": "Erster Punkt der Spiegelachse:", "cmd.mirror.second": "Zweiter Punkt der Spiegelachse:", @@ -749,6 +921,10 @@ export const de = { "cmd.offset.label": "Versatz", "cmd.offset.pick": "Kurve wählen:", "cmd.offset.side": "Seite oder Distanz:", + "cmd.extrude.label": "Extrudieren", + "cmd.extrude.pick": "Geschlossenes Profil wählen:", + "cmd.extrude.height": "Höhe:", + "cmd.extrude.taper": "Verjüngung (0–1, 1=Spitze):", // Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend. "cmd.trim.label": "Stutzen", "cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):", @@ -863,11 +1039,43 @@ export const de = { "file.exportPdf": "Als PDF exportieren…", "file.exportDxf": "Als DXF exportieren…", "file.exportSchedule": "Bauteilliste (CSV)…", + "file.exportIfc": "IFC exportieren", + "file.exportObj": "OBJ exportieren (3D-Mesh)", + "file.exportStl": "STL exportieren (3D-Mesh)", + "file.export": "Exportieren", + "file.openFailed": "Projekt-Datei konnte nicht gelesen werden.", + + // ── Lock-Konflikt (Projektdatei bereits in anderer Instanz geöffnet) ──── + "lock.title": "Projekt bereits geöffnet", + "lock.message": + "Diese Projektdatei ist bereits in einer anderen Instanz geöffnet.", + "lock.messageInfo": "Geöffnet auf {host} (Prozess {pid}).", + "lock.openReadOnly": "Schreibgeschützt öffnen", + "lock.forceOpen": "Trotzdem erzwingen", + "lock.forceOpenWarning": + "Achtung: Öffnet zum Schreiben, obwohl eine andere Instanz die Datei hält — Änderungen können sich gegenseitig überschreiben.", + "exportSave.title": "Exportieren", + "exportSave.format": "Format", + "exportSave.filename": "Dateiname", + "exportSave.locationNote": "Wird in den Standard-Download-Ordner gespeichert.", + "exportSave.fmt.csv": "Bauteilliste (CSV)", + "exportSave.fmt.ifc": "IFC (BIM)", + "exportSave.fmt.obj": "OBJ (3D-Mesh)", + "exportSave.fmt.stl": "STL (3D-Mesh)", + "about.title": "Über dossier", + "about.version": "Version", + "about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.", + "about.copyright": "© 2026 Karim Gabriele Varano", + "about.license": "Lizenz: AGPL-3.0-or-later", + "about.suite": "Teil der openbureau-Suite", + "about.licenses": "Open-Source-Lizenzen (Drittkomponenten)", + "about.close": "Schließen", // ── Einstellungs-Fenster ────────────────────────────────────────────────── "topbar.settings": "Einstellungen", "topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)", "settings.title": "Einstellungen", + "settings.section.workspace": "Arbeitsumgebung", "settings.section.display": "Darstellung", "settings.marqueeColor": "Auswahlrahmen-Farbe", "settings.marqueeColor.hint": @@ -907,6 +1115,9 @@ export const de = { "ctxImport.src.osmRoads": "OSM-Strassen", "ctxImport.src.osmWater": "OSM-Wasser", "ctxImport.src.osmGreen": "OSM-Grün", + "ctxImport.src.osmParking": "OSM-Parkplätze", + "ctxImport.src.osmRailway": "OSM-Bahn", + "ctxImport.src.osmForest": "OSM-Wald", "ctxImport.cancel": "Abbrechen", "ctxImport.confirm": "Importieren", "ctxImport.importing": "Importiere…", @@ -950,6 +1161,9 @@ export const de = { "roomStamp.showFloorArea": "Bodenfläche anzeigen", "roomStamp.floorAreaPrefix": "Präfix Bodenfläche", "roomStamp.showUsage": "Nutzung anzeigen", + "roomStamp.roundingStep": "Flächen-Rundung", + "roomStamp.roundingStepDefault": "Standard (2 Nachkommastellen)", + "roomStamp.occupancy": "Personenzahl", "text.selectedHint": "Formatierung wirkt auf den ausgewählten Text", // ── Rich-Text-Editor ──────────────────────────────────────────────────────── @@ -958,6 +1172,8 @@ export const de = { "rt.underline": "Unterstrichen", "rt.strike": "Durchgestrichen", "rt.strikethrough": "Durchgestrichen", + "rt.super": "Hochgestellt", + "rt.sub": "Tiefgestellt", "rt.fontSize": "Schriftgrösse", "rt.color": "Farbe", "rt.preset": "Vorlage", @@ -969,6 +1185,148 @@ export const de = { "WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.", "viewport3d.grid.show": "Bodenraster einblenden", "viewport3d.grid.hide": "Bodenraster ausblenden", + "viewport3d.section.label": "Schnittebene", + "viewport3d.section.show": "Schnittebene ein", + "viewport3d.section.hide": "Schnittebene aus", + "viewport3d.grip.vertex": "Wandende ziehen", + "viewport3d.grip.height": "Höhe ziehen", + "viewport3d.grip.move": "Verschieben", + + // ── Element-Baum (Elemente-Panel, ROADMAP §11) ────────────────────────────── + "elements.title": "Elemente", + "elements.search": "Elemente suchen…", + "elements.empty": "Keine Bauteile im Projekt.", + "elements.noMatch": "Keine Treffer.", + "elements.kind.wall": "Wand", + "elements.kind.ceiling": "Decke", + "elements.kind.window": "Fenster", + "elements.kind.opening": "Öffnung", + "elements.kind.stair": "Treppe", + "elements.kind.extrusion": "Extrusion", + + // Ausschnitte / View-Snapshots (DOSSIER A2). + "viewsnap.title": "Ausschnitte", + "viewsnap.saveCurrent": "+ Aktuellen Ausschnitt speichern …", + "viewsnap.savePrompt": "Ausschnitt speichern als:", + "viewsnap.renamePrompt": "Ausschnitt umbenennen:", + "viewsnap.rename": "Umbenennen", + "viewsnap.delete": "Löschen", + "viewsnap.deleteConfirm": "Ausschnitt „{name}\" löschen?", + "viewsnap.empty": "Noch keine Ausschnitte. Ansicht einrichten und speichern.", + "viewsnap.applyHint": "Ausschnitt „{name}\" wiederherstellen", + "viewsnap.add.folder": "Ordner erstellen", + "viewsnap.add.snapshot": "Ausschnitt speichern", + "viewsnap.folder.defaultName": "Neuer Ordner", + "viewsnap.folder.rename": "Ordner umbenennen", + "viewsnap.folder.delete": "Ordner löschen", + "viewsnap.folder.empty": "Leerer Ordner", + "viewsnap.tree.empty": "Noch keine Ordner oder Ausschnitte. Über „+\" anlegen.", + "viewsnap.footer.scale": "Massstab", + "viewsnap.footer.layerCombo": "Ebenenkombi", + "viewsnap.footer.drawingCombo": "Zeichnungskombi", + "viewsnap.footer.overrides": "Overrides", + "viewsnap.footer.none": "—", + + // Layout-Blätter mit Masterlayout (DOSSIER A3). + "layouts.panelTitle": "Mappe", + "layouts.title": "Layouts", + "layouts.new": "Anlegen", + "layouts.namePlaceholder": "Blattname …", + "layouts.defaultName": "Neues Layout", + "layouts.rename": "Umbenennen", + "layouts.delete": "Löschen", + "layouts.renamePrompt": "Umbenennen:", + "layouts.deleteConfirm": "„{name}\" löschen?", + "layouts.openHint": "Layout „{name}\" öffnen", + "layouts.empty": "Noch keine Layouts. Blatt anlegen und öffnen.", + "layouts.paper": "Papierformat", + "layouts.orientation": "Ausrichtung", + "layouts.portrait": "Hoch", + "layouts.landscape": "Quer", + "layouts.border": "Rahmen", + "layouts.masters": "Masterlayouts", + "layouts.newMaster": "+ Master", + "layouts.newMasterPrompt": "Neues Masterlayout:", + "layouts.defaultMasterName": "Master", + "layouts.noMasters": "Noch keine Masterlayouts.", + "layouts.master": "Master", + "layouts.noMaster": "Kein Master", + "layouts.tb.projectName": "Projekt", + "layouts.tb.sheetName": "Blattname", + "layouts.tb.scale": "Massstab", + "layouts.tb.date": "Datum", + "layouts.tb.author": "Bearbeiter", + "layouts.tb.autoPlaceholder": "(automatisch)", + // Editor + "layouts.editorTitle": "Layout-Editor", + "layouts.fit": "Einpassen", + "layouts.addViewport": "Viewport hinzufügen", + "layouts.pickSnapshot": "Ausschnitt wählen …", + "layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.", + "layouts.viewportProps": "Viewport-Eigenschaften", + "layouts.noViewports": "Noch keine Viewports. Ausschnitt wählen und hinzufügen.", + "layouts.selectViewport": "Viewport auf dem Blatt anklicken.", + "layouts.missingSnapshot": "Ausschnitt fehlt", + "layouts.xMm": "X (mm)", + "layouts.yMm": "Y (mm)", + "layouts.widthMm": "Breite (mm)", + "layouts.heightMm": "Höhe (mm)", + "layouts.scale": "Massstab", + "layouts.scaleReset": "Massstab vom Ausschnitt", + "layouts.removeViewport": "Viewport entfernen", + "layouts.backToModel": "Zurück zum Modell", + "layouts.placeViewport": "Viewport aufziehen", + "layouts.placeHint": "Rechteck auf dem Blatt aufziehen, dann Ausschnitt binden.", + "layouts.bindSnapshot": "Ausschnitt binden", + "layouts.emptyViewports": + "Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.", + // Annotationen (Linie/Rechteck/Text) direkt auf dem Blatt + "layouts.annotLine": "Linie zeichnen", + "layouts.annotRect": "Rechteck zeichnen", + "layouts.annotText": "Text setzen", + "layouts.annotTextTitle": "Text", + "layouts.annotTextLabel": "Textinhalt:", + "layouts.annotEditText": "Text ändern", + "layouts.annotColor": "Farbe", + "layouts.annotWeight": "Strichstärke (mm)", + "layouts.annotRemove": "Annotation entfernen", + "layouts.annot.line": "Linie", + "layouts.annot.rect": "Rechteck", + "layouts.annot.text": "Text", + // Baum / „+"-Menü / Ordner + "layouts.add": "Neu …", + "layouts.addFolder": "Ordner erstellen", + "layouts.addLayout": "Layout erstellen", + "layouts.addMaster": "Masterlayout erstellen", + "layouts.add.folder": "Ordner erstellen", + "layouts.add.layout": "Layout erstellen", + "layouts.add.master": "Masterlayout erstellen", + "layouts.folder.defaultName": "Neuer Ordner", + "layouts.folder.rename": "Ordner umbenennen", + "layouts.folder.delete": "Ordner löschen", + "layouts.folder.export": "Ordner als PDF exportieren", + "layouts.folder.empty": "Leerer Ordner", + "layouts.folder.exportEmpty": "Ordner enthält keine Layouts.", + "layouts.tree.empty": "Noch keine Ordner oder Layouts. Über „+\" anlegen.", + // Dialog „Neues Layout" + "layouts.newLayout.title": "Neues Layout", + "layouts.newLayout.source": "Vorlage", + "layouts.newLayout.fromMaster": "Masterlayout als Vorlage", + "layouts.newLayout.freeSize": "Freie Grösse", + "layouts.newLayout.pickMaster": "Masterlayout wählen …", + "layouts.newLayout.noMasters": "Keine Masterlayouts vorhanden.", + // Dialog „Neues Masterlayout" + "layouts.newMaster.title": "Neues Masterlayout", + // Gemeinsame Grössen-Auswahl (beide Dialoge) + "layouts.size.format": "Format", + "layouts.size.custom": "Freie Grösse", + "layouts.size.a4": "A4", + "layouts.size.a3": "A3", + "layouts.paperGroup.a": "A-Reihe", + "layouts.paperGroup.b": "B-Reihe", + "layouts.size.widthMm": "Breite (mm)", + "layouts.size.heightMm": "Höhe (mm)", + "layouts.size.create": "Erstellen", } as const; export type TranslationKey = keyof typeof de; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6b40492..d1eeaff 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -16,14 +16,26 @@ export const en: Record = { "view3d.group": "3D view", "view3d.front": "Front", "view3d.front.hint": "Front view (frontal)", + "view3d.back": "Back", + "view3d.back.hint": "Back view (rear)", "view3d.top": "Top", "view3d.top.hint": "Top view (straight down)", - "view3d.side": "Side", - "view3d.side.hint": "Side view (90° to front)", + "view3d.side": "Right", + "view3d.side.hint": "Right side view (90° to the front)", + "view3d.left": "Left", + "view3d.left.hint": "Left side view (90° to the front, opposite Right)", "view3d.perspective": "Perspective", "view3d.perspective.hint": "Free perspective (3/4 view)", "view3d.iso": "Isometric", "view3d.iso.hint": "Isometric view (equiangular from front-top-right)", + "view3d.isoFrontLeft": "Front Left", + "view3d.isoFrontLeft.hint": "Isometric view (equiangular from front-top-left)", + "view3d.isoBackRight": "Back Right", + "view3d.isoBackRight.hint": "Isometric view (equiangular from back-top-right)", + "view3d.isoBackLeft": "Back Left", + "view3d.isoBackLeft.hint": "Isometric view (equiangular from back-top-left)", + "view3d.iso.menu": "More isometric views", + "view3d.iso.menu.hint": "Pick one of the four upper isometric octants", "view3d.camera": "Camera", "view3d.camera.hint": "Camera settings (field of view)", "view3d.fov": "Field of view", @@ -85,12 +97,12 @@ export const en: Record = { "topbar.resources.hint": "Show/hide the resources window", // Layout menu. - "layout.title": "Layout", - "layout.placeholder": "Layout …", + "layout.title": "Workspace", + "layout.placeholder": "Workspace …", "layout.saveCurrent": "+ Save current …", "layout.loadGroup": "Load", "layout.deleteGroup": "Delete", - "layout.savePrompt": "Save layout as:", + "layout.savePrompt": "Save workspace as:", // Visibility combinations (topbar). "combo.layers": "Layer comb.", @@ -119,6 +131,10 @@ export const en: Record = { "tool.circle.hint": "Circle: set center, then radius", "tool.arc": "Arc", "tool.arc.hint": "Arc: center, start point, then end point (CCW)", + "tool.text": "Text", + "tool.text.hint": "Text: set anchor point, then type the text", + "tool.textbox": "Text column", + "tool.textbox.hint": "Text column: anchor, drag column width, then type the text", // Ribbon top bar (tabs + groups) "ribbon.tab.2d": "2D", "ribbon.tab.3d": "3D", @@ -233,6 +249,7 @@ export const en: Record = { "attr.fill": "Fill", "attr.fillColor": "Fill color", "attr.clearFill": "Remove fill color", + "attr.pickColor": "Choose color", "attr.foreground": "Foreground", "attr.background": "Background", "attr.bySystem": "By system", @@ -265,12 +282,29 @@ export const en: Record = { "objinfo.kind.opening": "Opening", "objinfo.kind.stair": "Stair", "objinfo.kind.room": "Room", + "objinfo.kind.extrudedSolid": "Extrusion", + "objinfo.kind.column": "Column", + // ── Column attributes (object info panel) ──────────────────────────────── + "objinfo.column.section": "Column", + "objinfo.column.profile": "Profile", + "objinfo.column.profile.rect": "Rectangle", + "objinfo.column.profile.round": "Round", + "objinfo.column.width": "Width", + "objinfo.column.depth": "Depth", + "objinfo.column.radius": "Radius", + "objinfo.column.height": "Height", + "objinfo.column.rotation": "Rotation (°)", + "objinfo.column.floor": "Floor", // ── Wall attributes (object info panel) ────────────────────────────────── "objinfo.wall.section": "Wall", "objinfo.wall.refLine": "Reference line", "objinfo.wall.refLine.left": "Exterior (left)", "objinfo.wall.refLine.center": "Center", "objinfo.wall.refLine.right": "Interior (right)", + "objinfo.wall.sliceTermination": "End at ceiling", + "objinfo.wall.sliceTermination.both": "Off", + "objinfo.wall.sliceTermination.below": "Below", + "objinfo.wall.sliceTermination.above": "Above", "objinfo.wall.buildup": "Build-up", "objinfo.wall.single": "Solid", "objinfo.wall.multi": "Multi layer", @@ -283,6 +317,11 @@ export const en: Record = { "objinfo.wall.top": "Top (OK)", "objinfo.wall.anchorFloor": "Bound", "objinfo.wall.anchorCustom": "Custom", + "objinfo.anchor.refFloor": "Reference storey", + "objinfo.anchor.customHeight": "Custom height", + "objinfo.wall.length": "Length", + "objinfo.wall.volumeGross": "Gross", + "objinfo.volume": "Volume", "objinfo.wall.height": "Height", "objinfo.wall.floorAboveNone": "No floor above", // ── Ceiling attributes (object info panel) ─────────────────────────────── @@ -299,6 +338,9 @@ export const en: Record = { "objinfo.ceiling.anchorCustom": "Custom", // ── Opening attributes (object info panel) ────────────────────────────── "objinfo.opening.section": "Opening", + "objinfo.opening.type": "Type", + "objinfo.stair.type": "Type", + "objinfo.type.none": "(no type)", "objinfo.opening.kind": "Type", "objinfo.opening.kind.window": "Window", "objinfo.opening.kind.door": "Door", @@ -356,6 +398,15 @@ export const en: Record = { "objinfo.room.perimeter": "Perimeter", "objinfo.room.refFloor": "Reference floor", "objinfo.room.editStamp": "Edit stamp …", + // ── Extruded solid attributes (truck integration, object info panel) ──── + "objinfo.extrudedSolid.section": "Extrusion", + "objinfo.extrudedSolid.height": "Height", + "objinfo.extrudedSolid.taper": "Taper", + "objinfo.extrudedSolid.area": "Footprint area", + "objinfo.extrudedSolid.floor": "Floor", + "objinfo.rotate": "Rotate", + "objinfo.drawing.length": "Length", + "objinfo.drawing.radius": "Radius", "levels.addFloor": "+ Floor", "levels.addSection": "+ Section", "levels.addElevation": "+ Elevation", @@ -484,6 +535,9 @@ export const en: Record = { "default.ceilingTypeSingle": "Ceiling {thickness} m", "default.wallTypeName": "New wall type", "default.ceilingTypeName": "New ceiling type", + "default.doorTypeName": "New door type", + "default.windowTypeName": "New window type", + "default.stairTypeName": "New stair type", // Alerts. "alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.", @@ -492,6 +546,9 @@ export const en: Record = { "alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.", "alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.", "alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.", + "alert.doorTypeInUse": "Door type “{name}” is used by a door and cannot be deleted.", + "alert.windowTypeInUse": "Window type “{name}” is used by a window and cannot be deleted.", + "alert.stairTypeInUse": "Stair type “{name}” is used by a stair and cannot be deleted.", // Resource manager. "resources.title": "Resources", @@ -502,7 +559,38 @@ export const en: Record = { "resources.tab.lines": "Lines", "resources.tab.wallStyles": "Wall styles", "resources.tab.ceilingStyles": "Ceiling styles", + "resources.tab.doorStyles": "Door types", + "resources.tab.windowStyles": "Window types", + "resources.tab.stairStyles": "Stair types", "resources.tab.materials": "Materials", + "resources.tab.overrides": "Overrides", + + // Graphic overrides (rule engine). + "resources.overrides.hint": + "Rules override color/lineweight/line style at render time (element data stays untouched). Order = priority: per field, the topmost active rule wins.", + "resources.overrides.empty": "No override rules yet.", + "resources.overrides.placeholder": "Rule", + "resources.overrides.defaultName": "New rule", + "resources.overrides.delete": "Delete rule", + "resources.overrides.moveUp": "Move rule up (higher priority)", + "resources.overrides.moveDown": "Move rule down (lower priority)", + "resources.overrides.enabled": "Rule active", + "resources.overrides.condition": "Condition", + "resources.overrides.condType": "Criterion", + "resources.overrides.condType.layer": "Layer (name/code)", + "resources.overrides.condType.object": "Object name", + "resources.overrides.operator": "Operator", + "resources.overrides.op.equals": "equals", + "resources.overrides.op.contains": "contains", + "resources.overrides.op.startsWith": "starts with", + "resources.overrides.op.notEquals": "not equal to", + "resources.overrides.value": "Value", + "resources.overrides.value.placeholder": "e.g. Walls or 20", + "resources.overrides.actions": "Actions", + "resources.overrides.action.color": "Color", + "resources.overrides.action.lineweight": "Lineweight (mm)", + "resources.overrides.action.linetype": "Line style", + "resources.overrides.linetype.none": "— (unchanged)", "resources.wallStyles.hint": "Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.", @@ -512,6 +600,75 @@ export const en: Record = { "resources.ceilingStyles.empty": "No ceiling types yet.", "resources.wallStyles.placeholder": "Wall type", "resources.ceilingStyles.placeholder": "Ceiling type", + + // Component types (door/window/stair) — form-based libraries. + "resources.doorTypes.hint": + "Reusable door types with construction, leaf style and default dimensions for newly placed doors.", + "resources.doorTypes.empty": "No door types yet.", + "resources.doorTypes.placeholder": "Door type", + "resources.windowTypes.hint": + "Reusable window types with opening style, glazing and default dimensions for newly placed windows.", + "resources.windowTypes.empty": "No window types yet.", + "resources.windowTypes.placeholder": "Window type", + "resources.stairTypes.hint": + "Reusable stair types with structure, tread style and railing for newly placed stairs.", + "resources.stairTypes.empty": "No stair types yet.", + "resources.stairTypes.placeholder": "Stair type", + "resources.delete.doorType": "Delete door type “{name}”", + "resources.delete.windowType": "Delete window type “{name}”", + "resources.delete.stairType": "Delete stair type “{name}”", + + "resources.field.doorKind": "Construction", + "resources.field.leafCount": "Leaf count", + "resources.field.leafStyle": "Leaf style", + "resources.field.glazingRatio": "Glazing ratio", + "resources.field.frameThickness": "Frame thickness (m)", + "resources.field.frameDepth": "Frame depth (m)", + "resources.field.defaultWidth": "Clear width (m)", + "resources.field.defaultHeight": "Clear height (m)", + "resources.field.threshold": "Threshold", + "resources.field.windowKind": "Opening style", + "resources.field.wingCount": "Wing count", + "resources.field.glazing": "Glazing", + "resources.field.defaultSillHeight": "Sill height (m)", + "resources.field.sillBoard": "Sill board", + "resources.field.structure": "Structure", + "resources.field.closedRisers": "Closed risers", + "resources.field.treadThickness": "Tread thickness (m)", + "resources.field.nosing": "Nosing (m)", + "resources.field.railing": "Railing", + "resources.field.stairWidth": "Run width (m)", + + "resources.doorKind.dreh": "Hinged", + "resources.doorKind.schiebe": "Sliding", + "resources.doorKind.wandoeffnung": "Wall opening", + "resources.leafCount.1": "Single-leaf", + "resources.leafCount.2": "Double-leaf", + "resources.leafStyle.glatt": "Flush", + "resources.leafStyle.kassette": "Panelled", + "resources.leafStyle.glas": "Glazed", + "resources.windowKind.dreh": "Turn", + "resources.windowKind.kipp": "Tilt", + "resources.windowKind.drehkipp": "Turn-tilt", + "resources.windowKind.fest": "Fixed", + "resources.windowKind.schiebe": "Sliding", + "resources.glazing.einfach": "Single", + "resources.glazing.zweifach": "Double", + "resources.glazing.dreifach": "Triple", + "resources.sillBoard.keine": "None", + "resources.sillBoard.innen": "Inside", + "resources.sillBoard.aussen": "Outside", + "resources.sillBoard.beide": "Both", + "resources.structure.massiv": "Solid", + "resources.structure.beton": "Concrete (waist slab)", + "resources.structure.wange": "String", + "resources.structure.aufgesattelt": "Saddled", + "resources.structure.spindel": "Spiral", + "resources.railing.keine": "None", + "resources.railing.links": "Left", + "resources.railing.rechts": "Right", + "resources.railing.beide": "Both", + "resources.col.layer": "Layer", "resources.col.component": "Component", "resources.col.thickness": "Thickness (mm)", @@ -688,6 +845,9 @@ export const en: Record = { "cmd.opening.dir": "Direction", "cmd.opening.sillField": "Sill", "cmd.opening.swingField": "Angle", + "cmd.column.label": "Column", + "cmd.column.place": "Place column:", + "cmd.column.profile": "Profile", "cmd.stair.label": "Stair", "cmd.stair.start": "Start point of the stair:", "cmd.stair.run": "Run direction / end ( Shape · Up/Down ):", @@ -722,10 +882,22 @@ export const en: Record = { "cmd.arc.center": "Center:", "cmd.arc.start": "Start point (radius + start angle):", "cmd.arc.end": "End point (end angle):", + "cmd.text.label": "Text", + "cmd.text.point": "Anchor point:", + "cmd.text.enter": "Enter text:", + "cmd.textbox.label": "Text column", + "cmd.textbox.point": "Anchor point:", + "cmd.textbox.width": "Column width (second point):", + "cmd.textbox.enter": "Enter text:", "cmd.move.label": "Move", "cmd.move.base": "Base point:", "cmd.move.target": "Target point:", "cmd.move.empty": "Nothing selected (select objects first).", + "cmd.rotate.label": "Rotate", + "cmd.measure.label": "Measure", + "cmd.measure.start": "Measure from:", + "cmd.measure.to": "to (distance · angle):", + "transform.needSelection": "Select objects first, then rotate.", "cmd.mirror.label": "Mirror", "cmd.mirror.first": "First point of mirror axis:", "cmd.mirror.second": "Second point of mirror axis:", @@ -738,6 +910,10 @@ export const en: Record = { "cmd.offset.label": "Offset", "cmd.offset.pick": "Select curve:", "cmd.offset.side": "Side or distance:", + "cmd.extrude.label": "Extrude", + "cmd.extrude.pick": "Select closed profile:", + "cmd.extrude.height": "Height:", + "cmd.extrude.taper": "Taper (0–1, 1=point):", // Trim (quick-trim): click the part to cut away; repeats until Esc. "cmd.trim.label": "Trim", "cmd.trim.pick": "Click the part to trim away (Esc to finish):", @@ -852,11 +1028,42 @@ export const en: Record = { "file.exportPdf": "Export as PDF…", "file.exportDxf": "Export as DXF…", "file.exportSchedule": "Component schedule (CSV)…", + "file.exportIfc": "Export IFC", + "file.exportObj": "Export OBJ (3D mesh)", + "file.exportStl": "Export STL (3D mesh)", + "file.export": "Export", + "file.openFailed": "Could not read the project file.", + + // Lock conflict (project file already open in another instance). + "lock.title": "Project already open", + "lock.message": "This project file is already open in another instance.", + "lock.messageInfo": "Opened on {host} (process {pid}).", + "lock.openReadOnly": "Open read-only", + "lock.forceOpen": "Force open anyway", + "lock.forceOpenWarning": + "Warning: opens for writing even though another instance holds the file — changes may overwrite each other.", + "exportSave.title": "Export", + "exportSave.format": "Format", + "exportSave.filename": "File name", + "exportSave.locationNote": "Saved to the default downloads folder.", + "exportSave.fmt.csv": "Component schedule (CSV)", + "exportSave.fmt.ifc": "IFC (BIM)", + "exportSave.fmt.obj": "OBJ (3D mesh)", + "exportSave.fmt.stl": "STL (3D mesh)", + "about.title": "About dossier", + "about.version": "Version", + "about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.", + "about.copyright": "© 2026 Karim Gabriele Varano", + "about.license": "License: AGPL-3.0-or-later", + "about.suite": "Part of the openbureau suite", + "about.licenses": "Open-source licenses (third-party)", + "about.close": "Close", // Settings dialog. "topbar.settings": "Settings", "topbar.settings.hint": "Open settings (display colors, project)", "settings.title": "Settings", + "settings.section.workspace": "Workspace", "settings.section.display": "Display", "settings.marqueeColor": "Selection box color", "settings.marqueeColor.hint": @@ -896,6 +1103,9 @@ export const en: Record = { "ctxImport.src.osmRoads": "OSM roads", "ctxImport.src.osmWater": "OSM water", "ctxImport.src.osmGreen": "OSM greenery", + "ctxImport.src.osmParking": "OSM parking", + "ctxImport.src.osmRailway": "OSM railway", + "ctxImport.src.osmForest": "OSM forest", "ctxImport.cancel": "Cancel", "ctxImport.confirm": "Import", "ctxImport.importing": "Importing…", @@ -940,6 +1150,9 @@ export const en: Record = { "roomStamp.showFloorArea": "Show floor area", "roomStamp.floorAreaPrefix": "Floor area prefix", "roomStamp.showUsage": "Show usage", + "roomStamp.roundingStep": "Area rounding", + "roomStamp.roundingStepDefault": "Default (2 decimal places)", + "roomStamp.occupancy": "Occupancy", "text.selectedHint": "Formatting applies to the selected text", "rt.bold": "Bold", @@ -947,6 +1160,8 @@ export const en: Record = { "rt.underline": "Underline", "rt.strike": "Strikethrough", "rt.strikethrough": "Strikethrough", + "rt.super": "Superscript", + "rt.sub": "Subscript", "rt.fontSize": "Font size", "rt.color": "Color", "rt.preset": "Preset", @@ -958,4 +1173,146 @@ export const en: Record = { "WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.", "viewport3d.grid.show": "Show ground grid", "viewport3d.grid.hide": "Hide ground grid", + "viewport3d.section.label": "Section plane", + "viewport3d.section.show": "Section plane on", + "viewport3d.section.hide": "Section plane off", + "viewport3d.grip.vertex": "Drag wall endpoint", + "viewport3d.grip.height": "Drag height", + "viewport3d.grip.move": "Move", + + // ── Element tree (Elements panel, ROADMAP §11) ────────────────────────────── + "elements.title": "Elements", + "elements.search": "Search elements…", + "elements.empty": "No components in the project.", + "elements.noMatch": "No matches.", + "elements.kind.wall": "Wall", + "elements.kind.ceiling": "Ceiling", + "elements.kind.window": "Window", + "elements.kind.opening": "Opening", + "elements.kind.stair": "Stair", + "elements.kind.extrusion": "Extrusion", + + // Views / view snapshots (DOSSIER A2). + "viewsnap.title": "Views", + "viewsnap.saveCurrent": "+ Save current view …", + "viewsnap.savePrompt": "Save view as:", + "viewsnap.renamePrompt": "Rename view:", + "viewsnap.rename": "Rename", + "viewsnap.delete": "Delete", + "viewsnap.deleteConfirm": "Delete view “{name}”?", + "viewsnap.empty": "No views yet. Set up a view and save it.", + "viewsnap.applyHint": "Restore view “{name}”", + "viewsnap.add.folder": "Create folder", + "viewsnap.add.snapshot": "Save view", + "viewsnap.folder.defaultName": "New folder", + "viewsnap.folder.rename": "Rename folder", + "viewsnap.folder.delete": "Delete folder", + "viewsnap.folder.empty": "Empty folder", + "viewsnap.tree.empty": "No folders or views yet. Add via “+”.", + "viewsnap.footer.scale": "Scale", + "viewsnap.footer.layerCombo": "Layer combo", + "viewsnap.footer.drawingCombo": "Drawing combo", + "viewsnap.footer.overrides": "Overrides", + "viewsnap.footer.none": "—", + + // Layout sheets with master layout (DOSSIER A3). + "layouts.panelTitle": "Portfolio", + "layouts.title": "Layouts", + "layouts.new": "Create", + "layouts.namePlaceholder": "Sheet name …", + "layouts.defaultName": "New layout", + "layouts.rename": "Rename", + "layouts.delete": "Delete", + "layouts.renamePrompt": "Rename:", + "layouts.deleteConfirm": "Delete “{name}”?", + "layouts.openHint": "Open layout “{name}”", + "layouts.empty": "No layouts yet. Create a sheet and open it.", + "layouts.paper": "Paper size", + "layouts.orientation": "Orientation", + "layouts.portrait": "Portrait", + "layouts.landscape": "Landscape", + "layouts.border": "Border", + "layouts.masters": "Master layouts", + "layouts.newMaster": "+ Master", + "layouts.newMasterPrompt": "New master layout:", + "layouts.defaultMasterName": "Master", + "layouts.noMasters": "No master layouts yet.", + "layouts.master": "Master", + "layouts.noMaster": "No master", + "layouts.tb.projectName": "Project", + "layouts.tb.sheetName": "Sheet name", + "layouts.tb.scale": "Scale", + "layouts.tb.date": "Date", + "layouts.tb.author": "Author", + "layouts.tb.autoPlaceholder": "(automatic)", + // Editor + "layouts.editorTitle": "Layout editor", + "layouts.fit": "Fit", + "layouts.addViewport": "Add viewport", + "layouts.pickSnapshot": "Pick a view …", + "layouts.noSnapshots": "No views available. Save a view first.", + "layouts.viewportProps": "Viewport properties", + "layouts.noViewports": "No viewports yet. Pick a view and add it.", + "layouts.selectViewport": "Click a viewport on the sheet.", + "layouts.missingSnapshot": "View missing", + "layouts.xMm": "X (mm)", + "layouts.yMm": "Y (mm)", + "layouts.widthMm": "Width (mm)", + "layouts.heightMm": "Height (mm)", + "layouts.scale": "Scale", + "layouts.scaleReset": "Scale from view", + "layouts.removeViewport": "Remove viewport", + "layouts.backToModel": "Back to model", + "layouts.placeViewport": "Draw viewport", + "layouts.placeHint": "Drag a rectangle on the sheet, then bind a snapshot.", + "layouts.bindSnapshot": "Bind snapshot", + "layouts.emptyViewports": + "No viewports yet. Choose “Draw viewport” and drag a rectangle on the sheet.", + // Annotations (line/rect/text) drawn directly on the sheet + "layouts.annotLine": "Draw line", + "layouts.annotRect": "Draw rectangle", + "layouts.annotText": "Place text", + "layouts.annotTextTitle": "Text", + "layouts.annotTextLabel": "Text content:", + "layouts.annotEditText": "Edit text", + "layouts.annotColor": "Color", + "layouts.annotWeight": "Line weight (mm)", + "layouts.annotRemove": "Remove annotation", + "layouts.annot.line": "Line", + "layouts.annot.rect": "Rectangle", + "layouts.annot.text": "Text", + // Tree / “+” menu / folders + "layouts.add": "New …", + "layouts.addFolder": "Create folder", + "layouts.addLayout": "Create layout", + "layouts.addMaster": "Create master layout", + "layouts.add.folder": "Create folder", + "layouts.add.layout": "Create layout", + "layouts.add.master": "Create master layout", + "layouts.folder.defaultName": "New folder", + "layouts.folder.rename": "Rename folder", + "layouts.folder.delete": "Delete folder", + "layouts.folder.export": "Export folder as PDF", + "layouts.folder.empty": "Empty folder", + "layouts.folder.exportEmpty": "Folder contains no layouts.", + "layouts.tree.empty": "No folders or layouts yet. Add one via “+”.", + // “New layout” dialog + "layouts.newLayout.title": "New layout", + "layouts.newLayout.source": "Template", + "layouts.newLayout.fromMaster": "Master layout as template", + "layouts.newLayout.freeSize": "Free size", + "layouts.newLayout.pickMaster": "Pick a master layout …", + "layouts.newLayout.noMasters": "No master layouts available.", + // “New master layout” dialog + "layouts.newMaster.title": "New master layout", + // Shared size picker (both dialogs) + "layouts.size.format": "Format", + "layouts.size.custom": "Free size", + "layouts.size.a4": "A4", + "layouts.size.a3": "A3", + "layouts.paperGroup.a": "A series", + "layouts.paperGroup.b": "B series", + "layouts.size.widthMm": "Width (mm)", + "layouts.size.heightMm": "Height (mm)", + "layouts.size.create": "Create", }; diff --git a/src/io/geoContext.ts b/src/io/geoContext.ts index 807d91a..f687eac 100644 --- a/src/io/geoContext.ts +++ b/src/io/geoContext.ts @@ -2,7 +2,7 @@ // // Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in // LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts), -// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in +// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün/Parkplatz/Bahn/Wald). Die UI wandelt diese in // `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`. // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). @@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95"; import type { ContextObject, Contour, ImportedMesh } from "../model/types"; /** Fachliche Kategorie einer importierten Kontext-Linie. */ -export type GeoCategory = "building" | "road" | "water" | "green"; +export type GeoCategory = + | "building" + | "road" + | "water" + | "green" + | "parking" + | "railway" + | "forest"; /** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */ export interface GeoFeature { @@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record = { road: "Strassen", water: "Gewässer", green: "Grünflächen", + parking: "Parkplätze", + railway: "Bahn", + forest: "Wald", }; /** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */ @@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record = { road: "context-roads", water: "context-water", green: "context-green", + parking: "context-parking", + railway: "context-railway", + forest: "context-forest", }; /** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */ diff --git a/src/io/osm.ts b/src/io/osm.ts index 95cd874..46b3741 100644 --- a/src/io/osm.ts +++ b/src/io/osm.ts @@ -1,7 +1,8 @@ // OpenStreetMap-Anbindung (Overpass API) für den Standort-Import. // -// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt -// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe +// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze / +// Bahnlinien / Wald und wandelt sie in Polylinien in LOKALEN Modell-Metern (über +// LV95 + Origin, siehe // lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher // läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern // `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen @@ -24,6 +25,9 @@ export interface OsmSelection { roads: boolean; water: boolean; green: boolean; + parking: boolean; + railway: boolean; + forest: boolean; } /** Ein Overpass-Element mit inline-Geometrie (`out geom`). */ @@ -55,7 +59,12 @@ function buildQuery( } if (sel.green) { parts.push(`way["leisure"="park"](${b});`); - parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`); + parts.push(`way["landuse"~"grass|meadow|recreation_ground"](${b});`); + } + if (sel.parking) parts.push(`way["amenity"="parking"](${b});`); + if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`); + if (sel.forest) { + parts.push(`way["landuse"="forest"](${b});`); parts.push(`way["natural"="wood"](${b});`); } return `[out:json][timeout:40];(${parts.join("")});out geom;`; @@ -69,20 +78,23 @@ function categorize( if (!tags) return null; if (sel.buildings && tags.building) return "building"; if (sel.water && (tags.natural === "water" || tags.waterway)) return "water"; + if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest")) + return "forest"; if ( sel.green && (tags.leisure === "park" || - tags.natural === "wood" || - /grass|forest|meadow|recreation_ground/.test(tags.landuse ?? "")) + /grass|meadow|recreation_ground/.test(tags.landuse ?? "")) ) return "green"; + if (sel.parking && tags.amenity === "parking") return "parking"; + if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway"; if (sel.roads && tags.highway) return "road"; return null; } -/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */ +/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */ function isClosedCategory(cat: GeoCategory): boolean { - return cat !== "road"; + return cat !== "road" && cat !== "railway"; } /** @@ -118,7 +130,15 @@ export async function fetchOsm( originIn?: GeoOrigin, ): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> { const origin = originIn ?? makeOrigin(center); - if (!sel.buildings && !sel.roads && !sel.water && !sel.green) { + if ( + !sel.buildings && + !sel.roads && + !sel.water && + !sel.green && + !sel.parking && + !sel.railway && + !sel.forest + ) { return { origin, features: [] }; } diff --git a/src/io/projectFile.ts b/src/io/projectFile.ts new file mode 100644 index 0000000..1da7fcf --- /dev/null +++ b/src/io/projectFile.ts @@ -0,0 +1,161 @@ +// Eigenes Projektdatei-Format (Endung `.obp`, "openbureau project") + Lock-Datei +// gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei Instanzen. +// +// Der Dateiinhalt ist unverändert das bisherige Projekt-JSON — nur Endung/ +// Dialog-Filter sind neu. Alte `.json`-Projekte bleiben ladbar (Filter erlaubt +// beide, s. PROJECT_DIALOG_FILTERS). +// +// Der Lock ist ein OS-Advisory-Lock im Rust-Kern (src-tauri/src/lock.rs, Crate +// `fs4`): eine Sidecar-Datei `foo.obp.lock` wird exklusiv gelockt, solange die +// Instanz die Datei offen hat. Der OS-Lock fällt beim Prozessende (auch bei +// Absturz) automatisch weg — hier nur "best effort" für sauberes Timing beim +// normalen Schliessen. Nur unter Tauri relevant: im reinen Browser gibt es +// keinen echten Dateipfad (Blob-Download), daher entfällt der Lock dort. + +import { saveTextFile } from "./saveFile"; + +/** Datei-Endung für Projektdateien (ohne Punkt). Einzige Änderungsstelle. */ +export const PROJECT_EXT = "obp"; + +/** Dialog-Filter für den nativen Speichern-/Öffnen-Dialog: neue Endung + Alt-JSON. */ +export const PROJECT_DIALOG_FILTERS = [ + { name: "openbureau-Projekt", extensions: [PROJECT_EXT] }, + { name: "openbureau-Projekt (alt, JSON)", extensions: ["json"] }, +]; + +/** Erkennt Tauri wie an anderen Stellen (saveFile.ts, nativeSync.ts). */ +export function isTauriRuntime(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +/** Ergebnis eines erfolgreichen nativen Öffnen-Vorgangs. */ +export interface OpenedProjectFile { + path: string; + content: string; +} + +/** + * Öffnet eine Projektdatei über den NATIVEN Öffnen-Dialog (Filter: `.obp` + + * `.json`). Nur unter Tauri sinnvoll — liefert `null` im Browser, dort greift + * weiterhin der bestehende ``-Fluss (FileReader). + * + * @returns `null` bei Abbruch/ohne Tauri, sonst Pfad + Textinhalt. + */ +export async function openProjectFileNative(): Promise { + if (!isTauriRuntime()) return null; + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + multiple: false, + directory: false, + filters: PROJECT_DIALOG_FILTERS, + }); + if (!selected || Array.isArray(selected)) return null; // Abbruch (oder Mehrfachauswahl, sollte hier nicht vorkommen) + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + const content = await readTextFile(selected); + return { path: selected, content }; +} + +/** + * Speichert das Projekt-JSON in eine `.obp`-Datei. Unter Tauri nativer + * Speichern-Dialog mit Projekt-Filter, im Browser Blob-Download — beides via + * {@link saveTextFile} (dort steckt die WKWebView-Härtung). + * + * @param json Fertig serialisiertes Projekt (JSON.stringify des ProjectSlice). + * @param projectName `project.name` für den Standard-Dateinamen (Fallback "projekt"). + */ +export async function saveProjectFile(json: string, projectName: string): Promise { + const filename = `${projectName || "projekt"}.${PROJECT_EXT}`; + return saveTextFile(json, filename, "application/json", PROJECT_DIALOG_FILTERS); +} + +// ── Lock gegen Doppelöffnung (nur Tauri) ──────────────────────────────────── + +/** Info-Block aus der Lock-Sidecar-Datei — für die Konflikt-Meldung an den Nutzer. */ +export interface LockInfo { + pid: number; + hostname: string; + timestamp: string; +} + +export type LockResult = { ok: true } | { ok: false; info: LockInfo | null }; + +/** + * Holt (ungeguardet) die Tauri-`invoke`-Funktion, oder `null` ohne Tauri-Core. + * Anders als das Muster in compute/index.ts bzw. plan/nativeSync.ts schluckt + * dieser Helfer KEINE Fehler aus dem eigentlichen Aufruf — ein `Err(...)` vom + * Rust-Command (hier: „schon gelockt", inkl. {@link LockInfo}-Payload) muss bis + * zum Aufrufer durchgereicht werden. + */ +async function tauriInvokeFn(): Promise< + ((cmd: string, args?: Record) => Promise) | null +> { + try { + // @ts-ignore optionales Paket + const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null); + if (!mod || typeof (mod as any).invoke !== "function") return null; + return (mod as any).invoke; + } catch { + return null; + } +} + +/** + * Fordert den exklusiven Lock für `path` an (Rust-Command `acquire_project_lock`, + * legt `path + ".lock"` an und hält einen fs4-OS-Lock darauf). Im Browser (kein + * Tauri) sofort `{ok:true}` — dort gibt es keinen echten Pfad, also keinen Lock. + */ +export async function acquireProjectLock(path: string): Promise { + if (!isTauriRuntime()) return { ok: true }; + const invoke = await tauriInvokeFn(); + if (!invoke) return { ok: true }; // Tauri-Core nicht verfügbar -> nicht blockieren + try { + await invoke("acquire_project_lock", { path }); + return { ok: true }; + } catch (err) { + return { ok: false, info: isLockInfo(err) ? err : null }; + } +} + +/** Gibt den Lock für `path` frei (Rust-Command `release_project_lock`). No-op ohne Tauri. */ +export async function releaseProjectLock(path: string): Promise { + if (!isTauriRuntime()) return; + const invoke = await tauriInvokeFn(); + if (!invoke) return; + try { + await invoke("release_project_lock", { path }); + } catch { + // Freigabe ist best effort — der OS-Lock fällt beim Prozessende ohnehin weg. + } +} + +function isLockInfo(v: unknown): v is LockInfo { + return ( + typeof v === "object" && + v !== null && + typeof (v as LockInfo).pid === "number" && + typeof (v as LockInfo).hostname === "string" + ); +} + +/** + * Best-effort Freigabe beim Schliessen des Fensters (sauberes Timing): der + * OS-Lock fällt beim Prozessende/Absturz ohnehin automatisch weg (fs4-Handle + * schliesst mit dem Prozess) — dieser Hook sorgt nur dafür, dass ein normales + * Schliessen die Lock-Datei nicht unnötig bis zum nächsten GC/Poll blockiert. + * No-op ohne Tauri. + */ +export function registerLockAutoRelease(getPath: () => string | null): void { + if (!isTauriRuntime()) return; + void (async () => { + try { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const win = getCurrentWindow(); + await win.onCloseRequested(() => { + const path = getPath(); + if (path) void releaseProjectLock(path); + }); + } catch { + // Tauri-window-API nicht verfügbar -> ignorieren, OS-Lock greift ohnehin. + } + })(); +} diff --git a/src/io/saveFile.test.ts b/src/io/saveFile.test.ts new file mode 100644 index 0000000..7391934 --- /dev/null +++ b/src/io/saveFile.test.ts @@ -0,0 +1,51 @@ +// Tests fuer saveTextFile. Vitest laeuft hier im "node"-Env (kein jsdom), daher +// stubben wir eine minimale DOM-/URL-Umgebung, um den Browser-Fallback-Pfad zu +// pruefen — ohne echten Tauri (kein `__TAURI_INTERNALS__`). + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { saveTextFile } from "./saveFile"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +/** Stubt Blob, URL und ein rudimentaeres document fuer den Anchor-Download. */ +function stubBrowserEnv() { + const clicked: string[] = []; + const anchor: Record = { + click: () => clicked.push("click"), + }; + vi.stubGlobal("URL", { + createObjectURL: () => "blob:stub", + revokeObjectURL: () => {}, + }); + vi.stubGlobal("document", { + createElement: () => anchor, + body: { appendChild: () => {}, removeChild: () => {} }, + }); + return { clicked, anchor }; +} + +describe("saveTextFile", () => { + it("nimmt im Nicht-Tauri-Umfeld den Browser-Download und liefert true", async () => { + // Kein __TAURI_INTERNALS__ → Browser-Pfad. + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) vi.stubGlobal("window", {}); + const { clicked, anchor } = stubBrowserEnv(); + + const ok = await saveTextFile("hallo", "modell.txt", "text/plain"); + + expect(ok).toBe(true); + expect(clicked).toContain("click"); + expect(anchor.download).toBe("modell.txt"); + }); + + it("nutzt den Standard-MIME-Typ ohne Angabe, ohne zu crashen", async () => { + const hadWindow = typeof globalThis.window !== "undefined"; + if (!hadWindow) vi.stubGlobal("window", {}); + stubBrowserEnv(); + + await expect(saveTextFile("x", "a.bin")).resolves.toBe(true); + }); +}); diff --git a/src/io/saveFile.ts b/src/io/saveFile.ts new file mode 100644 index 0000000..8d13636 --- /dev/null +++ b/src/io/saveFile.ts @@ -0,0 +1,116 @@ +// Datei-Speicherung mit nativem „Speichern unter"-Dialog im Tauri-Fenster, +// im reinen Browser Fallback auf den klassischen Blob-/Anchor-Download. +// +// Wichtig: Die Tauri-Plugins werden NUR dynamisch importiert. So scheitert der +// reine Browser-Build/vitest nicht an fehlenden Tauri-Modulen und der Code laeuft +// auch ohne Tauri-Laufzeit. + +/** Erkennt, ob wir im Tauri-Webview laufen (dann steht `__TAURI_INTERNALS__`). */ +function isTauri(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +let warnedFallback = false; + +/** Datei-Endungsfilter fuer den nativen Speichern-/Oeffnen-Dialog (Tauri). */ +export interface FileDialogFilter { + name: string; + extensions: string[]; +} + +/** + * Speichert Text-Inhalt in eine Datei. + * - Unter Tauri: nativer Save-Dialog (Ordner + Name) → `writeTextFile`. + * - Im Browser: Blob + Anchor-Download in den Download-Ordner. + * + * @param filters Optionale Datei-Endungsfilter fuer den nativen Dialog (z. B. + * Projekt-Endung). Additiv/optional, damit bestehende Aufrufer ohne + * Filter unveraendert funktionieren (Default-Verhalten: irgendeine Endung). + * @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch (Dialog + * ohne Pfad). Bei einem Tauri-Fehler wird auf den Browser-Fallback + * zurueckgefallen. + */ +export async function saveTextFile( + content: string, + defaultName: string, + mime = "application/octet-stream", + filters?: FileDialogFilter[], +): Promise { + if (isTauri()) { + try { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ defaultPath: defaultName, filters }); + if (!path) return false; // Nutzer hat abgebrochen + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + await writeTextFile(path, content); + return true; + } catch (err) { + if (!warnedFallback) { + warnedFallback = true; + console.warn( + "Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.", + err, + ); + } + // Weiter zum Browser-Fallback unten. + } + } + return browserDownload(content, defaultName, mime); +} + +/** + * Speichert Binaer-Inhalt (z. B. ein PDF) in eine Datei — gleiches Tauri-/ + * Browser-Muster wie {@link saveTextFile}, nur mit `writeFile` (Bytes) statt + * `writeTextFile`. + * - Unter Tauri: nativer Save-Dialog → `writeFile(path, bytes)`. + * - Im Browser: Blob + Anchor-Download. + * + * @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch. + */ +export async function saveBinaryFile( + bytes: Uint8Array, + defaultName: string, + mime = "application/octet-stream", +): Promise { + if (isTauri()) { + try { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ defaultPath: defaultName }); + if (!path) return false; // Nutzer hat abgebrochen + const { writeFile } = await import("@tauri-apps/plugin-fs"); + await writeFile(path, bytes); + return true; + } catch (err) { + if (!warnedFallback) { + warnedFallback = true; + console.warn( + "Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.", + err, + ); + } + // Weiter zum Browser-Fallback unten. + } + } + return browserDownloadBlob(new Blob([bytes as BlobPart], { type: mime }), defaultName); +} + +/** Klassischer Blob-/Anchor-Download (gehaertet wie der bisherige Helfer). */ +function browserDownload(content: string, defaultName: string, mime: string): boolean { + return browserDownloadBlob(new Blob([content], { type: mime }), defaultName); +} + +/** Anchor-Download eines fertigen Blobs (Text wie Binaer). */ +function browserDownloadBlob(blob: Blob, defaultName: string): boolean { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = defaultName; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 4000); + return true; +} diff --git a/src/model/roomStamp.test.ts b/src/model/roomStamp.test.ts index 8f9a7e4..8852316 100644 --- a/src/model/roomStamp.test.ts +++ b/src/model/roomStamp.test.ts @@ -4,6 +4,7 @@ import { roomStampToDoc, roomStampExtraLines, roomDisplayName, + formatStampArea, } from "./roomStamp"; import type { Room } from "./types"; @@ -82,6 +83,54 @@ describe("roomStampExtraLines", () => { ); expect(lines).toHaveLength(0); }); + + it("rundet die Fläche auf roundingStep, falls gesetzt", () => { + const lines = roomStampExtraLines( + { name: "Bad", showFloorArea: true, showUsage: false, roundingStep: 0.5 }, + { netArea: 12.34, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" }, + ); + expect(lines[0].text).toBe("12.5 m²"); + }); + + it("nutzt 2 Nachkommastellen ohne roundingStep (Alt-Verhalten)", () => { + const lines = roomStampExtraLines( + { name: "Bad", showFloorArea: true, showUsage: false }, + { netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" }, + ); + expect(lines[0].text).toBe("12.35 m²"); + }); + + it("zeigt die Personenzahl als eigene Zeile, wenn gesetzt", () => { + const lines = roomStampExtraLines( + { name: "Bad", showFloorArea: false, showUsage: false, occupancy: 4 }, + { netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" }, + ); + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe("4 Pers."); + expect(lines[0].align).toBe("center"); + }); + + it("blendet die Personenzahl-Zeile aus, wenn occupancy fehlt", () => { + const lines = roomStampExtraLines( + { name: "Bad", showFloorArea: false, showUsage: false }, + { netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" }, + ); + expect(lines).toHaveLength(0); + }); +}); + +describe("formatStampArea", () => { + it("2 Nachkommastellen ohne roundingStep", () => { + expect(formatStampArea(12.345)).toBe("12.35"); + expect(formatStampArea(12)).toBe("12.00"); + }); + + it("rundet auf das nächste Vielfache von roundingStep", () => { + expect(formatStampArea(12.24, 0.5)).toBe("12"); + expect(formatStampArea(12.26, 0.5)).toBe("12.5"); + expect(formatStampArea(12.3, 0.1)).toBe("12.3"); + expect(formatStampArea(12.34, 1)).toBe("12"); + }); }); describe("roomDisplayName", () => { diff --git a/src/model/roomStamp.ts b/src/model/roomStamp.ts index b796679..1655e03 100644 --- a/src/model/roomStamp.ts +++ b/src/model/roomStamp.ts @@ -67,17 +67,32 @@ export interface RoomStampLine { align: Align; } +/** + * Formatiert die Bodenfläche für die Stempel-Anzeige: ohne `roundingStep` wie + * bisher auf 2 Nachkommastellen; mit `roundingStep` auf das nächste Vielfache + * davon gerundet (z. B. 0.5 → halbe m²), ohne Nachkommastellen-Rauschen. + */ +export function formatStampArea(area: number, roundingStep?: number): string { + if (roundingStep && roundingStep > 0) { + const rounded = Math.round(area / roundingStep) * roundingStep; + // Rundungsfehler aus der Fliesskomma-Division kappen (max. 2 Nachkommastellen). + return `${Math.round(rounded * 100) / 100}`; + } + return area.toFixed(2); +} + /** * Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea, - * Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. Die Ausrichtung folgt den - * Stempel-Feldern; fehlt sie, gilt „zentriert" (Alt-Verhalten). + * Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage, Personenzahl wenn gesetzt. + * Die Ausrichtung folgt den Stempel-Feldern; fehlt sie, gilt „zentriert" + * (Alt-Verhalten). */ export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] { const lines: RoomStampLine[] = []; if (stamp.showFloorArea) { const prefix = stamp.floorAreaPrefix ?? ""; lines.push({ - text: `${prefix}${ctx.netArea.toFixed(2)} m²`, + text: `${prefix}${formatStampArea(ctx.netArea, stamp.roundingStep)} m²`, align: stamp.floorAreaAlign ?? "center", }); } @@ -87,6 +102,12 @@ export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): Ro align: stamp.usageAlign ?? "center", }); } + if (stamp.occupancy != null) { + lines.push({ + text: `${stamp.occupancy} Pers.`, + align: "center", + }); + } return lines; } diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index f2ee033..d3a5270 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -1,5 +1,15 @@ import type { Project } from "./types"; import { recomputeFloorElevations } from "./types"; +import { MATERIAL_LIBRARY } from "../materials/library"; +import { materialFromAsset } from "../materials/runtime"; + +// Bibliotheks-Materialien für die Basis-Components ab Werk (PBR-Texturen, +// s. `src/materials/library.ts`). Zuordnung nur, wo ein Bibliotheks-Asset +// sinnvoll passt — Wärmedämmung und Zementestrich bleiben bewusst ohne +// Zuweisung (kein passendes Asset in der Bibliothek), statt zu raten. +const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!; +const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!; +const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!; // Demo-Haus mit zwei Geschossen: // EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand. @@ -111,14 +121,44 @@ export const sampleProject: Project = { // Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe; // joinPriority höher = läuft am Stoß durch (für spätere T-Stöße). components: [ - { id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }, + { + id: "render-ext", + name: "Aussenputz", + color: "#d8d2c7", + hatchId: "none", + joinPriority: 10, + material: materialFromAsset(PLASTER_ASSET), + }, // Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand). + // Kein passendes Bibliotheks-Asset (Dämmung) → bewusst ohne `material`. { id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 }, - { id: "brick", name: "Backstein", color: "#8c5544", hatchId: "sia-brick", joinPriority: 50 }, - { id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 }, + { + id: "brick", + name: "Backstein", + color: "#8c5544", + hatchId: "sia-brick", + joinPriority: 50, + material: materialFromAsset(BRICK_ASSET), + }, + { + id: "render-int", + name: "Innenputz", + color: "#efece6", + hatchId: "none", + joinPriority: 10, + material: materialFromAsset(PLASTER_ASSET), + }, // Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität). - { id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "sia-concrete", joinPriority: 100 }, + { + id: "concrete", + name: "Beton", + color: "#9aa0a6", + hatchId: "sia-concrete", + joinPriority: 100, + material: materialFromAsset(CONCRETE_ASSET), + }, // Zementestrich — oberste Schicht des mehrschichtigen Deckenaufbaus (Decken-Stile). + // Kein passendes Bibliotheks-Asset (Estrich) → bewusst ohne `material`. { id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 }, ], wallTypes: [ @@ -162,6 +202,80 @@ export const sampleProject: Project = { ], }, ], + // Bauteil-Typen (Bibliothek) — Tür/Fenster/Treppe, analog Wand-/Deckenstile. + // Elemente referenzieren sie per `typeId`; ohne Referenz gilt Inline-Verhalten. + doorTypes: [ + { + id: "door-standard", + name: "Zimmertür 0.9 m", + kind: "dreh", + leafCount: 1, + leafStyle: "glatt", + frameThickness: 0.05, + defaultWidth: 0.9, + defaultHeight: 2.0, + }, + ], + windowTypes: [ + { + id: "window-standard", + name: "Fenster 1.2 × 1.4 m", + kind: "drehkipp", + wingCount: 1, + glazing: "zweifach", + frameThickness: 0.06, + defaultSillHeight: 0.9, + defaultWidth: 1.2, + defaultHeight: 1.4, + }, + ], + stairTypes: [ + { + // Standard: normale Ortbeton-Laufplatte (schräge, offene Untersicht). + id: "stair-standard", + name: "Betontreppe 1.0 m", + structure: "beton", + closedRisers: true, + treadThickness: 0.05, + nosing: 0.03, + railing: "keine", + defaultWidth: 1.0, + }, + { + id: "stair-massiv", + name: "Massivtreppe (auf Sockel)", + structure: "massiv", + closedRisers: true, + treadThickness: 0.04, + nosing: 0.03, + railing: "keine", + defaultWidth: 1.0, + }, + { + // Offene Wangentreppe: schwebende Tritte (unten offen), zum direkten + // Vergleich mit der Massivtreppe — im ObjectInfoPanel der Treppe zuweisen. + id: "stair-wange-holz", + name: "Wangentreppe Holz (offen)", + structure: "wange", + closedRisers: false, + treadThickness: 0.05, + nosing: 0.03, + railing: "keine", + defaultWidth: 1.0, + }, + { + // Aufgesattelte Treppe mit geschlossenen Setzstufen (Tritt + Setzstufe, + // aber unten offen — der Raum darunter bleibt frei). + id: "stair-aufgesattelt", + name: "Aufgesattelt (Setzstufen)", + structure: "aufgesattelt", + closedRisers: true, + treadThickness: 0.05, + nosing: 0.025, + railing: "keine", + defaultWidth: 1.0, + }, + ], // Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung. // baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6. drawingLevels: recomputeFloorElevations([ @@ -257,6 +371,7 @@ export const sampleProject: Project = { hostWallId: "W1", categoryCode: "21", kind: "window", + typeId: "window-standard", position: 3.0, // Startkante 3.0 m → Fenster x 3.0–4.0, rechts von W9 mit Massiv-Abstand width: 1.0, height: 1.2, @@ -268,6 +383,7 @@ export const sampleProject: Project = { hostWallId: "W3", categoryCode: "21", kind: "window", + typeId: "window-standard", // Startkante 0.6 m ab W3-Start (x=5) → Fenster x 3.0–4.4, rechts von W9 (x=2.4) // mit sichtbarer Massiv-Wand dazwischen (die Querwand stösst auf Mauerwerk). position: 0.6, @@ -306,6 +422,7 @@ export const sampleProject: Project = { floorId: "eg", categoryCode: "40", shape: "straight", + typeId: "stair-standard", start: { x: 3.7, y: 0.4 }, dir: { x: 0, y: 1 }, runLength: 3.2, diff --git a/src/model/types.ts b/src/model/types.ts index 90a2de1..8b77a01 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -18,6 +18,10 @@ export type { SiaCategory } from "../geometry/roomArea"; // Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil). import type { RichTextDoc, Align } from "../text/richText"; export type { RichTextDoc } from "../text/richText"; +// Ansichts-Enums für Ausschnitte/View-Snapshots (Ansichtstyp, Kamera-Preset, +// Detailgrad). Rein type-only importiert — zur Laufzeit erased, kein Zyklus +// (TopBar importiert seinerseits nur `Project` als Typ). Wie SiaCategory oben. +import type { DetailLevel, View3d, ViewType } from "../ui/TopBar"; // ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ──────────────────── // Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette: @@ -272,6 +276,166 @@ export interface CeilingType { layers: Layer[]; } +// ── Bauteil-Typen: Tür / Fenster / Treppe ────────────────────────────────── +// Wiederverwendbare Stile (Presets) für Türen, Fenster und Treppen — analog +// `WallType`/`CeilingType` (Bibliothek im Projekt, referenziert per `typeId`). +// Getrennte Typen (kein gemeinsames „OpeningType"), damit türspezifische und +// fensterspezifische Parameter sauber je Gattung wohnen. Ein Element ohne +// `typeId` behält sein heutiges Inline-Verhalten (rückwärtskompatibel). + +// Detailstufe der Bauteil-Darstellung (Grundriss + 3D) nutzt das bestehende +// {@link DetailLevel}-Vokabular ("grob" | "mittel" | "fein", aus ui/TopBar) — +// bewusst KEIN zweites Detail-Enum, damit Ansicht und Bauteil dieselbe Skala +// teilen. Ausgewertet an der Symbol-/Mesh-Erzeugung (Phase 2). + +/** + * Ein Türtyp (Türstil) — wiederverwendbares Preset für {@link Opening} mit + * `kind: "door"`. Bündelt Bauart, Blattausführung und Standardmaße. Werte am + * einzelnen Element (Breite/Höhe/Anschlag) übersteuern die Typ-Defaults. + */ +export interface DoorType { + id: string; + name: string; + /** + * Bauart: normaler Drehflügel, reine Wandöffnung (kein Blatt/Schwenk), + * Schiebetür. Ersetzt/ergänzt das Inline-Feld `Opening.doorType`. + */ + kind: "dreh" | "schiebe" | "wandoeffnung"; + /** Anzahl Türblätter (1 = einflügelig, 2 = zweiflügelig). */ + leafCount: 1 | 2; + /** Blatt-Ausführung (3D/Detail): glatt, Kassette, Glasfüllung. */ + leafStyle: "glatt" | "kassette" | "glas"; + /** Glasanteil des Blatts (0..1); nur bei `leafStyle: "glas"` relevant. */ + glazingRatio?: number; + /** Zargen-/Rahmenstärke quer zur Wand (Meter). */ + frameThickness: number; + /** Rahmen-/Zargen-Tiefe in Wandrichtung (Meter); fehlt ⇒ aus Wanddicke. */ + frameDepth?: number; + /** Default-Lichtbreite (Meter) für neu platzierte Türen dieses Typs. */ + defaultWidth: number; + /** Default-Lichthöhe (Meter). */ + defaultHeight: number; + /** Bodenschwelle/Anschlag zeichnen (3D/Detail). */ + threshold?: boolean; +} + +/** + * Ein Fenstertyp (Fensterstil) — wiederverwendbares Preset für {@link Opening} + * mit `kind: "window"`. Bündelt Öffnungsart, Flügelzahl, Verglasung und + * Standardmaße inkl. Brüstungshöhe. + */ +export interface WindowType { + id: string; + name: string; + /** Öffnungsart: Dreh, Kipp, Dreh-Kipp, fest verglast, Schiebe. */ + kind: "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe"; + /** Anzahl Flügel (1–4) → Mittelpfosten = wingCount − 1 (vgl. `Opening.wingCount`). */ + wingCount: number; + /** Verglasung: Einfach/Zweifach/Dreifach (3D-Scheibenzahl). */ + glazing: "einfach" | "zweifach" | "dreifach"; + /** Rahmenstärke quer zur Wand (Meter). */ + frameThickness: number; + /** Rahmen-Tiefe in Wandrichtung (Meter); fehlt ⇒ aus Wanddicke. */ + frameDepth?: number; + /** Default-Brüstungshöhe über Wand-UK (Meter). */ + defaultSillHeight: number; + /** Default-Lichtbreite (Meter). */ + defaultWidth: number; + /** Default-Lichthöhe (Meter). */ + defaultHeight: number; + /** Fensterbank zeichnen (Detail): keine / innen / aussen / beide. */ + sillBoard?: "keine" | "innen" | "aussen" | "beide"; +} + +/** + * Ein Treppentyp (Treppenstil) — wiederverwendbares Preset für {@link Stair}. + * Bündelt Tragart, Stufenausbildung und Geländer. Geometrische Grundform + * (gerade/L/Wendel) + Lauflänge/Stufenzahl bleiben am einzelnen Element. + */ +export interface StairType { + id: string; + name: string; + /** + * Tragart: + * • "massiv" — Vollblock bis zur Treppen-UK (Treppe auf Erdreich/Sockel). + * • "beton" — Ortbeton-Laufplatte mit SCHRÄGER, offener Untersicht + * (die klassische „normale Betontreppe" zwischen zwei Geschossen). + * • "wange" — Wangentreppe (schwebende Tritte, offen). + * • "aufgesattelt" — aufgesattelte Tritte (mit Setzstufen, offen). + * • "spindel" — Spindel-/Wendeltreppe (offen). + */ + structure: "massiv" | "beton" | "wange" | "aufgesattelt" | "spindel"; + /** Setzstufen geschlossen (true) oder offen (false, durchsichtige Tritte). */ + closedRisers: boolean; + /** Trittstufendicke (Meter). */ + treadThickness: number; + /** Trittkanten-Überstand / Nase (Meter); fehlt ⇒ 0. */ + nosing?: number; + /** Handlauf/Geländer: keine / links / rechts / beide (in Laufrichtung). */ + railing?: "keine" | "links" | "rechts" | "beide"; + /** Default-Laufbreite (Meter) für neu platzierte Treppen dieses Typs. */ + defaultWidth: number; +} + +// ── Grafische Overrides (Regel-Engine) ───────────────────────────────────── +// ArchiCAD-/Vectorworks-Stil: benannte Regeln `condition → actions`, die beim +// RENDERN als oberste Schicht über die By-Layer/By-Object-Attribut-Auflösung +// gelegt werden (reines Rendering-Overlay — die Elementdaten bleiben +// unverändert, jederzeit reversibel). Regeln werden additiv angewendet: pro +// Aktions-Feld gewinnt die OBERSTE (erste) aktive Regel, die es setzt. +// Auswertung: `src/overrides/engine.ts`; Einhängepunkt: `plan/generatePlan.ts`. + +/** + * Bedingungs-Typ einer Override-Regel — wogegen der Wert verglichen wird: + * • "layer_name" — Name ODER Code der Ebene (LayerCategory) des Elements. + * • "object_name" — Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name, + * Öffnungs-Label, Drawing2D-Formname). + * Ein `user_string`-Tag (DOSSIER-Rhino) existiert am Element noch nicht und + * ist deshalb bewusst NICHT enthalten (kein Schein-Feature). + */ +export type OverrideConditionType = "layer_name" | "object_name"; + +/** Vergleichs-Operator einer Override-Bedingung (case-insensitiv). */ +export type OverrideOperator = + | "equals" + | "contains" + | "starts_with" + | "not_equals"; + +/** Bedingung einer Override-Regel. */ +export interface OverrideCondition { + type: OverrideConditionType; + operator: OverrideOperator; + /** Vergleichswert (Freitext, case-insensitiv verglichen). */ + value: string; +} + +/** + * Aktionen einer Override-Regel — jede optional (Teilaktionen sind erlaubt; + * fehlende Felder lassen die reguläre Auflösung unberührt). + */ +export interface OverrideActions { + /** Strich-/Umrandungsfarbe (hex). */ + color?: string; + /** Strichstärke in mm Papier. */ + lineweight?: number; + /** Linienstil (Line Manager) — wirkt, wo Elemente einen LineStyle tragen (Drawing2D). */ + linetypeId?: string; +} + +/** + * Eine grafische Override-Regel. Lebt in `Project.overrideRules` (Reihenfolge + * = Priorität, oben gewinnt) und ist einzeln aktivier-/deaktivierbar. + */ +export interface OverrideRule { + id: string; + name: string; + /** Deaktivierte Regeln werden bei der Auswertung übersprungen. */ + enabled: boolean; + condition: OverrideCondition; + actions: OverrideActions; +} + // ── Parametrische Wände (Parametric Walls) ──────────────────────────────── // Parametrische Wand-Regeln generieren automatisch Wall[]-Arrays — analog zu // FreeCAD BIM. Sie leben in Project.resources.parametricWalls[] und werden @@ -619,8 +783,26 @@ export interface Wall { * Z-Wert; „floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss. */ top?: VerticalAnchor; + /** + * TERMINIERUNGS-Regel am horizontalen Deckenanschluss (Zuschnitt, NICHT + * Priorität — bewusst getrennt von `Component.joinPriority`/der Schnitt- + * Boolean-Dominanz gehalten). Steuert, ob ein Wandband, das eine dominante + * Deckenschicht durchstösst, oberhalb der Decke „wieder auftaucht": + * • `undefined`/"both" — heutiges Verhalten: die dominante Deckenschicht + * stanzt nur ihr z-Band aus, das Wandband bleibt oben UND unten erhalten. + * • "below" — die Wand ENDET an der Decke: nur der Teil UNTER dem höchst- + * gelegenen dominanten Cut bleibt (Regelfall, Wand steigt von unten in die + * Decke). Der oberhalb der Decke stehende Rest wird verworfen. + * • "above" — spiegelbildlich: nur der Teil ÜBER dem tiefsten dominanten Cut + * bleibt (Brüstung/Attika, die von oben an die Decke stösst). + * Additiv; Alt-Projekte laden unverändert (Default = "both"). + */ + sliceTermination?: SliceTermination; } +/** Terminierungs-Regel einer Wand am Deckenanschluss (siehe {@link Wall.sliceTermination}). */ +export type SliceTermination = "both" | "below" | "above"; + /** * Eine Decke (Slab) — ein geschossgebundenes, mehrschichtiges Flächenbauteil, * definiert über einen GESCHLOSSENEN Umriss (Polygon) im Grundriss und eine @@ -631,7 +813,9 @@ export interface Wall { * Vertikale Lage: die OBERKANTE (OK) der Decke. Fehlt `top`, liegt die OK an der * Oberkante des Geschosses (baseElevation + floorHeight — also bündig mit dem * Wandkopf); die Decke wächst um `thickness` nach UNTEN. `top: custom` setzt eine - * absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss. + * absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss. Optional übersteuert + * `bottom` die UNTERKANTE (UK) direkt (analog zur Wand); fehlt `bottom`, bleibt es + * beim heutigen Verhalten UK = OK − `thickness`. */ export interface Ceiling { id: string; @@ -699,6 +883,12 @@ export interface Ceiling { * Oberkante des Geschosses (baseElevation + floorHeight). */ top?: VerticalAnchor; + /** + * Vertikale Bindung der UNTERKANTE (UK). Fehlt sie, ergibt sich die UK aus + * OK − `thickness` (= heutiges Verhalten). „custom" setzt einen absoluten + * Z-Wert; „floor" bindet die UK an ein Geschoss. + */ + bottom?: VerticalAnchor; } /** Schwenkrichtung der Tür relativ zur Wandachse. */ @@ -772,6 +962,18 @@ export interface Opening { * "beide" → beide Linien (Default wenn nicht gesetzt) */ lintelLines?: "keine" | "innen" | "aussen" | "beide"; + /** + * Referenz auf einen Bauteil-Typ (Bibliothek): {@link DoorType} bei + * `kind: "door"`, {@link WindowType} bei `kind: "window"`. Fehlt sie, gilt + * das heutige Inline-Verhalten (rückwärtskompatibel). Typ-Defaults liefern + * Bauart/Rahmen/Verglasung; Element-Felder (Breite/Höhe/…) übersteuern. + */ + typeId?: string; + /** + * Optionale per-Element-Übersteuerung der Detailstufe (grob/normal/ + * detailliert); fehlt sie, gilt die Ansichts-/Projekt-Detailstufe. + */ + detailLevel?: DetailLevel; /** * Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die * Kategorie-Farbe. @@ -862,6 +1064,17 @@ export interface Stair { * `dir` (Default). false kehrt Auf-/Abpfeil um (Treppe steigt zum Start hin). */ up?: boolean; + /** + * Referenz auf einen {@link StairType} (Bibliothek). Fehlt sie, gilt das + * heutige Inline-Verhalten (rückwärtskompatibel). Typ-Defaults liefern + * Tragart/Stufenausbildung/Geländer; Element-Felder übersteuern. + */ + typeId?: string; + /** + * Optionale per-Element-Übersteuerung der Detailstufe (grob/normal/ + * detailliert); fehlt sie, gilt die Ansichts-/Projekt-Detailstufe. + */ + detailLevel?: DetailLevel; /** * Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die * Kategorie-Farbe. @@ -948,6 +1161,17 @@ export interface RoomStamp { floorAreaAlign?: Align; /** Ausrichtung der Nutzungs-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */ usageAlign?: Align; + /** + * Personenzahl (Live-Zeile), z. B. für Nutzungsauflagen. Fehlt/undefined: + * keine Personenzahl-Zeile (Alt-Verhalten). + */ + occupancy?: number; + /** + * Rundungsschritt der angezeigten Bodenfläche in m² (z. B. 0.5 → auf halbe + * m² gerundet). Fehlt er, gilt das Alt-Verhalten: 2 Nachkommastellen ohne + * Schrittrundung. + */ + roundingStep?: number; } /** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */ @@ -980,7 +1204,19 @@ export type Drawing2DGeom = | { shape: "rect"; min: Vec2; max: Vec2 } | { shape: "circle"; center: Vec2; r: number } | { shape: "arc"; center: Vec2; r: number; a0: number; a1: number } - | { shape: "text"; at: Vec2; text: string; height: number; angle: number }; + | { + shape: "text"; + at: Vec2; + text: string; + height: number; + angle: number; + /** + * Optionale Spaltenbreite in Metern (Textspalte/Absatztext). Ist sie gesetzt, + * wird der Text beim Rendern wortweise auf diese Breite umgebrochen; fehlt + * sie, bleibt es einzeiliger Text (heutiges Verhalten). + */ + width?: number; + }; /** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */ export interface Drawing2D { @@ -1067,6 +1303,96 @@ export interface EdgeGrip { export type Element = Wall | Ceiling | Opening | Door | Stair | Room | Drawing2D; +// ── Extrudierter Körper (truck-Integration, docs/design/truck-plan.md) ───── +// Ein per `extrude`-Befehl aus einem geschlossenen 2D-Profil (Polylinie-Ring/ +// Rechteck) erzeugter 3D-Körper. Die eigentliche B-Rep-Extrusion (truck-WASM, +// `engine/truckSolid.ts`) läuft NICHT hier, sondern asynchron in +// `toWalls3d.ts` (emitExtrudedSolids) — dieser Typ hält nur die Rohdaten. + +/** Ein extrudierter Körper: geschlossenes Profil (Modell-Meter) + Höhe. */ +export interface ExtrudedSolid { + id: string; + type: "extrudedSolid"; + /** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */ + levelId: string; + /** + * Geschlossenes Profil in der XY-Ebene (Grundriss), ≥3 Punkte. Bei einem + * Kreis-Profil (`circle` gesetzt) eine Tessellierung des Kreises (48-Eck) — + * für 2D-Footprint/Auswahl/bbox/Verschieben; die 3D-Extrusion nutzt in dem + * Fall stattdessen `circle` (echte runde truck-Extrusion, `extrudeCircle`). + */ + points: Vec2[]; + /** Extrusionshöhe in Metern (> 0), nach +Z ab der Geschoss-UK. */ + height: number; + /** + * Gesetzt, wenn das Profil ein Kreis ist (aus einem `Drawing2D` mit + * `shape:"circle"`) — `points` bleibt die Tessellierung, `toWalls3d.ts` + * (emitExtrudedSolids) nutzt `circle` für die runde 3D-Extrusion. + */ + circle?: { center: Vec2; r: number }; + /** + * Verjüngung 0 (Prisma, Default/fehlend) … 1 (Spitze — Kegel bei Kreis-, + * Pyramide bei Polygon-Profil). Linear zum Profil-Schwerpunkt skaliert. + */ + taper?: number; +} + +// ── Stütze (Column, Tragwerk) ────────────────────────────────────────────── +// Eine Stütze ist im Kern eine PLATZIERTE PROFIL-EXTRUSION: ein an `position` +// gesetztes 2D-Profil (Rechteck oder Kreis), um `rotation` gedreht, über `height` +// nach oben extrudiert. Anders als der generische `ExtrudedSolid` trägt sie +// vollständige BIM-Attribute (Geschoss, Kategorie, optionales Bauteil, UK/OK- +// Anker) — sie lebt daher in `Project.columns` mit eigenem Selektionskanal. + +/** + * Querschnitt einer Stütze im Grundriss (Modell-Meter), lokal um `position`. + * • "rect" — Rechteck `width` (X, quer) × `depth` (Y, längs), vor Rotation. + * • "round" — Kreis mit `radius`. + */ +export type ColumnProfile = + | { kind: "rect"; width: number; depth: number } + | { kind: "round"; radius: number }; + +/** + * Eine Stütze (Column) — ein geschossgebundenes Tragwerk-Bauteil (Kategorie + * „50"), definiert über einen Einfügepunkt `position`, ein Profil, eine Drehung + * und eine Höhe. Vertikale Lage analog Wand (UK/OK-Anker, sonst Geschoss-UK + + * `height`). + */ +export interface Column { + id: string; + type: "column"; + /** Zugehörige Zeichnungsebene (Geschoss). */ + floorId: string; + /** Grafik-Kategorie (Ebene), z. B. "50" für Tragwerk. */ + categoryCode: string; + /** Einfügepunkt (Profil-Mitte) im Grundriss (Meter). */ + position: Vec2; + /** Querschnitt (Rechteck oder Kreis). */ + profile: ColumnProfile; + /** Drehung des Profils um `position` (Radiant, CCW; Default 0). */ + rotation: number; + /** Höhe in Metern (> 0), nach +Z ab der UK. */ + height: number; + /** Optionales tragendes Bauteil (Component) für Poché/3D-Farbe/Schraffur. */ + componentId?: string; + /** + * Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der + * baseElevation des zugehörigen Geschosses (= Wand-Default). + */ + bottom?: VerticalAnchor; + /** + * Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus + * UK + `height`. + */ + top?: VerticalAnchor; + /** + * Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die + * Kategorie-Farbe. + */ + color?: string; +} + // ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ─────────── // Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres // Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen @@ -1160,6 +1486,278 @@ export interface TerrainMesh { export type ContextObject = ImportedMesh | ContourSet | TerrainMesh; /** Das gesamte Projekt. */ +/** + * Ein Ausschnitt (View-Snapshot) — eine benannte, gespeicherte Ansicht, die den + * kompletten Darstellungszustand einfängt und per Klick wiederherstellt + * (DOSSIER A2, ROADMAP §2c/§11). KEIN neuer Zustand, sondern ein Container, der + * bereits vorhandene Bausteine KOMPONIERT + benennt: Ansicht (Typ/Kamera/ + * Massstab/Detail), Sichtbarkeit (Ebenen-/Zeichnungskombination, gleiches + * Payload-Format wie {@link https LayerCombo}/`DrawingCombo`), aktive Overrides + * und das aktive Geschoss. Lebt im DOKUMENT (`Project.viewSnapshots`), nicht im + * localStorage — Ausschnitte gehören zum Projekt (Export/Teilen). + */ +export interface ViewSnapshot { + id: string; + name: string; + /** + * LEGACY: name-basierte Flach-Gruppierung (Phase 1). Wird von der neuen + * Ordner-/Baum-UI nicht mehr genutzt, bleibt aber für Alt-Projekte gültig. + * Neue Ausschnitte tragen stattdessen `folderId` (echte Ordner-Referenz). + */ + folder?: string; + /** + * Ordner (Baumstruktur) des Ausschnitte-Panels, in dem der Ausschnitt liegt + * ({@link ViewSnapshotFolder}). Fehlt er (oder verweist er ins Leere), liegt + * der Ausschnitt auf der Wurzelebene. Additiv — Alt-Projekte laden unverändert. + */ + folderId?: string; + // ── Ansicht ────────────────────────────────────────────────────────────── + /** Ansichtstyp (Grundriss / Perspektive). */ + viewType: ViewType; + /** Kanonischer 3D-Blickwinkel (Front/Top/Iso/… bzw. freie Perspektive). */ + view3d: View3d; + /** Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad. Optional (nur 3D relevant). */ + fov?: number; + /** Papier-Massstab „1:N" (der Nenner). */ + scaleDenominator: number; + /** Detailgrad der Darstellung (grob/mittel/fein). */ + detail: DetailLevel; + // ── Zustand ────────────────────────────────────────────────────────────── + /** Aktives Geschoss/Zeichnungsebene beim Erfassen. */ + activeLevelId: string; + /** Ebenen-Sichtbarkeit: Kategorie-`code` → sichtbar (wie `LayerCombo.codes`). */ + layerVisibility: Record; + /** Zeichnungsebenen-Sichtbarkeit: DrawingLevel-`id` → sichtbar (wie `DrawingCombo.ids`). */ + drawingVisibility: Record; + /** + * Ids der beim Erfassen AKTIVEN (`enabled`) Override-Regeln. Beim + * Wiederherstellen wird jede Regel des Projekts auf `enabled = ids.includes(id)` + * gesetzt. Optional, damit hand-/altangelegte Snapshots ohne Feld gültig sind. + */ + enabledOverrideRuleIds?: string[]; +} + +/** + * Ein Ordner im Ausschnitte-Baum (DOSSIER A2). Ausschnitte verweisen per + * `folderId` auf ihren Ordner; `parentId` erlaubt Verschachtelung (Ordner in + * Ordner). Fehlt `parentId`, liegt der Ordner auf der Wurzelebene. Additiv — + * Alt-Projekte laden unverändert (spiegelbildlich zu {@link LayoutFolder}). + */ +export interface ViewSnapshotFolder { + id: string; + name: string; + /** Übergeordneter Ordner; ohne → Wurzelebene. */ + parentId?: string; +} + +// ── Layout-Blätter mit Masterlayout (DOSSIER A3, ROADMAP §11) ─────────────── +// +// Ein „Layout" ist ein Druck-/Plan-Blatt (A4/A3), auf dem mehrere Viewports +// platziert werden; jeder Viewport ist an einen Ausschnitt ({@link ViewSnapshot}) +// gebunden und rendert dessen Plan im gewählten Massstab. Ein Masterlayout +// liefert die gemeinsamen Blatt-Elemente (Titelblock/Rahmen), die die Layouts +// erben. Alles lebt im DOKUMENT (`Project.layouts`/`Project.masterLayouts`), +// nicht im localStorage — Layouts gehören zum Projekt. Additiv: bestehende +// Projekte ohne diese Felder bleiben unverändert gültig. + +/** + * Papierformat eines Layout-Blatts (Blattmasse in mm identisch zum PDF-Export). + * ISO-216-Reihen A0–A6 und B0–B6; Alt-Projekte (nur A4/A3) laden unverändert. + */ +export type LayoutPaperFormat = + | "a0" + | "a1" + | "a2" + | "a3" + | "a4" + | "a5" + | "a6" + | "b0" + | "b1" + | "b2" + | "b3" + | "b4" + | "b5" + | "b6"; +/** Ausrichtung eines Layout-Blatts. */ +export type LayoutOrientation = "portrait" | "landscape"; + +/** + * Einfache Textfelder des Titelblocks (Master). Alle optional — leere Felder + * werden beim Auflösen aus dem Projekt/Layout mit sinnvollen Defaults gefüllt + * (Projektname ← `Project.name`, Blattname ← `Layout.name`, Datum ← heute). + */ +export interface LayoutTitleBlock { + projectName?: string; + sheetName?: string; + scale?: string; + date?: string; + author?: string; +} + +/** + * Masterlayout — gemeinsame Blatt-Elemente (Titelblock + optionaler Rahmen), die + * einzelne {@link Layout}s per `masterId` erben. Eine Änderung am Master schlägt + * auf alle Layouts durch, die ihn referenzieren (InDesign-/ArchiCAD-Muster). + */ +export interface MasterLayout { + id: string; + name: string; + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + /** Titelblock-Textfelder (Vorlage; leere Felder werden aufgelöst/aufgefüllt). */ + titleBlock: LayoutTitleBlock; + /** + * Ordner (Master-Baum), in dem das Masterlayout liegt ({@link LayoutFolder} mit + * `kind:"master"`). Fehlt er (oder verweist auf einen gelöschten/Layout-Ordner), + * liegt der Master auf der Wurzelebene. Additiv — Alt-Projekte laden unverändert. + */ + folderId?: string; + /** Blattrahmen zeichnen (Randlinie). Optional, Default `true`. */ + border?: boolean; + /** + * Freie Blattbreite in mm. Ist sie (zusammen mit `customHeightMm`) gesetzt, + * überschreibt sie `paper`/A4·A3 (die effektive Blattgrösse ist dann + * `customWidthMm`×`customHeightMm`, `orientation` wird ignoriert). Fehlt sie, + * gilt `paper`+`orientation` wie bisher. Additiv — Alt-Projekte laden unverändert. + */ + customWidthMm?: number; + /** Freie Blatthöhe in mm (siehe {@link MasterLayout.customWidthMm}). */ + customHeightMm?: number; +} + +/** + * Ein platzierter Viewport auf einem Layout-Blatt — Position/Grösse in Papier-mm, + * gebunden an einen Ausschnitt (`snapshotId`). `scaleDenominator` übersteuert + * optional den Massstab des Ausschnitts (Massstab pro Viewport). + */ +export interface LayoutViewport { + id: string; + /** Id des gebundenen Ausschnitts ({@link ViewSnapshot}). */ + snapshotId: string; + /** Linke Kante auf dem Blatt (mm, von links). */ + xMm: number; + /** Obere Kante auf dem Blatt (mm, von oben). */ + yMm: number; + /** Breite des Viewport-Rahmens auf dem Blatt (mm). */ + widthMm: number; + /** Höhe des Viewport-Rahmens auf dem Blatt (mm). */ + heightMm: number; + /** Optionaler Massstab-Nenner (1:N); übersteuert `snapshot.scaleDenominator`. */ + scaleDenominator?: number; +} + +/** + * Eine 2D-Annotation (Linie/Rechteck/Text), direkt auf dem Layout-Blatt in + * Papier-mm-Koordinaten gezeichnet — eine vom Viewport-Baum unabhängige + * Zusatzschicht (Markup). `color`/`weightMm` sind optional (Editor-Defaults: + * `#111111` / 0.25 mm); Text trägt statt `weightMm` seine Zeilenhöhe. + */ +export type LayoutAnnotation = + | { + id: string; + kind: "line"; + x1Mm: number; + y1Mm: number; + x2Mm: number; + y2Mm: number; + color?: string; + weightMm?: number; + } + | { + id: string; + kind: "rect"; + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; + color?: string; + weightMm?: number; + } + | { + id: string; + kind: "text"; + xMm: number; + yMm: number; + text: string; + heightMm: number; + color?: string; + }; + +/** + * Flaches Patch-Objekt für {@link LayoutAnnotation} — alle Felder ALLER Arten + * optional (statt eines pro Art unterscheidenden Unions), damit CRUD/Handler + * unabhängig von der konkreten `kind` bleiben (der Aufrufer setzt ohnehin nur + * die zur gewählten Annotation passenden Felder). + */ +export type LayoutAnnotationPatch = Partial<{ + x1Mm: number; + y1Mm: number; + x2Mm: number; + y2Mm: number; + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; + text: string; + color: string; + weightMm: number; +}>; + +/** + * Ein Layout-Blatt — Papierformat/Ausrichtung, optionaler Master (`masterId`) + * und die platzierten Viewports. + */ +export interface Layout { + id: string; + name: string; + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + /** Referenz auf ein {@link MasterLayout}; ohne → kein Master (blanko Blatt). */ + masterId?: string; + /** + * Ordner (Baumstruktur), in dem das Layout liegt ({@link LayoutFolder}). Fehlt + * er (oder verweist auf einen gelöschten Ordner), liegt das Layout auf der + * Wurzelebene. Additiv — Alt-Projekte laden unverändert. + */ + folderId?: string; + /** + * Freie Blattbreite in mm — überschreibt `paper`/A4·A3, siehe + * {@link MasterLayout.customWidthMm}. Bei gesetztem `masterId` erbt das Blatt + * ohnehin die Master-Grösse; eigene Custom-Werte gelten nur ohne Master. + */ + customWidthMm?: number; + /** Freie Blatthöhe in mm (siehe {@link Layout.customWidthMm}). */ + customHeightMm?: number; + viewports: LayoutViewport[]; + /** + * 2D-Markups direkt auf dem Blatt (Linie/Rechteck/Text), siehe + * {@link LayoutAnnotation}. Additiv — Alt-Layouts ohne dieses Feld laden + * unverändert (Editor/CRUD behandeln ein fehlendes Array wie ein leeres). + */ + annotations?: LayoutAnnotation[]; +} + +/** + * Ein Ordner im Layouts-Baum (DOSSIER A3). Layouts verweisen per `folderId` auf + * ihren Ordner; `parentId` erlaubt Verschachtelung (Ordner in Ordner). Fehlt + * `parentId`, liegt der Ordner auf der Wurzelebene. Masterlayouts liegen NICHT + * in Ordnern (sie sind Vorlagen). Additiv — Alt-Projekte laden unverändert. + */ +export interface LayoutFolder { + id: string; + name: string; + /** Übergeordneter Ordner; ohne → Wurzelebene. */ + parentId?: string; + /** + * Baum-Zugehörigkeit: `"layout"`-Ordner tragen Layouts, `"master"`-Ordner + * tragen Masterlayouts. Die beiden Bäume bleiben getrennt (ein Master-Ordner + * erscheint nie im Layout-Baum und umgekehrt). Fehlt das Feld, gilt `"layout"` + * (rückwärtskompatibel — Alt-Projekte ohne `kind` bleiben Layout-Ordner). + */ + kind?: "layout" | "master"; +} + export interface Project { id: string; name: string; @@ -1177,6 +1775,20 @@ export interface Project { * `Ceiling.wallTypeId` gegen `wallTypes` auf (siehe `getCeilingType`). */ ceilingTypes?: CeilingType[]; + /** + * Türtypen-Bibliothek (Türstile), analog `wallTypes`. Optional, damit + * bestehende Projekte/Tests ohne `doorTypes` gültig bleiben; Türen ohne + * `typeId` verhalten sich wie bisher (Inline-Felder). + */ + doorTypes?: DoorType[]; + /** + * Fenstertypen-Bibliothek (Fensterstile). Optional (siehe `doorTypes`). + */ + windowTypes?: WindowType[]; + /** + * Treppentypen-Bibliothek (Treppenstile). Optional (siehe `doorTypes`). + */ + stairTypes?: StairType[]; /** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */ drawingLevels: DrawingLevel[]; /** Grafik-Kategorie-Baum (geschossübergreifend). */ @@ -1213,6 +1825,18 @@ export interface Project { * Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln). */ context?: ContextObject[]; + /** + * Extrudierte Körper (truck-Integration) — geschlossene 2D-Profile mit Höhe, + * siehe {@link ExtrudedSolid}. Optional, damit bestehende Projekte/Tests ohne + * `extrudedSolids` gültig bleiben (Default: leer behandeln). + */ + extrudedSolids?: ExtrudedSolid[]; + /** + * Stützen (Columns) — geschossgebundene Tragwerk-Bauteile (platzierte + * Profil-Extrusionen), siehe {@link Column}. Optional, damit bestehende + * Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln). + */ + columns?: Column[]; /** * Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende * Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln). @@ -1220,6 +1844,45 @@ export interface Project { * aufgelöst — generiert Wall[]-Objekte bei Bedarf. */ parametricWalls?: ParametricWall[]; + /** + * Grafische Override-Regeln (Regel-Engine, Reihenfolge = Priorität, oben + * gewinnt) — reines Rendering-Overlay, siehe {@link OverrideRule}. Optional, + * damit bestehende Projekte/Tests ohne `overrideRules` gültig bleiben + * (Default: leer behandeln). + */ + overrideRules?: OverrideRule[]; + /** + * Ausschnitte / View-Snapshots (DOSSIER A2) — benannte, wiederherstellbare + * Ansichten (siehe {@link ViewSnapshot}). Optional, damit bestehende + * Projekte/Tests ohne `viewSnapshots` unverändert gültig bleiben (Default: + * leer behandeln). Gehört ins Dokument (nicht localStorage). + */ + viewSnapshots?: ViewSnapshot[]; + /** + * Ordner des Ausschnitte-Baums (DOSSIER A2) — Gruppierung der + * {@link ViewSnapshot}s per `folderId`. Optional, damit bestehende + * Projekte/Tests ohne `viewSnapshotFolders` gültig bleiben (Default: leer → + * alle Ausschnitte auf Wurzelebene). + */ + viewSnapshotFolders?: ViewSnapshotFolder[]; + /** + * Layout-Blätter (DOSSIER A3) — Druck-/Plan-Blätter mit platzierten Viewports + * (siehe {@link Layout}). Optional, damit bestehende Projekte/Tests ohne + * `layouts` gültig bleiben (Default: leer behandeln). Gehört ins Dokument. + */ + layouts?: Layout[]; + /** + * Masterlayouts (DOSSIER A3) — gemeinsame Blatt-Elemente (Titelblock/Rahmen), + * die einzelne {@link Layout}s erben (siehe {@link MasterLayout}). Optional, + * damit bestehende Projekte/Tests ohne `masterLayouts` gültig bleiben. + */ + masterLayouts?: MasterLayout[]; + /** + * Ordner des Layouts-Baums (DOSSIER A3) — Gruppierung der {@link Layout}s per + * `folderId`. Optional, damit bestehende Projekte/Tests ohne `layoutFolders` + * gültig bleiben (Default: leer → alle Layouts auf Wurzelebene). + */ + layoutFolders?: LayoutFolder[]; /** * Referenzhöhe des Erdgeschosses in Metern über Meer (m ü. M.), editierbar * im Einstellungs-Fenster. Optional, damit bestehende Projekte ohne diesen @@ -1270,6 +1933,22 @@ export const ceilingThickness = (project: Project, ceiling: Ceiling): number => } }; +/** + * Löst den {@link DoorType} einer Tür-Öffnung auf, oder `undefined` (keine + * `typeId` bzw. unbekannt ⇒ Inline-Verhalten). Bewusst NICHT werfend — der + * Typ ist eine optionale Übersteuerung, kein Pflichtbezug wie beim Wandtyp. + */ +export const getDoorType = (project: Project, opening: Opening): DoorType | undefined => + opening.typeId ? (project.doorTypes ?? []).find((t) => t.id === opening.typeId) : undefined; + +/** Löst den {@link WindowType} einer Fenster-Öffnung auf, oder `undefined`. */ +export const getWindowType = (project: Project, opening: Opening): WindowType | undefined => + opening.typeId ? (project.windowTypes ?? []).find((t) => t.id === opening.typeId) : undefined; + +/** Löst den {@link StairType} einer Treppe auf, oder `undefined`. */ +export const getStairType = (project: Project, stair: Stair): StairType | undefined => + stair.typeId ? (project.stairTypes ?? []).find((t) => t.id === stair.typeId) : undefined; + /** Alle Öffnungen einer Wand (leere Liste, wenn keine oder `openings` fehlt). */ export const openingsOfWall = (project: Project, wallId: string): Opening[] => (project.openings ?? []).filter((o) => o.hostWallId === wallId); @@ -1291,6 +1970,18 @@ export const stairLabel = (s: Stair): string => { return `${shape} ${s.stepCount} STG`; }; +/** Alle Stützen eines Geschosses (leere Liste, wenn keine oder `columns` fehlt). */ +export const columnsOfFloor = (project: Project, floorId: string): Column[] => + (project.columns ?? []).filter((c) => c.floorId === floorId); + +/** Menschenlesbarer Standardname einer Stütze (Profil + Masse in cm). */ +export const columnLabel = (c: Column): string => { + if (c.profile.kind === "round") { + return `Stütze Ø${(c.profile.radius * 200).toFixed(0)}`; + } + return `Stütze ${(c.profile.width * 100).toFixed(0)}×${(c.profile.depth * 100).toFixed(0)}`; +}; + /** Alle Räume eines Geschosses (leere Liste, wenn keine oder `rooms` fehlt). */ export const roomsOfFloor = (project: Project, floorId: string): Room[] => (project.rooms ?? []).filter((r) => r.floorId === floorId); diff --git a/src/model/wall.test.ts b/src/model/wall.test.ts new file mode 100644 index 0000000..04932f6 --- /dev/null +++ b/src/model/wall.test.ts @@ -0,0 +1,115 @@ +// Unit-Tests für die Wand-/Decken-Resolver (wallVerticalExtent / +// ceilingVerticalExtent): reine Datenschicht, kein React/Store. + +import { describe, it, expect } from "vitest"; +import { ceilingVerticalExtent } from "./wall"; +import type { Ceiling, Project } from "./types"; + +/** Minimalprojekt mit einem Geschoss (baseElevation 0, floorHeight 2.6) und + * einem einschichtigen Wandtyp (0.2 m), den die Decke als Aufbau nutzt. */ +function makeProject(overrides?: Partial): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }], + wallTypes: [ + { id: "dt", name: "Decke", layers: [{ componentId: "c", thickness: 0.2 }] }, + ], + drawingLevels: [ + { + id: "eg", + name: "EG", + kind: "floor", + visible: true, + locked: false, + floorHeight: 2.6, + cutHeight: 1.0, + baseElevation: 0, + }, + { + id: "og", + name: "OG", + kind: "floor", + visible: true, + locked: false, + floorHeight: 2.6, + cutHeight: 1.0, + baseElevation: 2.8, + }, + ], + layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + ...overrides, + }; +} + +function makeCeiling(overrides?: Partial): Ceiling { + return { + id: "cl1", + type: "ceiling", + floorId: "eg", + categoryCode: "30", + outline: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 4 }, + { x: 0, y: 4 }, + ], + wallTypeId: "dt", + ...overrides, + }; +} + +describe("ceilingVerticalExtent", () => { + it("ohne top/bottom: OK = Geschoss-Oberkante, UK = OK − Dicke (heutiges Verhalten)", () => { + const project = makeProject(); + const ceiling = makeCeiling(); + const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling); + expect(zTop).toBeCloseTo(2.6); // baseElevation(0) + floorHeight(2.6) + expect(zBottom).toBeCloseTo(2.4); // zTop − thickness(0.2) + }); + + it("mit top: custom übersteuert die Oberkante; UK bleibt OK − Dicke", () => { + const project = makeProject(); + const ceiling = makeCeiling({ top: { mode: "custom", z: 3.0 } }); + const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling); + expect(zTop).toBeCloseTo(3.0); + expect(zBottom).toBeCloseTo(2.8); + }); + + it("mit bottom: custom übersteuert die Unterkante — gewinnt über OK − Dicke", () => { + const project = makeProject(); + const ceiling = makeCeiling({ bottom: { mode: "custom", z: 2.2 } }); + const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling); + expect(zTop).toBeCloseTo(2.6); // OK unverändert (Geschoss-Default) + expect(zBottom).toBeCloseTo(2.2); // UK-Override statt OK − Dicke (2.4) + }); + + it("mit top UND bottom: beide Overrides wirken unabhängig (Dicke wird ignoriert)", () => { + const project = makeProject(); + const ceiling = makeCeiling({ + top: { mode: "custom", z: 5.0 }, + bottom: { mode: "custom", z: 4.5 }, + }); + const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling); + expect(zTop).toBeCloseTo(5.0); + expect(zBottom).toBeCloseTo(4.5); + }); + + it("bottom: floor bindet die Unterkante an ein Geschoss (+ optionalem offset)", () => { + const project = makeProject(); + const ceiling = makeCeiling({ bottom: { mode: "floor", floorId: "og", offset: 0.1 } }); + const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling); + expect(zTop).toBeCloseTo(2.6); + expect(zBottom).toBeCloseTo(2.9); // baseElevation(og)=2.8 + offset(0.1) + }); +}); diff --git a/src/model/wall.ts b/src/model/wall.ts index 9bb295d..114a86a 100644 --- a/src/model/wall.ts +++ b/src/model/wall.ts @@ -5,7 +5,7 @@ // // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). -import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types"; +import type { Ceiling, Column, Project, Stair, VerticalAnchor, Wall } from "./types"; import { ceilingThickness, getFloor } from "./types"; /** @@ -74,7 +74,9 @@ export function wallVerticalExtent( * Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern). * • zTop: aus `ceiling.top`, sonst Oberkante des Geschosses * (baseElevation + floorHeight) — also bündig mit dem Wandkopf. - * • zBottom: zTop − Deckendicke (die Decke wächst nach UNTEN). + * • zBottom: aus `ceiling.bottom`, sonst zTop − Deckendicke (die Decke + * wächst nach UNTEN — heutiges Verhalten). Die `thickness` bleibt bei + * gesetztem `bottom` rein informativ (dient nicht mehr der UK-Ableitung). * Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante. */ export function ceilingVerticalExtent( @@ -89,7 +91,9 @@ export function ceilingVerticalExtent( floorTop = 0; } const zTop = resolveAnchor(project, ceiling.top) ?? floorTop; - const zBottom = zTop - ceilingThickness(project, ceiling); + const zBottom = + resolveAnchor(project, ceiling.bottom) ?? + zTop - ceilingThickness(project, ceiling); return { zBottom, zTop }; } @@ -124,6 +128,28 @@ export function stairVerticalExtent( return { zBottom: base, zTop: base + rise }; } +/** + * Vertikale Ausdehnung einer Stütze (absolute Z-Werte in Metern) — dieselbe + * Regel wie {@link wallVerticalExtent}: + * • zBottom: aus `column.bottom`, sonst baseElevation des Geschosses. + * • zTop: aus `column.top`, sonst `zBottom + column.height`. + * Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante. + */ +export function columnVerticalExtent( + project: Project, + column: Column, +): { zBottom: number; zTop: number } { + let floorBase = 0; + try { + floorBase = getFloor(project, column.floorId).baseElevation ?? 0; + } catch { + floorBase = 0; + } + const zBottom = resolveAnchor(project, column.bottom) ?? floorBase; + const zTop = resolveAnchor(project, column.top) ?? zBottom + column.height; + return { zBottom, zTop }; +} + /** * Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der * "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt. diff --git a/src/overrides/engine.test.ts b/src/overrides/engine.test.ts new file mode 100644 index 0000000..9a8ed86 --- /dev/null +++ b/src/overrides/engine.test.ts @@ -0,0 +1,189 @@ +// Unit-Tests der Override-Regel-Engine (reine Auswertung, src/overrides/engine.ts): +// Operatoren, Reihenfolge (oberste Regel gewinnt pro Feld), additive +// Teilaktionen, deaktivierte Regeln, layer_name gegen Name UND Code. + +import { describe, it, expect } from "vitest"; +import type { OverrideRule } from "../model/types"; +import { + EMPTY_OVERRIDES, + effectiveOverrides, + hasOverrides, + matchesCondition, +} from "./engine"; + +/** Kurzform: Regel mit Defaults. */ +function rule(partial: Partial & Pick): OverrideRule { + return { + id: partial.id ?? "r", + name: partial.name ?? "Regel", + enabled: partial.enabled ?? true, + condition: partial.condition, + actions: partial.actions, + }; +} + +const WALL_SUBJECT = { layerName: "Wände", layerCode: "20", objectName: "AW 24" }; + +describe("overrides/engine — matchesCondition", () => { + it("equals: case-insensitiv, trimmt den Regelwert", () => { + expect( + matchesCondition( + { type: "layer_name", operator: "equals", value: " wände " }, + WALL_SUBJECT, + ), + ).toBe(true); + expect( + matchesCondition( + { type: "layer_name", operator: "equals", value: "Decken" }, + WALL_SUBJECT, + ), + ).toBe(false); + }); + + it("layer_name matcht Namen ODER Code der Ebene", () => { + expect( + matchesCondition( + { type: "layer_name", operator: "equals", value: "20" }, + WALL_SUBJECT, + ), + ).toBe(true); + }); + + it("contains und starts_with auf dem Objektnamen", () => { + expect( + matchesCondition( + { type: "object_name", operator: "contains", value: "24" }, + WALL_SUBJECT, + ), + ).toBe(true); + expect( + matchesCondition( + { type: "object_name", operator: "starts_with", value: "aw" }, + WALL_SUBJECT, + ), + ).toBe(true); + expect( + matchesCondition( + { type: "object_name", operator: "starts_with", value: "24" }, + WALL_SUBJECT, + ), + ).toBe(false); + }); + + it("not_equals trifft nur, wenn WEDER Name noch Code gleich sind", () => { + expect( + matchesCondition( + { type: "layer_name", operator: "not_equals", value: "20" }, + WALL_SUBJECT, + ), + ).toBe(false); + expect( + matchesCondition( + { type: "layer_name", operator: "not_equals", value: "Wände" }, + WALL_SUBJECT, + ), + ).toBe(false); + expect( + matchesCondition( + { type: "layer_name", operator: "not_equals", value: "Decken" }, + WALL_SUBJECT, + ), + ).toBe(true); + }); + + it("fehlende Subjekt-Eigenschaft: positive Operatoren treffen nicht, not_equals schon", () => { + expect( + matchesCondition( + { type: "object_name", operator: "contains", value: "x" }, + { layerName: "Wände", layerCode: "20" }, + ), + ).toBe(false); + expect( + matchesCondition( + { type: "object_name", operator: "not_equals", value: "x" }, + { layerName: "Wände", layerCode: "20" }, + ), + ).toBe(true); + }); +}); + +describe("overrides/engine — effectiveOverrides", () => { + it("ohne Regeln (oder undefined) kommt EMPTY_OVERRIDES zurück", () => { + expect(effectiveOverrides(undefined, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES); + expect(effectiveOverrides([], WALL_SUBJECT)).toBe(EMPTY_OVERRIDES); + expect(hasOverrides(EMPTY_OVERRIDES)).toBe(false); + }); + + it("oberste Regel gewinnt pro Aktions-Feld", () => { + const rules = [ + rule({ + id: "top", + condition: { type: "layer_name", operator: "equals", value: "20" }, + actions: { color: "#ff0000" }, + }), + rule({ + id: "below", + condition: { type: "layer_name", operator: "equals", value: "20" }, + actions: { color: "#00ff00" }, + }), + ]; + expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#ff0000"); + }); + + it("additive Teilaktionen: untere Regeln füllen offene Felder", () => { + const rules = [ + rule({ + id: "color-only", + condition: { type: "layer_name", operator: "equals", value: "20" }, + actions: { color: "#ff0000" }, + }), + rule({ + id: "weight-only", + condition: { type: "object_name", operator: "starts_with", value: "AW" }, + actions: { lineweight: 0.7, color: "#0000ff" }, + }), + ]; + const eff = effectiveOverrides(rules, WALL_SUBJECT); + // Farbe aus der obersten Regel, Strichstärke additiv aus der zweiten. + expect(eff).toEqual({ color: "#ff0000", lineweight: 0.7 }); + }); + + it("deaktivierte Regeln werden übersprungen", () => { + const rules = [ + rule({ + id: "off", + enabled: false, + condition: { type: "layer_name", operator: "equals", value: "20" }, + actions: { color: "#ff0000" }, + }), + rule({ + id: "on", + condition: { type: "layer_name", operator: "equals", value: "20" }, + actions: { color: "#00ff00" }, + }), + ]; + expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#00ff00"); + }); + + it("nicht matchende Regeln tragen nichts bei", () => { + const rules = [ + rule({ + id: "other-layer", + condition: { type: "layer_name", operator: "equals", value: "30" }, + actions: { color: "#ff0000" }, + }), + ]; + expect(effectiveOverrides(rules, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES); + }); + + it("linetypeId wird wie die anderen Felder aufgelöst", () => { + const rules = [ + rule({ + id: "lt", + condition: { type: "layer_name", operator: "contains", value: "wän" }, + actions: { linetypeId: "dashed" }, + }), + ]; + expect(effectiveOverrides(rules, WALL_SUBJECT).linetypeId).toBe("dashed"); + }); +}); diff --git a/src/overrides/engine.ts b/src/overrides/engine.ts new file mode 100644 index 0000000..d388ace --- /dev/null +++ b/src/overrides/engine.ts @@ -0,0 +1,131 @@ +// Grafische Overrides — reine Auswertungs-Engine (Regel-Engine, DOSSIER A1). +// +// Wertet die Regelliste `Project.overrideRules` für ein Element aus und +// liefert die effektiven Aktionen (Farbe/Strichstärke/Linienstil). Die Regeln +// werden ADDITIV angewendet: pro Aktions-Feld gewinnt die OBERSTE (erste) +// aktive Regel, deren Bedingung matcht; spätere Regeln füllen nur noch die +// verbleibenden Felder. Deaktivierte Regeln werden übersprungen. +// +// Reines Rendering-Overlay: die Engine kennt weder Project noch Renderer — +// der Aufrufer (plan/generatePlan.ts) baut das Subjekt aus Ebene + Element +// und legt das Ergebnis als oberste Schicht ÜBER die reguläre +// By-Layer/By-Object-Attribut-Auflösung. Elementdaten bleiben unverändert. + +import type { + OverrideActions, + OverrideCondition, + OverrideRule, +} from "../model/types"; + +/** + * Das Subjekt einer Auswertung — die vergleichbaren Eigenschaften EINES + * Elements. `layer_name`-Bedingungen matchen gegen Namen ODER Code der Ebene + * (LayerCategory), `object_name`-Bedingungen gegen den Element-/Typ-Namen. + */ +export interface OverrideSubject { + /** Name der Ebene (LayerCategory.name) des Elements. */ + layerName?: string; + /** Code der Ebene (LayerCategory.code / Element.categoryCode). */ + layerCode?: string; + /** Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name, Öffnungs-Label). */ + objectName?: string; +} + +/** Leere Aktionen (kein Override) — geteilte Konstante für den Schnellpfad. */ +export const EMPTY_OVERRIDES: OverrideActions = Object.freeze({}); + +/** Case-insensitiver Feld-Vergleich eines Operators. */ +function matchValue( + operator: OverrideCondition["operator"], + candidate: string, + value: string, +): boolean { + switch (operator) { + case "equals": + return candidate === value; + case "contains": + return candidate.includes(value); + case "starts_with": + return candidate.startsWith(value); + case "not_equals": + return candidate !== value; + } +} + +/** + * Prüft, ob eine Bedingung auf ein Subjekt zutrifft (case-insensitiv, + * Wert getrimmt). `layer_name` vergleicht gegen Ebenen-NAME und -CODE: + * positive Operatoren (equals/contains/starts_with) treffen, wenn EINER der + * beiden passt; `not_equals` trifft nur, wenn KEINER der beiden gleich ist + * (sonst würde der Code fast jede Namens-Ungleichheit erfüllen). + */ +export function matchesCondition( + condition: OverrideCondition, + subject: OverrideSubject, +): boolean { + const value = condition.value.trim().toLowerCase(); + const candidates: string[] = []; + if (condition.type === "layer_name") { + if (subject.layerName != null) candidates.push(subject.layerName.trim().toLowerCase()); + if (subject.layerCode != null) candidates.push(subject.layerCode.trim().toLowerCase()); + } else { + if (subject.objectName != null) candidates.push(subject.objectName.trim().toLowerCase()); + } + if (candidates.length === 0) { + // Subjekt trägt die Eigenschaft nicht: positive Operatoren können nicht + // treffen; „ungleich" ist trivial erfüllt. + return condition.operator === "not_equals"; + } + if (condition.operator === "not_equals") { + return candidates.every((c) => c !== value); + } + return candidates.some((c) => matchValue(condition.operator, c, value)); +} + +/** + * Kollabiert die Regelliste für ein Subjekt zu den effektiven Aktionen. + * Additiv, oberste Regel gewinnt PRO FELD: die erste aktive, matchende Regel, + * die ein Feld setzt, bestimmt es; weitere Regeln füllen nur offene Felder. + * Ohne Treffer (oder ohne Regeln) kommt {@link EMPTY_OVERRIDES} zurück. + */ +export function effectiveOverrides( + rules: readonly OverrideRule[] | undefined, + subject: OverrideSubject, +): OverrideActions { + if (!rules || rules.length === 0) return EMPTY_OVERRIDES; + let out: OverrideActions | null = null; + for (const rule of rules) { + if (!rule.enabled) continue; + if (!matchesCondition(rule.condition, subject)) continue; + const a = rule.actions; + if (a.color === undefined && a.lineweight === undefined && a.linetypeId === undefined) { + continue; + } + if (!out) out = {}; + if (out.color === undefined && a.color !== undefined) out.color = a.color; + if (out.lineweight === undefined && a.lineweight !== undefined) { + out.lineweight = a.lineweight; + } + if (out.linetypeId === undefined && a.linetypeId !== undefined) { + out.linetypeId = a.linetypeId; + } + // Alle Felder belegt → keine weitere Regel kann noch etwas beitragen. + if ( + out.color !== undefined && + out.lineweight !== undefined && + out.linetypeId !== undefined + ) { + break; + } + } + return out ?? EMPTY_OVERRIDES; +} + +/** True, wenn mindestens eine Aktion gesetzt ist (Schnelltest für Aufrufer). */ +export function hasOverrides(actions: OverrideActions): boolean { + return ( + actions.color !== undefined || + actions.lineweight !== undefined || + actions.linetypeId !== undefined + ); +} diff --git a/src/panels/AttributesPanel.tsx b/src/panels/AttributesPanel.tsx index 83c541e..c5868b7 100644 --- a/src/panels/AttributesPanel.tsx +++ b/src/panels/AttributesPanel.tsx @@ -39,6 +39,8 @@ import { OpeningSection, StairSection, RoomSection, + ExtrudedSolidSection, + ColumnSection, } from "./ObjectInfoPanel"; /** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld: @@ -315,6 +317,8 @@ export function AttributesPanel() { {sel.opening && } {sel.stair && } {sel.room && } + {sel.extrudedSolid && } + {sel.column && } ); } diff --git a/src/panels/ElementTreePanel.tsx b/src/panels/ElementTreePanel.tsx new file mode 100644 index 0000000..9087d6d --- /dev/null +++ b/src/panels/ElementTreePanel.tsx @@ -0,0 +1,287 @@ +// Inhalts-Panel „Elemente" — BIM-Baum Geschoss → Bauteilklasse → Element +// (ROADMAP §11 „Element-Übersicht", Phase 1). +// +// Datenquelle: `scheduleRows()` (export/exportSchedule.ts) — dieselbe +// Auswertung wie die Bauteilliste (CSV-Export), hier aber live zu einem +// klick-/zoombaren Baum gruppiert statt exportiert. Legacy-„Tür"-Datensätze +// (project.doors) werden ausgeblendet: dafür existiert aktuell KEIN +// Auswahl-Kanal (nichts legt sie mehr an, siehe evalDoor-Kommentar) — echte +// Türen laufen als Öffnung (`Opening.kind === "door"`) und erscheinen hier +// als Bauteilklasse „Öffnung". +// +// Klick selektiert das Element (leert alle anderen Auswahl-Kanäle, wechselt +// bei Bedarf das Geschoss); Shift-Klick oder Doppelklick selektiert UND passt +// die Plan-Ansicht ein. Auf-/zuklappbare Geschoss- und Klassen-Knoten, ein +// Textfilter oben durchsucht Name/Typ/Klasse. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useMemo, useState } from "react"; +import { usePanelHost } from "./host"; +import type { ScheduleKind, ScheduleRow } from "./host"; +import { scheduleRows } from "../export/exportSchedule"; +import { t } from "../i18n"; + +/** Bauteilklassen, die im Baum erscheinen (Reihenfolge = Anzeigereihenfolge). */ +const CLASS_ORDER: Exclude[] = [ + "Wand", + "Decke", + "Fenster", + "Öffnung", + "Treppe", + "Extrusion", +]; + +/** i18n-Key des Klassen-Labels je Bauteilklasse. */ +const CLASS_LABEL_KEY: Record, string> = { + Wand: "elements.kind.wall", + Decke: "elements.kind.ceiling", + Fenster: "elements.kind.window", + Öffnung: "elements.kind.opening", + Treppe: "elements.kind.stair", + Extrusion: "elements.kind.extrusion", +}; + +/** Kurzform einer ID für die Anzeige (voller Wert bleibt im Tooltip). */ +function shortId(id: string): string { + return id.length <= 14 ? id : `${id.slice(0, 4)}…${id.slice(-6)}`; +} + +/** Ein Geschoss-Knoten des Baums (auch für Elemente ohne bekanntes Geschoss). */ +interface FloorGroup { + /** Gruppierungsschlüssel: die Geschoss-ID, oder ein Namens-Fallback. */ + key: string; + /** Anzeigename (Geschoss-Name bzw. bereits aufgelöster Fallback aus row.floor). */ + name: string; + rows: ScheduleRow[]; +} + +/** Gruppiert Zeilen nach Geschoss, in der Reihenfolge der Zeichnungsebenen (oben→unten). */ +function buildFloorGroups( + rows: ScheduleRow[], + levels: { id: string; name: string }[], +): FloorGroup[] { + const byKey = new Map(); + for (const r of rows) { + const key = r.floorId ?? `name:${r.floor}`; + const list = byKey.get(key); + if (list) list.push(r); + else byKey.set(key, [r]); + } + // Oberstes Geschoss zuoberst, wie im Zeichnungsebenen-Panel. + const levelsTopDown = [...levels].reverse(); + const groups: FloorGroup[] = []; + const used = new Set(); + for (const lvl of levelsTopDown) { + const list = byKey.get(lvl.id); + if (list && list.length) { + groups.push({ key: lvl.id, name: lvl.name, rows: list }); + used.add(lvl.id); + } + } + // Rest (Elemente ohne bekannte Zeichnungsebene, z. B. verwaiste Referenz). + for (const [key, list] of byKey) { + if (used.has(key)) continue; + groups.push({ key, name: list[0].floor, rows: list }); + } + return groups; +} + +/** Ob eine Zeile zum Suchtext passt (Name/Typ/Klasse, case-insensitive). */ +function matchesQuery(row: ScheduleRow, classLabel: string, query: string): boolean { + const haystack = `${row.typeName} ${classLabel} ${row.id}`.toLowerCase(); + return haystack.includes(query); +} + +export function ElementTreePanel() { + const host = usePanelHost(); + const [query, setQuery] = useState(""); + const [collapsedFloors, setCollapsedFloors] = useState>(new Set()); + const [collapsedClasses, setCollapsedClasses] = useState>(new Set()); + + const allRows = useMemo( + () => scheduleRows(host.project).filter((r) => r.kind !== "Tür"), + [host.project], + ); + + const q = query.trim().toLowerCase(); + const searching = q.length > 0; + + const visibleRows = useMemo(() => { + if (!searching) return allRows; + return allRows.filter((r) => + matchesQuery(r, t(CLASS_LABEL_KEY[r.kind as Exclude]), q), + ); + }, [allRows, q, searching]); + + const floorGroups = useMemo( + () => buildFloorGroups(visibleRows, host.project.drawingLevels), + [visibleRows, host.project.drawingLevels], + ); + + const toggleFloor = (key: string) => + setCollapsedFloors((s) => { + const next = new Set(s); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + const toggleClass = (key: string) => + setCollapsedClasses((s) => { + const next = new Set(s); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const onRowSelect = (row: ScheduleRow, zoom: boolean) => { + host.onSelectScheduleRow(row.kind, row.id, row.floorId, { zoom }); + }; + + if (allRows.length === 0) { + return ( +
+
+ {t("elements.title")} +
+
+ {t("elements.empty")} +
+
+ ); + } + + return ( +
+
+ {t("elements.title")} +
+ +
+ setQuery(e.target.value)} + style={{ + width: "100%", + boxSizing: "border-box", + background: "var(--input)", + border: "1px solid var(--border)", + borderRadius: 6, + color: "var(--label)", + fontFamily: "inherit", + fontSize: 11, + padding: "4px 8px", + }} + /> +
+ + {floorGroups.length === 0 ? ( +
+ {t("elements.noMatch")} +
+ ) : ( + floorGroups.map((floor) => { + const floorOpen = searching || !collapsedFloors.has(floor.key); + return ( +
+
toggleFloor(floor.key)} + > + + {floor.name} + + {floor.rows.length} + +
+ + {floorOpen && ( +
+ {CLASS_ORDER.map((kind) => { + const rowsOfKind = floor.rows.filter((r) => r.kind === kind); + if (rowsOfKind.length === 0) return null; + const classKey = `${floor.key}::${kind}`; + const classOpen = searching || !collapsedClasses.has(classKey); + return ( +
+
toggleClass(classKey)} + > + + + {t(CLASS_LABEL_KEY[kind])} + + + {rowsOfKind.length} + +
+ + {classOpen && ( +
+ {rowsOfKind.map((row) => ( +
onRowSelect(row, e.shiftKey)} + onDoubleClick={() => onRowSelect(row, true)} + > + {row.typeName} + {shortId(row.id)} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); + }) + )} +
+ ); +} + +/** Tab-Symbol „Elemente" — kleiner Baum (Ast + drei Blätter). */ +export function ElementsTabIcon() { + return ( + + ); +} diff --git a/src/panels/LayoutsPanel.tsx b/src/panels/LayoutsPanel.tsx new file mode 100644 index 0000000..7aede6e --- /dev/null +++ b/src/panels/LayoutsPanel.tsx @@ -0,0 +1,912 @@ +// Inhalts-Panel „Layouts" — Druck-/Plan-Blätter als ORDNER-/BAUM-Struktur +// (DOSSIER A3, ROADMAP §11). Ordner enthalten Layouts (Verschachtelung möglich); +// Masterlayouts bilden einen eigenen Abschnitt (Vorlagen, kein Ordner-Mitglied). +// +// Ein einziges „+"-Menü oben legt Ordner/Layout/Masterlayout an; Layout/Master +// öffnen zuvor einen Grössen-Dialog. Neu angelegte Elemente landen im aktiven +// Ordner (Klick auf eine Ordnerzeile) und werden SOFORT inline umbenennbar +// (autofocus, Enter bestätigt — KEIN window.prompt, im Tauri-WKWebView deaktiviert). +// Ein Ordner lässt sich als EIN Mehrseiten-PDF exportieren. +// +// Reine Verdrahtung über den Host-Context (usePanelHost); Mutationslogik in +// App.tsx (setProject), pure CRUD/Baum-Aufbau in panels/layoutModel.ts. +// +// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). + +import { useState } from "react"; +import { usePanelHost } from "./host"; +import type { + Layout, + LayoutOrientation, + LayoutPaperFormat, + MasterLayout, +} from "../model/types"; +import { + buildLayoutTree, + buildMasterTree, + PAPER_FORMAT_GROUPS, + type FolderNode, + type MasterFolderNode, +} from "./layoutModel"; +import { + NewLayoutDialog, + NewMasterLayoutDialog, + type LayoutSizeStart, + type NewLayoutStart, +} from "../ui/LayoutCreateDialogs"; +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 ? ( + + ) : ( + + )} + + ); +} + +/** Layout-Blatt — Papier-Rechteck mit ein paar Inhaltslinien. */ +function LayoutSheetIcon() { + return ( + + + + + + + ); +} + +/** Masterlayout — Papier mit Stern-Akzent (Vorlage), hebt sich vom Blatt ab. */ +function MasterSheetIcon() { + return ( + + + + + ); +} + +/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */ +function FolderPlusIcon() { + return ( + + + + + + + ); +} + +/** „Erstellen" — reines Plus (Layout/Masterlayout). */ +function PlusIcon() { + return ( + + + + + ); +} + +/** Welche Zeilenart gerade inline umbenannt wird (Commit-Handler unterscheidet sich). */ +type RenameKind = "folder" | "layout" | "master"; +interface Renaming { + id: string; + kind: RenameKind; +} + +/** Ein Inline-Rename-Input (Muster wie ViewSnapshotsPanel: 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} + /> + ); +} + +/** Kompakte Paper/Orientation-Auswahl (Master-Editor). Setzt Custom zurück. */ +function PaperControls({ + paper, + orientation, + onPaper, + onOrientation, +}: { + paper: LayoutPaperFormat; + orientation: LayoutOrientation; + onPaper: (p: LayoutPaperFormat) => void; + onOrientation: (o: LayoutOrientation) => void; +}) { + return ( + <> + + + + ); +} + +function MasterRow({ + master, + depth, + renaming, + renameDraft, + setRenameDraft, + startRename, + commitRename, + cancelRename, +}: { + master: MasterLayout; + depth: number; + renaming: boolean; + renameDraft: string; + setRenameDraft: (v: string) => void; + startRename: (e: React.MouseEvent) => void; + commitRename: () => void; + cancelRename: () => void; +}) { + const host = usePanelHost(); + const [open, setOpen] = useState(false); + const tb = master.titleBlock; + const onDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + host.onDeleteMasterLayout(master.id); + }; + const field = (key: keyof typeof tb, label: string) => ( + + ); + return ( +
+
setOpen((o) => !o)} + style={{ + display: "flex", + alignItems: "center", + gap: 4, + cursor: "pointer", + paddingLeft: 10 + depth * 14, + }} + > + {renaming ? ( + + ) : ( + + {open ? "▾" : "▸"} + + {master.name} + + )} + + +
+ {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 (