From 1507c2bf001b44373c13d012111a095e8517bdcc Mon Sep 17 00:00:00 2001 From: Karim Date: Wed, 19 Aug 2026 22:45:41 +0200 Subject: [PATCH] =?UTF-8?q?God-Files=20aufgeteilt=20(ResourceManager/gener?= =?UTF-8?q?atePlan/toWalls3d/App.tsx-Handler),=20Totcode=20entfernt=20(geo?= =?UTF-8?q?metry-Crate,=20OCCT/HLR-Spike),=20PDF/Bild-Import=20erg=C3=A4nz?= =?UTF-8?q?t.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResourceManager.tsx, generatePlan.ts und toWalls3d.ts sind nach Bauteil- Domäne in src/ui/resourceManager/, src/plan/generatePlan/ und src/plan/toWalls3d/ aufgeteilt (reine Verschiebung, keine Verhaltens- änderung). App.tsx verliert weitere Handler-Gruppen an eigene Hooks (useExportHandlers, useImportHandling, useKeyboardShortcuts) sowie Hit-Testing an src/viewport/planHitTest.ts. Unbenutzte Rust-geometry-Crate (durch WASM-Joins abgelöst) sowie der alte OCCT/HLR-Schnitt-Spike (hlr.ts/occt.ts, vite occt-Aliases) entfernt. Neu: PDF-Import als Rasterbild (src/io/pdfImport.ts) + gemeinsamer Bild-Asset-Helfer (src/io/imageAsset.ts) für Plan- und Layout-Bilder. tsc -b sauber, vitest 891/891 grün. --- package-lock.json | 285 +- package.json | 2 +- src-tauri/Cargo.lock | 11 - src-tauri/Cargo.toml | 12 +- src-tauri/capabilities/default.json | 4 +- src-tauri/geometry/Cargo.lock | 75 - src-tauri/geometry/Cargo.toml | 30 - src-tauri/geometry/examples/parity.rs | 16 - src-tauri/geometry/src/lib.rs | 1057 ------- src-tauri/src/lib.rs | 13 +- src/App.tsx | 923 ++---- src/compute/index.ts | 10 +- src/compute/parity.test.ts | 88 - src/export/layoutPdf.ts | 143 +- src/export/planToPrintSvg.ts | 106 + src/export/sceneToPrintSvg.ts | 25 + src/i18n/de.ts | 23 +- src/i18n/en.ts | 22 +- src/io/imageAsset.ts | 48 + src/io/pdfImport.ts | 41 + src/model/geoRebase.ts | 2 + src/model/types.ts | 43 +- src/panels/AttributesPanel.tsx | 246 +- src/panels/ObjectInfoPanel.tsx | 171 +- src/panels/ToolsPanel.tsx | 11 +- src/panels/host.ts | 32 +- src/panels/layoutModel.test.ts | 15 +- src/panels/layoutModel.ts | 14 + src/plan/PlanView.tsx | 117 +- src/plan/generatePlan.ts | 2684 +---------------- src/plan/generatePlan/ceilings.ts | 139 + src/plan/generatePlan/context.ts | 170 ++ src/plan/generatePlan/drawing2d.ts | 168 ++ src/plan/generatePlan/openings.ts | 662 ++++ src/plan/generatePlan/roofs.ts | 103 + src/plan/generatePlan/rooms.ts | 96 + src/plan/generatePlan/sections.ts | 145 + src/plan/generatePlan/shared.ts | 521 ++++ src/plan/generatePlan/stairs.ts | 185 ++ src/plan/generatePlan/structures.ts | 93 + src/plan/generatePlan/walls.ts | 531 ++++ src/plan/toRenderScene.ts | 74 +- src/plan/toWalls3d.ts | 2800 +---------------- src/plan/toWalls3d/bands.ts | 923 ++++++ src/plan/toWalls3d/ceilings.ts | 97 + src/plan/toWalls3d/columns.ts | 78 + src/plan/toWalls3d/drawing2d.ts | 88 + src/plan/toWalls3d/extrusions.ts | 91 + src/plan/toWalls3d/openings.ts | 899 ++++++ src/plan/toWalls3d/roofs.ts | 147 + src/plan/toWalls3d/shared.ts | 385 +++ src/plan/toWalls3d/stairs.ts | 155 + src/section/hlr.ts | 270 -- src/section/occt-wasm.d.ts | 25 - src/section/occt.ts | 178 -- src/state/projectSlice.ts | 52 +- src/state/selectionInfo.ts | 19 +- src/state/useExportHandlers.ts | 96 + src/state/useImportHandling.ts | 159 + src/state/useKeyboardShortcuts.ts | 549 ++++ src/styles.css | 161 +- src/text/renderHtml.test.ts | 58 + src/text/renderHtml.ts | 75 + src/tools/tools.ts | 18 + src/tools/transform.ts | 15 + src/tools/types.ts | 3 +- src/ui/LayoutSheet.tsx | 811 +++-- src/ui/ResourceManager.tsx | 2995 +------------------ src/ui/StartScreen.tsx | 120 +- src/ui/layoutSheetMath.test.ts | 87 + src/ui/layoutSheetMath.ts | 127 +- src/ui/resourceManager/ComponentsTab.tsx | 738 +++++ src/ui/resourceManager/DoorStylesTab.tsx | 167 ++ src/ui/resourceManager/HatchesTab.tsx | 316 ++ src/ui/resourceManager/LayeredStylesTab.tsx | 358 +++ src/ui/resourceManager/LinesTab.tsx | 339 +++ src/ui/resourceManager/MaterialsTab.tsx | 172 ++ src/ui/resourceManager/OverridesTab.tsx | 312 ++ src/ui/resourceManager/StairStylesTab.tsx | 96 + src/ui/resourceManager/TypeLibraryTab.tsx | 96 + src/ui/resourceManager/WindowStylesTab.tsx | 186 ++ src/ui/resourceManager/fields.tsx | 250 ++ src/viewport/drawingGrips.ts | 2 +- src/viewport/planHitTest.ts | 47 + vite.config.ts | 25 - 85 files changed, 12461 insertions(+), 11280 deletions(-) delete mode 100644 src-tauri/geometry/Cargo.lock delete mode 100644 src-tauri/geometry/Cargo.toml delete mode 100644 src-tauri/geometry/examples/parity.rs delete mode 100644 src-tauri/geometry/src/lib.rs delete mode 100644 src/compute/parity.test.ts create mode 100644 src/io/imageAsset.ts create mode 100644 src/io/pdfImport.ts create mode 100644 src/plan/generatePlan/ceilings.ts create mode 100644 src/plan/generatePlan/context.ts create mode 100644 src/plan/generatePlan/drawing2d.ts create mode 100644 src/plan/generatePlan/openings.ts create mode 100644 src/plan/generatePlan/roofs.ts create mode 100644 src/plan/generatePlan/rooms.ts create mode 100644 src/plan/generatePlan/sections.ts create mode 100644 src/plan/generatePlan/shared.ts create mode 100644 src/plan/generatePlan/stairs.ts create mode 100644 src/plan/generatePlan/structures.ts create mode 100644 src/plan/generatePlan/walls.ts create mode 100644 src/plan/toWalls3d/bands.ts create mode 100644 src/plan/toWalls3d/ceilings.ts create mode 100644 src/plan/toWalls3d/columns.ts create mode 100644 src/plan/toWalls3d/drawing2d.ts create mode 100644 src/plan/toWalls3d/extrusions.ts create mode 100644 src/plan/toWalls3d/openings.ts create mode 100644 src/plan/toWalls3d/roofs.ts create mode 100644 src/plan/toWalls3d/shared.ts create mode 100644 src/plan/toWalls3d/stairs.ts delete mode 100644 src/section/hlr.ts delete mode 100644 src/section/occt-wasm.d.ts delete mode 100644 src/section/occt.ts create mode 100644 src/state/useExportHandlers.ts create mode 100644 src/state/useImportHandling.ts create mode 100644 src/state/useKeyboardShortcuts.ts create mode 100644 src/text/renderHtml.test.ts create mode 100644 src/ui/resourceManager/ComponentsTab.tsx create mode 100644 src/ui/resourceManager/DoorStylesTab.tsx create mode 100644 src/ui/resourceManager/HatchesTab.tsx create mode 100644 src/ui/resourceManager/LayeredStylesTab.tsx create mode 100644 src/ui/resourceManager/LinesTab.tsx create mode 100644 src/ui/resourceManager/MaterialsTab.tsx create mode 100644 src/ui/resourceManager/OverridesTab.tsx create mode 100644 src/ui/resourceManager/StairStylesTab.tsx create mode 100644 src/ui/resourceManager/TypeLibraryTab.tsx create mode 100644 src/ui/resourceManager/WindowStylesTab.tsx create mode 100644 src/ui/resourceManager/fields.tsx create mode 100644 src/viewport/planHitTest.ts diff --git a/package-lock.json b/package-lock.json index e5881b2..a935bfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "dxf-parser": "^1.1.2", "jspdf": "^4.2.1", "jszip": "^3.10.1", - "opencascade.js": "^1.1.1", + "pdfjs-dist": "^6.2.108", "polygon-clipping": "^0.15.7", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -875,6 +875,271 @@ "node": ">=20" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz", + "integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.3", + "@napi-rs/canvas-darwin-arm64": "1.0.3", + "@napi-rs/canvas-darwin-x64": "1.0.3", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.3", + "@napi-rs/canvas-linux-arm64-musl": "1.0.3", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.3", + "@napi-rs/canvas-linux-x64-gnu": "1.0.3", + "@napi-rs/canvas-linux-x64-musl": "1.0.3", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.3", + "@napi-rs/canvas-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz", + "integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz", + "integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz", + "integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -3255,12 +3520,6 @@ "node": ">=12.20.0" } }, - "node_modules/opencascade.js": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/opencascade.js/-/opencascade.js-1.1.1.tgz", - "integrity": "sha512-lw6/vOl86+CkJ8d3V01mlbGAC0A49gc1HbwGcqGeKjk5SGRLiF15jyUuA8aYEvizcPNTu4Ta4A+Ut2DJgsa7AQ==", - "license": "LGPL-2.1-only" - }, "node_modules/pako": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", @@ -3284,6 +3543,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "6.2.108", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", + "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/package.json b/package.json index 581e50d..31ee475 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dxf-parser": "^1.1.2", "jspdf": "^4.2.1", "jszip": "^3.10.1", - "opencascade.js": "^1.1.1", + "pdfjs-dist": "^6.2.108", "polygon-clipping": "^0.15.7", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fcb92f..344f03c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -394,7 +394,6 @@ name = "cad-tauri" version = "0.1.0" dependencies = [ "fs4", - "geometry", "pollster", "render2d", "render3d", @@ -1585,16 +1584,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "geometry" -version = "0.1.0" -dependencies = [ - "console_error_panic_hook", - "serde", - "serde_json", - "wasm-bindgen", -] - [[package]] name = "gethostname" version = "1.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 749fe60..5448faa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,14 +1,13 @@ [workspace] members = ["."] -# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne +# render2d/render3d sind eigenstaendige Crates, damit sie headless ohne # Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie # liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — # hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit -# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut -# 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", "trucksolid"] +# nutzen kann. 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", "kernel2d", "dwgimport", "trucksolid"] [package] name = "cad-tauri" @@ -45,7 +44,6 @@ tauri-plugin-http = "2" tauri-plugin-shell = "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). diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ee359b0..8684b03 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss. Dialog/Fs: nativer Oeffnen- UND Speichern-Dialog fuer Projektdateien (.obp/.json) plus Lesen/Schreiben der gewaehlten Datei (io/projectFile.ts, io/saveFile.ts) - open+read fuer Projekt laden, save+write fuer Projekt/Export speichern. Updater/Process: Selbst-Update-Check beim Start (src/update/checkForUpdate.ts) + Neustart nach Installation. Http: Versionsverlauf (versions.json vom Gitea-Release) ohne Browser-CORS, Scope auf den eigenen Gitea-Host begrenzt. Shell:allow-open: oeffnet den DMG-Download einer aelteren Version im System-Browser (Rollback).", + "description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss. Dialog/Fs: nativer Oeffnen- UND Speichern-Dialog fuer Projektdateien (.obp/.json) plus Lesen/Schreiben der gewaehlten Datei (io/projectFile.ts, io/saveFile.ts) - open+read fuer Projekt laden, save+write fuer Projekt/Export speichern. Updater/Process: Selbst-Update-Check beim Start (src/update/checkForUpdate.ts) + Neustart nach Installation. Http: Versionsverlauf (versions.json vom Gitea-Release) ohne Browser-CORS, Scope auf den eigenen Gitea-Host begrenzt. Shell:allow-open: oeffnet den DMG-Download einer aelteren Version im System-Browser (Rollback). allow-set-size/allow-center: kompaktes Splash-/Startbildschirm-Fenster vor dem Editor (App.tsx bootPhase).", "windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"], "permissions": [ "core:default", @@ -13,6 +13,8 @@ "core:window:allow-close", "core:window:allow-start-dragging", "core:window:allow-create", + "core:window:allow-set-size", + "core:window:allow-center", "core:webview:allow-create-webview-window", "core:menu:default", "core:event:default", diff --git a/src-tauri/geometry/Cargo.lock b/src-tauri/geometry/Cargo.lock deleted file mode 100644 index 5cb3a2b..0000000 --- a/src-tauri/geometry/Cargo.lock +++ /dev/null @@ -1,75 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "geometry" -version = "0.1.0" -dependencies = [ - "serde", -] - -[[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 = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[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 = "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 = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/src-tauri/geometry/Cargo.toml b/src-tauri/geometry/Cargo.toml deleted file mode 100644 index 23747fe..0000000 --- a/src-tauri/geometry/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "geometry" -version = "0.1.0" -edition = "2021" -description = "Serde-only Geometrie-Kern (Wand-Verschneidungen/Joins) — headless und per wasm-pack (Feature \"web\") zu WASM baubar; Single Source of Truth fuer TS + nativen Host." - -# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib: -# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri) und im Test-Build -# nutzbar bleibt. Der cdylib-Artefakt-Build auf nativen Zielen ist harmlos (leere -# Export-Oberflaeche ohne Feature "web"). Muster: render2d/render3d. -[lib] -crate-type = ["cdylib", "rlib"] - -[features] -# Standard: reine serde-Geometrie, headless per `cargo test` pruefbar. -default = [] -# Browser-Bindings: dieselbe Geometrie hinter einer wasm-bindgen-Fassade -# (`compute_joins_json`), aus TS via wasm-pack aufgerufen. Baut nur fuer -# target wasm32-unknown-unknown sinnvoll. Muster: render3d/Cargo.toml. -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 } - -# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen. -[dev-dependencies] -serde_json = "1" diff --git a/src-tauri/geometry/examples/parity.rs b/src-tauri/geometry/examples/parity.rs deleted file mode 100644 index d63d48c..0000000 --- a/src-tauri/geometry/examples/parity.rs +++ /dev/null @@ -1,16 +0,0 @@ -// Paritaets-Harness: liest ein JoinInput-JSON von stdin, rechnet compute_joins -// und schreibt das WallCuts-JSON nach stdout. Dient dem TS<->Rust-Vergleich -// (siehe src/compute/parity.test.ts) — identische Eingabe, identische Ausgabe. - -use std::io::Read; - -fn main() { - let mut buf = String::new(); - std::io::stdin() - .read_to_string(&mut buf) - .expect("stdin lesen"); - let input: geometry::JoinInput = serde_json::from_str(&buf).expect("JoinInput parsen"); - let cuts = geometry::compute_joins(input); - let out = serde_json::to_string(&cuts).expect("WallCuts serialisieren"); - println!("{out}"); -} diff --git a/src-tauri/geometry/src/lib.rs b/src-tauri/geometry/src/lib.rs deleted file mode 100644 index 985aab7..0000000 --- a/src-tauri/geometry/src/lib.rs +++ /dev/null @@ -1,1057 +0,0 @@ -// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Waende aufeinander- -// treffen, sollen sich die Schicht-Baender nicht ueberlappen, sondern an einer -// gemeinsamen Gehrungslinie sauber stossen. Dieses Modul berechnet pro Wand -// die optionalen Schnittlinien an Start- und Endpunkt. -// -// Reine 2D-Mathematik, kein externer Kernel noetig. Portiert aus dem -// TS-Modell (joins.ts / geometry.ts); die Vektor-Helfer add/sub/scale/ -// normalize/leftNormal/len/lineIntersect sind unten dupliziert. - -use serde::{Deserialize, Serialize}; - -/// Browser-Fassade (Feature "web"): JSON rein/raus, damit das TS-Frontend die -/// EINE Rust-Join-Implementierung per wasm-pack aufrufen kann (Single Source of -/// Truth statt TS-Duplikat). `input_json` = serialisiertes `JoinInput`, Rueckgabe -/// = serialisiertes `Vec`. -#[cfg(feature = "web")] -#[wasm_bindgen::prelude::wasm_bindgen] -pub fn compute_joins_json(input_json: &str) -> Result { - console_error_panic_hook::set_once(); - let input: JoinInput = serde_json::from_str(input_json) - .map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))?; - let out = compute_joins(input); - serde_json::to_string(&out).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string())) -} - -#[derive(Serialize, Deserialize, Clone, Copy)] -pub struct Vec2 { - pub x: f64, - pub y: f64, -} - -/// Eine unendliche Gerade als Stuetzpunkt + Richtung. -#[derive(Serialize, Deserialize, Clone, Copy)] -pub struct Line { - pub point: Vec2, - pub dir: Vec2, -} - -/// Eine Schicht einer Wand, bereits aus dem WallType aufgeloest (Komponenten-Id -/// + Prioritaet + Dicke) -- fuer die materialbewusste T-Stoss-Erweiterung -/// (siehe `resolve_join_priority`/`apply_layer_cuts`). ADDITIV: fehlt der Key -/// `layers` in der JSON-Eingabe, liefert `#[serde(default)]` eine leere Liste, -/// sodass die bestehende Paritaets-Eingabe (ohne Schichten) unveraendert -/// funktioniert. -#[derive(Serialize, Deserialize, Clone)] -pub struct LayerInput { - #[serde(rename = "componentId")] - pub component_id: String, - #[serde(rename = "joinPriority")] - pub join_priority: f64, - pub thickness: f64, -} - -#[derive(Serialize, Deserialize)] -pub struct WallInput { - pub id: String, - pub start: Vec2, - pub end: Vec2, - pub thickness: f64, - #[serde(rename = "referenceOffset")] - pub reference_offset: f64, - #[serde(default)] - pub layers: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct JoinInput { - pub walls: Vec, -} - -/// Pro-Schicht-Override der Nahflaechen-Cuts (materialbewusster T-Stoss). -/// Index = Schicht-Index in `WallInput.layers` der Abzweig-Wand. Spiegelt -/// `LayerCuts` aus `model/joins.ts` 1:1. -#[derive(Serialize, Deserialize, Clone)] -pub struct LayerCuts { - pub start: Vec>, - pub end: Vec>, - #[serde(rename = "startSide")] - pub start_side: Vec>, - #[serde(rename = "endSide")] - pub end_side: Vec>, -} - -/// Aussparung der DURCHGANGSWAND am materialbewussten T-Stoss -- Rust-Spiegel -/// von `SpanCutout` aus `model/joins.ts`. `from`/`to` = Achsen-Intervall (Meter -/// ab `wall.start`), `offA`/`offB` = Quer-Offsetbereich (Nah-Putz-Zone) in der -/// Offset-Konvention der Schicht-Baender; `offA` ist stets die Nahflaeche. -#[derive(Serialize, Deserialize, Clone, Copy)] -pub struct SpanCutout { - pub from: f64, - pub to: f64, - #[serde(rename = "offA")] - pub off_a: f64, - #[serde(rename = "offB")] - pub off_b: f64, -} - -/// Schnittlinien einer Wand an ihren beiden Achsenden. -#[derive(Serialize, Deserialize)] -pub struct WallCuts { - #[serde(rename = "wallId")] - pub wall_id: String, - #[serde(rename = "startCut")] - pub start_cut: Option, - #[serde(rename = "endCut")] - pub end_cut: Option, - /// ADDITIV: nur gesetzt, wenn an einem T-Stoss mindestens eine Schicht - /// materialbewusst verschmilzt (siehe `apply_layer_cuts`). Fehlt sie in der - /// Ausgabe (`None` -> von serde bei Serialisierung weggelassen), gilt - /// weiterhin `start_cut`/`end_cut` fuer alle Schichten. - #[serde(rename = "layerCuts", skip_serializing_if = "Option::is_none")] - pub layer_cuts: Option, - /// ADDITIV (Durchgangswand-Seite): Nah-Putz-Aussparungen, durch die ein - /// Abzweig-Kern sticht. Leer -> von serde bei Serialisierung weggelassen - /// (`skip_serializing_if`), Wand wird unveraendert gezeichnet. - #[serde(rename = "spanCutouts", skip_serializing_if = "Vec::is_empty")] - pub span_cutouts: Vec, -} - -/// Verschneidungs-Entscheidung zweier Bauteile am Stoss -- Rust-Spiegel von -/// `JoinPriorityResolution`/`resolveJoinPriority` aus `model/joins.ts`. -#[derive(PartialEq)] -enum JoinPriorityResolution { - Merge, - TrimA, - TrimB, - Coexist, -} - -fn resolve_join_priority( - a_id: &str, - a_prio: f64, - b_id: &str, - b_prio: f64, -) -> JoinPriorityResolution { - if a_prio == b_prio { - if a_id == b_id { - JoinPriorityResolution::Merge - } else { - JoinPriorityResolution::Coexist - } - } else if a_prio > b_prio { - JoinPriorityResolution::TrimB - } else { - JoinPriorityResolution::TrimA - } -} - -/// Materialbewusste Pro-Schicht-Erweiterung eines T-Stoss-Cuts -- Rust-Spiegel -/// von `applyLayerCuts` aus `model/joins.ts`. Vergleicht jede Schicht der -/// Abzweig-Wand mit dem Rueckgrat (hoechste `joinPriority`) der Durchgangswand; -/// verschmilzt sie (gleiches Rueckgrat-Material, oder die Abzweig-Schicht ist -/// sogar prioritaerer), bekommt sie einen Cut an der NAHFLAECHE DES RUECKGRAT- -/// KERNS der Durchgangswand -- der verschmelzende Kern sticht nur durch den -/// Nah-Putz und stoppt am Durchgangs-Kern, statt bis zur Achse durchzulaufen. -/// Getrimmte Schichten neben einer verschmelzenden Nachbarschicht bekommen -/// zusaetzlich die seitliche L-Linie an eben dieser Rueckgrat-Nahflaeche. -/// `sign`/`off_t` = Nahseite bzw. Referenzversatz der Durchgangswand. -#[allow(clippy::too_many_arguments)] -fn apply_layer_cuts( - result: &mut [WallCuts], - branch_wall_idx: usize, - branch_end: WallEndKind, - branch_layers: &[LayerInput], - through_layers: &[LayerInput], - face_cut: Line, - axis_point: Vec2, - through_dir: Vec2, - sign: f64, - off_t: f64, - // Durchgangswand-Aussparung (Phase 1c): Index/Start der Durchgangswand, - // Achsrichtung + Referenzversatz des Abzweigs -- fuer die Projektion der - // Kernbreite auf die Durchgangsachse. - through_wall_idx: usize, - through_start: Vec2, - branch_dir: Vec2, - branch_ref_off: f64, -) { - if branch_layers.is_empty() || through_layers.is_empty() { - return; - } - - // Rueckgrat der Durchgangswand: die Schicht mit der hoechsten joinPriority - // (Index mitgefuehrt fuer die Nah-Putz-Dicke davor). - let mut backbone = &through_layers[0]; - let mut backbone_idx = 0usize; - for (i, l) in through_layers.iter().enumerate() { - if l.join_priority > backbone.join_priority { - backbone = l; - backbone_idx = i; - } - } - - let n = branch_layers.len(); - let mut merged = vec![false; n]; - for (i, layer) in branch_layers.iter().enumerate() { - let res = resolve_join_priority( - &layer.component_id, - layer.join_priority, - &backbone.component_id, - backbone.join_priority, - ); - merged[i] = matches!(res, JoinPriorityResolution::Merge | JoinPriorityResolution::TrimB); - } - if !merged.iter().any(|&m| m) { - return; // keine Materialuebereinstimmung -> Default (kein layer_cuts) - } - - let cuts = &mut result[branch_wall_idx]; - if cuts.layer_cuts.is_none() { - cuts.layer_cuts = Some(LayerCuts { - start: vec![None; n], - end: vec![None; n], - start_side: vec![None; n], - end_side: vec![None; n], - }); - } - let lc = cuts.layer_cuts.as_mut().unwrap(); - if lc.start.len() != n { - return; // Schicht-Anzahl passt nicht zusammen (sollte nicht vorkommen) - } - - // Nahflaechen-Cut des verschmelzenden Kerns: von der Durchgangswand-Nah- - // flaeche (off_t + sign*t_t/2) um die Nah-Putz-Dicke nach innen versetzt = - // die dem Abzweig zugewandte Flaeche des Rueckgrat-Kerns. Nah-Putz = Summe - // der Durchgangswand-Schichten zwischen Nahseite (sign) und Rueckgrat: auf - // der +nT-Nahseite (sign>0) die Schichten hinter dem Rueckgrat - // (Index > backbone_idx), auf der -nT-Nahseite die davor. - let t_t: f64 = through_layers.iter().map(|l| l.thickness).sum(); - let mut near_plaster = 0.0; - for (i, l) in through_layers.iter().enumerate() { - let near = if sign > 0.0 { i > backbone_idx } else { i < backbone_idx }; - if near { - near_plaster += l.thickness; - } - } - let n_t = left_normal(through_dir); - let merge_point = add(axis_point, scale(n_t, off_t + sign * (t_t / 2.0 - near_plaster))); - let merge_cut = Line { point: merge_point, dir: through_dir }; - - // Getrimmte Nachbarschichten schliessen mit ihrer L-Seitenlinie an dieser - // Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die - // mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die - // Durchgangswand unten endet. - // Nah-Putz-Komponenten der Durchgangswand (dieselbe sign-Seite wie - // near_plaster oben): ist die getrimmte Abzweig-Schicht materialgleich mit - // einer davon, fuellt der Nah-Putz den Abzweig-Putz an dieser Stelle - // durchgehend (spanCutout schneidet nur die KERN-Breite aus, nicht die - // schmalen Putzflanken) -> Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie. - let near_plaster_comps: Vec<&str> = through_layers - .iter() - .enumerate() - .filter(|(i, _)| if sign > 0.0 { *i > backbone_idx } else { *i < backbone_idx }) - .map(|(_, l)| l.component_id.as_str()) - .collect(); - - { - let side_line = merge_cut; - let (face_arr, side_arr) = match branch_end { - WallEndKind::Start => (&mut lc.start, &mut lc.start_side), - WallEndKind::End => (&mut lc.end, &mut lc.end_side), - }; - for i in 0..n { - face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) }; - let adj_merged = - !merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1])); - let merges_with_near_plaster = - near_plaster_comps.contains(&branch_layers[i].component_id.as_str()); - side_arr[i] = if adj_merged && !merges_with_near_plaster { - Some(side_line) - } else { - None - }; - } - } - - // Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand ueber - // die Breite des durchstechenden Abzweig-Kerns ECHT ausschneiden. Nur noetig, - // wenn davor Nah-Putz liegt (near_plaster > 0). - if near_plaster > 1e-9 { - let total_branch: f64 = branch_layers.iter().map(|l| l.thickness).sum(); - let mut b_off = branch_ref_off - total_branch / 2.0; - let mut core_lo = f64::INFINITY; - let mut core_hi = f64::NEG_INFINITY; - for (i, l) in branch_layers.iter().enumerate() { - if merged[i] { - if b_off < core_lo { - core_lo = b_off; - } - if b_off + l.thickness > core_hi { - core_hi = b_off + l.thickness; - } - } - b_off += l.thickness; - } - if core_hi > core_lo { - // Projektion der Kern-Offsethuelle auf die Durchgangsachse. - let n_branch = left_normal(branch_dir); - let proj = dot(n_branch, through_dir); - let axis_at = dot(sub(axis_point, through_start), through_dir); - let e1 = axis_at + core_lo * proj; - let e2 = axis_at + core_hi * proj; - let off_a = off_t + sign * (t_t / 2.0); - let off_b = off_t + sign * (t_t / 2.0 - near_plaster); - result[through_wall_idx].span_cutouts.push(SpanCutout { - from: e1.min(e2), - to: e1.max(e2), - off_a, - off_b, - }); - } - } -} - -// --- Vektor-Helfer ----------------------------------------------------------- - -fn sub(a: Vec2, b: Vec2) -> Vec2 { - Vec2 { x: a.x - b.x, y: a.y - b.y } -} - -fn add(a: Vec2, b: Vec2) -> Vec2 { - Vec2 { x: a.x + b.x, y: a.y + b.y } -} - -fn scale(a: Vec2, s: f64) -> Vec2 { - Vec2 { x: a.x * s, y: a.y * s } -} - -fn len(a: Vec2) -> f64 { - a.x.hypot(a.y) -} - -fn normalize(a: Vec2) -> Vec2 { - let l = len(a); - let l = if l == 0.0 { 1.0 } else { l }; - Vec2 { x: a.x / l, y: a.y / l } -} - -/// Linke Normale (90 Grad gegen den Uhrzeigersinn gedreht). -fn left_normal(a: Vec2) -> Vec2 { - Vec2 { x: -a.y, y: a.x } -} - -/// Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. -fn cross(p: Vec2, q: Vec2) -> f64 { - p.x * q.y - p.y * q.x -} - -/// Skalarprodukt zweier 2D-Vektoren. -fn dot(p: Vec2, q: Vec2) -> f64 { - p.x * q.x + p.y * q.y -} - -/// Schnittpunkt der Geraden (a + t*da) mit (b + s*db). -/// Liefert None bei (nahezu) parallelen Richtungen. -fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option { - let denom = cross(da, db); - if denom.abs() < 1e-9 { - return None; // parallel -> kein Schnitt - } - let t = cross(sub(b, a), db) / denom; - Some(add(a, scale(da, t))) -} - -// --- Verschneidung ----------------------------------------------------------- - -/// Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. -fn round_key(p: Vec2) -> String { - let r = |v: f64| (v * 1e4).round() / 1e4; - format!("{},{}", r(p.x), r(p.y)) -} - -#[derive(Clone, Copy, PartialEq)] -enum WallEndKind { - Start, - End, -} - -struct WallEnd { - wall_id: String, - end: WallEndKind, -} - -/// Achsrichtung start->end, normalisiert. -fn dir_of(w: &WallInput) -> Vec2 { - normalize(sub(w.end, w.start)) -} - -/** - * Berechnet fuer jede Wand die Gehrungs-Schnittlinien. - * Behandelt werden: L-Ecken (genau zwei Wandenden treffen sich), T-Knoten - * (drei Wandenden treffen sich, zwei davon kollinear) und Mittelspannen- - * T-Stoesse (ein freies Wandende trifft auf die Seite einer anderen Wand). - * X-Stoesse (vier und mehr Enden) bleiben vorerst rechtwinklig. - */ -pub fn compute_joins(input: JoinInput) -> Vec { - let walls = input.walls; - - // Ergebnis: pro Wand ein Eintrag, Reihenfolge wie in der Eingabe. - let mut result: Vec = walls - .iter() - .map(|w| WallCuts { - wall_id: w.id.clone(), - start_cut: None, - end_cut: None, - layer_cuts: None, - span_cutouts: Vec::new(), - }) - .collect(); - - // Index Wand-Id -> Position im Ergebnis/Eingabe. - let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for (i, w) in walls.iter().enumerate() { - index.insert(w.id.as_str(), i); - } - - // Knotenkarte: gerundeter Endpunkt -> Liste der dort endenden Wandenden. - // BTreeMap fuer deterministische Reihenfolge. - let mut junctions: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - let push = |p: Vec2, we: WallEnd, m: &mut std::collections::BTreeMap>| { - m.entry(round_key(p)).or_default().push(we); - }; - for w in &walls { - push( - w.start, - WallEnd { wall_id: w.id.clone(), end: WallEndKind::Start }, - &mut junctions, - ); - push( - w.end, - WallEnd { wall_id: w.id.clone(), end: WallEndKind::End }, - &mut junctions, - ); - } - - for (_key, ends) in &junctions { - // Freies Ende -> kein Schnitt hier (wird unten separat auf Mittelspannen-T - // geprueft, Fall 2). - if ends.len() == 1 { - continue; - } - - if ends.len() == 2 { - let a_idx = match index.get(ends[0].wall_id.as_str()) { - Some(i) => *i, - None => continue, - }; - let b_idx = match index.get(ends[1].wall_id.as_str()) { - Some(i) => *i, - None => continue, - }; - let a = &walls[a_idx]; - let b = &walls[b_idx]; - - let cut = match miter_line(a, ends[0].end, b) { - Some(c) => c, - None => continue, // kollinear -> kein Schnitt - }; - - set_cut(&mut result[a_idx], ends[0].end, cut); - set_cut(&mut result[b_idx], ends[1].end, cut); - continue; - } - - if ends.len() == 3 { - apply_t_junction(&walls, &index, ends, &mut result); - continue; - } - - // X-Stoesse u. ae. (>3 Enden): vorerst rechtwinklig lassen (Folge-Arbeit). - } - - // Fall 2: Mittelspannen-T-Stoss. Ein freies Wandende (kein anderes Wandende - // teilt sich seinen Knoten) kann trotzdem auf die Seite einer anderen Wand - // treffen (nicht an deren Endpunkten). In dem Fall bekommt das freie Ende - // einen Schnitt entlang der zugewandten Flaeche dieser Wand. - for (_key, ends) in &junctions { - if ends.len() != 1 { - continue; - } - apply_mid_span_tee(&walls, &index, &ends[0], &mut result); - } - - result -} - -/// Traegt eine Schnittlinie am passenden Ende einer Wand ein. -fn set_cut(cuts: &mut WallCuts, end: WallEndKind, cut: Line) { - match end { - WallEndKind::Start => cuts.start_cut = Some(cut), - WallEndKind::End => cuts.end_cut = Some(cut), - } -} - -/** - * Fall 1 -- T-Knoten: drei Wandenden treffen sich im selben (gerundeten) - * Knoten. Typisch sind zwei davon kollinear (die zwei Haelften der - * Durchgangswand, die geometrisch als eine Achse durchlaeuft) und das dritte - * zweigt ab. Die kollinearen zwei bleiben ungeschnitten; der Abzweig bekommt - * einen Schnitt entlang der ihm zugewandten Flaeche der Durchgangswand. - * Findet sich kein eindeutig kollineares Paar (z. B. echter Y-/X-Knoten), - * bleibt der Knoten unangetastet (rechtwinklig). - */ -fn apply_t_junction( - walls: &[WallInput], - index: &std::collections::HashMap<&str, usize>, - ends: &[WallEnd], - result: &mut [WallCuts], -) { - let idx_of = |id: &str| index.get(id).copied(); - - let dirs: Vec = match ends - .iter() - .map(|e| idx_of(e.wall_id.as_str()).map(|i| dir_of(&walls[i]))) - .collect::>>() - { - Some(d) => d, - None => return, - }; - - // Suche das (naeherungsweise) kollineare Paar: |cos(Winkel der Achsen)| ~ 1. - let mut through_pair: Option<(usize, usize)> = None; - 'outer: for i in 0..3 { - for k in (i + 1)..3 { - if 1.0 - dot(dirs[i], dirs[k]).abs() < 1e-6 { - through_pair = Some((i, k)); - break 'outer; - } - } - } - let (ti, tk) = match through_pair { - Some(p) => p, - None => return, // kein eindeutiger Durchgang erkennbar - }; - - let branch_idx = match (0..3).find(|&i| i != ti && i != tk) { - Some(i) => i, - None => return, - }; - let branch_end = &ends[branch_idx]; - let through_end = &ends[ti]; - let branch_wall_idx = match idx_of(branch_end.wall_id.as_str()) { - Some(i) => i, - None => return, - }; - let through_wall_idx = match idx_of(through_end.wall_id.as_str()) { - Some(i) => i, - None => return, - }; - let branch_wall = &walls[branch_wall_idx]; - let through_wall = &walls[through_wall_idx]; - - let j = match through_end.end { - WallEndKind::Start => through_wall.start, - WallEndKind::End => through_wall.end, - }; - let t_t = through_wall.thickness; - let u_t = dir_of(through_wall); - let n_t = left_normal(u_t); - let off_t = through_wall.reference_offset; - - // Auslaufrichtung des Abzweigs vom Knoten weg, in seinen eigenen Koerper - // (gleiche Konvention wie d_a/d_b in miter_line). - let d_branch = match branch_end.end { - WallEndKind::Start => dir_of(branch_wall), - WallEndKind::End => scale(dir_of(branch_wall), -1.0), - }; - - // Zugewandte Flaeche: die Seite der Durchgangsachse, zu der der Abzweig - // zeigt (positive n_t-Seite, wenn der Abzweig dorthin auslaeuft). - let sign = if dot(n_t, d_branch) >= 0.0 { 1.0 } else { -1.0 }; - let face_point = add(j, scale(n_t, off_t + sign * (t_t / 2.0))); - let face_cut = Line { point: face_point, dir: u_t }; - - set_cut(&mut result[branch_wall_idx], branch_end.end, face_cut); - apply_layer_cuts( - result, - branch_wall_idx, - branch_end.end, - &branch_wall.layers, - &through_wall.layers, - face_cut, - j, - u_t, - sign, - off_t, - through_wall_idx, - through_wall.start, - dir_of(branch_wall), - branch_wall.reference_offset, - ); -} - -/** - * Fall 2 -- Mittelspannen-T-Stoss: prueft, ob das freie Wandende `free_end` - * auf die innere Spanne einer anderen Wand trifft (projizierter Parameter im - * Inneren, nicht an deren Endpunkten) und dabei senkrecht nah genug an deren - * Achse liegt (<= halbe Dicke + Toleranz). Bei Treffer bekommt das freie Ende - * einen Schnitt entlang der zugewandten Flaeche der getroffenen Wand; bei - * mehreren Treffern gewinnt die naechstliegende Wand. - */ -fn apply_mid_span_tee( - walls: &[WallInput], - index: &std::collections::HashMap<&str, usize>, - free_end: &WallEnd, - result: &mut [WallCuts], -) { - let free_wall_idx = match index.get(free_end.wall_id.as_str()) { - Some(i) => *i, - None => return, - }; - let free_wall = &walls[free_wall_idx]; - let p = match free_end.end { - WallEndKind::Start => free_wall.start, - WallEndKind::End => free_wall.end, - }; - - let end_gap = 1e-4; // Mindestabstand zu den Wandenden fuer "innere Spanne" - let tol = 1e-3; // Toleranz fuer den senkrechten Abstand zur Wandflaeche - - // (Index in `walls`, perp-Abstand vorzeichenbehaftet, absoluter Abstand). - let mut best: Option<(usize, f64, f64)> = None; - for (i, w) in walls.iter().enumerate() { - if w.id == free_wall.id { - continue; - } - let u = dir_of(w); - let w_len = len(sub(w.end, w.start)); - if w_len < 1e-9 { - continue; - } - - let rel = sub(p, w.start); - let dist_along = dot(rel, u); - if dist_along <= end_gap || dist_along >= w_len - end_gap { - continue; // an Endpunkt, kein Mittelspann - } - - let perp = dot(rel, left_normal(u)); - let tw = w.thickness; - let dist = perp.abs(); - if dist > tw / 2.0 + tol { - continue; - } - - if best.map_or(true, |(_, _, best_dist)| dist < best_dist) { - best = Some((i, perp, dist)); - } - } - let (w_idx, perp, _dist) = match best { - Some(b) => b, - None => return, // kein Treffer -> rechtwinklig - }; - - let w = &walls[w_idx]; - let tw = w.thickness; - let u_w = dir_of(w); - let n_w = left_normal(u_w); - let off_w = w.reference_offset; - let sign = if perp >= 0.0 { 1.0 } else { -1.0 }; - let face_point = add(w.start, scale(n_w, off_w + sign * (tw / 2.0))); - let face_cut = Line { point: face_point, dir: u_w }; - - set_cut(&mut result[free_wall_idx], free_end.end, face_cut); - // Achsenpunkt auf der getroffenen Wand, auf Hoehe der Projektion des - // freien Endes (nicht zwingend w.start) -- Anker fuer die - // materialbewusste Pro-Schicht-Erweiterung (analog zum T-Knoten-Fall). - let dist_along = dot(sub(p, w.start), u_w); - let axis_point = add(w.start, scale(u_w, dist_along)); - apply_layer_cuts( - result, - free_wall_idx, - free_end.end, - &free_wall.layers, - &w.layers, - face_cut, - axis_point, - u_w, - sign, - off_w, - w_idx, - w.start, - dir_of(free_wall), - free_wall.reference_offset, - ); -} - -/** - * Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen. - * Robust gegen beliebige Wicklung, ungleiche Dicken und JEDEN Oeffnungswinkel - * (inkl. spitzer): Die Wandflaechen werden orientierungsbasiert (vorzeichen- - * richtig) gepaart -- A's Aussenflaeche verschneidet B's Aussenflaeche zum - * aeusseren Apex, A's Innenflaeche B's Innenflaeche zum inneren Apex. "Aussen" - * ist jeweils die vom Koerper der anderen Wand ABGEWANDTE Flaeche. Die Gerade - * durch beide Apexe ist die Gehrung; sie trennt beide Wandbaender ueberlappungs- - * frei. - * - * Die fruehere Distanz-Heuristik ("naechstgelegene B-Flaeche") kippt bei spitzen - * Winkeln -- dort wird die falsche Flaeche zur naeheren, sodass Aussen mit Innen - * gepaart wird und die Poche-Baender sich kreuzweise ueberlappen. - * - * Dicke und Referenzversatz kommen direkt aus WallInput (bereits flach). - */ -fn miter_line(a: &WallInput, a_end: WallEndKind, b: &WallInput) -> Option { - let j = match a_end { - WallEndKind::Start => a.start, - WallEndKind::End => a.end, - }; - - let t_a = a.thickness; - let t_b = b.thickness; - let u_a = dir_of(a); - let u_b = dir_of(b); - let n_a = left_normal(u_a); - let n_b = left_normal(u_b); - - // Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden - // Wandflaechen um diesen Betrag entlang +n verschoben. Fuer "center" - // (Default) ist off=0 -> unveraendert. - let off_a = a.reference_offset; - let off_b = b.reference_offset; - - // Flaechen-Stuetzpunkte am Knoten (linke/rechte Wandseite), inkl. Versatz. - let p_la = add(j, scale(n_a, off_a + t_a / 2.0)); - let p_ra = add(j, scale(n_a, off_a - t_a / 2.0)); - let p_lb = add(j, scale(n_b, off_b + t_b / 2.0)); - let p_rb = add(j, scale(n_b, off_b - t_b / 2.0)); - - // Auslauf-Richtungen der Achsen vom Knoten weg, in den jeweiligen Wandkoerper. - let d_a = match a_end { - WallEndKind::Start => u_a, - WallEndKind::End => scale(u_a, -1.0), - }; - let b_at_start = len(sub(b.start, j)) <= len(sub(b.end, j)); - let d_b = if b_at_start { u_b } else { scale(u_b, -1.0) }; - - // Orientierungsbasierte, vorzeichenrichtige Flaechen-Paarung. "Aussen" ist die - // vom Koerper der anderen Wand ABGEWANDTE Flaeche: A's +nA-Flaeche (p_la) liegt - // aussen, wenn nA*dB < 0; B's +nB-Flaeche (p_lb) aussen, wenn nB*dA < 0. Gepaart - // wird Aussen-mit-Aussen und Innen-mit-Innen -- nie ueber Kreuz. (nA*dB = 0 tritt - // nur bei kollinearen Achsen auf; dann sind die Flaechen parallel und - // line_intersect liefert unten ohnehin None.) - let b_outer = if dot(n_b, d_a) < 0.0 { p_lb } else { p_rb }; - let b_inner = if dot(n_b, d_a) < 0.0 { p_rb } else { p_lb }; - let a_left_is_outer = dot(n_a, d_b) < 0.0; - let b_for_left = if a_left_is_outer { b_outer } else { b_inner }; - let b_for_right = if a_left_is_outer { b_inner } else { b_outer }; - - // c1 an A's linke Flaeche (p_la), c2 an A's rechte (p_ra) gebunden -- Reihenfolge - // wie zuvor, damit rechte/stumpfe Ecken bit-identisch bleiben; nur die - // B-Partnerwahl ist jetzt orientierungs- statt distanzbasiert. - let c1 = line_intersect(p_la, u_a, b_for_left, u_b)?; - let c2 = line_intersect(p_ra, u_a, b_for_right, u_b)?; - - let dir = sub(c2, c1); - if len(dir) < 1e-9 { - return None; - } - Some(Line { point: c1, dir }) -} - -// --- Tests ------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - fn w(id: &str, sx: f64, sy: f64, ex: f64, ey: f64, thickness: f64) -> WallInput { - WallInput { - id: id.to_string(), - start: Vec2 { x: sx, y: sy }, - end: Vec2 { x: ex, y: ey }, - thickness, - reference_offset: 0.0, - layers: Vec::new(), - } - } - - fn find<'a>(cuts: &'a [WallCuts], id: &str) -> &'a WallCuts { - cuts.iter().find(|c| c.wall_id == id).expect("wall id present") - } - - #[test] - fn l_corner_shared_miter() { - // Wand A (0,0)-(5,0) und Wand B (5,0)-(5,4) treffen sich in (5,0). - let input = JoinInput { - walls: vec![ - w("A", 0.0, 0.0, 5.0, 0.0, 0.2), - w("B", 5.0, 0.0, 5.0, 4.0, 0.2), - ], - }; - let out = compute_joins(input); - assert_eq!(out.len(), 2); - - let ca = find(&out, "A"); - let cb = find(&out, "B"); - - // A endet im Knoten -> endCut gesetzt; B startet dort -> startCut gesetzt. - let a_cut = ca.end_cut.expect("A endCut set"); - let b_cut = cb.start_cut.expect("B startCut set"); - assert!(ca.start_cut.is_none(), "A startCut is free end"); - assert!(cb.end_cut.is_none(), "B endCut is free end"); - - // Beide Waende teilen sich dieselbe Gehrungslinie. - assert!((a_cut.point.x - b_cut.point.x).abs() < 1e-9); - assert!((a_cut.point.y - b_cut.point.y).abs() < 1e-9); - assert!((a_cut.dir.x - b_cut.dir.x).abs() < 1e-9); - assert!((a_cut.dir.y - b_cut.dir.y).abs() < 1e-9); - - // Sanity: die Gehrung einer 90-Grad-Ecke gleicher Dicke ist die - // Diagonale durch (5,0), Richtung parallel zu (1,1) oder (-1,-1). - let d = normalize(a_cut.dir); - assert!( - (d.x.abs() - d.y.abs()).abs() < 1e-6, - "45-Grad-Gehrung erwartet" - ); - } - - #[test] - fn acute_corner_pairs_outer_with_outer() { - // Symmetrische V-Ecke, Knoten (0,0), Oeffnungswinkel 60 Grad (spitz), - // nach oben. Beide Wandkoerper laufen unter +/-30 Grad zur +y-Achse aus. - // WA endet im Knoten, WB startet dort. Erwartet: vertikale Gehrung durch - // Aussen-Apex (0,-0.4) und Innen-Apex (0,0.4). Die alte Distanz-Heuristik - // lieferte hier eine um 90 Grad verdrehte, horizontale Gehrung. - let s3 = 3.0_f64.sqrt() / 2.0; - let input = JoinInput { - walls: vec![ - w("WA", -1.0, 2.0 * s3, 0.0, 0.0, 0.4), - w("WB", 0.0, 0.0, 1.0, 2.0 * s3, 0.4), - ], - }; - let out = compute_joins(input); - let ca = find(&out, "WA"); - let cb = find(&out, "WB"); - let cut = ca.end_cut.or(cb.start_cut).expect("Gehrung gesetzt"); - - // Abstand Punkt->Gerade: |cross(dir, q - point)| / len(dir). - let dist = |q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir); - assert!(dist(Vec2 { x: 0.0, y: -0.4 }) < 1e-6, "Aussen-Apex auf Gehrung"); - assert!(dist(Vec2 { x: 0.0, y: 0.4 }) < 1e-6, "Innen-Apex auf Gehrung"); - // Gehrung vertikal: dir.x ~ 0 (die falsche, horizontale Gehrung haette - // stattdessen dir.y ~ 0 gehabt). - assert!(cut.dir.x.abs() / len(cut.dir) < 1e-6, "vertikale Gehrung erwartet"); - } - - #[test] - fn free_end_no_cut() { - let input = JoinInput { - walls: vec![w("A", 0.0, 0.0, 5.0, 0.0, 0.2)], - }; - let out = compute_joins(input); - assert_eq!(out.len(), 1); - let ca = find(&out, "A"); - assert!(ca.start_cut.is_none()); - assert!(ca.end_cut.is_none()); - } - - #[test] - fn t_junction_branch_gets_face_cut() { - // Drei Enden treffen sich in (5,0): A/C sind kollinear (Durchgangswand), - // B zweigt ab. A/C bleiben ungeschnitten; B bekommt einen Schnitt entlang - // der ihm zugewandten Flaeche der Durchgangswand (hier: y = +0.1, da B - // nach +y auslaeuft und die Durchgangswand entlang +x verlaeuft, also - // liegt deren linke Flaeche bei +0.1). - let input = JoinInput { - walls: vec![ - w("A", 0.0, 0.0, 5.0, 0.0, 0.2), - w("B", 5.0, 0.0, 5.0, 4.0, 0.2), - w("C", 5.0, 0.0, 10.0, 0.0, 0.2), - ], - }; - let out = compute_joins(input); - assert_eq!(out.len(), 3); - - let ca = find(&out, "A"); - let cc = find(&out, "C"); - assert!(ca.start_cut.is_none(), "A startCut ist freies Ende"); - assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt"); - assert!(cc.start_cut.is_none(), "C durchgehend -> kein Schnitt"); - assert!(cc.end_cut.is_none(), "C endCut ist freies Ende"); - - let cb = find(&out, "B"); - let cut = cb.start_cut.expect("B startCut gesetzt"); - assert!(cb.end_cut.is_none(), "B endCut ist freies Ende"); - assert!((cut.point.x - 5.0).abs() < 1e-9); - assert!((cut.point.y - 0.1).abs() < 1e-9, "Flaeche bei y=+0.1 erwartet"); - // Schnittlinie verlaeuft entlang der Durchgangsachse (parallel zu +x). - assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9); - } - - #[test] - fn collinear_pair_no_cut() { - // A (0,0)-(5,0) und B (5,0)-(10,0): parallel -> None. - let input = JoinInput { - walls: vec![ - w("A", 0.0, 0.0, 5.0, 0.0, 0.2), - w("B", 5.0, 0.0, 10.0, 0.0, 0.2), - ], - }; - let out = compute_joins(input); - assert_eq!(out.len(), 2); - for id in ["A", "B"] { - let c = find(&out, id); - assert!(c.start_cut.is_none(), "{id} startCut none"); - assert!(c.end_cut.is_none(), "{id} endCut none"); - } - } - - #[test] - fn mid_span_tee_free_end_hits_wall_side() { - // Freie Wand B endet bei (5,0) exakt auf der Achse der durchgehenden - // Wand A (0,0)-(10,0), mittig auf deren Spanne (nicht an deren Enden). - // B's anderes Ende (5,2) bleibt frei (zu weit von A entfernt). Der - // Treffer liegt exakt auf A's Achse (perp=0) -> die zugewandte Flaeche - // ist A's linke Flaeche bei y=+tw/2=+0.1 (sign=+1 per Konvention bei - // perp>=0). Die Schnittlinie ist als Punkt+Richtung auf A's Anker - // (A.start) verankert, verlaeuft aber unabhaengig davon entlang y=0.1. - let input = JoinInput { - walls: vec![ - w("A", 0.0, 0.0, 10.0, 0.0, 0.2), - w("B", 5.0, 2.0, 5.0, 0.0, 0.2), - ], - }; - let out = compute_joins(input); - assert_eq!(out.len(), 2); - - let ca = find(&out, "A"); - assert!(ca.start_cut.is_none(), "A durchgehend -> kein Schnitt"); - assert!(ca.end_cut.is_none(), "A durchgehend -> kein Schnitt"); - - let cb = find(&out, "B"); - assert!(cb.start_cut.is_none(), "B startCut ist freies Ende bei (5,2)"); - let cut = cb.end_cut.expect("B endCut (Mittelspann-Treffer) gesetzt"); - - // Verankert an A.start = (0,0), verschoben um A's halbe Dicke entlang - // ihrer linken Normale (0,1) -> Punkt (0, 0.1), Richtung parallel zu A. - assert!((cut.point.x - 0.0).abs() < 1e-9); - assert!((cut.point.y - 0.1).abs() < 1e-9); - assert!(cut.dir.y.abs() / len(cut.dir) < 1e-9, "Schnittlinie parallel zu A"); - - // Robuster: die Gerade verlaeuft exakt bei y=0.1, unabhaengig vom x. - let dist = |q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir); - assert!(dist(Vec2 { x: 5.0, y: 0.1 }) < 1e-9, "Treffpunkt (5,0.1) auf Gehrung"); - } - - /// W9-artiger Wandtyp: Innenputz (0.015) / Backstein-Kern (0.12) / Innenputz - /// (0.015), joinPriority 10 bzw. 50 -- Spiegel von `layeredWallProject` aus - /// `model/joins.test.ts`. - fn w_iw(id: &str, sx: f64, sy: f64, ex: f64, ey: f64) -> WallInput { - WallInput { - id: id.to_string(), - start: Vec2 { x: sx, y: sy }, - end: Vec2 { x: ex, y: ey }, - thickness: 0.15, - reference_offset: 0.0, - layers: vec![ - LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 }, - LayerInput { component_id: "brick".into(), join_priority: 50.0, thickness: 0.12 }, - LayerInput { component_id: "render-int".into(), join_priority: 10.0, thickness: 0.015 }, - ], - } - } - - #[test] - fn t_junction_layer_cuts_merge_and_trim() { - // Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom - // W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den - // Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden - // Innenputz-Schichten (Index 0/2) werden getrimmt. Da der Nah-Putz der - // Durchgangswand materialgleich ist, entsteht KEINE L-Trennnaht. - let input = JoinInput { - walls: vec![ - w_iw("A", 0.0, 0.0, 5.0, 0.0), - w_iw("B", 5.0, 0.0, 5.0, 3.0), - w_iw("C", 5.0, 0.0, 10.0, 0.0), - ], - }; - let out = compute_joins(input); - let cb = find(&out, "B"); - - // Aggregat-Cut bleibt unveraendert (bit-identisch zum bisherigen Verhalten). - let agg = cb.start_cut.expect("B startCut gesetzt"); - assert!((agg.point.x - 5.0).abs() < 1e-9); - assert!((agg.point.y - 0.075).abs() < 1e-9); - - let lc = cb.layer_cuts.as_ref().expect("layerCuts gesetzt (Backstein verschmilzt)"); - assert_eq!(lc.start.len(), 3); - - let dist = |cut: Line, q: Vec2| cross(cut.dir, sub(q, cut.point)).abs() / len(cut.dir); - - // Schicht 1 (Backstein-Kern): Cut an der Rueckgrat-Nahflaeche y=0.06 - // (Halbdicke 0.075 minus Nah-Putz 0.015), NICHT null/Achse; keine - // Seitenlinie (verschmilzt). - let l1 = lc.start[1].expect("Schicht 1 (Kern) hat Merge-Cut"); - assert!(dist(l1, Vec2 { x: 5.0, y: 0.06 }) < 1e-9); - assert!(dist(l1, Vec2 { x: 0.0, y: 0.06 }) < 1e-9); - assert!(dist(l1, Vec2 { x: 5.0, y: 0.0 }) > 1e-6); // nicht bis zur Achse - assert!(lc.start_side[1].is_none()); - - // Schichten 0/2 (Innenputz): derselbe Nahflaechen-Cut wie das Aggregat... - let l0 = lc.start[0].expect("Schicht 0 getrimmt"); - let l2 = lc.start[2].expect("Schicht 2 getrimmt"); - assert!((l0.point.y - 0.075).abs() < 1e-9); - assert!((l2.point.y - 0.075).abs() < 1e-9); - // ... aber KEINE seitliche L-Linie: der Innenputz der Durchgangswand ist - // materialgleich und fuellt den getrimmten Abzweig-Putz durchgehend - // (Putz-L ohne Trennnaht). Nur bei materialFREMDEM Nah-Putz stuende hier - // eine L-Linie. - assert!(lc.start_side[0].is_none()); - assert!(lc.start_side[2].is_none()); - - // Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet. - let ca = find(&out, "A"); - let cc = find(&out, "C"); - assert!(ca.layer_cuts.is_none()); - assert!(cc.layer_cuts.is_none()); - - // ... bekommt aber die Durchgangswand-Aussparung (Phase 1c): die als - // Durchgang gewaehlte Haelfte A wird ueber die Backstein-Kernbreite (0.12) - // im Nah-Putz-Bereich (y in [0.06,0.075]) ausgeschnitten. Das Intervall - // ist die Projektion der Kernbreite auf die Achse, zentriert am Knoten x=5. - assert_eq!(ca.span_cutouts.len(), 1, "A (Durchgang) hat genau eine Aussparung"); - let sc = ca.span_cutouts[0]; - assert!((sc.from - 4.94).abs() < 1e-9, "from = 5 - 0.06"); - assert!((sc.to - 5.06).abs() < 1e-9, "to = 5 + 0.06"); - assert!(((sc.to - sc.from) - 0.12).abs() < 1e-9, "Intervallbreite = Kernbreite"); - assert!((sc.off_a - 0.075).abs() < 1e-9, "offA = Nahflaeche"); - assert!((sc.off_b - 0.06).abs() < 1e-9, "offB = Rueckgrat-Nahflaeche"); - // Der Abzweig B traegt keine Aussparung (die gehoert der Durchgangswand). - assert!(cb.span_cutouts.is_empty()); - } - - #[test] - fn mid_span_tee_through_wall_gets_span_cutout() { - // Mittelspannen-T-Stoss (wie der reale W9-Fall): Durchgangswand W als EINE - // Wand (0,0)->(10,0), Abzweig B trifft mit seinem freien Ende (4,0) mittig - // auf ihre Seite. W bekommt EINE Aussparung um x=4, Breite = Kernbreite. - let input = JoinInput { - walls: vec![ - w_iw("W", 0.0, 0.0, 10.0, 0.0), - w_iw("B", 4.0, 3.0, 4.0, 0.0), - ], - }; - let out = compute_joins(input); - let cw = find(&out, "W"); - assert_eq!(cw.span_cutouts.len(), 1, "W hat genau eine Aussparung"); - let sc = cw.span_cutouts[0]; - assert!((sc.from - 3.94).abs() < 1e-9); - assert!((sc.to - 4.06).abs() < 1e-9); - assert!((sc.off_a - 0.075).abs() < 1e-9); - assert!((sc.off_b - 0.06).abs() < 1e-9); - // Der Abzweig selbst hat keine Aussparung. - assert!(find(&out, "B").span_cutouts.is_empty()); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ecfe5be..f728863 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,4 @@ -// Tauri-v2-Einstieg. Die eigentliche Geometrie liegt im serde-only Crate -// `geometry`; hier nur die Befehls-Bruecke und der App-Start. +// Tauri-v2-Einstieg: die Befehls-Bruecke und der App-Start. // M2: native wgpu-Viewports (2D und/oder 3D) im Tauri-Prozess. EIN Modul, EINE // winit-Event-Loop fuer beide Fenster (winit erlaubt nur eine Loop pro Prozess). @@ -32,14 +31,6 @@ 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( - input: geometry::JoinInput, -) -> Result, String> { - Ok(geometry::compute_joins(input)) -} - // Live-Spiegelung: die Webview schiebt bei jeder Modellaenderung (debounced) // die aktuelle 2D-Szene bzw. die 3D-Waende hierher; native.rs stellt sie ueber // einen EventLoopProxy in die winit-Loop zu. Die Payload kommt als rohes JSON @@ -96,7 +87,6 @@ pub fn run() { #[cfg(any(feature = "native2d", feature = "native3d"))] let builder = builder.invoke_handler(tauri::generate_handler![ - compute_joins, push_native_scene, push_native_walls, acquire_project_lock, @@ -104,7 +94,6 @@ pub fn run() { ]); #[cfg(not(any(feature = "native2d", feature = "native3d")))] let builder = builder.invoke_handler(tauri::generate_handler![ - compute_joins, acquire_project_lock, release_project_lock ]); diff --git a/src/App.tsx b/src/App.tsx index e6a6b4f..c7066b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,10 +20,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { collectVisibleCodes, flattenCategories, - getWallType, - wallTypeThickness, } from "./model/types"; -import { wallCorners } from "./model/geometry"; import { add, dot, leftNormal, normalize, scale, sub } from "./model/geometry"; import type { Ceiling, @@ -44,12 +41,7 @@ import { roofBaseElevation, roofGeometry } from "./geometry/roof"; import { setDxfImportTrigger } from "./io/dxfImportHook"; import { ImportDialog } from "./ui/ImportDialog"; import { ExportPdfDialog } from "./ui/ExportPdfDialog"; -import type { ExportPdfDecision } from "./ui/ExportPdfDialog"; -import { savePlanPdf } from "./export/exportPdf"; import { ExportDxfDialog } from "./ui/ExportDxfDialog"; -import type { ExportDxfDecision } from "./ui/ExportDxfDialog"; -import { savePlanDxf } from "./export/exportDxf"; -import { projectToWorldCoords } from "./model/geoRebase"; import type { ScheduleKind } from "./export/exportSchedule"; import { SettingsDialog } from "./ui/SettingsDialog"; import { OpeningEditorDialog } from "./ui/OpeningEditorDialog"; @@ -118,7 +110,6 @@ import { } from "./tools/transform"; import type { CopyMode, TransformOp, TransformSelection } from "./tools/transform"; import type { - MeasurementReadout, SnapResult, ToolContext, ToolDraft, @@ -139,6 +130,19 @@ import { useDrawingToolState } from "./state/useDrawingToolState"; import { useSelectionState } from "./state/useSelectionState"; import { useProjectFileActions } from "./state/useProjectFileActions"; import { useProjectActions } from "./state/useProjectActions"; +import { + useActiveToolKeys, + useBooleanOpsShortcut, + useCommandTabShortcut, + useDrawingClipboardShortcut, + useSelectAllAndDeleteShortcut, + useSplitJoinShortcut, + useToolNumberShortcuts, + useTransformShortcuts, + useUndoRedoShortcut, +} from "./state/useKeyboardShortcuts"; +import { useFileDropImport, useImageImportHandling } from "./state/useImportHandling"; +import { useExportHandlers } from "./state/useExportHandlers"; import { useThemeModeState } from "./theme/useThemeMode"; import { CommandLine } from "./ui/CommandLine"; import type { CommandLineHandle, CommandLineField, CommandLineOption } from "./ui/CommandLine"; @@ -176,9 +180,12 @@ import { FloatingPanel } from "./panels/FloatingPanel"; import { DragOverlay, usePanelDrag } from "./panels/panelDrag"; import { itemDisplay } from "./panels/displayMode"; import { PanelHostContext } from "./panels/types"; -import type { DisplayMode, DockId, PanelHost } from "./panels/types"; +import type { DockId } from "./panels/types"; +import { hostWithMode } from "./panels/host"; import type { PanelHostValue } from "./panels/host"; +import { hitTestWall } from "./viewport/planHitTest"; import { deriveSelection } from "./state/selectionInfo"; +import type { Selection } from "./state/selectionInfo"; import { centroid as roomCentroid } from "./geometry/roomArea"; import { docFromText, plainText } from "./text/richText"; import type { RichTextDoc } from "./text/richText"; @@ -276,6 +283,27 @@ export default function App() { // weiterhin über das normale Menü laufen). const [bootPhase, setBootPhase] = useState<"splash" | "start" | "editor">("splash"); + // Fenstergrösse an die Boot-Phase anpassen: Splash/Startbildschirm sind ein + // kleines, kompaktes Fenster (wie das Start-Fenster gängiger CAD-Programme), + // NICHT die volle Editor-Fenstergrösse — sonst wirkt der Startbildschirm wie + // leerer Raum in einem viel zu grossen Fenster. Beim Wechsel in "editor" auf + // die Standardgrösse aus tauri.conf.json zurück + zentrieren. Nur unter + // Tauri relevant (Browser-Fenster lässt sich per Web-API nicht sinnvoll + // resizen). + useEffect(() => { + if (!isTauriRuntime()) return; + void (async () => { + const [{ getCurrentWindow }, { LogicalSize }] = await Promise.all([ + import("@tauri-apps/api/window"), + import("@tauri-apps/api/dpi"), + ]); + const win = getCurrentWindow(); + const [w, h] = bootPhase === "editor" ? [1400, 900] : [520, 420]; + await win.setSize(new LogicalSize(w, h)); + await win.center(); + })(); + }, [bootPhase]); + // ── Globaler Zustand aus dem Store (Slices: project/selection/view/layout) ─ // Pro useStore-Aufruf EIN Feld selektieren (Referenz-Gleichheit, sonst // Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil. @@ -437,6 +465,13 @@ export default function App() { setEditTextId, } = useContextMenuState(); + // Gewählte Annotation auf einem offenen Layout-Blatt (Linie/Rechteck/Text/ + // Bild) — gehoben aus LayoutSheetView, damit die Attribute-Palette (rechtes + // Panel) dieselbe Auswahl sieht wie ein Drawing2D im Grundriss. + const [selectedLayoutAnnotationId, setSelectedLayoutAnnotationId] = useState( + null, + ); + // Beim Geschosswechsel die Wand-Auswahl + offene Overlays zurücksetzen (die // Wände eines anderen Geschosses sind eine andere Menge). Der Ansichtswechsel // (Grundriss ↔ 3D) lässt die Auswahl bestehen — dieselbe wallId gilt in @@ -471,6 +506,11 @@ export default function App() { // liest die Datei als Text und reicht sie durch parseDxf → addContextObjects. const dxfInputRef = useRef(null); + // Verstecktes Datei-Input für „Bild einfügen" (Werkzeug „image", s. tools.ts- + // Kommentar) — akzeptiert auch PDF (jede Seite wird gerastert, s. pdfImport.ts); + // beides landet als Drawing2D `{shape:"image"}`, direkt selektiert. + const imageInputRef = useRef(null); + // Den DXF-Datei-Dialog-Öffner für den „import"-Befehl registrieren (entkoppelt // über den Modul-Hook). Beim Unmount wieder abmelden. useEffect(() => { @@ -848,7 +888,28 @@ export default function App() { // Ein Klick auf ein gekoppeltes Zeichen-Werkzeug (Wand/Linie/Rechteck/Polylinie) // STARTET den passenden Befehl in der Engine und fokussiert das Befehlsfeld // („Dialog drin"). „Auswahl" beendet einen laufenden Befehl (Auswahl-Modus). + // Zeichenwerkzeuge, die auf einem offenen Layout-Blatt sinnvoll sind (Markups + // in Papier-mm, s. LayoutSheet.tsx `annotToolFromGlobal`) — dieselben Tool-IDs + // wie im Grundriss, KEIN eigenes Werkzeug nur fürs Layout (Nutzer-Feedback). + const LAYOUT_ANNOT_TOOLS: ToolId[] = ["line", "rect", "text", "textbox", "image"]; + const onSelectTool = (id: ToolId) => { + if (activeLayout) { + // Ein Layout-Blatt ist offen: PlanView/die Befehls-Engine haben keinen + // sichtbaren Canvas (LayoutSheetView ersetzt die Mitte) — Zeichenwerkzeuge + // wertet dort `activeTool` direkt aus (Klick + ggf. Datei-Dialog fürs + // Bild), alles andere (Wand/Raum/… — auf dem Blatt ohne Bedeutung) fällt + // auf „Auswahl" zurück statt eine Befehls-Engine ohne Canvas zu starten. + setActiveTool(LAYOUT_ANNOT_TOOLS.includes(id) ? id : "select"); + return; + } + if (id === "image") { + // Datei-Auswahl ist async (Dateidialog) — kein Klick-Platzieren wie bei + // den übrigen Zeichen-Werkzeugen, daher hier abgefangen statt über die + // Befehls-Engine/`activeTool` zu laufen (s. tools.ts `imageTool`-Kommentar). + imageInputRef.current?.click(); + return; + } const cmd = coupledCommandFor(id); if (cmd) { // Den gekoppelten Befehl in DERSELBEN Engine starten wie das Tippen von @@ -919,180 +980,15 @@ export default function App() { if (eng?.isActive()) eng.confirm(); }; - // Globaler Tab-Handler: läuft ein Befehl MIT Feldern (§2.7), zyklt Tab das - // aktive Feld (Präzisionseingabe), ohne den Fokus zu stehlen — so kann man bei - // der Maus bleiben. Sonst fokussiert/öffnet Tab die Command-Line. Gleicher - // INPUT/TEXTAREA/contentEditable-Guard wie die bestehenden keydown-Handler - // (das CommandLine-Input selbst behandelt Tab eigenständig). - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - const el = e.target as HTMLElement | null; - const inInput = - !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); - // Direktes Zahlen-Tippen beim Zeichnen (VW-Verhalten): läuft ein Befehl - // mit Tab-Feldern und tippt man eine Ziffer (oder . , -), ohne dass ein - // Eingabefeld fokussiert ist, startet das die Werteingabe in der - // Befehlszeile — der Wert erscheint live im Cursor-HUD UND unten im Feld. - if ( - !inInput && - !e.ctrlKey && - !e.metaKey && - !e.altKey && - /^[0-9.,-]$/.test(e.key) && - engineRef.current?.isActive() && - engineRef.current.hasFields() - ) { - e.preventDefault(); - commandLineRef.current?.beginTyping(e.key); - return; - } - if (e.key !== "Tab") return; - if (inInput) { - return; - } - e.preventDefault(); - const eng = engineRef.current; - if (eng?.isActive() && eng.hasFields()) { - eng.cycleField(); - return; - } - // (c) Läuft ein Vertex-Griff-Drag, öffnet/zyklt Tab das Editier-Feld - // (Länge/Winkel relativ zur Nachbarecke). Beim ersten Tab wird zusätzlich - // das Befehlsfeld fokussiert, damit man den Zahlenwert direkt tippen kann. - if (gripDragInfoRef.current) { - const wasOpen = gripEditRef.current !== null; - cycleGripEditFieldRef.current?.(); - if (!wasOpen) commandLineRef.current?.focus(); - return; - } - commandLineRef.current?.focus(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); - - // Globaler Undo/Redo-Shortcut: Ctrl+Z / Cmd+Z, Ctrl+Shift+Z / Cmd+Shift+Z - // bzw. Ctrl+Y für redo. Gleicher Eingabe-Guard wie die übrigen globalen - // keydown-Handler (Tab-Zyklus, Zahlen-Shortcuts). - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - const mod = e.ctrlKey || e.metaKey; - const key = e.key.toLowerCase(); - if (!mod || (key !== "z" && key !== "y")) return; - const el = e.target as HTMLElement | null; - if ( - el && - (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable) - ) { - return; - } - e.preventDefault(); - if (key === "y" || (key === "z" && e.shiftKey)) redo(); - else undo(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [undo, redo]); + // Globaler Undo/Redo-Shortcut — siehe state/useKeyboardShortcuts.ts. + useUndoRedoShortcut(undo, redo); // Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie, - // 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum. - // Nur auf Geschoss-Tabs, nicht mit Modifikator (Ctrl/Cmd/Alt bleiben frei). - // Direkt nach dem Setzen eines Punkts (z. B. „Startpunkt angeben") steht der - // Fokus schon im Befehlsfeld (onSelectTool fokussiert es) — trotzdem soll die - // nächste Zifferntaste OHNE Esc gleich das nächste Werkzeug wählen, wie in - // Vectorworks. Das ist nur sicher, wenn dort gerade nichts Sinnvolles getippt - // werden könnte: das Feld ist noch leer UND der Schritt hat weder Tab-Felder - // (Länge/Winkel/Radius, hasFields()) noch erwartet er Freitext (Text-Label, - // acceptsFreeText()) — sonst wäre die Ziffer selbst Nutzinhalt (z. B. „Raum - // 101" oder eine getippte Koordinate) und darf nicht als Werkzeugwechsel - // verschluckt werden. Der Handler wird einmal registriert und liest die - // aktuelle Auswahl-Funktion/Ebenen-Art über ein Ref. - const toolShortcutRef = useRef({ - onSelectTool, - floor: activeLevel.kind === "floor", - }); - toolShortcutRef.current = { - onSelectTool, - floor: activeLevel.kind === "floor", - }; - useEffect(() => { - const MAP: Record = { - "1": "text", - "2": "line", - "3": "circle", - "4": "rect", - "5": "polyline", - "6": "wall", - "7": "ceiling", - "8": "window", - "9": "door", - "0": "room", - }; - const onKey = (e: KeyboardEvent) => { - if (e.ctrlKey || e.metaKey || e.altKey) return; - const el = e.target as HTMLElement | null; - const isInput = - !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); - if (isInput) { - const isCmdLineInput = el.classList.contains("cmdline-input"); - const empty = isCmdLineInput && (el as HTMLInputElement).value === ""; - const eng = engineRef.current; - const cmdLineFreeToSwitch = - empty && !eng?.hasFields() && !eng?.acceptsFreeText(); - if (!cmdLineFreeToSwitch) return; - } - const id = MAP[e.key]; - if (!id) return; - if (!toolShortcutRef.current.floor) return; // Werkzeuge nur auf Geschossen - e.preventDefault(); - toolShortcutRef.current.onSelectTool(id); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); - - // Tastatur für aktive Zeichenwerkzeuge: Esc verwirft den Entwurf (bzw. schaltet - // im Ruhezustand zurück auf Auswahl), Enter schließt mehrteilige Werkzeuge ab, - // Backspace nimmt den letzten Punkt zurück. In echten Eingaben inaktiv. - useEffect(() => { - if (activeTool === "select") return; - const onKey = (e: KeyboardEvent) => { - const el = e.target as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { - return; - } - // Läuft ein gekoppelter Befehl (Linie/Rechteck/Polylinie über die Engine), - // gehören Esc/Enter/Backspace der Engine bzw. dem Befehlsfeld — der Legacy- - // Werkzeug-Pfad bleibt stumm (keine doppelte Mechanik). - if (engineRef.current?.isActive()) return; - const tool = getTool(activeTool); - const drawing = toolStateRef.current.phase !== "idle"; - if (e.key === "Escape") { - if (drawing) { - const [st] = tool.onCancel(toolStateRef.current); - toolStateRef.current = st; - setDraft(null); - } else { - setActiveTool("select"); - } - e.preventDefault(); - } else if (e.key === "Enter") { - const [st, res] = tool.onCommitGesture(toolStateRef.current, toolCtx); - toolStateRef.current = st; - if (res.commit) setProject(res.commit); - setDraft(null); - if (res.done) toolStateRef.current = tool.init(); - e.preventDefault(); - } else if (e.key === "Backspace" && tool.onUndoPoint) { - const [st, res] = tool.onUndoPoint(toolStateRef.current, toolCtx); - toolStateRef.current = st; - setDraft(res.draft); - e.preventDefault(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [activeTool, toolCtx]); + // 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum + // — sowie Esc/Enter/Backspace für das aktive Zeichenwerkzeug. Siehe + // state/useKeyboardShortcuts.ts. + useToolNumberShortcuts(onSelectTool, activeLevel.kind === "floor", engineRef); + useActiveToolKeys(activeTool, toolCtx, engineRef, toolStateRef, setDraft, setActiveTool, setProject); // ── Editier-Griffe (Grips) des einzeln selektierten Elements ────────────── // Genau eine Wand ODER ein 2D-Element → dessen Eckpunkte als ziehbare Griffe. @@ -1236,7 +1132,7 @@ export default function App() { }; if (selectedDrawing) { const g = selectedDrawing.geom; - if (g.shape === "rect") return ringEdges(grips); + if (g.shape === "rect" || g.shape === "image") return ringEdges(grips); if (g.shape === "polyline" && g.closed) return ringEdges(grips); if (g.shape === "polyline" && !g.closed) return openEdges(grips); if (g.shape === "line") return openEdges(grips); @@ -1430,6 +1326,9 @@ export default function App() { }; cycleGripEditFieldRef.current = cycleGripEditField; + // Globaler Tab-Handler (Feld-Zyklus/Zahlen-Tippen) — siehe state/useKeyboardShortcuts.ts. + useCommandTabShortcut(engineRef, commandLineRef, gripDragInfoRef, gripEditRef, cycleGripEditFieldRef); + // Getippte Zahl im Vertex-Drag: aktives Feld locken, aufs nächste vorrücken und // den Zielpunkt sofort mit dem gelockten Wert neu setzen. const submitGripEditValue = (value: number) => { @@ -1724,89 +1623,34 @@ export default function App() { setSelectedSectionLineId(null); }; - // Tastatur im Auswahl-Werkzeug: Ctrl/Cmd+A = alles auf der aktiven Ebene - // wählen; Entf/Backspace = ALLE Selektierten (Wände + 2D-Elemente) löschen. - useEffect(() => { - if (activeTool !== "select") return; - const onKey = (e: KeyboardEvent) => { - const el = e.target as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { - return; - } - // Alles auswählen (Wände + Zeichnungen des aktiven Geschosses/der Ebene). - if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) { - const wallIds = project.walls - .filter((w) => w.floorId === activeLevel.id) - .map((w) => w.id); - const drawingIds = project.drawings2d - .filter((d) => d.levelId === activeLevel.id) - .map((d) => d.id); - const ceilingIds = (project.ceilings ?? []) - .filter((c) => c.floorId === activeLevel.id) - .map((c) => c.id); - const stairIds = (project.stairs ?? []) - .filter((s) => s.floorId === activeLevel.id) - .map((s) => s.id); - const roomIds = (project.rooms ?? []) - .filter((r) => r.floorId === activeLevel.id) - .map((r) => r.id); - 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 || - extrudedSolidIds.length || - columnIds.length - ) { - setSelectedWallIds(wallIds); - setSelectedDrawingIds(drawingIds); - setSelectedCeilingIds(ceilingIds); - setSelectedStairIds(stairIds); - setSelectedRoomIds(roomIds); - setSelectedExtrudedSolidIds(extrudedSolidIds); - setSelectedColumnIds(columnIds); - e.preventDefault(); - } - return; - } - if (e.key !== "Delete" && e.key !== "Backspace") return; - if (activeTransform) return; // während einer Transformation nicht löschen - if (hasSelectionToDelete()) { - deleteSelection(); - e.preventDefault(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [ + // Tastatur im Auswahl-Werkzeug: Ctrl/Cmd+A = alles wählen, Entf/Backspace = + // Auswahl löschen — siehe state/useKeyboardShortcuts.ts. + useSelectAllAndDeleteShortcut({ activeTool, activeTransform, + project, + activeLevelId: activeLevel.id, selectedDrawingIds, selectedWallIds, selectedCeilingIds, selectedOpeningIds, selectedStairIds, + selectedRoomIds, selectedExtrudedSolidIds, selectedColumnIds, selectedRoofIds, selectedContextObjectIds, selectedSectionLineId, - project.walls, - project.drawings2d, - project.ceilings, - project.roofs, - project.stairs, - project.extrudedSolids, - project.columns, - activeLevel.id, - ]); + setSelectedWallIds, + setSelectedDrawingIds, + setSelectedCeilingIds, + setSelectedStairIds, + setSelectedRoomIds, + setSelectedExtrudedSolidIds, + setSelectedColumnIds, + hasSelectionToDelete, + deleteSelection, + }); // Split der gewählten 2D-Elemente an einem Punkt in mehrere Stücke (shared von // Ctrl+S und einem etwaigen Menü-Eintrag). Ersetzt jedes Quell-Drawing durch ≥1 @@ -1838,101 +1682,13 @@ export default function App() { setSelectedWallIds([]); }; - // Ctrl/Cmd+S = Split, Ctrl/Cmd+J = Join der selektierten Drawing2D-Elemente. - // Join läuft über DENSELBEN Engine-Befehl wie getipptes „join" (§Task C, ein - // Pfad). Split bleibt vorerst die direkte Aktion (kein Split-Engine-Befehl - // gefordert). Beide rufen preventDefault (sonst Browser-Speichern/-Lesezeichen). - // Wände sind ausgenommen (no-op). Tool-unabhängig, solange eine 2D-Auswahl steht. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (!(e.ctrlKey || e.metaKey)) return; - const k = e.key.toLowerCase(); - if (k !== "s" && k !== "j") return; - const el = e.target as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { - return; - } - const ids = selectedDrawingIds; - if (ids.length === 0) return; // nur 2D-Auswahl bedient; Wände → no-op - e.preventDefault(); + // Ctrl/Cmd+S = Split, Ctrl/Cmd+J = Join der selektierten Drawing2D-Elemente — + // siehe state/useKeyboardShortcuts.ts. + useSplitJoinShortcut(selectedDrawingIds, engine, runSplit, setSelectedDrawingIds, setSelectedWallIds); - if (k === "s") { - runSplit(ids); - return; - } - // Join: exakt derselbe Befehl wie „join" tippen (Sofort-Befehl, autoRun). - // Nach dem Verbinden zeigt die alte Auswahl auf ersetzte IDs → aufräumen. - engine.start("join"); - setSelectedDrawingIds([]); - setSelectedWallIds([]); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDrawingIds, engine]); - - // Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt - // die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit - // neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so - // wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im - // Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return; - const k = e.key.toLowerCase(); - if (k !== "c" && k !== "v") return; - const el = e.target as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { - return; - } - if (k === "c") { - const ids = new Set(selectedDrawingIds); - if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy) - const picked = project.drawings2d.filter((d) => ids.has(d.id)); - if (picked.length === 0) return; - e.preventDefault(); - setDrawingClipboard(picked.map((d) => structuredClone(d))); - return; - } - // Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt. - if (drawingClipboard.length === 0) return; - e.preventDefault(); - const stamp = Date.now(); - const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({ - ...structuredClone(d), - id: `drawing-${stamp}-${i}`, - levelId: activeLevelId, - })); - importDrawings(pasted); - setSelectedDrawingIds(pasted.map((p) => p.id)); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDrawingIds, drawingClipboard, activeLevelId, project.drawings2d]); - - // Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten - // geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2 - // geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.) - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; - const k = e.key.toLowerCase(); - if (k !== "u" && k !== "d" && k !== "i") return; - const el = e.target as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { - return; - } - if (selectedDrawingIds.length < 2) return; - e.preventDefault(); - if (k === "u") applyBoolean(unionDrawings); - else if (k === "d") applyBoolean(differenceDrawings); - else applyBoolean(intersectionDrawings); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDrawingIds, project.drawings2d]); + // Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt — siehe + // state/useKeyboardShortcuts.ts. (Ctrl/Cmd+C/V-Kopieren/Einfügen folgt weiter + // unten, nach `importDrawings`.) // 2D-Boolean-Operation (Union / Subtract / Intersect) auf die gewählten // geschlossenen Drawing2D anwenden. Reihenfolge der Auswahl = Reihenfolge in @@ -1962,6 +1718,7 @@ export default function App() { setSelectedDrawingIds(result.created.map((d) => d.id)); setSelectedWallIds([]); }; + useBooleanOpsShortcut(selectedDrawingIds, project.drawings2d, applyBoolean); // Alt+Klick im Grundriss = Segment löschen (Schere): PlanView meldet das // getroffene Drawing2D + den Modellpunkt; App ermittelt daraus die nächst- @@ -1988,74 +1745,22 @@ export default function App() { if (removed) setSelectedDrawingIds([]); }; - // Transformations-Shortcuts (nur im Auswahl-Werkzeug): - // m = Bewegen, s = Spiegeln, d = Drehen (startet auf der Auswahl); - // während einer Transformation: u/i/o/p = Modus (move/copy/array/distribute), - // Esc = abbrechen. O/P fragen die Kopienanzahl ab. - useEffect(() => { - if (activeTool !== "select") return; - const onKey = (e: KeyboardEvent) => { - const el = e.target as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) { - return; - } - if (e.ctrlKey || e.metaKey || e.altKey) return; - if (activeTransform) { - const k = e.key.toLowerCase(); - if (k === "u") setActiveTransform((t) => (t ? { ...t, copyMode: "move" } : t)); - else if (k === "i") setActiveTransform((t) => (t ? { ...t, copyMode: "copy" } : t)); - else if (k === "o") { - requestCopyCount("array"); - } else if (k === "p") { - requestCopyCount("distribute"); - } else if (e.key === "Escape") { - setActiveTransform(null); - setDraft(null); - } else return; - e.preventDefault(); - return; - } - // „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 - // (`activeTransform`) mit den U/I/O/P-Kopiermodi (Bewegen/Kopie/Reihe/ - // Verteilen). Prompt + Optionen + Live-Feld speisen die Command-Line; die - // Kürzel U/I/O/P wirken dort auch bei fokussiertem Eingabefeld (options.key). - const op: TransformOp | null = - k === "m" ? "move" : k === "s" ? "mirror" : k === "d" ? "rotate" : null; - if (op) { - setActiveTransform({ op, copyMode: "move", count: 1, points: [] }); - setDraft(null); - requestAnimationFrame(() => commandLineRef.current?.focus()); - e.preventDefault(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [ + // Transformations-Shortcuts (m/s/d, U/I/O/P-Modusleiste, „q" Messen) — siehe + // state/useKeyboardShortcuts.ts. + useTransformShortcuts( activeTool, activeTransform, + setActiveTransform, selectedWallIds, selectedDrawingId, selectedExtrudedSolidId, selectedColumnId, engine, - ]); + setDraft, + requestCopyCount, + onRunCommand, + commandLineRef, + ); // ── Projekt-Mutationen (aus dem Store-Slice) ───────────────────────────── // Sämtliche immutablen Projekt-Mutationen + Ressourcen-Typ-CRUD (Wand-/ @@ -2151,6 +1856,18 @@ export default function App() { setRoomStamp, } = useProjectActions(setProject, notify, planViewRef); + // Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen — siehe + // state/useKeyboardShortcuts.ts. + useDrawingClipboardShortcut( + selectedDrawingIds, + drawingClipboard, + setDrawingClipboard, + activeLevelId, + project.drawings2d, + importDrawings, + setSelectedDrawingIds, + ); + // Projektdatei-I/O (Speichern/Öffnen/Lock), Schnellexport (CSV/IFC/OBJ/STL) // und die Ausführung eines bestätigten DXF/DWG-Imports — gebündelt in // useProjectFileActions(), siehe dort. @@ -2184,6 +1901,16 @@ export default function App() { planViewRef, ); + // Gewählte Layout-Annotation als echtes Objekt (statt nur ihrer Id) — liegt + // in `Layout.annotations` (Papier-mm), NICHT im Modell, daher separat von + // `deriveSelection` (die nur Projekt-Bauteile kennt) aufgelöst. Muss VOR + // `textTarget`/`selection` stehen (beide lesen sie synchron im selben Render). + const selectedLayoutAnnotation = useMemo(() => { + if (activeLayoutId === null || !selectedLayoutAnnotationId) return null; + const layout = (project.layouts ?? []).find((l) => l.id === activeLayoutId); + return layout?.annotations?.find((a) => a.id === selectedLayoutAnnotationId) ?? null; + }, [project.layouts, activeLayoutId, selectedLayoutAnnotationId]); + // Text-Ziel der Oberleisten-Text-Gruppe: der Stempel-Text des (einzeln) // gewählten Raums. Ohne aktive Editor-Auswahl wirkt Formatierung auf das ganze // Doc (range: null). Ist kein Raum gewählt → null (Controls setzen nur @@ -2204,20 +1931,51 @@ export default function App() { const d = project.drawings2d.find((x) => x.id === selectedDrawingId); if (d && d.geom.shape === "text") { const g = d.geom; + // Trägt das Element bereits ein reiches Doc (Doppelklick-Editor), wirkt + // die Oberleiste per Bereichs-Operation (toggleMark/applyMark) DARAUF — + // Mischformatierung anderer Runs bleibt erhalten. Ohne Doc (Alt-Element) + // bleibt das bisherige Uniform-Verhalten (ein `marks`-Objekt fürs Ganze). + const doc = g.doc ?? docFromText(g.text, g.marks ?? {}); return { - doc: docFromText(g.text, g.marks ?? {}), + doc, range: null, - apply: (doc: RichTextDoc) => { - const runMarks = doc.paragraphs.flatMap((p) => p.runs)[0]?.marks ?? {}; + apply: (newDoc: RichTextDoc) => { + const runMarks = newDoc.paragraphs.flatMap((p) => p.runs)[0]?.marks ?? {}; const marks = { ...runMarks }; delete marks.sizePt; // Grösse folgt der Modell-Höhe, nicht dem pt-Wert. - updateDrawing2D(d.id, { geom: { ...g, text: plainText(doc), marks } }); + const patch = g.doc + ? { doc: newDoc, text: plainText(newDoc), marks } + : { text: plainText(newDoc), marks }; + updateDrawing2D(d.id, { geom: { ...g, ...patch } }); }, }; } } + // Ausgewähltes Text-Markup auf einem offenen Layout-Blatt: wie Raum-Stempel + // OHNE Grössen-Kopplung ans Modell (Papier-mm-Kontext, `heightMm` ist bereits + // der Basiswert für Runs ohne eigene `sizePt`) — die Oberleisten-Grösse + // wirkt hier also normal, anders als bei freiem Grundriss-Text oben. + if (selectedLayoutAnnotation && selectedLayoutAnnotation.kind === "text" && activeLayoutId !== null) { + const ann = selectedLayoutAnnotation; + return { + doc: ann.doc ?? docFromText(ann.text), + range: null, + apply: (newDoc: RichTextDoc) => + patchOpenLayout(activeLayoutId, (l) => + patchAnnotationInLayout(l, ann.id, { doc: newDoc, text: plainText(newDoc) }), + ), + }; + } return null; - }, [selectedRoom, setRoomStampDoc, selectedDrawingId, project.drawings2d, updateDrawing2D]); + }, [ + selectedRoom, + setRoomStampDoc, + selectedDrawingId, + project.drawings2d, + updateDrawing2D, + selectedLayoutAnnotation, + activeLayoutId, + ]); // ── 3D-Editier-Griffe (direkt im Viewport3D) ────────────────────────────── // Genau EINE gewählte Wand (kein 2D-Element) bzw. genau EIN 2D-Element liefert @@ -2363,22 +2121,41 @@ export default function App() { // ── Selektions-Attribut-Kontrakt (Attributes-/Object-Info-Paletten) ─────── // Normalisierte, effektiv aufgelöste Sicht auf das erste selektierte Element. - const selection = useMemo( - () => - deriveSelection( - project, - selectedWallIds, - selectedDrawingId, - selectedCeilingId, - selectedOpeningId, - selectedStairId, - selectedRoomId, - selectedExtrudedSolidId, - selectedColumnId, - selectedRoofId, - selectedContextObjectIds.length === 1 ? selectedContextObjectIds[0] : null, - ), - [ + // Eine gewählte Layout-Annotation hat Vorrang (eigener, minimaler Zweig — + // sie ist kein Projekt-Bauteil, `deriveSelection` kennt sie nicht): Farbe + // (Linie/Rechteck/Text) + Strichstärke (nur Linie/Rechteck, s. `annotKind`- + // Prüfung in AttributesPanel) landen so in DERSELBEN Palette wie ein + // Drawing2D im Grundriss (Nutzer-Feedback). + const selection = useMemo((): Selection | null => { + if (selectedLayoutAnnotation) { + const a = selectedLayoutAnnotation; + return { + kind: "layoutAnnotation", + annotKind: a.kind, + id: a.id, + categoryCode: "", + color: ("color" in a ? a.color : undefined) ?? "#111111", + weightMm: ("weightMm" in a ? a.weightMm : undefined) ?? 0.25, + isImage: a.kind === "image", + opacity: a.kind === "image" ? a.opacity ?? 1 : undefined, + bbox: { minX: 0, minY: 0, maxX: 0, maxY: 0 }, + }; + } + return deriveSelection( + project, + selectedWallIds, + selectedDrawingId, + selectedCeilingId, + selectedOpeningId, + selectedStairId, + selectedRoomId, + selectedExtrudedSolidId, + selectedColumnId, + selectedRoofId, + selectedContextObjectIds.length === 1 ? selectedContextObjectIds[0] : null, + ); + }, [ + selectedLayoutAnnotation, project, selectedWallIds, selectedDrawingId, @@ -2550,13 +2327,21 @@ export default function App() { updateStair(selection.id, { color }); else if (selection.kind === "room") updateRoom(selection.id, { color }); + else if (selection.kind === "layoutAnnotation" && activeLayout) + patchOpenLayout(activeLayout.id, (l) => + patchAnnotationInLayout(l, selection.id, { color }), + ); }, onSetSelectionWeight: (weightMm) => { - // Strichstärke-Override: Wand/Decke (`strokeWeight`) oder Drawing2D - // (`weightMm`, Alt-Feldname bleibt). + // Strichstärke-Override: Wand/Decke (`strokeWeight`) oder Drawing2D/ + // Layout-Annotation (`weightMm`, Alt-Feldname bleibt). if (selection?.kind === "wall") updateWall(selection.id, { strokeWeight: weightMm }); else if (selection?.kind === "ceiling") updateCeiling(selection.id, { strokeWeight: weightMm }); + else if (selection?.kind === "layoutAnnotation" && activeLayout) + patchOpenLayout(activeLayout.id, (l) => + patchAnnotationInLayout(l, selection.id, { weightMm }), + ); else if (selection?.kind === "drawing2d") setElementWeight(selection.id, weightMm); }, @@ -2580,6 +2365,23 @@ export default function App() { if (selection?.kind === "drawing2d") setElementFillColor(selection.id, color); }, + onSetSelectionOpacity: (opacity) => { + // Deckkraft nur bei Bild-Elementen (Drawing2D `{shape:"image"}` im + // Plan, Layout-Annotation `kind:"image"` auf dem Blatt). + if (selection?.kind === "drawing2d") + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.map((d) => + d.id === selection.id && d.geom.shape === "image" + ? { ...d, geom: { ...d.geom, opacity } } + : d, + ), + })); + else if (selection?.kind === "layoutAnnotation" && activeLayout) + patchOpenLayout(activeLayout.id, (l) => + patchAnnotationInLayout(l, selection.id, { opacity }), + ); + }, onSetSelectionForeground: (color) => { // Vordergrund-Override: Wand, Decke oder Drawing2D. if ( @@ -3868,53 +3670,19 @@ export default function App() { // (Geschoss, sichtbare Codes, Darstellungsmodus, Detailgrad, Referenzlinien, // Farb-/Mono-Modus) und schreibt ihn massstäblich als Vektor-PDF. Reines // Modell → SVG → PDF, unabhängig vom Bildschirm-DOM. - const onExportPdf = async (decision: ExportPdfDecision) => { - const plan = generatePlan( - project, - activeLevel.id, - visibleCodes, - categoryDisplay, - detail, - referenceLines, - planColorMode === "mono", - ); - await savePlanPdf(plan, { - scaleDenominator: decision.scaleDenominator, - paper: decision.paper, - orientation: decision.orientation, - title: activeLevel.name, - subtitle: project.name, - fileName: `${project.name}-${activeLevel.name}`, - }); - setExportOpen(false); - }; - - // DXF-Export: erzeugt den Plan mit EXAKT denselben Eingaben wie die Ansicht und - // schreibt ihn als Modell-Space-DXF in echten Metern (1:1). Layer aus den - // Kategorie-Ebenen; jede Entity auf ihrem Kategorie-Layer. - const onExportDxf = (decision: ExportDxfDecision) => { - // Georeferenziert exportieren: bei gesetztem Standort-Bezug (geoAnchor) - // schreibt der Modell-DXF die ECHTEN LV95-Koordinaten (weit vom Nullpunkt), - // damit die Datei in anderer CAD-Software lagerichtig ankommt — auch wenn - // lokal nahe (0,0) gezeichnet wurde. Ohne Bezug bleibt alles lokal. - // (Der Papier-/Layout-Export bleibt bewusst in lokalen Koordinaten.) - const exportProject = projectToWorldCoords(project); - const plan = generatePlan( - exportProject, - activeLevel.id, - visibleCodes, - categoryDisplay, - detail, - referenceLines, - planColorMode === "mono", - ); - savePlanDxf(plan, exportProject, { - title: activeLevel.name, - includeHatches: decision.includeHatches, - fileName: `${project.name}-${activeLevel.name}`, - }); - setExportDxfOpen(false); - }; + // PDF-/DXF-Plan-Export — siehe state/useExportHandlers.ts. + const { onExportPdf, onExportDxf } = useExportHandlers({ + project, + activeLevelId: activeLevel.id, + activeLevelName: activeLevel.name, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + planColorMode, + setExportOpen, + setExportDxfOpen, + }); // Modusleiste-Wechsel: array/distribute fragen die Kopienanzahl ab. const onTransformMode = (mode: CopyMode) => { @@ -4052,17 +3820,38 @@ export default function App() { setSelectedSectionLineId(null); setEditTextId(drawingId); }; - // Bearbeiteten Text-Inhalt übernehmen (immutabel; nur bei Text-Geometrie). - const commitTextEdit = (value: string) => { + + // „Bild einfügen" (Werkzeug „image") — siehe state/useImportHandling.ts. + const { onImageOrPdfFileChosen } = useImageImportHandling({ + project, + activeLevelId: activeLevel.id, + activeCategoryCode, + setProject, + notify, + setSelectedDrawingId, + setSelectedWallIds, + setSelectedCeilingIds, + setSelectedOpeningIds, + setSelectedStairIds, + setSelectedRoomIds, + setSelectedExtrudedSolidIds, + setSelectedColumnIds, + setSelectedRoofIds, + setSelectedContextObjectIds, + setSelectedSectionLineId, + }); + // Bearbeitetes Rich-Text-Dokument übernehmen (immutabel; nur bei Text-Geometrie). + // `text` bleibt als Reintext-Spiegel bestehen (DXF-Export, Suche, Alt-Konsumenten). + const commitTextEdit = (doc: RichTextDoc) => { const id = editTextId; if (!id) return; - const text = value.trim(); + const text = plainText(doc).trim(); if (text !== "") { setProject((p) => ({ ...p, drawings2d: p.drawings2d.map((d) => d.id === id && d.geom.shape === "text" - ? { ...d, geom: { ...d.geom, text } } + ? { ...d, geom: { ...d.geom, doc, text: plainText(doc) } } : d, ), })); @@ -4437,36 +4226,11 @@ export default function App() { }); }; - // ── App-weites Drag & Drop einer DXF/DWG-Datei ──────────────────────────── - // dragover muss preventDefault rufen, sonst lässt der Browser nicht fallen. - // dragenter/leave wird ausgezählt (Kind-Elemente lösen sonst Flackern aus). - const dragDepthRef = useRef(0); - const hasFiles = (e: React.DragEvent) => - Array.from(e.dataTransfer?.types ?? []).includes("Files"); - const onAppDragEnter = (e: React.DragEvent) => { - if (!hasFiles(e)) return; - e.preventDefault(); - dragDepthRef.current += 1; - setDropActive(true); - }; - const onAppDragOver = (e: React.DragEvent) => { - if (!hasFiles(e)) return; - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }; - const onAppDragLeave = (e: React.DragEvent) => { - if (!hasFiles(e)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) setDropActive(false); - }; - const onAppDrop = (e: React.DragEvent) => { - if (!hasFiles(e)) return; - e.preventDefault(); - dragDepthRef.current = 0; - setDropActive(false); - const f = e.dataTransfer.files?.[0]; - if (f) openImportForFile(f); - }; + // App-weites Drag & Drop einer DXF/DWG-Datei — siehe state/useImportHandling.ts. + const { onAppDragEnter, onAppDragOver, onAppDragLeave, onAppDrop } = useFileDropImport( + setDropActive, + openImportForFile, + ); if (bootPhase === "splash") { return setBootPhase("start")} />; @@ -4512,6 +4276,18 @@ export default function App() { e.target.value = ""; }} /> + {/* Verstecktes Bild/PDF-Datei-Input (Werkzeug „image"). */} + { + const f = e.target.files?.[0]; + if (f) onImageOrPdfFileChosen(f); + e.target.value = ""; + }} + /> {/* Verstecktes Projekt-Datei-Input (Datei-Burger „Öffnen"). */} setActiveLayoutId(null), + onBack: () => { + setActiveLayoutId(null); + setSelectedLayoutAnnotationId(null); + // Ein auf dem Blatt armiertes Zeichenwerkzeug (line/rect/text/ + // image) hat im Grundriss KEINEN laufenden Befehl in der Engine + // (der wird für ein offenes Layout bewusst nicht gestartet, s. + // `onSelectTool`) — zurück auf „Auswahl", sonst zeigt das + // Werkzeugpanel ein „aktives" Werkzeug ohne echten Befehl dahinter. + setActiveTool("select"); + }, onPatchViewport: (viewportId, patch) => patchOpenLayout(activeLayout.id, (l) => patchViewportInLayout(l, viewportId, patch), @@ -4682,13 +4471,13 @@ export default function App() { createViewport(`vp-${Date.now()}`, snapshotId, init), ), ), - onAddAnnotation: (kind, init) => + onAddAnnotation: (kind, init) => { + const id = `ann-${Date.now()}`; patchOpenLayout(activeLayout.id, (l) => - addAnnotationToLayout( - l, - createAnnotation(`ann-${Date.now()}`, kind, init), - ), - ), + addAnnotationToLayout(l, createAnnotation(id, kind, init)), + ); + return id; + }, onPatchAnnotation: (annotationId, patch) => patchOpenLayout(activeLayout.id, (l) => patchAnnotationInLayout(l, annotationId, patch), @@ -5051,22 +4840,21 @@ export default function App() { /> )} - {/* 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). */} + {/* Rich-Text-Editor: per Doppelklick auf ein Text-2D-Element geöffnet (wie + beim Raum-Stempel) — mehrere Absätze, gemischte Formatierung pro + Zeichen (InDesign-/Affinity-artig). Alt-Elemente ohne `doc` starten + aus ihrem bisherigen String + einheitlichen `marks`. */} {editTextId && (() => { const d = project.drawings2d.find((x) => x.id === editTextId); if (!d || d.geom.shape !== "text") return null; + const g = d.geom; + const doc = g.doc ?? docFromText(g.text, g.marks ?? {}); return ( - setEditTextId(null)} + setEditTextId(null)} /> ); })()} @@ -5306,76 +5094,3 @@ export default function App() { ); } - -// ── Host-Helfer ────────────────────────────────────────────────────────────── - -/** - * Baut den vollständigen Host-Wert für den Context, indem an den gemeinsamen - * `base` der zum aktiven Tab des Docks gehörende Darstellungsmodus angehängt - * wird. So liest das gerade gerenderte Panel über `usePanelHost().displayMode` - * genau seinen Modus. Rückgabe als `PanelHost` (Record), das der Context - * erwartet — die Verengung auf `PanelHostValue` macht `usePanelHost`. - */ -function hostWithMode( - base: Omit, - activeTab: string | null, - modeFor: (id: string | null) => DisplayMode, - setModeFor: (id: string | null, mode: DisplayMode) => void, - measurement: MeasurementReadout | null = null, -): PanelHost { - const value: PanelHostValue = { - ...base, - // Live-Messwerte NICHT aus dem memoisierten `base` (dort wären sie eingefroren, - // da `draft` bewusst kein Memo-Dep ist — sonst würde der ganze Host bei jeder - // Mausbewegung neu gebaut). Hier bei jedem Render frisch injiziert. - measurement, - displayMode: { - mode: modeFor(activeTab), - setMode: (mode) => setModeFor(activeTab, mode), - }, - }; - return value as unknown as PanelHost; -} - -// ── Wand-Trefferprüfung im Modellraum (für das Grundriss-Kontextmenü) ──────── - -/** - * Liefert die ID der obersten Wand, deren (gehrtes) Band den Modellpunkt - * enthält — gefiltert auf das aktive Geschoss + sichtbare Kategorien, in - * umgekehrter Wand-Reihenfolge (zuletzt gezeichnet zuerst). Spiegelt die - * Trefferlogik der PlanView, arbeitet aber direkt auf den Wand-Polygonen, da - * das Grundriss-Kontextmenü nur den Modellpunkt kennt. `null` bei Leerraum. - */ -function hitTestWall( - project: Project, - floorId: string, - visibleCodes: Set, - m: Vec2, -): string | null { - const walls = project.walls.filter( - (w) => w.floorId === floorId && visibleCodes.has(w.categoryCode), - ); - for (let i = walls.length - 1; i >= 0; i--) { - const w = walls[i]; - const thickness = wallTypeThickness(getWallType(project, w)); - const corners = wallCorners(w.start, w.end, thickness); - if (pointInPoly(m, corners)) return w.id; - } - return null; -} - -/** Punkt-in-Polygon (Ray-Casting) im Modellraum — wie in der PlanView. */ -function pointInPoly(pt: Vec2, poly: Vec2[]): boolean { - let inside = false; - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const xi = poly[i].x; - const yi = poly[i].y; - const xj = poly[j].x; - const yj = poly[j].y; - const intersect = - yi > pt.y !== yj > pt.y && - pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi) + xi; - if (intersect) inside = !inside; - } - return inside; -} diff --git a/src/compute/index.ts b/src/compute/index.ts index 7b80e96..eb60c80 100644 --- a/src/compute/index.ts +++ b/src/compute/index.ts @@ -1,9 +1,9 @@ // Compute-Grenze: der EINE Einstiegspunkt für rechenintensive Operationen. -// Läuft die App unter Tauri, werden Ops an einen Rust-`#[tauri::command]` -// geroutet; sonst greift die bestehende TS-Implementierung. In dieser PoC ist -// nur `computeJoins` real an Rust angebunden — die übrigen Funktionen sind -// dünne Pass-throughs auf ihre TS-Impls (Grenze etabliert, Migration -// inkrementell). +// War als Brücke zu einem Rust-`#[tauri::command]` gedacht (PoC), aber +// ungenutzt: kein Aufrufer im Frontend, und die Rust-Seite (`compute_joins` + +// das `geometry`-Crate) wurde entfernt (ungenutzter Totcode, s. PENDENZEN.md). +// `computeJoins` läuft daher immer über den TS-Fallback; die übrigen +// Funktionen waren schon vorher reine TS-Pass-throughs. import type { Project, Wall } from "../model/types"; import { getWallType, wallTypeThickness } from "../model/types"; diff --git a/src/compute/parity.test.ts b/src/compute/parity.test.ts deleted file mode 100644 index f6b5c5d..0000000 --- a/src/compute/parity.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// TS<->Rust-Paritaet fuer compute_joins: dieselben Sample-Waende, flach gemacht -// wie in der Compute-Boundary, einmal durch die TS-Implementierung und einmal -// durch das Rust-geometry-Crate (examples/parity) — die Gehrungs-Schnittlinien -// muessen numerisch identisch sein. Belegt, dass der Rust-Port die Semantik -// erhaelt (Kernbeweis der Migration). - -// @ts-ignore -- node:child_process ohne globales @types/node; im Vitest-Node-Lauf vorhanden -import { execFileSync } from "node:child_process"; -import { describe, expect, it } from "vitest"; -import { sampleProject } from "../model/sampleProject"; -import { computeJoins as computeJoinsTS } from "../model/joins"; -import type { WallCuts } from "../model/joins"; -import type { Line } from "../model/geometry"; -import { getWallType, wallTypeThickness } from "../model/types"; -import { wallReferenceOffset } from "../model/wall"; - -const EPS = 1e-9; - -/** Flach wie in src/compute/index.ts (Boundary) — Eingang fuer das Rust-Crate. */ -function flatten() { - return sampleProject.walls.map((w) => { - const thickness = wallTypeThickness(getWallType(sampleProject, w)); - return { - id: w.id, - start: w.start, - end: w.end, - thickness, - referenceOffset: wallReferenceOffset(w, thickness), - }; - }); -} - -/** Rust-Ausgabe ueber das geometry-Beispiel (liest stdin-JSON, druckt stdout-JSON). */ -function runRust(input: unknown): Map { - const out = execFileSync( - "cargo", - [ - "run", - "-q", - "--manifest-path", - "src-tauri/geometry/Cargo.toml", - "--example", - "parity", - ], - { input: JSON.stringify(input), encoding: "utf8", timeout: 180000 }, - ); - const arr = JSON.parse(out) as Array<{ - wallId: string; - startCut: Line | null; - endCut: Line | null; - }>; - const map = new Map(); - for (const c of arr) map.set(c.wallId, { startCut: c.startCut, endCut: c.endCut }); - return map; -} - -function lineClose(a: Line | null, b: Line | null): boolean { - if (a === null || b === null) return a === b; - return ( - Math.abs(a.point.x - b.point.x) < EPS && - Math.abs(a.point.y - b.point.y) < EPS && - Math.abs(a.dir.x - b.dir.x) < EPS && - Math.abs(a.dir.y - b.dir.y) < EPS - ); -} - -describe("compute_joins TS<->Rust Paritaet", () => { - it("liefert fuer alle Sample-Waende identische Gehrungslinien", () => { - const walls = sampleProject.walls; - const ts = computeJoinsTS(sampleProject, walls); - const rust = runRust({ walls: flatten() }); - - expect(rust.size).toBe(ts.size); - - // Mindestens eine Wand muss eine echte Gehrung haben (sonst prueft der Test nichts). - let cutsSeen = 0; - for (const w of walls) { - const t = ts.get(w.id)!; - const r = rust.get(w.id); - expect(r, `Wand ${w.id} fehlt in Rust-Ausgabe`).toBeDefined(); - expect(lineClose(t.startCut, r!.startCut), `startCut ${w.id}`).toBe(true); - expect(lineClose(t.endCut, r!.endCut), `endCut ${w.id}`).toBe(true); - if (t.startCut) cutsSeen++; - if (t.endCut) cutsSeen++; - } - expect(cutsSeen, "keine einzige Gehrung im Sample — Test waere aussagelos").toBeGreaterThan(0); - }); -}); diff --git a/src/export/layoutPdf.ts b/src/export/layoutPdf.ts index 25acbbc..9add621 100644 --- a/src/export/layoutPdf.ts +++ b/src/export/layoutPdf.ts @@ -12,10 +12,12 @@ import { jsPDF } from "jspdf"; import "svg2pdf.js"; -import type { MasterLayout, Project } from "../model/types"; +import type { Layout, LayoutAnnotation, MasterLayout, Project } from "../model/types"; import { generatePlan } from "../plan/generatePlan"; import { planToPrintSvg } from "./planToPrintSvg"; import { saveBinaryFile } from "../io/saveFile"; +import { wrapDocToLines, type SvgLine } from "../text/renderHtml"; +import { docFromText } from "../text/richText"; import { findMaster, findSnapshot, @@ -77,6 +79,133 @@ function clip(s: string, max: number): string { return s.length > max ? s.slice(0, max - 1) + "…" : s; } +/** "#rrggbb"/"#rgb" → [r,g,b] (0..255); ungültige Eingabe fällt auf Tinte zurück. */ +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "").trim(); + const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; + const n = parseInt(full, 16); + if (full.length !== 6 || !Number.isFinite(n)) return [17, 17, 17]; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** Bildformat aus dem MIME-Teil der Data-URL ableiten (jsPDF `addImage` braucht es explizit). */ +function imageFormatFromDataUrl(src: string): string { + const ext = (/^data:image\/(\w+)/i.exec(src)?.[1] ?? "png").toLowerCase(); + if (ext === "jpg" || ext === "jpeg") return "JPEG"; + if (ext === "webp") return "WEBP"; + return "PNG"; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Baut EIN Mini-SVG (Blattgrösse) mit allen Text-Markups einer Seite — reiche + * Docs (mehrere Absätze/gemischte Formatierung) + Wortumbruch bei gesetzter + * Rahmenbreite, exakt wie `LayoutSheet.tsx`s `AnnotationSvg`. Ein gemeinsamer + * `doc.svg()`-Aufruf statt einem je Annotation (einfacher, ein Layout-Pass). + * `null`, wenn die Seite keine Text-Markups trägt. + */ +function buildAnnotationTextsSvg( + annotations: LayoutAnnotation[], + sheetW: number, + sheetH: number, +): SVGSVGElement | null { + const texts = annotations.filter( + (a): a is Extract => a.kind === "text", + ); + if (texts.length === 0) return null; + + const svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement; + svg.setAttribute("xmlns", SVG_NS); + svg.setAttribute("width", String(sheetW)); + svg.setAttribute("height", String(sheetH)); + svg.setAttribute("viewBox", `0 0 ${sheetW} ${sheetH}`); + + for (const ann of texts) { + const doc = ann.doc ?? docFromText(ann.text); + const lineHeight = ann.heightMm * 1.25; + const lines: SvgLine[] = wrapDocToLines(doc, { + x: 0, + y: 0, + lineHeight, + unitPerPt: 25.4 / 72, + basePt: (ann.heightMm / 25.4) * 72, + color: ann.color ?? "#111111", + ...(ann.widthMm && ann.widthMm > 0 ? { wrapWidthUnits: ann.widthMm } : {}), + }); + let y = ann.yMm; + const el = document.createElementNS(SVG_NS, "text"); + lines.forEach((line, k) => { + if (k > 0) y += line.lineHeight; + const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; + const x = !ann.widthMm + ? ann.xMm + : line.align === "center" + ? ann.xMm + ann.widthMm / 2 + : line.align === "right" + ? ann.xMm + ann.widthMm + : ann.xMm; + const lineTspan = document.createElementNS(SVG_NS, "tspan"); + lineTspan.setAttribute("x", String(x)); + lineTspan.setAttribute("y", String(y)); + lineTspan.setAttribute("text-anchor", anchor); + if (line.tspans.length === 0) { + lineTspan.textContent = "​"; + } else { + for (const ts of line.tspans) { + const run = document.createElementNS(SVG_NS, "tspan"); + run.setAttribute("font-size", String(ts.fontSize)); + run.setAttribute("fill", ts.fill); + if (ts.fontWeight) run.setAttribute("font-weight", ts.fontWeight); + if (ts.fontStyle) run.setAttribute("font-style", ts.fontStyle); + run.setAttribute("font-family", ts.fontFamily ?? "Helvetica, Arial, sans-serif"); + if (ts.textDecoration) run.setAttribute("text-decoration", ts.textDecoration); + run.textContent = ts.text; + lineTspan.appendChild(run); + } + } + el.appendChild(lineTspan); + }); + svg.appendChild(el); + } + return svg; +} + +/** + * Zeichnet die 2D-Markups (Linie/Rechteck/Bild) EINER Seite direkt via jsPDF- + * Vektorprimitive — dieselbe Schicht wie `LayoutSheet.tsx`s Annotations-SVG, + * hier aber ÜBER Viewports UND Master gezeichnet (Aufrufreihenfolge in + * `renderPage`), analog zur Zeichenreihenfolge im In-Viewport-Editor. + */ +function drawAnnotationShapes(doc: jsPDF, layout: Layout): void { + for (const ann of layout.annotations ?? []) { + if (ann.kind === "line") { + const [r, g, b] = hexToRgb(ann.color ?? "#111111"); + doc.setDrawColor(r, g, b); + doc.setLineWidth(ann.weightMm ?? 0.25); + doc.line(ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm); + } else if (ann.kind === "rect") { + const [r, g, b] = hexToRgb(ann.color ?? "#111111"); + doc.setDrawColor(r, g, b); + doc.setLineWidth(ann.weightMm ?? 0.25); + doc.rect(ann.xMm, ann.yMm, ann.widthMm, ann.heightMm); + } else if (ann.kind === "image" && ann.widthMm > 0 && ann.heightMm > 0 && ann.src) { + try { + doc.addImage( + ann.src, + imageFormatFromDataUrl(ann.src), + ann.xMm, + ann.yMm, + ann.widthMm, + ann.heightMm, + ); + } catch { + // Defekte/leere Data-URL überspringt nur dieses Bild, nicht die Seite. + } + } + } +} + /** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */ async function renderPage( doc: jsPDF, @@ -128,6 +257,18 @@ async function renderPage( const tb = resolveTitleBlock(project, layout, master); drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation); } + + // 2D-Markups (Linie/Rechteck/Bild/Text) — ÜBER Viewports + Master, wie im + // In-Viewport-Editor (LayoutSheet.tsx). Fehlte bisher komplett im Export. + drawAnnotationShapes(doc, layout); + const textsSvg = buildAnnotationTextsSvg(layout.annotations ?? [], widthMm, heightMm); + if (textsSvg) { + try { + await doc.svg(textsSvg, { x: 0, y: 0, width: widthMm, height: heightMm }); + } catch { + // Rich-Text-Rendering eines Markups darf die Seite nicht killen. + } + } } /** diff --git a/src/export/planToPrintSvg.ts b/src/export/planToPrintSvg.ts index c9cf3bb..02d6c5e 100644 --- a/src/export/planToPrintSvg.ts +++ b/src/export/planToPrintSvg.ts @@ -25,6 +25,8 @@ import type { HatchRender, Plan, Primitive } from "../plan/generatePlan"; import type { Vec2 } from "../model/types"; import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch"; import { dashHasDot } from "../ui/lineSegments"; +import { wrapDocToLines, type SvgLine } from "../text/renderHtml"; +import { docFromText } from "../text/richText"; /** SVG-Namespace für die programmatisch erzeugten Knoten. */ const SVG_NS = "http://www.w3.org/2000/svg"; @@ -254,9 +256,113 @@ function appendPrimitive( svg.appendChild(node); break; } + case "drawingText": { + const node = drawingTextNode(p, map, mmPerM); + if (node) { + if (opacity) node.setAttribute("opacity", opacity); + svg.appendChild(node); + } + break; + } + case "drawingImage": { + const node = drawingImageNode(p, map, mmPerM); + if (node) { + if (p.opacity < 1) node.setAttribute("opacity", String(p.opacity * (p.greyed ? 0.45 : 1))); + else if (opacity) node.setAttribute("opacity", opacity); + svg.appendChild(node); + } + break; + } } } +/** + * Modellverankerter Einzeltext (Text-Werkzeug/DXF-Import) als `` mit + * ``-Zeilen — analog zur Bildschirm-Darstellung (PlanView.tsx + * `case "drawingText"`), aber DOM-Knoten statt JSX. Bricht bei gesetzter + * Spaltenbreite wortweise um (Run-Grenzen bleiben erhalten). + */ +function drawingTextNode( + p: Extract, + map: Mapper, + mmPerM: number, +): SVGTextElement | null { + const doc = p.doc ?? docFromText(p.text, p.marks ?? {}); + const c = map(p.at); + const fontSizeMm = p.heightM * mmPerM; + const unitPerPt = (0.0254 / 72) * mmPerM; + const basePt = unitPerPt > 0 ? p.heightM / unitPerPt : 0; + const lineHeight = fontSizeMm * 1.25; + const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth * mmPerM : undefined; + const lines: SvgLine[] = wrapDocToLines(doc, { + x: 0, + y: 0, + lineHeight, + unitPerPt, + basePt, + color: p.color, + ...(wrapWidthUnits ? { wrapWidthUnits } : {}), + }); + if (lines.length === 0) return null; + + const el = document.createElementNS(SVG_NS, "text"); + const deg = -(p.angle * 180) / Math.PI; + if (deg) el.setAttribute("transform", `rotate(${round(deg)} ${round(c.x)} ${round(c.y)})`); + let y = c.y; + lines.forEach((line, k) => { + if (k > 0) y += line.lineHeight; + const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; + const x = !wrapWidthUnits + ? c.x + : line.align === "center" + ? c.x + wrapWidthUnits / 2 + : line.align === "right" + ? c.x + wrapWidthUnits + : c.x; + const lineTspan = document.createElementNS(SVG_NS, "tspan"); + lineTspan.setAttribute("x", String(round(x))); + lineTspan.setAttribute("y", String(round(y))); + lineTspan.setAttribute("text-anchor", anchor); + if (line.tspans.length === 0) { + lineTspan.textContent = "​"; + } else { + for (const ts of line.tspans) { + const run = document.createElementNS(SVG_NS, "tspan"); + run.setAttribute("font-size", String(round(ts.fontSize))); + run.setAttribute("fill", ts.fill); + if (ts.fontWeight) run.setAttribute("font-weight", ts.fontWeight); + if (ts.fontStyle) run.setAttribute("font-style", ts.fontStyle); + run.setAttribute("font-family", ts.fontFamily ?? "Helvetica, Arial, sans-serif"); + if (ts.textDecoration) run.setAttribute("text-decoration", ts.textDecoration); + run.textContent = ts.text; + lineTspan.appendChild(run); + } + } + el.appendChild(lineTspan); + }); + return el; +} + +/** Eingebettetes Rasterbild (Data-URL) als achsparalleles ``. */ +function drawingImageNode( + p: Extract, + map: Mapper, + mmPerM: number, +): SVGImageElement | null { + const w = (p.max.x - p.min.x) * mmPerM; + const h = (p.max.y - p.min.y) * mmPerM; + if (w <= 0 || h <= 0) return null; + const topLeft = map({ x: p.min.x, y: p.max.y }); + const el = document.createElementNS(SVG_NS, "image"); + el.setAttribute("href", p.src); + el.setAttribute("x", String(round(topLeft.x))); + el.setAttribute("y", String(round(topLeft.y))); + el.setAttribute("width", String(round(w))); + el.setAttribute("height", String(round(h))); + el.setAttribute("preserveAspectRatio", "none"); + return el; +} + /** * Polygon mit Füllung/Umriss/Schraffur. Reihenfolge wie in der PlanView: * • pattern "none" → reine Füllung (oder „none") + Umriss. diff --git a/src/export/sceneToPrintSvg.ts b/src/export/sceneToPrintSvg.ts index 50e5655..bdafcb0 100644 --- a/src/export/sceneToPrintSvg.ts +++ b/src/export/sceneToPrintSvg.ts @@ -25,6 +25,7 @@ import type { RArc, RFill, + RImage, RLine, ROutline, RPoint, @@ -142,6 +143,11 @@ export function sceneToPrintSvg(scene: RScene, opts: PrintSceneOptions): PrintSc bg.setAttribute("fill", "#ffffff"); svg.appendChild(bg); + // Eingebettete Bilder (Foto/Logo/PDF-Seite) ZWISCHEN Blatt und Vektorgeometrie + // — dieselbe Schicht-Reihenfolge wie georeferenzierte Luftbilder im SVG-Pfad + // (PlanView.tsx), damit darübergezeichnete Linien/Schraffuren sichtbar bleiben. + for (const img of scene.images) appendImage(svg, img, map, k); + // ── Maler-Reihenfolge: Fuellungen/Umrisse/Polylinien/Linien gemeinsam nach // `z` sortieren (stabiler Sort => Einfuegereihenfolge bricht Gleichstaende // genau wie `seq` in `compile_scene`). @@ -319,6 +325,25 @@ function appendArc(svg: SVGSVGElement, a: RArc, map: Mapper, mmPerM: number): vo svg.appendChild(el); } +/** Eingebettetes Rasterbild (Data-URL) als achsparalleles ``. */ +function appendImage(svg: SVGSVGElement, img: RImage, map: Mapper, mmPerM: number): void { + const w = (img.max[0] - img.min[0]) * mmPerM; + const h = (img.max[1] - img.min[1]) * mmPerM; + if (w <= 0 || h <= 0) return; + // Papier-Y zeigt nach unten -> oben-links = (min.x, max.y) im Welt-Raum. + const [x] = map(img.min); + const [, y] = map([img.min[0], img.max[1]]); + const el = document.createElementNS(SVG_NS, "image"); + el.setAttribute("href", img.src); + el.setAttribute("x", String(round(x))); + el.setAttribute("y", String(round(y))); + el.setAttribute("width", String(round(w))); + el.setAttribute("height", String(round(h))); + el.setAttribute("preserveAspectRatio", "none"); + if (img.opacity < 1) setOpacityAttr(el, "opacity", img.opacity); + svg.appendChild(el); +} + /** SVG-Schriftfamilie fuer Plan-Beschriftung (Vektor-Text im PDF). */ const TEXT_FONT_FAMILY = "Helvetica, Arial, sans-serif"; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 32c9e30..044fe1a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -182,6 +182,11 @@ export const de = { "tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen", "tool.textbox": "Textspalte", "tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen", + "tool.image": "Bild/PDF", + "tool.image.hint": "Bild oder PDF einfügen (jede PDF-Seite wird als Bild eingebettet)", + "image.readError": "Bild konnte nicht gelesen werden.", + "image.pdfError": "PDF konnte nicht gelesen werden.", + "image.pdfEmpty": "PDF enthält keine Seiten.", // Ribbon-Oberleiste (Tabs + Gruppen) "ribbon.tab.2d": "2D", "ribbon.tab.3d": "3D", @@ -300,6 +305,7 @@ export const de = { "attr.stroke": "Stift", "attr.color": "Farbe", "attr.weight": "Strichstärke (mm)", + "attr.opacity": "Deckkraft", "attr.lineStyle": "Linienstil", "attr.lineStyle.none": "Standard", "attr.fill": "Füllung", @@ -344,6 +350,8 @@ export const de = { "objinfo.kind.column": "Stütze", "objinfo.kind.roof": "Dach", "objinfo.kind.contextObject": "Kontext-Objekt", + "objinfo.kind.layoutAnnotation": "Blatt-Markup", + "objinfo.layoutAnnotationHint": "Position/Grösse in mm direkt am Rahmen auf dem Blatt bearbeiten (Greifpunkte oder die Felder dort).", // ── Kontext-Objekt-Attribute (Object-Info-Panel) ──────────────────────── "objinfo.contextObject.section": "Georeferenzierung", "objinfo.contextObject.type": "Art", @@ -1569,6 +1577,7 @@ export const de = { // Editor "layouts.editorTitle": "Layout-Editor", "layouts.fit": "Einpassen", + "layouts.zoom100": "Echte Grösse (100 %, 1:1 auf dem Bildschirm)", "layouts.addViewport": "Viewport hinzufügen", "layouts.pickSnapshot": "Ausschnitt wählen …", "layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.", @@ -1589,19 +1598,21 @@ export const de = { "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", + // Annotationen (Linie/Rechteck/Text/Bild) direkt auf dem Blatt — gezeichnet + // mit denselben Werkzeugen wie im Grundriss (linkes Werkzeugpanel), kein + // eigenes Werkzeug nur fürs Layout. "layouts.annotTextTitle": "Text", "layouts.annotTextLabel": "Textinhalt:", "layouts.annotEditText": "Text ändern", - "layouts.annotColor": "Farbe", - "layouts.annotWeight": "Strichstärke (mm)", "layouts.annotRemove": "Annotation entfernen", + "layouts.annotX": "X (mm)", + "layouts.annotY": "Y (mm)", + "layouts.annotWidth": "Breite (mm)", + "layouts.annotHeight": "Höhe (mm)", "layouts.annot.line": "Linie", "layouts.annot.rect": "Rechteck", "layouts.annot.text": "Text", + "layouts.annot.image": "Bild", // Baum / „+"-Menü / Ordner "layouts.add": "Neu …", "layouts.addFolder": "Ordner erstellen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f9c6fef..bcdd4d4 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -180,6 +180,11 @@ export const en: Record = { "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", + "tool.image": "Image/PDF", + "tool.image.hint": "Insert an image or PDF (each PDF page is embedded as an image)", + "image.readError": "Could not read image.", + "image.pdfError": "Could not read PDF.", + "image.pdfEmpty": "PDF has no pages.", // Ribbon top bar (tabs + groups) "ribbon.tab.2d": "2D", "ribbon.tab.3d": "3D", @@ -298,6 +303,7 @@ export const en: Record = { "attr.stroke": "Pen", "attr.color": "Color", "attr.weight": "Line weight (mm)", + "attr.opacity": "Opacity", "attr.lineStyle": "Line style", "attr.lineStyle.none": "Default", "attr.fill": "Fill", @@ -342,6 +348,8 @@ export const en: Record = { "objinfo.kind.column": "Column", "objinfo.kind.roof": "Roof", "objinfo.kind.contextObject": "Context object", + "objinfo.kind.layoutAnnotation": "Sheet markup", + "objinfo.layoutAnnotationHint": "Edit position/size in mm directly on the frame on the sheet (grips or the fields there).", // ── Context object attributes (object info panel) ─────────────────────── "objinfo.contextObject.section": "Georeferencing", "objinfo.contextObject.type": "Type", @@ -1556,6 +1564,7 @@ export const en: Record = { // Editor "layouts.editorTitle": "Layout editor", "layouts.fit": "Fit", + "layouts.zoom100": "True size (100%, 1:1 on screen)", "layouts.addViewport": "Add viewport", "layouts.pickSnapshot": "Pick a view …", "layouts.noSnapshots": "No views available. Save a view first.", @@ -1576,19 +1585,20 @@ export const en: Record = { "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", + // Annotations (line/rect/text/image) drawn directly on the sheet — with the + // same tools as the plan (left tools panel), no layout-only tool. "layouts.annotTextTitle": "Text", "layouts.annotTextLabel": "Text content:", "layouts.annotEditText": "Edit text", - "layouts.annotColor": "Color", - "layouts.annotWeight": "Line weight (mm)", "layouts.annotRemove": "Remove annotation", + "layouts.annotX": "X (mm)", + "layouts.annotY": "Y (mm)", + "layouts.annotWidth": "Width (mm)", + "layouts.annotHeight": "Height (mm)", "layouts.annot.line": "Line", "layouts.annot.rect": "Rectangle", "layouts.annot.text": "Text", + "layouts.annot.image": "Image", // Tree / “+” menu / folders "layouts.add": "New …", "layouts.addFolder": "Create folder", diff --git a/src/io/imageAsset.ts b/src/io/imageAsset.ts new file mode 100644 index 0000000..128965c --- /dev/null +++ b/src/io/imageAsset.ts @@ -0,0 +1,48 @@ +// Bild-Datei → eingebettete Data-URL + natürliche Pixelgrösse — gemeinsame +// Grundlage für „Bild einfügen" im Plan (Drawing2D `{shape:"image"}`) UND im +// Layout (LayoutAnnotation `kind:"image"`). Reine Browser-API (FileReader + +// Image), kein Tauri-fs nötig — funktioniert identisch im Web-Preview. + +export interface LoadedImageAsset { + /** Data-URL (PNG/JPEG/…), direkt im Projekt einbettbar (kein externer Pfad). */ + src: string; + naturalWidth: number; + naturalHeight: number; +} + +/** Liest eine Bilddatei als Data-URL + ermittelt ihre natürliche Pixelgrösse. */ +export function readImageFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error("read failed")); + reader.onload = () => { + const src = String(reader.result); + const img = new Image(); + img.onload = () => + resolve({ src, naturalWidth: img.naturalWidth || 1, naturalHeight: img.naturalHeight || 1 }); + img.onerror = () => resolve({ src, naturalWidth: 1, naturalHeight: 1 }); + img.src = src; + }; + reader.readAsDataURL(file); + }); +} + +/** Default-Grösse (Modell-Meter) aus dem Pixel-Seitenverhältnis: feste Breite, Höhe folgt. */ +export function defaultImageSizeM( + naturalWidth: number, + naturalHeight: number, + widthM = 2, +): { widthM: number; heightM: number } { + const ratio = naturalHeight / Math.max(1, naturalWidth); + return { widthM, heightM: Math.max(0.05, widthM * ratio) }; +} + +/** Default-Grösse (mm) fürs Layout-Blatt, analog mit anderer Default-Breite. */ +export function defaultImageSizeMm( + naturalWidth: number, + naturalHeight: number, + widthMm = 60, +): { widthMm: number; heightMm: number } { + const ratio = naturalHeight / Math.max(1, naturalWidth); + return { widthMm, heightMm: Math.max(2, widthMm * ratio) }; +} diff --git a/src/io/pdfImport.ts b/src/io/pdfImport.ts new file mode 100644 index 0000000..4e0bd03 --- /dev/null +++ b/src/io/pdfImport.ts @@ -0,0 +1,41 @@ +// PDF-Import als Rasterbild: jede Seite wird auf ein `` gerendert +// (pdfjs-dist, reine Browser-/Webview-API — kein Tauri-fs nötig) und als +// Data-URL zurückgegeben. Kein eigener PDF-Rendering-Pfad im Plan/Layout — +// eine importierte PDF-Seite ist danach ein ganz normales Bild-Element +// (Drawing2D `{shape:"image"}` bzw. LayoutAnnotation `kind:"image"`). + +import * as pdfjsLib from "pdfjs-dist"; +import type { LoadedImageAsset } from "./imageAsset"; + +// Worker-Bundle-URL über `import.meta.url` auflösen (Vite-Standardmuster für +// Asset-URLs) — braucht keine `?url`-Modul-Deklaration wie ein direkter Import. +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url, +).href; + +/** + * Rastert alle Seiten einer PDF-Datei zu PNG-Data-URLs. `scale` steuert die + * Auflösung (2 ≈ Bildschirm-Retina-Schärfe bei üblichen Seitengrössen). + */ +export async function rasterizePdfPages(file: File, scale = 2): Promise { + const buf = await file.arrayBuffer(); + const pdf = await pdfjsLib.getDocument({ data: buf }).promise; + const pages: LoadedImageAsset[] = []; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(viewport.width)); + canvas.height = Math.max(1, Math.ceil(viewport.height)); + const ctx = canvas.getContext("2d"); + if (!ctx) continue; + await page.render({ canvasContext: ctx, viewport, canvas }).promise; + pages.push({ + src: canvas.toDataURL("image/png"), + naturalWidth: canvas.width, + naturalHeight: canvas.height, + }); + } + return pages; +} diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts index c915763..d1fbb4d 100644 --- a/src/model/geoRebase.ts +++ b/src/model/geoRebase.ts @@ -108,6 +108,8 @@ export function translateProject(project: Project, dx: number, dy: number): Proj return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } }; case "text": return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } }; + case "image": + return { ...d, geom: { ...g, min: shift(g.min, dx, dy), max: shift(g.max, dx, dy) } }; } }), context: project.context?.map((o) => shiftContextObject(o, dx, dy)), diff --git a/src/model/types.ts b/src/model/types.ts index 5b6f927..3d1be01 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1513,6 +1513,26 @@ export type Drawing2DGeom = * (Modell-Meter) geführt, nicht über `marks.sizePt`. */ marks?: Marks; + /** + * Reiches Textfeld (mehrere Absätze, gemischte Formatierung pro Zeichen — + * InDesign-/Affinity-artig): gesetzt über den Doppelklick-Editor + * (TextEditorDialog/RichTextEditor). Ist `doc` gesetzt, hat es Vorrang vor + * `text`/`marks` beim Rendern; `text` wird beim Schreiben als Reintext- + * Spiegel nachgeführt (DXF-Export, Suche, Alt-Konsumenten lesen weiter + * `text`). `width` bricht wie gehabt wortweise um. + */ + doc?: RichTextDoc; + } + | { + shape: "image"; + /** Obere-linke Ecke der Bild-Box (Modell-Meter). */ + min: Vec2; + /** Untere-rechte Ecke der Bild-Box (Modell-Meter). */ + max: Vec2; + /** Bildquelle als Data-URL (PNG/JPEG) — eingebettet, keine externe Datei. */ + src: string; + /** Deckkraft 0..1 (Default 1). */ + opacity?: number; }; /** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */ @@ -1598,8 +1618,6 @@ export interface EdgeGrip { free?: boolean; } -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, @@ -2016,6 +2034,24 @@ export type LayoutAnnotation = text: string; heightMm: number; color?: string; + /** + * Reiches Textfeld (mehrere Absätze, gemischte Formatierung) — Vorrang vor + * `text`, s. gleichnamiges Feld bei `Drawing2DGeom["text"]`. + */ + doc?: RichTextDoc; + /** Optionale Rahmenbreite (mm) — bricht `doc` wortweise um; fehlt sie: einzeilig. */ + widthMm?: number; + } + | { + id: string; + kind: "image"; + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; + /** Bildquelle als Data-URL (PNG/JPEG) — eingebettet, keine externe Datei. */ + src: string; + opacity?: number; }; /** @@ -2036,6 +2072,9 @@ export type LayoutAnnotationPatch = Partial<{ text: string; color: string; weightMm: number; + doc: RichTextDoc; + src: string; + opacity: number; }>; /** diff --git a/src/panels/AttributesPanel.tsx b/src/panels/AttributesPanel.tsx index 8215c19..e340fed 100644 --- a/src/panels/AttributesPanel.tsx +++ b/src/panels/AttributesPanel.tsx @@ -15,7 +15,7 @@ // Es werden NUR Eigenschaften angeboten, die der Host-Kontrakt tatsächlich // setzen kann — keine Stubs/Fake-Setter (Konvention wire-dont-stub). Felder ohne // echten Setter (Linienstil) werden read-only/informativ gezeigt; Felder, die -// das Modell gar nicht trägt (Deckkraft, Caps, Schlagschatten), werden bewusst +// das Modell gar nicht trägt (Caps, Schlagschatten), werden bewusst // weggelassen, um die Palette ehrlich und schlank zu halten. // // DOSSIER-Tabellen-Look: KEINE wiederholten Feld-Labels pro Zeile — ein kompaktes @@ -107,10 +107,20 @@ export function AttributesPanel() { } const isDrawing = sel.kind === "drawing2d"; + // Layout-Annotation (Linie/Rechteck/Text/Bild auf einem Blatt) — kein + // Projekt-Bauteil, daher ohne Ebene/Bauteil-Vererbung: Farbe/Strichstärke + // sind IMMER ein eigener Wert (kein 3-Quellen-Dropdown, keine Füllung/ + // Schraffur/Masse — s. Ausblendungen weiter unten). + const isAnnotation = sel.kind === "layoutAnnotation"; // Strichstärke: Wand/Decke/Drawing2D tragen einen eigenen Override // (`strokeWeight`/`weightMm`) + Quellen-Dropdown; andere Elementarten erben - // stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar). - const weightEditable = sel.kind === "wall" || sel.kind === "ceiling" || isDrawing; + // stets aus der Kategorie (kein Setter im Kontrakt → nicht editierbar). Bei + // Annotationen nur Linie/Rechteck (Text/Bild kennen keine Strichstärke). + const weightEditable = + sel.kind === "wall" || + sel.kind === "ceiling" || + isDrawing || + (isAnnotation && (sel.annotKind === "line" || sel.annotKind === "rect")); // Schraffur: Wand/Decke (Schnitt-/Ansichts-Schraffur) oder geschlossene 2D-Form. const fillEditable = sel.kind === "wall" || sel.kind === "ceiling" || (isDrawing && sel.closed === true); @@ -223,97 +233,165 @@ export function AttributesPanel() { - {/* Strichstärke (mm) — Wand/Decke/Drawing2D, mit Nach-Ebene/Nach-Bauteil/ - eigener-Wert-Quelle; andere Elementarten erben stets aus der Kategorie. */} - {sourceRow( - "attr.weight", - weightEditable, - weightUi, - onWeightSourceChange, - <> - { - const v = Number(e.target.value); - if (Number.isFinite(v)) host.onSetSelectionWeight(v); - }} - /> - - {PEN_WEIGHTS.map((w) => ( - - , + {/* Strichstärke (mm): Wand/Decke/Drawing2D mit Nach-Ebene/Nach-Bauteil/ + eigener-Wert-Quelle; eine Layout-Annotation kennt diese Vererbung + nicht (kein Bauteil/keine Ebene) — dort ein schlichtes Zahlenfeld + ohne Quellen-Dropdown, wenn die Art überhaupt eine Strichstärke + trägt (Linie/Rechteck, nicht Text/Bild). */} + {isAnnotation ? ( + weightEditable && ( + <> + {t("attr.weight")} + + { + const v = Number(e.target.value); + if (Number.isFinite(v)) host.onSetSelectionWeight(v); + }} + /> + + + ) + ) : ( + sourceRow( + "attr.weight", + weightEditable, + weightUi, + onWeightSourceChange, + <> + { + const v = Number(e.target.value); + if (Number.isFinite(v)) host.onSetSelectionWeight(v); + }} + /> + + {PEN_WEIGHTS.map((w) => ( + + , + ) )} - {/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/etc. read-only). */} - {t("attr.lineStyle")} - - {isDrawing ? ( - - host.onSetSelectionLineStyle(v === "__none__" ? undefined : v) - } - options={[ - { value: "__none__", label: t("attr.lineStyle.none") }, - ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), - ]} - /> - ) : ( - {lineStyle ? lineStyle.name : "—"} - )} - + {/* Deckkraft (0..1) — nur Bild-Elemente (Drawing2D `{shape:"image"}` + im Plan, Layout-Annotation `kind:"image"`). Schlichtes Zahlenfeld + wie die Strichstärke bei Annotationen: kein Bauteil/keine Ebene, + also keine Quellen-Vererbung. */} + {sel.isImage && ( + <> + {t("attr.opacity")} + + { + const v = Number(e.target.value); + if (Number.isFinite(v)) host.onSetSelectionOpacity(Math.max(0, Math.min(1, v))); + }} + /> + + + )} + + {/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/Annotation etc. + read-only), Rest wie gehabt. */} + {!isAnnotation && ( + <> + {t("attr.lineStyle")} + + {isDrawing ? ( + + host.onSetSelectionLineStyle(v === "__none__" ? undefined : v) + } + options={[ + { value: "__none__", label: t("attr.lineStyle.none") }, + ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), + ]} + /> + ) : ( + {lineStyle ? lineStyle.name : "—"} + )} + + + )} {/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */} {/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/ Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) / - Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */} -
{t("attr.fill")}
+ Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. + Eine Layout-Annotation kennt keine Füllung (nur Umriss) — Sektion + entfällt komplett statt drei bedeutungslose Dropdowns zu zeigen. */} + {!isAnnotation && ( + <> +
{t("attr.fill")}
- {sourceRow( - "attr.foreground", - pocheEditable, - fgUi, - onForegroundSourceChange, - colorValue(sel.foreground, host.onSetSelectionForeground), + {sourceRow( + "attr.foreground", + pocheEditable, + fgUi, + onForegroundSourceChange, + colorValue(sel.foreground, host.onSetSelectionForeground), + )} + + {sourceRow( + "attr.background", + pocheEditable, + bgUi, + onBackgroundSourceChange, + colorValue(sel.background, host.onSetSelectionBackground), + )} + + {sourceRow( + "attr.hatch", + fillEditable, + hatchUi, + onHatchSourceChange, + host.onSetSelectionFill(v || null)} + options={[ + { value: "", label: t("attr.none") }, + ...project.hatches.map((h) => ({ value: h.id, label: h.name })), + ]} + />, + )} + )} - {sourceRow( - "attr.background", - pocheEditable, - bgUi, - onBackgroundSourceChange, - colorValue(sel.background, host.onSetSelectionBackground), + {/* ── Masse (informativ, aus der bbox) — bei einer Layout-Annotation + in Papier-mm statt Modell-Metern geführt (`formatM` passt nicht), + daher hier weggelassen; Grösse steht bereits im Blatt-HUD. */} + {!isAnnotation && ( + <> +
{t("attr.size")}
+ {t("attr.width")} + {formatM(bboxW)} + {t("attr.height")} + {formatM(bboxH)} + )} - - {sourceRow( - "attr.hatch", - fillEditable, - hatchUi, - onHatchSourceChange, - host.onSetSelectionFill(v || null)} - options={[ - { value: "", label: t("attr.none") }, - ...project.hatches.map((h) => ({ value: h.id, label: h.name })), - ]} - />, - )} - - {/* ── Maße (informativ, aus der bbox) ───────────────────────── */} -
{t("attr.size")}
- {t("attr.width")} - {formatM(bboxW)} - {t("attr.height")} - {formatM(bboxH)} {/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) — diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index c903cb1..d0e5674 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -158,89 +158,102 @@ export function ObjectInfoPanel() {
- {/* 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 ( - - ); - }), - )} -
+ {/* Bezugspunkt/X-Y/Maße sind MODELL-Meter (Grundriss) — für eine Layout- + Annotation (Papier-Millimeter, eigenes Koordinatensystem) gibt es + keinen sinnvollen Bezugspunkt hier; Position/Grösse editiert man + direkt am Rahmen auf dem Blatt (Greifpunkte + das dortige HUD in + mm — `layout-sheet-hud` in LayoutSheet.tsx). Sonst zeigte dieses + Panel für sie fest 0/0 (Selection.bbox ist für sie ein Platzhalter, + s. App.tsx) — verwirrend statt nur „nicht anwendbar". */} + {sel.kind === "layoutAnnotation" ? ( +
{t("objinfo.layoutAnnotationHint")}
+ ) : ( + <> + {/* 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 ( + + ); + }), + )} +
- {/* 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")} - - — - + {/* 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)} /> +
- {/* 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)} /> -
-
-
+
- {/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */} -
{t("objinfo.dimensions")}
- commitSize(w, height)} - /> - 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)} - /> + {/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */} +
{t("objinfo.dimensions")}
+ commitSize(w, height)} + /> + 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)} + /> + )} + )} {/* Kontext-Objekt (importiertes Gebäude/Terrain/Konturen): reale LV95- Koordinate des Bbox-Mittelpunkts — direkt sichtbar, ob/wohin diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index d8441f7..b38fab6 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -156,6 +156,15 @@ export function ToolIcon({ id }: { id: ToolId }) { ); + case "image": + // Bild-Rahmen mit Bergsilhouette + Sonne (klassisches Bild-Icon). + return ( + + + + + + ); default: return null; } @@ -166,7 +175,7 @@ export function ToolIcon({ id }: { id: ToolId }) { // und 3D und Bearbeiten"). Befehlsnamen spiegeln die Ribbon-Registry // (ui/ribbon/ribbonItems.ts) — dort liegt die kanonische Liste; hier nur die // Panel-Gruppierung (2D-Zeichnen · 3D/BIM-Bauteile · Bearbeiten-Aktionen). -const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox"]; +const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox", "image"]; const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"]; const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"]; const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "join", "measure"]; diff --git a/src/panels/host.ts b/src/panels/host.ts index f112ef1..01af8b2 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -13,7 +13,7 @@ import { useContext } from "react"; import { PanelHostContext } from "./types"; -import type { DisplayMode } from "./types"; +import type { DisplayMode, PanelHost } from "./types"; import type { AttributeSource, CeilingType, @@ -197,6 +197,11 @@ export interface PanelHostValue { * `null` entfernt die Füllfarbe (Fläche wieder transparent). */ onSetSelectionFillColor: (color: string | null) => void; + /** + * Setzt die Deckkraft (0..1) — wirkt NUR auf Bild-Elemente (Drawing2D + * `{shape:"image"}` oder Layout-Annotation `kind:"image"`), sonst No-op. + */ + onSetSelectionOpacity: (opacity: number) => void; /** * Setzt/entfernt den Vordergrund-Override (Muster-/Schraffurfarbe, „eigener * Wert") der Selektion — Wand, Decke oder geschlossene Drawing2D. `null` = @@ -653,3 +658,28 @@ export function usePanelHost(): PanelHostValue { } return host as unknown as PanelHostValue; } + +/** + * Baut den fertigen {@link PanelHost}-Wert aus dem memoisierten `base` + + * live injizierten Feldern (Messwerte, aktiver Darstellungsmodus je Tab). + */ +export function hostWithMode( + base: Omit, + activeTab: string | null, + modeFor: (id: string | null) => DisplayMode, + setModeFor: (id: string | null, mode: DisplayMode) => void, + measurement: MeasurementReadout | null = null, +): PanelHost { + const value: PanelHostValue = { + ...base, + // Live-Messwerte NICHT aus dem memoisierten `base` (dort wären sie eingefroren, + // da `draft` bewusst kein Memo-Dep ist — sonst würde der ganze Host bei jeder + // Mausbewegung neu gebaut). Hier bei jedem Render frisch injiziert. + measurement, + displayMode: { + mode: modeFor(activeTab), + setMode: (mode) => setModeFor(activeTab, mode), + }, + }; + return value as unknown as PanelHost; +} diff --git a/src/panels/layoutModel.test.ts b/src/panels/layoutModel.test.ts index b79c438..2660722 100644 --- a/src/panels/layoutModel.test.ts +++ b/src/panels/layoutModel.test.ts @@ -163,6 +163,19 @@ describe("createAnnotation — Defaults je Art", () => { const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 }); expect(a).toMatchObject({ text: "EG", heightMm: 5 }); }); + it("text: init mit doc/widthMm (Rich-Text-Frame) wird übernommen", () => { + const doc = { version: 1 as const, paragraphs: [{ runs: [{ text: "Hi", marks: {} }] }] }; + const a = createAnnotation("a3", "text", { doc, widthMm: 50 }); + expect(a).toMatchObject({ doc, widthMm: 50 }); + }); + it("image: Default-Grösse, leere Quelle", () => { + const a = createAnnotation("a4", "image"); + expect(a).toEqual({ id: "a4", kind: "image", xMm: 20, yMm: 20, widthMm: 60, heightMm: 40, src: "" }); + }); + it("image: init setzt Quelle + Grösse + Deckkraft", () => { + const a = createAnnotation("a4", "image", { src: "data:image/png;base64,x", widthMm: 30, heightMm: 20, opacity: 0.5 }); + expect(a).toMatchObject({ src: "data:image/png;base64,x", widthMm: 30, heightMm: 20, opacity: 0.5 }); + }); }); describe("Annotations-CRUD (immutabel)", () => { @@ -199,7 +212,7 @@ describe("Annotations-CRUD (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"); + expect((l.annotations![0] as { color?: string }).color).toBe("#111"); }); it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => { diff --git a/src/panels/layoutModel.ts b/src/panels/layoutModel.ts index c63ec0c..4081302 100644 --- a/src/panels/layoutModel.ts +++ b/src/panels/layoutModel.ts @@ -546,6 +546,18 @@ export function createAnnotation( ...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}), }; } + if (kind === "image") { + return { + id, + kind: "image", + xMm: init.xMm ?? 20, + yMm: init.yMm ?? 20, + widthMm: init.widthMm ?? 60, + heightMm: init.heightMm ?? 40, + src: init.src ?? "", + ...(init.opacity !== undefined ? { opacity: init.opacity } : {}), + }; + } return { id, kind: "text", @@ -554,6 +566,8 @@ export function createAnnotation( text: init.text ?? "", heightMm: init.heightMm ?? 3.5, ...colorPatch, + ...(init.doc !== undefined ? { doc: init.doc } : {}), + ...(init.widthMm !== undefined ? { widthMm: init.widthMm } : {}), }; } diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index ae61d51..ec434dd 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -14,7 +14,7 @@ import { useState, } from "react"; import type { HatchRender, Plan, Primitive } from "./generatePlan"; -import { docToLines, type SvgLine } from "../text/renderHtml"; +import { docToLines, wrapDocToLines, type SvgLine } from "../text/renderHtml"; import type { EdgeGrip, Vec2 } from "../model/types"; import type { SnapResult, ToolDraft, ToolHandlers, ToolId, ToolMods } from "../tools/types"; import { useGlPlanRenderer } from "./useGlPlanRenderer"; @@ -566,9 +566,14 @@ export const PlanView = forwardRef( // vollen SVG-Pfad nutzen — dort liegt das Bild als eigene Ebene ZWISCHEN // Blatt-Hintergrund und Geometrie (sichtbar, ohne den GL-Renderer zu ändern). const hasBgImages = (plan.backgroundImages?.length ?? 0) > 0; + // Eingebettete Bild-Zeichenelemente (Drawing2D `{shape:"image"}`, Foto/Logo/ + // PDF-Seite) kann das GPU-Backend ebenfalls nicht texturieren (kein Bild- + // Primitiv in render2d, s. toRenderScene.ts `RImage`-Kommentar) — dieselbe + // SVG-Fallback-Regel wie bei georeferenzierten Luftbildern. + const hasDrawingImages = plan.primitives.some((p) => p.kind === "drawingImage"); // Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der // volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay. - const useGpuRenderer = wantGpu && glReady && !hasBgImages; + const useGpuRenderer = wantGpu && glReady && !hasBgImages && !hasDrawingImages; // Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich- // breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim @@ -959,8 +964,8 @@ export const PlanView = forwardRef( } } if (bestId) return bestId; - // Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik). - return pickDrawingText(clientX, clientY); + // Kein Linien-Treffer: Text- dann Bild-Elemente prüfen (eigene Bbox-Logik). + return pickDrawingText(clientX, clientY) ?? pickDrawingImage(clientX, clientY); }; // Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell- @@ -988,6 +993,21 @@ export const PlanView = forwardRef( return null; }; + // Trifft der Klick ein Bild-2D-Element (kind "drawingImage")? Reine + // Bounding-Box-Prüfung (achsparallel, keine Rotation). Liefert die drawingId + // oder null. + const pickDrawingImage = (clientX: number, clientY: number): string | null => { + const m = rawModelAt(clientX, clientY); + for (let i = plan.primitives.length - 1; i >= 0; i--) { + const p = plan.primitives[i]; + if (p.kind !== "drawingImage" || !p.drawingId) continue; + if (m.x >= p.min.x && m.x <= p.max.x && m.y >= p.min.y && m.y <= p.max.y) { + return p.drawingId; + } + } + return null; + }; + // Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit // `openingId` per Bildschirm-Nähe, plus getroffene Fenster-Rahmen (Polygon mit // openingId, per Punkt-in-Polygon). Liefert die openingId oder null. @@ -3815,6 +3835,25 @@ function renderPrimitive( ); } + case "drawingImage": { + // Eingebettetes Rasterbild (Foto/Logo/gerasterte PDF-Seite), achsparallel. + // Screen-Y ist gespiegelt (toScreen) → oben-links = (min.x, max.y). + const topLeft = toScreen(p.max); + const w = (p.max.x - p.min.x) * PX_PER_M; + const h = (p.max.y - p.min.y) * PX_PER_M; + if (w <= 0 || h <= 0) return null; + return ( + + ); + } case "drawingText": { // Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern → // viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). @@ -3822,6 +3861,76 @@ function renderPrimitive( 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; + + // Reiches Textfeld (InDesign-artig, mehrere Absätze/gemischte Formatierung): + // hat Vorrang vor dem Alt-Pfad (einfacher String + einheitliche `marks`) + // weiter unten. Bricht bei gesetzter Spaltenbreite wortweise um (Run-Grenzen + // bleiben erhalten, s. `wrapDocToLines`). + if (p.doc) { + const unitPerPt = (0.0254 / 72) * PX_PER_M; + const basePt = (p.heightM / 0.0254) * 72; + const lineHeight = fontSize * 1.25; + const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth * PX_PER_M : undefined; + const docLines: SvgLine[] = wrapDocToLines(p.doc, { + x: 0, + y: 0, + lineHeight, + unitPerPt, + basePt, + color: p.color, + ...(wrapWidthUnits ? { wrapWidthUnits } : {}), + }); + if (docLines.length === 0) return null; + const lineYs: number[] = []; + { + let y = c.y; + docLines.forEach((line, k) => { + if (k > 0) y += line.lineHeight; + lineYs.push(y); + }); + } + return ( + + {docLines.map((line, k) => { + const anchor = + line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; + const x = !wrapWidthUnits + ? c.x + : line.align === "center" + ? c.x + wrapWidthUnits / 2 + : line.align === "right" + ? c.x + wrapWidthUnits + : c.x; + return ( + + {line.tspans.length === 0 + ? "​" + : line.tspans.map((ts, j) => ( + + {ts.text} + + ))} + + ); + })} + + ); + } + + // Alt-Pfad: einfacher String + einheitliche `marks` (Text-Werkzeug/DXF-Import + // ohne Rich-Doc) — unverändert. // 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; diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 378d593..b4a3724 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -4,18 +4,23 @@ // Kern-Erkenntnis: Der Grundriss entsteht NICHT durch Zerschneiden eines // 3D-Meshes, sondern direkt aus den Parametern (Wandachsen, Schichtaufbau, // Öffnungen) — exakt, schnell, sauber. +// +// Diese Datei ist nur noch der ORCHESTRATOR: `generatePlan()`/ +// `generateSectionPlan()` sowie die `resolve*SectionStyle`-Helfer (für den +// Schnitt-Pfad, `toSection.ts`). Das gemeinsame `Primitive`-Vokabular, die +// By-Layer/By-Object-Attribut-Auflösung (`resolveHatch`/`resolveForeground`/…) +// und die Papier-mm-/Tinten-Konstanten leben in `generatePlan/shared.ts`; jede +// Bauteil-Domäne (Wände, Decken, Öffnungen, Treppen, Dächer, Kontext, +// Schnittführung, Räume, sonstige Tragwerks-/Fremdkörper, freie 2D-Geometrie) +// hat ihre eigene Datei unter `generatePlan/` — siehe dort für die konkrete +// Zeichenlogik je Bauteil. import type { - AttributeSource, Ceiling, - Column, Component, ContextObject, Drawing2D, - ExtrudedSolid, - HatchPattern, LayerCategory, - Opening, OverrideActions, Project, Roof, @@ -30,533 +35,58 @@ import { flattenCategories, getCeilingType, getComponent, - getDoorType, - getHatch, getLayerCategory, - getLineStyle, getWallType, - getWindowType, openingLabel, openingsOfWall, - roofLayers, roomsOfFloor, - sashesOfWindowType, stairLabel, stairsOfFloor, wallTypeThickness, } from "../model/types"; -import { columnFootprint } from "../geometry/column"; -import { mergeHoles } from "../geometry/polygonHoles"; import { roofGeometry } from "../geometry/roof"; import { effectiveOverrides, hasOverrides } from "../overrides/engine"; -import { evaluateRoom, siaLabel } from "../geometry/roomArea"; -import type { Marks, RichTextDoc } from "../text/richText"; -import { docFromText } from "../text/richText"; -import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp"; -import type { RoomStampLine } from "../model/roomStamp"; -import { stairVerticalExtent, wallReferenceOffset } from "../model/wall"; -import { stairGeometry, stairCut, stairOutline } from "../geometry/stair"; -import type { SpiralOutlineSegments } from "../geometry/stair"; -import { - doorSymbol, - doorLeaves, - openingGapQuad, - openingInterval, - openingJambs, - wallAxisFrame, - windowSymbol, -} from "../geometry/opening"; -import { - add, - along, - clippedBand, - dot, - leftNormal, - lineIntersect, - normalize, - scale, - sub, - wallCorners, -} from "../model/geometry"; -import type { Line } from "../model/geometry"; - -/** - * Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier - * konkret zwei Dinge im Grundriss: - * • Tür-/Fenstersymbole: grob = nur Öffnungs-Lücke + gerade Türblatt-Linie; - * mittel = + Schwenkbogen; fein = + Rahmen/Anschlag-Striche. - * • Schraffuren + Linienfeinheit: grob = vereinfachte Poché (eine Sammelfläche - * ohne Schraffur, dickere Umrisslinie); mittel = aktueller mehrschichtiger - * Aufbau mit Schraffuren; fein = volle Schraffuren + dünnere Linien. - * - * NOCH OFFEN (folgt, sobald die Features existieren — hier bewusst KEIN - * Schein-Effekt): Bemaßung/Beschriftung werden mit dem Detailgrad auf-/abblenden - * (Maßketten, Raumstempel), und die 3D-Geometrie-Auflösung (z. B. Profil- - * Rundungen, Tritt-/Setzstufen) wird ebenfalls dem Detailgrad folgen, sobald - * Bemaßung bzw. eine feinere 3D-Tessellierung implementiert sind. - */ -export type DetailLevel = "grob" | "mittel" | "fein"; +import { stairVerticalExtent } from "../model/wall"; +import { stairGeometry } from "../geometry/stair"; +import { wallCorners } from "../model/geometry"; import { computeJoins } from "../model/joins"; -import type { WallCuts } from "../model/joins"; import type { SectionOutput } from "./toSection"; +import { + HATCH_INK, + HATCH_PAPER, + LAYER_LINE_MM, + MONO_INK, + SHOW_ALL, + WALL_FALLBACK_MM, + resolveForeground, + resolveHatch, + resolveHatchId, + resolveStrokeWeight, + type CategoryDisplayResolver, + type DetailLevel, + type HatchRender, + type Primitive, +} from "./generatePlan/shared"; +import { addCeilingPoche } from "./generatePlan/ceilings"; +import { addContextContours } from "./generatePlan/context"; +import { addDrawing2D } from "./generatePlan/drawing2d"; +import { addDoorSymbol, addOpeningSymbol } from "./generatePlan/openings"; +import { addRoof } from "./generatePlan/roofs"; +import { addRoomArea } from "./generatePlan/rooms"; +import { addSectionLines } from "./generatePlan/sections"; +import { addStairSymbol } from "./generatePlan/stairs"; +import { addColumnPoche, addExtrudedSolidOutline } from "./generatePlan/structures"; +import { addReferenceLine, addWallPoche, buildWallFootprints, wallGaps } from "./generatePlan/walls"; -/** Stroke-Farbe der Schicht-Polygone (neutrales Schwarz, geteilte Tinte mit der Schraffur). */ -const POCHE_STROKE = "#1a1a1a"; - -/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */ -const MONO_INK = "#111111"; - -// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ── -// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über -// dpi = 96 in konstante Bildschirm-Pixel um (non-scaling-stroke), damit Linien -// beim Zoomen ihre Papiergröße behalten und der Massstab stimmt. - -/** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02), - * gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar - * (z. B. 0.13, wenn zwei massive Materialien aufeinandertreffen). */ -const LAYER_LINE_MM = 0.02; -/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */ -const SYMBOL_HAIRLINE_MM = 0.02; -/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der - * Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach - * BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */ -const OVERHEAD_DASH: number[] = [0.4, 0.25]; -/** Strichstärken der Dach-Grundrisslinien (mm Papier): Traufe = mittlere - * Haarlinie, First etwas kräftiger als die Traufe, Grat dazwischen, Knick dünn - * + gestrichelt (vgl. addRoofs). */ -const ROOF_EAVES_MM = 0.13; -const ROOF_RIDGE_MM = 0.25; -const ROOF_HIP_MM = 0.18; -const ROOF_BREAK_MM = 0.09; -/** Strichmuster (mm Papier) der Dach-Knicklinie (Mansarde). */ -const ROOF_BREAK_DASH: number[] = [0.12, 0.08]; -/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */ -const OUTLINE_DETAIL_FACTOR: Record = { - grob: 1.6, // dickere Sammellinie - mittel: 1.0, // wie die Kategorie-Strichstärke - fein: 0.6, // feiner -}; -/** Stärke der Schichtfugen je Detailgrad (mittel/fein; grob hat keine Fugen). */ -const LAYER_DETAIL_FACTOR: Record = { - grob: 1.0, - mittel: 1.0, - fein: 0.7, -}; -/** Default-Umrandungs-Strichstärke (mm), falls die Kategorie keine lw trägt - (Grundeinstellung für „normale Elemente"). */ -const WALL_FALLBACK_MM = 0.18; +// Re-Export des gemeinsamen Vokabulars — externe Konsumenten (Export-Pipeline, +// PlanView, toSection.ts, toWalls3d.ts, toRenderScene.ts, …) importieren +// weiterhin ausschliesslich von hier, unverändert zur Zeit vor dem Domänen-Split. +export { HATCH_INK, HATCH_PAPER, resolveForeground, resolveHatch }; +export type { CategoryDisplayResolver, DetailLevel, HatchRender, Primitive }; /** - * Aufgelöste Schraffur eines Polygons — alle Werte stammen aus einer - * HatchStyle-Ressource (Muster/Maßstab/Winkel/Farbe) plus dem optional - * verknüpften LineStyle (Linienstärke/Strichmuster der Musterlinien). Die - * PlanView ist damit rein darstellend und kennt das Projekt nicht. + * Füllfarbe + Schraffur eines aufgelösten Bauteils, fertig für eine Cut-Poché. */ -export interface HatchRender { - pattern: HatchPattern; - scale: number; - angle: number; - /** - * AUFGELÖSTE Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid"). - * Ergebnis der Farb-Resolve-Kette (siehe {@link resolveHatch}): Vordergrund = - * (Attribut-Override) ?? (Component.foreground) ?? (HatchStyle.color, Backward- - * Compat) ?? Default. Der heutige SVG-/GL-/DXF-Renderer liest weiterhin nur - * dieses Feld — die Kette ist zur Renderzeit bereits kollabiert. - */ - color: string; - /** Linienstärke der Musterlinien in mm. */ - lineWeight: number; - /** Strichmuster der Musterlinien in mm; null = durchgezogen. */ - dash: number[] | null; - /** - * Schraffur-Typ (additiv). `undefined`/"vector" ⇒ Muster nach `pattern` (heutiges - * Verhalten); "image" ⇒ Bild-Muster (`image`). Bestehende Renderer ignorieren - * `kind` und zeichnen nach `pattern` → unbekannte kinds fallen auf heute zurück. - */ - kind?: "vector" | "image"; - /** Vektor-Untermodus (additiv). `undefined`/"parallel" ⇒ regelmäßig; "random" ⇒ zufällig. */ - lines?: "parallel" | "random"; - /** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */ - image?: { src: string; scaleX: number; scaleY: number; rotation: number }; - /** - * Steuer-Parameter der Random-Vektor-Streuung (nur `lines==="random"`), 1:1 aus - * dem HatchStyle durchgereicht. Alle optional — die eigentliche Erzeugung/ - * Default-Auflösung liegt zentral in `glPlan/glPlanHatch.ts` (single render truth). - */ - seed?: number; - density?: number; - lengthMin?: number; - lengthMax?: number; -} - -/** - * Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus - * dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus, - * `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked") - * verbietet die Anwahl (siehe App.tsx onPlanSelect). Wird kein Resolver - * übergeben, gilt für jede sichtbare Kategorie {render:true, greyed:false, - * locked:false} (bisheriges Verhalten). - */ -export interface CategoryDisplay { - render: boolean; - greyed: boolean; - locked: boolean; -} - -/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */ -export type CategoryDisplayResolver = (code: string) => CategoryDisplay; - -/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */ -const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false, locked: false }); - -export type Primitive = - | { - kind: "polygon"; - pts: Vec2[]; - /** Füllfarbe (Component-Poché) bzw. "none" für reine Umrisslinien. */ - fill: string; - stroke: string; - /** Umriss-Strichstärke in PAPIER-Millimeter (siehe oben). */ - strokeWidthMm: number; - hatch: HatchRender; - /** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */ - greyed?: boolean; - /** - * Kanten-Indizes, die NICHT umrissen (gestrichen) werden dürfen — die Füllung - * bleibt das volle Polygon. Kante `i` läuft von `pts[i]` nach `pts[(i+1)%n]`. - * Damit werden die inneren Gehrungs-/Stoß-Stirnflächen an einer Wandecke - * unterdrückt: die Diagonale, an der GLEICHFARBIGE Schichten zweier Wände - * aneinanderstoßen, würde sonst als sichtbare 45°-Naht erscheinen und der - * Umriss würde als Barbe über den Eck-Apex hinausschießen (Gehrungs-Spitze). - * Längskanten (Schichtfugen zwischen VERSCHIEDENEN Materialien) bleiben - * gestrichen — nur die Stoß-Stirnkanten am Knoten entfallen. - */ - noStrokeEdges?: number[]; - /** - * ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die - * Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben - * Wand und meldet die wallId nach oben). Reine Umriss-/Symbol-Primitive - * ohne Wandbezug lassen das Feld weg. - */ - wallId?: string; - /** - * ID des 2D-Zeichenelements (Drawing2D), falls dieses gefüllte Polygon eine - * geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche). - */ - drawingId?: string; - /** - * ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss - * ist (für die Links-Klick-Auswahl der Decke). - */ - ceilingId?: string; - /** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */ - openingId?: string; - /** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */ - 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 des Kontext-Objekts (importiertes Gebäude/Terrain, `project.context`), - * falls dieses (unsichtbare) Pick-Band dessen Grundriss-Bounding-Box - * umhüllt (Links-Klick-Auswahl — analog `sectionLineId`, s. u.). - */ - contextObjectId?: 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; - /** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */ - roofId?: string; - /** - * ID der Schnitt-/Ansichtslinie (DrawingLevel), falls dieses (unsichtbare) - * Pick-Band das Schnittführungs-Symbol umhüllt (für die Links-Klick-Auswahl - * der Schnittlinie im Grundriss). - */ - sectionLineId?: string; - } - | { - kind: "line"; - a: Vec2; - b: Vec2; - cls: string; - /** Strichstärke in mm Papier (non-scaling). */ - weightMm: number; - /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ - dash?: number[] | null; - /** - * Zickzack-/Wellen-Parameter (aus einem LineStyle mit `kind==="zigzag"`), - * beide in mm Papier. Ist es gesetzt, zeichnen die Renderer die Linie als - * Zickzack-Pfad statt als gerade (ggf. gestrichelte) Linie — ADDITIV, ohne - * die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute). - */ - zigzag?: { amplitude: number; wavelength: number }; - /** - * Frei gezeichnetes Wiederhol-Motiv (aus einem LineStyle mit - * `kind==="custom"`): eine offene Polylinie in einer Einheitszelle, `points` - * in mm Papier, `length` = Wiederhollänge in mm Papier. Ist es gesetzt, - * kacheln die Renderer das Motiv entlang der Linie (analog `zigzag`) — - * ADDITIV; fehlt es, bleibt die Linie gerade/gestrichelt. - */ - motif?: { points: Vec2[]; length: number }; - /** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */ - color?: string; - /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */ - drawingId?: string; - /** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */ - openingId?: string; - /** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */ - stairId?: string; - greyed?: boolean; - } - | { - kind: "arc"; - center: Vec2; - from: Vec2; - to: Vec2; - r: number; - cls: string; - /** Strichstärke in mm Papier (non-scaling). */ - weightMm: number; - /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ - dash?: number[] | null; - /** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */ - openingId?: string; - /** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */ - stairId?: string; - greyed?: boolean; - } - | { - kind: "text"; - /** Ankerpunkt im Modell (Meter). */ - at: Vec2; - /** - * Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet; - * `align` je Absatz steuert die Ausrichtung. - */ - doc: RichTextDoc; - /** - * LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc - * gezeichnet — unabhängig vom editierten Text, stets aktuell. Jede Zeile - * trägt ihre eigene Ausrichtung (`align`). - */ - extraLines: RoomStampLine[]; - /** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */ - basePt: number; - /** Textfarbe (hex) als Default. */ - color: string; - /** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */ - roomId?: string; - greyed?: boolean; - } - | { - // Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`, - // z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt - // er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert. - kind: "drawingText"; - /** Ankerpunkt im Modell (Meter). */ - at: Vec2; - text: string; - /** Schrifthöhe in Modell-Metern. */ - heightM: number; - /** Drehung in RADIANT (CCW im Modell). */ - angle: number; - color: string; - /** Optionale einheitliche Formatierung (Schriftfamilie/fett/kursiv/…). */ - marks?: Marks; - /** - * 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; - } - | { - // Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als - // echtes `` gerendert (rund bei jedem Zoom), nicht als Vieleck. - kind: "drawingCircle"; - center: Vec2; - /** Radius in Modell-Metern. */ - r: number; - /** Vollton-Füllfarbe oder "none". */ - fill: string; - stroke: string; - weightMm: number; - dash?: number[] | null; - drawingId?: string; - greyed?: boolean; - } - | { - // Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als - // SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell). - kind: "drawingArc"; - center: Vec2; - r: number; - a0: number; - a1: number; - stroke: string; - weightMm: number; - dash?: number[] | null; - drawingId?: string; - greyed?: boolean; - }; - -/** - * Löst eine HatchStyle-Ressource (per id) zu einer darstellungsfertigen - * {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt, - * aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default. - * - * VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): AKTUELL fest auf - * {@link HATCH_INK} gezwungen (Nutzer-Vorgabe 2026-07-04) — Schnitt- WIE - * Oberflächen-/Ansichtsschraffur zeigen dieselbe dunkle Tinte, unabhängig von - * Material-/Bauteilfarbe, HatchStyle.color oder einem By-Layer/By-Object- - * Vordergrund-Override. `_foreground` bleibt Teil der Signatur — alle Aufrufer - * reichen weiterhin den über {@link resolveForeground} aufgelösten Bauteil- - * Vordergrund durch (z. B. für eine spätere Wiederherstellung der materialtreuen - * Schraffur) —, fließt aber bewusst nicht mehr in `color` ein. - */ -export function resolveHatch( - project: Project, - hatchId: string, - wallAngleDeg?: number, - _foreground?: string, -): HatchRender { - const h = getHatch(project, hatchId); - const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null; - // Wandbezogener Winkel: `HatchRender.angle` wird von allen Renderern in der - // SVG-Konvention (im Uhrzeigersinn, Bildschirm-Y nach unten) verstanden — der - // Modell-Renderer negiert ihn in `toModelAngleRad`. Die Wandachse `wallAngleDeg` - // ist dagegen ein MODELL-Winkel (atan2 der Richtung, gegen den Uhrzeigersinn, - // Y nach oben). Wegen der Y-Spiegelung entspricht die Wandrichtung am Bildschirm - // dem Winkel −wallAngleDeg; das Muster folgt der Wand also, indem der - // Wandwinkel SUBTRAHIERT wird. Ohne Wandkontext (Decke) bleibt `angle` absolut. - const angle = - h.relativeToWall && wallAngleDeg != null ? h.angle - wallAngleDeg : h.angle; - return { - pattern: h.pattern, - scale: h.scale, - angle, - // Fest auf die Monochrom-Tinte gezwungen (siehe JSDoc oben) — weder - // `_foreground` noch `HatchStyle.color` (Material-/Bauteilfarbe) fließen ein. - color: HATCH_INK, - lineWeight: ls?.weight ?? 0.13, - dash: ls?.dash ?? null, - kind: h.kind, - lines: h.lines, - image: h.image, - // Random-Streu-Parameter unverändert durchreichen (Erzeugung in glPlanHatch). - seed: h.seed, - density: h.density, - lengthMin: h.lengthMin, - lengthMax: h.lengthMax, - }; -} - -/** - * Kollabiert die Vordergrund-Farbkette (Muster-/Schraffurlinienfarbe) eines - * Bauteils zu einem einzelnen Wert für {@link resolveHatch}. AUFLÖSUNGSREIHENFOLGE - * (By-Layer/By-Object, siehe `docs`/Attribut-Panel): - * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ?? - * (`Component.foreground`) ?? `undefined`. - * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert). - * `undefined` als Endergebnis heißt „Nach System" — die Kette läuft dann in - * resolveHatch auf den HatchStyle.color-Backward-Compat-Fallback weiter - * (Sample-Verhalten: kein Element setzt `override`/`source`, also identisch). - */ -export function resolveForeground( - comp: Component, - override?: string, - category?: LayerCategory, - source?: AttributeSource, -): string | undefined { - if (override != null) return override; - if (source === "layer") return category?.color ?? comp.foreground; - return comp.foreground; -} - -/** - * Kollabiert die Hintergrund-/Füllfarbkette (Poché) eines Bauteils, analog zu - * {@link resolveForeground}: - * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ?? - * (`Component.background`) ?? `Component.color`. - * Immer definiert (endet spätestens bei `Component.color` = heutiges Verhalten). - */ -export function resolveBackground( - comp: Component, - override?: string, - category?: LayerCategory, - source?: AttributeSource, -): string { - if (override != null) return override; - if (source === "layer") return category?.color ?? comp.background ?? comp.color; - return comp.background ?? comp.color; -} - -/** - * Kollabiert die Schraffur-Referenz (Hatch Manager) eines Elements zu einer - * einzelnen `hatchId` für {@link resolveHatch} — By-Layer/By-Object, analog zu - * {@link resolveForeground}: - * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ?? - * `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder - * `undefined` bei Drawing2D ohne Bauteil-Bezug). - * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert). - * Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück. - */ -export function resolveHatchId( - override: string | undefined, - source: AttributeSource | undefined, - category: LayerCategory | undefined, - fallback?: string, -): string | undefined { - if (override != null) return override; - if (source === "layer") return category?.hatch ?? fallback; - return fallback; -} - -/** - * Kollabiert die Strichstärke-Kette (mm Papier) eines Elements, By-Layer/By- - * Object, analog zu {@link resolveHatchId}: - * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ?? - * `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des - * Aufrufers — heutiges Verhalten). - * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert). - */ -export function resolveStrokeWeight( - override: number | undefined, - source: AttributeSource | undefined, - category: LayerCategory | undefined, - fallback: number, -): number { - if (override != null) return override; - if (source === "layer") return category?.lw ?? fallback; - return fallback; -} - -// ── SIA-Poché-Füllung + monochrome Schraffur-Tinte ────────────────────────── -// Die 2D-Poché einer (auch mehrschichtigen) Wand trägt NICHT die rohe Bauteil- -// Albedo (3D-Materialfarbe), sondern einen neutralen Hintergrund, auf dem die -// Schraffur-Musterlinien liegen: Vollmuster ("solid") = dunkle Tinten-Poché -// (kein Linienmuster), jedes andere Muster (inkl. "none") = HELLER Hintergrund. -// Dieselben zwei Werte sind AKTUELL auch die feste Schraffur-Vordergrundfarbe -// (siehe {@link resolveHatch}) — Schnitt- wie Oberflächen-Schraffur zeigen also -// einheitlich Tinte auf hellem Papier, unabhängig von Material-/Bauteilfarbe. -export const HATCH_PAPER = "#f0f0f0"; -export const HATCH_INK = "#0f0f0f"; -/** Neutrale Poché-Füllfarbe für ein Muster: "solid" → Tinte, sonst Papier. */ -function pocheFill(pattern: HatchPattern): string { - return pattern === "solid" ? HATCH_INK : HATCH_PAPER; -} - -/** Füllfarbe + Schraffur eines aufgelösten Bauteils, fertig für eine Cut-Poché. */ export interface SectionCutStyle { fill: string; hatch: HatchRender; @@ -604,7 +134,7 @@ export function resolveWallSectionStyle(project: Project, wall: Wall): SectionCu /** * Löst das erste Bauteil des Deckentyps einer Decke auf — dieselbe Regel wie - * {@link addCeilingPoche} für die normale Deckenpoché — und liefert dessen + * `addCeilingPoche` für die normale Deckenpoché — und liefert dessen * Füllfarbe + echte Schraffur für die Schnitt-Poché. `null`, wenn Deckentyp * oder Bauteil nicht auflösbar sind bzw. der Deckentyp keine Schicht trägt. */ @@ -1087,169 +617,6 @@ export function generateSectionPlan( return { primitives, bounds: { minX, minY, maxX, maxY } }; } -/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */ -const CONTEXT_LINE_COLOR = "#9aa3ad"; -/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */ -const CONTEXT_LINE_MM = 0.1; - -/** Höhen-Toleranz (Meter): Dreieckskanten, deren beide Endpunkte höchstens so - * weit über der Objekt-Unterkante (minZ) liegen, gelten als „Sockelkante" und - * bilden den Grundriss-Fussabdruck eines importierten Gebäudes. */ -const FOOTPRINT_Z_TOL = 0.6; - -/** - * Zeichnet die Kontext-Schicht `project.context` in den Grundriss: - * • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert). - * • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe - * der Objekt-Unterkante (Sockel), auf die XY-Ebene projiziert und über - * quantisierte Endpunkte entdoppelt — so erscheint das importierte Haus als - * erkennbarer Umriss im Plan (Nutzer-Wunsch: „ich sehe es sonst nicht"), - * ohne das gesamte Dach-Drahtgitter dicht darüberzulegen. - * • TerrainMesh → nur der 2D-Umriss (Bounding-Box), da ein volles TIN-Gitter - * im Plan unbrauchbar dicht wäre. - * Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt. - */ -function addContextContours(out: Primitive[], project: Project): void { - const context = project.context ?? []; - for (const obj of context) { - if (obj.type === "contourSet") { - for (const c of obj.contours) { - const pts = c.pts; - if (pts.length < 2) continue; - const segEnd = c.closed ? pts.length : pts.length - 1; - for (let i = 0; i < segEnd; i++) { - out.push({ - kind: "line", - a: pts[i], - b: pts[(i + 1) % pts.length], - cls: "context-line", - weightMm: CONTEXT_LINE_MM, - color: CONTEXT_LINE_COLOR, - }); - } - } - } else if (obj.type === "importedMesh") { - addMeshFootprint(out, obj.positions, obj.indices); - addContextPickRegion(out, obj.positions, obj.id); - } else if (obj.type === "terrainMesh") { - addMeshBboxOutline(out, obj.positions); - addContextPickRegion(out, obj.positions, obj.id); - } - } -} - -/** - * Zeichnet den Grundriss-Fussabdruck eines Meshes: Dreieckskanten nahe der - * Unterkante (minZ + {@link FOOTPRINT_Z_TOL}), auf XY projiziert, über - * quantisierte Endpunkt-Schlüssel entdoppelt. `positions` = flaches [x,y,z…]- - * Array (Modell-Meter, z = Höhe), `indices` = Dreiecks-Indizes. - */ -function addMeshFootprint(out: Primitive[], positions: number[], indices: number[]): void { - const vcount = Math.floor(positions.length / 3); - if (vcount < 3 || indices.length < 3) return; - let minZ = Infinity; - for (let i = 2; i < positions.length; i += 3) if (positions[i] < minZ) minZ = positions[i]; - if (!Number.isFinite(minZ)) return; - const zLimit = minZ + FOOTPRINT_Z_TOL; - const q = (v: number) => Math.round(v * 100); // 1-cm-Quantisierung (Entdopplung) - const seen = new Set(); - const edge = (ia: number, ib: number) => { - if (ia >= vcount || ib >= vcount) return; - const za = positions[ia * 3 + 2]; - const zb = positions[ib * 3 + 2]; - if (za > zLimit || zb > zLimit) return; // nur Sockelkanten - const ax = positions[ia * 3], ay = positions[ia * 3 + 1]; - const bx = positions[ib * 3], by = positions[ib * 3 + 1]; - // Kanten-Schlüssel richtungsunabhängig (kleinerer Endpunkt zuerst). - const ka = `${q(ax)},${q(ay)}`; - const kb = `${q(bx)},${q(by)}`; - if (ka === kb) return; - const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; - if (seen.has(key)) return; - seen.add(key); - out.push({ - kind: "line", - a: { x: ax, y: ay }, - b: { x: bx, y: by }, - cls: "context-line", - weightMm: CONTEXT_LINE_MM, - color: CONTEXT_LINE_COLOR, - }); - }; - for (let i = 0; i + 2 < indices.length; i += 3) { - const a = indices[i], b = indices[i + 1], c = indices[i + 2]; - edge(a, b); - edge(b, c); - edge(c, a); - } -} - -/** Zeichnet nur den achsenparallelen 2D-Umriss (Bounding-Box) eines Meshes — - * für Terrain, dessen volles TIN-Gitter im Plan zu dicht wäre. */ -function addMeshBboxOutline(out: Primitive[], positions: number[]): void { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (let i = 0; i + 2 < positions.length; i += 3) { - const x = positions[i], y = positions[i + 1]; - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - if (!Number.isFinite(minX)) return; - const corners = [ - { x: minX, y: minY }, - { x: maxX, y: minY }, - { x: maxX, y: maxY }, - { x: minX, y: maxY }, - ]; - for (let i = 0; i < 4; i++) { - out.push({ - kind: "line", - a: corners[i], - b: corners[(i + 1) % 4], - cls: "context-line", - weightMm: CONTEXT_LINE_MM, - color: CONTEXT_LINE_COLOR, - }); - } -} - -/** - * Unsichtbares Pick-Band (Grundriss-Bounding-Box) für ein Kontext-Objekt - * (importiertes Gebäude/Terrain) — klickbar per Punkt-in-Polygon in PlanView, - * trägt die `ContextObject.id` (analog `sectionLineId`, s. dort). Macht das - * Objekt IRGENDWO innerhalb seiner Grundfläche anklickbar (nicht nur exakt auf - * der dünnen Umriss-/Fussabdrucklinie) — wie bei Wänden, deren Poché-Fläche - * ebenfalls als Ganzes klickbar ist (Nutzer-Wunsch: „eigene Elemente wie Wände"). - * `positions` = flaches [x,y,z…]-Array (Modell-Meter); die Bbox nutzt NUR x/y, - * unabhängig von der Höhe. - */ -function addContextPickRegion(out: Primitive[], positions: number[], id: string): void { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (let i = 0; i + 2 < positions.length; i += 3) { - const x = positions[i], y = positions[i + 1]; - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - if (!Number.isFinite(minX)) return; - out.push({ - kind: "polygon", - pts: [ - { x: minX, y: minY }, - { x: maxX, y: minY }, - { x: maxX, y: maxY }, - { x: minX, y: maxY }, - ], - fill: "none", - stroke: "none", - strokeWidthMm: 0, - hatch: NO_HATCH, - contextObjectId: id, - }); -} - /** * Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der * Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place. @@ -1305,1964 +672,6 @@ 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 - * Kategorie. Phase 1 deckt line/polyline/rect ab (alles → line-Primitive); - * circle/arc/text folgen mit den Primitive-Erweiterungen (Phase 3). - */ -function addDrawing2D( - out: Primitive[], - project: Project, - d: Drawing2D, - greyed: boolean, - lwByCode: Map, - colorByCode: Map, - catByCode: Map, -): void { - const category = catByCode.get(d.categoryCode); - const ls = d.lineStyleId - ? project.lineStyles.find((l) => l.id === d.lineStyleId) - : undefined; - // Plan-Tinte fix dunkel: Fallback auf MONO_INK (statt hell „#d0d0d0"), damit - // ein kategorieloses 2D-Element auf dem hellen Papier sichtbar bleibt. - const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK; - // Strichstärke: "layer" erzwingt die Ebenen-Strichstärke (umgeht das - // LineStyle-Gewicht), sonst (Default "object") die heutige Kette. - const weightMm = resolveStrokeWeight( - d.weightMm, - d.strokeWeightSource, - category, - ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM, - ); - const dash = ls?.dash ?? null; - // Drawing2D trägt kein Bauteil → die By-Layer-Quelle ist hier die einzige - // Alternative zum expliziten Wert (kein Component-Fallback); Default - // "object"/undefined bleibt exakt das heutige Verhalten. - const foreground = d.foreground ?? (d.foregroundSource === "layer" ? category?.color : undefined); - const background = d.background ?? (d.backgroundSource === "layer" ? category?.color : undefined); - const hatchId = resolveHatchId(d.hatchId, d.hatchSource, category, undefined); - // Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag"). - // Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher. - const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined; - // Custom-Motiv analog durchreichen (nur kind==="custom"). - const motif = ls?.kind === "custom" ? ls.motif : undefined; - const seg = (a: Vec2, b: Vec2) => - out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, motif, color, greyed, drawingId: d.id }); - - /** - * Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe - * (`d.fillColor`, sonst transparent) UND die optionale Füllschraffur - * (`d.hatchId`, sonst NO_HATCH). Wird VOR den Strichen gepusht, damit die - * Umrisslinien oben liegen. Trägt `drawingId`, sodass ein Klick auf die Fläche - * das Element selektiert (analog zu den Linien). - */ - const fillPoly = (pts: Vec2[]) => - out.push({ - kind: "polygon", - pts, - // Hintergrund-Override (Wert ?? By-Layer) hat Vorrang vor dem Alt-Feld - // fillColor (alle leer = transparente Fläche, nur Schraffur) — Sample - // bleibt identisch (kein Element setzt background/backgroundSource). - fill: background ?? d.fillColor ?? "none", - // Umriss zeichnen die separaten Linien-Primitive (mit Strichmuster); die - // Füllfläche selbst bleibt randlos, um doppelte Striche zu vermeiden. - stroke: "none", - strokeWidthMm: 0, - // Drawing2D trägt kein Bauteil → Vordergrund/Schraffur-Override direkt aus - // d.foreground/d.hatchId bzw. der Ebene (Attribut „Nach System" ⇒ - // undefined ⇒ HatchStyle.color-Fallback bzw. NO_HATCH). - hatch: hatchId ? resolveHatch(project, hatchId, undefined, foreground) : NO_HATCH, - greyed, - drawingId: d.id, - }); - - const g = d.geom; - switch (g.shape) { - case "line": - seg(g.a, g.b); - break; - case "polyline": { - // Geschlossene Polylinie: zuerst die gefüllte Fläche (Vollton/Schraffur). - if (g.closed && g.pts.length > 2) fillPoly(g.pts); - for (let i = 0; i < g.pts.length - 1; i++) seg(g.pts[i], g.pts[i + 1]); - if (g.closed && g.pts.length > 2) seg(g.pts[g.pts.length - 1], g.pts[0]); - break; - } - case "rect": { - const c1 = g.min; - const c2 = { x: g.max.x, y: g.min.y }; - const c3 = g.max; - const c4 = { x: g.min.x, y: g.max.y }; - // Rechteck ist immer geschlossen → zuerst die gefüllte Fläche. - fillPoly([c1, c2, c3, c4]); - seg(c1, c2); - seg(c2, c3); - seg(c3, c4); - seg(c4, c1); - break; - } - case "circle": - // Echtes Kreis-Primitiv → glattes `` (rund bei jedem Zoom) mit - // optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation. - out.push({ - kind: "drawingCircle", - center: g.center, - r: g.r, - fill: background ?? d.fillColor ?? "none", - stroke: color, - weightMm, - dash, - greyed, - drawingId: d.id, - }); - break; - case "arc": - // Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant). - out.push({ - kind: "drawingArc", - center: g.center, - r: g.r, - a0: g.a0, - a1: g.a1, - stroke: color, - weightMm, - dash, - greyed, - drawingId: d.id, - }); - break; - case "text": - out.push({ - kind: "drawingText", - at: g.at, - text: g.text, - heightM: g.height, - angle: g.angle, - // marks.color (falls gesetzt) übersteuert die Kategorie-/Elementfarbe. - color: g.marks?.color ?? color, - ...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}), - ...(g.marks ? { marks: g.marks } : {}), - greyed, - drawingId: d.id, - }); - break; - default: - break; - } -} - -/** - * Wand als gefüllte Schnittfläche (Poché), um Öffnungen ausgespart. - * - * Detailgrad: - * • mittel/fein — jede Schicht des Wandtyps als eigenes Band (Füllfarbe + - * Schraffur des Bauteils), dazu eine kräftige Wand-Umrisslinie. fein zeichnet - * Schichtfugen und Umriss dünner als mittel. - * • grob — KEINE Schichten, KEINE Schraffur: eine einzige Sammelfläche über die - * volle Wanddicke (Füllung = Farbe des „Backbone"-Bauteils mit höchster - * joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER - * „Einfach"). - */ -/** - * Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight` - * = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan - * ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad. - */ -export interface WallGap { - from: number; - to: number; -} - -/** - * Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer - * sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle - * (Breite ≤ 0 oder außerhalb der Achse) werden verworfen. - */ -export function wallGaps( - wall: Wall, - doors: Project["doors"], - openings: Opening[], -): WallGap[] { - const gaps: WallGap[] = []; - for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width }); - for (const o of openings) { - const iv = openingInterval(wall, o); - if (iv) gaps.push(iv); - } - return gaps.sort((a, b) => a.from - b.from); -} - -/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */ -const CUTOUT_EPS = 1e-9; - -/** - * Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die - * verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden - * auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz - * ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes - * Verhalten für Wände ohne Aussparung). - */ -function subtractIntervals( - s: number, - e: number, - holes: Array<[number, number]>, -): Array<[number, number]> { - const clipped = holes - .map(([a, b]): [number, number] => [ - Math.max(s, Math.min(a, b)), - Math.min(e, Math.max(a, b)), - ]) - .filter(([a, b]) => b > a + CUTOUT_EPS) - .sort((p, q) => p[0] - q[0]); - const parts: Array<[number, number]> = []; - let cursor = s; - for (const [a, b] of clipped) { - if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]); - if (b > cursor) cursor = b; - } - if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]); - return parts; -} - -/** - * Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der - * darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u` - * und -Normale `m`, Halbdicke, Referenz-Querversatz (Wandachse↔Bandmitte) und - * Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke - * verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind. - */ -interface WallFootprint { - a: Vec2; - u: Vec2; - m: Vec2; - len: number; - half: number; - refOff: number; -} - -/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer - * Wandtyp ⇒ Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */ -function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] { - const out: WallFootprint[] = []; - for (const w of walls) { - let t = 0.2; - try { - t = wallTypeThickness(getWallType(project, w)); - } catch { - // Default-Dicke behalten. - } - const u = normalize(sub(w.end, w.start)); - out.push({ - a: w.start, - u, - m: leftNormal(u), - len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y), - half: t / 2, - refOff: wallReferenceOffset(w, t), - }); - } - return out; -} - -/** - * Parametrisches Intervall [t0,t1] ⊆ [0,1], über das die Strecke `pa→pb` - * INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn - * die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion - * auf die Achse `u` (Bereich [−half, len+half]) und auf die Normale `m` (Bereich - * [refOff−half, refOff+half]); das Schnitt-Intervall beider Slabs ∩ [0,1] ist die - * verdeckte Strecke. - */ -function segmentInsideWallBand( - pa: Vec2, - pb: Vec2, - fp: WallFootprint, -): [number, number] | null { - const d = sub(pb, pa); - const rel = sub(pa, fp.a); - const clipSlab = ( - f0: number, - fd: number, - lo: number, - hi: number, - ): [number, number] | null => { - if (Math.abs(fd) < 1e-12) - return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null; - let ta = (lo - f0) / fd; - let tb = (hi - f0) / fd; - if (ta > tb) [ta, tb] = [tb, ta]; - return [ta, tb]; - }; - const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half); - if (!su) return null; - const sm = clipSlab( - dot(rel, fp.m), - dot(d, fp.m), - fp.refOff - fp.half, - fp.refOff + fp.half, - ); - if (!sm) return null; - const t0 = Math.max(0, su[0], sm[0]); - const t1 = Math.min(1, su[1], sm[1]); - return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null; -} - -function addWallPoche( - out: Primitive[], - project: Project, - wall: Wall, - gaps: WallGap[], - cuts: WallCuts, - greyed: boolean, - detail: DetailLevel, - wallLwMm: number, - category: LayerCategory | undefined, - // Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei - // 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE - // Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`. - viewOnly: boolean, -): void { - const wt = getWallType(project, wall); - const total = wallTypeThickness(wt); - // Versatz der Schichtstapelung über die Dicke gemäß Referenzlinie (center=0, - // left=+T/2, right=−T/2). Alle Band-Offsets unten werden hierum verschoben, - // sodass die Achse bei „außen"/„innen" auf der jeweiligen Wandfläche liegt. - const refOff = wallReferenceOffset(wall, total); - // Jede Schicht-/Umrissfläche trägt die Wand-ID, damit ein Links-Klick die - // GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben - // melden kann. - const wallId = wall.id; - // Achswinkel der Wand (Modell, Grad) — treibt wandbezogene Schraffuren - // ({@link resolveHatch}), damit z. B. eine Diagonale oder die Dämmungsstriche - // mit der Wandorientierung mitdrehen. - const wallAngleDeg = - (Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI; - // Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color), - // sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss + - // Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren. - const stroke = wall.color ?? POCHE_STROKE; - - // Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint). - const openings = gaps; - - // Wand in Segmente zwischen den Öffnungen zerlegen. - let cursor = 0; - const segments: Array<[number, number]> = []; - for (const op of openings) { - if (op.from > cursor) segments.push([cursor, op.from]); - cursor = Math.max(cursor, op.to); - } - const axisLen = length(wall); - if (cursor < axisLen) segments.push([cursor, axisLen]); - - const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail]; - const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]; - // Vereinfachte Füllung (grob): immer schwarze Vollfüllung, keine Schraffur - - // Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer- - // Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten - // (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die - // Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges - // Verhalten (Wand ohne T-Durchstoss). - const spanCutouts = cuts.spanCutouts ?? []; - - // Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten). - // clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end) - // ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche. - // Diese werden NICHT umrissen: sonst 45°-Naht zwischen gleichfarbigen Schichten - // + überschießende Gehrungs-Barbe am Eck-Apex. Freie Enden (kein Cut) bleiben - // rechtwinklig gestrichen. Die Längskanten (Schichtfugen) bleiben stets sichtbar. - const joinEdges = (startCut: Line | null, endCut: Line | null): number[] => { - const e: number[] = []; - if (endCut) e.push(1); - if (startCut) e.push(3); - return e; - }; - - for (const [s, e] of segments) { - const p1 = along(wall.start, wall.end, s); - const p2 = along(wall.start, wall.end, e); - // Nur die echten Wandenden bekommen die Gehrung. Türnahe Segmentenden - // bleiben rechtwinklig (kein Schnitt). - const startCut = s <= 1e-6 ? cuts.startCut : null; - const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null; - - if (viewOnly) { - // Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke - // als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik - // bleibt dieselbe wie im Schnittfall (nur eben ohne Poché). - out.push({ - kind: "polygon", - pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), - fill: "none", - stroke, - strokeWidthMm: layerLineMm, - hatch: NO_HATCH, - greyed, - noStrokeEdges: joinEdges(startCut, endCut), - wallId, - }); - continue; - } - - if (detail === "grob") { - // Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss. - // Bei grob: immer Vollschwarz-Füllung gemäß SIA 400 Anhang B.9, Figur 36 - out.push({ - kind: "polygon", - pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), - fill: HATCH_INK, - stroke, - strokeWidthMm: outlineMm, - hatch: NO_HATCH, - greyed, - noStrokeEdges: joinEdges(startCut, endCut), - wallId, - }); - continue; - } - - // mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede - // Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe) - // auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede - // innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet. - const bandJoinEdges = joinEdges(startCut, endCut); - // Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren. - const segCutouts = spanCutouts.filter( - (c) => - Math.min(c.from, c.to) < e - CUTOUT_EPS && - Math.max(c.from, c.to) > s + CUTOUT_EPS, - ); - // Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern). - const axisPt = (d: number): Vec2 => - d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d); - // Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts): - // liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den - // einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe - // Maskierung wie oben für startCut/endCut, s <= 1e-6 / e >= axisLen-1e-6). - // Fehlt sie, bleibt alles wie zuvor (startCut/endCut für jede Schicht). - const layerCuts = cuts.layerCuts; - let off = refOff - total / 2; - for (let li = 0; li < wt.layers.length; li++) { - const layer = wt.layers[li]; - const comp = getComponent(project, layer.componentId); - const layerHatchId = - resolveHatchId(wall.hatchId, wall.hatchSource, category, comp.hatchId) ?? comp.hatchId; - const layerHatch = resolveHatch( - project, - layerHatchId, - wallAngleDeg, - resolveForeground(comp, wall.foreground, category, wall.foregroundSource), - ); - // SIA-Poché: neutraler Hintergrund (Papier) statt Bauteil-Albedo, die - // Schichtschraffur (fest in HATCH_INK, siehe resolveHatch) liegt darüber; - // Vollmuster ("solid") = Tinten-Poché. - const layerSolid = layerHatch.pattern === "solid"; - const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null; - const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null; - // Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes - // [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band - // entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das - // Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band - // [s,e] (unverändertes Verhalten). - const bandLo = off; - const bandHi = off + layer.thickness; - const bandHoles: Array<[number, number]> = []; - for (const c of segCutouts) { - const cLo = Math.min(c.offA, c.offB); - const cHi = Math.max(c.offA, c.offB); - if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS) - bandHoles.push([c.from, c.to]); - } - for (const [a, b] of subtractIntervals(s, e, bandHoles)) { - const sc = a <= s + CUTOUT_EPS ? layerStartCut : null; - const ec = b >= e - CUTOUT_EPS ? layerEndCut : null; - out.push({ - kind: "polygon", - pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec), - fill: layerSolid ? HATCH_INK : HATCH_PAPER, - // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht - // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten). - stroke: "none", - strokeWidthMm: 0, - hatch: layerHatch, - greyed, - // Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos). - noStrokeEdges: bandJoinEdges, - wallId, - }); - } - // L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine - // verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt - // zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie - // begrenzt die Schichtbreite dort, wo der Kern sie unterbricht (L-Form). - const sideStart = s <= 1e-6 ? layerCuts?.startSide[li] : null; - const sideEnd = e >= axisLen - 1e-6 ? layerCuts?.endSide[li] : null; - if (sideStart || sideEnd) { - const uSeg = normalize(sub(p2, p1)); - const nSeg = leftNormal(uSeg); - const sidePoint = (base: Vec2, layerOff: number, cut: Line): Vec2 => { - const pt = add(base, scale(nSeg, layerOff)); - return lineIntersect(pt, uSeg, cut.point, cut.dir) ?? pt; - }; - if (sideStart) { - out.push({ - kind: "line", - a: sidePoint(p1, off, sideStart), - b: sidePoint(p1, off + layer.thickness, sideStart), - cls: "layer-joint-l", - weightMm: layerLineMm, - color: stroke, - greyed, - }); - } - if (sideEnd) { - out.push({ - kind: "line", - a: sidePoint(p2, off, sideEnd), - b: sidePoint(p2, off + layer.thickness, sideEnd), - cls: "layer-joint-l", - weightMm: layerLineMm, - color: stroke, - greyed, - }); - } - } - off += layer.thickness; - // Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als - // eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt. - // Stil aus layer.jointLineStyleId (Gewicht/Farbe/Strich); Rückfall = Haar- - // linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail- - // Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade - // sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge. - // Nutzt bewusst weiterhin den einheitlichen startCut/endCut (nicht die - // Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB - // dieser Wand und bleibt daher an der bisherigen Nahfläche. - if (li < wt.layers.length - 1) { - const jls = layer.jointLineStyleId - ? project.lineStyles.find((l) => l.id === layer.jointLineStyleId) - : undefined; - // Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz- - // Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt) - // —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der - // Verschmelzungsfläche keine Trennlinie stehen. - const fugeHoles: Array<[number, number]> = []; - for (const c of segCutouts) { - const cLo = Math.min(c.offA, c.offB); - const cHi = Math.max(c.offA, c.offB); - if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS) - fugeHoles.push([c.from, c.to]); - } - for (const [a, b] of subtractIntervals(s, e, fugeHoles)) { - const sc = a <= s + CUTOUT_EPS ? startCut : null; - const ec = b >= e - CUTOUT_EPS ? endCut : null; - // clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd, - // edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1 - // (Ende), beide am Cut abgeschnitten wie die Bandlängskante. - const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec); - out.push({ - kind: "line", - a: jq[0], - b: jq[1], - cls: "layer-joint", - weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm, - color: jls ? jls.color : stroke, - dash: jls ? jls.dash : null, - // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen. - zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined, - // Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen. - motif: jls?.kind === "custom" ? jls.motif : undefined, - greyed, - }); - } - } - } - // Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), - // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs- - // Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe. - // Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante - // (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht - // und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe - // die Umrisslinie über die Durchstoss-Breite. - const offLo = refOff - total / 2; - const offHi = refOff + total / 2; - const holesHi: Array<[number, number]> = []; - const holesLo: Array<[number, number]> = []; - for (const c of segCutouts) { - // `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der - // passenden Längskante (offHi bzw. offLo) zu. - if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]); - else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]); - } - const outlineNoStroke = [...bandJoinEdges]; - // clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi), - // B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante. - if (holesHi.length) outlineNoStroke.push(2); - if (holesLo.length) outlineNoStroke.push(0); - out.push({ - kind: "polygon", - pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut), - fill: "none", - stroke, - strokeWidthMm: outlineMm, - hatch: NO_HATCH, - greyed, - noStrokeEdges: outlineNoStroke, - wallId, - }); - const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => { - for (const [a, b] of subtractIntervals(s, e, holes)) { - const sc = a <= s + CUTOUT_EPS ? startCut : null; - const ec = b >= e - CUTOUT_EPS ? endCut : null; - const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec); - out.push({ - kind: "line", - a: jq[0], - b: jq[1], - cls: "wall-outline", - weightMm: outlineMm, - color: stroke, - greyed, - }); - } - }; - if (holesHi.length) drawNearEdge(offHi, holesHi); - if (holesLo.length) drawNearEdge(offLo, holesLo); - } -} - -/** - * Tür-Symbol je Detailgrad: - * • grob — nur die gerade Türblatt-Linie (die Öffnungs-Lücke kommt aus der - * ausgesparten Poché). - * • mittel — + Schwenkbogen (geschlossen → offen). - * • fein — + Rahmen/Anschlag-Striche an beiden Pfosten (kurze Striche quer - * zur Wand, die den Türrahmen/-anschlag andeuten). - */ -function addDoorSymbol( - out: Primitive[], - wall: Wall, - door: Project["doors"][number], - greyed: boolean, - detail: DetailLevel, - _doorLwMm: number, -): void { - const u = normalize(sub(wall.end, wall.start)); - const n = leftNormal(u); // linke Seite der Wandachse - const swingDir = door.swing === "left" ? n : scale(n, -1); - - const jambStart = along(wall.start, wall.end, door.position); - const jambEnd = along(wall.start, wall.end, door.position + door.width); - const hinge = door.hinge === "start" ? jambStart : jambEnd; - const closedDir = door.hinge === "start" ? u : scale(u, -1); - - const openEnd = add(hinge, scale(swingDir, door.width)); - const closedEnd = add(hinge, scale(closedDir, door.width)); - - // Türblatt in geöffneter Stellung (alle Stufen). - out.push({ - kind: "line", - a: hinge, - b: openEnd, - cls: "door-leaf", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - }); - - // mittel + fein: Schwenkbogen von geschlossen → offen. - if (detail !== "grob") { - out.push({ - kind: "arc", - center: hinge, - from: closedEnd, - to: openEnd, - r: door.width, - cls: "door-swing", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - }); - } - - // fein: Rahmen/Anschlag-Striche an beiden Pfosten (kurz quer zur Wand, - // symmetrisch um die Achse über eine halbe Wanddicke-Andeutung). - if (detail === "fein") { - const reveal = 0.06; // 6 cm Anschlag-Andeutung je Seite - for (const jamb of [jambStart, jambEnd]) { - out.push({ - kind: "line", - a: add(jamb, scale(n, reveal)), - b: add(jamb, scale(n, -reveal)), - cls: "door-frame", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - }); - } - } -} - -/** - * Aufgelöste Rahmen-Parameter (Typ-Bibliothek) einer Tür-/Fenster-Öffnung — - * nur vorhanden, wenn die Öffnung einen Bauteil-Typ referenziert (`typeId`). - * Fehlt der Typ, bleibt das heutige Inline-Verhalten unverändert (kein - * Rahmenband, kein Oberlicht) — siehe `resolveOpeningFrameSpec`. - */ -interface OpeningFrameSpec { - frameWidth: number; - /** Nur bei Türen relevant; Fenster zeichnen stets die schmale Zargen-Optik. */ - frameKind: "zarge" | "blockrahmen"; - insetFromFace: number; - insetFace: "aussen" | "innen"; - transomHeight: number; -} - -/** - * Löst die Rahmen-Parameter aus dem referenzierten Tür-/Fenstertyp auf (samt - * Defaults); `undefined`, wenn kein Typ referenziert/auflösbar ist — die - * Öffnung bleibt dann beim heutigen Inline-Verhalten (rückwärtskompatibel). - */ -function resolveOpeningFrameSpec(project: Project, o: Opening): OpeningFrameSpec | undefined { - if (o.kind === "door") { - const t = getDoorType(project, o); - if (!t) return undefined; - return { - frameWidth: t.frameWidth ?? 0.06, - frameKind: t.frameKind ?? "zarge", - insetFromFace: t.insetFromFace ?? 0, - insetFace: t.insetFace ?? "aussen", - transomHeight: t.transomHeight ?? 0, - }; - } - const t = getWindowType(project, o); - if (!t) return undefined; - return { - frameWidth: t.frameWidth ?? 0.06, - frameKind: "zarge", - insetFromFace: t.insetFromFace ?? 0, - insetFace: t.insetFace ?? "aussen", - transomHeight: t.transomHeight ?? 0, - }; -} - -/** - * Rahmenband + Oberlicht-Andeutung einer Tür-/Fenstertyp-Öffnung im - * Grundriss-Schnitt. Nur aufgerufen, wenn `resolveOpeningFrameSpec` einen Typ - * liefert — sonst bleibt die Darstellung exakt wie zuvor (kein neues Primitiv). - * - * • Zarge — schmales Umlauf-Rahmenband, ringsum um `frameWidth` - * von der vollen Öffnung (Pfosten × Wanddicke) nach innen eingezogen. - * • Blockrahmen (Tür) — kräftiges Rechteckprofil, sitzt PROUD an der - * gewählten Fläche (kein Einzug an den Pfosten, nur eine Tiefenscheibe von - * `frameWidth` ab der Nah-Fläche), kräftigere Linie. - * • Schichteinzug — verschiebt die Nah-Fläche des Rahmens um - * `insetFromFace` von der gewählten Wandfläche nach innen; die Gegenfläche - * bleibt unverändert (der Rahmen reicht weiterhin bis dorthin). - * • Oberlicht — Überkopf-Andeutung (gestrichelte Linie quer über die - * Öffnung), nur wenn `transomHeight > 0`. - */ -function addOpeningFrameBand( - out: Primitive[], - project: Project, - wall: Wall, - o: Opening, - spec: OpeningFrameSpec, - greyed: boolean, -): void { - const jambs = openingJambs(wall, o); - if (!jambs) return; - const { n } = wallAxisFrame(wall); - const total = wallTypeThickness(getWallType(project, wall)); - const refOff = wallReferenceOffset(wall, total); - // "aussen" = die Fläche der ERSTEN Wandtyp-Schicht (kleinster Achs-Offset, - // s. addWallPoche/`off` startet bei `refOff-total/2`) — mit `n = leftNormal(u)` - // ist das die NEGATIVE Seite. Bug bis 2026-07-12: hier stand `+total/2` für - // "aussen" (positive Seite, tatsächlich "innen") — Türrahmenbänder mit - // insetFace:"aussen" landeten dadurch an der falschen Wandfläche. - const outerFace = refOff - total / 2; // "aussen"-Fläche (−n, erste Schicht) - const innerFace = refOff + total / 2; // "innen"-Fläche (+n, letzte Schicht) - - // Schichteinzug: die GEWÄHLTE Fläche rückt um `insetFromFace` nach innen; die - // gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin - // bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace. - const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total)); - const nearIsAussen = spec.insetFace !== "innen"; - // "Nach innen" heisst: von der gewählten Fläche RICHTUNG WANDMITTE (refOff) — - // von aussen (negative Seite) aus AUFWÄRTS (+clampedInset), von innen - // (positive Seite) aus ABWÄRTS (−clampedInset). - const faceNear = nearIsAussen ? outerFace + clampedInset : innerFace - clampedInset; - const faceFar = nearIsAussen ? innerFace : outerFace; - const lo = Math.min(faceNear, faceFar); - const hi = Math.max(faceNear, faceFar); - - const { jambStart, jambEnd } = jambs; - const color = o.color ?? POCHE_STROKE; - - // Rahmen-Rechteck NUR bei Türen zeichnen. Beim Fenster stellt bereits - // `windowSymbol` den Rahmen dar (voller Öffnungsumriss + Glaslinien); ein - // zusätzliches Band ergäbe ein doppeltes, verschachteltes Rechteck — genau - // das wirkte im 2D „nicht korrekt". Die Tür hat dort nur Blatt+Bogen, daher - // ist ihr Rahmenband die einzige Rahmenkontur und bleibt. - if (o.kind === "door") { - const frameWidth = Math.max(0, spec.frameWidth); - const isBlock = spec.frameKind === "blockrahmen"; - let bandLo: number; - let bandHi: number; - let jStart: Vec2; - let jEnd: Vec2; - let weightMm: number; - if (isBlock) { - // Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine - // Tiefenscheibe von frameWidth ab der Nah-Fläche — sitzt proud VOR/auf der - // Laibung, statt schmal darin zu verschwinden (Zargen-Optik). "aussen" - // liegt bei `lo` (s. Kommentar zu outerFace/innerFace oben), die Scheibe - // beginnt dort und wächst RICHTUNG WANDMITTE (Richtung `hi`). - const depth = Math.min(frameWidth, hi - lo); - bandLo = nearIsAussen ? lo : hi - depth; - bandHi = nearIsAussen ? lo + depth : hi; - jStart = jambStart; - jEnd = jambEnd; - weightMm = SYMBOL_HAIRLINE_MM * 2.2; - } else { - // Zarge: ringsum um frameWidth von der vollen Öffnung eingezogenes, - // schmales Rahmenband (Laibungs-Anschlaglinie). - const jambSpan = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y); - const jambInset = Math.min(frameWidth, jambSpan / 2); - jStart = along(jambStart, jambEnd, jambInset); - jEnd = along(jambEnd, jambStart, jambInset); - const depthInset = Math.min(frameWidth, (hi - lo) / 2); - bandLo = lo + depthInset; - bandHi = hi - depthInset; - weightMm = SYMBOL_HAIRLINE_MM; - } - - // Geschlossenes Rechteck aus 4 Kanten (Primitive "line", nicht "polygon" — - // so bleibt das Band per `cls` selektierbar/testbar wie die übrigen Symbole). - const p1 = add(jStart, scale(n, bandLo)); - const p2 = add(jEnd, scale(n, bandLo)); - const p3 = add(jEnd, scale(n, bandHi)); - const p4 = add(jStart, scale(n, bandHi)); - const edges: [Vec2, Vec2][] = [ - [p1, p2], - [p2, p3], - [p3, p4], - [p4, p1], - ]; - for (const [a, b] of edges) { - out.push({ - kind: "line", - a, - b, - cls: "opening-frame", - weightMm, - color, - greyed, - openingId: o.id, - }); - } - } - - // Die frühere Oberlicht-Andeutung (gestrichelte Linie quer über die ganze - // Öffnung bei transomHeight>0) ist entfernt (Nutzer-Report: erscheint immer - // auf der Wandachse, unabhängig davon, ob die Schnittebene den Kämpfer - // überhaupt trifft — im horizontalen Grundriss-Schnitt architektonisch nicht - // aussagekräftig, ein Kämpfer ist ein Höhen-, kein Grundriss-Merkmal). -} - -/** - * Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol` - * arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt - * ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist. - * - * Tür: - * • grob — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché). - * • mittel — + Schwenkbogen (Öffnungswinkel). - * • fein — + Anschlag-Striche an beiden Pfosten. - * Fenster: - * • grob — eine einzelne Glaslinie über die Lücke. - * • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie. - * • fein — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet). - */ -function addOpeningSymbol( - out: Primitive[], - project: Project, - wall: Wall, - o: Opening, - greyed: boolean, - detail: DetailLevel, - lwMm: number, -): void { - 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). - // Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin. - const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung"; - if (!isWandoeffnung) { - // Türblätter (1 oder 2 Flügel, je DoorType.leafCount). Zweiflüglig ⇒ zwei - // halbbreite Blätter an gegenüberliegenden Pfosten (symmetrische Doppeltür). - const leafCount = Math.max(1, Math.round(getDoorType(project, o)?.leafCount ?? 1)); - for (const leaf of doorLeaves(wall, o, leafCount)) { - out.push({ - kind: "line", - a: leaf.hinge, - b: leaf.openEnd, - cls: "door-leaf", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - openingId: o.id, - }); - // mittel + fein: Schwenkbogen von geschlossen → offen je Blatt. - if (detail !== "grob") { - out.push({ - kind: "arc", - center: leaf.hinge, - from: leaf.closedEnd, - to: leaf.openEnd, - r: leaf.radius, - cls: "door-swing", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - openingId: o.id, - }); - } - } - } - // fein: Anschlag-Striche an beiden Pfosten (quer zur Wand). - if (detail === "fein") { - const reveal = 0.06; - for (const jamb of [sym.jambStart, sym.jambEnd]) { - out.push({ - kind: "line", - a: add(jamb, scale(sym.normal, reveal)), - b: add(jamb, scale(sym.normal, -reveal)), - cls: "door-frame", - weightMm: SYMBOL_HAIRLINE_MM, - greyed, - openingId: o.id, - }); - } - } - // Rahmenband (Zarge/Blockrahmen) + Oberlicht-Andeutung — nur wenn die - // Öffnung einen Türtyp referenziert (siehe `resolveOpeningFrameSpec`). - const doorFrameSpec = resolveOpeningFrameSpec(project, o); - if (doorFrameSpec) addOpeningFrameBand(out, project, wall, o, doorFrameSpec, greyed); - // Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über - // die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves` - // im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt. - const lintelMode = o.lintelLines ?? "beide"; - if (lintelMode !== "keine") { - const jambs = openingJambs(wall, o); - if (jambs) { - const { n } = wallAxisFrame(wall); - const total = wallTypeThickness(getWallType(project, wall)); - const refOff = wallReferenceOffset(wall, total); - const halfD = total / 2; - // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in - // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht. - const outerOff = refOff - halfD; - const innerOff = refOff + halfD; - // Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention). - const dash: number[] = [0.18, 0.09]; - const strokeColor = o.color ?? POCHE_STROKE; - /** Linie quer über die Öffnung am gegebenen Perp-Offset. */ - const lintelLine = (perpOff: number) => ({ - a: add(jambs.jambStart, scale(n, perpOff)), - b: add(jambs.jambEnd, scale(n, perpOff)), - }); - if (lintelMode === "aussen" || lintelMode === "beide") { - const seg = lintelLine(outerOff); - out.push({ - kind: "line" as const, - a: seg.a, - b: seg.b, - cls: "door-lintel", - weightMm: SYMBOL_HAIRLINE_MM, - dash, - color: strokeColor, - greyed, - openingId: o.id, - }); - } - if (lintelMode === "innen" || lintelMode === "beide") { - const seg = lintelLine(innerOff); - out.push({ - kind: "line" as const, - a: seg.a, - b: seg.b, - cls: "door-lintel", - weightMm: SYMBOL_HAIRLINE_MM, - dash, - color: strokeColor, - greyed, - openingId: o.id, - }); - } - } - } - return; - } - - // Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad + Fenstertyp. - const wt = getWindowType(project, o); - // Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er - // (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen - // `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit). - const spec = wt - ? { - sashes: sashesOfWindowType(wt), - frameWidth: wt.frameWidth ?? 0.06, - frameDepth: wt.frameDepth, - insetFromFace: wt.insetFromFace, - insetFace: wt.insetFace, - } - : undefined; - // Glaslinienzahl NUR für den Alt-Pfad (kein `spec`) relevant — der typisierte - // Pfad entscheidet die Anzahl selbst nach SIA-Detailgrad (s. windowSymbol). - const glassCount = detail === "fein" ? 2 : 1; - const sym = windowSymbol(project, wall, o, glassCount, spec, detail); - 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, - }); - } - // ── SIA-Fensterdarstellung, nach Detailgrad gestaffelt ──────────────────── - // grob (1:100): weisse Öffnung + EINE dünne Glaslinie, sonst nichts. - // mittel (1:50): Blendrahmen-Rechteck + Pfosten-Trennlinien + Stulp-Mittel- - // quadrat je Flügel-Junction + Glaslinie(n). - // fein (1:20): zusätzlich Flügelrahmen, Stulp als ZWEI Rechtecke, Fensterbank - // (aussen) und Anschlag-/Laibungskerben — deutlich reicher als mittel. - const strokeColor = o.color ?? POCHE_STROKE; - const { n: wn } = wallAxisFrame(wall); - - if (detail === "grob") { - // SIA 1:100 — nur eine dünne Glaslinie mittig über die weisse Öffnung. - // Zählt zur "Fenster"-Kategorie (sashLine) — dieselbe Glaslinie, die bei - // mittel/fein früher sichtbar war (jetzt dort entfernt, s. Fenster-Session). - const g = sym.glassLines[Math.floor(sym.glassLines.length / 2)] ?? sym.glassLines[0]; - if (g) { - out.push({ - kind: "line", - a: g[0], - b: g[1], - cls: "window-glass", - weightMm: o.sashLine?.weight ?? SYMBOL_HAIRLINE_MM, - color: o.sashLine?.color ?? strokeColor, - greyed, - openingId: o.id, - }); - } - } else { - // Linien-Kategorien einzeln übersteuerbar (Nutzer-Wunsch): Rahmen-Schnitt - // (Laibungs-/Stulp-Blöcke, geschnittenes Profil), Rahmen-Aufsicht (die - // durchlaufende Kontur DAZWISCHEN, projiziert statt geschnitten), Fenster - // (Flügelrahmen/Sprossen), Sims (Auf-/Untersicht-Andeutung, s. u.). - // `undefined` ⇒ bisheriges Verhalten (strokeColor/SYMBOL_HAIRLINE_MM); - // `frameViewLine` fällt zusätzlich auf `frameLine` zurück (eine Farbe für - // Schnitt+Aufsicht, bis der Nutzer sie explizit trennt). - const frameColor = o.frameLine?.color ?? strokeColor; - const frameWeight = o.frameLine?.weight ?? lwMm; - const frameHairline = o.frameLine?.weight ?? SYMBOL_HAIRLINE_MM; - const frameViewColor = o.frameViewLine?.color ?? frameColor; - const frameViewWeight = o.frameViewLine?.weight ?? frameWeight; - const sashColor = o.sashLine?.color ?? strokeColor; - const sashWeight = o.sashLine?.weight ?? SYMBOL_HAIRLINE_MM; - - // Blendrahmen-Rechteck in Aufsicht (mittel + fein) — die durchlaufende - // Kontur zwischen den geschnittenen Laibungs-/Stulp-Blöcken. - out.push({ - kind: "polygon", - pts: sym.frame, - fill: "none", - stroke: frameViewColor, - strokeWidthMm: frameViewWeight, - hatch: NO_HATCH, - greyed, - openingId: o.id, - }); - // Pfosten-/Flügel-Trennlinien (Alt-Pfad ohne Typ — zählt als "Fenster"). - for (const [a, b] of sym.mullionLines) { - out.push({ kind: "line", a, b, cls: "window-mullion", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id }); - } - // fein: Flügelrahmen (Flügelkontur) je öffenbarem Flügel — Schachtelung - // Blendrahmen → Flügelrahmen → Glas (Feindetail, hebt fein klar von mittel ab). - if (detail === "fein") { - for (const pts of sym.sashFrames) { - for (let i = 0; i < pts.length; i++) { - out.push({ kind: "line", a: pts[i], b: pts[(i + 1) % pts.length], cls: "window-sash", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id }); - } - } - } - // Laibungs-Blöcke an den Öffnungsenden (Blendrahmen-Querschnitt) — Teil des - // Blendrahmens, zählt zu "Rahmen". - for (const mark of sym.jambMarks) { - for (let i = 0; i < mark.length; i++) { - out.push({ kind: "line", a: mark[i], b: mark[(i + 1) % mark.length], cls: "window-stulp", weightMm: frameHairline, color: frameColor, greyed, openingId: o.id }); - } - } - // Stulp-Mittelquadrat je Flügel-Junction (SIA-Signatur): mittel = EIN Quadrat, - // fein = ZWEI anliegende Rechtecke (die beiden Stulp-Stäbe der Flügel) — - // bereits tiefenkorrekt (inset-/insetFace-bewusst) in windowSymbol berechnet. - // Sitzt zwischen den Flügeln, zählt zu "Fenster" (separat vom Blendrahmen - // steuerbar — Nutzer-Feedback). - for (const mark of sym.meetingMarks) { - for (let i = 0; i < mark.length; i++) { - out.push({ kind: "line", a: mark[i], b: mark[(i + 1) % mark.length], cls: "window-stulp", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id }); - } - } - // Glaslinien (Anzahl je Detailgrad/Verglasung, s. glassCount). - for (const [a, b] of sym.glassLines) { - out.push({ kind: "line", a, b, cls: "window-glass", weightMm: SYMBOL_HAIRLINE_MM, greyed, openingId: o.id }); - } - // Oberlicht-Andeutung (transom, gestrichelt) — mittel/fein. - const windowFrameSpec = resolveOpeningFrameSpec(project, o); - if (windowFrameSpec) addOpeningFrameBand(out, project, wall, o, windowFrameSpec, greyed); - // fein: Fensterbank (aussen) + Anschlag-/Laibungskerben. - if (detail === "fein") { - const jb = openingJambs(wall, o); - if (jb) { - const { jambStart, jambEnd } = jb; - // Tatsächliche Rahmen-Aussenkante (NICHT pauschal die volle Wandfläche — - // ein eingezogener Rahmen, s. {@link WindowType.insetFromFace}, hat seine - // Aussenkante weiter innen). "Aussen" ist der ALGEBRAISCH KLEINERE der - // beiden Tiefen-Werte in sym.frame (s. windowSymbol: frontRel für - // insetFace:"aussen" ist negativ) — welcher der beiden Eckpunkt-Paare das - // ist, hängt von insetFace ab, daher robust über Math.min bestimmt statt - // fest über sym.frame[0]. - const outerN = Math.min( - dot(sub(sym.frame[0], jambStart), wn), - dot(sub(sym.frame[2], jambStart), wn), - ); - // Fensterbank: schmales Rechteck vor der Aussenfläche, seitlich ~3 cm - // überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss). - // Auskragung geht IMMER weiter nach aussen (algebraisch kleiner, s. o.). - const ov = 0.03; - const proj = 0.04; - const s0 = along(jambStart, jambEnd, -ov); - const s1 = along(jambEnd, jambStart, -ov); - const b0 = add(s0, scale(wn, outerN)); - const b1 = add(s1, scale(wn, outerN)); - const b2 = add(s1, scale(wn, outerN - proj)); - const b3 = add(s0, scale(wn, outerN - proj)); - for (const [a, b] of [[b0, b1], [b1, b2], [b2, b3], [b3, b0]] as [Vec2, Vec2][]) { - out.push({ kind: "line", a, b, cls: "window-sill", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); - } - // Anschlag-Kerben an beiden Pfosten (kurz quer zur Wand). - const reveal = 0.06; - for (const jamb of [jambStart, jambEnd]) { - out.push({ kind: "line", a: add(jamb, scale(wn, reveal)), b: add(jamb, scale(wn, -reveal)), cls: "window-frame", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); - } - } - } - } - // Auf-/Untersicht-Andeutung ({@link WindowType.sillLine}): eine Referenzlinie - // an der Rahmen-Aussen-/Innenkante (NICHT der Wandachse — ersetzt die frühere, - // falsch positionierte Brüstungslinie), nur wenn der Typ es explizit wählt. - // "auf" (Aufsicht von oben, dünn gepunktet) vs. "unter" (Untersicht von unten, - // gestrichelt wie die übrigen Überkopf-Projektionen). - if (detail !== "grob" && wt?.sillLine) { - const { faces, view } = wt.sillLine; - const dash = view === "unter" ? OVERHEAD_DASH : [0.04, 0.08]; - const sillColor = o.sillLineStyle?.color ?? strokeColor; - const sillWeight = o.sillLineStyle?.weight ?? SYMBOL_HAIRLINE_MM; - // Welches der beiden sym.frame-Eckpunkt-Paare physisch "aussen" ist, hängt - // von insetFace ab (s. windowSymbol) — robust über den Tiefen-Vergleich - // entlang wn bestimmt statt fest über den Index. - const pair01IsOuter = dot(sym.frame[0], wn) <= dot(sym.frame[3], wn); - const outerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[0], sym.frame[1]] : [sym.frame[3], sym.frame[2]]; - const innerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[3], sym.frame[2]] : [sym.frame[0], sym.frame[1]]; - if (faces === "aussen" || faces === "beide") { - out.push({ kind: "line", a: outerPair[0], b: outerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); - } - if (faces === "innen" || faces === "beide") { - out.push({ kind: "line", a: innerPair[0], b: innerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); - } - } - // Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein - // gestricheltes Rechteck ÜBER der Öffnung, ausserhalb der Wand-Aussenfläche — - // Breite = `shading.box.width` (0/fehlt ⇒ Öffnungsbreite), die zweite - // Rechteckseite = `shading.box.height`. Die 3D-Tiefe (`box.depth`) bleibt für - // eine spätere 3D-Box reserviert (Fokus hier bewusst 2D, s. Aufgabenstellung). - // Gestrichelt wie die übrigen Überkopf-Projektionen (Sturz/Oberlicht) — der - // Kasten liegt oberhalb der Schnittebene und wird nur angedeutet. - if (wt?.shading?.create) { - const jambs = openingJambs(wall, o); - if (jambs) { - const { u, n: nrm } = wallAxisFrame(wall); - const total = wallTypeThickness(getWallType(project, wall)); - const refOff = wallReferenceOffset(wall, total); - // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in - // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht (der - // Rollladenkasten landete an der Innenfläche statt aussen am Sturz). - const outerFace = refOff - total / 2; - const axisWidth = Math.hypot( - jambs.jambEnd.x - jambs.jambStart.x, - jambs.jambEnd.y - jambs.jambStart.y, - ); - const boxWidth = wt.shading.box.width > 0 ? wt.shading.box.width : axisWidth; - const boxHeight = Math.max(0, wt.shading.box.height); - const center = add(jambs.jambStart, scale(u, axisWidth / 2)); - const half = boxWidth / 2; - const c1 = add(center, scale(u, -half)); - const c2 = add(center, scale(u, half)); - const p1 = add(c1, scale(nrm, outerFace)); - const p2 = add(c2, scale(nrm, outerFace)); - const p3 = add(c2, scale(nrm, outerFace + boxHeight)); - const p4 = add(c1, scale(nrm, outerFace + boxHeight)); - const edges: [Vec2, Vec2][] = [ - [p1, p2], - [p2, p3], - [p3, p4], - [p4, p1], - ]; - for (const [a, b] of edges) { - out.push({ - kind: "line", - a, - b, - cls: "window-shading", - weightMm: SYMBOL_HAIRLINE_MM, - dash: OVERHEAD_DASH, - color: o.color ?? POCHE_STROKE, - greyed, - openingId: o.id, - }); - } - } - } -} - -/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */ -const NO_HATCH: HatchRender = { - pattern: "none", - scale: 1, - angle: 0, - color: POCHE_STROKE, - lineWeight: LAYER_LINE_MM, - dash: null, -}; - -/** - * Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse - * (start → end). Von der Oberleiste umschaltbar; rein darstellend. - */ -function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void { - out.push({ - kind: "line", - a: wall.start, - b: wall.end, - cls: "wall-axis", - weightMm: 0.13, - dash: [0.18, 0.06, 0.04, 0.06], // Strich-Punkt-Achse in mm Papier - greyed, - }); -} - -/** - * Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und - * wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine - * gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur - * stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog - * zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe - * bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl. - * - * BEWUSST FLÄCHIG, auch bei mehrschichtigem Deckentyp: anders als die Wand - * (deren Schichten im Grundriss als Querschnitt sichtbar sind) liegt der - * Schichtaufbau einer Decke in Z (oben → unten) — von oben unsichtbar. Die - * Schichtbänderung erscheint daher NUR im Schnitt (siehe `toSection.ts`), nicht - * hier. Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss); - * „mittel"/„fein" zeigen die Ansichts-Schraffur des Bauteils (`viewHatchId`, - * Default weiss) — die frontal gesehene Aufsicht ist kein Schnitt. `lwMm` ist - * die Kategorie-Strichstärke. - */ -function addCeilingPoche( - out: Primitive[], - project: Project, - ceiling: Ceiling, - greyed: boolean, - detail: DetailLevel, - category: LayerCategory | undefined, - wallFootprints: WallFootprint[], -): void { - const pts = ceiling.outline; - if (pts.length < 3) return; - // Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN - // einfachen Poché-Ring einfügen: kein `openings` ⇒ `ring === pts` und - // `bridgeEdges` leer (exakt heutiger Codepfad, siehe {@link mergeHoles}). - const ceilingHoles = ceiling.openings ?? []; - const { ring, bridgeEdges } = ceilingHoles.length - ? mergeHoles(pts, ceilingHoles) - : { ring: pts, bridgeEdges: [] as number[] }; - const wt = getCeilingType(project, ceiling); - const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null; - const stroke = ceiling.color ?? POCHE_STROKE; - const ceilingId = ceiling.id; - - // Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss - // frontal von unten gesehen, nicht aufgeschnitten — daher greift hier - // NICHT die Schnitt-Schraffur (die gehört auf echte Schnittflächen, siehe - // resolveCeilingSectionStyle/splitSlabLayers), sondern `viewHatchId`. Fehlt - // sie (Default), bleibt die Aufsicht weiss. Im Detailgrad „grob" entfällt - // die Schraffur ganz (nur Fläche + Umriss). Das Attribut-Override - // `ceiling.hatchId` (+ `hatchSource`) überschreibt diese Ansichts-Schraffur - // genauso wie die Schnitt-Schraffur in {@link resolveCeilingSectionStyle}. - const effectiveViewHatchId = resolveHatchId( - ceiling.hatchId, - ceiling.hatchSource, - category, - comp?.viewHatchId, - ); - const hatch = - detail !== "grob" && comp && effectiveViewHatchId - ? resolveHatch( - project, - effectiveViewHatchId, - undefined, - resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource), - ) - : NO_HATCH; - // Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter - // der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie - // gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt. - // Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche. - out.push({ - kind: "polygon", - pts: ring, - fill: comp ? pocheFill(hatch.pattern) : "none", - stroke: "none", - strokeWidthMm: 0, - hatch, - greyed, - ceilingId, - ...(bridgeEdges.length ? { noStrokeEdges: bridgeEdges } : {}), - }); - // Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede - // Umriss-Kante (inkl. Lochränder, die als Teil des Rings automatisch - // mitgestrichen werden) gegen die Wand-Footprints clippen und die freien - // Teilstücke als Linien ausgeben. Liegt die Decke deckungsgleich unter den - // Wänden (Regelfall: Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt - // der Umriss ganz; nur Überstände (Vordach/Balkon jenseits der Wand) - // bleiben sichtbar. Brückenkanten (künstliche Verbindung Aussenring↔Loch) - // werden NICHT gestrichen. - const n = ring.length; - const bridgeSet = new Set(bridgeEdges); - for (let i = 0; i < n; i++) { - if (bridgeSet.has(i)) continue; - const a = ring[i]; - const b = ring[(i + 1) % n]; - const holes: Array<[number, number]> = []; - for (const fp of wallFootprints) { - const iv = segmentInsideWallBand(a, b, fp); - if (iv) holes.push(iv); - } - const d = sub(b, a); - for (const [t0, t1] of subtractIntervals(0, 1, holes)) { - out.push({ - kind: "line", - a: add(a, scale(d, t0)), - b: add(a, scale(d, t1)), - cls: "ceiling-outline", - // Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) → - // gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention). - weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail], - dash: OVERHEAD_DASH, - color: stroke, - greyed, - }); - } - } -} - -/** - * Dach im Grundriss (Dachaufsicht): reine Linien-Darstellung ohne Poché — die - * 2D-Geometrie aus `roofGeometry` (First/Grat/Knick hängen NICHT von der - * Traufhöhe ab, daher `eavesZ=0`): - * • Traufe (`eaves`): geschlossener Umriss-Ring als mittlere Haarlinie. - * • First (`ridges`): kräftige Volllinie, etwas stärker als die Traufe. - * • Grat (`hips`, Walm/Zelt): Volllinie zwischen Traufe- und First-Stärke. - * • Knick (`breaks`, Mansarde): dünne gestrichelte Linie. - * Farbe: `roof.color` übersteuert sonst die Kategorie-Farbe (analog Stützen- - * Poché), Default die neutrale Poché-Tinte. - */ -function addRoof( - out: Primitive[], - project: Project, - roof: Roof, - greyed: boolean, - category: LayerCategory | undefined, -): void { - const stroke = roof.color ?? category?.color ?? POCHE_STROKE; - const geo = roofGeometry(roof, 0); - - // Ansichts-Poché der Dachaufsicht: das Dach liegt ÜBER der Schnittebene und - // wird von oben gesehen (nicht geschnitten) → Ansichts-Schraffur - // (viewHatchId) der ÄUSSERSTEN Schicht (Eindeckung) des Dachtyps, analog dem - // Decken-Poché (Aufsicht = erste Lage). Ohne Dachtyp/viewHatchId keine - // Schraffur (weiss) — die Fläche bleibt trotzdem das Pick-Polygon. - let viewHatch = NO_HATCH; - const firstLayer = roofLayers(project, roof)[0]; - if (firstLayer?.componentId) { - try { - const comp = getComponent(project, firstLayer.componentId); - if (comp.viewHatchId) viewHatch = resolveHatch(project, comp.viewHatchId); - } catch { - viewHatch = NO_HATCH; - } - } - - // Pick-Polygon über der Traufe-Fläche (macht das Dach über die ganze Fläche - // per Links-Klick selektierbar) — trägt jetzt zusätzlich die Ansichts- - // Schraffur der Eindeckung (fill bleibt "none": keine Volltonfüllung, nur - // Musterlinien). PlanView testet die `pts` (Punkt-in-Polygon). - if (geo.eaves.length >= 3) { - out.push({ - kind: "polygon", - pts: geo.eaves, - fill: "none", - stroke: "none", - strokeWidthMm: 0, - hatch: viewHatch, - greyed, - roofId: roof.id, - }); - } - - const n = geo.eaves.length; - for (let i = 0; i < n; i++) { - out.push({ - kind: "line", - a: geo.eaves[i], - b: geo.eaves[(i + 1) % n], - cls: "roof-eaves", - weightMm: ROOF_EAVES_MM, - color: stroke, - greyed, - }); - } - for (const [a, b] of geo.ridges) { - out.push({ kind: "line", a, b, cls: "roof-ridge", weightMm: ROOF_RIDGE_MM, color: stroke, greyed }); - } - for (const [a, b] of geo.hips) { - out.push({ kind: "line", a, b, cls: "roof-hip", weightMm: ROOF_HIP_MM, color: stroke, greyed }); - } - for (const [a, b] of geo.breaks) { - out.push({ - kind: "line", - a, - b, - cls: "roof-break", - weightMm: ROOF_BREAK_MM, - dash: ROOF_BREAK_DASH, - color: stroke, - greyed, - }); - } -} - -// ── Schnittführungs-Symbol (klassische Schnittlinie im Grundriss) ─────────── -/** Tinte der Schnittführungs-Linie/Marken (kräftiges Grau). */ -const SECTION_LINE_INK = "#1f242c"; -/** Haarlinie der Strichpunkt-Schnittlinie (mm Papier). */ -const SECTION_LINE_MM = 0.18; -/** Kräftige Endmarken (Querstrich + Pfeil) (mm Papier). */ -const SECTION_LINE_MARK_MM = 0.5; -/** Strichpunkt-Muster der Schnittlinie (lange Striche + Punkt, mm Papier). */ -const SECTION_LINE_DASH: number[] = [6, 1.5, 0.4, 1.5]; -/** Länge der Endmarken (Querstrich/Pfeil) in Modell-Metern. */ -const SECTION_MARK_LEN = 0.6; -/** Halbe Breite des unsichtbaren Pick-Bands über der Linie (Modell-Meter). */ -const SECTION_PICK_HALF = 0.3; -/** Schrifthöhe des Schnitt-Labels (Modell-Meter). */ -const SECTION_LABEL_H = 0.5; - -/** - * Kurz-Tag eines Schnitt-Labels: der letzte „Wort"-Teil des Ebenennamens (z. B. - * „Schnitt A" → „A"); enthält der Name kein Leerzeichen, der ganze Name. Klassisch - * wird der Schnitt an beiden Enden mit diesem Tag beschriftet (A–A). - */ -function sectionLabelTag(name: string): string { - const parts = name.trim().split(/\s+/); - return parts.length > 1 ? parts[parts.length - 1] : name.trim(); -} - -/** - * Zeichnet für jede Schnitt-/Ansichtsebene MIT gesetzter `linePoints` das - * klassische Schnittführungs-Symbol in den Grundriss: eine Strichpunkt-Haarlinie - * über die volle Länge, an beiden Enden einen kräftigen Querstrich + Richtungspfeil - * auf der Blickseite (`directionSign`) und das Schnitt-Label (Kurz-Tag) daneben. - * Zusätzlich ein unsichtbares Pick-Band (Polygon mit `sectionLineId`), damit die - * Linie im Grundriss anklickbar ist (analog dem Dach-Traufe-Pick-Polygon). - */ -function addSectionLines(out: Primitive[], project: Project): void { - for (const level of project.drawingLevels) { - if (level.kind !== "section" && level.kind !== "elevation") continue; - // Schnittebene ist ein-/ausblendbar wie andere Ebenen: eine unsichtbare - // Schnitt-/Ansichtsebene zeigt ihre Linie im Grundriss nicht (Nutzer-Vision - // „eigene Ebene Schnittebenen"). `visible` fehlt = sichtbar (Alt-Projekte). - if (level.visible === false) continue; - const line = level.linePoints; - if (!line) continue; - const [p0, p1] = line; - const axis = sub(p1, p0); - const len = Math.hypot(axis.x, axis.y); - if (len < 1e-6) continue; - const u = normalize(axis); - const n = leftNormal(u); // (-uy, ux) - const sign = level.directionSign ?? 1; - const bn = { x: n.x * sign, y: n.y * sign }; // Blicknormale - - // Hauptlinie (Strichpunkt-Haarlinie). - out.push({ - kind: "line", - a: p0, - b: p1, - cls: "section-line", - weightMm: SECTION_LINE_MM, - dash: SECTION_LINE_DASH, - color: SECTION_LINE_INK, - }); - - // Endmarken (Querstrich + Richtungspfeil + Label) an beiden Enden. - const half = SECTION_MARK_LEN / 2; - for (const end of [p0, p1]) { - // Querstrich senkrecht zur Linie, mittig am Endpunkt. - const t0 = { x: end.x - n.x * half, y: end.y - n.y * half }; - const t1 = { x: end.x + n.x * half, y: end.y + n.y * half }; - out.push({ - kind: "line", - a: t0, - b: t1, - cls: "section-line", - weightMm: SECTION_LINE_MARK_MM, - color: SECTION_LINE_INK, - }); - // Richtungspfeil: vom Querstrich-Ende auf der Blickseite entlang bn. - const base = { x: end.x + bn.x * half, y: end.y + bn.y * half }; - const tip = { x: base.x + bn.x * SECTION_MARK_LEN, y: base.y + bn.y * SECTION_MARK_LEN }; - const wingBack = { - x: tip.x - bn.x * SECTION_MARK_LEN * 0.4, - y: tip.y - bn.y * SECTION_MARK_LEN * 0.4, - }; - const w = SECTION_MARK_LEN * 0.25; - out.push({ kind: "line", a: base, b: tip, cls: "section-line", weightMm: SECTION_LINE_MARK_MM, color: SECTION_LINE_INK }); - out.push({ - kind: "line", - a: tip, - b: { x: wingBack.x + u.x * w, y: wingBack.y + u.y * w }, - cls: "section-line", - weightMm: SECTION_LINE_MARK_MM, - color: SECTION_LINE_INK, - }); - out.push({ - kind: "line", - a: tip, - b: { x: wingBack.x - u.x * w, y: wingBack.y - u.y * w }, - cls: "section-line", - weightMm: SECTION_LINE_MARK_MM, - color: SECTION_LINE_INK, - }); - // Label (Kurz-Tag) etwas hinter dem Pfeil, horizontal. - const labelAt = { - x: tip.x + bn.x * SECTION_MARK_LEN * 0.4 - SECTION_LABEL_H * 0.3, - y: tip.y + bn.y * SECTION_MARK_LEN * 0.4 + SECTION_LABEL_H * 0.5, - }; - out.push({ - kind: "drawingText", - at: labelAt, - text: sectionLabelTag(level.name), - heightM: SECTION_LABEL_H, - angle: 0, - color: SECTION_LINE_INK, - }); - } - - // Unsichtbares Pick-Band (Rechteck entlang der Linie) — klickbar per - // Punkt-in-Polygon in PlanView, trägt die DrawingLevel-Id. - const off = { x: n.x * SECTION_PICK_HALF, y: n.y * SECTION_PICK_HALF }; - out.push({ - kind: "polygon", - pts: [ - { x: p0.x + off.x, y: p0.y + off.y }, - { x: p1.x + off.x, y: p1.y + off.y }, - { x: p1.x - off.x, y: p1.y - off.y }, - { x: p0.x - off.x, y: p0.y - off.y }, - ], - fill: "none", - stroke: "none", - strokeWidthMm: 0, - hatch: NO_HATCH, - sectionLineId: level.id, - }); - } -} - -/** - * Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente - * Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren - * Schema-Ebenen vorbehalten. Alpha 0 = unsichtbar; das Polygon bleibt als - * `roomId`-Primitiv erhalten (Auswahl per Punkt-in-Polygon in PlanView, NICHT - * über die native Pointer-Trefferfläche der Füllung — die volle Fläche bleibt - * damit trotz Alpha 0 klickbar). - */ -const ROOM_FILL_ALPHA = "00"; -/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */ -const ROOM_STAMP_PT = 10; - -/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */ -function withAlpha(color: string, alphaHex: string): string { - return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color; -} - -/** - * Raum (SIA-416-Fläche) im Grundriss: eine (unsichtbare, nur klickbare) Füll- - * fläche über dem lichten Umriss + eine dünne neutrale Umrisslinie (beide - * `roomId`-getaggt für die Auswahl) + ein Raum-Stempel (Text) am Anker - * (Centroid oder `stampAnchor`) mit Name, Fläche „24.30 m²" und SIA-Kürzel. - * Keine Farbwäsche — Plandarstellung schwarz/grau, Farbe bleibt späteren - * Schema-Ebenen vorbehalten. Der Stempel entfällt beim Detailgrad „grob"; - * Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie gespeichert). - * Wird UNTER der Wand-Poché gezeichnet. - */ -function addRoomArea( - out: Primitive[], - room: Room, - greyed: boolean, - detail: DetailLevel, -): void { - const pts = room.boundary; - if (pts.length < 3) return; - const res = evaluateRoom(pts, room.siaCategory); - const stroke = room.color; - - // Unsichtbare Füllfläche (Alpha 0): keine Farbwäsche im Plan, das Polygon - // bleibt aber als `roomId`-Primitiv für die Punkt-in-Polygon-Auswahl erhalten. - out.push({ - kind: "polygon", - pts, - fill: withAlpha(room.color, ROOM_FILL_ALPHA), - stroke, - strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail], - hatch: NO_HATCH, - greyed, - roomId: room.id, - }); - - // Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile - // + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad - // „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text. - if (detail !== "grob") { - const at = room.stampAnchor ?? res.centroid; - // Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text - // bzw. Raum-Name als einfacher Text). - const doc = room.stamp - ? roomStampToDoc(room.stamp) - : room.stampDoc ?? docFromText(room.name); - const extraLines = room.stamp - ? roomStampExtraLines(room.stamp, { - netArea: res.netArea, - siaCategory: room.siaCategory, - siaLabel: siaLabel(room.siaCategory), - }) - : [ - { text: `${res.netArea.toFixed(2)} m²`, align: "center" as const }, - { - text: `${room.siaCategory} · ${siaLabel(room.siaCategory)}`, - align: "center" as const, - }, - ]; - out.push({ - kind: "text", - at, - doc, - extraLines, - basePt: ROOM_STAMP_PT, - color: stroke, - roomId: room.id, - greyed, - }); - } -} - -/** - * Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei - * radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`. - */ -function _addSpiralOutline( - out: Primitive[], - seg: SpiralOutlineSegments, - stroke: string, - lwMm: number, - greyed: boolean, - stairId: string, -): void { - // Radiale Schliesslinien (Startwinkel + Endwinkel). - for (const [a, b] of seg.lines) { - out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId }); - } - // Innen- und Aussenbogen. - for (const arc of seg.arcs) { - const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) }; - const to: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) }; - out.push({ - kind: "arc", - center: { x: arc.cx, y: arc.cy }, - from, - to, - r: arc.r, - cls: "stair-outline", - weightMm: lwMm, - greyed, - stairId, - }); - } -} - -/** - * Treppen-Symbol im Grundriss (DOSSIER/SIA): - * • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der - * Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt). - * • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie - * (Doppelstrich quer über den Lauf). - * • Zwischenpodest (L/Wendel) als Umriss-Polygon. - * • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label. - * Alle Primitive tragen `stairId` für die Auswahl. - */ -function addStairSymbol( - out: Primitive[], - project: Project, - stair: Stair, - greyed: boolean, - lwMm: number, - cutHeight: number, -): void { - const { zBottom, zTop } = stairVerticalExtent(project, stair); - const totalRise = zTop - zBottom; - const geo = stairGeometry(stair, totalRise); - const cut = stairCut(geo, cutHeight); - const below = new Set(cut.belowIndices); - const stroke = stair.color ?? POCHE_STROKE; - const stairId = stair.id; - - // Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks - // liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante. - for (const tr of geo.treads) { - const isBelow = below.has(tr.index); - // Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn - // unter der Schnittebene, sonst gestrichelt (verdeckt). - out.push({ - kind: "polygon", - pts: tr.pts, - fill: "none", - stroke, - strokeWidthMm: lwMm * 0.7, - hatch: NO_HATCH, - greyed, - stairId, - }); - // Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger. - const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]]; - out.push({ - kind: "line", - a: front[0], - b: front[1], - cls: "stair-tread", - weightMm: lwMm * (isBelow ? 1.0 : 0.6), - dash: isBelow ? null : [0.12, 0.08], - color: stroke, - greyed, - stairId, - }); - } - - // Zwischenpodest (L/Wendel-Auge): Umriss-Polygon. - if (geo.landing && geo.landing.length >= 3) { - out.push({ - kind: "polygon", - pts: geo.landing, - fill: "none", - stroke, - strokeWidthMm: lwMm * 0.7, - hatch: NO_HATCH, - greyed, - stairId, - }); - } - - // SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt). - if (cut.breakLine) { - for (const [a, b] of cut.breakLine) { - out.push({ - kind: "line", - a, - b, - cls: "stair-break", - weightMm: lwMm, - color: stroke, - greyed, - stairId, - }); - } - } - - // Lauflinie (Lauflinie) — kräftig, durchgezogen. - for (let i = 0; i < geo.runLine.length - 1; i++) { - out.push({ - kind: "line", - a: geo.runLine[i], - b: geo.runLine[i + 1], - cls: "stair-run", - weightMm: lwMm, - color: stroke, - greyed, - stairId, - }); - } - // Auf-/Abpfeil an der Spitze der Lauflinie. "filled" = gefülltes Dreieck - // (SIA-Vollpfeil), sonst der offene Winkelpfeil (zwei Linien, Default). - const [h1, tip, h2] = geo.arrow.head; - if (stair.arrowStyle === "filled") { - out.push({ - kind: "polygon", - pts: [h1, tip, h2], - fill: stroke, - stroke, - strokeWidthMm: lwMm, - hatch: NO_HATCH, - greyed, - stairId, - }); - } else { - out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); - out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); - } - - // Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L) - // bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel). - // Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im - // Rhino-Plugin. Stets durchgezogen, volle Strichstärke. - const outline = stairOutline(stair, totalRise); - if (outline.polygon && outline.polygon.length >= 3) { - out.push({ - kind: "polygon", - pts: outline.polygon, - fill: "none", - stroke, - strokeWidthMm: lwMm, - hatch: NO_HATCH, - greyed, - stairId, - }); - } - if (outline.spiral) { - _addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId); - } -} - -const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y); - function computeBounds( project: Project, walls: Wall[], @@ -3301,6 +710,7 @@ function computeBounds( acc({ x: g.center.x - g.r, y: g.center.y - g.r }); acc({ x: g.center.x + g.r, y: g.center.y + g.r }); } else if (g.shape === "text") acc(g.at); + else if (g.shape === "image") { acc(g.min); acc(g.max); } } // GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild) // mit einrahmen, damit „Einpassen"/die Standard-Ansicht sie zeigt — sonst diff --git a/src/plan/generatePlan/ceilings.ts b/src/plan/generatePlan/ceilings.ts new file mode 100644 index 0000000..f775de7 --- /dev/null +++ b/src/plan/generatePlan/ceilings.ts @@ -0,0 +1,139 @@ +// Decken-Domäne des Grundriss-Generators: die Decke liegt als Slab ÜBER der +// Grundriss-Schnittebene und wird daher als flächiger Aufsicht-Umriss +// gezeichnet (nicht als Schicht-Querschnitt wie eine Wand) — die Kante wird +// gegen die {@link WallFootprint}s der darunterliegenden Wände geclippt, damit +// sie dort nicht durch die Wand-Poché schlägt. + +import type { Ceiling, LayerCategory, Project } from "../../model/types"; +import { getCeilingType, getComponent } from "../../model/types"; +import { mergeHoles } from "../../geometry/polygonHoles"; +import { add, scale, sub } from "../../model/geometry"; +import { + NO_HATCH, + OVERHEAD_DASH, + POCHE_STROKE, + LAYER_DETAIL_FACTOR, + LAYER_LINE_MM, + pocheFill, + resolveForeground, + resolveHatch, + resolveHatchId, + type DetailLevel, + type Primitive, +} from "./shared"; +import { segmentInsideWallBand, subtractIntervals, type WallFootprint } from "./walls"; + +/** + * Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und + * wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine + * gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur + * stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog + * zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe + * bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl. + * + * BEWUSST FLÄCHIG, auch bei mehrschichtigem Deckentyp: anders als die Wand + * (deren Schichten im Grundriss als Querschnitt sichtbar sind) liegt der + * Schichtaufbau einer Decke in Z (oben → unten) — von oben unsichtbar. Die + * Schichtbänderung erscheint daher NUR im Schnitt (siehe `toSection.ts`), nicht + * hier. Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss); + * „mittel"/„fein" zeigen die Ansichts-Schraffur des Bauteils (`viewHatchId`, + * Default weiss) — die frontal gesehene Aufsicht ist kein Schnitt. `lwMm` ist + * die Kategorie-Strichstärke. + */ +export function addCeilingPoche( + out: Primitive[], + project: Project, + ceiling: Ceiling, + greyed: boolean, + detail: DetailLevel, + category: LayerCategory | undefined, + wallFootprints: WallFootprint[], +): void { + const pts = ceiling.outline; + if (pts.length < 3) return; + // Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN + // einfachen Poché-Ring einfügen: kein `openings` ⇒ `ring === pts` und + // `bridgeEdges` leer (exakt heutiger Codepfad, siehe {@link mergeHoles}). + const ceilingHoles = ceiling.openings ?? []; + const { ring, bridgeEdges } = ceilingHoles.length + ? mergeHoles(pts, ceilingHoles) + : { ring: pts, bridgeEdges: [] as number[] }; + const wt = getCeilingType(project, ceiling); + const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null; + const stroke = ceiling.color ?? POCHE_STROKE; + const ceilingId = ceiling.id; + + // Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss + // frontal von unten gesehen, nicht aufgeschnitten — daher greift hier + // NICHT die Schnitt-Schraffur (die gehört auf echte Schnittflächen, siehe + // resolveCeilingSectionStyle/splitSlabLayers), sondern `viewHatchId`. Fehlt + // sie (Default), bleibt die Aufsicht weiss. Im Detailgrad „grob" entfällt + // die Schraffur ganz (nur Fläche + Umriss). Das Attribut-Override + // `ceiling.hatchId` (+ `hatchSource`) überschreibt diese Ansichts-Schraffur + // genauso wie die Schnitt-Schraffur in {@link resolveCeilingSectionStyle}. + const effectiveViewHatchId = resolveHatchId( + ceiling.hatchId, + ceiling.hatchSource, + category, + comp?.viewHatchId, + ); + const hatch = + detail !== "grob" && comp && effectiveViewHatchId + ? resolveHatch( + project, + effectiveViewHatchId, + undefined, + resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource), + ) + : NO_HATCH; + // Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter + // der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie + // gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt. + // Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche. + out.push({ + kind: "polygon", + pts: ring, + fill: comp ? pocheFill(hatch.pattern) : "none", + stroke: "none", + strokeWidthMm: 0, + hatch, + greyed, + ceilingId, + ...(bridgeEdges.length ? { noStrokeEdges: bridgeEdges } : {}), + }); + // Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede + // Umriss-Kante (inkl. Lochränder, die als Teil des Rings automatisch + // mitgestrichen werden) gegen die Wand-Footprints clippen und die freien + // Teilstücke als Linien ausgeben. Liegt die Decke deckungsgleich unter den + // Wänden (Regelfall: Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt + // der Umriss ganz; nur Überstände (Vordach/Balkon jenseits der Wand) + // bleiben sichtbar. Brückenkanten (künstliche Verbindung Aussenring↔Loch) + // werden NICHT gestrichen. + const n = ring.length; + const bridgeSet = new Set(bridgeEdges); + for (let i = 0; i < n; i++) { + if (bridgeSet.has(i)) continue; + const a = ring[i]; + const b = ring[(i + 1) % n]; + const holes: Array<[number, number]> = []; + for (const fp of wallFootprints) { + const iv = segmentInsideWallBand(a, b, fp); + if (iv) holes.push(iv); + } + const d = sub(b, a); + for (const [t0, t1] of subtractIntervals(0, 1, holes)) { + out.push({ + kind: "line", + a: add(a, scale(d, t0)), + b: add(a, scale(d, t1)), + cls: "ceiling-outline", + // Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) → + // gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention). + weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail], + dash: OVERHEAD_DASH, + color: stroke, + greyed, + }); + } + } +} diff --git a/src/plan/generatePlan/context.ts b/src/plan/generatePlan/context.ts new file mode 100644 index 0000000..29f6e34 --- /dev/null +++ b/src/plan/generatePlan/context.ts @@ -0,0 +1,170 @@ +// Kontext-Domäne des Grundriss-Generators: zeichnet `project.context` +// (Höhenlinien, importierte Gebäude/Terrain-Meshes) als dezente Referenz- +// Konturen — für Meshes der Grundriss-Fussabdruck nahe der Objekt-Unterkante, +// nicht das volle Drahtgitter. + +import type { Project } from "../../model/types"; +import { NO_HATCH, type Primitive } from "./shared"; + +/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */ +const CONTEXT_LINE_COLOR = "#9aa3ad"; +/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */ +const CONTEXT_LINE_MM = 0.1; + +/** Höhen-Toleranz (Meter): Dreieckskanten, deren beide Endpunkte höchstens so + * weit über der Objekt-Unterkante (minZ) liegen, gelten als „Sockelkante" und + * bilden den Grundriss-Fussabdruck eines importierten Gebäudes. */ +const FOOTPRINT_Z_TOL = 0.6; + +/** + * Zeichnet die Kontext-Schicht `project.context` in den Grundriss: + * • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert). + * • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe + * der Objekt-Unterkante (Sockel), auf die XY-Ebene projiziert und über + * quantisierte Endpunkte entdoppelt — so erscheint das importierte Haus als + * erkennbarer Umriss im Plan (Nutzer-Wunsch: „ich sehe es sonst nicht"), + * ohne das gesamte Dach-Drahtgitter dicht darüberzulegen. + * • TerrainMesh → nur der 2D-Umriss (Bounding-Box), da ein volles TIN-Gitter + * im Plan unbrauchbar dicht wäre. + * Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt. + */ +export function addContextContours(out: Primitive[], project: Project): void { + const context = project.context ?? []; + for (const obj of context) { + if (obj.type === "contourSet") { + for (const c of obj.contours) { + const pts = c.pts; + if (pts.length < 2) continue; + const segEnd = c.closed ? pts.length : pts.length - 1; + for (let i = 0; i < segEnd; i++) { + out.push({ + kind: "line", + a: pts[i], + b: pts[(i + 1) % pts.length], + cls: "context-line", + weightMm: CONTEXT_LINE_MM, + color: CONTEXT_LINE_COLOR, + }); + } + } + } else if (obj.type === "importedMesh") { + addMeshFootprint(out, obj.positions, obj.indices); + addContextPickRegion(out, obj.positions, obj.id); + } else if (obj.type === "terrainMesh") { + addMeshBboxOutline(out, obj.positions); + addContextPickRegion(out, obj.positions, obj.id); + } + } +} + +/** + * Zeichnet den Grundriss-Fussabdruck eines Meshes: Dreieckskanten nahe der + * Unterkante (minZ + {@link FOOTPRINT_Z_TOL}), auf XY projiziert, über + * quantisierte Endpunkt-Schlüssel entdoppelt. `positions` = flaches [x,y,z…]- + * Array (Modell-Meter, z = Höhe), `indices` = Dreiecks-Indizes. + */ +function addMeshFootprint(out: Primitive[], positions: number[], indices: number[]): void { + const vcount = Math.floor(positions.length / 3); + if (vcount < 3 || indices.length < 3) return; + let minZ = Infinity; + for (let i = 2; i < positions.length; i += 3) if (positions[i] < minZ) minZ = positions[i]; + if (!Number.isFinite(minZ)) return; + const zLimit = minZ + FOOTPRINT_Z_TOL; + const q = (v: number) => Math.round(v * 100); // 1-cm-Quantisierung (Entdopplung) + const seen = new Set(); + const edge = (ia: number, ib: number) => { + if (ia >= vcount || ib >= vcount) return; + const za = positions[ia * 3 + 2]; + const zb = positions[ib * 3 + 2]; + if (za > zLimit || zb > zLimit) return; // nur Sockelkanten + const ax = positions[ia * 3], ay = positions[ia * 3 + 1]; + const bx = positions[ib * 3], by = positions[ib * 3 + 1]; + // Kanten-Schlüssel richtungsunabhängig (kleinerer Endpunkt zuerst). + const ka = `${q(ax)},${q(ay)}`; + const kb = `${q(bx)},${q(by)}`; + if (ka === kb) return; + const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ + kind: "line", + a: { x: ax, y: ay }, + b: { x: bx, y: by }, + cls: "context-line", + weightMm: CONTEXT_LINE_MM, + color: CONTEXT_LINE_COLOR, + }); + }; + for (let i = 0; i + 2 < indices.length; i += 3) { + const a = indices[i], b = indices[i + 1], c = indices[i + 2]; + edge(a, b); + edge(b, c); + edge(c, a); + } +} + +/** Zeichnet nur den achsenparallelen 2D-Umriss (Bounding-Box) eines Meshes — + * für Terrain, dessen volles TIN-Gitter im Plan zu dicht wäre. */ +function addMeshBboxOutline(out: Primitive[], positions: number[]): void { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let i = 0; i + 2 < positions.length; i += 3) { + const x = positions[i], y = positions[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + if (!Number.isFinite(minX)) return; + const corners = [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ]; + for (let i = 0; i < 4; i++) { + out.push({ + kind: "line", + a: corners[i], + b: corners[(i + 1) % 4], + cls: "context-line", + weightMm: CONTEXT_LINE_MM, + color: CONTEXT_LINE_COLOR, + }); + } +} + +/** + * Unsichtbares Pick-Band (Grundriss-Bounding-Box) für ein Kontext-Objekt + * (importiertes Gebäude/Terrain) — klickbar per Punkt-in-Polygon in PlanView, + * trägt die `ContextObject.id` (analog `sectionLineId`, s. dort). Macht das + * Objekt IRGENDWO innerhalb seiner Grundfläche anklickbar (nicht nur exakt auf + * der dünnen Umriss-/Fussabdrucklinie) — wie bei Wänden, deren Poché-Fläche + * ebenfalls als Ganzes klickbar ist (Nutzer-Wunsch: „eigene Elemente wie Wände"). + * `positions` = flaches [x,y,z…]-Array (Modell-Meter); die Bbox nutzt NUR x/y, + * unabhängig von der Höhe. + */ +function addContextPickRegion(out: Primitive[], positions: number[], id: string): void { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let i = 0; i + 2 < positions.length; i += 3) { + const x = positions[i], y = positions[i + 1]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + if (!Number.isFinite(minX)) return; + out.push({ + kind: "polygon", + pts: [ + { x: minX, y: minY }, + { x: maxX, y: minY }, + { x: maxX, y: maxY }, + { x: minX, y: maxY }, + ], + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + contextObjectId: id, + }); +} diff --git a/src/plan/generatePlan/drawing2d.ts b/src/plan/generatePlan/drawing2d.ts new file mode 100644 index 0000000..0678e22 --- /dev/null +++ b/src/plan/generatePlan/drawing2d.ts @@ -0,0 +1,168 @@ +// Freie 2D-Zeichengeometrie-Domäne des Grundriss-Generators (docs/design/ +// drawing-tools.md §7.2): leitet ein `Drawing2D`-Element (line/polyline/rect/ +// circle/arc/text/image) in Plan-Primitive ab. Farbe/Strichstärke kommen aus +// dem optionalen LineStyle, sonst aus der Kategorie. + +import type { Drawing2D, LayerCategory, Project, Vec2 } from "../../model/types"; +import { + NO_HATCH, + WALL_FALLBACK_MM, + MONO_INK, + resolveHatch, + resolveHatchId, + resolveStrokeWeight, + type Primitive, +} from "./shared"; + +export function addDrawing2D( + out: Primitive[], + project: Project, + d: Drawing2D, + greyed: boolean, + lwByCode: Map, + colorByCode: Map, + catByCode: Map, +): void { + const category = catByCode.get(d.categoryCode); + const ls = d.lineStyleId + ? project.lineStyles.find((l) => l.id === d.lineStyleId) + : undefined; + // Plan-Tinte fix dunkel: Fallback auf MONO_INK (statt hell „#d0d0d0"), damit + // ein kategorieloses 2D-Element auf dem hellen Papier sichtbar bleibt. + const color = d.color ?? ls?.color ?? colorByCode.get(d.categoryCode) ?? MONO_INK; + // Strichstärke: "layer" erzwingt die Ebenen-Strichstärke (umgeht das + // LineStyle-Gewicht), sonst (Default "object") die heutige Kette. + const weightMm = resolveStrokeWeight( + d.weightMm, + d.strokeWeightSource, + category, + ls?.weight ?? lwByCode.get(d.categoryCode) ?? WALL_FALLBACK_MM, + ); + const dash = ls?.dash ?? null; + // Drawing2D trägt kein Bauteil → die By-Layer-Quelle ist hier die einzige + // Alternative zum expliziten Wert (kein Component-Fallback); Default + // "object"/undefined bleibt exakt das heutige Verhalten. + const foreground = d.foreground ?? (d.foregroundSource === "layer" ? category?.color : undefined); + const background = d.background ?? (d.backgroundSource === "layer" ? category?.color : undefined); + const hatchId = resolveHatchId(d.hatchId, d.hatchSource, category, undefined); + // Zickzack-Parameter aus dem LineStyle durchreichen (nur kind==="zigzag"). + // Additiv: fehlt es, bleibt die Linie gerade/gestrichelt wie bisher. + const zigzag = ls?.kind === "zigzag" ? ls.zigzag : undefined; + // Custom-Motiv analog durchreichen (nur kind==="custom"). + const motif = ls?.kind === "custom" ? ls.motif : undefined; + const seg = (a: Vec2, b: Vec2) => + out.push({ kind: "line", a, b, cls: "draw2d", weightMm, dash, zigzag, motif, color, greyed, drawingId: d.id }); + + /** + * Gefüllte Fläche einer GESCHLOSSENEN Form: trägt die Vollton-Füllfarbe + * (`d.fillColor`, sonst transparent) UND die optionale Füllschraffur + * (`d.hatchId`, sonst NO_HATCH). Wird VOR den Strichen gepusht, damit die + * Umrisslinien oben liegen. Trägt `drawingId`, sodass ein Klick auf die Fläche + * das Element selektiert (analog zu den Linien). + */ + const fillPoly = (pts: Vec2[]) => + out.push({ + kind: "polygon", + pts, + // Hintergrund-Override (Wert ?? By-Layer) hat Vorrang vor dem Alt-Feld + // fillColor (alle leer = transparente Fläche, nur Schraffur) — Sample + // bleibt identisch (kein Element setzt background/backgroundSource). + fill: background ?? d.fillColor ?? "none", + // Umriss zeichnen die separaten Linien-Primitive (mit Strichmuster); die + // Füllfläche selbst bleibt randlos, um doppelte Striche zu vermeiden. + stroke: "none", + strokeWidthMm: 0, + // Drawing2D trägt kein Bauteil → Vordergrund/Schraffur-Override direkt aus + // d.foreground/d.hatchId bzw. der Ebene (Attribut „Nach System" ⇒ + // undefined ⇒ HatchStyle.color-Fallback bzw. NO_HATCH). + hatch: hatchId ? resolveHatch(project, hatchId, undefined, foreground) : NO_HATCH, + greyed, + drawingId: d.id, + }); + + const g = d.geom; + switch (g.shape) { + case "line": + seg(g.a, g.b); + break; + case "polyline": { + // Geschlossene Polylinie: zuerst die gefüllte Fläche (Vollton/Schraffur). + if (g.closed && g.pts.length > 2) fillPoly(g.pts); + for (let i = 0; i < g.pts.length - 1; i++) seg(g.pts[i], g.pts[i + 1]); + if (g.closed && g.pts.length > 2) seg(g.pts[g.pts.length - 1], g.pts[0]); + break; + } + case "rect": { + const c1 = g.min; + const c2 = { x: g.max.x, y: g.min.y }; + const c3 = g.max; + const c4 = { x: g.min.x, y: g.max.y }; + // Rechteck ist immer geschlossen → zuerst die gefüllte Fläche. + fillPoly([c1, c2, c3, c4]); + seg(c1, c2); + seg(c2, c3); + seg(c3, c4); + seg(c4, c1); + break; + } + case "circle": + // Echtes Kreis-Primitiv → glattes `` (rund bei jedem Zoom) mit + // optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation. + out.push({ + kind: "drawingCircle", + center: g.center, + r: g.r, + fill: background ?? d.fillColor ?? "none", + stroke: color, + weightMm, + dash, + greyed, + drawingId: d.id, + }); + break; + case "arc": + // Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant). + out.push({ + kind: "drawingArc", + center: g.center, + r: g.r, + a0: g.a0, + a1: g.a1, + stroke: color, + weightMm, + dash, + greyed, + drawingId: d.id, + }); + break; + case "text": + out.push({ + kind: "drawingText", + at: g.at, + text: g.text, + heightM: g.height, + angle: g.angle, + // marks.color (falls gesetzt) übersteuert die Kategorie-/Elementfarbe. + color: g.marks?.color ?? color, + ...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}), + ...(g.marks ? { marks: g.marks } : {}), + ...(g.doc ? { doc: g.doc } : {}), + greyed, + drawingId: d.id, + }); + break; + case "image": + out.push({ + kind: "drawingImage", + min: g.min, + max: g.max, + src: g.src, + opacity: g.opacity ?? 1, + greyed, + drawingId: d.id, + }); + break; + default: + break; + } +} diff --git a/src/plan/generatePlan/openings.ts b/src/plan/generatePlan/openings.ts new file mode 100644 index 0000000..b7e48f6 --- /dev/null +++ b/src/plan/generatePlan/openings.ts @@ -0,0 +1,662 @@ +// Öffnungs-Domäne des Grundriss-Generators: Tür-/Fenstersymbole je Detailgrad +// (Blatt/Schwenkbogen/Anschlag bzw. Rahmen/Sprossen/Glas), das optionale +// Rahmenband (Zarge/Blockrahmen) samt Schichteinzug aus der Typ-Bibliothek, +// sowie das Legacy-Türsymbol (`Door`, vor der Öffnungs-Typ-Bibliothek). + +import type { Opening, Project, Vec2, Wall } from "../../model/types"; +import { + getDoorType, + getWindowType, + getWallType, + sashesOfWindowType, + wallTypeThickness, +} from "../../model/types"; +import { + doorLeaves, + doorSymbol, + openingGapQuad, + openingJambs, + wallAxisFrame, + windowSymbol, +} from "../../geometry/opening"; +import { wallReferenceOffset } from "../../model/wall"; +import { add, along, dot, leftNormal, normalize, scale, sub } from "../../model/geometry"; +import { + NO_HATCH, + OVERHEAD_DASH, + POCHE_STROKE, + SYMBOL_HAIRLINE_MM, + type DetailLevel, + type Primitive, +} from "./shared"; + +/** + * Tür-Symbol je Detailgrad: + * • grob — nur die gerade Türblatt-Linie (die Öffnungs-Lücke kommt aus der + * ausgesparten Poché). + * • mittel — + Schwenkbogen (geschlossen → offen). + * • fein — + Rahmen/Anschlag-Striche an beiden Pfosten (kurze Striche quer + * zur Wand, die den Türrahmen/-anschlag andeuten). + */ +export function addDoorSymbol( + out: Primitive[], + wall: Wall, + door: Project["doors"][number], + greyed: boolean, + detail: DetailLevel, + _doorLwMm: number, +): void { + const u = normalize(sub(wall.end, wall.start)); + const n = leftNormal(u); // linke Seite der Wandachse + const swingDir = door.swing === "left" ? n : scale(n, -1); + + const jambStart = along(wall.start, wall.end, door.position); + const jambEnd = along(wall.start, wall.end, door.position + door.width); + const hinge = door.hinge === "start" ? jambStart : jambEnd; + const closedDir = door.hinge === "start" ? u : scale(u, -1); + + const openEnd = add(hinge, scale(swingDir, door.width)); + const closedEnd = add(hinge, scale(closedDir, door.width)); + + // Türblatt in geöffneter Stellung (alle Stufen). + out.push({ + kind: "line", + a: hinge, + b: openEnd, + cls: "door-leaf", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + }); + + // mittel + fein: Schwenkbogen von geschlossen → offen. + if (detail !== "grob") { + out.push({ + kind: "arc", + center: hinge, + from: closedEnd, + to: openEnd, + r: door.width, + cls: "door-swing", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + }); + } + + // fein: Rahmen/Anschlag-Striche an beiden Pfosten (kurz quer zur Wand, + // symmetrisch um die Achse über eine halbe Wanddicke-Andeutung). + if (detail === "fein") { + const reveal = 0.06; // 6 cm Anschlag-Andeutung je Seite + for (const jamb of [jambStart, jambEnd]) { + out.push({ + kind: "line", + a: add(jamb, scale(n, reveal)), + b: add(jamb, scale(n, -reveal)), + cls: "door-frame", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + }); + } + } +} + +/** + * Aufgelöste Rahmen-Parameter (Typ-Bibliothek) einer Tür-/Fenster-Öffnung — + * nur vorhanden, wenn die Öffnung einen Bauteil-Typ referenziert (`typeId`). + * Fehlt der Typ, bleibt das heutige Inline-Verhalten unverändert (kein + * Rahmenband, kein Oberlicht) — siehe `resolveOpeningFrameSpec`. + */ +interface OpeningFrameSpec { + frameWidth: number; + /** Nur bei Türen relevant; Fenster zeichnen stets die schmale Zargen-Optik. */ + frameKind: "zarge" | "blockrahmen"; + insetFromFace: number; + insetFace: "aussen" | "innen"; + transomHeight: number; +} + +/** + * Löst die Rahmen-Parameter aus dem referenzierten Tür-/Fenstertyp auf (samt + * Defaults); `undefined`, wenn kein Typ referenziert/auflösbar ist — die + * Öffnung bleibt dann beim heutigen Inline-Verhalten (rückwärtskompatibel). + */ +function resolveOpeningFrameSpec(project: Project, o: Opening): OpeningFrameSpec | undefined { + if (o.kind === "door") { + const t = getDoorType(project, o); + if (!t) return undefined; + return { + frameWidth: t.frameWidth ?? 0.06, + frameKind: t.frameKind ?? "zarge", + insetFromFace: t.insetFromFace ?? 0, + insetFace: t.insetFace ?? "aussen", + transomHeight: t.transomHeight ?? 0, + }; + } + const t = getWindowType(project, o); + if (!t) return undefined; + return { + frameWidth: t.frameWidth ?? 0.06, + frameKind: "zarge", + insetFromFace: t.insetFromFace ?? 0, + insetFace: t.insetFace ?? "aussen", + transomHeight: t.transomHeight ?? 0, + }; +} + +/** + * Rahmenband + Oberlicht-Andeutung einer Tür-/Fenstertyp-Öffnung im + * Grundriss-Schnitt. Nur aufgerufen, wenn `resolveOpeningFrameSpec` einen Typ + * liefert — sonst bleibt die Darstellung exakt wie zuvor (kein neues Primitiv). + * + * • Zarge — schmales Umlauf-Rahmenband, ringsum um `frameWidth` + * von der vollen Öffnung (Pfosten × Wanddicke) nach innen eingezogen. + * • Blockrahmen (Tür) — kräftiges Rechteckprofil, sitzt PROUD an der + * gewählten Fläche (kein Einzug an den Pfosten, nur eine Tiefenscheibe von + * `frameWidth` ab der Nah-Fläche), kräftigere Linie. + * • Schichteinzug — verschiebt die Nah-Fläche des Rahmens um + * `insetFromFace` von der gewählten Wandfläche nach innen; die Gegenfläche + * bleibt unverändert (der Rahmen reicht weiterhin bis dorthin). + * • Oberlicht — Überkopf-Andeutung (gestrichelte Linie quer über die + * Öffnung), nur wenn `transomHeight > 0`. + */ +function addOpeningFrameBand( + out: Primitive[], + project: Project, + wall: Wall, + o: Opening, + spec: OpeningFrameSpec, + greyed: boolean, +): void { + const jambs = openingJambs(wall, o); + if (!jambs) return; + const { n } = wallAxisFrame(wall); + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + // "aussen" = die Fläche der ERSTEN Wandtyp-Schicht (kleinster Achs-Offset, + // s. addWallPoche/`off` startet bei `refOff-total/2`) — mit `n = leftNormal(u)` + // ist das die NEGATIVE Seite. Bug bis 2026-07-12: hier stand `+total/2` für + // "aussen" (positive Seite, tatsächlich "innen") — Türrahmenbänder mit + // insetFace:"aussen" landeten dadurch an der falschen Wandfläche. + const outerFace = refOff - total / 2; // "aussen"-Fläche (−n, erste Schicht) + const innerFace = refOff + total / 2; // "innen"-Fläche (+n, letzte Schicht) + + // Schichteinzug: die GEWÄHLTE Fläche rückt um `insetFromFace` nach innen; die + // gegenüberliegende Fläche bleibt unverändert (der Rahmen reicht weiterhin + // bis dorthin) — vgl. DoorType.insetFromFace/WindowType.insetFromFace. + const clampedInset = Math.max(0, Math.min(spec.insetFromFace, total)); + const nearIsAussen = spec.insetFace !== "innen"; + // "Nach innen" heisst: von der gewählten Fläche RICHTUNG WANDMITTE (refOff) — + // von aussen (negative Seite) aus AUFWÄRTS (+clampedInset), von innen + // (positive Seite) aus ABWÄRTS (−clampedInset). + const faceNear = nearIsAussen ? outerFace + clampedInset : innerFace - clampedInset; + const faceFar = nearIsAussen ? innerFace : outerFace; + const lo = Math.min(faceNear, faceFar); + const hi = Math.max(faceNear, faceFar); + + const { jambStart, jambEnd } = jambs; + const color = o.color ?? POCHE_STROKE; + + // Rahmen-Rechteck NUR bei Türen zeichnen. Beim Fenster stellt bereits + // `windowSymbol` den Rahmen dar (voller Öffnungsumriss + Glaslinien); ein + // zusätzliches Band ergäbe ein doppeltes, verschachteltes Rechteck — genau + // das wirkte im 2D „nicht korrekt". Die Tür hat dort nur Blatt+Bogen, daher + // ist ihr Rahmenband die einzige Rahmenkontur und bleibt. + if (o.kind === "door") { + const frameWidth = Math.max(0, spec.frameWidth); + const isBlock = spec.frameKind === "blockrahmen"; + let bandLo: number; + let bandHi: number; + let jStart: Vec2; + let jEnd: Vec2; + let weightMm: number; + if (isBlock) { + // Blockrahmen: volle Öffnungsbreite (kein Pfosten-Einzug), nur eine + // Tiefenscheibe von frameWidth ab der Nah-Fläche — sitzt proud VOR/auf der + // Laibung, statt schmal darin zu verschwinden (Zargen-Optik). "aussen" + // liegt bei `lo` (s. Kommentar zu outerFace/innerFace oben), die Scheibe + // beginnt dort und wächst RICHTUNG WANDMITTE (Richtung `hi`). + const depth = Math.min(frameWidth, hi - lo); + bandLo = nearIsAussen ? lo : hi - depth; + bandHi = nearIsAussen ? lo + depth : hi; + jStart = jambStart; + jEnd = jambEnd; + weightMm = SYMBOL_HAIRLINE_MM * 2.2; + } else { + // Zarge: ringsum um frameWidth von der vollen Öffnung eingezogenes, + // schmales Rahmenband (Laibungs-Anschlaglinie). + const jambSpan = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y); + const jambInset = Math.min(frameWidth, jambSpan / 2); + jStart = along(jambStart, jambEnd, jambInset); + jEnd = along(jambEnd, jambStart, jambInset); + const depthInset = Math.min(frameWidth, (hi - lo) / 2); + bandLo = lo + depthInset; + bandHi = hi - depthInset; + weightMm = SYMBOL_HAIRLINE_MM; + } + + // Geschlossenes Rechteck aus 4 Kanten (Primitive "line", nicht "polygon" — + // so bleibt das Band per `cls` selektierbar/testbar wie die übrigen Symbole). + const p1 = add(jStart, scale(n, bandLo)); + const p2 = add(jEnd, scale(n, bandLo)); + const p3 = add(jEnd, scale(n, bandHi)); + const p4 = add(jStart, scale(n, bandHi)); + const edges: [Vec2, Vec2][] = [ + [p1, p2], + [p2, p3], + [p3, p4], + [p4, p1], + ]; + for (const [a, b] of edges) { + out.push({ + kind: "line", + a, + b, + cls: "opening-frame", + weightMm, + color, + greyed, + openingId: o.id, + }); + } + } + + // Die frühere Oberlicht-Andeutung (gestrichelte Linie quer über die ganze + // Öffnung bei transomHeight>0) ist entfernt (Nutzer-Report: erscheint immer + // auf der Wandachse, unabhängig davon, ob die Schnittebene den Kämpfer + // überhaupt trifft — im horizontalen Grundriss-Schnitt architektonisch nicht + // aussagekräftig, ein Kämpfer ist ein Höhen-, kein Grundriss-Merkmal). +} + +/** + * Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol` + * arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt + * ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist. + * + * Tür: + * • grob — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché). + * • mittel — + Schwenkbogen (Öffnungswinkel). + * • fein — + Anschlag-Striche an beiden Pfosten. + * Fenster: + * • grob — eine einzelne Glaslinie über die Lücke. + * • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie. + * • fein — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet). + */ +export function addOpeningSymbol( + out: Primitive[], + project: Project, + wall: Wall, + o: Opening, + greyed: boolean, + detail: DetailLevel, + lwMm: number, +): void { + 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). + // Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin. + const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung"; + if (!isWandoeffnung) { + // Türblätter (1 oder 2 Flügel, je DoorType.leafCount). Zweiflüglig ⇒ zwei + // halbbreite Blätter an gegenüberliegenden Pfosten (symmetrische Doppeltür). + const leafCount = Math.max(1, Math.round(getDoorType(project, o)?.leafCount ?? 1)); + for (const leaf of doorLeaves(wall, o, leafCount)) { + out.push({ + kind: "line", + a: leaf.hinge, + b: leaf.openEnd, + cls: "door-leaf", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + openingId: o.id, + }); + // mittel + fein: Schwenkbogen von geschlossen → offen je Blatt. + if (detail !== "grob") { + out.push({ + kind: "arc", + center: leaf.hinge, + from: leaf.closedEnd, + to: leaf.openEnd, + r: leaf.radius, + cls: "door-swing", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + openingId: o.id, + }); + } + } + } + // fein: Anschlag-Striche an beiden Pfosten (quer zur Wand). + if (detail === "fein") { + const reveal = 0.06; + for (const jamb of [sym.jambStart, sym.jambEnd]) { + out.push({ + kind: "line", + a: add(jamb, scale(sym.normal, reveal)), + b: add(jamb, scale(sym.normal, -reveal)), + cls: "door-frame", + weightMm: SYMBOL_HAIRLINE_MM, + greyed, + openingId: o.id, + }); + } + } + // Rahmenband (Zarge/Blockrahmen) + Oberlicht-Andeutung — nur wenn die + // Öffnung einen Türtyp referenziert (siehe `resolveOpeningFrameSpec`). + const doorFrameSpec = resolveOpeningFrameSpec(project, o); + if (doorFrameSpec) addOpeningFrameBand(out, project, wall, o, doorFrameSpec, greyed); + // Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über + // die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves` + // im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt. + const lintelMode = o.lintelLines ?? "beide"; + if (lintelMode !== "keine") { + const jambs = openingJambs(wall, o); + if (jambs) { + const { n } = wallAxisFrame(wall); + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + const halfD = total / 2; + // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in + // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht. + const outerOff = refOff - halfD; + const innerOff = refOff + halfD; + // Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention). + const dash: number[] = [0.18, 0.09]; + const strokeColor = o.color ?? POCHE_STROKE; + /** Linie quer über die Öffnung am gegebenen Perp-Offset. */ + const lintelLine = (perpOff: number) => ({ + a: add(jambs.jambStart, scale(n, perpOff)), + b: add(jambs.jambEnd, scale(n, perpOff)), + }); + if (lintelMode === "aussen" || lintelMode === "beide") { + const seg = lintelLine(outerOff); + out.push({ + kind: "line" as const, + a: seg.a, + b: seg.b, + cls: "door-lintel", + weightMm: SYMBOL_HAIRLINE_MM, + dash, + color: strokeColor, + greyed, + openingId: o.id, + }); + } + if (lintelMode === "innen" || lintelMode === "beide") { + const seg = lintelLine(innerOff); + out.push({ + kind: "line" as const, + a: seg.a, + b: seg.b, + cls: "door-lintel", + weightMm: SYMBOL_HAIRLINE_MM, + dash, + color: strokeColor, + greyed, + openingId: o.id, + }); + } + } + } + return; + } + + // Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad + Fenstertyp. + const wt = getWindowType(project, o); + // Flügeltabelle NUR übergeben, wenn ein Fenstertyp auflösbar ist — fehlt er + // (kein `typeId`/unbekannt), bleibt `windowSymbol` beim reinen + // `o.wingCount`-Alt-Verhalten (siehe dortiger Kommentar, Regressionssicherheit). + const spec = wt + ? { + sashes: sashesOfWindowType(wt), + frameWidth: wt.frameWidth ?? 0.06, + frameDepth: wt.frameDepth, + insetFromFace: wt.insetFromFace, + insetFace: wt.insetFace, + } + : undefined; + // Glaslinienzahl NUR für den Alt-Pfad (kein `spec`) relevant — der typisierte + // Pfad entscheidet die Anzahl selbst nach SIA-Detailgrad (s. windowSymbol). + const glassCount = detail === "fein" ? 2 : 1; + const sym = windowSymbol(project, wall, o, glassCount, spec, detail); + 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, + }); + } + // ── SIA-Fensterdarstellung, nach Detailgrad gestaffelt ──────────────────── + // grob (1:100): weisse Öffnung + EINE dünne Glaslinie, sonst nichts. + // mittel (1:50): Blendrahmen-Rechteck + Pfosten-Trennlinien + Stulp-Mittel- + // quadrat je Flügel-Junction + Glaslinie(n). + // fein (1:20): zusätzlich Flügelrahmen, Stulp als ZWEI Rechtecke, Fensterbank + // (aussen) und Anschlag-/Laibungskerben — deutlich reicher als mittel. + const strokeColor = o.color ?? POCHE_STROKE; + const { n: wn } = wallAxisFrame(wall); + + if (detail === "grob") { + // SIA 1:100 — nur eine dünne Glaslinie mittig über die weisse Öffnung. + // Zählt zur "Fenster"-Kategorie (sashLine) — dieselbe Glaslinie, die bei + // mittel/fein früher sichtbar war (jetzt dort entfernt, s. Fenster-Session). + const g = sym.glassLines[Math.floor(sym.glassLines.length / 2)] ?? sym.glassLines[0]; + if (g) { + out.push({ + kind: "line", + a: g[0], + b: g[1], + cls: "window-glass", + weightMm: o.sashLine?.weight ?? SYMBOL_HAIRLINE_MM, + color: o.sashLine?.color ?? strokeColor, + greyed, + openingId: o.id, + }); + } + } else { + // Linien-Kategorien einzeln übersteuerbar (Nutzer-Wunsch): Rahmen-Schnitt + // (Laibungs-/Stulp-Blöcke, geschnittenes Profil), Rahmen-Aufsicht (die + // durchlaufende Kontur DAZWISCHEN, projiziert statt geschnitten), Fenster + // (Flügelrahmen/Sprossen), Sims (Auf-/Untersicht-Andeutung, s. u.). + // `undefined` ⇒ bisheriges Verhalten (strokeColor/SYMBOL_HAIRLINE_MM); + // `frameViewLine` fällt zusätzlich auf `frameLine` zurück (eine Farbe für + // Schnitt+Aufsicht, bis der Nutzer sie explizit trennt). + const frameColor = o.frameLine?.color ?? strokeColor; + const frameWeight = o.frameLine?.weight ?? lwMm; + const frameHairline = o.frameLine?.weight ?? SYMBOL_HAIRLINE_MM; + const frameViewColor = o.frameViewLine?.color ?? frameColor; + const frameViewWeight = o.frameViewLine?.weight ?? frameWeight; + const sashColor = o.sashLine?.color ?? strokeColor; + const sashWeight = o.sashLine?.weight ?? SYMBOL_HAIRLINE_MM; + + // Blendrahmen-Rechteck in Aufsicht (mittel + fein) — die durchlaufende + // Kontur zwischen den geschnittenen Laibungs-/Stulp-Blöcken. + out.push({ + kind: "polygon", + pts: sym.frame, + fill: "none", + stroke: frameViewColor, + strokeWidthMm: frameViewWeight, + hatch: NO_HATCH, + greyed, + openingId: o.id, + }); + // Pfosten-/Flügel-Trennlinien (Alt-Pfad ohne Typ — zählt als "Fenster"). + for (const [a, b] of sym.mullionLines) { + out.push({ kind: "line", a, b, cls: "window-mullion", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id }); + } + // fein: Flügelrahmen (Flügelkontur) je öffenbarem Flügel — Schachtelung + // Blendrahmen → Flügelrahmen → Glas (Feindetail, hebt fein klar von mittel ab). + if (detail === "fein") { + for (const pts of sym.sashFrames) { + for (let i = 0; i < pts.length; i++) { + out.push({ kind: "line", a: pts[i], b: pts[(i + 1) % pts.length], cls: "window-sash", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id }); + } + } + } + // Laibungs-Blöcke an den Öffnungsenden (Blendrahmen-Querschnitt) — Teil des + // Blendrahmens, zählt zu "Rahmen". + for (const mark of sym.jambMarks) { + for (let i = 0; i < mark.length; i++) { + out.push({ kind: "line", a: mark[i], b: mark[(i + 1) % mark.length], cls: "window-stulp", weightMm: frameHairline, color: frameColor, greyed, openingId: o.id }); + } + } + // Stulp-Mittelquadrat je Flügel-Junction (SIA-Signatur): mittel = EIN Quadrat, + // fein = ZWEI anliegende Rechtecke (die beiden Stulp-Stäbe der Flügel) — + // bereits tiefenkorrekt (inset-/insetFace-bewusst) in windowSymbol berechnet. + // Sitzt zwischen den Flügeln, zählt zu "Fenster" (separat vom Blendrahmen + // steuerbar — Nutzer-Feedback). + for (const mark of sym.meetingMarks) { + for (let i = 0; i < mark.length; i++) { + out.push({ kind: "line", a: mark[i], b: mark[(i + 1) % mark.length], cls: "window-stulp", weightMm: sashWeight, color: sashColor, greyed, openingId: o.id }); + } + } + // Glaslinien (Anzahl je Detailgrad/Verglasung, s. glassCount). + for (const [a, b] of sym.glassLines) { + out.push({ kind: "line", a, b, cls: "window-glass", weightMm: SYMBOL_HAIRLINE_MM, greyed, openingId: o.id }); + } + // Oberlicht-Andeutung (transom, gestrichelt) — mittel/fein. + const windowFrameSpec = resolveOpeningFrameSpec(project, o); + if (windowFrameSpec) addOpeningFrameBand(out, project, wall, o, windowFrameSpec, greyed); + // fein: Fensterbank (aussen) + Anschlag-/Laibungskerben. + if (detail === "fein") { + const jb = openingJambs(wall, o); + if (jb) { + const { jambStart, jambEnd } = jb; + // Tatsächliche Rahmen-Aussenkante (NICHT pauschal die volle Wandfläche — + // ein eingezogener Rahmen, s. {@link WindowType.insetFromFace}, hat seine + // Aussenkante weiter innen). "Aussen" ist der ALGEBRAISCH KLEINERE der + // beiden Tiefen-Werte in sym.frame (s. windowSymbol: frontRel für + // insetFace:"aussen" ist negativ) — welcher der beiden Eckpunkt-Paare das + // ist, hängt von insetFace ab, daher robust über Math.min bestimmt statt + // fest über sym.frame[0]. + const outerN = Math.min( + dot(sub(sym.frame[0], jambStart), wn), + dot(sub(sym.frame[2], jambStart), wn), + ); + // Fensterbank: schmales Rechteck vor der Aussenfläche, seitlich ~3 cm + // überstehend, ~4 cm auskragend (SIA-Sims-Andeutung im Grundriss). + // Auskragung geht IMMER weiter nach aussen (algebraisch kleiner, s. o.). + const ov = 0.03; + const proj = 0.04; + const s0 = along(jambStart, jambEnd, -ov); + const s1 = along(jambEnd, jambStart, -ov); + const b0 = add(s0, scale(wn, outerN)); + const b1 = add(s1, scale(wn, outerN)); + const b2 = add(s1, scale(wn, outerN - proj)); + const b3 = add(s0, scale(wn, outerN - proj)); + for (const [a, b] of [[b0, b1], [b1, b2], [b2, b3], [b3, b0]] as [Vec2, Vec2][]) { + out.push({ kind: "line", a, b, cls: "window-sill", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); + } + // Anschlag-Kerben an beiden Pfosten (kurz quer zur Wand). + const reveal = 0.06; + for (const jamb of [jambStart, jambEnd]) { + out.push({ kind: "line", a: add(jamb, scale(wn, reveal)), b: add(jamb, scale(wn, -reveal)), cls: "window-frame", weightMm: SYMBOL_HAIRLINE_MM, color: strokeColor, greyed, openingId: o.id }); + } + } + } + } + // Auf-/Untersicht-Andeutung ({@link WindowType.sillLine}): eine Referenzlinie + // an der Rahmen-Aussen-/Innenkante (NICHT der Wandachse — ersetzt die frühere, + // falsch positionierte Brüstungslinie), nur wenn der Typ es explizit wählt. + // "auf" (Aufsicht von oben, dünn gepunktet) vs. "unter" (Untersicht von unten, + // gestrichelt wie die übrigen Überkopf-Projektionen). + if (detail !== "grob" && wt?.sillLine) { + const { faces, view } = wt.sillLine; + const dash = view === "unter" ? OVERHEAD_DASH : [0.04, 0.08]; + const sillColor = o.sillLineStyle?.color ?? strokeColor; + const sillWeight = o.sillLineStyle?.weight ?? SYMBOL_HAIRLINE_MM; + // Welches der beiden sym.frame-Eckpunkt-Paare physisch "aussen" ist, hängt + // von insetFace ab (s. windowSymbol) — robust über den Tiefen-Vergleich + // entlang wn bestimmt statt fest über den Index. + const pair01IsOuter = dot(sym.frame[0], wn) <= dot(sym.frame[3], wn); + const outerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[0], sym.frame[1]] : [sym.frame[3], sym.frame[2]]; + const innerPair: [Vec2, Vec2] = pair01IsOuter ? [sym.frame[3], sym.frame[2]] : [sym.frame[0], sym.frame[1]]; + if (faces === "aussen" || faces === "beide") { + out.push({ kind: "line", a: outerPair[0], b: outerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); + } + if (faces === "innen" || faces === "beide") { + out.push({ kind: "line", a: innerPair[0], b: innerPair[1], cls: "window-sill", weightMm: sillWeight, dash, color: sillColor, greyed, openingId: o.id }); + } + } + // Rollladen-/Sonnenschutzkasten (P0-Minimalfassung, reine 2D-Kontur): ein + // gestricheltes Rechteck ÜBER der Öffnung, ausserhalb der Wand-Aussenfläche — + // Breite = `shading.box.width` (0/fehlt ⇒ Öffnungsbreite), die zweite + // Rechteckseite = `shading.box.height`. Die 3D-Tiefe (`box.depth`) bleibt für + // eine spätere 3D-Box reserviert (Fokus hier bewusst 2D, s. Aufgabenstellung). + // Gestrichelt wie die übrigen Überkopf-Projektionen (Sturz/Oberlicht) — der + // Kasten liegt oberhalb der Schnittebene und wird nur angedeutet. + if (wt?.shading?.create) { + const jambs = openingJambs(wall, o); + if (jambs) { + const { u, n: nrm } = wallAxisFrame(wall); + const total = wallTypeThickness(getWallType(project, wall)); + const refOff = wallReferenceOffset(wall, total); + // "aussen" = negative Seite (erste Wandtyp-Schicht), s. Kommentar in + // addOpeningFrameBand — Bug bis 2026-07-12: stand hier vertauscht (der + // Rollladenkasten landete an der Innenfläche statt aussen am Sturz). + const outerFace = refOff - total / 2; + const axisWidth = Math.hypot( + jambs.jambEnd.x - jambs.jambStart.x, + jambs.jambEnd.y - jambs.jambStart.y, + ); + const boxWidth = wt.shading.box.width > 0 ? wt.shading.box.width : axisWidth; + const boxHeight = Math.max(0, wt.shading.box.height); + const center = add(jambs.jambStart, scale(u, axisWidth / 2)); + const half = boxWidth / 2; + const c1 = add(center, scale(u, -half)); + const c2 = add(center, scale(u, half)); + const p1 = add(c1, scale(nrm, outerFace)); + const p2 = add(c2, scale(nrm, outerFace)); + const p3 = add(c2, scale(nrm, outerFace + boxHeight)); + const p4 = add(c1, scale(nrm, outerFace + boxHeight)); + const edges: [Vec2, Vec2][] = [ + [p1, p2], + [p2, p3], + [p3, p4], + [p4, p1], + ]; + for (const [a, b] of edges) { + out.push({ + kind: "line", + a, + b, + cls: "window-shading", + weightMm: SYMBOL_HAIRLINE_MM, + dash: OVERHEAD_DASH, + color: o.color ?? POCHE_STROKE, + greyed, + openingId: o.id, + }); + } + } + } +} diff --git a/src/plan/generatePlan/roofs.ts b/src/plan/generatePlan/roofs.ts new file mode 100644 index 0000000..7360448 --- /dev/null +++ b/src/plan/generatePlan/roofs.ts @@ -0,0 +1,103 @@ +// Dach-Domäne des Grundriss-Generators (Dachaufsicht): reine Linien-Darstellung +// ohne Poché — Traufe/First/Grat/Knick aus `roofGeometry`. + +import type { LayerCategory, Project, Roof } from "../../model/types"; +import { getComponent, roofLayers } from "../../model/types"; +import { roofGeometry } from "../../geometry/roof"; +import { + NO_HATCH, + POCHE_STROKE, + ROOF_BREAK_DASH, + ROOF_BREAK_MM, + ROOF_EAVES_MM, + ROOF_HIP_MM, + ROOF_RIDGE_MM, + resolveHatch, + type Primitive, +} from "./shared"; + +/** + * Dach im Grundriss (Dachaufsicht): reine Linien-Darstellung ohne Poché — die + * 2D-Geometrie aus `roofGeometry` (First/Grat/Knick hängen NICHT von der + * Traufhöhe ab, daher `eavesZ=0`): + * • Traufe (`eaves`): geschlossener Umriss-Ring als mittlere Haarlinie. + * • First (`ridges`): kräftige Volllinie, etwas stärker als die Traufe. + * • Grat (`hips`, Walm/Zelt): Volllinie zwischen Traufe- und First-Stärke. + * • Knick (`breaks`, Mansarde): dünne gestrichelte Linie. + * Farbe: `roof.color` übersteuert sonst die Kategorie-Farbe (analog Stützen- + * Poché), Default die neutrale Poché-Tinte. + */ +export function addRoof( + out: Primitive[], + project: Project, + roof: Roof, + greyed: boolean, + category: LayerCategory | undefined, +): void { + const stroke = roof.color ?? category?.color ?? POCHE_STROKE; + const geo = roofGeometry(roof, 0); + + // Ansichts-Poché der Dachaufsicht: das Dach liegt ÜBER der Schnittebene und + // wird von oben gesehen (nicht geschnitten) → Ansichts-Schraffur + // (viewHatchId) der ÄUSSERSTEN Schicht (Eindeckung) des Dachtyps, analog dem + // Decken-Poché (Aufsicht = erste Lage). Ohne Dachtyp/viewHatchId keine + // Schraffur (weiss) — die Fläche bleibt trotzdem das Pick-Polygon. + let viewHatch = NO_HATCH; + const firstLayer = roofLayers(project, roof)[0]; + if (firstLayer?.componentId) { + try { + const comp = getComponent(project, firstLayer.componentId); + if (comp.viewHatchId) viewHatch = resolveHatch(project, comp.viewHatchId); + } catch { + viewHatch = NO_HATCH; + } + } + + // Pick-Polygon über der Traufe-Fläche (macht das Dach über die ganze Fläche + // per Links-Klick selektierbar) — trägt jetzt zusätzlich die Ansichts- + // Schraffur der Eindeckung (fill bleibt "none": keine Volltonfüllung, nur + // Musterlinien). PlanView testet die `pts` (Punkt-in-Polygon). + if (geo.eaves.length >= 3) { + out.push({ + kind: "polygon", + pts: geo.eaves, + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: viewHatch, + greyed, + roofId: roof.id, + }); + } + + const n = geo.eaves.length; + for (let i = 0; i < n; i++) { + out.push({ + kind: "line", + a: geo.eaves[i], + b: geo.eaves[(i + 1) % n], + cls: "roof-eaves", + weightMm: ROOF_EAVES_MM, + color: stroke, + greyed, + }); + } + for (const [a, b] of geo.ridges) { + out.push({ kind: "line", a, b, cls: "roof-ridge", weightMm: ROOF_RIDGE_MM, color: stroke, greyed }); + } + for (const [a, b] of geo.hips) { + out.push({ kind: "line", a, b, cls: "roof-hip", weightMm: ROOF_HIP_MM, color: stroke, greyed }); + } + for (const [a, b] of geo.breaks) { + out.push({ + kind: "line", + a, + b, + cls: "roof-break", + weightMm: ROOF_BREAK_MM, + dash: ROOF_BREAK_DASH, + color: stroke, + greyed, + }); + } +} diff --git a/src/plan/generatePlan/rooms.ts b/src/plan/generatePlan/rooms.ts new file mode 100644 index 0000000..b019bb8 --- /dev/null +++ b/src/plan/generatePlan/rooms.ts @@ -0,0 +1,96 @@ +// Raum-Domäne des Grundriss-Generators (SIA-416-Flächen): eine (unsichtbare, +// nur klickbare) Füllfläche + dünne neutrale Umrisslinie + ein Raum-Stempel +// (Name, Fläche, SIA-Kürzel) am Anker. + +import type { Room } from "../../model/types"; +import { evaluateRoom, siaLabel } from "../../geometry/roomArea"; +import { docFromText } from "../../text/richText"; +import { roomStampExtraLines, roomStampToDoc } from "../../model/roomStamp"; +import { LAYER_DETAIL_FACTOR, LAYER_LINE_MM, NO_HATCH, type DetailLevel, type Primitive } from "./shared"; + +/** + * Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente + * Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren + * Schema-Ebenen vorbehalten. Alpha 0 = unsichtbar; das Polygon bleibt als + * `roomId`-Primitiv erhalten (Auswahl per Punkt-in-Polygon in PlanView, NICHT + * über die native Pointer-Trefferfläche der Füllung — die volle Fläche bleibt + * damit trotz Alpha 0 klickbar). + */ +const ROOM_FILL_ALPHA = "00"; +/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */ +const ROOM_STAMP_PT = 10; + +/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */ +function withAlpha(color: string, alphaHex: string): string { + return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color; +} + +/** + * Raum (SIA-416-Fläche) im Grundriss: eine (unsichtbare, nur klickbare) Füll- + * fläche über dem lichten Umriss + eine dünne neutrale Umrisslinie (beide + * `roomId`-getaggt für die Auswahl) + ein Raum-Stempel (Text) am Anker + * (Centroid oder `stampAnchor`) mit Name, Fläche „24.30 m²" und SIA-Kürzel. + * Keine Farbwäsche — Plandarstellung schwarz/grau, Farbe bleibt späteren + * Schema-Ebenen vorbehalten. Der Stempel entfällt beim Detailgrad „grob"; + * Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie gespeichert). + * Wird UNTER der Wand-Poché gezeichnet. + */ +export function addRoomArea( + out: Primitive[], + room: Room, + greyed: boolean, + detail: DetailLevel, +): void { + const pts = room.boundary; + if (pts.length < 3) return; + const res = evaluateRoom(pts, room.siaCategory); + const stroke = room.color; + + // Unsichtbare Füllfläche (Alpha 0): keine Farbwäsche im Plan, das Polygon + // bleibt aber als `roomId`-Primitiv für die Punkt-in-Polygon-Auswahl erhalten. + out.push({ + kind: "polygon", + pts, + fill: withAlpha(room.color, ROOM_FILL_ALPHA), + stroke, + strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail], + hatch: NO_HATCH, + greyed, + roomId: room.id, + }); + + // Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile + // + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad + // „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text. + if (detail !== "grob") { + const at = room.stampAnchor ?? res.centroid; + // Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text + // bzw. Raum-Name als einfacher Text). + const doc = room.stamp + ? roomStampToDoc(room.stamp) + : room.stampDoc ?? docFromText(room.name); + const extraLines = room.stamp + ? roomStampExtraLines(room.stamp, { + netArea: res.netArea, + siaCategory: room.siaCategory, + siaLabel: siaLabel(room.siaCategory), + }) + : [ + { text: `${res.netArea.toFixed(2)} m²`, align: "center" as const }, + { + text: `${room.siaCategory} · ${siaLabel(room.siaCategory)}`, + align: "center" as const, + }, + ]; + out.push({ + kind: "text", + at, + doc, + extraLines, + basePt: ROOM_STAMP_PT, + color: stroke, + roomId: room.id, + greyed, + }); + } +} diff --git a/src/plan/generatePlan/sections.ts b/src/plan/generatePlan/sections.ts new file mode 100644 index 0000000..0c21325 --- /dev/null +++ b/src/plan/generatePlan/sections.ts @@ -0,0 +1,145 @@ +// Schnittführungs-Symbol-Domäne des Grundriss-Generators (klassische +// Strichpunkt-Schnittlinie mit Endmarken + Label) — NICHT zu verwechseln mit +// `generateSectionPlan()` (der Schnitt-ALS-Plan-Übersetzung, bleibt Orchestrator +// in `../generatePlan.ts`): diese Domäne zeichnet nur das Führungssymbol EINER +// Schnitt-/Ansichtsebene IM Grundriss. + +import { leftNormal, normalize, sub } from "../../model/geometry"; +import type { Project } from "../../model/types"; +import { NO_HATCH, type Primitive } from "./shared"; + +/** Tinte der Schnittführungs-Linie/Marken (kräftiges Grau). */ +const SECTION_LINE_INK = "#1f242c"; +/** Haarlinie der Strichpunkt-Schnittlinie (mm Papier). */ +const SECTION_LINE_MM = 0.18; +/** Kräftige Endmarken (Querstrich + Pfeil) (mm Papier). */ +const SECTION_LINE_MARK_MM = 0.5; +/** Strichpunkt-Muster der Schnittlinie (lange Striche + Punkt, mm Papier). */ +const SECTION_LINE_DASH: number[] = [6, 1.5, 0.4, 1.5]; +/** Länge der Endmarken (Querstrich/Pfeil) in Modell-Metern. */ +const SECTION_MARK_LEN = 0.6; +/** Halbe Breite des unsichtbaren Pick-Bands über der Linie (Modell-Meter). */ +const SECTION_PICK_HALF = 0.3; +/** Schrifthöhe des Schnitt-Labels (Modell-Meter). */ +const SECTION_LABEL_H = 0.5; + +/** + * Kurz-Tag eines Schnitt-Labels: der letzte „Wort"-Teil des Ebenennamens (z. B. + * „Schnitt A" → „A"); enthält der Name kein Leerzeichen, der ganze Name. Klassisch + * wird der Schnitt an beiden Enden mit diesem Tag beschriftet (A–A). + */ +function sectionLabelTag(name: string): string { + const parts = name.trim().split(/\s+/); + return parts.length > 1 ? parts[parts.length - 1] : name.trim(); +} + +/** + * Zeichnet für jede Schnitt-/Ansichtsebene MIT gesetzter `linePoints` das + * klassische Schnittführungs-Symbol in den Grundriss: eine Strichpunkt-Haarlinie + * über die volle Länge, an beiden Enden einen kräftigen Querstrich + Richtungspfeil + * auf der Blickseite (`directionSign`) und das Schnitt-Label (Kurz-Tag) daneben. + * Zusätzlich ein unsichtbares Pick-Band (Polygon mit `sectionLineId`), damit die + * Linie im Grundriss anklickbar ist (analog dem Dach-Traufe-Pick-Polygon). + */ +export function addSectionLines(out: Primitive[], project: Project): void { + for (const level of project.drawingLevels) { + if (level.kind !== "section" && level.kind !== "elevation") continue; + // Schnittebene ist ein-/ausblendbar wie andere Ebenen: eine unsichtbare + // Schnitt-/Ansichtsebene zeigt ihre Linie im Grundriss nicht (Nutzer-Vision + // „eigene Ebene Schnittebenen"). `visible` fehlt = sichtbar (Alt-Projekte). + if (level.visible === false) continue; + const line = level.linePoints; + if (!line) continue; + const [p0, p1] = line; + const axis = sub(p1, p0); + const len = Math.hypot(axis.x, axis.y); + if (len < 1e-6) continue; + const u = normalize(axis); + const n = leftNormal(u); // (-uy, ux) + const sign = level.directionSign ?? 1; + const bn = { x: n.x * sign, y: n.y * sign }; // Blicknormale + + // Hauptlinie (Strichpunkt-Haarlinie). + out.push({ + kind: "line", + a: p0, + b: p1, + cls: "section-line", + weightMm: SECTION_LINE_MM, + dash: SECTION_LINE_DASH, + color: SECTION_LINE_INK, + }); + + // Endmarken (Querstrich + Richtungspfeil + Label) an beiden Enden. + const half = SECTION_MARK_LEN / 2; + for (const end of [p0, p1]) { + // Querstrich senkrecht zur Linie, mittig am Endpunkt. + const t0 = { x: end.x - n.x * half, y: end.y - n.y * half }; + const t1 = { x: end.x + n.x * half, y: end.y + n.y * half }; + out.push({ + kind: "line", + a: t0, + b: t1, + cls: "section-line", + weightMm: SECTION_LINE_MARK_MM, + color: SECTION_LINE_INK, + }); + // Richtungspfeil: vom Querstrich-Ende auf der Blickseite entlang bn. + const base = { x: end.x + bn.x * half, y: end.y + bn.y * half }; + const tip = { x: base.x + bn.x * SECTION_MARK_LEN, y: base.y + bn.y * SECTION_MARK_LEN }; + const wingBack = { + x: tip.x - bn.x * SECTION_MARK_LEN * 0.4, + y: tip.y - bn.y * SECTION_MARK_LEN * 0.4, + }; + const w = SECTION_MARK_LEN * 0.25; + out.push({ kind: "line", a: base, b: tip, cls: "section-line", weightMm: SECTION_LINE_MARK_MM, color: SECTION_LINE_INK }); + out.push({ + kind: "line", + a: tip, + b: { x: wingBack.x + u.x * w, y: wingBack.y + u.y * w }, + cls: "section-line", + weightMm: SECTION_LINE_MARK_MM, + color: SECTION_LINE_INK, + }); + out.push({ + kind: "line", + a: tip, + b: { x: wingBack.x - u.x * w, y: wingBack.y - u.y * w }, + cls: "section-line", + weightMm: SECTION_LINE_MARK_MM, + color: SECTION_LINE_INK, + }); + // Label (Kurz-Tag) etwas hinter dem Pfeil, horizontal. + const labelAt = { + x: tip.x + bn.x * SECTION_MARK_LEN * 0.4 - SECTION_LABEL_H * 0.3, + y: tip.y + bn.y * SECTION_MARK_LEN * 0.4 + SECTION_LABEL_H * 0.5, + }; + out.push({ + kind: "drawingText", + at: labelAt, + text: sectionLabelTag(level.name), + heightM: SECTION_LABEL_H, + angle: 0, + color: SECTION_LINE_INK, + }); + } + + // Unsichtbares Pick-Band (Rechteck entlang der Linie) — klickbar per + // Punkt-in-Polygon in PlanView, trägt die DrawingLevel-Id. + const off = { x: n.x * SECTION_PICK_HALF, y: n.y * SECTION_PICK_HALF }; + out.push({ + kind: "polygon", + pts: [ + { x: p0.x + off.x, y: p0.y + off.y }, + { x: p1.x + off.x, y: p1.y + off.y }, + { x: p1.x - off.x, y: p1.y - off.y }, + { x: p0.x - off.x, y: p0.y - off.y }, + ], + fill: "none", + stroke: "none", + strokeWidthMm: 0, + hatch: NO_HATCH, + sectionLineId: level.id, + }); + } +} diff --git a/src/plan/generatePlan/shared.ts b/src/plan/generatePlan/shared.ts new file mode 100644 index 0000000..f7e1ed1 --- /dev/null +++ b/src/plan/generatePlan/shared.ts @@ -0,0 +1,521 @@ +// Gemeinsame Grundlage des Grundriss-Generators: das `Primitive`-Vokabular +// (das EINZIGE Austauschformat zwischen `generatePlan()`/`generateSectionPlan()` +// und allen Bauteil-Domänen unter `generatePlan/`), die By-Layer/By-Object- +// Attribut-Auflösung (`resolve*`) sowie die Papier-mm-/Tinten-Konstanten, die +// über mehrere Domänen hinweg identisch gelten müssen (SIA-Poché, Haarlinien). +// +// Jede Datei unter `generatePlan/` importiert von hier, NIE von `../generatePlan` +// selbst — das hielte die Orchestrator-Datei (`generatePlan.ts`) andernfalls in +// einem Zirkelimport mit jeder einzelnen Domäne. + +import type { + AttributeSource, + Component, + HatchPattern, + LayerCategory, + Project, + Vec2, +} from "../../model/types"; +import { getHatch, getLineStyle } from "../../model/types"; +import type { Marks, RichTextDoc } from "../../text/richText"; +import type { RoomStampLine } from "../../model/roomStamp"; + +/** + * Detailgrad der Plan-Darstellung (Oberleiste „grob/mittel/fein"). Steuert hier + * konkret zwei Dinge im Grundriss: + * • Tür-/Fenstersymbole: grob = nur Öffnungs-Lücke + gerade Türblatt-Linie; + * mittel = + Schwenkbogen; fein = + Rahmen/Anschlag-Striche. + * • Schraffuren + Linienfeinheit: grob = vereinfachte Poché (eine Sammelfläche + * ohne Schraffur, dickere Umrisslinie); mittel = aktueller mehrschichtiger + * Aufbau mit Schraffuren; fein = volle Schraffuren + dünnere Linien. + * + * NOCH OFFEN (folgt, sobald die Features existieren — hier bewusst KEIN + * Schein-Effekt): Bemaßung/Beschriftung werden mit dem Detailgrad auf-/abblenden + * (Maßketten, Raumstempel), und die 3D-Geometrie-Auflösung (z. B. Profil- + * Rundungen, Tritt-/Setzstufen) wird ebenfalls dem Detailgrad folgen, sobald + * Bemaßung bzw. eine feinere 3D-Tessellierung implementiert sind. + */ +export type DetailLevel = "grob" | "mittel" | "fein"; + +/** Stroke-Farbe der Schicht-Polygone (neutrales Schwarz, geteilte Tinte mit der Schraffur). */ +export const POCHE_STROKE = "#1a1a1a"; + +/** Tinte für den Schwarz-Weiss-Modus: einheitliches Dunkel für alle Striche. */ +export const MONO_INK = "#111111"; + +// ── Linienstärken in PAPIER-Millimeter (vgl. docs/design/plans-output.md §3.2) ── +// Alle Stricharten sind in mm Papier definiert; die PlanView rechnet sie über +// dpi = 96 in konstante Bildschirm-Pixel um (non-scaling-stroke), damit Linien +// beim Zoomen ihre Papiergröße behalten und der Massstab stimmt. + +/** Default-Schichtfuge zwischen den Materialschichten in mm — Haarlinie (0.02), + * gleiche Stärke wie die Schraffur-Tinte. Pro Fuge im Wandstil überschreibbar + * (z. B. 0.13, wenn zwei massive Materialien aufeinandertreffen). */ +export const LAYER_LINE_MM = 0.02; +/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */ +export const SYMBOL_HAIRLINE_MM = 0.02; +/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der + * Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach + * BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */ +export const OVERHEAD_DASH: number[] = [0.4, 0.25]; +/** Strichstärken der Dach-Grundrisslinien (mm Papier): Traufe = mittlere + * Haarlinie, First etwas kräftiger als die Traufe, Grat dazwischen, Knick dünn + * + gestrichelt (vgl. addRoof). */ +export const ROOF_EAVES_MM = 0.13; +export const ROOF_RIDGE_MM = 0.25; +export const ROOF_HIP_MM = 0.18; +export const ROOF_BREAK_MM = 0.09; +/** Strichmuster (mm Papier) der Dach-Knicklinie (Mansarde). */ +export const ROOF_BREAK_DASH: number[] = [0.12, 0.08]; +/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */ +export const OUTLINE_DETAIL_FACTOR: Record = { + grob: 1.6, // dickere Sammellinie + mittel: 1.0, // wie die Kategorie-Strichstärke + fein: 0.6, // feiner +}; +/** Stärke der Schichtfugen je Detailgrad (mittel/fein; grob hat keine Fugen). */ +export const LAYER_DETAIL_FACTOR: Record = { + grob: 1.0, + mittel: 1.0, + fein: 0.7, +}; +/** Default-Umrandungs-Strichstärke (mm), falls die Kategorie keine lw trägt + (Grundeinstellung für „normale Elemente"). */ +export const WALL_FALLBACK_MM = 0.18; + +/** + * Aufgelöste Schraffur eines Polygons — alle Werte stammen aus einer + * HatchStyle-Ressource (Muster/Maßstab/Winkel/Farbe) plus dem optional + * verknüpften LineStyle (Linienstärke/Strichmuster der Musterlinien). Die + * PlanView ist damit rein darstellend und kennt das Projekt nicht. + */ +export interface HatchRender { + pattern: HatchPattern; + scale: number; + angle: number; + /** + * AUFGELÖSTE Farbe der Musterlinien bzw. der Vollfüllung (pattern==="solid"). + * Ergebnis der Farb-Resolve-Kette (siehe {@link resolveHatch}): Vordergrund = + * (Attribut-Override) ?? (Component.foreground) ?? (HatchStyle.color, Backward- + * Compat) ?? Default. Der heutige SVG-/GL-/DXF-Renderer liest weiterhin nur + * dieses Feld — die Kette ist zur Renderzeit bereits kollabiert. + */ + color: string; + /** Linienstärke der Musterlinien in mm. */ + lineWeight: number; + /** Strichmuster der Musterlinien in mm; null = durchgezogen. */ + dash: number[] | null; + /** + * Schraffur-Typ (additiv). `undefined`/"vector" ⇒ Muster nach `pattern` (heutiges + * Verhalten); "image" ⇒ Bild-Muster (`image`). Bestehende Renderer ignorieren + * `kind` und zeichnen nach `pattern` → unbekannte kinds fallen auf heute zurück. + */ + kind?: "vector" | "image"; + /** Vektor-Untermodus (additiv). `undefined`/"parallel" ⇒ regelmäßig; "random" ⇒ zufällig. */ + lines?: "parallel" | "random"; + /** Bild-Muster-Parameter (nur `kind==="image"`); durchgereicht aus dem HatchStyle. */ + image?: { src: string; scaleX: number; scaleY: number; rotation: number }; + /** + * Steuer-Parameter der Random-Vektor-Streuung (nur `lines==="random"`), 1:1 aus + * dem HatchStyle durchgereicht. Alle optional — die eigentliche Erzeugung/ + * Default-Auflösung liegt zentral in `glPlan/glPlanHatch.ts` (single render truth). + */ + seed?: number; + density?: number; + lengthMin?: number; + lengthMax?: number; +} + +/** + * Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus + * dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus, + * `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked") + * verbietet die Anwahl (siehe App.tsx onPlanSelect). Wird kein Resolver + * übergeben, gilt für jede sichtbare Kategorie {render:true, greyed:false, + * locked:false} (bisheriges Verhalten). + */ +export interface CategoryDisplay { + render: boolean; + greyed: boolean; + locked: boolean; +} + +/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */ +export type CategoryDisplayResolver = (code: string) => CategoryDisplay; + +/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */ +export const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false, locked: false }); + +export type Primitive = + | { + kind: "polygon"; + pts: Vec2[]; + /** Füllfarbe (Component-Poché) bzw. "none" für reine Umrisslinien. */ + fill: string; + stroke: string; + /** Umriss-Strichstärke in PAPIER-Millimeter (siehe oben). */ + strokeWidthMm: number; + hatch: HatchRender; + /** Gedimmt zeichnen (Darstellungsmodus „andere grau"). */ + greyed?: boolean; + /** + * Kanten-Indizes, die NICHT umrissen (gestrichen) werden dürfen — die Füllung + * bleibt das volle Polygon. Kante `i` läuft von `pts[i]` nach `pts[(i+1)%n]`. + * Damit werden die inneren Gehrungs-/Stoß-Stirnflächen an einer Wandecke + * unterdrückt: die Diagonale, an der GLEICHFARBIGE Schichten zweier Wände + * aneinanderstoßen, würde sonst als sichtbare 45°-Naht erscheinen und der + * Umriss würde als Barbe über den Eck-Apex hinausschießen (Gehrungs-Spitze). + * Längskanten (Schichtfugen zwischen VERSCHIEDENEN Materialien) bleiben + * gestrichen — nur die Stoß-Stirnkanten am Knoten entfallen. + */ + noStrokeEdges?: number[]; + /** + * ID der Wand, zu der dieses Schicht-/Umriss-Polygon gehört (für die + * Links-Klick-Auswahl: die PlanView markiert ALLE Polygone derselben + * Wand und meldet die wallId nach oben). Reine Umriss-/Symbol-Primitive + * ohne Wandbezug lassen das Feld weg. + */ + wallId?: string; + /** + * ID des 2D-Zeichenelements (Drawing2D), falls dieses gefüllte Polygon eine + * geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche). + */ + drawingId?: string; + /** + * ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss + * ist (für die Links-Klick-Auswahl der Decke). + */ + ceilingId?: string; + /** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */ + openingId?: string; + /** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */ + 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 des Kontext-Objekts (importiertes Gebäude/Terrain, `project.context`), + * falls dieses (unsichtbare) Pick-Band dessen Grundriss-Bounding-Box + * umhüllt (Links-Klick-Auswahl — analog `sectionLineId`, s. u.). + */ + contextObjectId?: 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; + /** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */ + roofId?: string; + /** + * ID der Schnitt-/Ansichtslinie (DrawingLevel), falls dieses (unsichtbare) + * Pick-Band das Schnittführungs-Symbol umhüllt (für die Links-Klick-Auswahl + * der Schnittlinie im Grundriss). + */ + sectionLineId?: string; + } + | { + kind: "line"; + a: Vec2; + b: Vec2; + cls: string; + /** Strichstärke in mm Papier (non-scaling). */ + weightMm: number; + /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ + dash?: number[] | null; + /** + * Zickzack-/Wellen-Parameter (aus einem LineStyle mit `kind==="zigzag"`), + * beide in mm Papier. Ist es gesetzt, zeichnen die Renderer die Linie als + * Zickzack-Pfad statt als gerade (ggf. gestrichelte) Linie — ADDITIV, ohne + * die bestehenden Strich/Dash-Linien zu verändern (Feld fehlt ⇒ heute). + */ + zigzag?: { amplitude: number; wavelength: number }; + /** + * Frei gezeichnetes Wiederhol-Motiv (aus einem LineStyle mit + * `kind==="custom"`): eine offene Polylinie in einer Einheitszelle, `points` + * in mm Papier, `length` = Wiederhollänge in mm Papier. Ist es gesetzt, + * kacheln die Renderer das Motiv entlang der Linie (analog `zigzag`) — + * ADDITIV; fehlt es, bleibt die Linie gerade/gestrichelt. + */ + motif?: { points: Vec2[]; length: number }; + /** Optionale explizite Strichfarbe (überschreibt die CSS-Klasse). */ + color?: string; + /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */ + drawingId?: string; + /** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */ + openingId?: string; + /** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */ + stairId?: string; + greyed?: boolean; + } + | { + kind: "arc"; + center: Vec2; + from: Vec2; + to: Vec2; + r: number; + cls: string; + /** Strichstärke in mm Papier (non-scaling). */ + weightMm: number; + /** Strichmuster in mm Papier; null/undefined = durchgezogen. */ + dash?: number[] | null; + /** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */ + openingId?: string; + /** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */ + stairId?: string; + greyed?: boolean; + } + | { + kind: "text"; + /** Ankerpunkt im Modell (Meter). */ + at: Vec2; + /** + * Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet; + * `align` je Absatz steuert die Ausrichtung. + */ + doc: RichTextDoc; + /** + * LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc + * gezeichnet — unabhängig vom editierten Text, stets aktuell. Jede Zeile + * trägt ihre eigene Ausrichtung (`align`). + */ + extraLines: RoomStampLine[]; + /** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */ + basePt: number; + /** Textfarbe (hex) als Default. */ + color: string; + /** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */ + roomId?: string; + greyed?: boolean; + } + | { + // Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`, + // z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt + // er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert. + kind: "drawingText"; + /** Ankerpunkt im Modell (Meter). */ + at: Vec2; + text: string; + /** Schrifthöhe in Modell-Metern. */ + heightM: number; + /** Drehung in RADIANT (CCW im Modell). */ + angle: number; + color: string; + /** Optionale einheitliche Formatierung (Schriftfamilie/fett/kursiv/…). */ + marks?: Marks; + /** + * 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; + /** + * Reiches Textfeld (mehrere Absätze, gemischte Formatierung) — Vorrang vor + * `text`/`marks` beim Rendern, s. `Drawing2DGeom["text"].doc`. Bricht bei + * gesetztem `wrapWidth` wortweise um (Run-Grenzen bleiben erhalten). + */ + doc?: RichTextDoc; + /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */ + drawingId?: string; + greyed?: boolean; + } + | { + // Eingebettetes Rasterbild (aus Drawing2D `{shape:"image"}`, Foto/Logo/ + // gerasterte PDF-Seite) — achsparalleles Rechteck, Data-URL als Quelle. + kind: "drawingImage"; + min: Vec2; + max: Vec2; + src: string; + opacity: number; + drawingId?: string; + greyed?: boolean; + } + | { + // Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als + // echtes `` gerendert (rund bei jedem Zoom), nicht als Vieleck. + kind: "drawingCircle"; + center: Vec2; + /** Radius in Modell-Metern. */ + r: number; + /** Vollton-Füllfarbe oder "none". */ + fill: string; + stroke: string; + weightMm: number; + dash?: number[] | null; + drawingId?: string; + greyed?: boolean; + } + | { + // Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als + // SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell). + kind: "drawingArc"; + center: Vec2; + r: number; + a0: number; + a1: number; + stroke: string; + weightMm: number; + dash?: number[] | null; + drawingId?: string; + greyed?: boolean; + }; + +/** + * Löst eine HatchStyle-Ressource (per id) zu einer darstellungsfertigen + * {@link HatchRender} auf. Der Linienstil der Musterlinien wird, falls gesetzt, + * aus dem Line Manager nachgeschlagen; sonst gilt ein dünner Default. + * + * VORDERGRUND (Muster-/Schraffurlinienfarbe bzw. Vollfüllung): AKTUELL fest auf + * {@link HATCH_INK} gezwungen (Nutzer-Vorgabe 2026-07-04) — Schnitt- WIE + * Oberflächen-/Ansichtsschraffur zeigen dieselbe dunkle Tinte, unabhängig von + * Material-/Bauteilfarbe, HatchStyle.color oder einem By-Layer/By-Object- + * Vordergrund-Override. `_foreground` bleibt Teil der Signatur — alle Aufrufer + * reichen weiterhin den über {@link resolveForeground} aufgelösten Bauteil- + * Vordergrund durch (z. B. für eine spätere Wiederherstellung der materialtreuen + * Schraffur) —, fließt aber bewusst nicht mehr in `color` ein. + */ +export function resolveHatch( + project: Project, + hatchId: string, + wallAngleDeg?: number, + _foreground?: string, +): HatchRender { + const h = getHatch(project, hatchId); + const ls = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null; + // Wandbezogener Winkel: `HatchRender.angle` wird von allen Renderern in der + // SVG-Konvention (im Uhrzeigersinn, Bildschirm-Y nach unten) verstanden — der + // Modell-Renderer negiert ihn in `toModelAngleRad`. Die Wandachse `wallAngleDeg` + // ist dagegen ein MODELL-Winkel (atan2 der Richtung, gegen den Uhrzeigersinn, + // Y nach oben). Wegen der Y-Spiegelung entspricht die Wandrichtung am Bildschirm + // dem Winkel −wallAngleDeg; das Muster folgt der Wand also, indem der + // Wandwinkel SUBTRAHIERT wird. Ohne Wandkontext (Decke) bleibt `angle` absolut. + const angle = + h.relativeToWall && wallAngleDeg != null ? h.angle - wallAngleDeg : h.angle; + return { + pattern: h.pattern, + scale: h.scale, + angle, + // Fest auf die Monochrom-Tinte gezwungen (siehe JSDoc oben) — weder + // `_foreground` noch `HatchStyle.color` (Material-/Bauteilfarbe) fließen ein. + color: HATCH_INK, + lineWeight: ls?.weight ?? 0.13, + dash: ls?.dash ?? null, + kind: h.kind, + lines: h.lines, + image: h.image, + // Random-Streu-Parameter unverändert durchreichen (Erzeugung in glPlanHatch). + seed: h.seed, + density: h.density, + lengthMin: h.lengthMin, + lengthMax: h.lengthMax, + }; +} + +/** + * Kollabiert die Vordergrund-Farbkette (Muster-/Schraffurlinienfarbe) eines + * Bauteils zu einem einzelnen Wert für {@link resolveHatch}. AUFLÖSUNGSREIHENFOLGE + * (By-Layer/By-Object, siehe `docs`/Attribut-Panel): + * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ?? + * (`Component.foreground`) ?? `undefined`. + * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert). + * `undefined` als Endergebnis heißt „Nach System" — die Kette läuft dann in + * resolveHatch auf den HatchStyle.color-Backward-Compat-Fallback weiter + * (Sample-Verhalten: kein Element setzt `override`/`source`, also identisch). + */ +export function resolveForeground( + comp: Component, + override?: string, + category?: LayerCategory, + source?: AttributeSource, +): string | undefined { + if (override != null) return override; + if (source === "layer") return category?.color ?? comp.foreground; + return comp.foreground; +} + +/** + * Kollabiert die Hintergrund-/Füllfarbkette (Poché) eines Bauteils, analog zu + * {@link resolveForeground}: + * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.color`) ?? + * (`Component.background`) ?? `Component.color`. + * Immer definiert (endet spätestens bei `Component.color` = heutiges Verhalten). + */ +export function resolveBackground( + comp: Component, + override?: string, + category?: LayerCategory, + source?: AttributeSource, +): string { + if (override != null) return override; + if (source === "layer") return category?.color ?? comp.background ?? comp.color; + return comp.background ?? comp.color; +} + +/** + * Kollabiert die Schraffur-Referenz (Hatch Manager) eines Elements zu einer + * einzelnen `hatchId` für {@link resolveHatch} — By-Layer/By-Object, analog zu + * {@link resolveForeground}: + * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.hatch`) ?? + * `fallback` (Bauteil-Schraffur, z. B. `Component.hatchId`/`viewHatchId`, oder + * `undefined` bei Drawing2D ohne Bauteil-Bezug). + * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert). + * Ergebnis kann `undefined` sein — der Aufrufer fällt dann auf `NO_HATCH` zurück. + */ +export function resolveHatchId( + override: string | undefined, + source: AttributeSource | undefined, + category: LayerCategory | undefined, + fallback?: string, +): string | undefined { + if (override != null) return override; + if (source === "layer") return category?.hatch ?? fallback; + return fallback; +} + +/** + * Kollabiert die Strichstärke-Kette (mm Papier) eines Elements, By-Layer/By- + * Object, analog zu {@link resolveHatchId}: + * (Attribut-Override `override`) ?? (`source==="layer"` ⇒ `category.lw`) ?? + * `fallback` (bisherige Bauteil-/LineStyle-/Kategorie-Default-Kette des + * Aufrufers — heutiges Verhalten). + * `source` fehlt/`"object"` ⇒ „Nach Bauteil" (heutiges Verhalten, unverändert). + */ +export function resolveStrokeWeight( + override: number | undefined, + source: AttributeSource | undefined, + category: LayerCategory | undefined, + fallback: number, +): number { + if (override != null) return override; + if (source === "layer") return category?.lw ?? fallback; + return fallback; +} + +// ── SIA-Poché-Füllung + monochrome Schraffur-Tinte ────────────────────────── +// Die 2D-Poché einer (auch mehrschichtigen) Wand trägt NICHT die rohe Bauteil- +// Albedo (3D-Materialfarbe), sondern einen neutralen Hintergrund, auf dem die +// Schraffur-Musterlinien liegen: Vollmuster ("solid") = dunkle Tinten-Poché +// (kein Linienmuster), jedes andere Muster (inkl. "none") = HELLER Hintergrund. +// Dieselben zwei Werte sind AKTUELL auch die feste Schraffur-Vordergrundfarbe +// (siehe {@link resolveHatch}) — Schnitt- wie Oberflächen-Schraffur zeigen also +// einheitlich Tinte auf hellem Papier, unabhängig von Material-/Bauteilfarbe. +export const HATCH_PAPER = "#f0f0f0"; +export const HATCH_INK = "#0f0f0f"; +/** Neutrale Poché-Füllfarbe für ein Muster: "solid" → Tinte, sonst Papier. */ +export function pocheFill(pattern: HatchPattern): string { + return pattern === "solid" ? HATCH_INK : HATCH_PAPER; +} + +/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */ +export const NO_HATCH: HatchRender = { + pattern: "none", + scale: 1, + angle: 0, + color: POCHE_STROKE, + lineWeight: LAYER_LINE_MM, + dash: null, +}; diff --git a/src/plan/generatePlan/stairs.ts b/src/plan/generatePlan/stairs.ts new file mode 100644 index 0000000..49dbf88 --- /dev/null +++ b/src/plan/generatePlan/stairs.ts @@ -0,0 +1,185 @@ +// Treppen-Domäne des Grundriss-Generators (DOSSIER/SIA): Tritte, Lauflinie mit +// Auf-/Abpfeil, Zwischenpodest und Aussenlinie — mit dem Grundriss-Schnitt +// (Tritte unter der Schnitthöhe durchgezogen, darüber gestrichelt/verdeckt + +// SIA-Bruchlinie). + +import type { Project, Stair, Vec2 } from "../../model/types"; +import { stairVerticalExtent } from "../../model/wall"; +import { stairCut, stairGeometry, stairOutline } from "../../geometry/stair"; +import type { SpiralOutlineSegments } from "../../geometry/stair"; +import { NO_HATCH, POCHE_STROKE, type Primitive } from "./shared"; + +/** + * Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei + * radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`. + */ +function _addSpiralOutline( + out: Primitive[], + seg: SpiralOutlineSegments, + stroke: string, + lwMm: number, + greyed: boolean, + stairId: string, +): void { + // Radiale Schliesslinien (Startwinkel + Endwinkel). + for (const [a, b] of seg.lines) { + out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId }); + } + // Innen- und Aussenbogen. + for (const arc of seg.arcs) { + const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) }; + const to: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) }; + out.push({ + kind: "arc", + center: { x: arc.cx, y: arc.cy }, + from, + to, + r: arc.r, + cls: "stair-outline", + weightMm: lwMm, + greyed, + stairId, + }); + } +} + +/** + * Treppen-Symbol im Grundriss (DOSSIER/SIA): + * • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der + * Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt). + * • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie + * (Doppelstrich quer über den Lauf). + * • Zwischenpodest (L/Wendel) als Umriss-Polygon. + * • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label. + * Alle Primitive tragen `stairId` für die Auswahl. + */ +export function addStairSymbol( + out: Primitive[], + project: Project, + stair: Stair, + greyed: boolean, + lwMm: number, + cutHeight: number, +): void { + const { zBottom, zTop } = stairVerticalExtent(project, stair); + const totalRise = zTop - zBottom; + const geo = stairGeometry(stair, totalRise); + const cut = stairCut(geo, cutHeight); + const below = new Set(cut.belowIndices); + const stroke = stair.color ?? POCHE_STROKE; + const stairId = stair.id; + + // Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks + // liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante. + for (const tr of geo.treads) { + const isBelow = below.has(tr.index); + // Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn + // unter der Schnittebene, sonst gestrichelt (verdeckt). + out.push({ + kind: "polygon", + pts: tr.pts, + fill: "none", + stroke, + strokeWidthMm: lwMm * 0.7, + hatch: NO_HATCH, + greyed, + stairId, + }); + // Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger. + const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]]; + out.push({ + kind: "line", + a: front[0], + b: front[1], + cls: "stair-tread", + weightMm: lwMm * (isBelow ? 1.0 : 0.6), + dash: isBelow ? null : [0.12, 0.08], + color: stroke, + greyed, + stairId, + }); + } + + // Zwischenpodest (L/Wendel-Auge): Umriss-Polygon. + if (geo.landing && geo.landing.length >= 3) { + out.push({ + kind: "polygon", + pts: geo.landing, + fill: "none", + stroke, + strokeWidthMm: lwMm * 0.7, + hatch: NO_HATCH, + greyed, + stairId, + }); + } + + // SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt). + if (cut.breakLine) { + for (const [a, b] of cut.breakLine) { + out.push({ + kind: "line", + a, + b, + cls: "stair-break", + weightMm: lwMm, + color: stroke, + greyed, + stairId, + }); + } + } + + // Lauflinie (Lauflinie) — kräftig, durchgezogen. + for (let i = 0; i < geo.runLine.length - 1; i++) { + out.push({ + kind: "line", + a: geo.runLine[i], + b: geo.runLine[i + 1], + cls: "stair-run", + weightMm: lwMm, + color: stroke, + greyed, + stairId, + }); + } + // Auf-/Abpfeil an der Spitze der Lauflinie. "filled" = gefülltes Dreieck + // (SIA-Vollpfeil), sonst der offene Winkelpfeil (zwei Linien, Default). + const [h1, tip, h2] = geo.arrow.head; + if (stair.arrowStyle === "filled") { + out.push({ + kind: "polygon", + pts: [h1, tip, h2], + fill: stroke, + stroke, + strokeWidthMm: lwMm, + hatch: NO_HATCH, + greyed, + stairId, + }); + } else { + out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); + out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId }); + } + + // Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L) + // bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel). + // Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im + // Rhino-Plugin. Stets durchgezogen, volle Strichstärke. + const outline = stairOutline(stair, totalRise); + if (outline.polygon && outline.polygon.length >= 3) { + out.push({ + kind: "polygon", + pts: outline.polygon, + fill: "none", + stroke, + strokeWidthMm: lwMm, + hatch: NO_HATCH, + greyed, + stairId, + }); + } + if (outline.spiral) { + _addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId); + } +} diff --git a/src/plan/generatePlan/structures.ts b/src/plan/generatePlan/structures.ts new file mode 100644 index 0000000..fd6b91f --- /dev/null +++ b/src/plan/generatePlan/structures.ts @@ -0,0 +1,93 @@ +// Sonstige Tragwerks-/Fremdkörper-Domäne des Grundriss-Generators: Stützen +// (Poché-Querschnitt, analog zur Wand-/Deckenschicht-Auflösung) und der +// Footprint-Umriss einer truck-Extrusion (Haarlinie ohne Poché/Schraffur, da +// Extrusionen noch keine Kategorie-/Bauteil-Zuordnung tragen). + +import type { Column, ExtrudedSolid, LayerCategory, Project } from "../../model/types"; +import { getComponent } from "../../model/types"; +import { columnFootprint } from "../../geometry/column"; +import { + HATCH_INK, + HATCH_PAPER, + MONO_INK, + NO_HATCH, + SYMBOL_HAIRLINE_MM, + resolveForeground, + resolveHatch, + resolveHatchId, + type HatchRender, + type Primitive, +} from "./shared"; + +/** 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 (`addWallPoche` `viewOnly`), da der Körper (noch) + * keine Schnitt-Poché/Kategorie-Zuordnung hat. + */ +export 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 + * ./shared.resolveHatch}); sonst eine neutrale Vollton-Poché. `columnId` macht + * die Fläche links-klick-selektierbar (analog Extrusion/Raum). + */ +export 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, + }); +} diff --git a/src/plan/generatePlan/walls.ts b/src/plan/generatePlan/walls.ts new file mode 100644 index 0000000..d75b8b6 --- /dev/null +++ b/src/plan/generatePlan/walls.ts @@ -0,0 +1,531 @@ +// Wand-Domäne des Grundriss-Generators: Öffnungs-Lücken entlang der Wandachse, +// die Wand-Footprints (für das Verdecken darunterliegender Decken-Umrisse, s. +// {@link ../ceilings}) und die eigentliche Wand-Poché (Schichten + Umriss + +// Öffnungsaussparungen + T-Stoss-/Gehrungs-Behandlung). + +import type { LayerCategory, Opening, Project, Vec2, Wall } from "../../model/types"; +import { getComponent, getWallType, wallTypeThickness } from "../../model/types"; +import { wallReferenceOffset } from "../../model/wall"; +import { openingInterval } from "../../geometry/opening"; +import { + add, + along, + clippedBand, + dot, + leftNormal, + lineIntersect, + normalize, + scale, + sub, +} from "../../model/geometry"; +import type { Line } from "../../model/geometry"; +import type { WallCuts } from "../../model/joins"; +import { + HATCH_INK, + HATCH_PAPER, + LAYER_DETAIL_FACTOR, + LAYER_LINE_MM, + NO_HATCH, + OUTLINE_DETAIL_FACTOR, + POCHE_STROKE, + resolveForeground, + resolveHatch, + resolveHatchId, + type DetailLevel, + type Primitive, +} from "./shared"; + +/** Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight` + * = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan + * ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad. */ +export interface WallGap { + from: number; + to: number; +} + +/** + * Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer + * sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle + * (Breite ≤ 0 oder außerhalb der Achse) werden verworfen. + */ +export function wallGaps( + wall: Wall, + doors: Project["doors"], + openings: Opening[], +): WallGap[] { + const gaps: WallGap[] = []; + for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width }); + for (const o of openings) { + const iv = openingInterval(wall, o); + if (iv) gaps.push(iv); + } + return gaps.sort((a, b) => a.from - b.from); +} + +/** Toleranz für die Achsen-Intervall-Arithmetik der Durchgangswand-Aussparungen. */ +export const CUTOUT_EPS = 1e-9; + +/** + * Zieht die Loch-Intervalle `holes` vom Segment `[s,e]` ab und liefert die + * verbleibenden (gezeichneten) Teil-Intervalle in Achsen-Metern. Löcher werden + * auf `[s,e]` geklemmt, sortiert und vereinigt; leeres Ergebnis heisst „ganz + * ausgespart". Ist `holes` leer, kommt exakt `[[s,e]]` zurück (unverändertes + * Verhalten für Wände ohne Aussparung). + */ +export function subtractIntervals( + s: number, + e: number, + holes: Array<[number, number]>, +): Array<[number, number]> { + const clipped = holes + .map(([a, b]): [number, number] => [ + Math.max(s, Math.min(a, b)), + Math.min(e, Math.max(a, b)), + ]) + .filter(([a, b]) => b > a + CUTOUT_EPS) + .sort((p, q) => p[0] - q[0]); + const parts: Array<[number, number]> = []; + let cursor = s; + for (const [a, b] of clipped) { + if (a > cursor + CUTOUT_EPS) parts.push([cursor, a]); + if (b > cursor) cursor = b; + } + if (cursor < e - CUTOUT_EPS) parts.push([cursor, e]); + return parts; +} + +/** + * Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der + * darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u` + * und -Normale `m`, Halbdicke, Referenz-Querversatz (Wandachse↔Bandmitte) und + * Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke + * verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind. + */ +export interface WallFootprint { + a: Vec2; + u: Vec2; + m: Vec2; + len: number; + half: number; + refOff: number; +} + +/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer + * Wandtyp ⇒ Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */ +export function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] { + const out: WallFootprint[] = []; + for (const w of walls) { + let t = 0.2; + try { + t = wallTypeThickness(getWallType(project, w)); + } catch { + // Default-Dicke behalten. + } + const u = normalize(sub(w.end, w.start)); + out.push({ + a: w.start, + u, + m: leftNormal(u), + len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y), + half: t / 2, + refOff: wallReferenceOffset(w, t), + }); + } + return out; +} + +/** + * Parametrisches Intervall [t0,t1] ⊆ [0,1], über das die Strecke `pa→pb` + * INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn + * die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion + * auf die Achse `u` (Bereich [−half, len+half]) und auf die Normale `m` (Bereich + * [refOff−half, refOff+half]); das Schnitt-Intervall beider Slabs ∩ [0,1] ist die + * verdeckte Strecke. + */ +export function segmentInsideWallBand( + pa: Vec2, + pb: Vec2, + fp: WallFootprint, +): [number, number] | null { + const d = sub(pb, pa); + const rel = sub(pa, fp.a); + const clipSlab = ( + f0: number, + fd: number, + lo: number, + hi: number, + ): [number, number] | null => { + if (Math.abs(fd) < 1e-12) + return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null; + let ta = (lo - f0) / fd; + let tb = (hi - f0) / fd; + if (ta > tb) [ta, tb] = [tb, ta]; + return [ta, tb]; + }; + const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half); + if (!su) return null; + const sm = clipSlab( + dot(rel, fp.m), + dot(d, fp.m), + fp.refOff - fp.half, + fp.refOff + fp.half, + ); + if (!sm) return null; + const t0 = Math.max(0, su[0], sm[0]); + const t1 = Math.min(1, su[1], sm[1]); + return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null; +} + +const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y); + +/** + * Wand als gefüllte Schnittfläche (Poché), um Öffnungen ausgespart. + * + * Detailgrad: + * • mittel/fein — jede Schicht des Wandtyps als eigenes Band (Füllfarbe + + * Schraffur des Bauteils), dazu eine kräftige Wand-Umrisslinie. fein zeichnet + * Schichtfugen und Umriss dünner als mittel. + * • grob — KEINE Schichten, KEINE Schraffur: eine einzige Sammelfläche über die + * volle Wanddicke (Füllung = Farbe des „Backbone"-Bauteils mit höchster + * joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER + * „Einfach"). + */ +export function addWallPoche( + out: Primitive[], + project: Project, + wall: Wall, + gaps: WallGap[], + cuts: WallCuts, + greyed: boolean, + detail: DetailLevel, + wallLwMm: number, + category: LayerCategory | undefined, + // Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei + // 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE + // Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`. + viewOnly: boolean, +): void { + const wt = getWallType(project, wall); + const total = wallTypeThickness(wt); + // Versatz der Schichtstapelung über die Dicke gemäß Referenzlinie (center=0, + // left=+T/2, right=−T/2). Alle Band-Offsets unten werden hierum verschoben, + // sodass die Achse bei „außen"/„innen" auf der jeweiligen Wandfläche liegt. + const refOff = wallReferenceOffset(wall, total); + // Jede Schicht-/Umrissfläche trägt die Wand-ID, damit ein Links-Klick die + // GANZE Wand (alle Bänder) markiert und die PlanView die wallId nach oben + // melden kann. + const wallId = wall.id; + // Achswinkel der Wand (Modell, Grad) — treibt wandbezogene Schraffuren + // ({@link resolveHatch}), damit z. B. eine Diagonale oder die Dämmungsstriche + // mit der Wandorientierung mitdrehen. + const wallAngleDeg = + (Math.atan2(wall.end.y - wall.start.y, wall.end.x - wall.start.x) * 180) / Math.PI; + // Strich-/Umrandungsfarbe der Wand: optionale Übersteuerung (wall.color), + // sonst der Default-Poché-Strich. Betrifft NUR die Linienfarbe (Umriss + + // Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren. + const stroke = wall.color ?? POCHE_STROKE; + + // Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint). + const openings = gaps; + + // Wand in Segmente zwischen den Öffnungen zerlegen. + let cursor = 0; + const segments: Array<[number, number]> = []; + for (const op of openings) { + if (op.from > cursor) segments.push([cursor, op.from]); + cursor = Math.max(cursor, op.to); + } + const axisLen = length(wall); + if (cursor < axisLen) segments.push([cursor, axisLen]); + + const outlineMm = wallLwMm * OUTLINE_DETAIL_FACTOR[detail]; + const layerLineMm = LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]; + // Vereinfachte Füllung (grob): immer schwarze Vollfüllung, keine Schraffur + + // Durchgangswand-Aussparungen (Phase 1c): über diese Achsen-Intervalle × Quer- + // Offsetbereiche wird die Nah-Putz-Zone der Durchgangswand echt ausgeschnitten + // (der Abzweig-Kern sticht durch). `offA` ist stets die Nahfläche, `offB` die + // Rückgrat-Nahfläche (siehe SpanCutout in model/joins.ts). Leer ⇒ heutiges + // Verhalten (Wand ohne T-Durchstoss). + const spanCutouts = cuts.spanCutouts ?? []; + + // Kanten-Indizes der inneren Gehrungs-Stirnflächen (die Diagonalen am Knoten). + // clippedBand liefert [A.start, A.end, B.end, B.start] → Kante 1 (A.end→B.end) + // ist die endCut-Stirnfläche, Kante 3 (B.start→A.start) die startCut-Stirnfläche. + // Diese werden NICHT umrissen: sonst 45°-Naht zwischen gleichfarbigen Schichten + // + überschießende Gehrungs-Barbe am Eck-Apex. Freie Enden (kein Cut) bleiben + // rechtwinklig gestrichen. Die Längskanten (Schichtfugen) bleiben stets sichtbar. + const joinEdges = (startCut: Line | null, endCut: Line | null): number[] => { + const e: number[] = []; + if (endCut) e.push(1); + if (startCut) e.push(3); + return e; + }; + + for (const [s, e] of segments) { + const p1 = along(wall.start, wall.end, s); + const p2 = along(wall.start, wall.end, e); + // Nur die echten Wandenden bekommen die Gehrung. Türnahe Segmentenden + // bleiben rechtwinklig (kein Schnitt). + const startCut = s <= 1e-6 ? cuts.startCut : null; + const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null; + + if (viewOnly) { + // Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke + // als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik + // bleibt dieselbe wie im Schnittfall (nur eben ohne Poché). + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), + fill: "none", + stroke, + strokeWidthMm: layerLineMm, + hatch: NO_HATCH, + greyed, + noStrokeEdges: joinEdges(startCut, endCut), + wallId, + }); + continue; + } + + if (detail === "grob") { + // Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss. + // Bei grob: immer Vollschwarz-Füllung gemäß SIA 400 Anhang B.9, Figur 36 + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut), + fill: HATCH_INK, + stroke, + strokeWidthMm: outlineMm, + hatch: NO_HATCH, + greyed, + noStrokeEdges: joinEdges(startCut, endCut), + wallId, + }); + continue; + } + + // mittel/fein: Schichten von außen (-T/2) nach innen (+T/2) stapeln. Jede + // Schicht löst Bauteil (Füllfarbe) + Schraffur (Muster/Maßstab/Winkel/Farbe) + // auf. Die Bänder tragen NUR Füllung + Schraffur (kein eigener Umriss); jede + // innere Schichtfuge wird als EIGENSTÄNDIGE, stilisierbare Linie gezeichnet. + const bandJoinEdges = joinEdges(startCut, endCut); + // Nur die Aussparungen, die dieses Segment [s,e] entlang der Achse berühren. + const segCutouts = spanCutouts.filter( + (c) => + Math.min(c.from, c.to) < e - CUTOUT_EPS && + Math.max(c.from, c.to) > s + CUTOUT_EPS, + ); + // Punkt entlang der Wandachse bei Achsen-Meter `d` (p1/p2 an den Rändern). + const axisPt = (d: number): Vec2 => + d === s ? p1 : d === e ? p2 : along(wall.start, wall.end, d); + // Materialbewusster T-Stoss (siehe resolveJoinPriority in model/joins.ts): + // liegt eine Pro-Schicht-Cut-Überschreibung vor, ersetzt sie PRO SCHICHT den + // einheitlichen startCut/endCut — nur an den echten Wandenden (dieselbe + // Maskierung wie oben für startCut/endCut, s <= 1e-6 / e >= axisLen-1e-6). + // Fehlt sie, bleibt alles wie zuvor (startCut/endCut für jede Schicht). + const layerCuts = cuts.layerCuts; + let off = refOff - total / 2; + for (let li = 0; li < wt.layers.length; li++) { + const layer = wt.layers[li]; + const comp = getComponent(project, layer.componentId); + const layerHatchId = + resolveHatchId(wall.hatchId, wall.hatchSource, category, comp.hatchId) ?? comp.hatchId; + const layerHatch = resolveHatch( + project, + layerHatchId, + wallAngleDeg, + resolveForeground(comp, wall.foreground, category, wall.foregroundSource), + ); + // SIA-Poché: neutraler Hintergrund (Papier) statt Bauteil-Albedo, die + // Schichtschraffur (fest in HATCH_INK, siehe resolveHatch) liegt darüber; + // Vollmuster ("solid") = Tinten-Poché. + const layerSolid = layerHatch.pattern === "solid"; + const layerStartCut = s <= 1e-6 ? (layerCuts ? layerCuts.start[li] : startCut) : null; + const layerEndCut = e >= axisLen - 1e-6 ? (layerCuts ? layerCuts.end[li] : endCut) : null; + // Durchgangswand-Aussparung: liegt der Quer-Offsetbereich dieses Bandes + // [off, off+thickness] im Nah-Putz-Bereich einer Aussparung, wird das Band + // entlang der Achse in Teil-Bänder VOR/NACH dem Intervall zerlegt (das + // Durchstoss-Stück entfällt). Ohne Überlappung bleibt genau ein Band + // [s,e] (unverändertes Verhalten). + const bandLo = off; + const bandHi = off + layer.thickness; + const bandHoles: Array<[number, number]> = []; + for (const c of segCutouts) { + const cLo = Math.min(c.offA, c.offB); + const cHi = Math.max(c.offA, c.offB); + if (bandLo < cHi - CUTOUT_EPS && bandHi > cLo + CUTOUT_EPS) + bandHoles.push([c.from, c.to]); + } + for (const [a, b] of subtractIntervals(s, e, bandHoles)) { + const sc = a <= s + CUTOUT_EPS ? layerStartCut : null; + const ec = b >= e - CUTOUT_EPS ? layerEndCut : null; + out.push({ + kind: "polygon", + pts: clippedBand(axisPt(a), axisPt(b), off, off + layer.thickness, sc, ec), + fill: layerSolid ? HATCH_INK : HATCH_PAPER, + // Band ohne Umriss: keine Schichtfuge über die Bandkante, keine 45°-Naht + // am Knoten. Die Fugen kommen als separate `line`-Primitive (siehe unten). + stroke: "none", + strokeWidthMm: 0, + hatch: layerHatch, + greyed, + // Für die Ecktests/Konsistenz beibehalten (bei stroke:"none" wirkungslos). + noStrokeEdges: bandJoinEdges, + wallId, + }); + } + // L-Anschluss: eine getrimmte Schicht (z. B. Putz), die an eine + // verschmelzende Nachbarschicht (durchlaufender Kern) grenzt, bekommt + // zusätzlich eine seitliche Cut-Linie entlang der Durchgangsachse — sie + // begrenzt die Schichtbreite dort, wo der Kern sie unterbricht (L-Form). + const sideStart = s <= 1e-6 ? layerCuts?.startSide[li] : null; + const sideEnd = e >= axisLen - 1e-6 ? layerCuts?.endSide[li] : null; + if (sideStart || sideEnd) { + const uSeg = normalize(sub(p2, p1)); + const nSeg = leftNormal(uSeg); + const sidePoint = (base: Vec2, layerOff: number, cut: Line): Vec2 => { + const pt = add(base, scale(nSeg, layerOff)); + return lineIntersect(pt, uSeg, cut.point, cut.dir) ?? pt; + }; + if (sideStart) { + out.push({ + kind: "line", + a: sidePoint(p1, off, sideStart), + b: sidePoint(p1, off + layer.thickness, sideStart), + cls: "layer-joint-l", + weightMm: layerLineMm, + color: stroke, + greyed, + }); + } + if (sideEnd) { + out.push({ + kind: "line", + a: sidePoint(p2, off, sideEnd), + b: sidePoint(p2, off + layer.thickness, sideEnd), + cls: "layer-joint-l", + weightMm: layerLineMm, + color: stroke, + greyed, + }); + } + } + off += layer.thickness; + // Innere Schichtfuge (zwischen dieser und der nächst-inneren Schicht) als + // eigene Linie am kumulierten Offset `off`, mit dem Cut der Bänder geclippt. + // Stil aus layer.jointLineStyleId (Gewicht/Farbe/Strich); Rückfall = Haar- + // linie (LAYER_LINE_MM) in Wandfarbe. Das Gewicht folgt demselben Detail- + // Faktor wie die frühere Schichtfuge (`layerLineMm`), damit die Detailgrade + // sich unverändert verhalten. Die innerste Schicht hat keine innere Fuge. + // Nutzt bewusst weiterhin den einheitlichen startCut/endCut (nicht die + // Pro-Schicht-Cuts): die Fuge markiert den Materialwechsel INNERHALB + // dieser Wand und bleibt daher an der bisherigen Nahfläche. + if (li < wt.layers.length - 1) { + const jls = layer.jointLineStyleId + ? project.lineStyles.find((l) => l.id === layer.jointLineStyleId) + : undefined; + // Durchgangswand-Aussparung: liegt die Fuge im (geschlossenen) Nah-Putz- + // Bereich einer Aussparung — inkl. der Rückgrat-Nahfläche (Merge-Kontakt) + // —, wird sie über das Durchstoss-Intervall unterbrochen. So bleibt an der + // Verschmelzungsfläche keine Trennlinie stehen. + const fugeHoles: Array<[number, number]> = []; + for (const c of segCutouts) { + const cLo = Math.min(c.offA, c.offB); + const cHi = Math.max(c.offA, c.offB); + if (off >= cLo - CUTOUT_EPS && off <= cHi + CUTOUT_EPS) + fugeHoles.push([c.from, c.to]); + } + for (const [a, b] of subtractIntervals(s, e, fugeHoles)) { + const sc = a <= s + CUTOUT_EPS ? startCut : null; + const ec = b >= e - CUTOUT_EPS ? endCut : null; + // clippedBand(pa, pb, off, off, …) liefert [edgeStart, edgeEnd, edgeEnd, + // edgeStart] → die zwei distinkten Endpunkte sind Index 0 (Start) und 1 + // (Ende), beide am Cut abgeschnitten wie die Bandlängskante. + const jq = clippedBand(axisPt(a), axisPt(b), off, off, sc, ec); + out.push({ + kind: "line", + a: jq[0], + b: jq[1], + cls: "layer-joint", + weightMm: jls ? jls.weight * LAYER_DETAIL_FACTOR[detail] : layerLineMm, + color: jls ? jls.color : stroke, + dash: jls ? jls.dash : null, + // Zickzack-Fuge (LineStyle kind==="zigzag") ebenfalls durchreichen. + zigzag: jls?.kind === "zigzag" ? jls.zigzag : undefined, + // Custom-Motiv-Fuge (LineStyle kind==="custom") ebenfalls durchreichen. + motif: jls?.kind === "custom" ? jls.motif : undefined, + greyed, + }); + } + } + } + // Kräftige Wand-Umrisslinie über die volle Dicke (nur Kontur, keine Füllung), + // damit die Wand sich klar von den dünnen Schichtfugen abhebt. Die Gehrungs- + // Stirnkanten am Knoten entfallen → sauberer Eck-Apex ohne überschießende Barbe. + // Bei einer Durchgangswand-Aussparung wird zusätzlich die NAHFLÄCHEN-Kante + // (die Längskante auf der Durchstoss-Seite) über [from,to] strokelos gemacht + // und als separate Segmente vor/nach dem Durchstoss gezeichnet — sonst liefe + // die Umrisslinie über die Durchstoss-Breite. + const offLo = refOff - total / 2; + const offHi = refOff + total / 2; + const holesHi: Array<[number, number]> = []; + const holesLo: Array<[number, number]> = []; + for (const c of segCutouts) { + // `offA` ist stets die Nahfläche (Wandoberfläche); ordne die Aussparung der + // passenden Längskante (offHi bzw. offLo) zu. + if (Math.abs(c.offA - offHi) < 1e-6) holesHi.push([c.from, c.to]); + else if (Math.abs(c.offA - offLo) < 1e-6) holesLo.push([c.from, c.to]); + } + const outlineNoStroke = [...bandJoinEdges]; + // clippedBand-Kantenreihenfolge [A.start(offLo), A.end(offLo), B.end(offHi), + // B.start(offHi)]: Kante 0 ist die offLo-Längskante, Kante 2 die offHi-Längskante. + if (holesHi.length) outlineNoStroke.push(2); + if (holesLo.length) outlineNoStroke.push(0); + out.push({ + kind: "polygon", + pts: clippedBand(p1, p2, offLo, offHi, startCut, endCut), + fill: "none", + stroke, + strokeWidthMm: outlineMm, + hatch: NO_HATCH, + greyed, + noStrokeEdges: outlineNoStroke, + wallId, + }); + const drawNearEdge = (edgeOff: number, holes: Array<[number, number]>): void => { + for (const [a, b] of subtractIntervals(s, e, holes)) { + const sc = a <= s + CUTOUT_EPS ? startCut : null; + const ec = b >= e - CUTOUT_EPS ? endCut : null; + const jq = clippedBand(axisPt(a), axisPt(b), edgeOff, edgeOff, sc, ec); + out.push({ + kind: "line", + a: jq[0], + b: jq[1], + cls: "wall-outline", + weightMm: outlineMm, + color: stroke, + greyed, + }); + } + }; + if (holesHi.length) drawNearEdge(offHi, holesHi); + if (holesLo.length) drawNearEdge(offLo, holesLo); + } +} + +/** + * Referenzlinie einer Wand: dünne, gestrichelte Achslinie entlang der Wandachse + * (start → end). Von der Oberleiste umschaltbar; rein darstellend. + */ +export function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void { + out.push({ + kind: "line", + a: wall.start, + b: wall.end, + cls: "wall-axis", + weightMm: 0.13, + dash: [0.18, 0.06, 0.04, 0.06], // Strich-Punkt-Achse in mm Papier + greyed, + }); +} diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index 0f938c0..ff688c9 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -21,7 +21,8 @@ import type { Plan, Primitive } from "./generatePlan"; import { applyDashRuns, buildHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; -import { docToLines } from "../text/renderHtml"; +import { docToLines, wrapDocToLines } from "../text/renderHtml"; +import { docFromText } from "../text/richText"; type LinePrim = Extract; @@ -100,6 +101,20 @@ export interface RBounds { maxX: number; maxY: number; } +/** + * EIN eingebettetes Rasterbild (aus Drawing2D `{shape:"image"}`), achsparallel. + * Wie `bounds` additiv/rückwirkungsfrei fürs GPU-Backend (s. dort) — der native + * Renderer kennt keine Textur/kein Bild-Primitiv (GPU-Pfad wird bei Bildern im + * Plan ohnehin deaktiviert, s. PlanView.tsx `useGpuRenderer`/`hasBgImages`); + * `sceneToPrintSvg.ts` liest dieses Feld für den PDF-Export. + */ +export interface RImage { + min: RPoint; + max: RPoint; + src: string; + opacity: number; +} + export interface RScene { fills: RFill[]; outlines: ROutline[]; @@ -107,6 +122,7 @@ export interface RScene { arcs: RArc[]; lines: RLine[]; texts: RText[]; + images: RImage[]; /** * Plan-Bounds (Modell-Meter) — additiv aus `Plan.bounds` übernommen, damit * Verbraucher (z. B. der Vektor-PDF-Serializer `sceneToPrintSvg.ts`) den @@ -323,6 +339,7 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL const arcs: RArc[] = []; const lines: RLine[] = []; const texts: RText[] = []; + const images: RImage[] = []; // Sichtbare Umriss-Läufe werden erst GESAMMELT (nach Stil gruppiert) und am // Ende über Polygon-Grenzen hinweg zusammengenäht — erst dadurch bekommen @@ -562,6 +579,59 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL }); }); } + } else if (p.kind === "drawingText") { + // Modellverankerter Einzeltext (Text-Werkzeug/DXF-Import) — wie "text" + // oben zeilenweise geflacht, aber TOP-DOWN ab `at` (kein vertikal + // zentrierter Block) und `heightM` (Modell-Meter) als Basisgrösse statt + // `basePt` (Papier-Punkt) — der Text bleibt so, wie bisher, massstabsfrei + // an der Modellgeometrie verankert (kein Papier-Punkt-Bezug nötig). + flushRun(); + const doc = p.doc ?? docFromText(p.text, p.marks ?? {}); + const defCol = withOpacity(toRgba(p.color, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed); + const unitPerPt = PT_TO_MM * mmToM; // pt (Run-Override) -> Papier-mm -> Modell-Meter. + const basePt = unitPerPt > 0 ? p.heightM / unitPerPt : 0; + const lineHeight = p.heightM * 1.25; + const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : undefined; + const docLines = wrapDocToLines(doc, { + x: 0, + y: 0, + lineHeight, + unitPerPt, + basePt, + color: p.color, + ...(wrapWidthUnits ? { wrapWidthUnits } : {}), + }); + let dy = 0; + docLines.forEach((line, k) => { + if (k > 0) dy += line.lineHeight; + const content = line.tspans.map((t) => t.text).join(""); + if (!content.trim()) return; + const sizeM = line.tspans.length + ? Math.max(...line.tspans.map((t) => t.fontSize)) + : p.heightM; + texts.push({ + pos: [p.at.x, p.at.y - dy], + content, + sizeMm: unitPerPt > 0 ? sizeM / mmToM : sizeM, + color: withOpacity( + toRgba(line.tspans[0]?.fill ?? p.color, 1) ?? defCol, + undefined, + p.greyed, + ), + align: line.align, + }); + }); + } else if (p.kind === "drawingImage") { + // Eingebettetes Rasterbild — keine Rust-Textur (s. `RImage`); wird vom + // GPU-Backend ignoriert (`useGpuRenderer` schaltet bei Bildern im Plan + // ohnehin auf den SVG-Pfad um), `sceneToPrintSvg.ts` liest es für den PDF-Export. + flushRun(); + images.push({ + min: [p.min.x, p.min.y], + max: [p.max.x, p.max.y], + src: p.src, + opacity: p.opacity, + }); } } flushRun(); @@ -579,5 +649,5 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL } } - return { fills, outlines, polylines, arcs, lines, texts, bounds: { ...plan.bounds } }; + return { fills, outlines, polylines, arcs, lines, texts, images, bounds: { ...plan.bounds } }; } diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 3517436..d01cf70 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -31,1938 +31,67 @@ // Schicht-Aufteilung passiert also komplett hier, render3d bleibt unverändert. // Ohne auflösbaren WallType/ohne Schichten: EIN Vollkörper über die volle // Dicke im neutralen Grundton (heutiges Fallback-Verhalten). +// +// Diese Datei ist nur noch der ORCHESTRATOR: `projectToWalls3d()`/ +// `projectToModel3d()`/`pickGeometry()`/`selectionHighlightLines()`. Das +// gemeinsame Serde-Vokabular (`RWall`/`RSlab`/`RMesh`/…) und die Farb-/ +// Schraffur-/Material-Auflösung leben in `toWalls3d/shared.ts`; jede +// Bauteil-Domäne (Wand-Bänder, Decken, Öffnungen, Treppen, Dächer, Stützen, +// truck-Extrusionen, freie 2D-Geometrie) hat ihre eigene Datei unter +// `toWalls3d/` — siehe dort für die konkrete Emissions-Logik je Bauteil. -import type { Project, Wall, Ceiling, Column, Layer, ExtrudedSolid, Drawing2D, Vec2, HatchPattern, SliceTermination, Opening, SashDef } from "../model/types"; -import { - getComponent, - getWallType, - getCeilingType, - getStairType, - wallTypeThickness, - openingsOfWall, - getFloor, - flattenCategories, - getDoorType, - getWindowType, - glazingPanesOf, - roofLayers, -} from "../model/types"; -import { wallVerticalExtent, ceilingVerticalExtent, columnVerticalExtent, stairVerticalExtent } from "../model/wall"; -import { columnFootprint } from "../geometry/column"; -import { pointInOutline } from "../geometry/ceiling"; -import { mergeHoles } from "../geometry/polygonHoles"; -import { openingInterval, openingVerticalExtent, openingJambs } from "../geometry/opening"; +import type { Project, Wall } from "../model/types"; +import { getWallType, wallTypeThickness } from "../model/types"; +import { stairVerticalExtent } from "../model/wall"; +import { openingJambs, openingVerticalExtent } from "../geometry/opening"; import type { DetailLevel } from "../ui/TopBar"; -import { stairGeometry, stairBBox } from "../geometry/stair"; -import { roofGeometry, roofBaseElevation, roofUndersideAt } from "../geometry/roof"; -import type { Vec3 as RoofVec3, RoofGeometry } from "../geometry/roof"; -import { extrudePolygon, extrudeCircle } from "../engine/truckSolid"; +import { stairBBox, stairGeometry } from "../geometry/stair"; +import { roofBaseElevation, roofGeometry } from "../geometry/roof"; +import type { Vec3 as RoofVec3 } from "../geometry/roof"; 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, PickRoof, PickStair, PickContextMesh } from "../viewport/raycast3d"; +import { + EPS, + IMPORTED_RGB, + TERRAIN_RGB, + wallMaterialColorMaps, + type RMesh, + type RModel3d, + type RRgb, + type RSlab, + type RVec2, + type RWall, +} from "./toWalls3d/shared"; +import { emitWall } from "./toWalls3d/bands"; +import { emitSlabs } from "./toWalls3d/ceilings"; +import { emitStairMeshes, emitStairs } from "./toWalls3d/stairs"; +import { emitExtrudedSolids, TRUCK_MESH_READY_EVENT } from "./toWalls3d/extrusions"; +import { emitDrawingLines } from "./toWalls3d/drawing2d"; +import { emitColumns } from "./toWalls3d/columns"; +import { emitRoofs } from "./toWalls3d/roofs"; +import { emitLegacyDoorLeaves, emitOpeningFrames, emitOpeningGlass, emitOpeningShading } from "./toWalls3d/openings"; + +// Re-Export des gemeinsamen Vokabulars — externe Konsumenten (viewport/, +// export/, toSection.ts, nativeSync.ts, …) importieren weiterhin +// ausschliesslich von hier, unverändert zur Zeit vor dem Domänen-Split. +export { wallMaterialColorMaps, TRUCK_MESH_READY_EVENT }; +export type { RVec2, RRgb, RWall, RSlab, RModel3d }; /** World-Punkt [x, Höhe, y] für den Dach-Pick (Vec3-Konvention von raycast3d). */ type RoofPickVec3 = [number, number, number]; -/** - * 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]; /** Weltpunkt (x, elevation, y) — nur intern für Highlight-Kantengeometrie. */ type RVec3 = [number, number, number]; -/** - * Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component). - * Wird seit der TS-seitigen Schicht-Zerlegung (Option B, siehe Moduldoc) NICHT - * mehr an render3d durchgereicht — jede Schicht ist eine eigene Voll-Box; das - * `RWall.layers`-Feld bleibt für Serde-Kompatibilität, aber stets leer. - */ -export interface RLayer { - thickness: number; - 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. - } - } - for (const r of project.roofs ?? []) { - for (const l of roofLayers(project, r)) { - if (l.componentId) consider(l.componentId); - } - } - 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 - * `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle); - * `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders - * als `Opening.sillHeight`). Eine Tür ist der Sonderfall - * `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die - * Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter- - * Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu - * zerlegen. - */ -export interface RHole { - from: number; - to: number; - zBottom: number; - 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; - thickness: number; - height: number; - baseElevation: number; - color: RRgb; - /** - * Immer leer: die Schichten werden TS-seitig in eigene Voll-Boxen zerlegt - * (Option B, siehe Moduldoc). Feld bleibt nur zur Serde-Kompatibilität mit - * render3d::WallInput (leer ⇒ Vollkörper-Extrusion in `color`). - */ - layers: RLayer[]; - /** - * Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer - * Wand tragen dieselbe Id. Rein für den TS-seitigen Pick-Raycast (raycast3d); - * der render3d/Serde-Pfad ignoriert das Feld (kein `deny_unknown_fields`, - * serde verwirft unbekannte Felder). - */ - wallId: string; - /** - * Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe - * {@link RHole}). Nur im 3D-Viewer-Pfad ({@link projectToModel3d} mit - * `layeredWalls:true`) gesetzt: dort emittiert {@link emitWall} EINEN RWall pro - * Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der - * Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt - * übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der - * Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert - * unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/ - * leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes` - * 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. */ -export interface RSlab { - outline: RVec2[]; - zBottom: number; - zTop: number; - color: RRgb; - /** - * Id der Ursprungs-Decke (`project.ceilings[].id`). Nur für den TS-seitigen - * Pick-Raycast (raycast3d); der render3d/Serde-Pfad ignoriert das Feld - * (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" | "extrusion"; - -/** - * Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes - * Gebäude-/DXF-Volumen). `positions` ist das flache (x,y,z=Höhe…)-Array in - * MODELL-Metern — genau wie `ImportedMesh`/`TerrainMesh` es hält; render3d bildet - * es selbst nach world (x, z, y) ab (analog Wände/Decken). Matcht - * render3d::types::MeshInput. - */ -export interface RMesh { - positions: number[]; - indices: number[]; - kind: RMeshKind; - color: RRgb; - /** Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je - * Positions-Vertex. Damit wird z. B. ein SWISSIMAGE-Luftbild aufs Gelände-TIN - * „drapiert" (jeder Vertex trägt seinen Bildpunkt). Fehlt/zu kurz -> `color` - * fürs ganze Mesh. Matcht `render3d::types::MeshInput.vertexColors`. */ - vertexColors?: number[]; - /** Material-Textur-Ebene (1-basiert, siehe {@link wallMaterialColorMaps}) für - * den Stil „Textured" — bislang nur von Dach-Schichten gesetzt. Fehlt sie, - * zeigt render3d das Fallback-Schachbrett. Matcht - * `render3d::types::MeshInput.materialIndex`. */ - materialIndex?: number; -} - -/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */ -export interface RModel3d { - walls: RWall[]; - slabs: RSlab[]; - meshes: RMesh[]; -} - -/** Warmer, neutraler Wand-Grundton (wie render3d default). */ -const WALL_RGB: RRgb = [0.82, 0.8, 0.76]; -/** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */ -const SLAB_RGB: RRgb = [0.86, 0.86, 0.88]; -/** Gedämpftes Lehm-/Geländegrün-grau (three.js mats.terrain = 0xa9b0a2). */ -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 EINER Fensterscheibe (Meter, Mehrfachverglasungs-Pfad). Kompakt - * gehalten (8 mm Gesamtdicke je Scheibe, wie ein reales Einzelglas), damit - * z. B. eine Dreifachverglasung noch in den realistisch schlanken Default- - * Rahmen ({@link DEFAULT_FRAME_DEPTH}, ~70 mm) passt — bei der früheren - * "volle Wanddicke"-Rahmentiefe gab es dafür reichlich Platz, im schlanken - * Rahmen nicht mehr. - */ -const GLASS_HALF_THICK = 0.004; -/** - * Helles, warmes Rahmen-Grau (Zarge/Blendrahmen/Kämpfer/Sprossen) — bewusst - * deutlich heller als Wand ({@link WALL_RGB}) und klar von der bläulichen - * Scheibe ({@link GLASS_RGB}) unterscheidbar, damit der Rahmen im 3D als - * eigenes Bauteil lesbar ist (s. {@link emitOpeningFrames}). - */ -const FRAME_RGB: RRgb = [0.85, 0.85, 0.83]; -/** Heller Grauton für den Rollladen-/Sonnenschutzkasten (s. {@link emitOpeningShading}). */ -const SHADING_RGB: RRgb = [0.88, 0.88, 0.9]; -/** - * Heller Stein-/Beton-Grauton der Fensterbank (Sims/Tropfkante) — bewusst etwas - * dunkler/kühler als der Rahmen ({@link FRAME_RGB}), damit die Bank im 3D als - * eigenes Bauteil (nicht als Rahmenfortsatz) liest (s. Sims-Zweig in - * {@link frameMeshesForOpening}). - */ -const SILL_RGB: RRgb = [0.8, 0.8, 0.78]; -/** - * Dunkles Grau der SIA-Öffnungssymbole (dünne Diagonalen AUF dem Glas, wie in der - * 2D-Ansicht) — feine dunkle Linien auf der Scheibe (s. Symbol-Zweig in - * {@link frameMeshesForOpening}). Als sehr dünne, in der Öffnungsebene gedrehte - * Prismen ({@link openingPlaneBox}) knapp vor der Glasebene. - */ -const SYMBOL_RGB: RRgb = [0.25, 0.27, 0.3]; -/** Halbe Breite der Symbol-Diagonalen (Meter) — ~5 mm Gesamtbreite, feine Linie. */ -const SYMBOL_HALF_WIDTH = 0.0025; -/** - * Warmer, heller Türblatt-Ton — bewusst wärmer/heller als der Rahmen - * ({@link FRAME_RGB}), damit das Blatt im 3D als eigene Füllung liest - * (s. {@link doorLeafPanels}). - */ -const DOOR_LEAF_RGB: RRgb = [0.93, 0.9, 0.85]; -/** Türblatt-Dicke quer zur Wand (Meter). */ -const DOOR_LEAF_THICK = 0.04; -/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */ -const DEFAULT_FRAME_WIDTH = 0.06; -/** - * Default-Rahmentiefe (Meter), wenn ein Typ keine `frameDepth` nennt: reale - * Fensterrahmen sind ~70–90 mm tief (Holz/PVC/Alu), UNABHÄNGIG von der - * Wanddicke — ein schlanker Rahmen mit sichtbarer Laibung ringsum, nicht ein - * massiver Block über die volle Wandtiefe. Deckt sich mit - * `geometry/opening.ts::DEFAULT_FRAME_DEPTH` (2D=3D). - */ -const DEFAULT_FRAME_DEPTH = 0.07; -/** Warmer Terrakotta-Grauton für Dachflächen/-giebel (s. {@link emitRoofs}) — hebt das Dach klar von Wand/Decke/Kontext ab. */ -const ROOF_RGB: RRgb = [0.72, 0.45, 0.36]; -/** 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; - -/** - * Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB - * (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den - * neutralen Wand-Grundton zurück (nie ein hartes Fehlbild). - */ -function hexToRgb(hex: string, fallback: RRgb = WALL_RGB): RRgb { - const m = /^#?([0-9a-fA-F]{6})$/.exec(hex ?? ""); - if (!m) return fallback; - const v = parseInt(m[1], 16); - return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255]; -} - -/** - * Repräsentative Bauteilfarbe eines Schichtaufbaus: die Farbe der DICKSTEN - * Schicht (i. d. R. die tragende/dominante Schicht, z. B. Mauerwerk/Beton) — - * einfache Näherung, solange Wände/Decken als EIN Vollkörper emittiert werden - * (kein Schicht-Rendering in 3D). Fällt auf `fallback` zurück, wenn keine - * Schicht vorhanden ist oder deren Component nicht auflösbar ist. - */ -function dominantLayerColor(layers: Layer[], project: Project, fallback: RRgb): RRgb { - if (layers.length === 0) return fallback; - const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best)); - try { - return hexToRgb(getComponent(project, dominant.componentId).color, fallback); - } catch { - return fallback; - } -} - -/** - * Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke, - * Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen, - * relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer - * eigenen Voll-Box (siehe {@link emitWall}/{@link pushSegment}). - */ -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; -} - -/** - * Löst den Schichtstapel EINER Wand auf: je Materiallage ein {@link WallBand} - * mit ihrer Dicke, Component-Farbe und dem Mitten-Versatz entlang der Normalen. - * Die Versätze zentrieren den Stapel um die Wandachse; als Gesamtdicke T dient - * die SUMME der tatsächlichen Lagen-Dicken (nicht `wallTypeThickness`, falls es - * abweicht) — so schließen die Bänder lückenlos aneinander an. Kein auflösbarer - * WallType / keine Lagen ⇒ EIN Vollkörper-Band über die volle Dicke im - * neutralen Grundton (heutiges Fallback-Verhalten). - */ -function resolveWallBands(project: Project, wall: Wall, layered = true): WallBand[] { - try { - const wt = getWallType(project, wall); - if (wt.layers.length > 0) { - // Gesamtdicke = Summe der Lagen (konsistent mit den Offsets); wenn diese - // von wallTypeThickness abwiche, gewinnt die Lagen-Summe (keine Lücken). - const total = wt.layers.reduce((s, l) => s + l.thickness, 0); - // Schnitt-Eingabe (layered=false): EINE Voll-Box über die ganze Dicke statt - // je Lage eine Box. So bleibt die Emission 1:1 Wand→Rechteck — passend zu - // `wallSegmentOwners`/`splitWallLayers` in toSection.ts, die die Schicht- - // 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) { - 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 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; - } - // WallType ohne Schichten: EIN Vollkörper über die Typ-Gesamtdicke. - return [{ thickness: wallTypeThickness(wt), color: WALL_RGB, offset: 0 }]; - } catch { - // Kein auflösbarer WallType: EIN Vollkörper mit Default-Dicke/Grundton. - return [{ thickness: 0.2, color: WALL_RGB, offset: 0 }]; - } -} - -/** - * Hängt eine Wand-Schicht-Box an: das Achsenstück [from..to] (Meter ab - * Wand-Startpunkt) über den Höhenbereich [zBottom..zTop], quer um `normalOffset` - * Meter entlang der Wand-Normalen versetzt (Schichtmitte relativ zur Achse) und - * mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0) - * werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne - * Nullquader. - * - * Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe - * {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe - * Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides - * unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur - * senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur - * im 3D-Pfad (voller Achsen-Quader) gesetzt. - */ -function pushSegment( - out: RWall[], - wall: Wall, - from: number, - to: number, - zBottom: number, - zTop: number, - thickness: number, - 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; - const dx = wall.end.x - wall.start.x; - const dy = wall.end.y - wall.start.y; - const len = Math.hypot(dx, dy); - if (len < 1e-9) return; - const ux = dx / len; - const uy = dy / len; - // LINKE Grundriss-Normale (identisch zu `leftNormal(u)` = (−uy, ux) in - // generatePlan.ts/clippedBand — dieselbe Konvention, die addWallPoche für - // den Schichtstapel im 2D-Grundriss benutzt). Der Schicht-Versatz verschiebt - // Start/Ende der Box quer zur Wand, sodass render3d die Box um ihre eigene - // (versetzte) Achse zentriert aufbaut — mit dieser Normale liegt Schicht 0 - // (Aussenseite, kleinster `offset`) auf derselben Weltseite wie im Grundriss. - // Bewusst NICHT identisch zur Normale, die raycast3d/selectionHighlightLines - // für den Box-Rebuild nutzen (n = (uy, −ux), rechte Normale) — das ist - // unschädlich, weil jene Boxen symmetrisch um die (hier bereits versetzte) - // Achse jedes einzelnen Layer-Records aufgebaut werden und daher unabhängig - // von der Vorzeichen-Konvention des Versatzes sind. - const ox = -uy * normalOffset; - const oy = ux * normalOffset; - const p1: RVec2 = [wall.start.x + ux * from + ox, wall.start.y + uy * from + oy]; - const p2: RVec2 = [wall.start.x + ux * to + ox, wall.start.y + uy * to + oy]; - out.push({ - start: p1, - end: p2, - thickness, - height: zTop - zBottom, - baseElevation: zBottom, - color, - layers: [], - wallId, - ...(hatch ? { hatch } : {}), - ...(materialIndex != null ? { materialIndex } : {}), - ...(holes && holes.length > 0 ? { holes } : {}), - ...(cut ? { cut } : {}), - }); -} - -/** - * Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY. - * Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s. - * {@link trimWallTopForCeilings}) — kein exakter Polygon-Test, siehe dort. - */ -interface Bounds2 { - minX: number; - maxX: number; - minY: number; - maxY: number; -} - -/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet - * (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von - * dessen Orientierung). */ -function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 { - return { - minX: Math.min(wall.start.x, wall.end.x) - halfThickness, - maxX: Math.max(wall.start.x, wall.end.x) + halfThickness, - minY: Math.min(wall.start.y, wall.end.y) - halfThickness, - maxY: Math.max(wall.start.y, wall.end.y) + halfThickness, - }; -} - -/** Bounding-Box eines geschlossenen Umrisses (Decke). */ -function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 { - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - for (const p of outline) { - if (p.x < minX) minX = p.x; - if (p.x > maxX) maxX = p.x; - if (p.y < minY) minY = p.y; - if (p.y > maxY) maxY = p.y; - } - return { minX, maxX, minY, maxY }; -} - -/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */ -function boundsOverlap(a: Bounds2, b: Bounds2): boolean { - return ( - a.minX <= b.maxX + EPS && - a.maxX >= b.minX - EPS && - a.minY <= b.maxY + EPS && - a.maxY >= b.minY - EPS - ); -} - -/** - * Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}): - * Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus - * {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist. - */ -function wallThicknessSafe(project: Project, wall: Wall): number { - try { - return wallTypeThickness(getWallType(project, wall)); - } catch { - return 0.2; - } -} - -/** - * 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 { - const ct = getCeilingType(project, ceiling); - let best: number | undefined; - for (const layer of ct.layers) { - const p = getComponent(project, layer.componentId).joinPriority; - if (best === undefined || p > best) best = p; - } - return best; - } catch { - return undefined; - } -} - -/** - * 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. - */ -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, -): CeilingCutter[] { - const halfThickness = wallThicknessSafe(project, wall) / 2; - const wallBounds = wallFootprintBounds(wall, halfThickness); - 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); - if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue; - if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue; - - // 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 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); -} - -/** Schrittweite (Meter) der Dach-Unterkanten-Annäherung, s. {@link clipPieceToRoofs}. */ -const ROOF_CLIP_STEP = 0.15; - -/** - * Kappt ein Wand-Achsenstück [a,bb] (z-Bereich [zb,zt]) an der Dach-UNTERKANTE - * (falls ein Dach darüber liegt) und emittiert es via `emit` — in FEINE Achsen- - * Schritte (~{@link ROOF_CLIP_STEP}) zerlegt, je Schritt auf die dort gemessene - * Dach-Unterkante geklemmt. Das ist eine TREPPENSTUFEN-Annäherung an eine echte - * geneigte Giebelwand-Stirnfläche (render3d-Wandkörper (`RWall`) haben nur einen - * FLACHEN Top — echte Schrägflächen bräuchten einen neuen Mesh-Pfad); bei der - * gewählten Schrittweite optisch glatt genug UND behebt das eigentliche Problem - * (Wand durchdringt die Dachfläche → Z-Fighting, s. PENDENZEN.md „Dach-Wand- - * Verschneidung"), ohne render3d/Rust anzufassen. - * - * Ohne Dächer (oder keinem, das dieses Wandstück überdeckt) läuft das Stück - * UNVERÄNDERT als EIN Body durch (kein Overhead im Normalfall). - */ -function clipPieceToRoofs( - project: Project, - wall: Wall, - u: Vec2, - n: Vec2, - bandOffset: number, - a: number, - bb: number, - zb: number, - zt: number, - emit: (a: number, bb: number, zb: number, zt: number) => void, -): void { - if (zt - zb <= EPS || bb - a <= EPS) return; - const wallBounds = wallFootprintBounds(wall, Math.abs(bandOffset) + 0.01); - const roofs = (project.roofs ?? []).filter((r) => { - if (r.floorId !== wall.floorId) return false; - const eavesZ = roofBaseElevation(project, r); - // Grober Höhen-Ausschluss: Dach-Traufe weit unter dem Wandstück ⇒ kann nicht - // schneiden (übliche Dächer haben keine nach unten ausufernden Übermasse). - return eavesZ + 50 >= zb; // grosszügige Schranke statt exakter First-Höhe - }); - if (roofs.length === 0) { - emit(a, bb, zb, zt); - return; - } - const roofData = roofs - .map((r) => { - const eavesZ = roofBaseElevation(project, r); - const geo = roofGeometry(r, eavesZ); - if (!boundsOverlap(wallBounds, outlineBounds(geo.eaves))) return null; - const total = roofLayers(project, r).reduce((s, l) => s + Math.max(0, l.thickness), 0); - return { geo, total }; - }) - .filter((d): d is { geo: RoofGeometry; total: number } => d !== null); - if (roofData.length === 0) { - emit(a, bb, zb, zt); - return; - } - - const span = bb - a; - const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP)); - const stepLen = span / steps; - // Je Stufe die geklemmte Oberkante bestimmen … - const tops: number[] = []; - for (let i = 0; i < steps; i++) { - const mid = a + (i + 0.5) * stepLen; - const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset)); - let clip = zt; - for (const { geo, total } of roofData) { - const under = roofUndersideAt(geo, total, p.x, p.y); - if (under !== null && under < clip) clip = under; - } - tops.push(Math.max(zb, clip)); - } - // … und AUFEINANDERFOLGENDE Stufen gleicher Oberkante zu EINEM Body verschmelzen. - // Wo das Dach das Stück gar nicht klippt (Oberkante == zt über die ganze Länge, - // z. B. nahe dem First oder wenn kein Dach wirklich überdeckt), entsteht so wieder - // EIN durchgehender Body statt vieler identischer 0.15-m-Fragmente — nur der echt - // geneigte Bereich bleibt gestuft. Das entlastet auch den Live-Schnitt, der genau - // diese Wandkörper cuttet (weniger, saubere Schnitt-Kappen). - let runStart = 0; - for (let i = 1; i <= steps; i++) { - if (i < steps && Math.abs(tops[i] - tops[runStart]) <= EPS) continue; - const sa = a + runStart * stepLen; - const sb = a + i * stepLen; - const zte = tops[runStart]; - if (zte - zb > EPS) emit(sa, sb, zb, zte); - runStart = i; - } -} - -/** - * Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen). - * - * ZWEI PFADE je `layered`: - * • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die - * VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in - * die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in - * EINEM Körper (keine Segment-Nähte). - * • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/ - * Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die - * `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners` - * passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher - * werden hier NICHT emittiert. - */ -function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void { - const bands = resolveWallBands(project, wall, layered); - 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; - - // Ein Achsen-Teilquader × jede Materiallage = eine Box (Schichtstapel quer - // zur Wanddicke). Alle tragen die Wand-Id, sodass ein Klick auf irgendeine - // 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, b.hatch, b.materialIndex, undefined, bandCutMeta(b)); - } - }; - - // Aussparungen der Wand als vereinheitlichte Cutouts sammeln: - // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz), - // • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz). - // Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung. - const cutouts: Array<{ - from: number; - to: number; - oBottom: number; - oTop: number; - /** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster - * mit `WindowType.innerLayerRecess`), s. {@link emitWall}-Moduldoc unten. */ - recessInnerLayer: boolean; - }> = []; - for (const op of openingsOfWall(project, wall.id)) { - const iv = openingInterval(wall, op); - if (!iv) continue; - const v = openingVerticalExtent(project, wall, op); - const recessInnerLayer = - op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true; - cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer }); - } - for (const d of project.doors ?? []) { - if (d.hostWallId !== wall.id) continue; - const from = Math.max(0, Math.min(d.position, axisLen)); - const to = Math.max(from, Math.min(d.position + d.width, axisLen)); - if (to - from < EPS) continue; - // Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe. - const oTop = Math.min(zTop, zBottom + d.height); - cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false }); - } - 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 (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 & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({ - from: c.from, - to: c.to, - zBottom: c.oBottom, - zTop: c.oTop, - recessInnerLayer: c.recessInnerLayer, - })); - 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). An der INNERSTEN - // Schicht (li = letztes Band) weicht das Loch bei Fenstern mit - // `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück - // (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig. - const isInnermostBand = li === bands.length - 1; - const recessDepth = b.thickness; - 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) => { - const widen = isInnermostBand && h.recessInnerLayer; - return widen - ? { - from: h.from - a - recessDepth, - to: h.to - a + recessDepth, - zBottom: h.zBottom - recessDepth, - zTop: h.zTop + recessDepth, - } - : { 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) — ausser ein Dach kappt den Top (s. {@link clipPieceToRoofs}). - clipPieceToRoofs(project, wall, u, n, b.offset, a, bb, zBottom, zTop, emitPiece); - 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)) { - clipPieceToRoofs(project, wall, u, n, b.offset, xa, xb, zb, zt, emitPiece); - } - } - } - } - return; - } - - // ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz- - // Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners). - // Ohne Aussparungen: ein durchgehender Quader je Schicht. - if (cutouts.length === 0) { - emitSeg(0, axisLen, zBottom, zTop); - return; - } - - // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen, - // Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor` - // zusammengefasst. - let cursor = 0; - for (const { from, to, oBottom, oTop } of cutouts) { - const segFrom = Math.max(cursor, from); - if (from > cursor) { - // Voller Wandpfeiler bis zur Aussparung. - emitSeg(cursor, from, zBottom, zTop); - } - if (to > segFrom) { - // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom). - if (oBottom > zBottom + EPS) { - emitSeg(segFrom, to, zBottom, oBottom); - } - // Sturz über der Öffnung (bis zum Wandkopf). - if (oTop < zTop - EPS) { - emitSeg(segFrom, to, oTop, zTop); - } - } - cursor = Math.max(cursor, to); - } - // Restlicher Wandpfeiler bis zum Achsenende. - if (cursor < axisLen) { - emitSeg(cursor, axisLen, zBottom, zTop); - } -} - -/** 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[] = []; - for (const c of project.ceilings ?? []) { - if (!c.outline || c.outline.length < 3) continue; - // zBottom/zTop (und damit die Deckendicke) berechnet ceilingVerticalExtent - // bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung). - const { zBottom, zTop } = ceilingVerticalExtent(project, c); - if (zTop - zBottom <= EPS) continue; - // Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN - // einfachen Umriss-Ring einfügen — derselbe Ring für ALLE Schichten dieser - // Decke. Kein `openings` ⇒ unverändertes `c.outline` (exakt heutiges - // Verhalten). Der Ring ist paritätskorrekt fürs Ear-Clipping (render3d) - // UND fürs Pick (pickGeometry nutzt denselben emitSlabs-Pfad): ein Klick - // ins Loch trifft die Decke NICHT — das ist gewollt. - const openings = c.openings ?? []; - const mergedOutline = openings.length ? mergeHoles(c.outline, openings).ring : c.outline; - const outline = mergedOutline.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 { - 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 = 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; -} - -/** - * Trianguliert ein KONVEXES 3D-Polygon per Dreiecks-Fächer ab Vertex 0 und hängt - * die Dreiecke an `positions`/`indices` an. `roofGeometry` (Vertrag in - * `geometry/roof.ts`) liefert für `planes`/`gables` ausschliesslich konvexe - * Polygone (Dreieck/Quader bei den meisten Formen, ein Fünfeck-Giebel bei - * Mansarde) — ein Fächer trianguliert diese korrekt, ohne Ear-Clipping. - */ -function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[]): void { - if (pts.length < 3) return; - const base = positions.length / 3; - for (const p of pts) positions.push(p[0], p[1], p[2]); - for (let i = 1; i < pts.length - 1; i++) indices.push(base, base + i, base + i + 1); -} - -/** - * Emittiert Dächer (`project.roofs`) als geschlossene Dreiecks-Meshes: sowohl die - * geneigten Dachflächen (`RoofGeometry.planes`) als auch die Giebelflächen - * (`RoofGeometry.gables`) — erst zusammen bilden sie einen von aussen - * geschlossenen Körper (die Giebel schliessen die offenen Enden bei Sattel/ - * Mansarde, die First-/Grat-/Knicklinien sind reine Grundriss-Hilfslinien und - * werden hier nicht gebraucht). Traufhöhe über {@link roofBaseElevation} - * (Override oder Geschoss-Oberkante), Flächen über {@link roofGeometry}. Farbe: - * `roof.color` (falls gültiges Hex), sonst {@link ROOF_RGB}. `positions` in - * MODELL-Metern (x, y, z=Höhe) wie {@link emitOpeningGlass}/{@link emitMeshes}; - * render3d rendert Kontext-Meshes doppelseitig, die Winding-Reihenfolge ist also - * unerheblich. - */ -/** - * Robuste Flächennormale (Newell) einer Polygon-Ebene in MODELL-Koordinaten - * ([x, y, z=Höhe]), nach OBEN orientiert (positive Höhen-Komponente) — für die - * Dach-Dickenversetzung (Unterseite = Oberseite − Normale·Dicke). - */ -function planeNormalUp(pts: RoofVec3[]): [number, number, number] { - let nx = 0; - let ny = 0; - let nz = 0; - for (let i = 0; i < pts.length; i++) { - const c = pts[i]; - const d = pts[(i + 1) % pts.length]; - nx += (c[1] - d[1]) * (c[2] + d[2]); - ny += (c[2] - d[2]) * (c[0] + d[0]); - nz += (c[0] - d[0]) * (c[1] + d[1]); - } - const len = Math.hypot(nx, ny, nz) || 1; - const u: [number, number, number] = [nx / len, ny / len, nz / len]; - return u[2] < 0 ? [-u[0], -u[1], -u[2]] : u; -} - -/** - * Eine Dachfläche als SOLIDER Slab (Oberseite + um `thickness` entlang der - * Normalen nach unten versetzte Unterseite + umlaufende Seitenwände) — gibt dem - * Dach eine echte Materialdicke statt einer papierdünnen Fläche. render3d - * rendert doppelseitig, Winding ist unerheblich. - */ -function pushRoofSlab( - positions: number[], - indices: number[], - pts: RoofVec3[], - thickness: number, -): void { - if (pts.length < 3 || thickness <= EPS) { - pushFanTriangles(positions, indices, pts); - return; - } - const nrm = planeNormalUp(pts); - const bottom: RoofVec3[] = pts.map((p) => [ - p[0] - nrm[0] * thickness, - p[1] - nrm[1] * thickness, - p[2] - nrm[2] * thickness, - ]); - pushFanTriangles(positions, indices, pts); // Oberseite - pushFanTriangles(positions, indices, bottom); // Unterseite - // Seitenwände: je Kante ein Quad (zwei Dreiecke) zwischen Ober- und Unterseite. - for (let i = 0; i < pts.length; i++) { - const j = (i + 1) % pts.length; - const base = positions.length / 3; - positions.push(pts[i][0], pts[i][1], pts[i][2]); - positions.push(pts[j][0], pts[j][1], pts[j][2]); - positions.push(bottom[j][0], bottom[j][1], bottom[j][2]); - positions.push(bottom[i][0], bottom[i][1], bottom[i][2]); - indices.push(base, base + 1, base + 2, base, base + 2, base + 3); - } -} - -function emitRoofs(project: Project): RMesh[] { - const out: RMesh[] = []; - for (const roof of project.roofs ?? []) { - const eavesZ = roofBaseElevation(project, roof); - const g = roofGeometry(roof, eavesZ); - const overrideColor = roof.color ? hexToRgb(roof.color, ROOF_RGB) : null; - // Schichtaufbau (aussen/Eindeckung → innen). Jede Schicht wird zu einem - // eigenen RMesh mit ihrer Bauteilfarbe, entlang der Flächennormalen unter die - // vorige gestapelt. Leere componentId (einschichtige thickness-Fallback) ⇒ - // Default-Dachfarbe. Eine Roof.color-Übersteuerung gilt für alle Schichten. - const layers = roofLayers(project, roof); - layers.reduce((offsetAbove, layer) => { - const t = Math.max(0, layer.thickness); - if (t <= EPS) return offsetAbove; - const color = - overrideColor ?? - (layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB); - // Material-Textur-Ebene fürs Textured-Rendering (z. B. echte Dachziegel- - // Textur der Eindeckung) — nur wenn KEIN Farb-Override greift (Override - // = bewusst eine einheitliche Farbe für alle Schichten, kein Bauteil-Bezug). - const materialIndex = - !overrideColor && layer.componentId - ? resolveComponentMaterialLayer(project, layer.componentId) - : undefined; - const positions: number[] = []; - const indices: number[] = []; - for (const plane of g.planes) { - // Oberseite dieser Schicht = Dachfläche um `offsetAbove` entlang der - // Normalen nach innen versetzt; Slab-Dicke = Schichtdicke. - const nrm = planeNormalUp(plane.pts); - const top: RoofVec3[] = plane.pts.map((p) => [ - p[0] - nrm[0] * offsetAbove, - p[1] - nrm[1] * offsetAbove, - p[2] - nrm[2] * offsetAbove, - ]); - pushRoofSlab(positions, indices, top, t); - } - if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex }); - return offsetAbove + t; - }, 0); - // Giebel-ENDFÜLLUNG bewusst NICHT mehr emittieren: die dreieckige Giebelfläche - // wurde früher als papierdünne Einzelfläche gebaut ("komischer Abschluss" an - // den Sattel-/Mansard-Enden) und lag zudem deckungsgleich vor der Giebelwand, - // die ohnehin bis an die Dach-Unterkante hochgeklemmt wird (s. - // {@link clipPieceToRoofs}) — also doppelt und optisch als dünne 2D-Schicht - // sichtbar. Es wird nur noch das Dach SELBST (die geneigten Slab-Flächen) - // konstruiert; der Giebel entsteht aus der Wand darunter. - } - return out; -} - /** * Emittiert die rohen Kontext-Meshes eines Projekts: Gelände-TINs (`terrainMesh`) - * und importierte Volumen (`importedMesh`) aus `project.context`. `contourSet` - * bleibt außen vor — das sind nur 2D-Linien (Höhenlinien/Footprints), keine - * Dreiecksmeshes. Die `positions`/`indices` werden 1:1 durchgereicht (Modell- - * Koordinaten, x,y,z=Höhe); render3d bildet sie selbst nach world ab. + * und importierte Volumen (`importedMesh`) aus `project.context`, plus alle + * übrigen Kontext-Mesh-Domänen (truck-Extrusionen, Stützen, 2D-Boden-Spuren). + * `contourSet` bleibt außen vor — das sind nur 2D-Linien (Höhenlinien/ + * Footprints), keine Dreiecksmeshes. Die `positions`/`indices` der Geo-Meshes + * werden 1:1 durchgereicht (Modell-Koordinaten, x,y,z=Höhe); render3d bildet + * sie selbst nach world ab. */ function emitMeshes(project: Project): RMesh[] { const out: RMesh[] = []; @@ -1991,839 +120,6 @@ function emitMeshes(project: Project): RMesh[] { return out; } -// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits: bit0 = -// Achse from/to, bit1 = Höhe UK/OK, bit2 = Quer-Versatz nMin/nMax). Doppelseitig -// gerendert (append_context_mesh) ⇒ Winding egal; je Fläche 2 Dreiecke, 6 -// Flächen = 12 Dreiecke. Gemeinsame Topologie für Glasscheiben UND Rahmen-/ -// Sprossen-Riegel (s. {@link openingAxisBox}). -const OPENING_BOX_TRIS: number[] = [ - 0, 1, 3, 0, 3, 2, // Quer-Versatz nMin - 4, 5, 7, 4, 7, 6, // Quer-Versatz nMax - 0, 1, 5, 0, 5, 4, // UK - 2, 3, 7, 2, 7, 6, // OK - 0, 2, 6, 0, 6, 4, // from - 1, 3, 7, 1, 7, 5, // to -]; - -/** - * Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine - * Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und - * {@link emitOpeningFrames} (Rahmen-/Sprossen-Riegel). Achsen-Intervall - * `[from,to]` (Meter ab `wall.start`, entlang der Wandrichtung u), Höhen- - * Intervall `[zBottom,zTop]` (absolute Weltmeter) und Quer-Versatz-Intervall - * `[nMin,nMax]` (Meter entlang der Grundriss-Normalen n=(uy,−ux), dieselbe - * Konvention wie die bisherige Glas-Box — `nMin`/`nMax` MÜSSEN nicht sortiert - * übergeben werden, die Box spannt zwischen beiden Werten). `positions` in - * MODELL-Metern (x, y, z=Höhe) wie {@link emitMeshes}. Liefert `null` bei - * entarteten Massen (Achse/Höhe/Dicke ≤ EPS) statt eines Nullquaders. - */ -function openingAxisBox( - wall: Wall, - from: number, - to: number, - zBottom: number, - zTop: number, - nMin: number, - nMax: number, - color: RRgb, -): RMesh | null { - if (to - from <= EPS) return null; - if (zTop - zBottom <= EPS) return null; - if (Math.abs(nMax - nMin) <= EPS) return null; - const dx = wall.end.x - wall.start.x; - const dy = wall.end.y - wall.start.y; - const len = Math.hypot(dx, dy); - if (len < 1e-9) return null; - const ux = dx / len; - const uy = dy / len; - // Grundriss-Normale (Dicken-Achse) — RECHTE Normale (uy,−ux), bewusst NICHT - // leftNormal (anders als pushSegment/windowSymbol) — kombiniert mit dem - // (ebenfalls gespiegelten) Vorzeichen in resolveFrameNormalRange ergibt sich - // daraus physisch die korrekte aussen/innen-Seite (s. dortigen Kommentar). - const nx = uy; - const ny = -ux; - const positions: number[] = []; - for (let i = 0; i < 8; i++) { - const s = i & 1 ? to : from; - const z = i & 2 ? zTop : zBottom; - const t = i & 4 ? nMax : nMin; - positions.push(wall.start.x + ux * s + nx * t, wall.start.y + uy * s + ny * t, z); - } - return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color }; -} - -/** - * Baut EIN sehr dünnes Prisma mit FREI orientierter Längsachse IN der Öffnungs- - * ebene — die diagonale Schwester von {@link openingAxisBox}. Die Längsachse - * verläuft vom Punkt `(u0,h0)` zum Punkt `(u1,h1)`, beide in Öffnungsebenen- - * koordinaten: `u` = Meter entlang der Wandrichtung (ab `wall.start`), `h` = - * absolute Welthöhe (Meter). Der Querschnitt spannt `±halfWidth` senkrecht zur - * Längsachse INNERHALB der Ebene (gedreht mit) und `[nBack,nFront]` entlang der - * Grundriss-Normalen (quer zur Wand, Tiefe). Damit lassen sich die schrägen - * SIA-Öffnungssymbole (Dreh-/Kipp-Diagonalen) als dünne, gedrehte dunkle Riegel - * AUF dem Glas erzeugen — der Renderer kennt nur Dreiecks-Meshes, keine Linien. - * `null` bei entarteter Länge (≤ EPS). 8 Ecken, dieselbe {@link OPENING_BOX_TRIS}- - * Topologie (doppelseitig gerendert ⇒ Winding egal; die drei Bits parametrisieren - * hier Endpunkt/Quer-Versatz/Tiefe). - */ -function openingPlaneBox( - wall: Wall, - u0: number, - h0: number, - u1: number, - h1: number, - halfWidth: number, - nFront: number, - nBack: number, - color: RRgb, -): RMesh | null { - const du = u1 - u0; - const dh = h1 - h0; - const segLen = Math.hypot(du, dh); - if (segLen < EPS) return null; - const dx = wall.end.x - wall.start.x; - const dy = wall.end.y - wall.start.y; - const wlen = Math.hypot(dx, dy); - if (wlen < 1e-9) return null; - const ux = dx / wlen; - const uy = dy / wlen; - // Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie openingAxisBox. - const nx = uy; - const ny = -ux; - // In der Ebene senkrecht zur Längsachse (u entlang Wand, h = Welthöhe): - // Rotation der normierten Richtung um 90°. `pu` wirkt auf die u-Achse (Wand), - // `ph` auf die Höhe — beide in Metern, orthonormal ⇒ echte `halfWidth`-Breite. - const pu = -dh / segLen; - const ph = du / segLen; - const positions: number[] = []; - for (let i = 0; i < 8; i++) { - const ui = i & 1 ? u1 : u0; - const hi = i & 1 ? h1 : h0; - const s = i & 2 ? 1 : -1; - const t = i & 4 ? nFront : nBack; - const u = ui + s * halfWidth * pu; - const h = hi + s * halfWidth * ph; - positions.push(wall.start.x + ux * u + nx * t, wall.start.y + uy * u + ny * t, h); - } - return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color }; -} - -/** Klemmt `v` auf `[lo,hi]`. */ -function clampRange(v: number, lo: number, hi: number): number { - return Math.max(lo, Math.min(hi, v)); -} - -/** - * Rahmen-Tiefe quer zur Wand (Meter): explizite `frameDepth` des Typs, sonst - * {@link DEFAULT_FRAME_DEPTH} (auf die Wanddicke geklemmt, falls die Wand - * dünner als der Default-Rahmen ist). - */ -function resolveAcrossWallDepth( - frameDepth: number | undefined, - wallThickness: number, -): number { - const depth = frameDepth !== undefined && frameDepth > EPS ? frameDepth : DEFAULT_FRAME_DEPTH; - return Math.min(depth, wallThickness); -} - -/** - * Quer-Versatz-Intervall `[nMin,nMax]` (Meter entlang der Wand-Normalen, s. - * {@link openingAxisBox}) des Rahmenkörpers — der 3D-Niederschlag des - * Schichteinzugs (`insetFromFace`/`insetFace`): die Rahmen-VORDERKANTE liegt - * bündig mit der gewählten Wandfläche, zurückversetzt um `insetFromFace` - * (0/fehlt ⇒ bündig), der Rahmenkörper erstreckt sich von dort aus `depth` - * Meter zur Wandmitte hin. "aussen" liegt an der POSITIVEN Normalen-Seite - * (kleinster Schicht-Offset in {@link pushSegment}, s. dessen Kommentar zur - * Aussenseiten-Konvention via `leftNormal`), "innen" an der negativen. Das - * Ergebnis wird auf die halbe Wanddicke geklemmt — ein Schichteinzug darf den - * Rahmen nie aus der Wand hinausschieben. - */ -function resolveFrameNormalRange( - halfThickness: number, - depth: number, - insetFromFace: number, - face: "aussen" | "innen", -): { nMin: number; nMax: number } { - const sign = face === "innen" ? -1 : 1; - const front = sign * (halfThickness - insetFromFace); - const back = front - sign * depth; - const a = clampRange(front, -halfThickness, halfThickness); - const b = clampRange(back, -halfThickness, halfThickness); - return { nMin: Math.min(a, b), nMax: Math.max(a, b) }; -} - -/** - * Aufgelöste Rahmen-/Sprossen-Parameter EINER Öffnung (Tür oder Fenster) aus - * ihrem {@link DoorType}/{@link WindowType} (via `getDoorType`/`getWindowType`). - * `null`, wenn kein Typ auflösbar ist (`Opening.typeId` fehlt/verwaist) — DANN - * emittieren weder {@link emitOpeningFrames} noch die Rahmen-/Sprossen-Logik in - * {@link emitOpeningGlass} irgendetwas (Rückwärtskompatibilität: unveränderte - * Alt-Darstellung, s. Moduldoc-Kommentar dort). - */ -interface OpeningFrameParams { - /** Sichtbare Rahmen-/Flügelprofil-Breite in der Öffnungsebene (Meter). */ - frameWidth: number; - /** Rahmen-Tiefe quer zur Wand (Meter, informativ — steckt bereits in nMin/nMax). */ - acrossWallDepth: number; - /** Quer-Versatz-Intervall des Rahmenkörpers (s. {@link resolveFrameNormalRange}). */ - nMin: number; - nMax: number; - /** Oberlicht-Höhe (Meter); 0 = kein Oberlicht/Kämpfer. */ - transomHeight: number; - /** Schwelle/Sohlbank zeichnen: Fenster immer, Tür nur mit `threshold`. */ - hasSill: boolean; - /** Flügelzahl (vertikale Mittelpfosten = wingCount−1); Türen immer 1. */ - wingCount: number; - /** Kämpfer-Zeilen (horizontale Teilung = mullionRows−1); Türen immer 1. */ - mullionRows: number; - /** Sprossen-Spalten (vertikale Teilung = mullionCols−1); Türen immer 1. */ - mullionCols: number; - /** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */ - glazed: boolean; - /** - * Verglasungs-Scheibenzahl (1–3), render-wirksam ({@link glazingPanesOf}). - * Türen: immer 1 (keine typisierte Mehrfachverglasung). Steuert die Anzahl - * dünner, in Wandrichtung leicht versetzter Glasflächen in - * {@link glassPanesForOpening}. - */ - glazingPanes: 1 | 2 | 3; - /** - * Verglasungs-Anteil des HAUPTFELDS (0..1, von OBEN gemessen): 1 = voll - * verglast (Fenster, Ganzglastür), < 1 = nur der obere Anteil ist Glas, unten - * bleibt ein blindes Feld (z. B. Tür mit Oberglas, `DoorType.glazingRatio`). - * Fenster: immer 1. - */ - glazedFraction: number; - /** - * Flügeleinteilung ({@link sashesOfWindowType}) — je ÖFFENBAREM Flügel wird im - * 3D ein Flügelrahmen (Rahmen-Riegel innerhalb des Blendrahmens) erzeugt - * (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer. - */ - sashes: SashDef[]; - /** Halbe Wanddicke (Meter) — Bezug der Wand-Aussenfläche (+n) für Bank/Zargen-Umgriff. */ - halfThickness: number; - /** - * Fensterbank (Sims) zeichnen: nur Fenster mit `sillBoard` ≠ "keine"/"innen" - * (fehlt ⇒ zeichnen, wie in der 2D-Ansicht). Türen: nie. - */ - drawSill: boolean; - /** - * Zargen-Umgriff (Bekleidung VOR beiden Wandflächen) zeichnen: nur Türen mit - * `frameKind === "zarge"`. Blockrahmen bleibt bündig (false). - */ - zargeCladding: boolean; -} - -function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null { - const wallThickness = wallThicknessSafe(project, wall); - const halfThickness = wallThickness / 2; - if (op.kind === "door") { - const dt = getDoorType(project, op); - if (!dt) return null; - const acrossWallDepth = resolveAcrossWallDepth(dt.frameDepth, wallThickness); - const { nMin, nMax } = resolveFrameNormalRange( - halfThickness, - acrossWallDepth, - dt.insetFromFace ?? 0, - dt.insetFace ?? "aussen", - ); - return { - frameWidth: dt.frameWidth ?? DEFAULT_FRAME_WIDTH, - acrossWallDepth, - nMin, - nMax, - transomHeight: Math.max(0, dt.transomHeight ?? 0), - hasSill: dt.threshold === true, - // Zweiflüglige Tür ⇒ 2 Flügel = ein mittiger Anschlagpfosten (Stulp) im 3D. - wingCount: Math.max(1, Math.round(dt.leafCount ?? 1)), - mullionRows: 1, - mullionCols: 1, - glazed: dt.leafStyle === "glas", - glazingPanes: 1, - // Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll. - glazedFraction: Math.max(0.05, Math.min(1, dt.glazingRatio ?? 1)), - sashes: [], - halfThickness, - drawSill: false, - // Zarge umgreift die Wand (Bekleidung auf beiden Flächen); Blockrahmen bleibt bündig. - zargeCladding: (dt.frameKind ?? "zarge") === "zarge", - }; - } - const wt = getWindowType(project, op); - if (!wt) return null; - const acrossWallDepth = resolveAcrossWallDepth(wt.frameDepth, wallThickness); - const { nMin, nMax } = resolveFrameNormalRange( - halfThickness, - acrossWallDepth, - wt.insetFromFace ?? 0, - wt.insetFace ?? "aussen", - ); - // Flügeltabelle: explizite `wt.sashes` gewinnen; sonst gleichmäßig aus dem - // effektiven Flügelzahl-Wert (Instanz-Override `op.wingCount` vor Typ-Default). - // wingCount wird DARAUS abgeleitet, damit Mittelpfosten (wingCount−1) und - // Flügelrahmen (je Flügel) konsistent bleiben. - let sashes = wt.sashes && wt.sashes.length > 0 ? wt.sashes : undefined; - if (!sashes) { - const n = Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)); - const opening = wt.kind === "fest" ? "fest" : wt.kind; - sashes = Array.from({ length: n }, (_, i) => ({ - kind: "fluegel" as const, - autoWidth: true, - opening, - hingeSide: (i % 2 === 0 ? "left" : "right") as "left" | "right", - })); - } - const wingCount = Math.max(1, sashes.filter((s) => s.kind === "fluegel").length); - return { - frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH, - acrossWallDepth, - nMin, - nMax, - transomHeight: Math.max(0, wt.transomHeight ?? 0), - hasSill: true, - wingCount, - mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)), - mullionCols: Math.max(1, Math.round(wt.mullionCols ?? 1)), - glazed: true, - glazingPanes: glazingPanesOf(wt), - glazedFraction: 1, - sashes, - halfThickness, - // Fensterbank zeichnen, ausser explizit "keine"/"innen" (fehlt ⇒ zeichnen). - drawSill: wt.sillBoard !== "keine" && wt.sillBoard !== "innen", - zargeCladding: false, - }; -} - -/** - * Rahmen-/Sprossen-Riegel EINER Öffnung: Pfosten (links/rechts, volle - * Öffnungshöhe), Sturz (oben), Schwelle (nur wenn `params.hasSill`), optional - * ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die - * Flügel-Mittelpfosten (`wingCount−1`, vertikal) und Kämpfer-Zeilen - * (`mullionRows−1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz - * bzw. Schwelle/Kämpfer. Alle Rahmen-Riegel in {@link FRAME_RGB}. - * - * DetailLevel-Staffelung (VW-Detailgrad „fein" als Referenz): - * • „grob" — wird gar nicht aufgerufen (s. {@link emitOpeningFrames}). - * • „mittel"— Blendrahmen + Oberlicht-Kämpfer + Flügelrahmen (verschachtelt) + - * Fensterbank/Zargen-Umgriff, aber KEINE Sprossen und KEINE SIA-Symbole. - * • „fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) und - * die SIA-Öffnungssymbole ({@link openingPlaneBox}) auf dem Glas. - */ -function frameMeshesForOpening( - wall: Wall, - iv: { from: number; to: number }, - vert: { zBottom: number; zTop: number }, - params: OpeningFrameParams, - detail: DetailLevel = "fein", -): RMesh[] { - const out: RMesh[] = []; - const fine = detail === "fein"; - const medium = detail === "mittel"; - const sashesOn = fine || medium; // Flügelrahmen: mittel + fein - const detailBodies = fine || medium; // Bank + Zargen-Umgriff: mittel + fein - const fw = params.frameWidth; - // Generischer Riegel mit frei wählbarem Quer-Band [nA,nB] und Farbe. - const pushC = ( - from: number, - to: number, - zBottom: number, - zTop: number, - nA: number, - nB: number, - color: RRgb, - ): void => { - const mesh = openingAxisBox(wall, from, to, zBottom, zTop, nA, nB, color); - if (mesh) out.push(mesh); - }; - // Blendrahmen-Riegel (volles Rahmen-Quer-Band nMin..nMax, Rahmenfarbe). - const push = (from: number, to: number, zBottom: number, zTop: number): void => - pushC(from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB); - // Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend. - push(iv.from, iv.from + fw, vert.zBottom, vert.zTop); - push(iv.to - fw, iv.to, vert.zBottom, vert.zTop); - // Sturz (oberer Riegel). - push(iv.from + fw, iv.to - fw, vert.zTop - fw, vert.zTop); - // Schwelle: Fenster immer, Tür nur mit Bodenanschlag (threshold). - if (params.hasSill) push(iv.from + fw, iv.to - fw, vert.zBottom, vert.zBottom + fw); - - // Flügel-/Sash-Bereich zwischen Schwelle und Sturz, ggf. durch den Kämpfer - // (Oberlicht-Trennung) oben beschnitten. - const sashFrom = iv.from + fw; - const sashTo = iv.to - fw; - const sashBottom = vert.zBottom + (params.hasSill ? fw : 0); - let sashTop = vert.zTop - fw; - if (params.transomHeight > EPS) { - const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTop); - push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel - sashTop = splitZ - fw / 2; - } - // Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) nur bei FEINEM Detailgrad — - // bei „mittel" bleibt es beim reinen Rahmen (+ Oberlicht-Kämpfer oben). - if (fine) { - // Flügel-Mittelpfosten (vertikal, wingCount−1) im Sash-Bereich. - if (params.wingCount > 1 && sashTo - sashFrom > EPS) { - const cell = (sashTo - sashFrom) / params.wingCount; - for (let i = 1; i < params.wingCount; i++) { - const center = sashFrom + i * cell; - push(center - fw / 2, center + fw / 2, sashBottom, sashTop); - } - } - // Kämpfer-Zeilen (horizontal, mullionRows−1) im Sash-Bereich. - if (params.mullionRows > 1 && sashTop - sashBottom > EPS) { - const cell = (sashTop - sashBottom) / params.mullionRows; - for (let i = 1; i < params.mullionRows; i++) { - const center = sashBottom + i * cell; - push(sashFrom, sashTo, center - fw / 2, center + fw / 2); - } - } - - // Sprossen-Spalten (vertikal, mullionCols−1) im Sash-Bereich. - if (params.mullionCols > 1 && sashTo - sashFrom > EPS) { - const cell = (sashTo - sashFrom) / params.mullionCols; - for (let i = 1; i < params.mullionCols; i++) { - const center = sashFrom + i * cell; - push(center - fw / 2, center + fw / 2, sashBottom, sashTop); - } - } - } - // Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/ - // rechts/oben/unten), der DIREKT am Blendrahmen ansetzt (kein Spalt — sonst - // schiene die Vollfeld-Glasscheibe als Streifen zwischen Blend- und Flügel- - // rahmen durch). Die Schachtelung Blendrahmen → Flügelrahmen → Glas liest sich - // über die Tiefe: der Flügelrahmen sitzt in einem EIGENEN, leicht ZURÜCK- - // GESETZTEN und flacheren Quer-Band (`sashBackN..sashFrontN`) hinter der - // Blendrahmen-Vorderkante — eine plastische Stufe nach innen (VW „fein"), - // ohne vor die Fassade auszukragen. Zusätzlich (nur „fein") die SIA-Öffnungs- - // symbole auf dem Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol. - if (sashesOn && params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) { - const segs = resolveSashSpans(params.sashes, sashFrom, sashTo); - const sfw = fw * 0.7; - // Verschachteltes Flügelrahmen-Band: ~5 mm HINTER der Blendrahmen-Vorderkante - // (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte Stufe nach innen, - // die Glas-Vorderkante (nMax − 0.02, Falzmass) liegt INNERHALB dieses Bands. - const sashFrontN = params.nMax - 0.005; - const sashBackN = Math.max(params.nMin, params.nMax - 0.055); - // Glas-Vorderkante (s. glassPanesForOpening: n1 = nMax − 0.02); die Symbole - // liegen als ~2.5 mm dünne dunkle Prismen KNAPP davor (weiter aussen). - const glassFrontN = params.nMax - 0.02; - const symBackN = glassFrontN; - const symFrontN = glassFrontN + 0.003; - const pushSash = (from: number, to: number, zB: number, zT: number): void => - pushC(from, to, zB, zT, sashBackN, sashFrontN, FRAME_RGB); - const pushSymbol = (u0: number, h0: number, u1: number, h1: number): void => { - const mesh = openingPlaneBox(wall, u0, h0, u1, h1, SYMBOL_HALF_WIDTH, symFrontN, symBackN, SYMBOL_RGB); - if (mesh) out.push(mesh); - }; - for (const seg of segs) { - const s = seg.sash; - if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue; - // Bündig am Blendrahmen/Segmentrand ansetzen — die Stufe kommt aus der - // Tiefe (sashBackN..sashFrontN), nicht aus einem sichtbaren Glas-Spalt. - const cf = seg.from; - const ct = seg.to; - const bz = sashBottom; - const tz = sashTop; - if (ct - cf <= EPS || tz - bz <= EPS) continue; - pushSash(cf, cf + sfw, bz, tz); // linker Flügelholm - pushSash(ct - sfw, ct, bz, tz); // rechter Flügelholm - pushSash(cf, ct, tz - sfw, tz); // oberer Flügelriegel - pushSash(cf, ct, bz, bz + sfw); // unterer Flügelriegel - // SIA-Öffnungssymbole (nur „fein") auf dem Glasfeld INNERHALB des Flügel- - // rahmens — exakt wie in der 2D-Ansicht (s. toElevation): Dreh = V mit - // Spitze am Bandseiten-Mittel des Glasfelds, Kipp = V mit Spitze oben Mitte. - if (!fine) continue; - const gA = cf + sfw; - const gB = ct - sfw; - const gbz = bz + sfw; - const gtz = tz - sfw; - if (gB - gA <= EPS || gtz - gbz <= EPS) continue; - const kind = s.opening; - if (kind === "dreh" || kind === "drehkipp") { - const hingeLeft = (s.hingeSide ?? "left") === "left"; - const apexU = hingeLeft ? gA : gB; - const apexH = (gbz + gtz) / 2; - const farU = hingeLeft ? gB : gA; - pushSymbol(farU, gbz, apexU, apexH); - pushSymbol(farU, gtz, apexU, apexH); - } - if (kind === "kipp" || kind === "drehkipp") { - const apexU = (gA + gB) / 2; - pushSymbol(gA, gbz, apexU, gtz); - pushSymbol(gB, gbz, apexU, gtz); - } - if (kind === "schiebe") { - // Schiebe: horizontaler Pfeil in Laufrichtung (Griff links ⇒ nach rechts). - const zc = (gbz + gtz) / 2; - const toRight = (s.hingeSide ?? "left") === "left"; - const tail = toRight ? gA : gB; - const head = toRight ? gB : gA; - const barb = (head - tail) * 0.22; - pushSymbol(tail, zc, head, zc); // Schaft - pushSymbol(head - barb, gbz + (gtz - gbz) * 0.32, head, zc); // obere Spitze - pushSymbol(head - barb, gbz + (gtz - gbz) * 0.68, head, zc); // untere Spitze - } - } - } - - // Fensterbank (Sims, VW „fein"/„mittel"): Bank-Quader unter der Öffnung, - // seitlich ~3 cm überstehend, nach aussen (Wand-Aussenfläche +halfThickness) - // auskragend, mit einer zweiten, dünneren Tropfkanten-Stufe an der Aussenkante - // darunter. Nur Fenster mit `params.drawSill` (sillBoard ≠ "keine"/"innen"). - if (detailBodies && params.drawSill) { - const ov = 0.03; // seitlicher Überstand je Seite - const proj = 0.04; // Auskragung nach aussen (vor die Wandfläche) - const under = 0.02; // Einbindung unter den Rahmen (in den Reveal) - const bankH = 0.04; // Bankdicke - const dripH = 0.02; // Höhe der Tropfkanten-Stufe - const dripW = 0.02; // Tiefe der Tropfkante an der Aussenkante - const outerN = params.halfThickness; // Wand-Aussenfläche - const bankTop = vert.zBottom; - const bankBottom = bankTop - bankH; - // Bank - pushC(iv.from - ov, iv.to + ov, bankBottom, bankTop, outerN - under, outerN + proj, SILL_RGB); - // Tropfkante (dünnere Stufe darunter, an der Aussenkante) - pushC(iv.from - ov, iv.to + ov, bankBottom - dripH, bankBottom, outerN + proj - dripW, outerN + proj, SILL_RGB); - } - - // Tür-Zarge mit Umgriff (VW): die Zarge umgreift die Wand — eine schmale - // Bekleidungs-Platte VOR beiden Wandflächen, als Ring (Pfosten links/rechts + - // Sturz) rund um die Öffnung, ~1.3 cm vor die Fläche vorstehend. Nur - // `frameKind === "zarge"`; Blockrahmen bleibt bündig (params.zargeCladding false). - if (detailBodies && params.zargeCladding) { - const proud = 0.013; // Vorstand vor die Wandfläche - const ext = 0.02; // Überstand auf die Wandfläche (über die Laibungskante hinaus) - const casW = fw; // Bekleidungsbreite (= Rahmenprofil) - for (const sign of [1, -1] as const) { - const faceN = sign * params.halfThickness; - const nA = faceN; - const nB = faceN + sign * proud; - pushC(iv.from - ext, iv.from + casW, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten links - pushC(iv.to - casW, iv.to + ext, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten rechts - pushC(iv.from - ext, iv.to + ext, vert.zTop - casW, vert.zTop + ext, nA, nB, FRAME_RGB); // Sturz - } - } - return out; -} - -/** - * Löst die Flügeleinteilung auf das konkrete Achsintervall `[from,to]` (Meter) - * auf — feste Breiten (`kind:"pfosten"`/`autoWidth:false`) exakt, der Rest - * gleichmäßig auf die Restbreite. Pendant zu `sashSegments` in `opening.ts` - * (2D), aber direkt in Öffnungs-Achskoordinaten (3D). - */ -function resolveSashSpans( - sashes: SashDef[], - from: number, - to: number, -): { sash: SashDef; from: number; to: number }[] { - const width = Math.max(0, to - from); - let fixedSum = 0; - let autoCount = 0; - for (const s of sashes) { - if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? 0.06); - else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0); - else autoCount++; - } - const autoW = autoCount > 0 ? Math.max(0, (width - fixedSum) / autoCount) : 0; - const out: { sash: SashDef; from: number; to: number }[] = []; - let cursor = from; - for (const s of sashes) { - const w = - s.kind === "pfosten" - ? Math.max(0, s.postWidth ?? 0.06) - : s.autoWidth === false - ? Math.max(0, s.width ?? 0) - : autoW; - out.push({ sash: s, from: cursor, to: cursor + w }); - cursor += w; - } - return out; -} - -/** Luftraum zwischen zwei Scheiben einer Mehrfachverglasung (Meter, informativ). */ -const GLASS_PANE_GAP = 0.006; - -/** - * Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei - * `params.glazed`): je Z-Band (Hauptfeld zwischen Schwelle und Sturz bzw. - * Kämpfer, plus — bei `transomHeight>0` — ein zweites Oberlicht-Band) werden - * `params.glazingPanes` dünne, in Wandrichtung (quer zur Wand, n-Achse) leicht - * versetzte Scheiben mit Luftraum dazwischen erzeugt (Isolierverglasung: - * 1 = Einfach-, 2 = Zweifach-, 3 = Dreifachverglasung). Bei `glazingPanes: 1` - * (Alt-Verhalten/Default) entsteht exakt EINE Scheibe an derselben Position wie - * bisher. Die Sprossen/Mittelpfosten aus {@link frameMeshesForOpening} liegen - * VOR diesen Scheiben (keine Pro-Feld-Aufteilung — bewusst einfach gehalten, - * s. Datei-weite Erläuterung). - */ -function glassPanesForOpening( - wall: Wall, - iv: { from: number; to: number }, - vert: { zBottom: number; zTop: number }, - params: OpeningFrameParams, -): RMesh[] { - const out: RMesh[] = []; - const fw = params.frameWidth; - const sashFrom = iv.from + fw; - const sashTo = iv.to - fw; - const sashBottom = vert.zBottom + (params.hasSill ? fw : 0); - const sashTopFull = vert.zTop - fw; - // Die Scheibe(n) sitzen als DÜNNE Tafeln NAHE der Aussenfläche (nMax = +n- - // Seite, s. resolveFrameNormalRange) — mittig tief im Reveal wirkte das - // schwach/kaum sichtbar. Direkt hinter der Aussenkante (Falzmass ~2 cm, s. - // Fensterbau-Recherche) liest sich das Fenster klar, die Laibung/Reveal- - // Tiefe zeigt nach innen. Bei mehreren Scheiben rücken die weiteren mit - // `GLASS_PANE_GAP` Luftraum dazwischen weiter nach innen (Richtung `nMin`). - const glassThick = GLASS_HALF_THICK * 2; - const panes = Math.max(1, Math.min(3, Math.round(params.glazingPanes))); - const push = (zBottom: number, zTop: number): void => { - let n1 = params.nMax - 0.02; - for (let p = 0; p < panes; p++) { - const n0 = Math.max(params.nMin, n1 - glassThick); - if (n1 - n0 <= EPS) break; - const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, n0, n1, GLASS_RGB); - if (mesh) out.push(mesh); - n1 = n0 - GLASS_PANE_GAP; - if (n1 <= params.nMin) break; - } - }; - // Teilverglasung (Türen mit glazingRatio < 1): das HAUPTFELD ist nur im oberen - // `glazedFraction`-Anteil Glas; darunter bleibt ein blindes Blattfeld. Von der - // OBERKANTE des Hauptbands gemessen. Fenster: glazedFraction = 1 (voll). - const frac = Math.max(0.05, Math.min(1, params.glazedFraction)); - const glazedBottom = (bBottom: number, bTop: number) => - bTop - frac * (bTop - bBottom); - if (params.transomHeight > EPS) { - const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull); - const mainTop = splitZ - fw / 2; - if (params.glazed) push(glazedBottom(sashBottom, mainTop), mainTop); // Hauptglas (ggf. nur oben). - push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe (immer voll, auch Vollblatt-Tür). - } else if (params.glazed) { - push(glazedBottom(sashBottom, sashTopFull), sashTopFull); - } - return out; -} - -/** - * Türblatt EINER typisierten Tür als solide Blatt-Tafel im Hauptfeld (zwischen - * den Rahmen-Pfosten, über Schwelle bis Sturz bzw. Kämpfer): Vollblatt - * (`leafStyle` ≠ "glas") füllt das ganze Feld, Teilverglasung - * (`glazingRatio` < 1) nur den blinden UNTEREN Rest unter dem Glasanteil - * (Anteil von OBEN gemessen, s. {@link glassPanesForOpening}). Das Blatt liegt - * mittig um die Glasebene (nMax − 0.03), ~4 cm dick ({@link DOOR_LEAF_THICK}), - * in {@link DOOR_LEAF_RGB} — damit Türen im 3D als Türen lesbar sind statt als - * offene Löcher mit Rahmen. Fenster: nie (glazedFraction = 1 ⇒ kein Blatt). - */ -function doorLeafPanels( - wall: Wall, - iv: { from: number; to: number }, - vert: { zBottom: number; zTop: number }, - params: OpeningFrameParams, -): RMesh[] { - const out: RMesh[] = []; - const fw = params.frameWidth; - const leafFrom = iv.from + fw; - const leafTo = iv.to - fw; - const leafBottom = vert.zBottom + (params.hasSill ? fw : 0); - let fieldTop = vert.zTop - fw; - if (params.transomHeight > EPS) { - const splitZ = clampRange(vert.zTop - params.transomHeight, leafBottom, fieldTop); - fieldTop = splitZ - fw / 2; // Hauptfeld endet am Kämpfer, Oberlicht bleibt Glas. - } - // Blinder Blattanteil: alles unterhalb des (von oben gemessenen) Glasanteils. - const frac = params.glazed ? Math.max(0.05, Math.min(1, params.glazedFraction)) : 0; - const leafTop = fieldTop - frac * (fieldTop - leafBottom); - if (leafTop - leafBottom <= EPS || leafTo - leafFrom <= EPS) return out; - const n1 = params.nMax - 0.02 + DOOR_LEAF_THICK / 2; - const n0 = Math.max(params.nMin, n1 - DOOR_LEAF_THICK); - const mesh = openingAxisBox(wall, leafFrom, leafTo, leafBottom, leafTop, n0, n1, DOOR_LEAF_RGB); - if (mesh) out.push(mesh); - return out; -} - -/** - * Emittiert je Fensteröffnung eine Glasscheibe und je Türöffnung die Füllung - * (Blatt-Tafel, s. {@link doorLeafPanels}, plus Scheibe bei Glasanteil/ - * Oberlicht) als rohe Quader-Meshes — damit Fenster/Türen im 3D lesbar sind - * statt als blosse Löcher in der Wand. - * - * OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`, - * z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe - * über die geklemmte Öffnungs-Achse ({@link openingInterval}) und -Höhe - * ({@link openingVerticalExtent}), quer ±{@link GLASS_HALF_THICK} mittig in der - * Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar). - * - * MIT Typ: die Scheibe(n) sitzen im Flügel-Bereich INNERHALB des Rahmens (s. - * {@link glassPanesForOpening}), quer entlang des über den Schichteinzug - * bestimmten Rahmen-Versatzes (s. {@link resolveFrameNormalRange}) statt fix - * mittig — bei Oberlicht (`transomHeight>0`) kommt eine zweite Scheibe im - * Oberlicht-Band dazu. Die zugehörigen Rahmen-/Sprossen-Riegel emittiert - * separat {@link emitOpeningFrames} (eigene Farbe, eigene Funktion — leichter - * einzeln zu testen/filtern als eine gemeinsame Emission). - * - * `positions` liegen — wie bei {@link emitMeshes} (Terrain/Import) — in MODELL- - * Metern als flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world - * (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh), - * daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"` - * mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB. - */ -function emitOpeningGlass(project: Project): RMesh[] { - const out: RMesh[] = []; - for (const wall of project.walls) { - for (const op of openingsOfWall(project, wall.id)) { - const iv = openingInterval(wall, op); - if (!iv) continue; - const vert = openingVerticalExtent(project, wall, op); - if (vert.zTop - vert.zBottom <= EPS) continue; - if (op.kind === "window") { - const params = resolveOpeningFrame(project, wall, op); - if (!params) { - // Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig). - const mesh = openingAxisBox( - wall, - iv.from, - iv.to, - vert.zBottom, - vert.zTop, - -GLASS_HALF_THICK, - GLASS_HALF_THICK, - GLASS_RGB, - ); - if (mesh) out.push(mesh); - continue; - } - out.push(...glassPanesForOpening(wall, iv, vert, params)); - } else { - // Tür: Blatt-Tafel (Vollblatt bzw. blinder Rest unter der Teilverglasung) - // + Scheibe(n) für Glasanteil/Oberlicht. Ohne auflösbaren Typ: nichts - // (Alt-Verhalten: offenes Loch). - const params = resolveOpeningFrame(project, wall, op); - if (params) { - out.push(...doorLeafPanels(wall, iv, vert, params)); - out.push(...glassPanesForOpening(wall, iv, vert, params)); - } - } - } - } - return out; -} - -/** - * Emittiert je LEGACY-Tür (`project.doors` — das alte flache Türfeld VOR dem - * Öffnungs-Typsystem, z. B. `Door.hostWallId/position/width/height` ohne - * `typeId`) EIN Blatt-Panel als rohen Quader. `emitWall` schneidet für diese - * Türen zwar bereits ein Loch in die Wand, aber `emitOpeningGlass`/ - * `emitOpeningFrames` durchlaufen NUR `project.openings` (das neue Typsystem) — - * ohne diese Funktion blieb eine Legacy-Tür ein blankes Loch, durch das man - * hindurchsieht (Nutzer-Report: "Türen fehlen im 3D komplett"). Analog zum - * Alt-Verhalten typloser Fenster ({@link emitOpeningGlass}: EINE Vollscheibe - * ohne Rahmen) — hier EIN Vollblatt ohne Rahmen/Zarge (das Rahmen-System gehört - * exklusiv zu `project.openings`, s. {@link resolveOpeningFrame}). `positions` - * in MODELL-Metern wie {@link emitMeshes}. - */ -function emitLegacyDoorLeaves(project: Project): RMesh[] { - const out: RMesh[] = []; - for (const wall of project.walls) { - const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); - if (axisLen < 1e-9) continue; - const { zBottom } = wallVerticalExtent(project, wall); - for (const d of project.doors ?? []) { - if (d.hostWallId !== wall.id) continue; - const from = Math.max(0, Math.min(d.position, axisLen)); - const to = Math.max(from, Math.min(d.position + d.width, axisLen)); - if (to - from <= EPS) continue; - const zTop = zBottom + d.height; - if (zTop - zBottom <= EPS) continue; - const mesh = openingAxisBox( - wall, - from, - to, - zBottom, - zTop, - -DOOR_LEAF_THICK / 2, - DOOR_LEAF_THICK / 2, - DOOR_LEAF_RGB, - ); - if (mesh) out.push(mesh); - } - } - return out; -} - -/** - * Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/ - * {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz, - * Schwelle, Kämpfer, Mittelpfosten — s. {@link frameMeshesForOpening}) als rohe - * Quader-Meshes in {@link FRAME_RGB}. Öffnungen OHNE auflösbaren Typ (`typeId` - * fehlt/verwaist) bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der - * Typ-Vertiefung (Rückwärtskompatibilität). - */ -function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] { - // Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank). - if (detail === "grob") return []; - const out: RMesh[] = []; - for (const wall of project.walls) { - for (const op of openingsOfWall(project, wall.id)) { - const iv = openingInterval(wall, op); - if (!iv) continue; - const vert = openingVerticalExtent(project, wall, op); - if (vert.zTop - vert.zBottom <= EPS) continue; - const params = resolveOpeningFrame(project, wall, op); - if (!params) continue; - out.push(...frameMeshesForOpening(wall, iv, vert, params, detail)); - } - } - return out; -} - -/** - * Rollladen-/Sonnenschutzkasten als 3D-Box über der Öffnung (nur Fenster mit - * `WindowType.shading.create`, nur mittel/fein). Ein heller Quader, aussen an - * der Aussenfläche (params.nMax) ansetzend und um `box.depth` nach aussen - * ragend, von der Öffnungs-OK um `box.height` nach oben. Breite = `box.width` - * (0/fehlt ⇒ Öffnungsbreite), mittig über der Öffnung. Pendant zur 2D-Kontur - * (`window-shading` in generatePlan). 3D vom Nutzer in der Tauri-App zu prüfen. - */ -function emitOpeningShading(project: Project, detail: DetailLevel = "fein"): RMesh[] { - if (detail === "grob") return []; - const out: RMesh[] = []; - for (const wall of project.walls) { - for (const op of openingsOfWall(project, wall.id)) { - if (op.kind !== "window") continue; - const wt = getWindowType(project, op); - if (!wt?.shading?.create) continue; - const iv = openingInterval(wall, op); - if (!iv) continue; - const vert = openingVerticalExtent(project, wall, op); - if (vert.zTop - vert.zBottom <= EPS) continue; - const params = resolveOpeningFrame(project, wall, op); - if (!params) continue; - const box = wt.shading.box; - const h = Math.max(0, box.height); - const d = Math.max(0, box.depth); - if (h <= EPS || d <= EPS) continue; - const span = box.width > 0 ? box.width : iv.to - iv.from; - const center = (iv.from + iv.to) / 2; - const from = center - span / 2; - const to = center + span / 2; - // Aussen an der Aussenfläche (nMax) ansetzen und um die Kastentiefe hinaus. - const nOuter = params.nMax; - const mesh = openingAxisBox( - wall, - from, - to, - vert.zTop, - vert.zTop + h, - nOuter, - nOuter + d, - SHADING_RGB, - ); - if (mesh) out.push(mesh); - } - } - return out; -} - /** * Nur die Wände (Rückwärts-Kompatibilität / bestehende Aufrufer). Zerlegt Wände * mit Öffnungen in Teilquader. @@ -2843,7 +139,7 @@ export function projectToWalls3d(project: Project, layered = true): RWall[] { } /** - * Berechnet {@link computeJoins} getrennt je Geschoss (`wall.floorId`) und führt + * Berechnet `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). diff --git a/src/plan/toWalls3d/bands.ts b/src/plan/toWalls3d/bands.ts new file mode 100644 index 0000000..79c781a --- /dev/null +++ b/src/plan/toWalls3d/bands.ts @@ -0,0 +1,923 @@ +// Wand-Domäne der 3D-Ableitung (SCHICHTEN → Bänder → Boxen, siehe Moduldoc in +// `../../toWalls3d.ts`): löst den Schichtaufbau EINER Wand in quer zur Dicke +// gestapelte Materialbänder auf und emittiert daraus Wandkörper — inklusive +// aller materialbewussten Verschneidungen (Gehrungs-/Anschluss-Cuts an L-/ +// T-Stössen, Durchgangswand-Span-Cutouts, Decken-Dominanz pro Schicht, Dach- +// Unterkanten-Kappung). Das 3D-Pendant zu `generatePlan/walls.ts::addWallPoche`. + +import type { + Ceiling, + Layer, + Project, + SliceTermination, + Vec2, + Wall, +} from "../../model/types"; +import { + getCeilingType, + getComponent, + getWallType, + getWindowType, + openingsOfWall, + roofLayers, + wallTypeThickness, +} from "../../model/types"; +import { ceilingVerticalExtent, wallVerticalExtent } from "../../model/wall"; +import { pointInOutline } from "../../geometry/ceiling"; +import { openingInterval, openingVerticalExtent } from "../../geometry/opening"; +import { roofBaseElevation, roofGeometry, roofUndersideAt } from "../../geometry/roof"; +import type { RoofGeometry } from "../../geometry/roof"; +import type { WallCuts } from "../../model/joins"; +import { add, dot, leftNormal, lineIntersect, scale, sub } from "../../model/geometry"; +import type { Line } from "../../model/geometry"; +import { + WALL_RGB, + dominantLayerColor, + dominantLayerHatch, + hexToRgb, + resolveComponentHatch, + resolveComponentMaterialLayer, + resolveCutLineStyle, + EPS, + type RCutMeta, + type RHatch, + type RHole, + type RRgb, + type RWall, +} from "./shared"; + +/** + * 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; + +/** + * Eine quer zur Wanddicke gestapelte Materiallage als 3D-Band: eigene Dicke, + * Materialfarbe und Mitten-Versatz `offset` (Meter, entlang der Wand-Normalen, + * relativ zur Wandachse). Jede solche Lage wird pro Achsen-Teilquader zu einer + * eigenen Voll-Box (siehe {@link emitWall}/{@link pushSegment}). + */ +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 `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; +} + +/** + * 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; +} + +/** + * Löst den Schichtstapel EINER Wand auf: je Materiallage ein {@link WallBand} + * mit ihrer Dicke, Component-Farbe und dem Mitten-Versatz entlang der Normalen. + * Die Versätze zentrieren den Stapel um die Wandachse; als Gesamtdicke T dient + * die SUMME der tatsächlichen Lagen-Dicken (nicht `wallTypeThickness`, falls es + * abweicht) — so schließen die Bänder lückenlos aneinander an. Kein auflösbarer + * WallType / keine Lagen ⇒ EIN Vollkörper-Band über die volle Dicke im + * neutralen Grundton (heutiges Fallback-Verhalten). + */ +function resolveWallBands(project: Project, wall: Wall, layered = true): WallBand[] { + try { + const wt = getWallType(project, wall); + if (wt.layers.length > 0) { + // Gesamtdicke = Summe der Lagen (konsistent mit den Offsets); wenn diese + // von wallTypeThickness abwiche, gewinnt die Lagen-Summe (keine Lücken). + const total = wt.layers.reduce((s, l) => s + l.thickness, 0); + // Schnitt-Eingabe (layered=false): EINE Voll-Box über die ganze Dicke statt + // je Lage eine Box. So bleibt die Emission 1:1 Wand→Rechteck — passend zu + // `wallSegmentOwners`/`splitWallLayers` in toSection.ts, die die Schicht- + // 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) { + 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 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; + } + // WallType ohne Schichten: EIN Vollkörper über die Typ-Gesamtdicke. + return [{ thickness: wallTypeThickness(wt), color: WALL_RGB, offset: 0 }]; + } catch { + // Kein auflösbarer WallType: EIN Vollkörper mit Default-Dicke/Grundton. + return [{ thickness: 0.2, color: WALL_RGB, offset: 0 }]; + } +} + +/** + * Hängt eine Wand-Schicht-Box an: das Achsenstück [from..to] (Meter ab + * Wand-Startpunkt) über den Höhenbereich [zBottom..zTop], quer um `normalOffset` + * Meter entlang der Wand-Normalen versetzt (Schichtmitte relativ zur Achse) und + * mit Schicht-`thickness`/`color`. Entartete Stücke (Länge ≤ 0 oder Höhe ≤ 0) + * werden übersprungen — so überleben Öffnungen dicht an den Wandenden ohne + * Nullquader. + * + * Optionale `holes`: echte rechteckige Aussparungen in DIESER Box (siehe + * {@link RHole}). Ihre `from`/`to` sind Achsen-Meter ab Wand-Start (dieselbe + * Achse wie `from`/`to` dieser Box), `zBottom`/`zTop` absolute Weltmeter — beides + * unverändert an render3d durchgereicht (der Querversatz `normalOffset` wirkt nur + * senkrecht zur Achse und lässt die Loch-Parametrisierung unberührt). Werden nur + * im 3D-Pfad (voller Achsen-Quader) gesetzt. + */ +function pushSegment( + out: RWall[], + wall: Wall, + from: number, + to: number, + zBottom: number, + zTop: number, + thickness: number, + 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; + const dx = wall.end.x - wall.start.x; + const dy = wall.end.y - wall.start.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return; + const ux = dx / len; + const uy = dy / len; + // LINKE Grundriss-Normale (identisch zu `leftNormal(u)` = (−uy, ux) in + // generatePlan.ts/clippedBand — dieselbe Konvention, die addWallPoche für + // den Schichtstapel im 2D-Grundriss benutzt). Der Schicht-Versatz verschiebt + // Start/Ende der Box quer zur Wand, sodass render3d die Box um ihre eigene + // (versetzte) Achse zentriert aufbaut — mit dieser Normale liegt Schicht 0 + // (Aussenseite, kleinster `offset`) auf derselben Weltseite wie im Grundriss. + // Bewusst NICHT identisch zur Normale, die raycast3d/selectionHighlightLines + // für den Box-Rebuild nutzen (n = (uy, −ux), rechte Normale) — das ist + // unschädlich, weil jene Boxen symmetrisch um die (hier bereits versetzte) + // Achse jedes einzelnen Layer-Records aufgebaut werden und daher unabhängig + // von der Vorzeichen-Konvention des Versatzes sind. + const ox = -uy * normalOffset; + const oy = ux * normalOffset; + const p1: [number, number] = [wall.start.x + ux * from + ox, wall.start.y + uy * from + oy]; + const p2: [number, number] = [wall.start.x + ux * to + ox, wall.start.y + uy * to + oy]; + out.push({ + start: p1, + end: p2, + thickness, + height: zTop - zBottom, + baseElevation: zBottom, + color, + layers: [], + wallId, + ...(hatch ? { hatch } : {}), + ...(materialIndex != null ? { materialIndex } : {}), + ...(holes && holes.length > 0 ? { holes } : {}), + ...(cut ? { cut } : {}), + }); +} + +/** + * Achsenparalleles Grundriss-Rechteck (min/max je Achse), Meter, Modell-XY. + * Dient hier NUR der groben Footprint-Überlapp-Prüfung Wand↔Decke (s. + * {@link collectCeilingCutters}) — kein exakter Polygon-Test, siehe dort. + */ +interface Bounds2 { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +/** Bounding-Box der Wandachse, um `halfThickness` in beide Richtungen geweitet + * (sichere Über-Approximation des tatsächlichen Wandkörpers, unabhängig von + * dessen Orientierung). */ +function wallFootprintBounds(wall: Wall, halfThickness: number): Bounds2 { + return { + minX: Math.min(wall.start.x, wall.end.x) - halfThickness, + maxX: Math.max(wall.start.x, wall.end.x) + halfThickness, + minY: Math.min(wall.start.y, wall.end.y) - halfThickness, + maxY: Math.max(wall.start.y, wall.end.y) + halfThickness, + }; +} + +/** Bounding-Box eines geschlossenen Umrisses (Decke). */ +function outlineBounds(outline: Array<{ x: number; y: number }>): Bounds2 { + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const p of outline) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + return { minX, maxX, minY, maxY }; +} + +/** Überlappen sich zwei Bounding-Boxen (mit EPS-Toleranz an den Rändern)? */ +function boundsOverlap(a: Bounds2, b: Bounds2): boolean { + return ( + a.minX <= b.maxX + EPS && + a.maxX >= b.minX - EPS && + a.minY <= b.maxY + EPS && + a.maxY >= b.minY - EPS + ); +} + +/** + * Dicke einer Wand für die Footprint-Näherung (s. {@link wallFootprintBounds}): + * Summe der WallType-Schichtdicken, sonst der Fallback-Grundton-Dicke aus + * {@link resolveWallBands} (0.2 m), falls kein WallType auflösbar ist. Auch von + * `../openings.ts::resolveOpeningFrame` genutzt (Rahmen-Tiefe auf die Wanddicke + * klemmen). + */ +export function wallThicknessSafe(project: Project, wall: Wall): number { + try { + return wallTypeThickness(getWallType(project, wall)); + } catch { + return 0.2; + } +} + +/** + * 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 { + const ct = getCeilingType(project, ceiling); + let best: number | undefined; + for (const layer of ct.layers) { + const p = getComponent(project, layer.componentId).joinPriority; + if (best === undefined || p > best) best = p; + } + return best; + } catch { + return undefined; + } +} + +/** + * 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. + */ +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 `../ceilings.ts::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, +): CeilingCutter[] { + const halfThickness = wallThicknessSafe(project, wall) / 2; + const wallBounds = wallFootprintBounds(wall, halfThickness); + 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); + if (cExtent.zBottom >= zTop - EPS || cExtent.zTop <= zBottom + EPS) continue; + if (!boundsOverlap(wallBounds, outlineBounds(c.outline))) continue; + + // 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 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/walls.ts::subtractIntervals`, hier lokal gehalten). + */ +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 (`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); +} + +/** Schrittweite (Meter) der Dach-Unterkanten-Annäherung, s. {@link clipPieceToRoofs}. */ +const ROOF_CLIP_STEP = 0.15; + +/** + * Kappt ein Wand-Achsenstück [a,bb] (z-Bereich [zb,zt]) an der Dach-UNTERKANTE + * (falls ein Dach darüber liegt) und emittiert es via `emit` — in FEINE Achsen- + * Schritte (~{@link ROOF_CLIP_STEP}) zerlegt, je Schritt auf die dort gemessene + * Dach-Unterkante geklemmt. Das ist eine TREPPENSTUFEN-Annäherung an eine echte + * geneigte Giebelwand-Stirnfläche (render3d-Wandkörper (`RWall`) haben nur einen + * FLACHEN Top — echte Schrägflächen bräuchten einen neuen Mesh-Pfad); bei der + * gewählten Schrittweite optisch glatt genug UND behebt das eigentliche Problem + * (Wand durchdringt die Dachfläche → Z-Fighting, s. PENDENZEN.md „Dach-Wand- + * Verschneidung"), ohne render3d/Rust anzufassen. + * + * Ohne Dächer (oder keinem, das dieses Wandstück überdeckt) läuft das Stück + * UNVERÄNDERT als EIN Body durch (kein Overhead im Normalfall). + */ +function clipPieceToRoofs( + project: Project, + wall: Wall, + u: Vec2, + n: Vec2, + bandOffset: number, + a: number, + bb: number, + zb: number, + zt: number, + emit: (a: number, bb: number, zb: number, zt: number) => void, +): void { + if (zt - zb <= EPS || bb - a <= EPS) return; + const wallBounds = wallFootprintBounds(wall, Math.abs(bandOffset) + 0.01); + const roofs = (project.roofs ?? []).filter((r) => { + if (r.floorId !== wall.floorId) return false; + const eavesZ = roofBaseElevation(project, r); + // Grober Höhen-Ausschluss: Dach-Traufe weit unter dem Wandstück ⇒ kann nicht + // schneiden (übliche Dächer haben keine nach unten ausufernden Übermasse). + return eavesZ + 50 >= zb; // grosszügige Schranke statt exakter First-Höhe + }); + if (roofs.length === 0) { + emit(a, bb, zb, zt); + return; + } + const roofData = roofs + .map((r) => { + const eavesZ = roofBaseElevation(project, r); + const geo = roofGeometry(r, eavesZ); + if (!boundsOverlap(wallBounds, outlineBounds(geo.eaves))) return null; + const total = roofLayers(project, r).reduce((s, l) => s + Math.max(0, l.thickness), 0); + return { geo, total }; + }) + .filter((d): d is { geo: RoofGeometry; total: number } => d !== null); + if (roofData.length === 0) { + emit(a, bb, zb, zt); + return; + } + + const span = bb - a; + const steps = Math.max(1, Math.ceil(span / ROOF_CLIP_STEP)); + const stepLen = span / steps; + // Je Stufe die geklemmte Oberkante bestimmen … + const tops: number[] = []; + for (let i = 0; i < steps; i++) { + const mid = a + (i + 0.5) * stepLen; + const p = add(add(wall.start, scale(u, mid)), scale(n, bandOffset)); + let clip = zt; + for (const { geo, total } of roofData) { + const under = roofUndersideAt(geo, total, p.x, p.y); + if (under !== null && under < clip) clip = under; + } + tops.push(Math.max(zb, clip)); + } + // … und AUFEINANDERFOLGENDE Stufen gleicher Oberkante zu EINEM Body verschmelzen. + // Wo das Dach das Stück gar nicht klippt (Oberkante == zt über die ganze Länge, + // z. B. nahe dem First oder wenn kein Dach wirklich überdeckt), entsteht so wieder + // EIN durchgehender Body statt vieler identischer 0.15-m-Fragmente — nur der echt + // geneigte Bereich bleibt gestuft. Das entlastet auch den Live-Schnitt, der genau + // diese Wandkörper cuttet (weniger, saubere Schnitt-Kappen). + let runStart = 0; + for (let i = 1; i <= steps; i++) { + if (i < steps && Math.abs(tops[i] - tops[runStart]) <= EPS) continue; + const sa = a + runStart * stepLen; + const sb = a + i * stepLen; + const zte = tops[runStart]; + if (zte - zb > EPS) emit(sa, sb, zb, zte); + runStart = i; + } +} + +/** + * Emittiert die Wandkörper EINER Wand (mit oder ohne Öffnungen). + * + * ZWEI PFADE je `layered`: + * • layered=true (3D-Viewer, Default): EIN Körper pro Schicht-Band über die + * VOLLE Achse mit allen Öffnungen als echte `holes` (render3d stanzt sie in + * die Langseiten). Ermöglicht auch versetzt übereinanderliegende Fenster in + * EINEM Körper (keine Segment-Nähte). + * • layered=false (Schnitt-Pfad, `computeSection`): UNVERÄNDERTE Pfeiler/ + * Brüstung/Sturz-Segmentierung (EIN Voll-Band je Segment), damit die + * `ComponentRef.index`-Reihenfolge exakt zu `toSection.ts::wallSegmentOwners` + * passt (Owner-Kompatibilität; toSection.ts bleibt unangetastet). Löcher + * werden hier NICHT emittiert. + */ +export function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cuts?: WallCuts): void { + const bands = resolveWallBands(project, wall, layered); + 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; + + // Ein Achsen-Teilquader × jede Materiallage = eine Box (Schichtstapel quer + // zur Wanddicke). Alle tragen die Wand-Id, sodass ein Klick auf irgendeine + // 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, b.hatch, b.materialIndex, undefined, bandCutMeta(b)); + } + }; + + // Aussparungen der Wand als vereinheitlichte Cutouts sammeln: + // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz), + // • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz). + // Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung. + const cutouts: Array<{ + from: number; + to: number; + oBottom: number; + oTop: number; + /** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster + * mit `WindowType.innerLayerRecess`), s. Moduldoc unten. */ + recessInnerLayer: boolean; + }> = []; + for (const op of openingsOfWall(project, wall.id)) { + const iv = openingInterval(wall, op); + if (!iv) continue; + const v = openingVerticalExtent(project, wall, op); + const recessInnerLayer = + op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true; + cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer }); + } + for (const d of project.doors ?? []) { + if (d.hostWallId !== wall.id) continue; + const from = Math.max(0, Math.min(d.position, axisLen)); + const to = Math.max(from, Math.min(d.position + d.width, axisLen)); + if (to - from < EPS) continue; + // Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe. + const oTop = Math.min(zTop, zBottom + d.height); + cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false }); + } + 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 (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 & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({ + from: c.from, + to: c.to, + zBottom: c.oBottom, + zTop: c.oTop, + recessInnerLayer: c.recessInnerLayer, + })); + 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/walls.ts::addWallPoche. + 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). An der INNERSTEN + // Schicht (li = letztes Band) weicht das Loch bei Fenstern mit + // `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück + // (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig. + const isInnermostBand = li === bands.length - 1; + const recessDepth = b.thickness; + 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) => { + const widen = isInnermostBand && h.recessInnerLayer; + return widen + ? { + from: h.from - a - recessDepth, + to: h.to - a + recessDepth, + zBottom: h.zBottom - recessDepth, + zTop: h.zTop + recessDepth, + } + : { 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) — ausser ein Dach kappt den Top (s. {@link clipPieceToRoofs}). + clipPieceToRoofs(project, wall, u, n, b.offset, a, bb, zBottom, zTop, emitPiece); + 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)) { + clipPieceToRoofs(project, wall, u, n, b.offset, xa, xb, zb, zt, emitPiece); + } + } + } + } + return; + } + + // ── Schnitt-Pfad (layered=false): unveränderte Pfeiler/Brüstung/Sturz- + // Segmentierung (Owner-Kompatibilität mit toSection.ts::wallSegmentOwners). + // Ohne Aussparungen: ein durchgehender Quader je Schicht. + if (cutouts.length === 0) { + emitSeg(0, axisLen, zBottom, zTop); + return; + } + + // Wand entlang der Achse durchlaufen: volle Pfeiler zwischen den Aussparungen, + // Brüstung/Sturz im Öffnungsbereich. Überlappende Cutouts werden über `cursor` + // zusammengefasst. + let cursor = 0; + for (const { from, to, oBottom, oTop } of cutouts) { + const segFrom = Math.max(cursor, from); + if (from > cursor) { + // Voller Wandpfeiler bis zur Aussparung. + emitSeg(cursor, from, zBottom, zTop); + } + if (to > segFrom) { + // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom). + if (oBottom > zBottom + EPS) { + emitSeg(segFrom, to, zBottom, oBottom); + } + // Sturz über der Öffnung (bis zum Wandkopf). + if (oTop < zTop - EPS) { + emitSeg(segFrom, to, oTop, zTop); + } + } + cursor = Math.max(cursor, to); + } + // Restlicher Wandpfeiler bis zum Achsenende. + if (cursor < axisLen) { + emitSeg(cursor, axisLen, zBottom, zTop); + } +} diff --git a/src/plan/toWalls3d/ceilings.ts b/src/plan/toWalls3d/ceilings.ts new file mode 100644 index 0000000..e802dbf --- /dev/null +++ b/src/plan/toWalls3d/ceilings.ts @@ -0,0 +1,97 @@ +// Decken-Domäne der 3D-Ableitung: extrudiert `project.ceilings` zu geschichteten +// Deckenplatten (eine RSlab je Materiallage, oben→unten gestapelt) — das 3D- +// Pendant zu `generatePlan/ceilings.ts::addCeilingPoche`. + +import type { Layer, Project } from "../../model/types"; +import { getCeilingType, getComponent } from "../../model/types"; +import { ceilingVerticalExtent } from "../../model/wall"; +import { mergeHoles } from "../../geometry/polygonHoles"; +import { + EPS, + SLAB_RGB, + hexToRgb, + resolveComponentHatch, + resolveCutLineStyle, + type RCutMeta, + type RRgb, + type RSlab, + type RVec2, +} from "./shared"; + +/** Emittiert die Deckenplatten eines Projekts (falls vorhanden). */ +export function emitSlabs(project: Project): RSlab[] { + const out: RSlab[] = []; + for (const c of project.ceilings ?? []) { + if (!c.outline || c.outline.length < 3) continue; + // zBottom/zTop (und damit die Deckendicke) berechnet ceilingVerticalExtent + // bereits über ceilingThickness (respektiert Typ-Dicke + Übersteuerung). + const { zBottom, zTop } = ceilingVerticalExtent(project, c); + if (zTop - zBottom <= EPS) continue; + // Aussparungen (Treppenauge/Schacht) über den Brücken-Trick in EINEN + // einfachen Umriss-Ring einfügen — derselbe Ring für ALLE Schichten dieser + // Decke. Kein `openings` ⇒ unverändertes `c.outline` (exakt heutiges + // Verhalten). Der Ring ist paritätskorrekt fürs Ear-Clipping (render3d) + // UND fürs Pick (pickGeometry nutzt denselben emitSlabs-Pfad): ein Klick + // ins Loch trifft die Decke NICHT — das ist gewollt. + const openings = c.openings ?? []; + const mergedOutline = openings.length ? mergeHoles(c.outline, openings).ring : c.outline; + const outline = mergedOutline.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 { + 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 = 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; +} diff --git a/src/plan/toWalls3d/columns.ts b/src/plan/toWalls3d/columns.ts new file mode 100644 index 0000000..16b8b72 --- /dev/null +++ b/src/plan/toWalls3d/columns.ts @@ -0,0 +1,78 @@ +// Stützen-Domäne der 3D-Ableitung (Tragwerk): extrudiert `project.columns` zu +// parametrischen Prisma-Meshes (Rechteck-Box bzw. tesselliertes Kreis-Prisma). + +import type { Column, Project, Vec2 } from "../../model/types"; +import { getComponent } from "../../model/types"; +import { columnFootprint } from "../../geometry/column"; +import { columnVerticalExtent } from "../../model/wall"; +import { EPS, hexToRgb, type RMesh, type RRgb } from "./shared"; + +/** Neutraler Beton-/Tragwerks-Grauton für Stützen (Fallback ohne Bauteil-Farbe). */ +const COLUMN_RGB: RRgb = [0.7, 0.7, 0.72]; + +/** + * 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); 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, s. `../extrusions.ts`): die + * Kontur kommt aus `columnFootprint`, die vertikale Lage aus + * `columnVerticalExtent` (UK/OK-Anker bzw. Geschoss-UK + Höhe). Rust/render3d + * bleibt unverändert (Kontext-Mesh-Pfad). + */ +export 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; +} diff --git a/src/plan/toWalls3d/drawing2d.ts b/src/plan/toWalls3d/drawing2d.ts new file mode 100644 index 0000000..ecfa026 --- /dev/null +++ b/src/plan/toWalls3d/drawing2d.ts @@ -0,0 +1,88 @@ +// Freie-2D-Geometrie-Domäne der 3D-Ableitung: zeigt jede 2D-Zeichnung +// (line/polyline/rect) als dünnes Boden-Ribbon-Mesh knapp über ihrer +// Geschossebene — die "2D-Referenz im 3D", die der drei.js-Fallback +// (`addDrawing2DLines` in Viewport3D.tsx) zeigt. + +import type { Drawing2D, Project, Vec2 } from "../../model/types"; +import { flattenCategories, getFloor } from "../../model/types"; +import { DRAWING_LINE_FALLBACK_RGB, hexToRgb, type RMesh, type RRgb } from "./shared"; + +/** 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; + +/** + * 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. + */ +export 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; +} diff --git a/src/plan/toWalls3d/extrusions.ts b/src/plan/toWalls3d/extrusions.ts new file mode 100644 index 0000000..19fdb62 --- /dev/null +++ b/src/plan/toWalls3d/extrusions.ts @@ -0,0 +1,91 @@ +// Extrusions-Domäne der 3D-Ableitung (truck-Integration, `docs/design/ +// truck-plan.md`): extrudiert `project.extrudedSolids` asynchron zu Kontext- +// Meshes, über einen modulweiten Cache je Solid-Id (siehe Moduldoc unten). + +import type { ExtrudedSolid, Project } from "../../model/types"; +import { getFloor } from "../../model/types"; +import { extrudeCircle, extrudePolygon } from "../../engine/truckSolid"; +import type { RMesh, RRgb } from "./shared"; + +/** 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]; + +/** 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(";")}`; +} + +export 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; +} diff --git a/src/plan/toWalls3d/openings.ts b/src/plan/toWalls3d/openings.ts new file mode 100644 index 0000000..ea3eef8 --- /dev/null +++ b/src/plan/toWalls3d/openings.ts @@ -0,0 +1,899 @@ +// Öffnungs-Domäne der 3D-Ableitung: Glasscheibe(n)/Türblatt, Rahmen-/Sprossen- +// Riegel (Zarge/Blendrahmen/Kämpfer/Mittelpfosten/Flügelrahmen + SIA-Symbole) +// und der Rollladen-/Sonnenschutzkasten typisierter Tür-/Fensteröffnungen — das +// 3D-Pendant zu `generatePlan/openings.ts`. + +import type { Opening, Project, SashDef, Wall } from "../../model/types"; +import { getDoorType, getWindowType, glazingPanesOf, openingsOfWall } from "../../model/types"; +import { openingInterval, openingVerticalExtent } from "../../geometry/opening"; +import { wallVerticalExtent } from "../../model/wall"; +import type { DetailLevel } from "../../ui/TopBar"; +import { EPS, type RMesh, type RRgb } from "./shared"; +import { wallThicknessSafe } from "./bands"; + +/** Helles, leicht bläuliches Fenster-Glas (Scheibe in der Öffnung). */ +const GLASS_RGB: RRgb = [0.62, 0.76, 0.85]; +/** + * Halbe Dicke EINER Fensterscheibe (Meter, Mehrfachverglasungs-Pfad). Kompakt + * gehalten (8 mm Gesamtdicke je Scheibe, wie ein reales Einzelglas), damit + * z. B. eine Dreifachverglasung noch in den realistisch schlanken Default- + * Rahmen ({@link DEFAULT_FRAME_DEPTH}, ~70 mm) passt — bei der früheren + * "volle Wanddicke"-Rahmentiefe gab es dafür reichlich Platz, im schlanken + * Rahmen nicht mehr. + */ +const GLASS_HALF_THICK = 0.004; +/** + * Helles, warmes Rahmen-Grau (Zarge/Blendrahmen/Kämpfer/Sprossen) — bewusst + * deutlich heller als Wand und klar von der bläulichen Scheibe ({@link GLASS_RGB}) + * unterscheidbar, damit der Rahmen im 3D als eigenes Bauteil lesbar ist + * (s. {@link emitOpeningFrames}). + */ +const FRAME_RGB: RRgb = [0.85, 0.85, 0.83]; +/** Heller Grauton für den Rollladen-/Sonnenschutzkasten (s. {@link emitOpeningShading}). */ +const SHADING_RGB: RRgb = [0.88, 0.88, 0.9]; +/** + * Heller Stein-/Beton-Grauton der Fensterbank (Sims/Tropfkante) — bewusst etwas + * dunkler/kühler als der Rahmen ({@link FRAME_RGB}), damit die Bank im 3D als + * eigenes Bauteil (nicht als Rahmenfortsatz) liest (s. Sims-Zweig in + * {@link frameMeshesForOpening}). + */ +const SILL_RGB: RRgb = [0.8, 0.8, 0.78]; +/** + * Dunkles Grau der SIA-Öffnungssymbole (dünne Diagonalen AUF dem Glas, wie in der + * 2D-Ansicht) — feine dunkle Linien auf der Scheibe (s. Symbol-Zweig in + * {@link frameMeshesForOpening}). Als sehr dünne, in der Öffnungsebene gedrehte + * Prismen ({@link openingPlaneBox}) knapp vor der Glasebene. + */ +const SYMBOL_RGB: RRgb = [0.25, 0.27, 0.3]; +/** Halbe Breite der Symbol-Diagonalen (Meter) — ~5 mm Gesamtbreite, feine Linie. */ +const SYMBOL_HALF_WIDTH = 0.0025; +/** + * Warmer, heller Türblatt-Ton — bewusst wärmer/heller als der Rahmen + * ({@link FRAME_RGB}), damit das Blatt im 3D als eigene Füllung liest + * (s. {@link doorLeafPanels}). + */ +const DOOR_LEAF_RGB: RRgb = [0.93, 0.9, 0.85]; +/** Türblatt-Dicke quer zur Wand (Meter). */ +const DOOR_LEAF_THICK = 0.04; +/** Default-Ansichtsbreite des Rahmenprofils (Meter), wenn `TYPE.frameWidth` fehlt. */ +const DEFAULT_FRAME_WIDTH = 0.06; +/** + * Default-Rahmentiefe (Meter), wenn ein Typ keine `frameDepth` nennt: reale + * Fensterrahmen sind ~70–90 mm tief (Holz/PVC/Alu), UNABHÄNGIG von der + * Wanddicke — ein schlanker Rahmen mit sichtbarer Laibung ringsum, nicht ein + * massiver Block über die volle Wandtiefe. Deckt sich mit + * `geometry/opening.ts::DEFAULT_FRAME_DEPTH` (2D=3D). + */ +const DEFAULT_FRAME_DEPTH = 0.07; + +// Faces eines Boxen-Quaders als Dreiecks-Indizes (Ecken-Index-Bits: bit0 = +// Achse from/to, bit1 = Höhe UK/OK, bit2 = Quer-Versatz nMin/nMax). Doppelseitig +// gerendert (append_context_mesh) ⇒ Winding egal; je Fläche 2 Dreiecke, 6 +// Flächen = 12 Dreiecke. Gemeinsame Topologie für Glasscheiben UND Rahmen-/ +// Sprossen-Riegel (s. {@link openingAxisBox}). +const OPENING_BOX_TRIS: number[] = [ + 0, 1, 3, 0, 3, 2, // Quer-Versatz nMin + 4, 5, 7, 4, 7, 6, // Quer-Versatz nMax + 0, 1, 5, 0, 5, 4, // UK + 2, 3, 7, 2, 7, 6, // OK + 0, 2, 6, 0, 6, 4, // from + 1, 3, 7, 1, 7, 5, // to +]; + +/** + * Baut EINEN geschlossenen Achsen-Quader (8 Ecken, 12 Dreiecke) für eine + * Öffnung — die gemeinsame Basis von {@link emitOpeningGlass} (Scheiben) und + * {@link emitOpeningFrames} (Rahmen-/Sprossen-Riegel). Achsen-Intervall + * `[from,to]` (Meter ab `wall.start`, entlang der Wandrichtung u), Höhen- + * Intervall `[zBottom,zTop]` (absolute Weltmeter) und Quer-Versatz-Intervall + * `[nMin,nMax]` (Meter entlang der Grundriss-Normalen n=(uy,−ux), dieselbe + * Konvention wie die bisherige Glas-Box — `nMin`/`nMax` MÜSSEN nicht sortiert + * übergeben werden, die Box spannt zwischen beiden Werten). `positions` in + * MODELL-Metern (x, y, z=Höhe). Liefert `null` bei entarteten Massen (Achse/ + * Höhe/Dicke ≤ EPS) statt eines Nullquaders. + */ +function openingAxisBox( + wall: Wall, + from: number, + to: number, + zBottom: number, + zTop: number, + nMin: number, + nMax: number, + color: RRgb, +): RMesh | null { + if (to - from <= EPS) return null; + if (zTop - zBottom <= EPS) return null; + if (Math.abs(nMax - nMin) <= EPS) return null; + const dx = wall.end.x - wall.start.x; + const dy = wall.end.y - wall.start.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return null; + const ux = dx / len; + const uy = dy / len; + // Grundriss-Normale (Dicken-Achse) — RECHTE Normale (uy,−ux), bewusst NICHT + // leftNormal (anders als pushSegment/windowSymbol) — kombiniert mit dem + // (ebenfalls gespiegelten) Vorzeichen in resolveFrameNormalRange ergibt sich + // daraus physisch die korrekte aussen/innen-Seite (s. dortigen Kommentar). + const nx = uy; + const ny = -ux; + const positions: number[] = []; + for (let i = 0; i < 8; i++) { + const s = i & 1 ? to : from; + const z = i & 2 ? zTop : zBottom; + const t = i & 4 ? nMax : nMin; + positions.push(wall.start.x + ux * s + nx * t, wall.start.y + uy * s + ny * t, z); + } + return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color }; +} + +/** + * Baut EIN sehr dünnes Prisma mit FREI orientierter Längsachse IN der Öffnungs- + * ebene — die diagonale Schwester von {@link openingAxisBox}. Die Längsachse + * verläuft vom Punkt `(u0,h0)` zum Punkt `(u1,h1)`, beide in Öffnungsebenen- + * koordinaten: `u` = Meter entlang der Wandrichtung (ab `wall.start`), `h` = + * absolute Welthöhe (Meter). Der Querschnitt spannt `±halfWidth` senkrecht zur + * Längsachse INNERHALB der Ebene (gedreht mit) und `[nBack,nFront]` entlang der + * Grundriss-Normalen (quer zur Wand, Tiefe). Damit lassen sich die schrägen + * SIA-Öffnungssymbole (Dreh-/Kipp-Diagonalen) als dünne, gedrehte dunkle Riegel + * AUF dem Glas erzeugen — der Renderer kennt nur Dreiecks-Meshes, keine Linien. + * `null` bei entarteter Länge (≤ EPS). 8 Ecken, dieselbe {@link OPENING_BOX_TRIS}- + * Topologie (doppelseitig gerendert ⇒ Winding egal; die drei Bits parametrisieren + * hier Endpunkt/Quer-Versatz/Tiefe). + */ +function openingPlaneBox( + wall: Wall, + u0: number, + h0: number, + u1: number, + h1: number, + halfWidth: number, + nFront: number, + nBack: number, + color: RRgb, +): RMesh | null { + const du = u1 - u0; + const dh = h1 - h0; + const segLen = Math.hypot(du, dh); + if (segLen < EPS) return null; + const dx = wall.end.x - wall.start.x; + const dy = wall.end.y - wall.start.y; + const wlen = Math.hypot(dx, dy); + if (wlen < 1e-9) return null; + const ux = dx / wlen; + const uy = dy / wlen; + // Grundriss-Normale (Dicken-Achse) — dieselbe Konvention wie openingAxisBox. + const nx = uy; + const ny = -ux; + // In der Ebene senkrecht zur Längsachse (u entlang Wand, h = Welthöhe): + // Rotation der normierten Richtung um 90°. `pu` wirkt auf die u-Achse (Wand), + // `ph` auf die Höhe — beide in Metern, orthonormal ⇒ echte `halfWidth`-Breite. + const pu = -dh / segLen; + const ph = du / segLen; + const positions: number[] = []; + for (let i = 0; i < 8; i++) { + const ui = i & 1 ? u1 : u0; + const hi = i & 1 ? h1 : h0; + const s = i & 2 ? 1 : -1; + const t = i & 4 ? nFront : nBack; + const u = ui + s * halfWidth * pu; + const h = hi + s * halfWidth * ph; + positions.push(wall.start.x + ux * u + nx * t, wall.start.y + uy * u + ny * t, h); + } + return { positions, indices: [...OPENING_BOX_TRIS], kind: "imported", color }; +} + +/** Klemmt `v` auf `[lo,hi]`. */ +function clampRange(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** + * Rahmen-Tiefe quer zur Wand (Meter): explizite `frameDepth` des Typs, sonst + * {@link DEFAULT_FRAME_DEPTH} (auf die Wanddicke geklemmt, falls die Wand + * dünner als der Default-Rahmen ist). + */ +function resolveAcrossWallDepth( + frameDepth: number | undefined, + wallThickness: number, +): number { + const depth = frameDepth !== undefined && frameDepth > EPS ? frameDepth : DEFAULT_FRAME_DEPTH; + return Math.min(depth, wallThickness); +} + +/** + * Quer-Versatz-Intervall `[nMin,nMax]` (Meter entlang der Wand-Normalen, s. + * {@link openingAxisBox}) des Rahmenkörpers — der 3D-Niederschlag des + * Schichteinzugs (`insetFromFace`/`insetFace`): die Rahmen-VORDERKANTE liegt + * bündig mit der gewählten Wandfläche, zurückversetzt um `insetFromFace` + * (0/fehlt ⇒ bündig), der Rahmenkörper erstreckt sich von dort aus `depth` + * Meter zur Wandmitte hin. "aussen" liegt an der POSITIVEN Normalen-Seite + * (kleinster Schicht-Offset in `../bands.ts::pushSegment`, s. dessen Kommentar + * zur Aussenseiten-Konvention via `leftNormal`), "innen" an der negativen. Das + * Ergebnis wird auf die halbe Wanddicke geklemmt — ein Schichteinzug darf den + * Rahmen nie aus der Wand hinausschieben. + */ +function resolveFrameNormalRange( + halfThickness: number, + depth: number, + insetFromFace: number, + face: "aussen" | "innen", +): { nMin: number; nMax: number } { + const sign = face === "innen" ? -1 : 1; + const front = sign * (halfThickness - insetFromFace); + const back = front - sign * depth; + const a = clampRange(front, -halfThickness, halfThickness); + const b = clampRange(back, -halfThickness, halfThickness); + return { nMin: Math.min(a, b), nMax: Math.max(a, b) }; +} + +/** + * Aufgelöste Rahmen-/Sprossen-Parameter EINER Öffnung (Tür oder Fenster) aus + * ihrem `DoorType`/`WindowType` (via `getDoorType`/`getWindowType`). `null`, + * wenn kein Typ auflösbar ist (`Opening.typeId` fehlt/verwaist) — DANN + * emittieren weder {@link emitOpeningFrames} noch die Rahmen-/Sprossen-Logik in + * {@link emitOpeningGlass} irgendetwas (Rückwärtskompatibilität: unveränderte + * Alt-Darstellung, s. Moduldoc-Kommentar in `../../toWalls3d.ts`). + */ +interface OpeningFrameParams { + /** Sichtbare Rahmen-/Flügelprofil-Breite in der Öffnungsebene (Meter). */ + frameWidth: number; + /** Rahmen-Tiefe quer zur Wand (Meter, informativ — steckt bereits in nMin/nMax). */ + acrossWallDepth: number; + /** Quer-Versatz-Intervall des Rahmenkörpers (s. {@link resolveFrameNormalRange}). */ + nMin: number; + nMax: number; + /** Oberlicht-Höhe (Meter); 0 = kein Oberlicht/Kämpfer. */ + transomHeight: number; + /** Schwelle/Sohlbank zeichnen: Fenster immer, Tür nur mit `threshold`. */ + hasSill: boolean; + /** Flügelzahl (vertikale Mittelpfosten = wingCount−1); Türen immer 1. */ + wingCount: number; + /** Kämpfer-Zeilen (horizontale Teilung = mullionRows−1); Türen immer 1. */ + mullionRows: number; + /** Sprossen-Spalten (vertikale Teilung = mullionCols−1); Türen immer 1. */ + mullionCols: number; + /** Ob eine Glasscheibe gezeichnet wird (Fenster immer, Tür nur `leafStyle: "glas"`). */ + glazed: boolean; + /** + * Verglasungs-Scheibenzahl (1–3), render-wirksam (`glazingPanesOf`). Türen: + * immer 1 (keine typisierte Mehrfachverglasung). Steuert die Anzahl dünner, + * in Wandrichtung leicht versetzter Glasflächen in {@link glassPanesForOpening}. + */ + glazingPanes: 1 | 2 | 3; + /** + * Verglasungs-Anteil des HAUPTFELDS (0..1, von OBEN gemessen): 1 = voll + * verglast (Fenster, Ganzglastür), < 1 = nur der obere Anteil ist Glas, unten + * bleibt ein blindes Feld (z. B. Tür mit Oberglas, `DoorType.glazingRatio`). + * Fenster: immer 1. + */ + glazedFraction: number; + /** + * Flügeleinteilung (`sashesOfWindowType`) — je ÖFFENBAREM Flügel wird im + * 3D ein Flügelrahmen (Rahmen-Riegel innerhalb des Blendrahmens) erzeugt + * (Schachtelung Blendrahmen → Flügelrahmen → Glas). Türen: leer. + */ + sashes: SashDef[]; + /** Halbe Wanddicke (Meter) — Bezug der Wand-Aussenfläche (+n) für Bank/Zargen-Umgriff. */ + halfThickness: number; + /** + * Fensterbank (Sims) zeichnen: nur Fenster mit `sillBoard` ≠ "keine"/"innen" + * (fehlt ⇒ zeichnen, wie in der 2D-Ansicht). Türen: nie. + */ + drawSill: boolean; + /** + * Zargen-Umgriff (Bekleidung VOR beiden Wandflächen) zeichnen: nur Türen mit + * `frameKind === "zarge"`. Blockrahmen bleibt bündig (false). + */ + zargeCladding: boolean; +} + +function resolveOpeningFrame(project: Project, wall: Wall, op: Opening): OpeningFrameParams | null { + const wallThickness = wallThicknessSafe(project, wall); + const halfThickness = wallThickness / 2; + if (op.kind === "door") { + const dt = getDoorType(project, op); + if (!dt) return null; + const acrossWallDepth = resolveAcrossWallDepth(dt.frameDepth, wallThickness); + const { nMin, nMax } = resolveFrameNormalRange( + halfThickness, + acrossWallDepth, + dt.insetFromFace ?? 0, + dt.insetFace ?? "aussen", + ); + return { + frameWidth: dt.frameWidth ?? DEFAULT_FRAME_WIDTH, + acrossWallDepth, + nMin, + nMax, + transomHeight: Math.max(0, dt.transomHeight ?? 0), + hasSill: dt.threshold === true, + // Zweiflüglige Tür ⇒ 2 Flügel = ein mittiger Anschlagpfosten (Stulp) im 3D. + wingCount: Math.max(1, Math.round(dt.leafCount ?? 1)), + mullionRows: 1, + mullionCols: 1, + glazed: dt.leafStyle === "glas", + glazingPanes: 1, + // Teilverglasung: glazingRatio (Glasanteil oben) bei Glasblatt; sonst voll. + glazedFraction: Math.max(0.05, Math.min(1, dt.glazingRatio ?? 1)), + sashes: [], + halfThickness, + drawSill: false, + // Zarge umgreift die Wand (Bekleidung auf beiden Flächen); Blockrahmen bleibt bündig. + zargeCladding: (dt.frameKind ?? "zarge") === "zarge", + }; + } + const wt = getWindowType(project, op); + if (!wt) return null; + const acrossWallDepth = resolveAcrossWallDepth(wt.frameDepth, wallThickness); + const { nMin, nMax } = resolveFrameNormalRange( + halfThickness, + acrossWallDepth, + wt.insetFromFace ?? 0, + wt.insetFace ?? "aussen", + ); + // Flügeltabelle: explizite `wt.sashes` gewinnen; sonst gleichmäßig aus dem + // effektiven Flügelzahl-Wert (Instanz-Override `op.wingCount` vor Typ-Default). + // wingCount wird DARAUS abgeleitet, damit Mittelpfosten (wingCount−1) und + // Flügelrahmen (je Flügel) konsistent bleiben. + let sashes = wt.sashes && wt.sashes.length > 0 ? wt.sashes : undefined; + if (!sashes) { + const n = Math.max(1, Math.round(op.wingCount ?? wt.wingCount ?? 1)); + const opening = wt.kind === "fest" ? "fest" : wt.kind; + sashes = Array.from({ length: n }, (_, i) => ({ + kind: "fluegel" as const, + autoWidth: true, + opening, + hingeSide: (i % 2 === 0 ? "left" : "right") as "left" | "right", + })); + } + const wingCount = Math.max(1, sashes.filter((s) => s.kind === "fluegel").length); + return { + frameWidth: wt.frameWidth ?? DEFAULT_FRAME_WIDTH, + acrossWallDepth, + nMin, + nMax, + transomHeight: Math.max(0, wt.transomHeight ?? 0), + hasSill: true, + wingCount, + mullionRows: Math.max(1, Math.round(wt.mullionRows ?? 1)), + mullionCols: Math.max(1, Math.round(wt.mullionCols ?? 1)), + glazed: true, + glazingPanes: glazingPanesOf(wt), + glazedFraction: 1, + sashes, + halfThickness, + // Fensterbank zeichnen, ausser explizit "keine"/"innen" (fehlt ⇒ zeichnen). + drawSill: wt.sillBoard !== "keine" && wt.sillBoard !== "innen", + zargeCladding: false, + }; +} + +/** + * Rahmen-/Sprossen-Riegel EINER Öffnung: Pfosten (links/rechts, volle + * Öffnungshöhe), Sturz (oben), Schwelle (nur wenn `params.hasSill`), optional + * ein Kämpfer-Riegel (Oberlicht-Trennung, `transomHeight>0`) sowie die + * Flügel-Mittelpfosten (`wingCount−1`, vertikal) und Kämpfer-Zeilen + * (`mullionRows−1`, horizontal) im Flügel-Bereich zwischen Schwelle/Sturz + * bzw. Schwelle/Kämpfer. Alle Rahmen-Riegel in {@link FRAME_RGB}. + * + * DetailLevel-Staffelung (VW-Detailgrad „fein" als Referenz): + * • „grob" — wird gar nicht aufgerufen (s. {@link emitOpeningFrames}). + * • „mittel"— Blendrahmen + Oberlicht-Kämpfer + Flügelrahmen (verschachtelt) + + * Fensterbank/Zargen-Umgriff, aber KEINE Sprossen und KEINE SIA-Symbole. + * • „fein" — zusätzlich Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) und + * die SIA-Öffnungssymbole (`openingPlaneBox`) auf dem Glas. + */ +function frameMeshesForOpening( + wall: Wall, + iv: { from: number; to: number }, + vert: { zBottom: number; zTop: number }, + params: OpeningFrameParams, + detail: DetailLevel = "fein", +): RMesh[] { + const out: RMesh[] = []; + const fine = detail === "fein"; + const medium = detail === "mittel"; + const sashesOn = fine || medium; // Flügelrahmen: mittel + fein + const detailBodies = fine || medium; // Bank + Zargen-Umgriff: mittel + fein + const fw = params.frameWidth; + // Generischer Riegel mit frei wählbarem Quer-Band [nA,nB] und Farbe. + const pushC = ( + from: number, + to: number, + zBottom: number, + zTop: number, + nA: number, + nB: number, + color: RRgb, + ): void => { + const mesh = openingAxisBox(wall, from, to, zBottom, zTop, nA, nB, color); + if (mesh) out.push(mesh); + }; + // Blendrahmen-Riegel (volles Rahmen-Quer-Band nMin..nMax, Rahmenfarbe). + const push = (from: number, to: number, zBottom: number, zTop: number): void => + pushC(from, to, zBottom, zTop, params.nMin, params.nMax, FRAME_RGB); + // Pfosten (Laibungsseiten), über die volle Öffnungshöhe durchlaufend. + push(iv.from, iv.from + fw, vert.zBottom, vert.zTop); + push(iv.to - fw, iv.to, vert.zBottom, vert.zTop); + // Sturz (oberer Riegel). + push(iv.from + fw, iv.to - fw, vert.zTop - fw, vert.zTop); + // Schwelle: Fenster immer, Tür nur mit Bodenanschlag (threshold). + if (params.hasSill) push(iv.from + fw, iv.to - fw, vert.zBottom, vert.zBottom + fw); + + // Flügel-/Sash-Bereich zwischen Schwelle und Sturz, ggf. durch den Kämpfer + // (Oberlicht-Trennung) oben beschnitten. + const sashFrom = iv.from + fw; + const sashTo = iv.to - fw; + const sashBottom = vert.zBottom + (params.hasSill ? fw : 0); + let sashTop = vert.zTop - fw; + if (params.transomHeight > EPS) { + const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTop); + push(sashFrom, sashTo, splitZ - fw / 2, splitZ + fw / 2); // Kämpfer-Riegel + sashTop = splitZ - fw / 2; + } + // Flügel-Mittelpfosten + Kämpfer-Zeilen (Sprossen) nur bei FEINEM Detailgrad — + // bei „mittel" bleibt es beim reinen Rahmen (+ Oberlicht-Kämpfer oben). + if (fine) { + // Flügel-Mittelpfosten (vertikal, wingCount−1) im Sash-Bereich. + if (params.wingCount > 1 && sashTo - sashFrom > EPS) { + const cell = (sashTo - sashFrom) / params.wingCount; + for (let i = 1; i < params.wingCount; i++) { + const center = sashFrom + i * cell; + push(center - fw / 2, center + fw / 2, sashBottom, sashTop); + } + } + // Kämpfer-Zeilen (horizontal, mullionRows−1) im Sash-Bereich. + if (params.mullionRows > 1 && sashTop - sashBottom > EPS) { + const cell = (sashTop - sashBottom) / params.mullionRows; + for (let i = 1; i < params.mullionRows; i++) { + const center = sashBottom + i * cell; + push(sashFrom, sashTo, center - fw / 2, center + fw / 2); + } + } + + // Sprossen-Spalten (vertikal, mullionCols−1) im Sash-Bereich. + if (params.mullionCols > 1 && sashTo - sashFrom > EPS) { + const cell = (sashTo - sashFrom) / params.mullionCols; + for (let i = 1; i < params.mullionCols; i++) { + const center = sashFrom + i * cell; + push(center - fw / 2, center + fw / 2, sashBottom, sashTop); + } + } + } + // Flügelrahmen je ÖFFENBAREM Flügel: ein eigener Rahmen-Riegel-Ring (links/ + // rechts/oben/unten), der DIREKT am Blendrahmen ansetzt (kein Spalt — sonst + // schiene die Vollfeld-Glasscheibe als Streifen zwischen Blend- und Flügel- + // rahmen durch). Die Schachtelung Blendrahmen → Flügelrahmen → Glas liest sich + // über die Tiefe: der Flügelrahmen sitzt in einem EIGENEN, leicht ZURÜCK- + // GESETZTEN und flacheren Quer-Band (`sashBackN..sashFrontN`) hinter der + // Blendrahmen-Vorderkante — eine plastische Stufe nach innen (VW „fein"), + // ohne vor die Fassade auszukragen. Zusätzlich (nur „fein") die SIA-Öffnungs- + // symbole auf dem Glas. Feste Flügel bekommen weder Flügelrahmen noch Symbol. + if (sashesOn && params.sashes.length > 0 && sashTo - sashFrom > EPS && sashTop - sashBottom > EPS) { + const segs = resolveSashSpans(params.sashes, sashFrom, sashTo); + const sfw = fw * 0.7; + // Verschachteltes Flügelrahmen-Band: ~5 mm HINTER der Blendrahmen-Vorderkante + // (nMax = Aussenseite) und nur ~50 mm tief — abgesetzte Stufe nach innen, + // die Glas-Vorderkante (nMax − 0.02, Falzmass) liegt INNERHALB dieses Bands. + const sashFrontN = params.nMax - 0.005; + const sashBackN = Math.max(params.nMin, params.nMax - 0.055); + // Glas-Vorderkante (s. glassPanesForOpening: n1 = nMax − 0.02); die Symbole + // liegen als ~2.5 mm dünne dunkle Prismen KNAPP davor (weiter aussen). + const glassFrontN = params.nMax - 0.02; + const symBackN = glassFrontN; + const symFrontN = glassFrontN + 0.003; + const pushSash = (from: number, to: number, zB: number, zT: number): void => + pushC(from, to, zB, zT, sashBackN, sashFrontN, FRAME_RGB); + const pushSymbol = (u0: number, h0: number, u1: number, h1: number): void => { + const mesh = openingPlaneBox(wall, u0, h0, u1, h1, SYMBOL_HALF_WIDTH, symFrontN, symBackN, SYMBOL_RGB); + if (mesh) out.push(mesh); + }; + for (const seg of segs) { + const s = seg.sash; + if (s.kind !== "fluegel" || !s.opening || s.opening === "fest") continue; + // Bündig am Blendrahmen/Segmentrand ansetzen — die Stufe kommt aus der + // Tiefe (sashBackN..sashFrontN), nicht aus einem sichtbaren Glas-Spalt. + const cf = seg.from; + const ct = seg.to; + const bz = sashBottom; + const tz = sashTop; + if (ct - cf <= EPS || tz - bz <= EPS) continue; + pushSash(cf, cf + sfw, bz, tz); // linker Flügelholm + pushSash(ct - sfw, ct, bz, tz); // rechter Flügelholm + pushSash(cf, ct, tz - sfw, tz); // oberer Flügelriegel + pushSash(cf, ct, bz, bz + sfw); // unterer Flügelriegel + // SIA-Öffnungssymbole (nur „fein") auf dem Glasfeld INNERHALB des Flügel- + // rahmens — exakt wie in der 2D-Ansicht (s. toElevation): Dreh = V mit + // Spitze am Bandseiten-Mittel des Glasfelds, Kipp = V mit Spitze oben Mitte. + if (!fine) continue; + const gA = cf + sfw; + const gB = ct - sfw; + const gbz = bz + sfw; + const gtz = tz - sfw; + if (gB - gA <= EPS || gtz - gbz <= EPS) continue; + const kind = s.opening; + if (kind === "dreh" || kind === "drehkipp") { + const hingeLeft = (s.hingeSide ?? "left") === "left"; + const apexU = hingeLeft ? gA : gB; + const apexH = (gbz + gtz) / 2; + const farU = hingeLeft ? gB : gA; + pushSymbol(farU, gbz, apexU, apexH); + pushSymbol(farU, gtz, apexU, apexH); + } + if (kind === "kipp" || kind === "drehkipp") { + const apexU = (gA + gB) / 2; + pushSymbol(gA, gbz, apexU, gtz); + pushSymbol(gB, gbz, apexU, gtz); + } + if (kind === "schiebe") { + // Schiebe: horizontaler Pfeil in Laufrichtung (Griff links ⇒ nach rechts). + const zc = (gbz + gtz) / 2; + const toRight = (s.hingeSide ?? "left") === "left"; + const tail = toRight ? gA : gB; + const head = toRight ? gB : gA; + const barb = (head - tail) * 0.22; + pushSymbol(tail, zc, head, zc); // Schaft + pushSymbol(head - barb, gbz + (gtz - gbz) * 0.32, head, zc); // obere Spitze + pushSymbol(head - barb, gbz + (gtz - gbz) * 0.68, head, zc); // untere Spitze + } + } + } + + // Fensterbank (Sims, VW „fein"/„mittel"): Bank-Quader unter der Öffnung, + // seitlich ~3 cm überstehend, nach aussen (Wand-Aussenfläche +halfThickness) + // auskragend, mit einer zweiten, dünneren Tropfkanten-Stufe an der Aussenkante + // darunter. Nur Fenster mit `params.drawSill` (sillBoard ≠ "keine"/"innen"). + if (detailBodies && params.drawSill) { + const ov = 0.03; // seitlicher Überstand je Seite + const proj = 0.04; // Auskragung nach aussen (vor die Wandfläche) + const under = 0.02; // Einbindung unter den Rahmen (in den Reveal) + const bankH = 0.04; // Bankdicke + const dripH = 0.02; // Höhe der Tropfkanten-Stufe + const dripW = 0.02; // Tiefe der Tropfkante an der Aussenkante + const outerN = params.halfThickness; // Wand-Aussenfläche + const bankTop = vert.zBottom; + const bankBottom = bankTop - bankH; + // Bank + pushC(iv.from - ov, iv.to + ov, bankBottom, bankTop, outerN - under, outerN + proj, SILL_RGB); + // Tropfkante (dünnere Stufe darunter, an der Aussenkante) + pushC(iv.from - ov, iv.to + ov, bankBottom - dripH, bankBottom, outerN + proj - dripW, outerN + proj, SILL_RGB); + } + + // Tür-Zarge mit Umgriff (VW): die Zarge umgreift die Wand — eine schmale + // Bekleidungs-Platte VOR beiden Wandflächen, als Ring (Pfosten links/rechts + + // Sturz) rund um die Öffnung, ~1.3 cm vor die Fläche vorstehend. Nur + // `frameKind === "zarge"`; Blockrahmen bleibt bündig (params.zargeCladding false). + if (detailBodies && params.zargeCladding) { + const proud = 0.013; // Vorstand vor die Wandfläche + const ext = 0.02; // Überstand auf die Wandfläche (über die Laibungskante hinaus) + const casW = fw; // Bekleidungsbreite (= Rahmenprofil) + for (const sign of [1, -1] as const) { + const faceN = sign * params.halfThickness; + const nA = faceN; + const nB = faceN + sign * proud; + pushC(iv.from - ext, iv.from + casW, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten links + pushC(iv.to - casW, iv.to + ext, vert.zBottom, vert.zTop + ext, nA, nB, FRAME_RGB); // Pfosten rechts + pushC(iv.from - ext, iv.to + ext, vert.zTop - casW, vert.zTop + ext, nA, nB, FRAME_RGB); // Sturz + } + } + return out; +} + +/** + * Löst die Flügeleinteilung auf das konkrete Achsintervall `[from,to]` (Meter) + * auf — feste Breiten (`kind:"pfosten"`/`autoWidth:false`) exakt, der Rest + * gleichmäßig auf die Restbreite. Pendant zu `sashSegments` in `opening.ts` + * (2D), aber direkt in Öffnungs-Achskoordinaten (3D). + */ +function resolveSashSpans( + sashes: SashDef[], + from: number, + to: number, +): { sash: SashDef; from: number; to: number }[] { + const width = Math.max(0, to - from); + let fixedSum = 0; + let autoCount = 0; + for (const s of sashes) { + if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? 0.06); + else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0); + else autoCount++; + } + const autoW = autoCount > 0 ? Math.max(0, (width - fixedSum) / autoCount) : 0; + const out: { sash: SashDef; from: number; to: number }[] = []; + let cursor = from; + for (const s of sashes) { + const w = + s.kind === "pfosten" + ? Math.max(0, s.postWidth ?? 0.06) + : s.autoWidth === false + ? Math.max(0, s.width ?? 0) + : autoW; + out.push({ sash: s, from: cursor, to: cursor + w }); + cursor += w; + } + return out; +} + +/** Luftraum zwischen zwei Scheiben einer Mehrfachverglasung (Meter, informativ). */ +const GLASS_PANE_GAP = 0.006; + +/** + * Glasscheibe(n) EINER typisierten Öffnung (Fenster immer, Tür nur bei + * `params.glazed`): je Z-Band (Hauptfeld zwischen Schwelle und Sturz bzw. + * Kämpfer, plus — bei `transomHeight>0` — ein zweites Oberlicht-Band) werden + * `params.glazingPanes` dünne, in Wandrichtung (quer zur Wand, n-Achse) leicht + * versetzte Scheiben mit Luftraum dazwischen erzeugt (Isolierverglasung: + * 1 = Einfach-, 2 = Zweifach-, 3 = Dreifachverglasung). Bei `glazingPanes: 1` + * (Alt-Verhalten/Default) entsteht exakt EINE Scheibe an derselben Position wie + * bisher. Die Sprossen/Mittelpfosten aus {@link frameMeshesForOpening} liegen + * VOR diesen Scheiben (keine Pro-Feld-Aufteilung — bewusst einfach gehalten). + */ +function glassPanesForOpening( + wall: Wall, + iv: { from: number; to: number }, + vert: { zBottom: number; zTop: number }, + params: OpeningFrameParams, +): RMesh[] { + const out: RMesh[] = []; + const fw = params.frameWidth; + const sashFrom = iv.from + fw; + const sashTo = iv.to - fw; + const sashBottom = vert.zBottom + (params.hasSill ? fw : 0); + const sashTopFull = vert.zTop - fw; + // Die Scheibe(n) sitzen als DÜNNE Tafeln NAHE der Aussenfläche (nMax = +n- + // Seite, s. resolveFrameNormalRange) — mittig tief im Reveal wirkte das + // schwach/kaum sichtbar. Direkt hinter der Aussenkante (Falzmass ~2 cm, s. + // Fensterbau-Recherche) liest sich das Fenster klar, die Laibung/Reveal- + // Tiefe zeigt nach innen. Bei mehreren Scheiben rücken die weiteren mit + // `GLASS_PANE_GAP` Luftraum dazwischen weiter nach innen (Richtung `nMin`). + const glassThick = GLASS_HALF_THICK * 2; + const panes = Math.max(1, Math.min(3, Math.round(params.glazingPanes))); + const push = (zBottom: number, zTop: number): void => { + let n1 = params.nMax - 0.02; + for (let p = 0; p < panes; p++) { + const n0 = Math.max(params.nMin, n1 - glassThick); + if (n1 - n0 <= EPS) break; + const mesh = openingAxisBox(wall, sashFrom, sashTo, zBottom, zTop, n0, n1, GLASS_RGB); + if (mesh) out.push(mesh); + n1 = n0 - GLASS_PANE_GAP; + if (n1 <= params.nMin) break; + } + }; + // Teilverglasung (Türen mit glazingRatio < 1): das HAUPTFELD ist nur im oberen + // `glazedFraction`-Anteil Glas; darunter bleibt ein blindes Blattfeld. Von der + // OBERKANTE des Hauptbands gemessen. Fenster: glazedFraction = 1 (voll). + const frac = Math.max(0.05, Math.min(1, params.glazedFraction)); + const glazedBottom = (bBottom: number, bTop: number) => + bTop - frac * (bTop - bBottom); + if (params.transomHeight > EPS) { + const splitZ = clampRange(vert.zTop - params.transomHeight, sashBottom, sashTopFull); + const mainTop = splitZ - fw / 2; + if (params.glazed) push(glazedBottom(sashBottom, mainTop), mainTop); // Hauptglas (ggf. nur oben). + push(splitZ + fw / 2, sashTopFull); // Oberlicht-Scheibe (immer voll, auch Vollblatt-Tür). + } else if (params.glazed) { + push(glazedBottom(sashBottom, sashTopFull), sashTopFull); + } + return out; +} + +/** + * Türblatt EINER typisierten Tür als solide Blatt-Tafel im Hauptfeld (zwischen + * den Rahmen-Pfosten, über Schwelle bis Sturz bzw. Kämpfer): Vollblatt + * (`leafStyle` ≠ "glas") füllt das ganze Feld, Teilverglasung + * (`glazingRatio` < 1) nur den blinden UNTEREN Rest unter dem Glasanteil + * (Anteil von OBEN gemessen, s. {@link glassPanesForOpening}). Das Blatt liegt + * mittig um die Glasebene (nMax − 0.03), ~4 cm dick ({@link DOOR_LEAF_THICK}), + * in {@link DOOR_LEAF_RGB} — damit Türen im 3D als Türen lesbar sind statt als + * offene Löcher mit Rahmen. Fenster: nie (glazedFraction = 1 ⇒ kein Blatt). + */ +function doorLeafPanels( + wall: Wall, + iv: { from: number; to: number }, + vert: { zBottom: number; zTop: number }, + params: OpeningFrameParams, +): RMesh[] { + const out: RMesh[] = []; + const fw = params.frameWidth; + const leafFrom = iv.from + fw; + const leafTo = iv.to - fw; + const leafBottom = vert.zBottom + (params.hasSill ? fw : 0); + let fieldTop = vert.zTop - fw; + if (params.transomHeight > EPS) { + const splitZ = clampRange(vert.zTop - params.transomHeight, leafBottom, fieldTop); + fieldTop = splitZ - fw / 2; // Hauptfeld endet am Kämpfer, Oberlicht bleibt Glas. + } + // Blinder Blattanteil: alles unterhalb des (von oben gemessenen) Glasanteils. + const frac = params.glazed ? Math.max(0.05, Math.min(1, params.glazedFraction)) : 0; + const leafTop = fieldTop - frac * (fieldTop - leafBottom); + if (leafTop - leafBottom <= EPS || leafTo - leafFrom <= EPS) return out; + const n1 = params.nMax - 0.02 + DOOR_LEAF_THICK / 2; + const n0 = Math.max(params.nMin, n1 - DOOR_LEAF_THICK); + const mesh = openingAxisBox(wall, leafFrom, leafTo, leafBottom, leafTop, n0, n1, DOOR_LEAF_RGB); + if (mesh) out.push(mesh); + return out; +} + +/** + * Emittiert je Fensteröffnung eine Glasscheibe und je Türöffnung die Füllung + * (Blatt-Tafel, s. {@link doorLeafPanels}, plus Scheibe bei Glasanteil/ + * Oberlicht) als rohe Quader-Meshes — damit Fenster/Türen im 3D lesbar sind + * statt als blosse Löcher in der Wand. + * + * OHNE auflösbaren Typ (`getWindowType`/`getDoorType` liefert `undefined`, + * z. B. `Opening.typeId` fehlt): unverändertes ALT-Verhalten — EINE Vollscheibe + * über die geklemmte Öffnungs-Achse (`openingInterval`) und -Höhe + * (`openingVerticalExtent`), quer ±{@link GLASS_HALF_THICK} mittig in der + * Wand (Rückwärtskompatibilität, s. Moduldoc-Kommentar). + * + * MIT Typ: die Scheibe(n) sitzen im Flügel-Bereich INNERHALB des Rahmens (s. + * {@link glassPanesForOpening}), quer entlang des über den Schichteinzug + * bestimmten Rahmen-Versatzes (s. {@link resolveFrameNormalRange}) statt fix + * mittig — bei Oberlicht (`transomHeight>0`) kommt eine zweite Scheibe im + * Oberlicht-Band dazu. Die zugehörigen Rahmen-/Sprossen-Riegel emittiert + * separat {@link emitOpeningFrames} (eigene Farbe, eigene Funktion — leichter + * einzeln zu testen/filtern als eine gemeinsame Emission). + * + * `positions` liegen — wie bei den übrigen Meshes — in MODELL-Metern als + * flaches (x, y, z=Höhe)-Array; render3d bildet sie selbst nach world + * (x, Höhe, y) ab und rendert die Meshes doppelseitig (append_context_mesh), + * daher ist die Winding-Reihenfolge der Dreiecke unerheblich. `kind: "imported"` + * mit gesetzter `color` ⇒ render3d zeichnet die Scheibe in GLASS_RGB. + */ +export function emitOpeningGlass(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const wall of project.walls) { + for (const op of openingsOfWall(project, wall.id)) { + const iv = openingInterval(wall, op); + if (!iv) continue; + const vert = openingVerticalExtent(project, wall, op); + if (vert.zTop - vert.zBottom <= EPS) continue; + if (op.kind === "window") { + const params = resolveOpeningFrame(project, wall, op); + if (!params) { + // Kein Typ auflösbar -> Alt-Verhalten (fixe Vollscheibe, mittig). + const mesh = openingAxisBox( + wall, + iv.from, + iv.to, + vert.zBottom, + vert.zTop, + -GLASS_HALF_THICK, + GLASS_HALF_THICK, + GLASS_RGB, + ); + if (mesh) out.push(mesh); + continue; + } + out.push(...glassPanesForOpening(wall, iv, vert, params)); + } else { + // Tür: Blatt-Tafel (Vollblatt bzw. blinder Rest unter der Teilverglasung) + // + Scheibe(n) für Glasanteil/Oberlicht. Ohne auflösbaren Typ: nichts + // (Alt-Verhalten: offenes Loch). + const params = resolveOpeningFrame(project, wall, op); + if (params) { + out.push(...doorLeafPanels(wall, iv, vert, params)); + out.push(...glassPanesForOpening(wall, iv, vert, params)); + } + } + } + } + return out; +} + +/** + * Emittiert je LEGACY-Tür (`project.doors` — das alte flache Türfeld VOR dem + * Öffnungs-Typsystem, z. B. `Door.hostWallId/position/width/height` ohne + * `typeId`) EIN Blatt-Panel als rohen Quader. `emitWall` (`../bands.ts`) + * schneidet für diese Türen zwar bereits ein Loch in die Wand, aber + * `emitOpeningGlass`/`emitOpeningFrames` durchlaufen NUR `project.openings` + * (das neue Typsystem) — ohne diese Funktion blieb eine Legacy-Tür ein + * blankes Loch, durch das man hindurchsieht (Nutzer-Report: "Türen fehlen im + * 3D komplett"). Analog zum Alt-Verhalten typloser Fenster + * ({@link emitOpeningGlass}: EINE Vollscheibe ohne Rahmen) — hier EIN + * Vollblatt ohne Rahmen/Zarge (das Rahmen-System gehört exklusiv zu + * `project.openings`, s. {@link resolveOpeningFrame}). `positions` in + * MODELL-Metern. + */ +export function emitLegacyDoorLeaves(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const wall of project.walls) { + const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y); + if (axisLen < 1e-9) continue; + const { zBottom } = wallVerticalExtent(project, wall); + for (const d of project.doors ?? []) { + if (d.hostWallId !== wall.id) continue; + const from = Math.max(0, Math.min(d.position, axisLen)); + const to = Math.max(from, Math.min(d.position + d.width, axisLen)); + if (to - from <= EPS) continue; + const zTop = zBottom + d.height; + if (zTop - zBottom <= EPS) continue; + const mesh = openingAxisBox( + wall, + from, + to, + zBottom, + zTop, + -DOOR_LEAF_THICK / 2, + DOOR_LEAF_THICK / 2, + DOOR_LEAF_RGB, + ); + if (mesh) out.push(mesh); + } + } + return out; +} + +/** + * Emittiert je Öffnung MIT auflösbarem Typ (`getDoorType`/`getWindowType`) die + * Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz, Schwelle, Kämpfer, + * Mittelpfosten — s. {@link frameMeshesForOpening}) als rohe Quader-Meshes in + * {@link FRAME_RGB}. Öffnungen OHNE auflösbaren Typ (`typeId` fehlt/verwaist) + * bekommen KEINEN Rahmen — exakt das Alt-Verhalten vor der Typ-Vertiefung + * (Rückwärtskompatibilität). + */ +export function emitOpeningFrames(project: Project, detail: DetailLevel = "fein"): RMesh[] { + // Grob: keine Rahmen/Sprossen — die Öffnung bleibt Loch + Scheibe (schlank). + if (detail === "grob") return []; + const out: RMesh[] = []; + for (const wall of project.walls) { + for (const op of openingsOfWall(project, wall.id)) { + const iv = openingInterval(wall, op); + if (!iv) continue; + const vert = openingVerticalExtent(project, wall, op); + if (vert.zTop - vert.zBottom <= EPS) continue; + const params = resolveOpeningFrame(project, wall, op); + if (!params) continue; + out.push(...frameMeshesForOpening(wall, iv, vert, params, detail)); + } + } + return out; +} + +/** + * Rollladen-/Sonnenschutzkasten als 3D-Box über der Öffnung (nur Fenster mit + * `WindowType.shading.create`, nur mittel/fein). Ein heller Quader, aussen an + * der Aussenfläche (params.nMax) ansetzend und um `box.depth` nach aussen + * ragend, von der Öffnungs-OK um `box.height` nach oben. Breite = `box.width` + * (0/fehlt ⇒ Öffnungsbreite), mittig über der Öffnung. Pendant zur 2D-Kontur + * (`window-shading` in generatePlan/openings.ts). + */ +export function emitOpeningShading(project: Project, detail: DetailLevel = "fein"): RMesh[] { + if (detail === "grob") return []; + const out: RMesh[] = []; + for (const wall of project.walls) { + for (const op of openingsOfWall(project, wall.id)) { + if (op.kind !== "window") continue; + const wt = getWindowType(project, op); + if (!wt?.shading?.create) continue; + const iv = openingInterval(wall, op); + if (!iv) continue; + const vert = openingVerticalExtent(project, wall, op); + if (vert.zTop - vert.zBottom <= EPS) continue; + const params = resolveOpeningFrame(project, wall, op); + if (!params) continue; + const box = wt.shading.box; + const h = Math.max(0, box.height); + const d = Math.max(0, box.depth); + if (h <= EPS || d <= EPS) continue; + const span = box.width > 0 ? box.width : iv.to - iv.from; + const center = (iv.from + iv.to) / 2; + const from = center - span / 2; + const to = center + span / 2; + // Aussen an der Aussenfläche (nMax) ansetzen und um die Kastentiefe hinaus. + const nOuter = params.nMax; + const mesh = openingAxisBox( + wall, + from, + to, + vert.zTop, + vert.zTop + h, + nOuter, + nOuter + d, + SHADING_RGB, + ); + if (mesh) out.push(mesh); + } + } + return out; +} diff --git a/src/plan/toWalls3d/roofs.ts b/src/plan/toWalls3d/roofs.ts new file mode 100644 index 0000000..2a4aac7 --- /dev/null +++ b/src/plan/toWalls3d/roofs.ts @@ -0,0 +1,147 @@ +// Dach-Domäne der 3D-Ableitung: emittiert Dächer (`project.roofs`) als +// geschlossene, geschichtete Dreiecks-Meshes (Eindeckung → innen) aus den +// geneigten Dachflächen (`RoofGeometry.planes`) — jede Schicht ein eigener +// solider Slab mit ihrer Bauteilfarbe/-textur. + +import type { Project } from "../../model/types"; +import { getComponent, roofLayers } from "../../model/types"; +import { roofBaseElevation, roofGeometry } from "../../geometry/roof"; +import type { Vec3 as RoofVec3 } from "../../geometry/roof"; +import { EPS, hexToRgb, resolveComponentMaterialLayer, type RMesh, type RRgb } from "./shared"; + +/** Warmer Terrakotta-Grauton für Dachflächen/-giebel — hebt das Dach klar von Wand/Decke/Kontext ab. */ +const ROOF_RGB: RRgb = [0.72, 0.45, 0.36]; + +/** + * Trianguliert ein KONVEXES 3D-Polygon per Dreiecks-Fächer ab Vertex 0 und hängt + * die Dreiecke an `positions`/`indices` an. `roofGeometry` (Vertrag in + * `geometry/roof.ts`) liefert für `planes`/`gables` ausschliesslich konvexe + * Polygone (Dreieck/Quader bei den meisten Formen, ein Fünfeck-Giebel bei + * Mansarde) — ein Fächer trianguliert diese korrekt, ohne Ear-Clipping. + */ +function pushFanTriangles(positions: number[], indices: number[], pts: RoofVec3[]): void { + if (pts.length < 3) return; + const base = positions.length / 3; + for (const p of pts) positions.push(p[0], p[1], p[2]); + for (let i = 1; i < pts.length - 1; i++) indices.push(base, base + i, base + i + 1); +} + +/** + * Robuste Flächennormale (Newell) einer Polygon-Ebene in MODELL-Koordinaten + * ([x, y, z=Höhe]), nach OBEN orientiert (positive Höhen-Komponente) — für die + * Dach-Dickenversetzung (Unterseite = Oberseite − Normale·Dicke). + */ +function planeNormalUp(pts: RoofVec3[]): [number, number, number] { + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 0; i < pts.length; i++) { + const c = pts[i]; + const d = pts[(i + 1) % pts.length]; + nx += (c[1] - d[1]) * (c[2] + d[2]); + ny += (c[2] - d[2]) * (c[0] + d[0]); + nz += (c[0] - d[0]) * (c[1] + d[1]); + } + const len = Math.hypot(nx, ny, nz) || 1; + const u: [number, number, number] = [nx / len, ny / len, nz / len]; + return u[2] < 0 ? [-u[0], -u[1], -u[2]] : u; +} + +/** + * Eine Dachfläche als SOLIDER Slab (Oberseite + um `thickness` entlang der + * Normalen nach unten versetzte Unterseite + umlaufende Seitenwände) — gibt dem + * Dach eine echte Materialdicke statt einer papierdünnen Fläche. render3d + * rendert doppelseitig, Winding ist unerheblich. + */ +function pushRoofSlab( + positions: number[], + indices: number[], + pts: RoofVec3[], + thickness: number, +): void { + if (pts.length < 3 || thickness <= EPS) { + pushFanTriangles(positions, indices, pts); + return; + } + const nrm = planeNormalUp(pts); + const bottom: RoofVec3[] = pts.map((p) => [ + p[0] - nrm[0] * thickness, + p[1] - nrm[1] * thickness, + p[2] - nrm[2] * thickness, + ]); + pushFanTriangles(positions, indices, pts); // Oberseite + pushFanTriangles(positions, indices, bottom); // Unterseite + // Seitenwände: je Kante ein Quad (zwei Dreiecke) zwischen Ober- und Unterseite. + for (let i = 0; i < pts.length; i++) { + const j = (i + 1) % pts.length; + const base = positions.length / 3; + positions.push(pts[i][0], pts[i][1], pts[i][2]); + positions.push(pts[j][0], pts[j][1], pts[j][2]); + positions.push(bottom[j][0], bottom[j][1], bottom[j][2]); + positions.push(bottom[i][0], bottom[i][1], bottom[i][2]); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } +} + +/** + * Emittiert Dächer (`project.roofs`) als geschlossene Dreiecks-Meshes: sowohl die + * geneigten Dachflächen (`RoofGeometry.planes`) als auch die Giebelflächen + * (`RoofGeometry.gables`) — erst zusammen bilden sie einen von aussen + * geschlossenen Körper (die Giebel schliessen die offenen Enden bei Sattel/ + * Mansarde, die First-/Grat-/Knicklinien sind reine Grundriss-Hilfslinien und + * werden hier nicht gebraucht). Traufhöhe über `roofBaseElevation` + * (Override oder Geschoss-Oberkante), Flächen über `roofGeometry`. Farbe: + * `roof.color` (falls gültiges Hex), sonst {@link ROOF_RGB}. `positions` in + * MODELL-Metern (x, y, z=Höhe); render3d rendert Kontext-Meshes doppelseitig, + * die Winding-Reihenfolge ist also unerheblich. + */ +export function emitRoofs(project: Project): RMesh[] { + const out: RMesh[] = []; + for (const roof of project.roofs ?? []) { + const eavesZ = roofBaseElevation(project, roof); + const g = roofGeometry(roof, eavesZ); + const overrideColor = roof.color ? hexToRgb(roof.color, ROOF_RGB) : null; + // Schichtaufbau (aussen/Eindeckung → innen). Jede Schicht wird zu einem + // eigenen RMesh mit ihrer Bauteilfarbe, entlang der Flächennormalen unter die + // vorige gestapelt. Leere componentId (einschichtige thickness-Fallback) ⇒ + // Default-Dachfarbe. Eine Roof.color-Übersteuerung gilt für alle Schichten. + const layers = roofLayers(project, roof); + layers.reduce((offsetAbove, layer) => { + const t = Math.max(0, layer.thickness); + if (t <= EPS) return offsetAbove; + const color = + overrideColor ?? + (layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB); + // Material-Textur-Ebene fürs Textured-Rendering (z. B. echte Dachziegel- + // Textur der Eindeckung) — nur wenn KEIN Farb-Override greift (Override + // = bewusst eine einheitliche Farbe für alle Schichten, kein Bauteil-Bezug). + const materialIndex = + !overrideColor && layer.componentId + ? resolveComponentMaterialLayer(project, layer.componentId) + : undefined; + const positions: number[] = []; + const indices: number[] = []; + for (const plane of g.planes) { + // Oberseite dieser Schicht = Dachfläche um `offsetAbove` entlang der + // Normalen nach innen versetzt; Slab-Dicke = Schichtdicke. + const nrm = planeNormalUp(plane.pts); + const top: RoofVec3[] = plane.pts.map((p) => [ + p[0] - nrm[0] * offsetAbove, + p[1] - nrm[1] * offsetAbove, + p[2] - nrm[2] * offsetAbove, + ]); + pushRoofSlab(positions, indices, top, t); + } + if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex }); + return offsetAbove + t; + }, 0); + // Giebel-ENDFÜLLUNG bewusst NICHT mehr emittieren: die dreieckige Giebelfläche + // wurde früher als papierdünne Einzelfläche gebaut ("komischer Abschluss" an + // den Sattel-/Mansard-Enden) und lag zudem deckungsgleich vor der Giebelwand, + // die ohnehin bis an die Dach-Unterkante hochgeklemmt wird (s. + // `../bands.ts::clipPieceToRoofs`) — also doppelt und optisch als dünne + // 2D-Schicht sichtbar. Es wird nur noch das Dach SELBST (die geneigten + // Slab-Flächen) konstruiert; der Giebel entsteht aus der Wand darunter. + } + return out; +} diff --git a/src/plan/toWalls3d/shared.ts b/src/plan/toWalls3d/shared.ts new file mode 100644 index 0000000..16b50de --- /dev/null +++ b/src/plan/toWalls3d/shared.ts @@ -0,0 +1,385 @@ +// Gemeinsame Grundlage der 3D-Ableitung: das Serde-Vokabular (`RWall`/`RSlab`/ +// `RMesh`/…, matcht 1:1 render3d::types) sowie die Farb-/Schraffur-/Material- +// Auflösung, die über mehrere Bauteil-Domänen hinweg identisch gelten muss +// (Hex→RGB, dominante Schicht, Schnitt-Schraffur, Material-Textur-Ebene). +// +// Jede Datei unter `toWalls3d/` importiert von hier, NIE von `../toWalls3d` +// selbst — das hielte die Orchestrator-Datei (`toWalls3d.ts`) andernfalls in +// einem Zirkelimport mit jeder einzelnen Domäne. + +import type { HatchPattern, Layer, Project } from "../../model/types"; +import { getCeilingType, getComponent, getWallType, roofLayers } from "../../model/types"; +import { resolveHatch } from "../generatePlan"; + +export type RVec2 = [number, number]; +export type RRgb = [number, number, number]; + +/** + * Eine aufgelöste Wandschicht: Dicke + 3D-Albedo-Farbe (aus dem Component). + * Wird seit der TS-seitigen Schicht-Zerlegung (Option B, siehe toWalls3d.ts- + * Moduldoc) NICHT mehr an render3d durchgereicht — jede Schicht ist eine eigene + * Voll-Box; das `RWall.layers`-Feld bleibt für Serde-Kompatibilität, aber stets leer. + */ +export interface RLayer { + thickness: number; + 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 `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). + */ +export 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). + */ +export 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 (`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. + } + } + for (const r of project.roofs ?? []) { + for (const l of roofLayers(project, r)) { + if (l.componentId) consider(l.componentId); + } + } + 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}. + */ +export 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 + * `RWall.start` (0..Achslänge, dieselbe Achse wie die Öffnungs-Intervalle); + * `zBottom`/`zTop` sind ABSOLUTE Weltmeter (NICHT relativ zur Wand-UK — anders + * als `Opening.sillHeight`). Eine Tür ist der Sonderfall + * `zBottom == RWall.baseElevation` (Loch bis zur Wand-UK). render3d stanzt die + * Löcher als echte Aussparungen in die beiden Langseiten (Rechteck-Gitter- + * Zerlegung + Laibungsflächen), statt die Wand in Pfeiler/Brüstung/Sturz zu + * zerlegen. + */ +export interface RHole { + from: number; + to: number; + zBottom: number; + 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; + thickness: number; + height: number; + baseElevation: number; + color: RRgb; + /** + * Immer leer: die Schichten werden TS-seitig in eigene Voll-Boxen zerlegt + * (Option B, siehe toWalls3d.ts-Moduldoc). Feld bleibt nur zur Serde- + * Kompatibilität mit render3d::WallInput (leer ⇒ Vollkörper-Extrusion in `color`). + */ + layers: RLayer[]; + /** + * Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer + * Wand tragen dieselbe Id. Rein für den TS-seitigen Pick-Raycast (raycast3d); + * der render3d/Serde-Pfad ignoriert das Feld (kein `deny_unknown_fields`, + * serde verwirft unbekannte Felder). + */ + wallId: string; + /** + * Echte rechteckige Löcher (Fenster/Türen) in DIESEM Wandkörper (siehe + * {@link RHole}). Nur im 3D-Viewer-Pfad (`projectToModel3d` mit + * `layeredWalls:true`) gesetzt: dort emittiert `emitWall` EINEN RWall pro + * Schicht-Band über die volle Achse mit ALLEN Öffnungen als `holes` — statt der + * Pfeiler/Brüstung/Sturz-Segment-Boxen. So bildet EIN Körper auch versetzt + * übereinanderliegende Fenster ab (das kann die Segment-Zerlegung nicht). Der + * Schnitt-Pfad (`layeredWalls:false`) setzt `holes` NICHT und segmentiert + * unverändert weiter (Owner-Kompatibilität mit `toSection.ts`, s. u.). Fehlt/ + * leer ⇒ Vollkörper ohne Aussparung (render3d: `#[serde(default)]`). `holes` + * 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. */ +export interface RSlab { + outline: RVec2[]; + zBottom: number; + zTop: number; + color: RRgb; + /** + * Id der Ursprungs-Decke (`project.ceilings[].id`). Nur für den TS-seitigen + * Pick-Raycast (raycast3d); der render3d/Serde-Pfad ignoriert das Feld + * (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" | "extrusion"; + +/** + * Ein rohes Dreiecks-Mesh aus dem Geo-Kontext (Terrain-TIN oder importiertes + * Gebäude-/DXF-Volumen). `positions` ist das flache (x,y,z=Höhe…)-Array in + * MODELL-Metern — genau wie `ImportedMesh`/`TerrainMesh` es hält; render3d bildet + * es selbst nach world (x, z, y) ab (analog Wände/Decken). Matcht + * render3d::types::MeshInput. + */ +export interface RMesh { + positions: number[]; + indices: number[]; + kind: RMeshKind; + color: RRgb; + /** Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je + * Positions-Vertex. Damit wird z. B. ein SWISSIMAGE-Luftbild aufs Gelände-TIN + * „drapiert" (jeder Vertex trägt seinen Bildpunkt). Fehlt/zu kurz -> `color` + * fürs ganze Mesh. Matcht `render3d::types::MeshInput.vertexColors`. */ + vertexColors?: number[]; + /** Material-Textur-Ebene (1-basiert, siehe {@link wallMaterialColorMaps}) für + * den Stil „Textured" — bislang nur von Dach-Schichten gesetzt. Fehlt sie, + * zeigt render3d das Fallback-Schachbrett. Matcht + * `render3d::types::MeshInput.materialIndex`. */ + materialIndex?: number; +} + +/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */ +export interface RModel3d { + walls: RWall[]; + slabs: RSlab[]; + meshes: RMesh[]; +} + +/** Warmer, neutraler Wand-Grundton (wie render3d default). */ +export const WALL_RGB: RRgb = [0.82, 0.8, 0.76]; +/** Etwas hellerer, kühlerer Ton für Deckenplatten (heben sich von Wänden ab). */ +export const SLAB_RGB: RRgb = [0.86, 0.86, 0.88]; +/** Gedämpftes Lehm-/Geländegrün-grau (three.js mats.terrain = 0xa9b0a2). */ +export const TERRAIN_RGB: RRgb = [169 / 255, 176 / 255, 162 / 255]; +/** Neutrales Hellgrau für importierte Meshes (three.js mats.mesh = 0x9fa6ae). */ +export const IMPORTED_RGB: RRgb = [159 / 255, 166 / 255, 174 / 255]; +/** Fallback-Farbe für 2D-Zeichnungen ohne color/lineStyle/Kategorie (wie drawing2DColor in Viewport3D.tsx, "#888888"). */ +export const DRAWING_LINE_FALLBACK_RGB: RRgb = [0x88 / 255, 0x88 / 255, 0x88 / 255]; + +/** Ignoriere entartete vertikale Ausschnitte (Rundungsrauschen). */ +export const EPS = 1e-4; + +/** + * Wandelt eine Hex-Farbe ("#rrggbb", Component-Farbe im Plan) in float-RGB + * (0..1, render3d-Konvention) um. Ungültiges/fehlendes Format fällt auf den + * neutralen Wand-Grundton zurück (nie ein hartes Fehlbild). + */ +export function hexToRgb(hex: string, fallback: RRgb = WALL_RGB): RRgb { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex ?? ""); + if (!m) return fallback; + const v = parseInt(m[1], 16); + return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255]; +} + +/** + * Repräsentative Bauteilfarbe eines Schichtaufbaus: die Farbe der DICKSTEN + * Schicht (i. d. R. die tragende/dominante Schicht, z. B. Mauerwerk/Beton) — + * einfache Näherung, solange Wände/Decken als EIN Vollkörper emittiert werden + * (kein Schicht-Rendering in 3D). Fällt auf `fallback` zurück, wenn keine + * Schicht vorhanden ist oder deren Component nicht auflösbar ist. + */ +export function dominantLayerColor(layers: Layer[], project: Project, fallback: RRgb): RRgb { + if (layers.length === 0) return fallback; + const dominant = layers.reduce((best, l) => (l.thickness > best.thickness ? l : best)); + try { + return hexToRgb(getComponent(project, dominant.componentId).color, fallback); + } catch { + return fallback; + } +} + +/** + * 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/walls.ts`). + * 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. Von {@link ../bands.resolveWallBands} + * UND `../ceilings.emitSlabs` genutzt (Wand- wie Deckenschicht teilen dieselbe + * Fugen-Auflösung). + */ +export 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 }; +} diff --git a/src/plan/toWalls3d/stairs.ts b/src/plan/toWalls3d/stairs.ts new file mode 100644 index 0000000..c0b318d --- /dev/null +++ b/src/plan/toWalls3d/stairs.ts @@ -0,0 +1,155 @@ +// Treppen-Domäne der 3D-Ableitung: emittiert Treppen als extrudierte RSlabs +// (Tritte + Podest, tragart-abhängige Untersicht) — der WASM/wgpu-Renderer +// kennt sonst KEINE Treppen (nur Wände/Decken/Meshes). Für Ortbeton-Läufe +// zusätzlich ein glattes Dreiecks-Mesh der schrägen Laufplatten-Untersicht. + +import type { Project, Vec2 } from "../../model/types"; +import { getStairType } from "../../model/types"; +import { stairVerticalExtent } from "../../model/wall"; +import { stairGeometry } from "../../geometry/stair"; +import { leftNormal, normalize } from "../../model/geometry"; +import { EPS, SLAB_RGB, hexToRgb, type RMesh, type RRgb, type RSlab, type RVec2 } from "./shared"; + +/** Neutraler Beton-Grauton für Treppen (wie der Three.js-Pfad `addStairMeshes`). */ +const STAIR_RGB: RRgb = hexToRgb("#9aa0a6", SLAB_RGB); + +/** Laufplattendicke einer Ortbetontreppe (Meter). */ +const CONCRETE_WAIST = 0.18; + +/** + * 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 `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 `pickGeometry`). + */ +export 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. + */ +export 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; +} diff --git a/src/section/hlr.ts b/src/section/hlr.ts deleted file mode 100644 index 9edfefb..0000000 --- a/src/section/hlr.ts +++ /dev/null @@ -1,270 +0,0 @@ -// Hidden-Line-Removal (HLR) über OCCT-WASM — Welle-C-Spike. -// -// Ziel: aus einfachen Volumenkörpern (Wände, Decke) eine 2D-Vektor-Projektion -// (Schnitt/Ansicht) mit ENTFERNTEN verdeckten Kanten erzeugen — die Grundlage, -// aus der später die bestehende SVG-Plan-Pipeline Schnitte/Ansichten zeichnet. -// -// Kernbefund des Spikes (siehe Report): der opencascade.js-Vollbuild 1.1.1 -// exportiert die klassischen HLR-Klassen (`HLRBRep_Algo`/`HLRBRep_HLRToShape`, -// `HLRBRep_PolyAlgo`/`HLRBRep_PolyHLRToShape`) NICHT als konstruierbare Klassen, -// sondern nur deren `Handle_…`-Wrapper. Konstruierbar ist aber der High-Level- -// Wrapper `HLRAppli_ReflectLines`, der intern GENAU den exakten HLR-Algorithmus -// (`HLRBRep_Algo`) fährt und sichtbare/verdeckte Kanten getrennt liefert — -// aufgeteilt nach Kanten-Typ (Sharp/OutLine/…). Das ist der hier genutzte Pfad. -// -// Identifier englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: METER. - -import { - loadOcct, - type GpPnt, - type OpenCascadeInstance, - type TopoDS_Shape, -} from "./occt"; - -/** 2D-Punkt in der Projektionsebene (Meter). */ -export interface Pt2 { - x: number; - y: number; -} - -/** Achsparalleler Quader: Ursprung (min-Ecke) + Größe (Meter). */ -export interface BoxSpec { - origin: [number, number, number]; - size: [number, number, number]; -} - -/** - * Orthographische Blickrichtung. `dir` = Blickrichtung (worauf die Kamera - * schaut), `up` = Hoch-Achse der Projektionsebene. Voreinstellungen decken die - * Ansichtstypen der Roadmap ab (Grundriss = Top, Schnitt/Ansicht = Front/…). - */ -export interface ViewDir { - dir: [number, number, number]; - up: [number, number, number]; -} - -/** Häufige Projektionen (rechtshändig, Z = oben). */ -export const VIEWS = { - /** Grundriss: von oben (−Z), Hoch-Achse = +Y. */ - top: { dir: [0, 0, -1], up: [0, 1, 0] } as ViewDir, - /** Ansicht/Schnitt von vorne (−Y), Hoch-Achse = +Z. */ - front: { dir: [0, -1, 0], up: [0, 0, 1] } as ViewDir, - /** Ansicht/Schnitt von der Seite (−X), Hoch-Achse = +Z. */ - side: { dir: [-1, 0, 0], up: [0, 0, 1] } as ViewDir, -} as const; - -/** Eine projizierte Polylinie (2D) mit Sichtbarkeits-Flag. */ -export interface HlrPolyline { - pts: Pt2[]; - /** `true` = sichtbare Kante, `false` = verdeckte Kante. */ - visible: boolean; - /** Kanten-Herkunft (für spätere Stil-/Linien-Zuordnung). */ - kind: "sharp" | "outline"; -} - -/** Ergebnis eines HLR-Laufs. */ -export interface HlrResult { - visible: HlrPolyline[]; - hidden: HlrPolyline[]; - /** Reine Rechenzeit des HLR (ohne WASM-Init), Millisekunden. */ - hlrMs: number; -} - -export interface HlrOptions { - /** Verdeckte Kanten mitberechnen (Default: true). */ - includeHidden?: boolean; - /** Silhouetten-/OutLine-Kanten mitnehmen (Default: true). */ - includeOutline?: boolean; -} - -// ── Geometrie-Aufbau ───────────────────────────────────────────────────────── - -function makeBox(oc: OpenCascadeInstance, spec: BoxSpec): TopoDS_Shape { - const [ox, oy, oz] = spec.origin; - const [sx, sy, sz] = spec.size; - const p0 = new oc.gp_Pnt_3(ox, oy, oz); - const p1 = new oc.gp_Pnt_3(ox + sx, oy + sy, oz + sz); - return new oc.BRepPrimAPI_MakeBox_3(p0, p1).Shape(); -} - -/** Mehrere Boxen zu EINEM Solid verschmelzen (BRepAlgoAPI_Fuse, sequenziell). */ -function fuseBoxes(oc: OpenCascadeInstance, specs: BoxSpec[]): TopoDS_Shape { - if (specs.length === 0) throw new Error("hlr: keine Box-Spezifikation"); - let acc = makeBox(oc, specs[0]); - for (let i = 1; i < specs.length; i++) { - const next = makeBox(oc, specs[i]); - const op = new oc.BRepAlgoAPI_Fuse_3(acc, next); - op.Build?.(); - acc = op.Shape(); - } - return acc; -} - -// ── Kanten-Extraktion ──────────────────────────────────────────────────────── - -/** - * Eine (bereits 2D-projizierte) OCCT-Kante als Polylinie ausgeben. Die HLR- - * Kanten sind gerade Segmente (Box-Fusion), daher genügen Start-/Endpunkt der - * Kurve; für spätere gekrümmte Geometrie hier zusätzlich tessellieren. - */ -function edgeToPolyline( - oc: OpenCascadeInstance, - edgeShape: TopoDS_Shape, -): Pt2[] { - const edge = oc.TopoDS.Edge_1(edgeShape); - const adaptor = new oc.BRepAdaptor_Curve_2(edge); - const u0 = adaptor.FirstParameter(); - const u1 = adaptor.LastParameter(); - const a: GpPnt = adaptor.Value(u0); - const b: GpPnt = adaptor.Value(u1); - // 2D-Projektion: X = horizontal, Y = vertikal (Z≈0 in der Projektionsebene). - return [ - { x: a.X(), y: a.Y() }, - { x: b.X(), y: b.Y() }, - ]; -} - -function collectEdges( - oc: OpenCascadeInstance, - compound: TopoDS_Shape, - visible: boolean, - kind: "sharp" | "outline", -): HlrPolyline[] { - const out: HlrPolyline[] = []; - const exp = new oc.TopExp_Explorer_2( - compound, - oc.TopAbs_ShapeEnum.TopAbs_EDGE, - oc.TopAbs_ShapeEnum.TopAbs_SHAPE, - ); - for (; exp.More(); exp.Next()) { - const pts = edgeToPolyline(oc, exp.Current()); - if (pts.length >= 2) out.push({ pts, visible, kind }); - } - return out; -} - -// ── Öffentliche API ────────────────────────────────────────────────────────── - -/** - * HLR direkt auf einem bereits aufgebauten OCCT-Shape ausführen. Liefert - * sichtbare (+ optional verdeckte) 2D-Polylinien in der Projektionsebene. - */ -export function hlrShape( - oc: OpenCascadeInstance, - shape: TopoDS_Shape, - view: ViewDir, - opts: HlrOptions = {}, -): HlrResult { - const includeHidden = opts.includeHidden ?? true; - const includeOutline = opts.includeOutline ?? true; - - const t0 = performance.now(); - const rl = new oc.HLRAppli_ReflectLines(shape); - const [dx, dy, dz] = view.dir; - const [ux, uy, uz] = view.up; - // SetAxes(projDir, projPoint(at), upDir). Blickpunkt = Ursprung genügt für - // eine orthographische Projektion (Lage in der Ebene ist translationsfrei). - rl.SetAxes(dx, dy, dz, 0, 0, 0, ux, uy, uz); - rl.Perform(); - - const T = oc.HLRBRep_TypeOfResultingEdge; - const visible: HlrPolyline[] = []; - const hidden: HlrPolyline[] = []; - - // in3d=false → 2D-projizierte Kanten. - visible.push( - ...collectEdges( - oc, - rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false), - true, - "sharp", - ), - ); - if (includeOutline) { - visible.push( - ...collectEdges( - oc, - rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false), - true, - "outline", - ), - ); - } - if (includeHidden) { - hidden.push( - ...collectEdges( - oc, - rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false), - false, - "sharp", - ), - ); - if (includeOutline) { - hidden.push( - ...collectEdges( - oc, - rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, false, false), - false, - "outline", - ), - ); - } - } - - const hlrMs = performance.now() - t0; - return { visible, hidden, hlrMs }; -} - -/** - * Komfort-Einstieg: aus Box-Spezifikationen (Wände/Decke) einen Solid fusen und - * HLR für die gegebene Blickrichtung laufen lassen. Lädt OCCT lazy. - */ -export async function hlrFromBoxes( - boxes: BoxSpec[], - view: ViewDir, - opts: HlrOptions = {}, -): Promise { - const oc = await loadOcct(); - const shape = fuseBoxes(oc, boxes); - return hlrShape(oc, shape, view, opts); -} - -// ── SVG-Ausgabe (Diagnose/Proof) ───────────────────────────────────────────── - -/** - * Minimaler SVG-Dump zur visuellen Kontrolle: sichtbare Kanten durchgezogen, - * verdeckte gestrichelt. Kein Bestandteil der Plan-Pipeline — nur Proof/Debug. - */ -export function hlrToSvg(result: HlrResult, padding = 0.5): string { - const all = [...result.visible, ...result.hidden]; - const xs: number[] = []; - const ys: number[] = []; - for (const pl of all) - for (const p of pl.pts) { - xs.push(p.x); - ys.push(p.y); - } - const minX = Math.min(...xs) - padding; - const maxX = Math.max(...xs) + padding; - const minY = Math.min(...ys) - padding; - const maxY = Math.max(...ys) + padding; - const w = maxX - minX; - const h = maxY - minY; - - // SVG-Y zeigt nach unten → Projektions-Y spiegeln. - const line = (pl: HlrPolyline): string => { - const d = pl.pts - .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${maxY - (p.y - minY)}`) - .join(" "); - const stroke = pl.visible ? "#111" : "#999"; - const dash = pl.visible ? "" : ' stroke-dasharray="0.1 0.1"'; - const sw = pl.kind === "outline" ? 0.04 : 0.02; - return ``; - }; - - const paths = all.map(line).join("\n "); - return ` - - ${paths} -`; -} diff --git a/src/section/occt-wasm.d.ts b/src/section/occt-wasm.d.ts deleted file mode 100644 index fa9728a..0000000 --- a/src/section/occt-wasm.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von opencascade.js -// (Welle-C-Spike: Hidden-Line-Removal). Analog zu `src/io/libredwg-web.d.ts`: -// das Paket liefert seine `dist/`-Dateien nicht über sinnvolle `exports`, daher -// laden wir Emscripten-Glue + `.wasm`-URL über zwei virtuelle Module (Aliase in -// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs. -// (Identifier englisch, Kommentare deutsch — CONVENTIONS.md.) - -declare module "virtual:occt-glue" { - /** - * Emscripten-Modul-Fabrik von opencascade.js. Nimmt Modul-Overrides - * (u. a. `locateFile`, um die `.wasm`-URL aufzulösen) und liefert das - * initialisierte Modul (das gesamte OCCT-Binding als `unknown`). - */ - const createModule: (opts?: { - locateFile?: (filename: string, scriptDir: string) => string; - wasmBinary?: ArrayBuffer | Uint8Array; - }) => Promise; - export default createModule; -} - -declare module "virtual:occt-wasm-url" { - /** Von Vite aufgelöste URL der `.wasm`-Datei (gehashtes Asset im Build). */ - const url: string; - export default url; -} diff --git a/src/section/occt.ts b/src/section/occt.ts deleted file mode 100644 index d16600f..0000000 --- a/src/section/occt.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Lazy-Loader + schmale Typ-Fassade für opencascade.js (OCCT-WASM). -// -// Welle-C-Spike (Hidden-Line-Removal). Das Paket `opencascade.js` (Vollbuild -// 1.1.1) liefert KEINE TypeScript-Typen und exportiert ~20 000 embind-Symbole. -// Wir tippen hier NUR die Klassen/Methoden, die `hlr.ts` tatsächlich benutzt — -// verifiziert gegen die reale Laufzeit-API (embind versioniert überladene -// Konstruktoren als `_1`, `_2`, …). -// -// Laden erfolgt bewusst über einen dynamischen Import (siehe `loadOcct`), damit -// die 66-MB-WASM NICHT ins Haupt-Bundle wandert und nur bei tatsächlichem -// Bedarf (Schnitt-/Ansichts-Generierung) geholt wird. -// -// Identifier englisch, Kommentare deutsch (CONVENTIONS.md). - -// ── Schmale Typ-Fassade (nur das Benutzte) ─────────────────────────────────── - -export interface GpPnt { - X(): number; - Y(): number; - Z(): number; -} - -export interface TopoDS_Shape { - ShapeType(): unknown; - IsNull(): boolean; -} - -interface TopoDS_Edge extends TopoDS_Shape {} - -interface Ctor1 { - new (a: A): T; -} -interface Ctor3 { - new (a: A, b: B, c: C): T; -} - -interface ShapeEnum { - TopAbs_EDGE: unknown; - TopAbs_FACE: unknown; - TopAbs_SHAPE: unknown; - TopAbs_SOLID: unknown; -} - -interface ResultingEdgeEnum { - HLRBRep_Sharp: unknown; - HLRBRep_OutLine: unknown; - HLRBRep_IsoLine: unknown; - HLRBRep_Rg1Line: unknown; - HLRBRep_RgNLine: unknown; -} - -interface TopExpExplorer { - More(): boolean; - Next(): void; - Current(): TopoDS_Shape; -} - -interface BRepAdaptorCurve { - FirstParameter(): number; - LastParameter(): number; - Value(u: number): GpPnt; -} - -interface MakeBox { - Shape(): TopoDS_Shape; -} - -interface BooleanOp { - Shape(): TopoDS_Shape; - Build?(): void; -} - -interface ReflectLines { - SetAxes( - nx: number, - ny: number, - nz: number, - xAt: number, - yAt: number, - zAt: number, - xUp: number, - yUp: number, - zUp: number, - ): void; - Perform(): void; - /** - * Kanten-Compound holen. `visible`=true → sichtbar, false → verdeckt. - * `in3d`=true → 3D-Kanten, false → 2D-projizierte Kanten (Z≈0 in der - * Projektionsebene: X = horizontal, Y = vertikal). - */ - GetCompoundOf3dEdges( - type: unknown, - visible: boolean, - in3d: boolean, - ): TopoDS_Shape; -} - -/** Nur die von `hlr.ts` genutzten OCCT-Symbole. */ -export interface OpenCascadeInstance { - gp_Pnt_3: Ctor3; - - BRepPrimAPI_MakeBox_1: Ctor3; - BRepPrimAPI_MakeBox_3: Ctor1 & { - new (origin: GpPnt, corner: GpPnt): MakeBox; - }; - - BRepAlgoAPI_Fuse_3: { - new (a: TopoDS_Shape, b: TopoDS_Shape): BooleanOp; - }; - - HLRAppli_ReflectLines: Ctor1; - - TopExp_Explorer_2: { - new (s: TopoDS_Shape, toFind: unknown, toAvoid: unknown): TopExpExplorer; - }; - - BRepAdaptor_Curve_2: Ctor1; - - TopoDS: { Edge_1(s: TopoDS_Shape): TopoDS_Edge }; - - TopAbs_ShapeEnum: ShapeEnum; - HLRBRep_TypeOfResultingEdge: ResultingEdgeEnum; -} - -// ── Lazy-Loader ────────────────────────────────────────────────────────────── - -type OcctFactory = (opts?: { - locateFile?: (filename: string, scriptDir: string) => string; - wasmBinary?: ArrayBuffer | Uint8Array; -}) => Promise; - -let occtPromise: Promise | null = null; - -/** Optionale Metriken des letzten Ladevorgangs (für den Feasibility-Report). */ -export interface LoadMetrics { - wasmBytes: number; - fetchMs: number; - initMs: number; -} -let lastMetrics: LoadMetrics | null = null; -export const getLoadMetrics = (): LoadMetrics | null => lastMetrics; - -/** - * Lädt + initialisiert OCCT-WASM (lazy, einmalig). Glue + `.wasm`-URL werden — - * wie bei LibreDWG — über virtuelle Vite-Module aufgelöst, damit die WASM in Dev - * UND Build mit korrektem MIME-Type geladen wird. Der dynamische Import hält die - * 66-MB-WASM aus dem Haupt-Bundle heraus. - */ -export function loadOcct(): Promise { - if (!occtPromise) { - occtPromise = (async () => { - const [glueMod, wasmUrlMod] = await Promise.all([ - import("virtual:occt-glue"), - import("virtual:occt-wasm-url"), - ]); - const createModule = glueMod.default as OcctFactory; - const wasmUrl = wasmUrlMod.default; - - // WASM einmal explizit holen → Größe messen + als `wasmBinary` reichen - // (spart Emscripten den zweiten Fetch, macht die Größe messbar). - const tFetch = performance.now(); - const resp = await fetch(wasmUrl); - const wasmBinary = await resp.arrayBuffer(); - const fetchMs = performance.now() - tFetch; - - const tInit = performance.now(); - const instance = (await createModule({ - locateFile: () => wasmUrl, - wasmBinary, - })) as OpenCascadeInstance; - const initMs = performance.now() - tInit; - - lastMetrics = { wasmBytes: wasmBinary.byteLength, fetchMs, initMs }; - return instance; - })(); - } - return occtPromise; -} diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 099d8a2..da6e778 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -25,6 +25,7 @@ import type { } from "../model/types"; import { commitTransform } from "../tools/transform"; import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform"; +import { uniqueId } from "../tools/types"; import { centroid as roomCentroid } from "../geometry/roomArea"; import { t } from "../i18n"; import type { StoreApi } from "./store"; @@ -1417,6 +1418,18 @@ function resizeGeom( const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor); return { ...g, at: { x: ax + (g.at.x - ax) * sx, y: ay + (g.at.y - ay) * sy } }; } + case "image": { + const box = bbox2([g.min, g.max]); + const { ax, ay, sx, sy } = scaleAround(box, w, h, anchor); + const f = (p: Vec2): Vec2 => ({ x: ax + (p.x - ax) * sx, y: ay + (p.y - ay) * sy }); + const a = f(g.min); + const b = f(g.max); + return { + ...g, + min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) }, + max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) }, + }; + } default: return g; } @@ -1432,7 +1445,7 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { const g = d.geom; if (g.shape === "line") return [g.a, g.b]; if (g.shape === "polyline") return [...g.pts]; - if (g.shape === "rect") { + if (g.shape === "rect" || g.shape === "image") { return [ g.min, { x: g.max.x, y: g.min.y }, @@ -1445,6 +1458,38 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { return []; } +/** + * Fügt ein Bild-Zeichenelement (eingebettete Data-URL, z. B. Foto/Logo oder + * gerasterte PDF-Seite) achsparallel bei `[at .. at+size]` an — analog zu den + * Zeichenwerkzeugen, aber ohne Command (Datei-Auswahl ist async, s. App.tsx + * Bild-/PDF-Import). Direkt selektiert zurückgegeben, damit Griffe/Objekt-Info + * sofort zum Verschieben/Skalieren bereitstehen. + */ +export function addImageDrawing2d( + project: Project, + levelId: string, + categoryCode: string, + src: string, + at: Vec2, + widthM: number, + heightM: number, +): { project: Project; id: string } { + const id = uniqueId("dr2d"); + const d: Drawing2D = { + id, + type: "drawing2d", + levelId, + categoryCode, + geom: { + shape: "image", + min: at, + max: { x: at.x + widthM, y: at.y + heightM }, + src, + }, + }; + return { project: { ...project, drawings2d: [...project.drawings2d, d] }, id }; +} + /** * Verschiebt den Griff `index` des selektierten Elements (2D-Element ODER Wand) * auf `pt` (immutabel). Beim Rechteck bleibt die Achsparallelität erhalten. @@ -1469,7 +1514,7 @@ function moveGrip( const pts = g.pts.map((p, i) => (i === index ? pt : p)); return { ...d, geom: { ...g, pts } }; } - if (g.shape === "rect") { + if (g.shape === "rect" || g.shape === "image") { const corners = [ g.min, { x: g.max.x, y: g.min.y }, @@ -1518,6 +1563,7 @@ function moveElementBy( case "polyline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; case "rect": + case "image": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } }; case "circle": case "arc": @@ -1573,7 +1619,7 @@ function moveEdge( const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p)); return { ...d, geom: { ...g, pts } }; } - if (g.shape === "rect") { + if (g.shape === "rect" || g.shape === "image") { // Ecken in derselben Reihenfolge wie drawingVertices, die zwei Kanten- // Ecken verschieben, dann bbox neu bilden (achsparallel halten). const corners = [ diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index e1cf8ef..b5ccb2f 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -353,8 +353,15 @@ export interface Selection { | "extrudedSolid" | "column" | "roof" - | "contextObject"; + | "contextObject" + | "layoutAnnotation"; id: string; + /** + * Konkrete Art der Layout-Annotation (nur `kind === "layoutAnnotation"`) — + * steuert z. B., ob die Strichstärke editierbar ist (Linie/Rechteck ja, + * Text/Bild nein). + */ + annotKind?: "line" | "rect" | "text" | "image"; /** Grafik-Kategorie (Ebene) des Elements. */ categoryCode: string; /** @@ -408,6 +415,13 @@ export interface Selection { hatchSource?: AttributeSource; /** Ob die Form geschlossen ist (rect/circle/closed polyline) — nur Drawing2D. */ closed?: boolean; + /** + * Ob das Element ein Bild ist (Drawing2D `{shape:"image"}` ODER Layout- + * Annotation `kind:"image"`) — steuert die Deckkraft-Zeile im Attribute-Panel. + */ + isImage?: boolean; + /** Deckkraft (0..1) — nur wenn `isImage`. */ + opacity?: number; /** Achsparallele Bounding-Box in Modell-Metern. */ bbox: SelectionBBox; /** @@ -487,6 +501,7 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox { for (const p of g.pts) acc(p.x, p.y); break; case "rect": + case "image": acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); break; @@ -1009,6 +1024,8 @@ function drawingSelection(project: Project, d: Drawing2D): Selection { strokeWeightSource: d.strokeWeightSource, hatchSource: closed ? d.hatchSource : undefined, closed, + isImage: d.geom.shape === "image", + opacity: d.geom.shape === "image" ? d.geom.opacity ?? 1 : undefined, bbox: geomBBox(d.geom), }; } diff --git a/src/state/useExportHandlers.ts b/src/state/useExportHandlers.ts new file mode 100644 index 0000000..f33f881 --- /dev/null +++ b/src/state/useExportHandlers.ts @@ -0,0 +1,96 @@ +// Bündelt die PDF-/DXF-Plan-Export-Handler (Papier-/Modell-Space-Export der +// aktuellen Grundriss-Ansicht) — die Schnellexporte (CSV/IFC/OBJ/STL) liegen +// bereits in useProjectFileActions(). Nur aus App.tsx herausgezogen, um die +// Funktion zu entlasten — reine Verlagerung, keine Verhaltensänderung. +// +// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { generatePlan } from "../plan/generatePlan"; +import type { CategoryDisplayResolver } from "../plan/generatePlan"; +import type { DetailLevel } from "../ui/TopBar"; +import type { DisplayResolver } from "../viewport/Viewport3D"; +import { savePlanPdf } from "../export/exportPdf"; +import { savePlanDxf } from "../export/exportDxf"; +import type { ExportPdfDecision } from "../ui/ExportPdfDialog"; +import type { ExportDxfDecision } from "../ui/ExportDxfDialog"; +import { projectToWorldCoords } from "../model/geoRebase"; +import type { Project } from "../model/types"; + +export function useExportHandlers(opts: { + project: Project; + activeLevelId: string; + activeLevelName: string; + visibleCodes: Set; + categoryDisplay: CategoryDisplayResolver & DisplayResolver; + detail: DetailLevel; + referenceLines: boolean; + planColorMode: "color" | "mono"; + setExportOpen: (open: boolean) => void; + setExportDxfOpen: (open: boolean) => void; +}): { + onExportPdf: (decision: ExportPdfDecision) => Promise; + onExportDxf: (decision: ExportDxfDecision) => void; +} { + const { + project, + activeLevelId, + activeLevelName, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + planColorMode, + setExportOpen, + setExportDxfOpen, + } = opts; + + const onExportPdf = async (decision: ExportPdfDecision) => { + const plan = generatePlan( + project, + activeLevelId, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + planColorMode === "mono", + ); + await savePlanPdf(plan, { + scaleDenominator: decision.scaleDenominator, + paper: decision.paper, + orientation: decision.orientation, + title: activeLevelName, + subtitle: project.name, + fileName: `${project.name}-${activeLevelName}`, + }); + setExportOpen(false); + }; + + // DXF-Export: erzeugt den Plan mit EXAKT denselben Eingaben wie die Ansicht und + // schreibt ihn als Modell-Space-DXF in echten Metern (1:1). Layer aus den + // Kategorie-Ebenen; jede Entity auf ihrem Kategorie-Layer. + const onExportDxf = (decision: ExportDxfDecision) => { + // Georeferenziert exportieren: bei gesetztem Standort-Bezug (geoAnchor) + // schreibt der Modell-DXF die ECHTEN LV95-Koordinaten (weit vom Nullpunkt), + // damit die Datei in anderer CAD-Software lagerichtig ankommt — auch wenn + // lokal nahe (0,0) gezeichnet wurde. Ohne Bezug bleibt alles lokal. + // (Der Papier-/Layout-Export bleibt bewusst in lokalen Koordinaten.) + const exportProject = projectToWorldCoords(project); + const plan = generatePlan( + exportProject, + activeLevelId, + visibleCodes, + categoryDisplay, + detail, + referenceLines, + planColorMode === "mono", + ); + savePlanDxf(plan, exportProject, { + title: activeLevelName, + includeHatches: decision.includeHatches, + fileName: `${project.name}-${activeLevelName}`, + }); + setExportDxfOpen(false); + }; + + return { onExportPdf, onExportDxf }; +} diff --git a/src/state/useImportHandling.ts b/src/state/useImportHandling.ts new file mode 100644 index 0000000..15e51e3 --- /dev/null +++ b/src/state/useImportHandling.ts @@ -0,0 +1,159 @@ +// Bündelt die Import-Handler, die NICHT schon in useProjectFileActions() +// stecken (Projektdatei-Öffnen + DXF/DWG-Ausführung liegen dort): Bild/PDF +// einfügen (Werkzeug „image") und die App-weite Drag&Drop-Verdrahtung für +// DXF/DWG-Dateien (ruft `openImportForFile` aus useProjectFileActions auf). +// Nur aus App.tsx herausgezogen, um die Funktion zu entlasten — reine +// Verlagerung, keine Verhaltensänderung. +// +// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import type { DragEvent } from "react"; +import { useRef } from "react"; +import { addImageDrawing2d } from "./projectSlice"; +import type { NotifyKind } from "./notifySlice"; +import { defaultImageSizeM, readImageFile } from "../io/imageAsset"; +import { rasterizePdfPages } from "../io/pdfImport"; +import { t } from "../i18n"; +import type { Project, Vec2 } from "../model/types"; + +/** + * „Bild einfügen" (Werkzeug „image"): kein Klick-Platzieren (Datei-Auswahl ist + * async) — jedes gewählte Bild (oder jede gerasterte PDF-Seite) landet mittig + * am Modell-Nullpunkt, leicht kaskadiert bei mehreren, sofort selektiert + * (Griffe/Objekt-Info stehen direkt zum Verschieben/Skalieren bereit). + */ +export function useImageImportHandling(opts: { + project: Project; + activeLevelId: string; + activeCategoryCode: string; + setProject: (next: Project | ((p: Project) => Project)) => void; + notify: (message: string, kind?: NotifyKind) => void; + setSelectedDrawingId: (id: string | null) => void; + setSelectedWallIds: (ids: string[]) => void; + setSelectedCeilingIds: (ids: string[]) => void; + setSelectedOpeningIds: (ids: string[]) => void; + setSelectedStairIds: (ids: string[]) => void; + setSelectedRoomIds: (ids: string[]) => void; + setSelectedExtrudedSolidIds: (ids: string[]) => void; + setSelectedColumnIds: (ids: string[]) => void; + setSelectedRoofIds: (ids: string[]) => void; + setSelectedContextObjectIds: (ids: string[]) => void; + setSelectedSectionLineId: (id: string | null) => void; +}): { + insertImageDrawings: (assets: { src: string; naturalWidth: number; naturalHeight: number }[]) => void; + onImageOrPdfFileChosen: (file: File) => void; +} { + const { + project, + activeLevelId, + activeCategoryCode, + setProject, + notify, + setSelectedDrawingId, + setSelectedWallIds, + setSelectedCeilingIds, + setSelectedOpeningIds, + setSelectedStairIds, + setSelectedRoomIds, + setSelectedExtrudedSolidIds, + setSelectedColumnIds, + setSelectedRoofIds, + setSelectedContextObjectIds, + setSelectedSectionLineId, + } = opts; + + const insertImageDrawings = ( + assets: { src: string; naturalWidth: number; naturalHeight: number }[], + ) => { + let cur = project; + let lastId: string | null = null; + assets.forEach((a, i) => { + const { widthM, heightM } = defaultImageSizeM(a.naturalWidth, a.naturalHeight); + const cascade = i * 0.3; + const at: Vec2 = { x: -widthM / 2 + cascade, y: -heightM / 2 - cascade }; + const r = addImageDrawing2d(cur, activeLevelId, activeCategoryCode, a.src, at, widthM, heightM); + cur = r.project; + lastId = r.id; + }); + setProject(cur); + if (lastId) { + setSelectedDrawingId(lastId); + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + setSelectedRoofIds([]); + setSelectedContextObjectIds([]); + setSelectedSectionLineId(null); + } + }; + + const onImageOrPdfFileChosen = (file: File) => { + const isPdf = file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); + if (isPdf) { + void rasterizePdfPages(file) + .then((pages) => { + if (pages.length === 0) { + notify(t("image.pdfEmpty"), "error"); + return; + } + insertImageDrawings(pages); + }) + .catch(() => notify(t("image.pdfError"), "error")); + } else { + void readImageFile(file) + .then((a) => insertImageDrawings([a])) + .catch(() => notify(t("image.readError"), "error")); + } + }; + + return { insertImageDrawings, onImageOrPdfFileChosen }; +} + +/** + * App-weites Drag & Drop einer DXF/DWG-Datei: dragover muss preventDefault + * rufen, sonst lässt der Browser nicht fallen. dragenter/leave wird ausgezählt + * (Kind-Elemente lösen sonst Flackern aus). Ruft `openImportForFile` aus + * useProjectFileActions auf (dieselbe Logik wie der Datei-Dialog). + */ +export function useFileDropImport( + setDropActive: (active: boolean) => void, + openImportForFile: (file: File) => void, +): { + onAppDragEnter: (e: DragEvent) => void; + onAppDragOver: (e: DragEvent) => void; + onAppDragLeave: (e: DragEvent) => void; + onAppDrop: (e: DragEvent) => void; +} { + const dragDepthRef = useRef(0); + const hasFiles = (e: DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + const onAppDragEnter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepthRef.current += 1; + setDropActive(true); + }; + const onAppDragOver = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }; + const onAppDragLeave = (e: DragEvent) => { + if (!hasFiles(e)) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setDropActive(false); + }; + const onAppDrop = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setDropActive(false); + const f = e.dataTransfer.files?.[0]; + if (f) openImportForFile(f); + }; + return { onAppDragEnter, onAppDragOver, onAppDragLeave, onAppDrop }; +} diff --git a/src/state/useKeyboardShortcuts.ts b/src/state/useKeyboardShortcuts.ts new file mode 100644 index 0000000..90616de --- /dev/null +++ b/src/state/useKeyboardShortcuts.ts @@ -0,0 +1,549 @@ +// Bündelt die globalen Tastatur-Shortcut-Effekte (Undo/Redo, Tab-Feld-Zyklus, +// Werkzeug-Zifferntasten, aktives-Werkzeug Esc/Enter/Backspace, Alles-wählen + +// Löschen, Split/Join, Kopieren/Einfügen von 2D-Elementen, Boolean-Operationen, +// Transformations-Shortcuts) — nur aus App.tsx herausgezogen, um die Funktion +// zu entlasten. Reine Verlagerung der `useEffect`-Registrierungen inkl. ihrer +// Abhängigkeits-Arrays (auch dort, wo bewusst `eslint-disable` steht) — keine +// Verhaltensänderung. Die Aktions-Funktionen selbst (deleteSelection, runSplit, +// applyBoolean, …) bleiben in App.tsx, da sie auch von Menüs/TopBar gebraucht +// werden, und werden hier nur als Parameter durchgereicht. +// +// Extrahiert aus App.tsx. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). + +import { useEffect, useRef } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import { getTool } from "../tools/tools"; +import type { ToolContext, ToolDraft, ToolId, ToolState } from "../tools/types"; +import type { CopyMode, TransformOp } from "../tools/transform"; +import type { CommandEngine } from "../commands/engine"; +import type { CommandLineHandle } from "../ui/CommandLine"; +import type { Drawing2D, Project, Vec2 } from "../model/types"; +import { differenceDrawings, intersectionDrawings, unionDrawings } from "../editors/booleanOps"; +import type { BooleanResult } from "../editors/booleanOps"; + +/** Aktive Transformation (Bewegen/Spiegeln/Drehen) — Form aus useDrawingToolState. */ +type ActiveTransform = { + op: TransformOp; + copyMode: CopyMode; + count: number; + points: Vec2[]; +} | null; + +const isEditableTarget = (e: KeyboardEvent): boolean => { + const el = e.target as HTMLElement | null; + return !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); +}; + +/** Globaler Undo/Redo-Shortcut: Ctrl+Z / Cmd+Z, Ctrl+Shift+Z / Cmd+Shift+Z bzw. Ctrl+Y für redo. */ +export function useUndoRedoShortcut(undo: () => void, redo: () => void): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const mod = e.ctrlKey || e.metaKey; + const key = e.key.toLowerCase(); + if (!mod || (key !== "z" && key !== "y")) return; + if (isEditableTarget(e)) return; + e.preventDefault(); + if (key === "y" || (key === "z" && e.shiftKey)) redo(); + else undo(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [undo, redo]); +} + +/** + * Globaler Tab-Handler: läuft ein Befehl MIT Feldern (§2.7), zyklt Tab das + * aktive Feld (Präzisionseingabe), ohne den Fokus zu stehlen — so kann man bei + * der Maus bleiben. Sonst fokussiert/öffnet Tab die Command-Line. Direktes + * Zahlen-Tippen beim Zeichnen (VW-Verhalten): läuft ein Befehl mit Tab-Feldern + * und tippt man eine Ziffer (oder . , -), ohne dass ein Eingabefeld fokussiert + * ist, startet das die Werteingabe in der Befehlszeile. + */ +export function useCommandTabShortcut( + engineRef: RefObject, + commandLineRef: RefObject, + gripDragInfoRef: RefObject<{ index: number; raw: Vec2 } | null>, + gripEditRef: RefObject<{ + anchor: Vec2; + active: "length" | "angle"; + locks: { length?: number; angle?: number }; + } | null>, + cycleGripEditFieldRef: RefObject<() => void>, +): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const inInput = isEditableTarget(e); + if ( + !inInput && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + /^[0-9.,-]$/.test(e.key) && + engineRef.current?.isActive() && + engineRef.current.hasFields() + ) { + e.preventDefault(); + commandLineRef.current?.beginTyping(e.key); + return; + } + if (e.key !== "Tab") return; + if (inInput) { + return; + } + e.preventDefault(); + const eng = engineRef.current; + if (eng?.isActive() && eng.hasFields()) { + eng.cycleField(); + return; + } + // (c) Läuft ein Vertex-Griff-Drag, öffnet/zyklt Tab das Editier-Feld + // (Länge/Winkel relativ zur Nachbarecke). Beim ersten Tab wird zusätzlich + // das Befehlsfeld fokussiert, damit man den Zahlenwert direkt tippen kann. + if (gripDragInfoRef.current) { + const wasOpen = gripEditRef.current !== null; + cycleGripEditFieldRef.current?.(); + if (!wasOpen) commandLineRef.current?.focus(); + return; + } + commandLineRef.current?.focus(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +} + +/** + * Nummern-Shortcuts für Zeichenwerkzeuge (Vectorworks-Stil): 1 Text, 2 Linie, + * 3 Kreis, 4 Rechteck, 5 Polylinie, 6 Wand, 7 Decke, 8 Fenster, 9 Türe, 0 Raum. + * Nur auf Geschoss-Tabs, nicht mit Modifikator (Ctrl/Cmd/Alt bleiben frei). Der + * Handler wird einmal registriert und liest die aktuelle Auswahl-Funktion/ + * Ebenen-Art über ein Ref (sonst müsste der Effekt bei jedem Render neu + * registrieren). + */ +export function useToolNumberShortcuts( + onSelectTool: (id: ToolId) => void, + isFloor: boolean, + engineRef: RefObject, +): void { + const toolShortcutRef = useRef({ onSelectTool, floor: isFloor }); + toolShortcutRef.current = { onSelectTool, floor: isFloor }; + useEffect(() => { + const MAP: Record = { + "1": "text", + "2": "line", + "3": "circle", + "4": "rect", + "5": "polyline", + "6": "wall", + "7": "ceiling", + "8": "window", + "9": "door", + "0": "room", + }; + const onKey = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + const el = e.target as HTMLElement | null; + const isInput = + !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); + if (isInput) { + const isCmdLineInput = el.classList.contains("cmdline-input"); + const empty = isCmdLineInput && (el as HTMLInputElement).value === ""; + const eng = engineRef.current; + const cmdLineFreeToSwitch = + empty && !eng?.hasFields() && !eng?.acceptsFreeText(); + if (!cmdLineFreeToSwitch) return; + } + const id = MAP[e.key]; + if (!id) return; + if (!toolShortcutRef.current.floor) return; // Werkzeuge nur auf Geschossen + e.preventDefault(); + toolShortcutRef.current.onSelectTool(id); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +} + +/** + * Tastatur für aktive Zeichenwerkzeuge: Esc verwirft den Entwurf (bzw. schaltet + * im Ruhezustand zurück auf Auswahl), Enter schließt mehrteilige Werkzeuge ab, + * Backspace nimmt den letzten Punkt zurück. In echten Eingaben inaktiv. Läuft + * ein gekoppelter Befehl (Linie/Rechteck/Polylinie über die Engine), gehören + * Esc/Enter/Backspace der Engine bzw. dem Befehlsfeld — der Legacy-Werkzeug- + * Pfad bleibt stumm (keine doppelte Mechanik). + */ +export function useActiveToolKeys( + activeTool: ToolId, + toolCtx: ToolContext, + engineRef: RefObject, + toolStateRef: MutableRefObject, + setDraft: (draft: ToolDraft | null) => void, + setActiveTool: (id: ToolId) => void, + setProject: (next: Project | ((p: Project) => Project)) => void, +): void { + useEffect(() => { + if (activeTool === "select") return; + const onKey = (e: KeyboardEvent) => { + if (isEditableTarget(e)) return; + if (engineRef.current?.isActive()) return; + const tool = getTool(activeTool); + const drawing = toolStateRef.current.phase !== "idle"; + if (e.key === "Escape") { + if (drawing) { + const [st] = tool.onCancel(toolStateRef.current); + toolStateRef.current = st; + setDraft(null); + } else { + setActiveTool("select"); + } + e.preventDefault(); + } else if (e.key === "Enter") { + const [st, res] = tool.onCommitGesture(toolStateRef.current, toolCtx); + toolStateRef.current = st; + if (res.commit) setProject(res.commit); + setDraft(null); + if (res.done) toolStateRef.current = tool.init(); + e.preventDefault(); + } else if (e.key === "Backspace" && tool.onUndoPoint) { + const [st, res] = tool.onUndoPoint(toolStateRef.current, toolCtx); + toolStateRef.current = st; + setDraft(res.draft); + e.preventDefault(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [activeTool, toolCtx]); +} + +/** + * Tastatur im Auswahl-Werkzeug: Ctrl/Cmd+A = alles auf der aktiven Ebene + * wählen; Entf/Backspace = ALLE Selektierten (Wände + 2D-Elemente) löschen. + * `hasSelectionToDelete`/`deleteSelection` bleiben in App.tsx (auch vom Menü + * „Bearbeiten > Löschen" gebraucht) und werden hier nur aufgerufen. + */ +export function useSelectAllAndDeleteShortcut(opts: { + activeTool: ToolId; + activeTransform: ActiveTransform; + project: Project; + activeLevelId: string; + selectedDrawingIds: string[]; + selectedWallIds: string[]; + selectedCeilingIds: string[]; + selectedOpeningIds: string[]; + selectedStairIds: string[]; + selectedRoomIds: string[]; + selectedExtrudedSolidIds: string[]; + selectedColumnIds: string[]; + selectedRoofIds: string[]; + selectedContextObjectIds: string[]; + selectedSectionLineId: string | null; + setSelectedWallIds: (ids: string[]) => void; + setSelectedDrawingIds: (ids: string[]) => void; + setSelectedCeilingIds: (ids: string[]) => void; + setSelectedStairIds: (ids: string[]) => void; + setSelectedRoomIds: (ids: string[]) => void; + setSelectedExtrudedSolidIds: (ids: string[]) => void; + setSelectedColumnIds: (ids: string[]) => void; + hasSelectionToDelete: () => boolean; + deleteSelection: () => void; +}): void { + const { + activeTool, + activeTransform, + project, + activeLevelId, + setSelectedWallIds, + setSelectedDrawingIds, + setSelectedCeilingIds, + setSelectedStairIds, + setSelectedRoomIds, + setSelectedExtrudedSolidIds, + setSelectedColumnIds, + hasSelectionToDelete, + deleteSelection, + } = opts; + useEffect(() => { + if (activeTool !== "select") return; + const onKey = (e: KeyboardEvent) => { + if (isEditableTarget(e)) return; + // Alles auswählen (Wände + Zeichnungen des aktiven Geschosses/der Ebene). + if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) { + const wallIds = project.walls + .filter((w) => w.floorId === activeLevelId) + .map((w) => w.id); + const drawingIds = project.drawings2d + .filter((d) => d.levelId === activeLevelId) + .map((d) => d.id); + const ceilingIds = (project.ceilings ?? []) + .filter((c) => c.floorId === activeLevelId) + .map((c) => c.id); + const stairIds = (project.stairs ?? []) + .filter((s) => s.floorId === activeLevelId) + .map((s) => s.id); + const roomIds = (project.rooms ?? []) + .filter((r) => r.floorId === activeLevelId) + .map((r) => r.id); + const extrudedSolidIds = (project.extrudedSolids ?? []) + .filter((s) => s.levelId === activeLevelId) + .map((s) => s.id); + const columnIds = (project.columns ?? []) + .filter((c) => c.floorId === activeLevelId) + .map((c) => c.id); + if ( + wallIds.length || + drawingIds.length || + ceilingIds.length || + stairIds.length || + roomIds.length || + extrudedSolidIds.length || + columnIds.length + ) { + setSelectedWallIds(wallIds); + setSelectedDrawingIds(drawingIds); + setSelectedCeilingIds(ceilingIds); + setSelectedStairIds(stairIds); + setSelectedRoomIds(roomIds); + setSelectedExtrudedSolidIds(extrudedSolidIds); + setSelectedColumnIds(columnIds); + e.preventDefault(); + } + return; + } + if (e.key !== "Delete" && e.key !== "Backspace") return; + if (activeTransform) return; // während einer Transformation nicht löschen + if (hasSelectionToDelete()) { + deleteSelection(); + e.preventDefault(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + opts.activeTool, + opts.activeTransform, + opts.selectedDrawingIds, + opts.selectedWallIds, + opts.selectedCeilingIds, + opts.selectedOpeningIds, + opts.selectedStairIds, + opts.selectedExtrudedSolidIds, + opts.selectedColumnIds, + opts.selectedRoofIds, + opts.selectedContextObjectIds, + opts.selectedSectionLineId, + opts.project.walls, + opts.project.drawings2d, + opts.project.ceilings, + opts.project.roofs, + opts.project.stairs, + opts.project.extrudedSolids, + opts.project.columns, + opts.activeLevelId, + ]); +} + +/** + * Ctrl/Cmd+S = Split, Ctrl/Cmd+J = Join der selektierten Drawing2D-Elemente. + * Join läuft über DENSELBEN Engine-Befehl wie getipptes „join" (Sofort- + * Befehl, autoRun). Split bleibt die direkte Aktion (`runSplit`, in App.tsx — + * bleibt dort, da es auch für einen etwaigen Menü-Eintrag geteilt wird). Beide + * rufen preventDefault (sonst Browser-Speichern/-Lesezeichen). Wände sind + * ausgenommen (no-op). Tool-unabhängig, solange eine 2D-Auswahl steht. + */ +export function useSplitJoinShortcut( + selectedDrawingIds: string[], + engine: CommandEngine, + runSplit: (ids: string[]) => void, + setSelectedDrawingIds: (ids: string[]) => void, + setSelectedWallIds: (ids: string[]) => void, +): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey)) return; + const k = e.key.toLowerCase(); + if (k !== "s" && k !== "j") return; + if (isEditableTarget(e)) return; + const ids = selectedDrawingIds; + if (ids.length === 0) return; // nur 2D-Auswahl bedient; Wände → no-op + e.preventDefault(); + + if (k === "s") { + runSplit(ids); + return; + } + // Join: exakt derselbe Befehl wie „join" tippen (Sofort-Befehl, autoRun). + // Nach dem Verbinden zeigt die alte Auswahl auf ersetzte IDs → aufräumen. + engine.start("join"); + setSelectedDrawingIds([]); + setSelectedWallIds([]); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDrawingIds, engine]); +} + +/** + * Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt + * die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit + * neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so + * wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im + * Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault. + */ +export function useDrawingClipboardShortcut( + selectedDrawingIds: string[], + drawingClipboard: Drawing2D[], + setDrawingClipboard: (items: Drawing2D[]) => void, + activeLevelId: string, + drawings2d: Drawing2D[], + importDrawings: (drawings: Drawing2D[]) => void, + setSelectedDrawingIds: (ids: string[]) => void, +): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return; + const k = e.key.toLowerCase(); + if (k !== "c" && k !== "v") return; + if (isEditableTarget(e)) return; + if (k === "c") { + const ids = new Set(selectedDrawingIds); + if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy) + const picked = drawings2d.filter((d) => ids.has(d.id)); + if (picked.length === 0) return; + e.preventDefault(); + setDrawingClipboard(picked.map((d) => structuredClone(d))); + return; + } + // Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt. + if (drawingClipboard.length === 0) return; + e.preventDefault(); + const stamp = Date.now(); + const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({ + ...structuredClone(d), + id: `drawing-${stamp}-${i}`, + levelId: activeLevelId, + })); + importDrawings(pasted); + setSelectedDrawingIds(pasted.map((p) => p.id)); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDrawingIds, drawingClipboard, activeLevelId, drawings2d]); +} + +/** + * Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten + * geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2 + * geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.) `applyBoolean` + * bleibt in App.tsx (auch vom Kontextmenü gebraucht). + */ +export function useBooleanOpsShortcut( + selectedDrawingIds: string[], + drawings2d: Drawing2D[], + applyBoolean: (op: (srcs: Drawing2D[]) => BooleanResult) => void, +): void { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return; + const k = e.key.toLowerCase(); + if (k !== "u" && k !== "d" && k !== "i") return; + if (isEditableTarget(e)) return; + if (selectedDrawingIds.length < 2) return; + e.preventDefault(); + if (k === "u") applyBoolean(unionDrawings); + else if (k === "d") applyBoolean(differenceDrawings); + else applyBoolean(intersectionDrawings); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDrawingIds, drawings2d]); +} + +/** + * Transformations-Shortcuts (nur im Auswahl-Werkzeug): + * m = Bewegen, s = Spiegeln, d = Drehen (startet auf der Auswahl); + * während einer Transformation: u/i/o/p = Modus (move/copy/array/distribute), + * Esc = abbrechen. O/P fragen die Kopienanzahl ab. „q" = Messen (Distanz/ + * Winkel) — braucht KEINE Auswahl. + */ +export function useTransformShortcuts( + activeTool: ToolId, + activeTransform: ActiveTransform, + setActiveTransform: ( + next: ActiveTransform | ((t: ActiveTransform) => ActiveTransform), + ) => void, + selectedWallIds: string[], + selectedDrawingId: string | null, + selectedExtrudedSolidId: string | null, + selectedColumnId: string | null, + engine: CommandEngine, + setDraft: (draft: ToolDraft | null) => void, + requestCopyCount: (mode: "array" | "distribute") => void, + onRunCommand: (name: string) => void, + commandLineRef: RefObject, +): void { + useEffect(() => { + if (activeTool !== "select") return; + const onKey = (e: KeyboardEvent) => { + if (isEditableTarget(e)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (activeTransform) { + const k = e.key.toLowerCase(); + if (k === "u") setActiveTransform((t) => (t ? { ...t, copyMode: "move" } : t)); + else if (k === "i") setActiveTransform((t) => (t ? { ...t, copyMode: "copy" } : t)); + else if (k === "o") { + requestCopyCount("array"); + } else if (k === "p") { + requestCopyCount("distribute"); + } else if (e.key === "Escape") { + setActiveTransform(null); + setDraft(null); + } else return; + e.preventDefault(); + return; + } + // „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 + // (`activeTransform`) mit den U/I/O/P-Kopiermodi (Bewegen/Kopie/Reihe/ + // Verteilen). Prompt + Optionen + Live-Feld speisen die Command-Line; die + // Kürzel U/I/O/P wirken dort auch bei fokussiertem Eingabefeld (options.key). + const op: TransformOp | null = + k === "m" ? "move" : k === "s" ? "mirror" : k === "d" ? "rotate" : null; + if (op) { + setActiveTransform({ op, copyMode: "move", count: 1, points: [] }); + setDraft(null); + requestAnimationFrame(() => commandLineRef.current?.focus()); + e.preventDefault(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + activeTool, + activeTransform, + selectedWallIds, + selectedDrawingId, + selectedExtrudedSolidId, + selectedColumnId, + engine, + ]); +} diff --git a/src/styles.css b/src/styles.css index a42f4d6..f1bc1d8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1442,6 +1442,10 @@ body { background: var(--accent-dim); color: var(--ink); } +.nav-add .material-symbols-outlined { + font-size: 15px; + line-height: 1; +} /* Zahnrad in der Panel-Kopfzeile (Zeichnungsebenen/Ebenen) → Einstellungen. Dezent, rund, wie der „+"-Knopf daneben. */ @@ -6649,6 +6653,18 @@ body { flex: 0 0 auto; z-index: 3; } +/* Nur Navigations-Knöpfe hier (Zurück/Viewport aufziehen/Zoom/Einpassen) — die + Zeichenwerkzeuge kommen aus dem linken Werkzeugpanel (kein Blatt-eigenes + Werkzeug mehr). `.nav-add` ist sonst ein runder 20px-Icon-Knopf; hier tragen + mehrere Knöpfe zusätzlich Text, daher eigene Breite/Form + kein Umbruch. */ +.layout-sheet-toolbar .nav-add { + width: auto; + height: 24px; + padding: 0 8px; + border-radius: 4px; + gap: 4px; + white-space: nowrap; +} .layout-sheet-name { font-size: 11px; @@ -6667,9 +6683,11 @@ body { min-height: 0; min-width: 0; overflow: hidden; - /* Dunkler „Tisch" mit dezentem Verlauf, damit das weisse Blatt abhebt. */ - background: - radial-gradient(circle at 50% 40%, #2a2a2a 0%, #161616 70%, #101010 100%); + /* Papierartiger „Tisch" (dieselbe Papierfarbe wie die Zeichenfläche im + Grundriss, `--sheet`) statt eines dunklen Tischs — das Blatt selbst + bleibt reinweiss (.layout-sheet-paper) und hebt sich per Randlinie + + Schatten ab, nicht per Hell/Dunkel-Kontrast. */ + background: var(--sheet); touch-action: none; user-select: none; } @@ -6677,7 +6695,8 @@ body { .layout-sheet-paper { position: absolute; background: #ffffff; - box-shadow: 0 4px 22px rgba(0, 0, 0, 0.55); + border: 1px solid rgba(26, 26, 24, 0.18); + box-shadow: 0 4px 22px rgba(26, 26, 24, 0.22); } .layout-sheet-vp { @@ -6724,6 +6743,33 @@ body { pointer-events: none; } +/* Transparenter Vollflächen-Fänger hinter dem Inline-Text-Editor — ein Klick + daneben schliesst ihn (jede Änderung ist bereits live gespeichert). */ +.layout-sheet-inline-backdrop { + position: absolute; + inset: 0; + z-index: 6; +} + +/* Inline-Text-Editor DIREKT auf dem Blatt (kein schwebendes Fenster) — weisser + Rahmen wie Papier, blauer Rand während der Bearbeitung. `transform: scale()` + (inline, s. LayoutSheet.tsx) bringt die feste 12-pt-Basisgrösse des + RichTextEditor auf die Schriftgrösse der Annotation beim aktuellen Zoom. */ +.layout-sheet-inline-text { + position: absolute; + z-index: 7; + background: #ffffff; + outline: 1.5px solid #3b82f6; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); +} +.layout-sheet-inline-text .rt-editor { + min-height: inherit; +} +.layout-sheet-inline-text .rt-surface { + min-height: 1.4em; + padding: 2px 4px; +} + /* HUD (Eigenschaften des gewählten VP) unten links. */ .layout-sheet-hud { position: absolute; @@ -6882,8 +6928,14 @@ body { } /* ── Splash + Startbildschirm (App.tsx: bootPhase splash → start → editor) ── - Immer dunkel (wie der Über-Dialog), unabhängig vom Hell/Dunkel-Theme — - eigener Marken-Moment vor dem eigentlichen Editor. */ + Eigenes, KLEINES Fenster (App.tsx resized das Tauri-Fenster waehrend dieser + beiden Phasen, s. dortigen Effekt) — wie das kompakte Start-Fenster der + meisten CAD-Programme, nicht die volle Editor-Fenstergroesse. Hintergrund + ist das "Papier" (--sheet, IMMER hell, wie das Zeichenblatt), UNABHAENGIG + vom Hell/Dunkel-Theme. Deshalb hier FESTE (nicht themeabhaengige) Ink-Farben + statt --ink/--muted/--border/--field-bg/--accent* — Papier ist immer hell, + die Schrift darauf muss deshalb immer dunkel bleiben, sonst verschwindet sie + im Dunkelmodus auf dem hellen Papier (exakte Werte = Licht-Palette oben). */ .splash-screen, .start-screen { position: fixed; @@ -6891,62 +6943,81 @@ body { z-index: 2000; display: flex; flex-direction: column; + background: var(--sheet); + color: #1a1a18; +} +.splash-screen { align-items: center; justify-content: center; - background: #16181c; - color: #e6e8ea; +} +/* Startbildschirm: Inhalt vertikal zentriert (.start-main), die Fusszeile + (Version) sitzt fix unten — wie ein klassisches Programm-Start-Fenster. */ +.start-main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 0; +} +.start-footer { + flex: 0 0 auto; + padding: 8px 0 12px; + font-size: 10px; + color: #8a8580; + text-align: center; } .splash-brand, .start-brand { font-weight: 700; - font-size: 32px; + font-size: 22px; text-transform: lowercase; letter-spacing: -0.01em; } .start-brand { - margin-bottom: 36px; + margin-bottom: 20px; } .start-actions { display: flex; - gap: 14px; + gap: 10px; } .start-action { display: flex; flex-direction: column; align-items: flex-start; - gap: 4px; - min-width: 200px; - padding: 16px 18px; - border-radius: 10px; - border: 1px solid #3a3f47; - background: #23262b; + gap: 3px; + min-width: 160px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid #c8c2ba; + background: #f5f2ec; color: inherit; cursor: pointer; transition: border-color 0.12s, background 0.12s; } .start-action:hover { - border-color: var(--accent, #0f766e); - background: #292d33; + border-color: #4a453e; + background: #ece8e2; } .start-action-title { - font-size: 14px; + font-size: 12.5px; font-weight: 600; } .start-action-hint { - font-size: 11px; - color: #9aa0a6; + font-size: 10.5px; + color: #8a8580; } .start-recent { - margin-top: 36px; - width: 420px; - max-width: calc(100vw - 48px); + margin-top: 22px; + width: 340px; + max-width: calc(100vw - 32px); } .start-recent-title { - font-size: 11px; + font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; - color: #9aa0a6; - margin-bottom: 8px; + color: #8a8580; + margin-bottom: 6px; } .start-recent-list { list-style: none; @@ -6955,7 +7026,7 @@ body { display: flex; flex-direction: column; gap: 2px; - max-height: 220px; + max-height: 140px; overflow-y: auto; } .start-recent-row { @@ -6970,8 +7041,8 @@ body { align-items: flex-start; gap: 1px; min-width: 0; - padding: 7px 10px; - border-radius: 7px; + padding: 6px 8px; + border-radius: 6px; border: none; background: transparent; color: inherit; @@ -6979,10 +7050,10 @@ body { text-align: left; } .start-recent-item:hover { - background: #23262b; + background: #ece8e2; } .start-recent-name { - font-size: 12.5px; + font-size: 11.5px; font-weight: 500; white-space: nowrap; overflow: hidden; @@ -6990,26 +7061,32 @@ body { max-width: 100%; } .start-recent-path { - font-size: 10.5px; - color: #767c83; + font-size: 9.5px; + color: #8a8580; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } +.start-recent-time { + flex: 0 0 auto; + font-size: 9.5px; + color: #8a8580; + white-space: nowrap; +} .start-recent-remove { flex: 0 0 auto; - width: 22px; - height: 22px; - border-radius: 6px; + width: 20px; + height: 20px; + border-radius: 5px; border: none; background: transparent; - color: #767c83; + color: #8a8580; cursor: pointer; - font-size: 14px; + font-size: 13px; line-height: 1; } .start-recent-remove:hover { - background: #23262b; - color: #e6e8ea; + background: #ece8e2; + color: #1a1a18; } diff --git a/src/text/renderHtml.test.ts b/src/text/renderHtml.test.ts new file mode 100644 index 0000000..72a2004 --- /dev/null +++ b/src/text/renderHtml.test.ts @@ -0,0 +1,58 @@ +// Tests für wrapDocToLines: Wortumbruch auf feste Breite unter Erhalt der +// Run-Grenzen (gemischte Formatierung pro Zeile bleibt möglich). + +import { describe, expect, it } from "vitest"; +import { docFromText, makeRun, type RichTextDoc } from "./richText"; +import { docToLines, wrapDocToLines } from "./renderHtml"; + +const OPTS = { x: 0, y: 0, lineHeight: 10, unitPerPt: 1, basePt: 10, color: "#000000" }; + +describe("wrapDocToLines", () => { + it("ohne wrapWidthUnits identisch zu docToLines (eine Zeile je Absatz)", () => { + const doc = docFromText("hallo welt"); + expect(wrapDocToLines(doc, OPTS)).toEqual(docToLines(doc, OPTS)); + }); + + it("bricht lange Absätze wortweise auf mehrere Zeilen um", () => { + const doc = docFromText("eins zwei drei vier fünf"); + const lines = wrapDocToLines(doc, { ...OPTS, wrapWidthUnits: 30 }); + expect(lines.length).toBeGreaterThan(1); + const joined = lines.map((l) => l.tspans.map((t) => t.text).join("")).join(" "); + expect(joined.replace(/\s+/g, " ").trim()).toBe("eins zwei drei vier fünf"); + }); + + it("respektiert Run-Grenzen: ein fett formatiertes Wort bleibt in einem eigenen Tspan", () => { + const doc: RichTextDoc = { + version: 1, + paragraphs: [ + { + runs: [ + makeRun("normal ", {}), + makeRun("fett", { bold: true }), + makeRun(" weiter", {}), + ], + }, + ], + }; + const lines = wrapDocToLines(doc, { ...OPTS, wrapWidthUnits: 1000 }); + expect(lines).toHaveLength(1); + const boldTspan = lines[0].tspans.find((t) => t.text === "fett"); + expect(boldTspan?.fontWeight).toBe("700"); + const otherTspans = lines[0].tspans.filter((t) => t.text !== "fett"); + expect(otherTspans.every((t) => t.fontWeight === undefined)).toBe(true); + }); + + it("leerer Absatz erzeugt eine leere Zeile (Vorschub bleibt erhalten)", () => { + const doc: RichTextDoc = { version: 1, paragraphs: [{ runs: [] }, { runs: [makeRun("x")] }] }; + const lines = wrapDocToLines(doc, { ...OPTS, wrapWidthUnits: 100 }); + expect(lines).toHaveLength(2); + expect(lines[0].tspans).toHaveLength(0); + }); + + it("sehr schmale Breite bricht auf ein Wort je Zeile um, ohne Wörter zu verlieren", () => { + const doc = docFromText("aa bb cc"); + const lines = wrapDocToLines(doc, { ...OPTS, wrapWidthUnits: 1 }); + const words = lines.map((l) => l.tspans.map((t) => t.text).join("").trim()); + expect(words).toEqual(["aa", "bb", "cc"]); + }); +}); diff --git a/src/text/renderHtml.ts b/src/text/renderHtml.ts index 2e6aa7a..460bc18 100644 --- a/src/text/renderHtml.ts +++ b/src/text/renderHtml.ts @@ -167,6 +167,81 @@ export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] { })); } +/** Geschätzte Zeichenbreite als Vielfaches der Schriftgrösse (gleiche Heuristik + * wie der bisherige Wortumbruch für einheitlich formatierten Text, s. + * PlanView.tsx `case "drawingText"`) — kein echtes Font-Metrics-Messen, aber + * für die Platzierung von Zeilenumbrüchen ausreichend. */ +const CHAR_WIDTH_FACTOR = 0.55; + +/** + * Wie {@link docToLines}, bricht aber zusätzlich wortweise auf `wrapWidthUnits` + * um (in denselben Benutzereinheiten wie `SvgTextOptions.x`) — für Textfelder/ + * -rahmen mit fester Breite (InDesign-artig). Ohne `wrapWidthUnits` identisch + * zu `docToLines` (eine Zeile je Absatz). Respektiert Run-Grenzen: ein Wort + * behält die Formatierung seines Runs, auch wenn die Zeile mehrere Runs mischt. + */ +export function wrapDocToLines( + doc: RichTextDoc, + opts: SvgTextOptions & { wrapWidthUnits?: number }, +): SvgLine[] { + if (!opts.wrapWidthUnits || opts.wrapWidthUnits <= 0) return docToLines(doc, opts); + const basePt = opts.basePt ?? 12; + const color = opts.color ?? "#000000"; + const baseUnit = basePt * opts.unitPerPt; + const maxWidth = opts.wrapWidthUnits; + + interface Token { + tspan: SvgTspan; + widthUnits: number; + isSpace: boolean; + } + + const lines: SvgLine[] = []; + for (const p of doc.paragraphs) { + const lineHeight = p.lineHeight != null ? baseUnit * p.lineHeight : opts.lineHeight; + const align = p.align ?? "left"; + + const tokens: Token[] = []; + for (const run of p.runs) { + if (run.text.length === 0) continue; + const base = runToTspan(run, basePt, opts.unitPerPt, color); + for (const part of run.text.split(/(\s+)/).filter((s) => s.length > 0)) { + const isSpace = /^\s/.test(part); + tokens.push({ + tspan: { ...base, text: part }, + widthUnits: part.length * base.fontSize * CHAR_WIDTH_FACTOR, + isSpace, + }); + } + } + + if (tokens.length === 0) { + lines.push({ tspans: [], align, lineHeight }); + continue; + } + + let current: Token[] = []; + let currentWidth = 0; + const flush = () => { + while (current.length && current[current.length - 1].isSpace) current.pop(); + lines.push({ tspans: current.map((t) => t.tspan), align, lineHeight }); + current = []; + currentWidth = 0; + }; + for (const tok of tokens) { + if (current.length === 0 && tok.isSpace) continue; + if (current.length > 0 && currentWidth + tok.widthUnits > maxWidth) { + flush(); + if (tok.isSpace) continue; + } + current.push(tok); + currentWidth += tok.widthUnits; + } + flush(); + } + return lines; +} + function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: string): SvgTspan { const m = run.marks; const script = m.super || m.sub; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 3e095a3..6e921a5 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -580,6 +580,22 @@ const textboxTool: Tool = { onCancel: (s) => [s, { draft: null, done: true }], }; +// Bild einfügen: KEIN Klick-Platzieren wie die übrigen Zeichen-Werkzeuge — +// die Datei-Auswahl ist async (Dateidialog), daher fängt `onSelectTool` +// (App.tsx) die ID VOR der normalen Werkzeug-Aktivierung ab und öffnet direkt +// den Datei-Dialog (s. dortigen Kommentar). Dieses Objekt existiert nur, damit +// `TOOLS`/`ToolsPanel` eine vollständige Abbildung + Label/Hint haben. +const imageTool: Tool = { + id: "image", + labelKey: "tool.image", + hintKey: () => "tool.image.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 = { @@ -597,6 +613,7 @@ const TOOLS: Record = { arc: arcTool, text: textTool, textbox: textboxTool, + image: imageTool, }; /** Liefert das Werkzeug zur ID. */ @@ -620,4 +637,5 @@ export const TOOL_ORDER: ToolId[] = [ "arc", "text", "textbox", + "image", ]; diff --git a/src/tools/transform.ts b/src/tools/transform.ts index 9ee3351..1524c24 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -181,6 +181,20 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => { return { ...geom, center: fn(geom.center) }; case "text": return { ...geom, at: fn(geom.at) }; + case "image": { + // Wie Text: nur die Position folgt der Transformation (Zentrum wandert + // dorthin, wo `fn` es abbildet); die Box bleibt achsparallel/unverzerrt — + // ein Bild kann nicht geschert/rotiert dargestellt werden (kein `angle`-Feld). + const oldCenter = { x: (geom.min.x + geom.max.x) / 2, y: (geom.min.y + geom.max.y) / 2 }; + const newCenter = fn(oldCenter); + const dx = newCenter.x - oldCenter.x; + const dy = newCenter.y - oldCenter.y; + return { + ...geom, + min: { x: geom.min.x + dx, y: geom.min.y + dy }, + max: { x: geom.max.x + dx, y: geom.max.y + dy }, + }; + } } }; @@ -213,6 +227,7 @@ function itemPreview(project: Project, item: AnyItem, fn: (p: Vec2) => Vec2): Dr return out; } case "rect": + case "image": return [{ kind: "poly", pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }], closed: true }]; default: return []; diff --git a/src/tools/types.ts b/src/tools/types.ts index 2b7ccdf..d974d86 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -22,7 +22,8 @@ export type ToolId = | "circle" | "arc" | "text" - | "textbox"; + | "textbox" + | "image"; // ── Snapping ─────────────────────────────────────────────────────────────── diff --git a/src/ui/LayoutSheet.tsx b/src/ui/LayoutSheet.tsx index 5a45c67..5c45d59 100644 --- a/src/ui/LayoutSheet.tsx +++ b/src/ui/LayoutSheet.tsx @@ -1,15 +1,15 @@ // 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). +// liegt auf einem papierartigen „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 { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react"; import type { Layout, LayoutAnnotation, @@ -32,23 +32,33 @@ import { import { annotationBoundsMm, applyHandleDrag, + applyLineHandleDrag, clampRectToSheet, fitTransform, handleAnchors, handleCursor, + lineHandleAnchors, + mmToScreen, normalizeRect, panBy, - pickAnnotation, + pickAnnotationHandle, pickViewport, screenToMm, translateAnnotation, vpRect, zoomAtCursor, + type AnnotHandleHit, type Handle, + type LineHandle, type RectMm, type SheetTransform, } from "./layoutSheetMath"; -import { PromptDialog } from "./PromptDialog"; +import { RichTextEditor } from "../text/RichTextEditor"; +import { docFromText } from "../text/richText"; +import { wrapDocToLines, type SvgLine } from "../text/renderHtml"; +import { readImageFile, defaultImageSizeMm } from "../io/imageAsset"; +import { rasterizePdfPages } from "../io/pdfImport"; +import type { ToolId } from "../tools/types"; import { t } from "../i18n"; // ── Geteilter Render-Kern (auch vom schwebenden LayoutEditor genutzt) ───────── @@ -179,8 +189,12 @@ export interface LayoutSheetHandlers { snapshotId: string, init: Partial>, ) => void; - /** Fügt eine neue Annotation (Linie/Rechteck/Text) mit gegebener Geometrie an. */ - onAddAnnotation: (kind: LayoutAnnotation["kind"], init: LayoutAnnotationPatch) => void; + /** + * Fügt eine neue Annotation (Linie/Rechteck/Text/Bild) mit gegebener + * Geometrie an — liefert die neue Id zurück (z. B. um nach dem Aufziehen + * eines Text-Rahmens sofort inline hineinzuschreiben, s. `inlineEdit`). + */ + onAddAnnotation: (kind: LayoutAnnotation["kind"], init: LayoutAnnotationPatch) => string; /** Ändert Felder einer Annotation (Position/Farbe/Strichstärke/Text). */ onPatchAnnotation: (annotationId: string, patch: LayoutAnnotationPatch) => void; /** Entfernt eine Annotation per Id. */ @@ -197,20 +211,55 @@ const MIN_PLACE_MM = 6; const MIN_ANNOT_MM = 3; /** Default-Zeilenhöhe eines neuen Text-Markups (mm). */ const DEFAULT_ANNOT_TEXT_MM = 3.5; +/** + * CSS-Pixel pro Millimeter bei „echten" 100 % — der CSS-Referenzpixel ist per + * Spezifikation auf 1/96 Zoll fixiert (96 px = 25.4 mm), UNABHÄNGIG von der + * physischen Bildschirmauflösung (`devicePixelRatio` gleicht das pro Gerät + * wieder aus). `SheetTransform.scale` ist „Pixel pro Millimeter" — bei diesem + * Wert misst ein A4-Blatt (210 mm) auf dem Bildschirm also wirklich 21 cm + * (Nutzer-Wunsch), nicht nur „passt ins Fenster" wie bei „Einpassen". + */ +const PX_PER_MM_AT_100 = 96 / 25.4; /** Zeichenwerkzeug für die Annotations-Schicht (zusätzlich zum Viewport-„placing"). */ -type AnnotTool = "select" | "line" | "rect" | "text"; +type AnnotTool = "select" | "line" | "rect" | "text" | "image"; + +/** + * Bildet das GLOBALE Werkzeug (linkes Werkzeugpanel, `ToolId`) auf ein + * `AnnotTool` ab — das Blatt hat keine eigene Werkzeugleiste mehr (Nutzer- + * Feedback: „ein Layout soll daherkommen wie eine normale Zeichnung, nur dass + * ein Blatt drin liegt" — dieselben Zeichenwerkzeuge links wie im Grundriss, + * nichts Eigenes nur fürs Layout). `textbox` zeichnet hier wie `text` (kein + * eigener Spaltenbreite-Ablauf im Blatt); alle übrigen Werkzeuge (Wand, Fenster, + * Kreis, …) ergeben auf dem Blatt keinen Sinn → „select". + */ +function annotToolFromGlobal(tool: ToolId): AnnotTool { + if (tool === "line" || tool === "rect" || tool === "image") return tool; + if (tool === "text" || tool === "textbox") return "text"; + return "select"; +} 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 } + | { + mode: "annot-resize"; + id: string; + hit: AnnotHandleHit; + start: LayoutAnnotation; + grabX: number; + grabY: number; + } | null; +/** Cursor für einen Linien-Endpunkt-Griff (kein direktes `Handle`-Äquivalent). */ +function lineHandleCursor(handle: LineHandle): string { + return handle === "body" ? "move" : "crosshair"; +} + /** 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; @@ -221,11 +270,30 @@ export function LayoutSheetView({ project, layout, handlers, + activeTool, + onToolChange, + selectedAnnotId, + onSelectAnnot, }: { project: Project; layout: Layout; handlers: LayoutSheetHandlers; + /** Globales Werkzeug (linkes Werkzeugpanel) — s. `annotToolFromGlobal`. */ + activeTool: ToolId; + /** Setzt das globale Werkzeug (z. B. zurück auf „select" nach einer Platzierung). */ + onToolChange: (id: ToolId) => void; + /** + * Gewählte Annotation — von App.tsx gehoben (statt lokalem State), damit die + * echte Attribute-Palette (rechtes Panel) dieselbe Auswahl sieht wie ein + * Drawing2D im Grundriss (Nutzer-Feedback: „Attribute genau wie bei + * normalen Linien beim Zeichnen"). + */ + selectedAnnotId: string | null; + onSelectAnnot: (id: string | null) => void; }) { + // Alias, damit die bestehenden `setSelectedAnnotId(...)`-Aufrufe unten + // unverändert bleiben (jetzt ein gehobener Prop-Setter statt lokalem State). + const setSelectedAnnotId = onSelectAnnot; const stageRef = useRef(null); const [canvas, setCanvas] = useState({ w: 800, h: 600 }); const [transform, setTransform] = useState(null); @@ -244,15 +312,39 @@ export function LayoutSheetView({ // ── 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); + // KEIN eigener Werkzeug-Zustand mehr — armiert wird über das globale Werkzeug + // (linkes Panel), s. `annotToolFromGlobal`. + const annotTool = annotToolFromGlobal(activeTool); // 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); + // Textwerkzeug: Rahmen aufziehen (Ghost, wie ein Rechteck) — direkt danach + // öffnet `inlineEdit` den Editor IM Rahmen (kein eigenes Fenster). + const [textFrameDraft, setTextFrameDraft] = useState(null); + // Erster Klick von Linie/Rechteck/Text: KEIN Ziehen nötig (Nutzer-Feedback — + // „alle 2D-Werkzeuge sollen sich gleich verhalten wie im Grundriss"), also + // Klick-Bewegen-Klick wie die Plan-Zeichenwerkzeuge (tools.ts `lineTool`/ + // `rectTool`): erster Klick setzt diesen Punkt, die Maus bewegt den Ghost + // (`onPointerMove` läuft auch OHNE gehaltene Taste), der zweite Klick + // schliesst ab. `null` = kein Werkzeug wartet auf den zweiten Klick. + const [pendingPoint, setPendingPoint] = useState< + { tool: "line" | "rect" | "text"; xMm: number; yMm: number } | null + >(null); + // Wechselt der Nutzer das Werkzeug, während ein erster Klick offen ist (z. B. + // Escape/anderes Werkzeug im linken Panel), verwirft das den offenen Entwurf. + useEffect(() => { + setPendingPoint(null); + setAnnotPreview((p) => (p?.id === "draft" ? null : p)); + setTextFrameDraft(null); + }, [annotTool]); + // Aktuell inline bearbeitete Text-Annotation (neu erstellt ODER per HUD „✎" + // geöffnet) — `minHeightMm` ist die beim Aufziehen gewählte Rahmenhöhe (rein + // fürs Editor-Startmass, nicht Teil des Modells — Text wächst mit dem Inhalt). + const [inlineEdit, setInlineEdit] = useState<{ id: string; minHeightMm: number } | null>(null); + // Bild-Werkzeug: nach dem Klick wartet die Position auf die Datei-Auswahl + // (async, s. imageInputRef weiter unten). + const [imagePendingAt, setImagePendingAt] = useState<{ xMm: number; yMm: number } | null>(null); + const imageInputRef = useRef(null); const master = findMaster(project, layout); // Effektive Grösse respektiert freie Grösse (custom Width/Height) — bindet @@ -297,6 +389,13 @@ export function LayoutSheetView({ 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)); + // Springt auf echte 1:1-Grösse (s. PX_PER_MM_AT_100), zentriert auf die + // Canvas-Mitte statt den Cursor (kein aktives Zoom-Gesten-Event hier). + const zoomTo100 = () => + setTransform((t) => + t ? zoomAtCursor(t, PX_PER_MM_AT_100 / t.scale, canvas.w / 2, canvas.h / 2) : t, + ); + const zoomPercent = Math.round(((transform?.scale ?? 0) / PX_PER_MM_AT_100) * 100); // Leertaste = Pan-Modifikator (wie in CAD). Delete/Backspace löscht die Auswahl. useEffect(() => { @@ -315,12 +414,11 @@ export function LayoutSheetView({ setPlacing(false); setPreviewRect(null); } else if (annotTool !== "select") { - setAnnotTool("select"); + onToolChange("select"); setAnnotPreview(null); + setTextFrameDraft(null); } else if (pendingRect) { setPendingRect(null); - } else if (textPendingAt) { - setTextPendingAt(null); } else if (selectedAnnotId) { setSelectedAnnotId(null); } else { @@ -351,8 +449,8 @@ export function LayoutSheetView({ selectedId, selectedAnnotId, annotTool, - textPendingAt, handlers, + onToolChange, ]); // Bildschirm-Pixel (canvas-lokal) eines Pointer-Events. @@ -379,6 +477,60 @@ export function LayoutSheetView({ return () => el.removeEventListener("wheel", onWheel); }, []); + // Schliesst Linie/Rechteck/Text-Rahmen beim ZWEITEN Klick ab (erster Punkt = + // `pendingPoint`, zweiter = `mm`) — dieselbe Zwei-Klick-Logik, die vorher am + // `pointerup` einer gehaltenen Ziehgeste hing (s. Git-Historie), jetzt ohne + // gehaltene Taste nötig. + const commitTwoPointAnnot = ( + tool: "line" | "rect" | "text", + p0: { xMm: number; yMm: number }, + p1: { xMm: number; yMm: number }, + ) => { + if (tool === "line") { + const len = Math.hypot(p1.xMm - p0.xMm, p1.yMm - p0.yMm); + if (len >= MIN_ANNOT_MM) { + handlers.onAddAnnotation("line", { + x1Mm: round1(p0.xMm), + y1Mm: round1(p0.yMm), + x2Mm: round1(p1.xMm), + y2Mm: round1(p1.yMm), + }); + } + return; + } + const r = normalizeRect(p0.xMm, p0.yMm, p1.xMm, p1.yMm); + if (tool === "rect") { + if (r.widthMm >= MIN_ANNOT_MM && r.heightMm >= MIN_ANNOT_MM) { + const clamped = clampRectToSheet(r, sheet.w, sheet.h); + handlers.onAddAnnotation("rect", { + xMm: round1(clamped.xMm), + yMm: round1(clamped.yMm), + widthMm: round1(clamped.widthMm), + heightMm: round1(clamped.heightMm), + }); + } + return; + } + // Text: zu klein aufgezogen (praktisch ein Doppelklick auf der Stelle) → + // Default-Rahmen an der Klickstelle, statt eine Mini-Box zu erzwingen. + const tooSmall = r.widthMm < MIN_ANNOT_MM || r.heightMm < MIN_ANNOT_MM; + const widthMm = tooSmall ? 60 : r.widthMm; + const frameHeightMm = tooSmall ? 20 : r.heightMm; + const clamped = clampRectToSheet({ xMm: r.xMm, yMm: r.yMm, widthMm, heightMm: frameHeightMm }, sheet.w, sheet.h); + // `yMm` einer Text-Annotation ist die BASELINE der ersten Zeile (s. + // `annotationBoundsMm`) — der aufgezogene Rahmen liefert die OBERE Kante, + // daher + eine Zeilenhöhe (heightMm = Schriftgrösse). + const id = handlers.onAddAnnotation("text", { + xMm: round1(clamped.xMm), + yMm: round1(clamped.yMm + DEFAULT_ANNOT_TEXT_MM), + widthMm: round1(clamped.widthMm), + heightMm: DEFAULT_ANNOT_TEXT_MM, + text: "", + }); + setSelectedAnnotId(id); + setInlineEdit({ id, minHeightMm: clamped.heightMm }); + }; + const onPointerDown = (e: ReactPointerEvent) => { if (!transform) return; // Klicks aus dem HUD/Popover (echte Bedienelemente) NICHT als Blatt-Geste werten. @@ -402,26 +554,38 @@ export function LayoutSheetView({ return; } - // Annotations-Werkzeuge: Linie/Rechteck aufziehen, Text sofort per Dialog. - if (annotTool === "text") { - setTextPendingAt({ xMm: mm.xMm, yMm: mm.yMm }); + // Annotations-Werkzeuge Linie/Rechteck/Text: Klick-Bewegen-Klick wie im + // Grundriss (kein Ziehen nötig) — erster Klick merkt sich den Punkt + // (`pendingPoint`), der zweite (hier) schliesst ab. Bild bleibt ein + // Einzelklick, der sofort den Datei-Dialog öffnet (async, s. imageInputRef). + if (annotTool === "image") { + setImagePendingAt({ xMm: mm.xMm, yMm: mm.yMm }); + imageInputRef.current?.click(); 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 }); + if (annotTool === "line" || annotTool === "rect" || annotTool === "text") { + if (pendingPoint && pendingPoint.tool === annotTool) { + commitTwoPointAnnot(annotTool, pendingPoint, mm); + setPendingPoint(null); + setAnnotPreview(null); + setTextFrameDraft(null); + // Linie/Rechteck bleiben armiert für die nächste (Plan-Konvention: + // tools.ts `lineTool`/`rectTool` bleiben nach dem Commit ebenfalls + // aktiv statt auf „Auswahl" zu springen — mehrere Linien am Stück ohne + // erneuten Werkzeug-Klick). Text springt zurück auf „Auswahl": direkt + // danach öffnet der Inline-Editor, ein weiterer Klick soll den Cursor + // darin setzen, keinen neuen Rahmen aufziehen. + if (annotTool === "text") onToolChange("select"); + return; + } + setPendingPoint({ tool: annotTool, xMm: mm.xMm, yMm: mm.yMm }); + if (annotTool === "line") { + setAnnotPreview({ id: "draft", kind: "line", x1Mm: mm.xMm, y1Mm: mm.yMm, x2Mm: mm.xMm, y2Mm: mm.yMm }); + } else if (annotTool === "rect") { + setAnnotPreview({ id: "draft", kind: "rect", xMm: mm.xMm, yMm: mm.yMm, widthMm: 0, heightMm: 0 }); + } else { + setTextFrameDraft({ xMm: mm.xMm, yMm: mm.yMm, widthMm: 0, heightMm: 0 }); + } return; } @@ -452,36 +616,94 @@ export function LayoutSheetView({ return; } - const annotId = pickAnnotation(annotations, mm.xMm, mm.yMm, tolMm); - if (annotId) { + const annotHit = pickAnnotationHandle(annotations, mm.xMm, mm.yMm, tolMm); + if (annotHit) { 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 }; + setSelectedAnnotId(annotHit.id); + const ann = annotations.find((a) => a.id === annotHit.id)!; + if (annotHit.hit.handle === "body") { + dragRef.current = { + mode: "annot-move", + id: annotHit.id, + start: ann, + grabX: mm.xMm, + grabY: mm.yMm, + }; + } else { + dragRef.current = { + mode: "annot-resize", + id: annotHit.id, + hit: annotHit.hit, + start: ann, + grabX: mm.xMm, + grabY: mm.yMm, + }; + } } else { setSelectedId(null); setSelectedAnnotId(null); } }; + // Doppelklick auf ein bestehendes Textfeld öffnet die Bearbeitung wieder — + // wie InDesign/Affinity (bisher nur über den „✎"-Knopf im HUD erreichbar). + const onDoubleClick = (e: ReactMouseEvent) => { + if (!transform || annotTool !== "select") return; + if ((e.target as HTMLElement).closest("[data-sheet-ui]")) return; + const { x, y } = localXY(e); + const mm = screenToMm(transform, x, y); + const tolMm = HANDLE_TOL_PX / transform.scale; + const hit = pickAnnotationHandle(annotations, mm.xMm, mm.yMm, tolMm); + if (!hit) return; + const ann = annotations.find((a) => a.id === hit.id); + if (!ann || ann.kind !== "text") return; + setSelectedId(null); + setSelectedAnnotId(ann.id); + setInlineEdit({ id: ann.id, minHeightMm: annotationBoundsMm(ann).heightMm }); + }; + const onPointerMove = (e: ReactPointerEvent) => { if (!transform) return; const { x, y } = localXY(e); const drag = dragRef.current; if (!drag) { + // Wartet Linie/Rechteck/Text auf den zweiten Klick (`pendingPoint`), folgt + // der Ghost dem Cursor auch OHNE gehaltene Taste (Klick-Bewegen-Klick wie + // im Grundriss) — kein Hover-Cursor-Update nötig, das Werkzeug ist eh aktiv. + if (pendingPoint) { + const mm = screenToMm(transform, x, y); + if (pendingPoint.tool === "line") { + setAnnotPreview({ + id: "draft", + kind: "line", + x1Mm: pendingPoint.xMm, + y1Mm: pendingPoint.yMm, + x2Mm: mm.xMm, + y2Mm: mm.yMm, + }); + } else if (pendingPoint.tool === "rect") { + const r = normalizeRect(pendingPoint.xMm, pendingPoint.yMm, mm.xMm, mm.yMm); + setAnnotPreview({ id: "draft", kind: "rect", ...r }); + } else { + setTextFrameDraft(normalizeRect(pendingPoint.xMm, pendingPoint.yMm, mm.xMm, mm.yMm)); + } + return; + } // 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; + const annotHit = !hit ? pickAnnotationHandle(annotations, mm.xMm, mm.yMm, tolMm) : null; setHoverCursor( placing || annotTool !== "select" ? "crosshair" : hit ? handleCursor(hit.handle) : annotHit - ? "move" + ? annotHit.hit.kind === "line" + ? lineHandleCursor(annotHit.hit.handle) + : handleCursor(annotHit.hit.handle) : "default", ); return; @@ -511,21 +733,31 @@ export function LayoutSheetView({ 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; } + if (drag.mode === "annot-resize") { + const dx = mm.xMm - drag.grabX; + const dy = mm.yMm - drag.grabY; + if (drag.hit.kind === "line") { + if (drag.start.kind === "line") { + setAnnotPreview({ ...drag.start, ...applyLineHandleDrag(drag.start, drag.hit.handle, dx, dy) }); + } + } else if (drag.start.kind === "rect" || drag.start.kind === "image") { + const r = applyHandleDrag( + { xMm: drag.start.xMm, yMm: drag.start.yMm, widthMm: drag.start.widthMm, heightMm: drag.start.heightMm }, + drag.hit.handle, + dx, + dy, + MIN_ANNOT_MM, + ); + setAnnotPreview({ ...drag.start, ...r }); + } + return; + } }; const endDrag = (e: ReactPointerEvent) => { @@ -563,43 +795,7 @@ export function LayoutSheetView({ } 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 (drag.mode === "annot-move" || drag.mode === "annot-resize") { if (annotPreview) { handlers.onPatchAnnotation(drag.id, annotationPatch(annotPreview)); } @@ -636,7 +832,8 @@ export function LayoutSheetView({ {/* Werkzeugleiste (Blatt-Navigation + Platzieren + Zurück). */}
{layout.name} - - - - + {/* Zeigt den echten Massstab (100 % = 1:1 auf dem Bildschirm, s. + PX_PER_MM_AT_100) und setzt ihn per Klick — wie die %-Anzeige in + Affinity/Figma, nicht nur „Einpassen" (das passt bloss ins Fenster). */} +
- {/* Blatt-Tisch: dunkler Hintergrund, weisses Blatt zentriert. */} + {/* Blatt-Tisch: papierartiger Hintergrund, weisses Blatt zentriert. */}
{/* Weisses Papier. */}
))} + {/* Auswahl-Griffe der gewählten Annotation — Rechteck/Bild wie ein + Viewport (8 Ecken/Kanten), Linie mit ihren zwei Endpunkten (wie im + Grundriss). Text hat (noch) keine Griffe. */} + {selectedAnnot && + (() => { + const display = + annotPreview && annotPreview.id === selectedAnnot.id ? annotPreview : selectedAnnot; + if (display.kind === "rect" || display.kind === "image") { + return handleAnchors({ + xMm: display.xMm, + yMm: display.yMm, + widthMm: display.widthMm, + heightMm: display.heightMm, + }).map((a) => ( +
+ )); + } + if (display.kind === "line") { + return lineHandleAnchors(display).map((p, i) => ( +
+ )); + } + return null; + })()} + {/* Ghost-Rechteck beim Aufziehen (Platzieren). */} {previewRect && dragRef.current?.mode === "place" && (
)} + + {/* Ghost-Rahmen beim Aufziehen eines neuen Textfelds. */} + {textFrameDraft && ( +
+ )}
{/* HUD: Eigenschaften des gewählten Viewports (Massstab/Grösse/Löschen). */} @@ -885,7 +1090,7 @@ export function LayoutSheetView({ } title={t("layouts.scaleReset")} > - ↺ + )}
)} - {/* HUD: Eigenschaften der gewählten Annotation (Farbe/Strichstärke/Text/Löschen). */} + {/* HUD: Eigenschaften der gewählten Annotation (Farbe/Strichstärke/Text/ + Bild-Grösse/Löschen — je nach Art). */} {selectedAnnot && (
{t(`layouts.annot.${selectedAnnot.kind}`)}
- - {selectedAnnot.kind !== "text" && ( - - )} + {/* Position (mm, Blattkoordinaten oben-links) — für ALLE Arten über + dieselbe Bbox-Ableitung wie die Griffe (`annotationBoundsMm`); + der Commit verschiebt die ganze Annotation um das Delta + (`translateAnnotation`, dieselbe Funktion wie beim Ziehen). + Das ist der eigentliche Ort für X/Y einer Blatt-Annotation + (Papier-Millimeter) — NICHT das Objekt-Info-Panel (Modell-Meter, + zeigt für Layout-Markups bewusst „nicht anwendbar" statt 0/0). */} + {(() => { + const b = annotationBoundsMm(selectedAnnot); + const moveTo = (nxMm: number, nyMm: number) => { + const moved = translateAnnotation(selectedAnnot, nxMm - b.xMm, nyMm - b.yMm); + handlers.onPatchAnnotation(selectedAnnot.id, annotationPatch(moved)); + }; + return ( + <> + + + + ); + })()} + {/* Farbe/Strichstärke stehen jetzt in der echten Attribute-Palette + (dieselbe wie bei einer Linie im Grundriss, Nutzer-Feedback) — + hier nur noch Position + Grösse (Bild) + Aktionen, keine Dopplung. */} {selectedAnnot.kind === "text" && (
{clip(selectedAnnot.text, 40)}
)} + {selectedAnnot.kind === "image" && ( + <> + + + + )}
{selectedAnnot.kind === "text" && ( )}
@@ -980,10 +1245,10 @@ export function LayoutSheetView({ ))}
@@ -1004,46 +1269,107 @@ export function LayoutSheetView({ )}
- {/* 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 && + {/* Inline-Text-Editor DIREKT im Rahmen auf dem Blatt (kein eigenes Fenster, + Nutzer-Feedback) — für eine neu aufgezogene Text-Annotation (sofort + nach dem Aufziehen) ODER eine per HUD „✎" geöffnete bestehende. Jede + Änderung schreibt sofort zurück (`onPatchAnnotation`); ein Klick + daneben (Backdrop) oder Escape schliesst nur, es gibt nichts zu + verwerfen. Skaliert die feste 12-pt-Basisgrösse des RichTextEditor + (s. RichTextEditor.tsx `renderHtml`) via CSS-Transform auf die + Schriftgrösse der Annotation UND den aktuellen Blatt-Zoom — Breite + bricht dadurch zoomunabhängig bei `widthMm` um. */} + {inlineEdit && (() => { - const ann = annotations.find((a) => a.id === editingTextId); + const ann = annotations.find((a) => a.id === inlineEdit.id); if (!ann || ann.kind !== "text") return null; + const doc = ann.doc ?? docFromText(ann.text); + const widthMm = ann.widthMm ?? 60; + const contentHeightMm = annotationBoundsMm(ann).heightMm; + const heightMm = Math.max(inlineEdit.minHeightMm, contentHeightMm); + const FONT_PX_AT_12PT = 16; // 12pt @ 96dpi (RichTextEditor-Default). + const scale = (ann.heightMm * tf.scale) / FONT_PX_AT_12PT; + const unscaledWidth = widthMm * (FONT_PX_AT_12PT / ann.heightMm); + const unscaledMinHeight = heightMm * (FONT_PX_AT_12PT / ann.heightMm); + const screen = mmToScreen(tf, { xMm: ann.xMm, yMm: ann.yMm - ann.heightMm }); + const commit = (edited: typeof doc) => + handlers.onPatchAnnotation(ann.id, { + doc: edited, + text: edited.paragraphs.map((p) => p.runs.map((r) => r.text).join("")).join("\n"), + }); return ( - { - handlers.onPatchAnnotation(editingTextId, { text: value }); - setEditingTextId(null); - }} - onClose={() => setEditingTextId(null)} - /> + <> +
setInlineEdit(null)} + /> +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.stopPropagation(); + setInlineEdit(null); + } + }} + > + +
+ ); })()} + + {/* Verstecktes Bild/PDF-Datei-Input (Werkzeug „image"). */} + { + const f = e.target.files?.[0]; + e.target.value = ""; + onToolChange("select"); + const at = imagePendingAt; + setImagePendingAt(null); + if (!f || !at) return; + const isPdf = f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"); + if (isPdf) { + void rasterizePdfPages(f).then((pages) => { + pages.forEach((pg, i) => { + const { widthMm, heightMm } = defaultImageSizeMm(pg.naturalWidth, pg.naturalHeight); + const cascade = i * 6; + handlers.onAddAnnotation("image", { + xMm: at.xMm - widthMm / 2 + cascade, + yMm: at.yMm - heightMm / 2 + cascade, + widthMm, + heightMm, + src: pg.src, + }); + }); + }); + } else { + void readImageFile(f).then(({ src, naturalWidth, naturalHeight }) => { + const { widthMm, heightMm } = defaultImageSizeMm(naturalWidth, naturalHeight); + handlers.onAddAnnotation("image", { + xMm: at.xMm - widthMm / 2, + yMm: at.yMm - heightMm / 2, + widthMm, + heightMm, + src, + }); + }); + } + }} + /> ); } @@ -1095,6 +1421,67 @@ function AnnotationSvg({ /> ); } + if (ann.kind === "image") { + return ( + + ); + } + // Text: reiches Doc (mehrere Absätze/gemischte Formatierung) hat Vorrang vor + // dem einfachen String — bricht bei gesetzter Rahmenbreite (`widthMm`) + // wortweise um (Run-Grenzen bleiben erhalten, s. `wrapDocToLines`). + if (ann.doc) { + const lineHeight = ann.heightMm * 1.25; + const lines: SvgLine[] = wrapDocToLines(ann.doc, { + x: 0, + y: 0, + lineHeight, + unitPerPt: 25.4 / 72, + basePt: (ann.heightMm / 25.4) * 72, + color: ann.color ?? "#111111", + ...(ann.widthMm && ann.widthMm > 0 ? { wrapWidthUnits: ann.widthMm } : {}), + }); + let y = ann.yMm; + return ( + + {lines.map((line, k) => { + if (k > 0) y += line.lineHeight; + const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; + const x = + !ann.widthMm ? ann.xMm + : line.align === "center" ? ann.xMm + ann.widthMm / 2 + : line.align === "right" ? ann.xMm + ann.widthMm + : ann.xMm; + return ( + + {line.tspans.length === 0 + ? "​" + : line.tspans.map((ts, j) => ( + + {ts.text} + + ))} + + ); + })} + + ); + } return ( void; - placeholder?: string; - mono?: boolean; -}) { - return ( - onChange(e.target.value)} - /> - ); -} - -/** Zahlenfeld (Pill, rechtsbündig, monospace) mit Live-Commit. */ -function NumberField({ - value, - onChange, - step = 1, - min, - max, - list, -}: { - value: number; - onChange: (v: number) => void; - step?: number; - min?: number; - max?: number; - /** Optionale -ID mit Vorschlagswerten (z. B. Stiftstärken). */ - list?: string; -}) { - return ( - { - const v = Number(e.target.value); - if (!Number.isNaN(v)) onChange(v); - }} - /> - ); -} - -/** - * Farb-Swatch mit nativem Color-Picker. Das -
-
- -
- - )} - - {/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */} - {mode !== "upload" && ( -
- - -
- )} -
-
- ); -} - -/** - * Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter - * + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt - * geladen — CORS `*`) mit Pagination („mehr laden"). Ein Klick lädt die Textur- - * Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt - * sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände - * sauber (Spinner, Offline-/CORS-Hinweis). - */ -function AmbientBrowser({ - sizeM, - activeId, - onAssign, -}: { - sizeM: number; - activeId: string | undefined; - onAssign: (m: ComponentMaterial) => void; -}) { - const [query, setQuery] = useState(""); - const [committedQuery, setCommittedQuery] = useState(""); - const [category, setCategory] = useState(""); - const [resolution, setResolution] = useState("1K"); - const [items, setItems] = useState([]); - const [total, setTotal] = useState(0); - const [offset, setOffset] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - // ID des gerade herunterladenden Materials (Spinner auf der Kachel). - const [downloadingId, setDownloadingId] = useState(null); - - const LIMIT = 24; - - // Suche (bei committedQuery/category-Wechsel neu laden — Seite 0). - useEffect(() => { - const ctrl = new AbortController(); - setLoading(true); - setError(null); - searchMaterials({ - query: committedQuery, - category, - limit: LIMIT, - offset: 0, - signal: ctrl.signal, - }) - .then((res) => { - setItems(res.items); - setTotal(res.total); - setOffset(0); - }) - .catch((e) => { - if (ctrl.signal.aborted) return; - setError(t("material.browse.error")); - setItems([]); - setTotal(0); - console.error("[ambientcg] search failed:", e); - }) - .finally(() => { - if (!ctrl.signal.aborted) setLoading(false); - }); - return () => ctrl.abort(); - }, [committedQuery, category]); - - const loadMore = () => { - const nextOffset = offset + LIMIT; - setLoading(true); - searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset }) - .then((res) => { - setItems((prev) => [...prev, ...res.items]); - setTotal(res.total); - setOffset(nextOffset); - }) - .catch((e) => { - setError(t("material.browse.error")); - console.error("[ambientcg] load more failed:", e); - }) - .finally(() => setLoading(false)); - }; - - const pick = async (id: string) => { - setDownloadingId(id); - setError(null); - try { - const { material } = await fetchMaterialMaps(id, resolution, sizeM); - onAssign(material); - } catch (e) { - setError(t("material.browse.downloadError")); - console.error("[ambientcg] download failed:", e); - } finally { - setDownloadingId(null); - } - }; - - const submitSearch = () => setCommittedQuery(query); - - return ( -
-
{t("material.browse.hint")}
-
- setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") submitSearch(); - }} - /> - - setCategory(v)} - options={[ - { value: "", label: t("material.browse.category.all") }, - ...AMBIENT_CATEGORIES.map((c) => ({ value: c, label: c })), - ]} - /> - setResolution(v as AmbientResolution)} - options={[ - { value: "1K", label: "1K" }, - { value: "2K", label: "2K" }, - { value: "4K", label: "4K" }, - ]} - /> -
- - {error &&
{error}
} - -
- {items.map((it) => ( - - ))} -
- - {!loading && items.length === 0 && !error && ( -
{t("material.browse.empty")}
- )} - -
- {loading && {t("material.browse.loading")}} - {!loading && items.length > 0 && ( - - {t("material.browse.count", { shown: items.length, total })} - - )} - {!loading && items.length < total && ( - - )} -
-
- ); -} - -/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */ -function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) { - const ref = useRef(null); - return ( - <> - - { - onFile(e.target.files?.[0] ?? null); - e.target.value = ""; - }} - /> - - ); -} - -/** Kleiner Farb-Swatch als Listen-Thumbnail für Bauteile ohne Schraffur. */ -function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) { - return ( - - ); -} - -/** - * 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, - hatches, - onPatch, - onDelete, -}: { - component: Component; - hatches: HatchStyle[]; - onPatch: (patch: Partial) => void; - onDelete: () => void; -}) { - const hatchOptions = hatches.map((h) => ({ value: h.id, label: h.name })); - // Ansichts-Schraffur ist optional; Leer-Eintrag = keine Schraffur (weiss). - const viewHatchOptions = [ - { value: "", label: t("resources.viewHatch.none") }, - ...hatchOptions, - ]; - const previewHatch = hatches.find((h) => h.id === component.hatchId); - return ( -
-
- onPatch({ name: e.target.value })} - /> - -
- -
- {component.material ? ( - - ) : previewHatch ? ( - - ) : ( - - )} -
- -
- - onPatch({ abbrev: v || undefined })} - placeholder={t("resources.col.abbrev.placeholder")} - /> - - - {/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift - als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */} - onPatch({ foreground })} - /> - - - {/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */} - onPatch({ background })} - /> - - - onPatch({ hatchId })} - options={hatchOptions} - /> - - - onPatch({ viewHatchId: viewHatchId || undefined })} - options={viewHatchOptions} - /> - - - onPatch({ joinPriority })} - step={10} - min={0} - /> - - - onPatch({ texture3d: v || undefined })} - placeholder={t("resources.texture.empty")} - mono - /> - - - onPatch({ material })} - onClear={() => onPatch({ material: undefined })} - /> - -
-
- ); -} - -function ComponentsTab({ - project, - onPatchComponent, - onAddComponent, - onDeleteComponent, -}: { - project: Project; - onPatchComponent: (id: string, patch: Partial) => void; - onAddComponent: () => void; - onDeleteComponent: (id: string) => void; -}) { - const [selectedId, setSelectedId] = useState(null); - const components = project.components; - // Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben). - const selected = components.find((c) => c.id === selectedId) ?? components[0]; - - return ( -
-
{t("resources.components.hint")}
-
-
- {components.map((c) => { - const hatch = project.hatches.find((h) => h.id === c.hatchId); - return ( - - ); - })} - {components.length === 0 && ( -
{t("resources.components.empty")}
- )} -
- -
-
-
- {selected ? ( - onPatchComponent(selected.id, patch)} - onDelete={() => onDeleteComponent(selected.id)} - /> - ) : ( -
{t("resources.components.empty")}
- )} -
-
-
- ); -} - -// ── Schraffuren (Hatches) ────────────────────────────────────────────────── - -/** Detail-Panel EINER Schraffur (rechte Seite des Master-Detail-Layouts). */ -function HatchDetail({ - hatch, - lineStyles, - onPatch, - onDelete, -}: { - hatch: HatchStyle; - lineStyles: LineStyle[]; - onPatch: (patch: Partial) => void; - onDelete: () => void; -}) { - const NONE = "__none__"; - const lineOptions = [ - { value: NONE, label: t("resources.lineStyle.none") }, - ...lineStyles.map((l) => ({ value: l.id, label: l.name })), - ]; - const kind = hatch.kind ?? "vector"; - const lines = hatch.lines ?? "parallel"; - - /** Wechselt den Typ; beim Umschalten auf „Bild" Default-Bildparameter setzen. */ - const setKind = (k: "vector" | "image") => { - if (k === "image" && !hatch.image) { - onPatch({ kind: k, image: { src: "", scaleX: 1, scaleY: 1, rotation: 0 } }); - } else { - onPatch({ kind: k }); - } - }; - - /** Merge-Patch für die Bild-Parameter (behält bestehende Werte). */ - const patchImage = (part: Partial>) => { - const cur = hatch.image ?? { src: "", scaleX: 1, scaleY: 1, rotation: 0 }; - onPatch({ image: { ...cur, ...part } }); - }; - - /** Datei → Data-URL (readAsDataURL) → image.src. */ - const loadImage = (file: File | null) => { - if (!file) return; - const r = new FileReader(); - r.onload = () => patchImage({ src: String(r.result ?? "") }); - r.readAsDataURL(file); - }; - - // ── Random-Vektor-Schraffur (Kies/Splitt) ─────────────────────────────────── - // Dichte + Längenspanne + „Neu würfeln". Der Seed wird beim Klick gesetzt (KEIN - // Math.random() zur Renderzeit — die Streuung selbst ist deterministisch per - // Seed). Länge min/max in mm; bis der Nutzer sie berührt, bleibt der Default. - const lenMin = hatch.lengthMin ?? 1; - const lenMax = hatch.lengthMax ?? 3; - const reroll = () => onPatch({ seed: Math.floor(Math.random() * 0x7fffffff) }); - - return ( -
-
- onPatch({ name: e.target.value })} - /> - -
- -
- -
- -
- - - - - {kind === "vector" ? ( - <> - - onPatch({ lines: v })} - options={[ - { value: "parallel", label: t("resources.lines.parallel") }, - { value: "random", label: t("resources.lines.random") }, - ]} - /> - - - onPatch({ pattern })} - options={PATTERN_OPTIONS.map((o) => ({ - value: o.value, - label: t(o.labelKey), - }))} - /> - - - onPatch({ scale })} - step={0.1} - min={0.01} - /> - - - onPatch({ angle })} - step={5} - /> - - - onPatch({ relativeToWall: e.target.checked })} - /> - - - onPatch({ lineStyleId: v === NONE ? undefined : v })} - options={lineOptions} - /> - - {lines === "random" && ( - <> - - onPatch({ density })} - step={0.1} - min={0.1} - /> - - - onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })} - step={0.5} - min={0} - /> - - - onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })} - step={0.5} - min={0} - /> - - - - - - )} - - ) : ( - <> - - - - - patchImage({ scaleX })} - step={0.1} - min={0.05} - /> - - - patchImage({ scaleY })} - step={0.1} - min={0.05} - /> - - - patchImage({ rotation })} - step={5} - /> - - - )} -
-
- ); -} - -function HatchesTab({ - project, - onPatchHatch, - onAddHatch, - onDeleteHatch, - onImportHatches, -}: { - project: Project; - onPatchHatch: (id: string, patch: Partial) => void; - onAddHatch: () => void; - onDeleteHatch: (id: string) => void; - onImportHatches: (hatches: Omit[]) => void; -}) { - const [selectedId, setSelectedId] = useState(null); - const hatches = project.hatches; - // Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben). - const selected = hatches.find((h) => h.id === selectedId) ?? hatches[0]; - - return ( -
-
{t("resources.hatches.hint")}
-
-
- {hatches.map((h) => ( - - ))} - {hatches.length === 0 && ( -
{t("resources.hatches.empty")}
- )} -
- - onImportHatches(patToHatches(text))} - /> -
-
-
- {selected ? ( - onPatchHatch(selected.id, patch)} - onDelete={() => onDeleteHatch(selected.id)} - /> - ) : ( -
{t("resources.hatches.empty")}
- )} -
-
-
- ); -} - -// ── Linien (Line Styles) ─────────────────────────────────────────────────── - -/** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */ -/** - * Default-Motiv beim Umschalten auf „Custom": eine Dreieckszelle (0..4 mm entlang, - * ±1.5 mm quer), die entlang der Linie loopt. Der Nutzer passt sie im MotifEditor an. - */ -const DEFAULT_MOTIF: NonNullable = { - points: [ - { x: 0, y: 0 }, - { x: 1, y: 1.5 }, - { x: 2, y: 0 }, - { x: 3, y: -1.5 }, - { x: 4, y: 0 }, - ], - length: 4, -}; - -function LineStyleDetail({ - style, - onPatch, - onDelete, -}: { - style: LineStyle; - onPatch: (patch: Partial) => void; - onDelete: () => void; -}) { - const kind = style.kind ?? "dash"; - - /** - * Wechselt den Typ; beim Umschalten auf „Zickzack"/„Custom" Default-Parameter - * setzen, falls noch keine vorhanden. - */ - const setKind = (k: "dash" | "zigzag" | "custom") => { - if (k === "zigzag" && !style.zigzag) { - onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } }); - } else if (k === "custom" && !style.motif) { - onPatch({ kind: k, motif: DEFAULT_MOTIF }); - } else { - onPatch({ kind: k }); - } - }; - - /** Merge-Patch für die Zickzack-Parameter. */ - const patchZig = (part: Partial>) => { - const cur = style.zigzag ?? { amplitude: 1, wavelength: 4 }; - onPatch({ zigzag: { ...cur, ...part } }); - }; - - // ── Modulares Segment-System (dash) ───────────────────────────────────────── - // Eine musterbasierte Linie ist eine geordnete, loopende Folge aus Segmenten - // (Strich / Punkt / Lücke). Die Folge wird aus `dash` abgeleitet und beim - // Editieren via `segmentsToDash` wieder nach `dash` geschrieben — `dash` bleibt - // die einzige Datenbasis. „Volllinie" = `dash: null`. Die Dicke gehört NICHT - // mehr hierher (sie wird per Element-Attribut aufgelöst; By-Object/By-Layer folgt). - const dash = style.dash; - const isSolid = dash === null; - const segments = dashToSegments(dash); - const preset = presetOfDash(dash); - const applyPreset = (p: LinePresetKey) => { - if (p === "custom") return; - onPatch({ dash: LINE_PRESETS[p] }); - }; - const commitSegments = (next: LineSegment[]) => onPatch({ dash: segmentsToDash(next) }); - const setSegType = (i: number, type: LineSegmentType) => { - const next = segments.slice(); - const cur = next[i]; - next[i] = { type, length: type === "dot" ? 0 : cur.length > 0 ? cur.length : 2 }; - commitSegments(next); - }; - const setSegLen = (i: number, v: number) => { - const next = segments.slice(); - next[i] = { ...next[i], length: Math.max(0, v) }; - commitSegments(next); - }; - const addSegOf = (type: LineSegmentType) => - commitSegments([...segments, { type, length: type === "dot" ? 0 : 2 }]); - const removeSeg = (i: number) => commitSegments(segments.filter((_, k) => k !== i)); - - return ( -
-
- onPatch({ name: e.target.value })} - /> - -
- -
- -
- -
- - - - - onPatch({ color })} /> - - {kind === "zigzag" ? ( - <> - - patchZig({ amplitude })} - step={0.1} - min={0.05} - /> - - - patchZig({ wavelength })} - step={0.5} - min={0.5} - /> - - - ) : kind === "custom" ? ( - - onPatch({ motif: { points, length } })} - /> - - ) : ( - <> - {/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */} - - - value={preset} - onChange={(p) => applyPreset(p as LinePresetKey)} - options={[ - { value: "solid", label: t("resources.linePreset.solid") }, - { value: "dash", label: t("resources.linePreset.dash") }, - { value: "dot", label: t("resources.linePreset.dot") }, - { value: "dashDot", label: t("resources.linePreset.dashDot") }, - ]} - /> - - {!isSolid && ( - -
- {segments.map((seg, i) => ( -
- setSegType(i, type)} - options={[ - { value: "dash", label: t("resources.seg.dash") }, - { value: "dot", label: t("resources.seg.dot") }, - { value: "gap", label: t("resources.seg.gap") }, - ]} - /> - - {seg.type === "dot" ? ( - 0 - ) : ( - setSegLen(i, x)} - step={0.5} - min={0} - /> - )} - - -
- ))} -
- - - -
-
-
- )} - - )} -
-
- ); -} - -function LinesTab({ - project, - onPatchLineStyle, - onAddLineStyle, - onDeleteLineStyle, - onImportLineStyles, -}: { - project: Project; - onPatchLineStyle: (id: string, patch: Partial) => void; - onAddLineStyle: () => void; - onDeleteLineStyle: (id: string) => void; - onImportLineStyles: (styles: Omit[]) => void; -}) { - const [selectedId, setSelectedId] = useState(null); - const styles = project.lineStyles; - const selected = styles.find((l) => l.id === selectedId) ?? styles[0]; - - return ( -
- {/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */} - - {PEN_WEIGHTS.map((w) => ( - -
{t("resources.lines.hint")}
-
-
- {styles.map((l) => ( - - ))} - {styles.length === 0 && ( -
{t("resources.lines.empty")}
- )} -
- - onImportLineStyles(linToStyles(text))} - /> -
-
-
- {selected ? ( - onPatchLineStyle(selected.id, patch)} - onDelete={() => onDeleteLineStyle(selected.id)} - /> - ) : ( -
{t("resources.lines.empty")}
- )} -
-
-
- ); -} - -// ── Wandstile & Deckenstile (geschichteter Aufbau) ───────────────────────── -// Beide Ressourcen teilen Struktur (WallType/CeilingType = {id,name,layers[]}) -// und Editor: ein Master-Detail-Layout wie bei Schraffuren/Linien. Liste links -// = je Typ eine Zeile mit kleiner Querschnitt-Vorschau + Name; Detail rechts = -// Aufbau-Editor (Schichten stapeln: Bauteil/Dicke/Fugenlinie, hinzufügen/ -// entfernen/umordnen) + großer Live-Querschnitt. Ein gemeinsames Detail-Panel -// {@link LayeredStyleDetail}, parametrisiert über die Orientierung (Wand: -// Bänder nebeneinander / Decke: liegend gestapelt) und die jeweiligen Handler. -// -// Fugen: `layer[i].jointLineStyleId` ist der Linienstil der INNEREN Kante der -// Schicht i (Fuge zur nächst-inneren Schicht). Die innerste Schicht hat keine -// innere Fuge (ihre Kante ist der Innenumriss) → dort kein Dropdown. - -/** Wand- und Deckentyp teilen dieselbe Form (`{id,name,layers}`). */ -type LayeredStyle = WallType | CeilingType; - -/** - * Baut die Querschnitt-Bänder (eine je Schicht) aus den Bauteil-Schnitt- - * schraffuren: Schicht → Bauteil → dessen `hatchId` → HatchStyle. Fehlt das - * Bauteil/die Schraffur, bleibt das Band leer (weiß). - */ -function swatchLayersOf(style: LayeredStyle, project: Project): SwatchLayer[] { - return style.layers.map((l) => { - const comp = project.components.find((c) => c.id === l.componentId); - const hatch = comp - ? project.hatches.find((hh) => hh.id === comp.hatchId) - : undefined; - return { hatch, thickness: l.thickness }; - }); -} - -/** Detail-Panel EINES Wand-/Deckentyps (rechte Seite des Master-Detail-Layouts). */ -function LayeredStyleDetail({ - style, - project, - orientation, - onPatch, - onDelete, - deleteTitle, - namePlaceholder, -}: { - style: LayeredStyle; - project: Project; - orientation: "wall" | "ceiling"; - onPatch: (patch: Partial) => void; - onDelete: () => void; - deleteTitle: string; - namePlaceholder: string; -}) { - // Sentinel „— (Haarlinie)": kein Feld gesetzt → Standard-Haarlinie 0.02 mm. - const DEFAULT = "__default__"; - const jointOptions = [ - { value: DEFAULT, label: t("resources.joint.default") }, - ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), - ]; - const compOptions = - project.components.length > 0 - ? project.components.map((c) => ({ value: c.id, label: c.name })) - : [{ value: "", label: "—" }]; - - const layers = style.layers; - const setLayers = (next: Layer[]) => onPatch({ layers: next }); - const patchLayer = (i: number, patch: Partial) => - setLayers(layers.map((l, k) => (k === i ? { ...l, ...patch } : l))); - const addLayer = () => - setLayers([ - ...layers, - { componentId: project.components[0]?.id ?? "", thickness: 0.1 }, - ]); - const removeLayer = (i: number) => { - if (layers.length <= 1) return; - setLayers(layers.filter((_, k) => k !== i)); - }; - const moveLayer = (i: number, dir: -1 | 1) => { - const j = i + dir; - if (j < 0 || j >= layers.length) return; - const next = layers.slice(); - [next[i], next[j]] = [next[j], next[i]]; - setLayers(next); - }; - - return ( -
-
- onPatch({ name: e.target.value })} - /> - -
- -
- -
- -
- {layers.map((layer, i) => { - const isInner = i === layers.length - 1; - return ( -
-
- - -
-
- - patchLayer(i, { componentId })} - options={compOptions} - /> - - - - patchLayer(i, { thickness: Math.max(0, mm) / 1000 }) - } - step={5} - min={0} - /> - - - {isInner ? ( - – - ) : ( - - patchLayer(i, { - jointLineStyleId: v === DEFAULT ? undefined : v, - }) - } - options={jointOptions} - /> - )} - -
- removeLayer(i)} - title={t("resources.layer.remove")} - /> -
- ); - })} - -
-
- ); -} - -/** - * Gemeinsamer Master-Detail-Tab-Rumpf für Wand-/Deckenstile: Liste links - * (Querschnitt-Vorschau + Name, +-Neu in der Fusszeile), Detail rechts über - * {@link LayeredStyleDetail}. Parametrisiert über die Ressource + Orientierung. - */ -function LayeredStylesTab({ - project, - types, - orientation, - hintKey, - emptyKey, - placeholderKey, - deleteKeyPrefix, - onPatch, - onAdd, - onDelete, -}: { - project: Project; - types: LayeredStyle[]; - orientation: "wall" | "ceiling"; - hintKey: string; - emptyKey: string; - placeholderKey: string; - deleteKeyPrefix: string; - onPatch: (id: string, patch: Partial) => void; - onAdd: () => void; - onDelete: (id: string) => void; -}) { - const [selectedId, setSelectedId] = useState(null); - const selected = types.find((s) => s.id === selectedId) ?? types[0]; - - return ( -
-
{t(hintKey)}
-
-
- {types.map((s) => ( - - ))} - {types.length === 0 && ( -
{t(emptyKey)}
- )} -
- -
-
-
- {selected ? ( - onPatch(selected.id, patch)} - onDelete={() => onDelete(selected.id)} - deleteTitle={t(deleteKeyPrefix, { name: selected.name })} - namePlaceholder={t(placeholderKey)} - /> - ) : ( -
{t(emptyKey)}
- )} -
-
-
- ); -} - -function WallStylesTab({ - project, - onPatchWallType, - onAddWallType, - onDeleteWallType, -}: { - project: Project; - onPatchWallType: (id: string, patch: Partial) => void; - onAddWallType: () => void; - onDeleteWallType: (id: string) => void; -}) { - return ( - - ); -} - -/** - * Tab „Deckenstile" — das liegende Gegenstück zu {@link WallStylesTab}: nutzt - * denselben {@link LayeredStylesTab}-Rumpf, nur die Ressource (`ceilingTypes`), - * die Orientierung (gestapelt) und die Handler unterscheiden sich. - */ -function CeilingStylesTab({ - project, - onPatchCeilingType, - onAddCeilingType, - onDeleteCeilingType, -}: { - project: Project; - onPatchCeilingType: (id: string, patch: Partial) => void; - onAddCeilingType: () => void; - onDeleteCeilingType: (id: string) => void; -}) { - return ( - - ); -} - -/** - * Tab „Dachtypen" — das geneigte Gegenstück zu {@link CeilingStylesTab}: gleicher - * {@link LayeredStylesTab}-Rumpf (Schichtaufbau aussen/Eindeckung → innen/ - * Verkleidung), Ressource `roofTypes`. Vorschau in Decken-Orientierung - * (gestapelt) — für die Schichtreihenfolge/-dicken ausreichend. - */ -function RoofStylesTab({ - project, - onPatchRoofType, - onAddRoofType, - onDeleteRoofType, -}: { - project: Project; - onPatchRoofType: (id: string, patch: Partial) => void; - onAddRoofType: () => void; - onDeleteRoofType: (id: string) => void; -}) { - return ( - - ); -} - -/** - * Datei-Import-Schaltfläche (verstecktes ). Liest die gewählte - * Datei als Text und reicht ihn nach oben. Für .lin/.pat-Import. - */ -function FileImportButton({ - accept, - label, - onText, -}: { - accept: string; - label: string; - onText: (text: string) => void; -}) { - const ref = useRef(null); - return ( - <> - - { - const f = e.target.files?.[0]; - if (f) { - const r = new FileReader(); - r.onload = () => onText(String(r.result ?? "")); - r.readAsText(f); - } - e.target.value = ""; - }} - /> - - ); -} - -/** .lin-Text → id-lose Linienstile (schwarze Haarlinie 0.18 als Default). */ -function linToStyles(text: string): Omit[] { - return parseLin(text).map((p) => ({ - name: p.name, - weight: 0.18, - color: "#1a1a1a", - dash: p.dash, - })); -} - -/** - * .pat-Text → id-lose Schraffuren. Unser Modell kennt nur feste Muster; daher - * approximieren wir: ≥2 Linienfamilien → Kreuzschraffur, sonst Diagonal, mit dem - * Winkel der ersten Familie. (Vollwertiges custom-Pattern später.) - */ -function patToHatches(text: string): Omit[] { - return parsePat(text).map((p) => ({ - name: p.name, - pattern: (p.families.length >= 2 ? "crosshatch" : "diagonal") as HatchPattern, - scale: 1, - angle: p.families[0]?.angle ?? 45, - color: "#1a1a1a", - // Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin"). - lineStyleId: "thin", - })); -} - -// ── 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 DOOR_FRAME_KIND_OPTIONS: { value: NonNullable; labelKey: string }[] = [ - { value: "zarge", labelKey: "resources.frameKind.zarge" }, - { value: "blockrahmen", labelKey: "resources.frameKind.blockrahmen" }, -]; - -// Bezugsfläche des Schichteinzugs — von aussen oder innen gemessen. Für Tür UND -// Fenster genutzt (gleiche Semantik). -const INSET_FACE_OPTIONS: { value: "aussen" | "innen"; labelKey: string }[] = [ - { value: "aussen", labelKey: "resources.insetFace.aussen" }, - { value: "innen", labelKey: "resources.insetFace.innen" }, -]; - -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({ frameKind })} - options={DOOR_FRAME_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} - /> - - - patch({ frameWidth })} - step={0.005} - min={0} - /> - - - patch({ insetFromFace })} - step={0.01} - min={0} - /> - - {(door.insetFromFace ?? 0) > 0 && ( - - patch({ insetFace })} - options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} - /> - - )} - - patch({ transomHeight })} - 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({ mullionRows: Math.max(1, Math.round(mullionRows)) })} - step={1} - min={1} - max={4} - /> - - - patch({ mullionCols: Math.max(1, Math.round(mullionCols)) })} - 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({ frameWidth })} - step={0.005} - min={0} - /> - - - patch({ insetFromFace })} - step={0.01} - min={0} - /> - - {(win.insetFromFace ?? 0) > 0 && ( - - patch({ insetFace })} - options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} - /> - - )} - - patch({ innerLayerRecess: e.target.checked })} - title={t("resources.field.innerLayerRecess.hint")} - /> - - - patch({ transomHeight })} - 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 -// als Badge, Suche + Kategorie-Chips kombinierbar. Die Zuweisung an ein -// konkretes Bauteil bleibt der bestehende Weg (Spalte „Material" der Bauteil- -// Tabelle → MaterialPicker); dieser Tab dient dem Durchsuchen/Vergleichen der -// Bibliothek, Klick auf eine Kachel markiert sie (aktiv, wie `mat-tile.active` -// im Picker) und zeigt Name/Kategorie in der Statuszeile. - -/** - * Alle in der Bibliothek vorkommenden Kategorien, alphabetisch. Das Manifest - * schreibt Kategorien uneinheitlich („PavingStones" neben „Paving Stones") — - * für Chips und Filter zählt der normalisierte Schlüssel (ohne Leerzeichen, - * case-insensitiv); angezeigt wird die zuerst gesehene Schreibweise. - */ -const categoryKey = (c: string) => c.replace(/\s+/g, "").toLowerCase(); -const MATERIAL_CATEGORIES: string[] = (() => { - const byKey = new Map(); - for (const a of MATERIAL_LIBRARY) { - const k = categoryKey(a.category); - if (!byKey.has(k)) byKey.set(k, a.category); - } - return Array.from(byKey.values()).sort((a, b) => a.localeCompare(b)); -})(); - -/** - * Eine Bibliotheks-Kachel: rendert erst eine PBR-Kugel-Vorschau, sobald sie - * tatsächlich sichtbar ist (IntersectionObserver) — bei ~90 Materialien sonst - * unnötige GPU-Arbeit für Kacheln außerhalb des Sichtfensters. Der eigentliche - * Render läuft ohnehin seriell über einen einzigen geteilten WebGL-Kontext - * (spherePreview.ts), die Sichtbarkeitsprüfung drosselt zusätzlich die - * Warteschlange auf das gerade Interessante. - */ -function MaterialLibraryTile({ - asset, - active, - onSelect, -}: { - asset: MaterialAsset; - active: boolean; - onSelect: () => void; -}) { - 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 ( - - ); -} - -function MaterialsTab() { - const [query, setQuery] = useState(""); - const [category, setCategory] = useState(null); - const [selectedId, setSelectedId] = useState(null); - - const filtered = MATERIAL_LIBRARY.filter((asset) => { - if (category && categoryKey(asset.category) !== categoryKey(category)) return false; - const q = query.trim().toLowerCase(); - if (q && !asset.name.toLowerCase().includes(q) && !asset.id.toLowerCase().includes(q)) { - return false; - } - return true; - }); - const selected = MATERIAL_LIBRARY.find((a) => a.id === selectedId); - - return ( -
-
{t("resources.materials.hint")}
- -
- setQuery(e.target.value)} - /> -
- - {MATERIAL_CATEGORIES.map((c) => ( - - ))} -
-
- - {selected && ( -
- {t("resources.materials.selected", { - name: selected.name, - category: selected.category, - })} -
- )} - -
- {filtered.map((asset) => ( - setSelectedId((cur) => (cur === asset.id ? null : asset.id))} - /> - ))} -
- {filtered.length === 0 && } - -
- - {t("resources.materials.count", { - shown: filtered.length, - total: MATERIAL_LIBRARY.length, - })} - -
-
- ); -} - -// ── 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 }) { - return
{text}
; -} +import { ComponentsTab } from "./resourceManager/ComponentsTab"; +import { HatchesTab } from "./resourceManager/HatchesTab"; +import { LinesTab } from "./resourceManager/LinesTab"; +import { CeilingStylesTab, RoofStylesTab, WallStylesTab } from "./resourceManager/LayeredStylesTab"; +import { DoorStylesTab } from "./resourceManager/DoorStylesTab"; +import { WindowStylesTab } from "./resourceManager/WindowStylesTab"; +import { StairStylesTab } from "./resourceManager/StairStylesTab"; +import { MaterialsTab } from "./resourceManager/MaterialsTab"; +import { OverridesTab } from "./resourceManager/OverridesTab"; // ── Manager-Schublade ────────────────────────────────────────────────────── diff --git a/src/ui/StartScreen.tsx b/src/ui/StartScreen.tsx index 4d2b150..16c0a81 100644 --- a/src/ui/StartScreen.tsx +++ b/src/ui/StartScreen.tsx @@ -1,16 +1,32 @@ // Startbildschirm nach dem Splash (s. SplashScreen.tsx) — wie bei den meisten // CAD-Programmen: „Neues Projekt" / „Projekt öffnen…" plus eine Liste zuletzt // geöffneter/gespeicherter Projekte (state/recentProjects.ts, nur Tauri — -// im Browser gibt es keine echten Dateipfade zum Wiederöffnen). -import { useState } from "react"; -import { t } from "../i18n"; +// im Browser gibt es keine echten Dateipfade zum Wiederöffnen) und die +// tatsächlich laufende Version in der Fussleiste (s. update/version.ts). +import { useEffect, useState } from "react"; +import { getLanguage, t } from "../i18n"; import { isTauriRuntime } from "../io/projectFile"; +import { APP_VERSION } from "./TopBar"; +import { getRunningVersion } from "../update/version"; import { getRecentProjects, removeRecentProject, type RecentProjectEntry, } from "../state/recentProjects"; +/** "vor 2 Std." / "2 hours ago" — grob gerundet auf die gröbste sinnvolle + * Einheit (Minuten/Stunden/Tage), lokalisiert über die aktuelle App-Sprache. */ +function formatRelativeTime(iso: string): string { + const diffMs = new Date(iso).getTime() - Date.now(); + const rtf = new Intl.RelativeTimeFormat(getLanguage(), { numeric: "auto" }); + const minutes = Math.round(diffMs / 60_000); + if (Math.abs(minutes) < 60) return rtf.format(minutes, "minute"); + const hours = Math.round(diffMs / 3_600_000); + if (Math.abs(hours) < 24) return rtf.format(hours, "hour"); + const days = Math.round(diffMs / 86_400_000); + return rtf.format(days, "day"); +} + export function StartScreen({ onNewProject, onOpenProject, @@ -21,55 +37,65 @@ export function StartScreen({ onOpenRecent: (path: string) => void; }) { const [recent, setRecent] = useState(() => getRecentProjects()); + const [version, setVersion] = useState(null); + useEffect(() => { + void getRunningVersion().then(setVersion); + }, []); const showRecent = isTauriRuntime() && recent.length > 0; return (
-
- dossier. -
-
- - -
- {showRecent && ( -
-
{t("start.recent")}
-
    - {recent.map((e) => ( -
  • - - -
  • - ))} -
+
+
+ dossier.
- )} +
+ + +
+ {showRecent && ( +
+
{t("start.recent")}
+
    + {recent.map((e) => ( +
  • + + {formatRelativeTime(e.openedAt)} + +
  • + ))} +
+
+ )} +
+
+ {t("about.version")} {version ?? APP_VERSION} +
); } diff --git a/src/ui/layoutSheetMath.test.ts b/src/ui/layoutSheetMath.test.ts index 1eb052f..b1618b8 100644 --- a/src/ui/layoutSheetMath.test.ts +++ b/src/ui/layoutSheetMath.test.ts @@ -2,15 +2,19 @@ import { describe, expect, it } from "vitest"; import type { LayoutAnnotation, LayoutViewport } from "../model/types"; import { annotationBoundsMm, + annotationHandleAt, applyHandleDrag, + applyLineHandleDrag, clampRectToSheet, distanceToSegment, fitTransform, handleAt, + lineHandleAt, mmToScreen, normalizeRect, panBy, pickAnnotation, + pickAnnotationHandle, pickViewport, screenToMm, translateAnnotation, @@ -226,6 +230,15 @@ const text = (id: string, x: number, y: number, t: string, h = 3.5): LayoutAnnot text: t, heightMm: h, }); +const image = (id: string, x: number, y: number, w: number, h: number): LayoutAnnotation => ({ + id, + kind: "image", + xMm: x, + yMm: y, + widthMm: w, + heightMm: h, + src: "data:image/png;base64,", +}); describe("distanceToSegment", () => { it("returns perpendicular distance for a point beside the segment", () => { @@ -264,6 +277,14 @@ describe("annotationBoundsMm", () => { expect(b.heightMm).toBeCloseTo(5); // heightMm * 1.25 expect(b.widthMm).toBeGreaterThan(0); }); + it("image: its own rectangle, like rect", () => { + expect(annotationBoundsMm(image("i", 5, 5, 40, 30))).toEqual({ + xMm: 5, + yMm: 5, + widthMm: 40, + heightMm: 30, + }); + }); }); describe("pickAnnotation", () => { @@ -286,6 +307,68 @@ describe("pickAnnotation", () => { const overlapping = [rect("a", 0, 0, 50, 50), rect("b", 10, 10, 50, 50)]; expect(pickAnnotation(overlapping, 30, 30, 1)).toBe("b"); }); + it("hits an image on its border and its interior, like rect", () => { + const img = image("i", 20, 20, 10, 10); + expect(pickAnnotation([img], 20, 20, 1)).toBe("i"); // corner + expect(pickAnnotation([img], 25, 25, 1)).toBe("i"); // interior + expect(pickAnnotation([img], 100, 100, 1)).toBe(null); + }); +}); + +describe("lineHandleAt", () => { + it("hits the first endpoint within tolerance", () => { + expect(lineHandleAt(0, 0, 10, 0, 0.5, 0.5, 2)).toBe("p1"); + }); + it("hits the second endpoint within tolerance", () => { + expect(lineHandleAt(0, 0, 10, 0, 9.5, 0.5, 2)).toBe("p2"); + }); + it("hits the body between the endpoints", () => { + expect(lineHandleAt(0, 0, 10, 0, 5, 1, 2)).toBe("body"); + }); + it("misses far away", () => { + expect(lineHandleAt(0, 0, 10, 0, 5, 20, 2)).toBe(null); + }); +}); + +describe("annotationHandleAt / pickAnnotationHandle", () => { + it("line: endpoint takes priority over body", () => { + const l = line("l", 0, 0, 10, 0); + expect(annotationHandleAt(l, 0, 0.5, 2)).toEqual({ kind: "line", handle: "p1" }); + expect(annotationHandleAt(l, 5, 0.5, 2)).toEqual({ kind: "line", handle: "body" }); + }); + it("rect: corner/edge/body like a viewport", () => { + const r = rect("r", 20, 20, 10, 10); + expect(annotationHandleAt(r, 20, 20, 1)).toEqual({ kind: "rect", handle: "nw" }); + expect(annotationHandleAt(r, 25, 25, 1)).toEqual({ kind: "rect", handle: "body" }); + }); + it("image: same handle set as rect", () => { + const img = image("i", 20, 20, 10, 10); + expect(annotationHandleAt(img, 30, 30, 1)).toEqual({ kind: "image", handle: "se" }); + }); + it("text: no handle system, plain bbox hit still reported as pickAnnotationHandle body", () => { + const tx = text("t", 10, 20, "Hallo", 4); + expect(annotationHandleAt(tx, 12, 18, 1)).toBe(null); + expect(pickAnnotationHandle([tx], 12, 18, 1)).toEqual({ + id: "t", + hit: { kind: "line", handle: "body" }, + }); + }); + it("pickAnnotationHandle returns null outside every annotation", () => { + expect(pickAnnotationHandle([line("l", 0, 0, 10, 0)], 500, 500, 1)).toBe(null); + }); +}); + +describe("applyLineHandleDrag", () => { + const l = line("l", 0, 0, 10, 0) as Extract; + it("p1 moves only the first endpoint", () => { + expect(applyLineHandleDrag(l, "p1", 2, 3)).toEqual({ x1Mm: 2, y1Mm: 3, x2Mm: 10, y2Mm: 0 }); + }); + it("p2 moves only the second endpoint", () => { + expect(applyLineHandleDrag(l, "p2", 2, 3)).toEqual({ x1Mm: 0, y1Mm: 0, x2Mm: 12, y2Mm: 3 }); + }); + it("body moves both endpoints", () => { + expect(applyLineHandleDrag(l, "body", 2, 3)).toEqual({ x1Mm: 2, y1Mm: 3, x2Mm: 12, y2Mm: 3 }); + }); }); describe("translateAnnotation", () => { @@ -301,4 +384,8 @@ describe("translateAnnotation", () => { const moved = translateAnnotation(text("t", 10, 10, "Hi"), 1, 2); expect(moved).toMatchObject({ xMm: 11, yMm: 12, text: "Hi" }); }); + it("moves the origin of an image, like rect", () => { + const moved = translateAnnotation(image("i", 10, 10, 20, 20), 3, 4); + expect(moved).toMatchObject({ xMm: 13, yMm: 14, widthMm: 20, heightMm: 20 }); + }); }); diff --git a/src/ui/layoutSheetMath.ts b/src/ui/layoutSheetMath.ts index cc453c0..aa92f61 100644 --- a/src/ui/layoutSheetMath.ts +++ b/src/ui/layoutSheetMath.ts @@ -321,9 +321,10 @@ export function handleAnchors(r: RectMm): { handle: Handle; xMm: number; yMm: nu ]; } -// ── 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). +// ── Annotationen (Linie/Rechteck/Text/Bild) ───────────────────────────────── +// Rechteck/Bild teilen sich die Viewport-Griff-Mathe (dieselbe RectMm-Form, +// 8 Ecken/Kanten-Griffe). Linie bekommt zwei Endpunkt-Griffe (wie eine Linie +// im Grundriss). Text bleibt vorerst griff-los (Rahmen kommt beim Aufziehen). /** Kürzester Abstand (mm) eines Punkts zu einem Liniensegment. */ export function distanceToSegment( @@ -351,18 +352,22 @@ export function distanceToSegment( * Baseline, die Box liegt daher oberhalb davon. */ export function annotationBoundsMm(ann: LayoutAnnotation): RectMm { - if (ann.kind === "rect") { + if (ann.kind === "rect" || ann.kind === "image") { 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); + // Text: grobe Box aus Zeichenzahl geschätzt (kein echtes Font-Measuring im + // SVG) — bei gesetzter Rahmenbreite (`widthMm`, Rich-Text-Frame) gilt diese + // statt der Schätzung; die Höhe bleibt eine grobe Zeilenschätzung. + const estWidthMm = Math.max(ann.heightMm, ann.text.length * ann.heightMm * 0.6); + const lines = ann.doc ? Math.max(1, ann.doc.paragraphs.length) : 1; return { xMm: ann.xMm, yMm: ann.yMm - ann.heightMm, - widthMm, - heightMm: ann.heightMm * 1.25, + widthMm: ann.widthMm ?? estWidthMm, + heightMm: ann.heightMm * 1.25 * lines, }; } @@ -380,7 +385,7 @@ export function annotationHit( if (ann.kind === "line") { return distanceToSegment(xMm, yMm, ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm) <= tolMm; } - if (ann.kind === "rect") { + if (ann.kind === "rect" || ann.kind === "image") { const r: RectMm = { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm }; return handleAt(r, xMm, yMm, tolMm) !== null; } @@ -410,6 +415,112 @@ export function pickAnnotation( return null; } +// ── Resize-Griffe für Annotationen (wie im Grundriss: Endpunkte/Ecken) ────── + +/** Griff einer Linien-Annotation: die beiden Endpunkte oder der Körper. */ +export type LineHandle = "p1" | "p2" | "body"; + +/** Griff-Ergebnis für EINE Annotation — je Art ein anderer Griff-Typ. */ +export type AnnotHandleHit = + | { kind: "rect" | "image"; handle: Handle } + | { kind: "line"; handle: LineHandle }; + +/** + * Griff (oder Körper) einer Linien-Annotation an einem mm-Punkt: die + * Endpunkte haben Vorrang vor dem Segment (wie ein Linien-Griff im Grundriss). + */ +export function lineHandleAt( + x1: number, + y1: number, + x2: number, + y2: number, + xMm: number, + yMm: number, + tolMm: number, +): LineHandle | null { + if (Math.hypot(xMm - x1, yMm - y1) <= tolMm) return "p1"; + if (Math.hypot(xMm - x2, yMm - y2) <= tolMm) return "p2"; + if (distanceToSegment(xMm, yMm, x1, y1, x2, y2) <= tolMm) return "body"; + return null; +} + +/** + * Griff (oder Körper) EINER Annotation an einem mm-Punkt — Linie liefert einen + * Endpunkt-Griff, Rechteck/Bild die 8 Viewport-artigen Ecken-/Kanten-Griffe + * (wiederverwendet {@link handleAt}). Text hat (noch) keine Griffe → `null`. + */ +export function annotationHandleAt( + ann: LayoutAnnotation, + xMm: number, + yMm: number, + tolMm: number, +): AnnotHandleHit | null { + if (ann.kind === "line") { + const h = lineHandleAt(ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm, xMm, yMm, tolMm); + return h ? { kind: "line", handle: h } : null; + } + if (ann.kind === "rect" || ann.kind === "image") { + const r: RectMm = { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm }; + const h = handleAt(r, xMm, yMm, tolMm); + return h ? { kind: ann.kind, handle: h } : null; + } + return null; +} + +/** + * Topmost Griff-Treffer über eine Annotations-Liste (wie {@link pickAnnotation}, + * aber liefert zusätzlich den konkreten Griff für Resize statt nur Verschieben). + */ +export function pickAnnotationHandle( + annotations: LayoutAnnotation[], + xMm: number, + yMm: number, + tolMm: number, +): { id: string; hit: AnnotHandleHit } | null { + for (let i = annotations.length - 1; i >= 0; i--) { + const a = annotations[i]; + const hit = annotationHandleAt(a, xMm, yMm, tolMm); + if (hit) return { id: a.id, hit }; + if (annotationHit(a, xMm, yMm, tolMm)) { + // Text (kein Griff-System) oder ein Nicht-Griff-Treffer im Inneren. + return { id: a.id, hit: { kind: "line", handle: "body" } }; + } + } + return null; +} + +/** Endpunkte einer Linien-Annotation als mm-Punkte (für die Griff-Darstellung). */ +export function lineHandleAnchors(ann: Extract): Mm[] { + return [ + { xMm: ann.x1Mm, yMm: ann.y1Mm }, + { xMm: ann.x2Mm, yMm: ann.y2Mm }, + ]; +} + +/** + * Wendet einen Linien-Griff-Drag an: `p1`/`p2` verschiebt genau diesen + * Endpunkt, `body` das ganze Segment (Delta). + */ +export function applyLineHandleDrag( + start: Extract, + handle: LineHandle, + dxMm: number, + dyMm: number, +): { x1Mm: number; y1Mm: number; x2Mm: number; y2Mm: number } { + if (handle === "p1") { + return { x1Mm: start.x1Mm + dxMm, y1Mm: start.y1Mm + dyMm, x2Mm: start.x2Mm, y2Mm: start.y2Mm }; + } + if (handle === "p2") { + return { x1Mm: start.x1Mm, y1Mm: start.y1Mm, x2Mm: start.x2Mm + dxMm, y2Mm: start.y2Mm + dyMm }; + } + return { + x1Mm: start.x1Mm + dxMm, + y1Mm: start.y1Mm + dyMm, + x2Mm: start.x2Mm + dxMm, + y2Mm: start.y2Mm + dyMm, + }; +} + /** Verschiebt eine Annotation um ein mm-Delta — alle ihre Punkte wandern mit. */ export function translateAnnotation( ann: LayoutAnnotation, diff --git a/src/ui/resourceManager/ComponentsTab.tsx b/src/ui/resourceManager/ComponentsTab.tsx new file mode 100644 index 0000000..6e4b26e --- /dev/null +++ b/src/ui/resourceManager/ComponentsTab.tsx @@ -0,0 +1,738 @@ +// Tab „Bauteile" (Components) — Poché-/3D-Farbe, Verschneidungs-Rang, Schraffur, +// Material. Master-Detail-Layout wie die übrigen Ressourcen-Tabs: Liste links, +// Detail-Editor rechts (siehe {@link ComponentDetail}). + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Component, ComponentMaterial, Project } from "../../model/types"; +import { MATERIAL_LIBRARY } from "../../materials/library"; +import type { MaterialAsset } from "../../materials/library"; +import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../../materials/runtime"; +import { + searchMaterials, + fetchMaterialMaps, + AMBIENT_CATEGORIES, + type AmbientMaterial, + type AmbientResolution, +} from "../../materials/ambientcg"; +import { requestMaterialPreview, cancelMaterialPreview } from "../../materials/spherePreview"; +import { Dropdown } from "../Dropdown"; +import { HatchSwatch } from "../hatchPreview"; +import { t } from "../../i18n"; +import { DeleteButton, FieldRow, ImagePickButton, TextField, ColorField, NumberField, SelectField } from "./fields"; + +/** + * Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material- + * Namen zeigt (oder „ohne") und ein Auswahl-Modal öffnet. Die Zuweisung läuft + * über `onAssign`/`onClear` (immutabel via onPatchComponent). + */ +function MaterialCell({ + material, + onAssign, + onClear, +}: { + material: ComponentMaterial | undefined; + onAssign: (m: ComponentMaterial) => void; + onClear: () => void; +}) { + const [open, setOpen] = useState(false); + const label = materialLabel(material); + return ( + <> + + {open && ( + { + onAssign(m); + }} + onClear={() => { + onClear(); + }} + onClose={() => setOpen(false)} + /> + )} + + ); +} + +/** Anzeigename eines zugewiesenen Materials (Bibliothek → Name, sonst Upload). */ +function materialLabel(m: ComponentMaterial | undefined): string { + if (!m) return t("material.none"); + if (m.libraryId) { + const asset = MATERIAL_LIBRARY.find((a) => a.id === m.libraryId); + if (asset) return asset.name; + } + return t("material.uploaded"); +} + +/** + * Material-Auswahl-Modal: oben die eingebaute Bibliothek als Vorschau-Grid + * (Farb-Karte als Thumbnail) — Klick weist sofort zu; darunter ein Upload- + * Bereich (Farbe/Normal/Rauheit/Höhe/AO als Bild-Dateien → ObjectURL) plus die + * physische Kachelgröße. „Zuweisen" übernimmt den Upload; „Material entfernen" + * setzt das Bauteil auf kein Material zurück. + */ +function MaterialPicker({ + material, + onAssign, + onClear, + onClose, +}: { + material: ComponentMaterial | undefined; + onAssign: (m: ComponentMaterial) => void; + onClear: () => void; + onClose: () => void; +}) { + // Upload-Zustand: gewählte Karten als ObjectURLs + Kachelgröße. Startwert aus + // einem bereits zugewiesenen Upload-Material (damit Größe/Karten sichtbar sind). + const [upload, setUpload] = useState(() => + material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M }, + ); + const [sizeM, setSizeM] = useState(material?.sizeM ?? DEFAULT_TILE_SIZE_M); + // Modus des Pickers: Schnellauswahl (lokaler Starter, offline), Live-Browse + // der kompletten ambientCG-Bibliothek, oder Upload eigener Karten. + const [mode, setMode] = useState<"starter" | "browse" | "upload">("starter"); + + const pickFile = (kind: keyof ComponentMaterial) => (file: File | null) => { + if (!file) return; + const url = URL.createObjectURL(file); + setUpload((u) => ({ ...u, [kind]: url })); + }; + + const uploadFields: { kind: keyof ComponentMaterial; labelKey: string }[] = [ + { kind: "color", labelKey: "material.upload.color" }, + { kind: "normal", labelKey: "material.upload.normal" }, + { kind: "roughness", labelKey: "material.upload.roughness" }, + { kind: "displacement", labelKey: "material.upload.displacement" }, + { kind: "ao", labelKey: "material.upload.ao" }, + ]; + + const applyUpload = () => { + if (!upload.color && !upload.normal && !upload.roughness && !upload.ao && !upload.displacement) { + return; + } + onAssign({ ...upload, libraryId: undefined, sizeM }); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > +
+ {t("material.dialog.title")} + +
+
{t("material.dialog.hint")}
+ + {/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */} + + + {/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */} + {mode === "starter" && ( +
+ {MATERIAL_LIBRARY.map((asset) => ( + + ))} +
+ )} + + {/* Live-Browse der kompletten ambientCG-Bibliothek. */} + {mode === "browse" && ( + { + onAssign(m); + onClose(); + }} + /> + )} + + {/* Upload eigener Karten. */} + {mode === "upload" && ( + <> +
{t("material.upload.hint")}
+
+ {uploadFields.map((f) => ( + + ))} + +
+
+ +
+ + )} + + {/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */} + {mode !== "upload" && ( +
+ + +
+ )} +
+
+ ); +} + +/** + * Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter + * + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt + * geladen — CORS `*`) mit Pagination („mehr laden"). Ein Klick lädt die Textur- + * Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt + * sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände + * sauber (Spinner, Offline-/CORS-Hinweis). + */ +function AmbientBrowser({ + sizeM, + activeId, + onAssign, +}: { + sizeM: number; + activeId: string | undefined; + onAssign: (m: ComponentMaterial) => void; +}) { + const [query, setQuery] = useState(""); + const [committedQuery, setCommittedQuery] = useState(""); + const [category, setCategory] = useState(""); + const [resolution, setResolution] = useState("1K"); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // ID des gerade herunterladenden Materials (Spinner auf der Kachel). + const [downloadingId, setDownloadingId] = useState(null); + + const LIMIT = 24; + + // Suche (bei committedQuery/category-Wechsel neu laden — Seite 0). + useEffect(() => { + const ctrl = new AbortController(); + setLoading(true); + setError(null); + searchMaterials({ + query: committedQuery, + category, + limit: LIMIT, + offset: 0, + signal: ctrl.signal, + }) + .then((res) => { + setItems(res.items); + setTotal(res.total); + setOffset(0); + }) + .catch((e) => { + if (ctrl.signal.aborted) return; + setError(t("material.browse.error")); + setItems([]); + setTotal(0); + console.error("[ambientcg] search failed:", e); + }) + .finally(() => { + if (!ctrl.signal.aborted) setLoading(false); + }); + return () => ctrl.abort(); + }, [committedQuery, category]); + + const loadMore = () => { + const nextOffset = offset + LIMIT; + setLoading(true); + searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset }) + .then((res) => { + setItems((prev) => [...prev, ...res.items]); + setTotal(res.total); + setOffset(nextOffset); + }) + .catch((e) => { + setError(t("material.browse.error")); + console.error("[ambientcg] load more failed:", e); + }) + .finally(() => setLoading(false)); + }; + + const pick = async (id: string) => { + setDownloadingId(id); + setError(null); + try { + const { material } = await fetchMaterialMaps(id, resolution, sizeM); + onAssign(material); + } catch (e) { + setError(t("material.browse.downloadError")); + console.error("[ambientcg] download failed:", e); + } finally { + setDownloadingId(null); + } + }; + + const submitSearch = () => setCommittedQuery(query); + + return ( +
+
{t("material.browse.hint")}
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitSearch(); + }} + /> + + setCategory(v)} + options={[ + { value: "", label: t("material.browse.category.all") }, + ...AMBIENT_CATEGORIES.map((c) => ({ value: c, label: c })), + ]} + /> + setResolution(v as AmbientResolution)} + options={[ + { value: "1K", label: "1K" }, + { value: "2K", label: "2K" }, + { value: "4K", label: "4K" }, + ]} + /> +
+ + {error &&
{error}
} + +
+ {items.map((it) => ( + + ))} +
+ + {!loading && items.length === 0 && !error && ( +
{t("material.browse.empty")}
+ )} + +
+ {loading && {t("material.browse.loading")}} + {!loading && items.length > 0 && ( + + {t("material.browse.count", { shown: items.length, total })} + + )} + {!loading && items.length < total && ( + + )} +
+
+ ); +} + +/** Kleiner Farb-Swatch als Listen-Thumbnail für Bauteile ohne Schraffur. */ +function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) { + return ( + + ); +} + +/** + * 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, + hatches, + onPatch, + onDelete, +}: { + component: Component; + hatches: Project["hatches"]; + onPatch: (patch: Partial) => void; + onDelete: () => void; +}) { + const hatchOptions = hatches.map((h) => ({ value: h.id, label: h.name })); + // Ansichts-Schraffur ist optional; Leer-Eintrag = keine Schraffur (weiss). + const viewHatchOptions = [ + { value: "", label: t("resources.viewHatch.none") }, + ...hatchOptions, + ]; + const previewHatch = hatches.find((h) => h.id === component.hatchId); + return ( +
+
+ onPatch({ name: e.target.value })} + /> + +
+ +
+ {component.material ? ( + + ) : previewHatch ? ( + + ) : ( + + )} +
+ +
+ + onPatch({ abbrev: v || undefined })} + placeholder={t("resources.col.abbrev.placeholder")} + /> + + + {/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift + als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */} + onPatch({ foreground })} + /> + + + {/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */} + onPatch({ background })} + /> + + + onPatch({ hatchId })} + options={hatchOptions} + /> + + + onPatch({ viewHatchId: viewHatchId || undefined })} + options={viewHatchOptions} + /> + + + onPatch({ joinPriority })} + step={10} + min={0} + /> + + + onPatch({ texture3d: v || undefined })} + placeholder={t("resources.texture.empty")} + mono + /> + + + onPatch({ material })} + onClear={() => onPatch({ material: undefined })} + /> + +
+
+ ); +} + +export function ComponentsTab({ + project, + onPatchComponent, + onAddComponent, + onDeleteComponent, +}: { + project: Project; + onPatchComponent: (id: string, patch: Partial) => void; + onAddComponent: () => void; + onDeleteComponent: (id: string) => void; +}) { + const [selectedId, setSelectedId] = useState(null); + const components = project.components; + // Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben). + const selected = components.find((c) => c.id === selectedId) ?? components[0]; + + return ( +
+
{t("resources.components.hint")}
+
+
+ {components.map((c) => { + const hatch = project.hatches.find((h) => h.id === c.hatchId); + return ( + + ); + })} + {components.length === 0 && ( +
{t("resources.components.empty")}
+ )} +
+ +
+
+
+ {selected ? ( + onPatchComponent(selected.id, patch)} + onDelete={() => onDeleteComponent(selected.id)} + /> + ) : ( +
{t("resources.components.empty")}
+ )} +
+
+
+ ); +} diff --git a/src/ui/resourceManager/DoorStylesTab.tsx b/src/ui/resourceManager/DoorStylesTab.tsx new file mode 100644 index 0000000..c01a8d2 --- /dev/null +++ b/src/ui/resourceManager/DoorStylesTab.tsx @@ -0,0 +1,167 @@ +// Tab „Türtypen" — Bauart, Blattausführung/-zahl, Zarge und Standardmaße. +// Nutzt den generischen {@link TypeLibraryTab}-Rumpf (siehe dort). + +import type { DoorType, Project } from "../../model/types"; +import { t } from "../../i18n"; +import { FieldRow, NumberField, Segmented, SelectField } from "./fields"; +import { INSET_FACE_OPTIONS, TypeLibraryTab } from "./TypeLibraryTab"; + +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 DOOR_FRAME_KIND_OPTIONS: { value: NonNullable; labelKey: string }[] = [ + { value: "zarge", labelKey: "resources.frameKind.zarge" }, + { value: "blockrahmen", labelKey: "resources.frameKind.blockrahmen" }, +]; + +export 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({ frameKind })} + options={DOOR_FRAME_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + + patch({ frameWidth })} + step={0.005} + min={0} + /> + + + patch({ insetFromFace })} + step={0.01} + min={0} + /> + + {(door.insetFromFace ?? 0) > 0 && ( + + patch({ insetFace })} + options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + )} + + patch({ transomHeight })} + step={0.01} + min={0} + /> + + + patch({ defaultWidth })} + step={0.01} + min={0} + /> + + + patch({ defaultHeight })} + step={0.01} + min={0} + /> + + + patch({ threshold: e.target.checked })} + /> + + + )} + /> + ); +} diff --git a/src/ui/resourceManager/HatchesTab.tsx b/src/ui/resourceManager/HatchesTab.tsx new file mode 100644 index 0000000..a223ae2 --- /dev/null +++ b/src/ui/resourceManager/HatchesTab.tsx @@ -0,0 +1,316 @@ +// Tab „Schraffuren" (Hatches) — Muster/Maßstab/Winkel/Farbe + Linienstil, +// wahlweise Vektor-Muster oder Bild-Textur. Master-Detail-Layout wie die +// übrigen Ressourcen-Tabs (siehe {@link HatchDetail}). + +import { useState } from "react"; +import type { HatchPattern, HatchStyle, LineStyle, Project } from "../../model/types"; +import { parsePat } from "../../io/patParser"; +import { HatchSwatch } from "../hatchPreview"; +import { t } from "../../i18n"; +import { + DeleteButton, + FieldRow, + FileImportButton, + ImagePickButton, + NumberField, + Segmented, + SelectField, +} from "./fields"; + +/** Schraffur-Muster mit i18n-Key für die Beschriftung. */ +const PATTERN_OPTIONS: { value: HatchPattern; labelKey: string }[] = [ + { value: "none", labelKey: "pattern.none" }, + { value: "solid", labelKey: "pattern.solid" }, + { value: "insulation", labelKey: "pattern.insulation" }, + { value: "diagonal", labelKey: "pattern.diagonal" }, + { value: "crosshatch", labelKey: "pattern.crosshatch" }, +]; + +/** Detail-Panel EINER Schraffur (rechte Seite des Master-Detail-Layouts). */ +function HatchDetail({ + hatch, + lineStyles, + onPatch, + onDelete, +}: { + hatch: HatchStyle; + lineStyles: LineStyle[]; + onPatch: (patch: Partial) => void; + onDelete: () => void; +}) { + const NONE = "__none__"; + const lineOptions = [ + { value: NONE, label: t("resources.lineStyle.none") }, + ...lineStyles.map((l) => ({ value: l.id, label: l.name })), + ]; + const kind = hatch.kind ?? "vector"; + const lines = hatch.lines ?? "parallel"; + + /** Wechselt den Typ; beim Umschalten auf „Bild" Default-Bildparameter setzen. */ + const setKind = (k: "vector" | "image") => { + if (k === "image" && !hatch.image) { + onPatch({ kind: k, image: { src: "", scaleX: 1, scaleY: 1, rotation: 0 } }); + } else { + onPatch({ kind: k }); + } + }; + + /** Merge-Patch für die Bild-Parameter (behält bestehende Werte). */ + const patchImage = (part: Partial>) => { + const cur = hatch.image ?? { src: "", scaleX: 1, scaleY: 1, rotation: 0 }; + onPatch({ image: { ...cur, ...part } }); + }; + + /** Datei → Data-URL (readAsDataURL) → image.src. */ + const loadImage = (file: File | null) => { + if (!file) return; + const r = new FileReader(); + r.onload = () => patchImage({ src: String(r.result ?? "") }); + r.readAsDataURL(file); + }; + + // ── Random-Vektor-Schraffur (Kies/Splitt) ─────────────────────────────────── + // Dichte + Längenspanne + „Neu würfeln". Der Seed wird beim Klick gesetzt (KEIN + // Math.random() zur Renderzeit — die Streuung selbst ist deterministisch per + // Seed). Länge min/max in mm; bis der Nutzer sie berührt, bleibt der Default. + const lenMin = hatch.lengthMin ?? 1; + const lenMax = hatch.lengthMax ?? 3; + const reroll = () => onPatch({ seed: Math.floor(Math.random() * 0x7fffffff) }); + + return ( +
+
+ onPatch({ name: e.target.value })} + /> + +
+ +
+ +
+ +
+ + + + + {kind === "vector" ? ( + <> + + onPatch({ lines: v })} + options={[ + { value: "parallel", label: t("resources.lines.parallel") }, + { value: "random", label: t("resources.lines.random") }, + ]} + /> + + + onPatch({ pattern })} + options={PATTERN_OPTIONS.map((o) => ({ + value: o.value, + label: t(o.labelKey), + }))} + /> + + + onPatch({ scale })} + step={0.1} + min={0.01} + /> + + + onPatch({ angle })} + step={5} + /> + + + onPatch({ relativeToWall: e.target.checked })} + /> + + + onPatch({ lineStyleId: v === NONE ? undefined : v })} + options={lineOptions} + /> + + {lines === "random" && ( + <> + + onPatch({ density })} + step={0.1} + min={0.1} + /> + + + onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })} + step={0.5} + min={0} + /> + + + onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })} + step={0.5} + min={0} + /> + + + + + + )} + + ) : ( + <> + + + + + patchImage({ scaleX })} + step={0.1} + min={0.05} + /> + + + patchImage({ scaleY })} + step={0.1} + min={0.05} + /> + + + patchImage({ rotation })} + step={5} + /> + + + )} +
+
+ ); +} + +/** + * .pat-Text → id-lose Schraffuren. Unser Modell kennt nur feste Muster; daher + * approximieren wir: ≥2 Linienfamilien → Kreuzschraffur, sonst Diagonal, mit dem + * Winkel der ersten Familie. (Vollwertiges custom-Pattern später.) + */ +function patToHatches(text: string): Omit[] { + return parsePat(text).map((p) => ({ + name: p.name, + pattern: (p.families.length >= 2 ? "crosshatch" : "diagonal") as HatchPattern, + scale: 1, + angle: p.families[0]?.angle ?? 45, + color: "#1a1a1a", + // Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin"). + lineStyleId: "thin", + })); +} + +export function HatchesTab({ + project, + onPatchHatch, + onAddHatch, + onDeleteHatch, + onImportHatches, +}: { + project: Project; + onPatchHatch: (id: string, patch: Partial) => void; + onAddHatch: () => void; + onDeleteHatch: (id: string) => void; + onImportHatches: (hatches: Omit[]) => void; +}) { + const [selectedId, setSelectedId] = useState(null); + const hatches = project.hatches; + // Auswahl gegen die Liste validieren (nach Delete/Add stabil bleiben). + const selected = hatches.find((h) => h.id === selectedId) ?? hatches[0]; + + return ( +
+
{t("resources.hatches.hint")}
+
+
+ {hatches.map((h) => ( + + ))} + {hatches.length === 0 && ( +
{t("resources.hatches.empty")}
+ )} +
+ + onImportHatches(patToHatches(text))} + /> +
+
+
+ {selected ? ( + onPatchHatch(selected.id, patch)} + onDelete={() => onDeleteHatch(selected.id)} + /> + ) : ( +
{t("resources.hatches.empty")}
+ )} +
+
+
+ ); +} diff --git a/src/ui/resourceManager/LayeredStylesTab.tsx b/src/ui/resourceManager/LayeredStylesTab.tsx new file mode 100644 index 0000000..d329ef8 --- /dev/null +++ b/src/ui/resourceManager/LayeredStylesTab.tsx @@ -0,0 +1,358 @@ +// Tabs „Wandstile", „Deckenstile" und „Dachtypen" — geschichteter Aufbau. +// Alle drei Ressourcen teilen Struktur (WallType/CeilingType/RoofType = +// {id,name,layers[]}) und Editor: ein Master-Detail-Layout wie bei Schraffuren/ +// Linien. Liste links = je Typ eine Zeile mit kleiner Querschnitt-Vorschau + +// Name; Detail rechts = Aufbau-Editor (Schichten stapeln: Bauteil/Dicke/ +// Fugenlinie, hinzufügen/entfernen/umordnen) + großer Live-Querschnitt. Ein +// gemeinsames Detail-Panel {@link LayeredStyleDetail}, parametrisiert über die +// Orientierung (Wand: Bänder nebeneinander / Decke+Dach: liegend gestapelt) +// und die jeweiligen Handler. +// +// Fugen: `layer[i].jointLineStyleId` ist der Linienstil der INNEREN Kante der +// Schicht i (Fuge zur nächst-inneren Schicht). Die innerste Schicht hat keine +// innere Fuge (ihre Kante ist der Innenumriss) → dort kein Dropdown. + +import { useState } from "react"; +import type { CeilingType, Layer, Project, RoofType, WallType } from "../../model/types"; +import { WallTypeSwatch } from "../hatchPreview"; +import type { SwatchLayer } from "../hatchPreview"; +import { t } from "../../i18n"; +import { DeleteButton, FieldRow, NumberField, SelectField } from "./fields"; + +/** Wand-, Decken- und Dachtyp teilen dieselbe Form (`{id,name,layers}`). */ +type LayeredStyle = WallType | CeilingType | RoofType; + +/** + * Baut die Querschnitt-Bänder (eine je Schicht) aus den Bauteil-Schnitt- + * schraffuren: Schicht → Bauteil → dessen `hatchId` → HatchStyle. Fehlt das + * Bauteil/die Schraffur, bleibt das Band leer (weiß). + */ +function swatchLayersOf(style: LayeredStyle, project: Project): SwatchLayer[] { + return style.layers.map((l) => { + const comp = project.components.find((c) => c.id === l.componentId); + const hatch = comp + ? project.hatches.find((hh) => hh.id === comp.hatchId) + : undefined; + return { hatch, thickness: l.thickness }; + }); +} + +/** Detail-Panel EINES Wand-/Decken-/Dachtyps (rechte Seite des Master-Detail-Layouts). */ +function LayeredStyleDetail({ + style, + project, + orientation, + onPatch, + onDelete, + deleteTitle, + namePlaceholder, +}: { + style: LayeredStyle; + project: Project; + orientation: "wall" | "ceiling"; + onPatch: (patch: Partial) => void; + onDelete: () => void; + deleteTitle: string; + namePlaceholder: string; +}) { + // Sentinel „— (Haarlinie)": kein Feld gesetzt → Standard-Haarlinie 0.02 mm. + const DEFAULT = "__default__"; + const jointOptions = [ + { value: DEFAULT, label: t("resources.joint.default") }, + ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), + ]; + const compOptions = + project.components.length > 0 + ? project.components.map((c) => ({ value: c.id, label: c.name })) + : [{ value: "", label: "—" }]; + + const layers = style.layers; + const setLayers = (next: Layer[]) => onPatch({ layers: next }); + const patchLayer = (i: number, patch: Partial) => + setLayers(layers.map((l, k) => (k === i ? { ...l, ...patch } : l))); + const addLayer = () => + setLayers([ + ...layers, + { componentId: project.components[0]?.id ?? "", thickness: 0.1 }, + ]); + const removeLayer = (i: number) => { + if (layers.length <= 1) return; + setLayers(layers.filter((_, k) => k !== i)); + }; + const moveLayer = (i: number, dir: -1 | 1) => { + const j = i + dir; + if (j < 0 || j >= layers.length) return; + const next = layers.slice(); + [next[i], next[j]] = [next[j], next[i]]; + setLayers(next); + }; + + return ( +
+
+ onPatch({ name: e.target.value })} + /> + +
+ +
+ +
+ +
+ {layers.map((layer, i) => { + const isInner = i === layers.length - 1; + return ( +
+
+ + +
+
+ + patchLayer(i, { componentId })} + options={compOptions} + /> + + + + patchLayer(i, { thickness: Math.max(0, mm) / 1000 }) + } + step={5} + min={0} + /> + + + {isInner ? ( + – + ) : ( + + patchLayer(i, { + jointLineStyleId: v === DEFAULT ? undefined : v, + }) + } + options={jointOptions} + /> + )} + +
+ removeLayer(i)} + title={t("resources.layer.remove")} + /> +
+ ); + })} + +
+
+ ); +} + +/** + * Gemeinsamer Master-Detail-Tab-Rumpf für Wand-/Decken-/Dachstile: Liste links + * (Querschnitt-Vorschau + Name, +-Neu in der Fusszeile), Detail rechts über + * {@link LayeredStyleDetail}. Parametrisiert über die Ressource + Orientierung. + */ +function LayeredStylesTab({ + project, + types, + orientation, + hintKey, + emptyKey, + placeholderKey, + deleteKeyPrefix, + onPatch, + onAdd, + onDelete, +}: { + project: Project; + types: LayeredStyle[]; + orientation: "wall" | "ceiling"; + hintKey: string; + emptyKey: string; + placeholderKey: string; + deleteKeyPrefix: string; + onPatch: (id: string, patch: Partial) => void; + onAdd: () => void; + onDelete: (id: string) => void; +}) { + const [selectedId, setSelectedId] = useState(null); + const selected = types.find((s) => s.id === selectedId) ?? types[0]; + + return ( +
+
{t(hintKey)}
+
+
+ {types.map((s) => ( + + ))} + {types.length === 0 && ( +
{t(emptyKey)}
+ )} +
+ +
+
+
+ {selected ? ( + onPatch(selected.id, patch)} + onDelete={() => onDelete(selected.id)} + deleteTitle={t(deleteKeyPrefix, { name: selected.name })} + namePlaceholder={t(placeholderKey)} + /> + ) : ( +
{t(emptyKey)}
+ )} +
+
+
+ ); +} + +export function WallStylesTab({ + project, + onPatchWallType, + onAddWallType, + onDeleteWallType, +}: { + project: Project; + onPatchWallType: (id: string, patch: Partial) => void; + onAddWallType: () => void; + onDeleteWallType: (id: string) => void; +}) { + return ( + + ); +} + +/** + * Tab „Deckenstile" — das liegende Gegenstück zu {@link WallStylesTab}: nutzt + * denselben {@link LayeredStylesTab}-Rumpf, nur die Ressource (`ceilingTypes`), + * die Orientierung (gestapelt) und die Handler unterscheiden sich. + */ +export function CeilingStylesTab({ + project, + onPatchCeilingType, + onAddCeilingType, + onDeleteCeilingType, +}: { + project: Project; + onPatchCeilingType: (id: string, patch: Partial) => void; + onAddCeilingType: () => void; + onDeleteCeilingType: (id: string) => void; +}) { + return ( + + ); +} + +/** + * Tab „Dachtypen" — das geneigte Gegenstück zu {@link CeilingStylesTab}: gleicher + * {@link LayeredStylesTab}-Rumpf (Schichtaufbau aussen/Eindeckung → innen/ + * Verkleidung), Ressource `roofTypes`. Vorschau in Decken-Orientierung + * (gestapelt) — für die Schichtreihenfolge/-dicken ausreichend. + */ +export function RoofStylesTab({ + project, + onPatchRoofType, + onAddRoofType, + onDeleteRoofType, +}: { + project: Project; + onPatchRoofType: (id: string, patch: Partial) => void; + onAddRoofType: () => void; + onDeleteRoofType: (id: string) => void; +}) { + return ( + + ); +} diff --git a/src/ui/resourceManager/LinesTab.tsx b/src/ui/resourceManager/LinesTab.tsx new file mode 100644 index 0000000..f334154 --- /dev/null +++ b/src/ui/resourceManager/LinesTab.tsx @@ -0,0 +1,339 @@ +// Tab „Linien" (Line Styles) — Strichstärke/Farbe/Strichmuster, wahlweise +// Strich-Presets, Zickzack oder ein frei gezeichnetes Custom-Motiv. Master- +// Detail-Layout wie die übrigen Ressourcen-Tabs (siehe {@link LineStyleDetail}). + +import { useState } from "react"; +import type { LineStyle, Project } from "../../model/types"; +import { PEN_WEIGHTS } from "../../model/types"; +import { parseLin } from "../../io/linParser"; +import { LineSwatch } from "../hatchPreview"; +import { + segmentsToDash, + dashToSegments, + presetOfDash, + LINE_PRESETS, + type LineSegment, + type LineSegmentType, + type LinePresetKey, +} from "../lineSegments"; +import { MotifEditor } from "../MotifEditor"; +import { t } from "../../i18n"; +import { + ColorField, + DeleteButton, + FieldRow, + FileImportButton, + NumberField, + Segmented, + SelectField, +} from "./fields"; + +/** + * Default-Motiv beim Umschalten auf „Custom": eine Dreieckszelle (0..4 mm entlang, + * ±1.5 mm quer), die entlang der Linie loopt. Der Nutzer passt sie im MotifEditor an. + */ +const DEFAULT_MOTIF: NonNullable = { + points: [ + { x: 0, y: 0 }, + { x: 1, y: 1.5 }, + { x: 2, y: 0 }, + { x: 3, y: -1.5 }, + { x: 4, y: 0 }, + ], + length: 4, +}; + +/** Detail-Panel EINES Linienstils (rechte Seite des Master-Detail-Layouts). */ +function LineStyleDetail({ + style, + onPatch, + onDelete, +}: { + style: LineStyle; + onPatch: (patch: Partial) => void; + onDelete: () => void; +}) { + const kind = style.kind ?? "dash"; + + /** + * Wechselt den Typ; beim Umschalten auf „Zickzack"/„Custom" Default-Parameter + * setzen, falls noch keine vorhanden. + */ + const setKind = (k: "dash" | "zigzag" | "custom") => { + if (k === "zigzag" && !style.zigzag) { + onPatch({ kind: k, zigzag: { amplitude: 1, wavelength: 4 } }); + } else if (k === "custom" && !style.motif) { + onPatch({ kind: k, motif: DEFAULT_MOTIF }); + } else { + onPatch({ kind: k }); + } + }; + + /** Merge-Patch für die Zickzack-Parameter. */ + const patchZig = (part: Partial>) => { + const cur = style.zigzag ?? { amplitude: 1, wavelength: 4 }; + onPatch({ zigzag: { ...cur, ...part } }); + }; + + // ── Modulares Segment-System (dash) ───────────────────────────────────────── + // Eine musterbasierte Linie ist eine geordnete, loopende Folge aus Segmenten + // (Strich / Punkt / Lücke). Die Folge wird aus `dash` abgeleitet und beim + // Editieren via `segmentsToDash` wieder nach `dash` geschrieben — `dash` bleibt + // die einzige Datenbasis. „Volllinie" = `dash: null`. Die Dicke gehört NICHT + // mehr hierher (sie wird per Element-Attribut aufgelöst; By-Object/By-Layer folgt). + const dash = style.dash; + const isSolid = dash === null; + const segments = dashToSegments(dash); + const preset = presetOfDash(dash); + const applyPreset = (p: LinePresetKey) => { + if (p === "custom") return; + onPatch({ dash: LINE_PRESETS[p] }); + }; + const commitSegments = (next: LineSegment[]) => onPatch({ dash: segmentsToDash(next) }); + const setSegType = (i: number, type: LineSegmentType) => { + const next = segments.slice(); + const cur = next[i]; + next[i] = { type, length: type === "dot" ? 0 : cur.length > 0 ? cur.length : 2 }; + commitSegments(next); + }; + const setSegLen = (i: number, v: number) => { + const next = segments.slice(); + next[i] = { ...next[i], length: Math.max(0, v) }; + commitSegments(next); + }; + const addSegOf = (type: LineSegmentType) => + commitSegments([...segments, { type, length: type === "dot" ? 0 : 2 }]); + const removeSeg = (i: number) => commitSegments(segments.filter((_, k) => k !== i)); + + return ( +
+
+ onPatch({ name: e.target.value })} + /> + +
+ +
+ +
+ +
+ + + + + onPatch({ color })} /> + + {kind === "zigzag" ? ( + <> + + patchZig({ amplitude })} + step={0.1} + min={0.05} + /> + + + patchZig({ wavelength })} + step={0.5} + min={0.5} + /> + + + ) : kind === "custom" ? ( + + onPatch({ motif: { points, length } })} + /> + + ) : ( + <> + {/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */} + + + value={preset} + onChange={(p) => applyPreset(p as LinePresetKey)} + options={[ + { value: "solid", label: t("resources.linePreset.solid") }, + { value: "dash", label: t("resources.linePreset.dash") }, + { value: "dot", label: t("resources.linePreset.dot") }, + { value: "dashDot", label: t("resources.linePreset.dashDot") }, + ]} + /> + + {!isSolid && ( + +
+ {segments.map((seg, i) => ( +
+ setSegType(i, type)} + options={[ + { value: "dash", label: t("resources.seg.dash") }, + { value: "dot", label: t("resources.seg.dot") }, + { value: "gap", label: t("resources.seg.gap") }, + ]} + /> + + {seg.type === "dot" ? ( + 0 + ) : ( + setSegLen(i, x)} + step={0.5} + min={0} + /> + )} + + +
+ ))} +
+ + + +
+
+
+ )} + + )} +
+
+ ); +} + +/** .lin-Text → id-lose Linienstile (schwarze Haarlinie 0.18 als Default). */ +function linToStyles(text: string): Omit[] { + return parseLin(text).map((p) => ({ + name: p.name, + weight: 0.18, + color: "#1a1a1a", + dash: p.dash, + })); +} + +export function LinesTab({ + project, + onPatchLineStyle, + onAddLineStyle, + onDeleteLineStyle, + onImportLineStyles, +}: { + project: Project; + onPatchLineStyle: (id: string, patch: Partial) => void; + onAddLineStyle: () => void; + onDeleteLineStyle: (id: string) => void; + onImportLineStyles: (styles: Omit[]) => void; +}) { + const [selectedId, setSelectedId] = useState(null); + const styles = project.lineStyles; + const selected = styles.find((l) => l.id === selectedId) ?? styles[0]; + + return ( +
+ {/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */} + + {PEN_WEIGHTS.map((w) => ( + +
{t("resources.lines.hint")}
+
+
+ {styles.map((l) => ( + + ))} + {styles.length === 0 && ( +
{t("resources.lines.empty")}
+ )} +
+ + onImportLineStyles(linToStyles(text))} + /> +
+
+
+ {selected ? ( + onPatchLineStyle(selected.id, patch)} + onDelete={() => onDeleteLineStyle(selected.id)} + /> + ) : ( +
{t("resources.lines.empty")}
+ )} +
+
+
+ ); +} diff --git a/src/ui/resourceManager/MaterialsTab.tsx b/src/ui/resourceManager/MaterialsTab.tsx new file mode 100644 index 0000000..2bd4a6d --- /dev/null +++ b/src/ui/resourceManager/MaterialsTab.tsx @@ -0,0 +1,172 @@ +// Tab „Materialien" (PBR-Bibliothek durchsuchen) — Eigenständiger Tab, der die +// eingebaute Materialbibliothek (MATERIAL_LIBRARY) als Kachel-Grid mit +// gerenderter PBR-Kugel-Vorschau zeigt — Name/Kategorie als Badge, Suche + +// Kategorie-Chips kombinierbar. Die Zuweisung an ein konkretes Bauteil bleibt +// der bestehende Weg (Spalte „Material" der Bauteil-Tabelle → MaterialPicker +// in {@link ComponentsTab}); dieser Tab dient dem Durchsuchen/Vergleichen der +// Bibliothek, Klick auf eine Kachel markiert sie (aktiv, wie `mat-tile.active` +// im Picker) und zeigt Name/Kategorie in der Statuszeile. + +import { useEffect, useRef, useState } from "react"; +import { MATERIAL_LIBRARY } from "../../materials/library"; +import type { MaterialAsset } from "../../materials/library"; +import { requestMaterialPreview, cancelMaterialPreview } from "../../materials/spherePreview"; +import { t } from "../../i18n"; +import { EmptyState } from "./fields"; + +/** + * Alle in der Bibliothek vorkommenden Kategorien, alphabetisch. Das Manifest + * schreibt Kategorien uneinheitlich („PavingStones" neben „Paving Stones") — + * für Chips und Filter zählt der normalisierte Schlüssel (ohne Leerzeichen, + * case-insensitiv); angezeigt wird die zuerst gesehene Schreibweise. + */ +const categoryKey = (c: string) => c.replace(/\s+/g, "").toLowerCase(); +const MATERIAL_CATEGORIES: string[] = (() => { + const byKey = new Map(); + for (const a of MATERIAL_LIBRARY) { + const k = categoryKey(a.category); + if (!byKey.has(k)) byKey.set(k, a.category); + } + return Array.from(byKey.values()).sort((a, b) => a.localeCompare(b)); +})(); + +/** + * Eine Bibliotheks-Kachel: rendert erst eine PBR-Kugel-Vorschau, sobald sie + * tatsächlich sichtbar ist (IntersectionObserver) — bei ~90 Materialien sonst + * unnötige GPU-Arbeit für Kacheln außerhalb des Sichtfensters. Der eigentliche + * Render läuft ohnehin seriell über einen einzigen geteilten WebGL-Kontext + * (spherePreview.ts), die Sichtbarkeitsprüfung drosselt zusätzlich die + * Warteschlange auf das gerade Interessante. + */ +function MaterialLibraryTile({ + asset, + active, + onSelect, +}: { + asset: MaterialAsset; + active: boolean; + onSelect: () => void; +}) { + 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 ( + + ); +} + +export function MaterialsTab() { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); + const [selectedId, setSelectedId] = useState(null); + + const filtered = MATERIAL_LIBRARY.filter((asset) => { + if (category && categoryKey(asset.category) !== categoryKey(category)) return false; + const q = query.trim().toLowerCase(); + if (q && !asset.name.toLowerCase().includes(q) && !asset.id.toLowerCase().includes(q)) { + return false; + } + return true; + }); + const selected = MATERIAL_LIBRARY.find((a) => a.id === selectedId); + + return ( +
+
{t("resources.materials.hint")}
+ +
+ setQuery(e.target.value)} + /> +
+ + {MATERIAL_CATEGORIES.map((c) => ( + + ))} +
+
+ + {selected && ( +
+ {t("resources.materials.selected", { + name: selected.name, + category: selected.category, + })} +
+ )} + +
+ {filtered.map((asset) => ( + setSelectedId((cur) => (cur === asset.id ? null : asset.id))} + /> + ))} +
+ {filtered.length === 0 && } + +
+ + {t("resources.materials.count", { + shown: filtered.length, + total: MATERIAL_LIBRARY.length, + })} + +
+
+ ); +} diff --git a/src/ui/resourceManager/OverridesTab.tsx b/src/ui/resourceManager/OverridesTab.tsx new file mode 100644 index 0000000..8726981 --- /dev/null +++ b/src/ui/resourceManager/OverridesTab.tsx @@ -0,0 +1,312 @@ +// Tab „Overrides" (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. + +import { useState } from "react"; +import type { OverrideConditionType, OverrideOperator, OverrideRule, Project } from "../../model/types"; +import { PEN_WEIGHTS } from "../../model/types"; +import { t } from "../../i18n"; +import { ColorField, DeleteButton, FieldRow, NumberField, SelectField, TextField } from "./fields"; + +/** 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. */ +export 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")}
+ )} +
+
+
+ ); +} diff --git a/src/ui/resourceManager/StairStylesTab.tsx b/src/ui/resourceManager/StairStylesTab.tsx new file mode 100644 index 0000000..bc2ef5e --- /dev/null +++ b/src/ui/resourceManager/StairStylesTab.tsx @@ -0,0 +1,96 @@ +// Tab „Treppentypen" — Tragart, Stufenausbildung, Geländer und Laufbreite. +// Nutzt den generischen {@link TypeLibraryTab}-Rumpf (siehe dort). + +import type { Project, StairType } from "../../model/types"; +import { t } from "../../i18n"; +import { FieldRow, NumberField, SelectField } from "./fields"; +import { TypeLibraryTab } from "./TypeLibraryTab"; + +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" }, +]; + +export 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} + /> + + + )} + /> + ); +} diff --git a/src/ui/resourceManager/TypeLibraryTab.tsx b/src/ui/resourceManager/TypeLibraryTab.tsx new file mode 100644 index 0000000..eb02be4 --- /dev/null +++ b/src/ui/resourceManager/TypeLibraryTab.tsx @@ -0,0 +1,96 @@ +// 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 `?.()`. +// +// Gemeinsam genutzt von {@link DoorStylesTab}, {@link WindowStylesTab} und +// {@link StairStylesTab} (siehe die jeweiligen Dateien). + +import { useState } from "react"; +import { t } from "../../i18n"; +import { DeleteButton } from "./fields"; + +// Bezugsfläche des Schichteinzugs — von aussen oder innen gemessen. Für Tür UND +// Fenster genutzt (gleiche Semantik). +export const INSET_FACE_OPTIONS: { value: "aussen" | "innen"; labelKey: string }[] = [ + { value: "aussen", labelKey: "resources.insetFace.aussen" }, + { value: "innen", labelKey: "resources.insetFace.innen" }, +]; + +export 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)}
+ )} +
+
+
+ ); +} diff --git a/src/ui/resourceManager/WindowStylesTab.tsx b/src/ui/resourceManager/WindowStylesTab.tsx new file mode 100644 index 0000000..d286773 --- /dev/null +++ b/src/ui/resourceManager/WindowStylesTab.tsx @@ -0,0 +1,186 @@ +// Tab „Fenstertypen" — Öffnungsart, Flügelzahl, Verglasung, Zarge, Brüstung. +// Nutzt den generischen {@link TypeLibraryTab}-Rumpf (siehe dort). + +import type { Project, WindowType } from "../../model/types"; +import { t } from "../../i18n"; +import { FieldRow, NumberField, SelectField } from "./fields"; +import { INSET_FACE_OPTIONS, TypeLibraryTab } from "./TypeLibraryTab"; + +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" }, +]; + +export 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({ mullionRows: Math.max(1, Math.round(mullionRows)) })} + step={1} + min={1} + max={4} + /> + + + patch({ mullionCols: Math.max(1, Math.round(mullionCols)) })} + 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({ frameWidth })} + step={0.005} + min={0} + /> + + + patch({ insetFromFace })} + step={0.01} + min={0} + /> + + {(win.insetFromFace ?? 0) > 0 && ( + + patch({ insetFace })} + options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))} + /> + + )} + + patch({ innerLayerRecess: e.target.checked })} + title={t("resources.field.innerLayerRecess.hint")} + /> + + + patch({ transomHeight })} + 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) }))} + /> + + + )} + /> + ); +} diff --git a/src/ui/resourceManager/fields.tsx b/src/ui/resourceManager/fields.tsx new file mode 100644 index 0000000..01a129e --- /dev/null +++ b/src/ui/resourceManager/fields.tsx @@ -0,0 +1,250 @@ +// Wiederverwendbare Feld-Bausteine (DOSSIER-Look) für den Ressourcen-Manager. +// Alle Steuerelemente füllen ihre Tabellenzelle (width:100%) und werden über +// die Spaltenbreite des Grids dimensioniert; Zahlenfelder sind rechtsbündig. +// Von ALLEN Tabs geteilt — hier ändern wirkt sich auf jede Ressourcen-Tabelle +// aus. + +import { useRef } from "react"; +import { Dropdown } from "../Dropdown"; +import { t } from "../../i18n"; + +/** Kompaktes Textfeld (Pill) für Namen/Platzhalter. */ +export function TextField({ + value, + onChange, + placeholder, + mono = false, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + mono?: boolean; +}) { + return ( + onChange(e.target.value)} + /> + ); +} + +/** Zahlenfeld (Pill, rechtsbündig, monospace) mit Live-Commit. */ +export function NumberField({ + value, + onChange, + step = 1, + min, + max, + list, +}: { + value: number; + onChange: (v: number) => void; + step?: number; + min?: number; + max?: number; + /** Optionale -ID mit Vorschlagswerten (z. B. Stiftstärken). */ + list?: string; +}) { + return ( + { + const v = Number(e.target.value); + if (!Number.isNaN(v)) onChange(v); + }} + /> + ); +} + +/** + * Farb-Swatch mit nativem Color-Picker. Das