God-Files aufgeteilt (ResourceManager/generatePlan/toWalls3d/App.tsx-Handler), Totcode entfernt (geometry-Crate, OCCT/HLR-Spike), PDF/Bild-Import ergänzt.
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.
This commit is contained in:
Generated
+278
-7
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
-11
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-75
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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}");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1
-12
@@ -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::LockState>) {
|
||||
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<Vec<geometry::WallCuts>, 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
|
||||
]);
|
||||
|
||||
+302
-587
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -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<string, WallCuts> {
|
||||
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<string, WallCuts>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
+142
-1
@@ -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<LayoutAnnotation, { kind: "text" }> => 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,7 +256,111 @@ 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 `<text>` mit
|
||||
* `<tspan>`-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<Primitive, { kind: "drawingText" }>,
|
||||
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 `<image>`. */
|
||||
function drawingImageNode(
|
||||
p: Extract<Primitive, { kind: "drawingImage" }>,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 `<image>`. */
|
||||
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";
|
||||
|
||||
|
||||
+17
-6
@@ -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",
|
||||
|
||||
+16
-6
@@ -180,6 +180,11 @@ export const en: Record<TranslationKey, string> = {
|
||||
"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<TranslationKey, string> = {
|
||||
"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<TranslationKey, string> = {
|
||||
"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<TranslationKey, string> = {
|
||||
// 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<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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<LoadedImageAsset> {
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// PDF-Import als Rasterbild: jede Seite wird auf ein `<canvas>` 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<LoadedImageAsset[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
+41
-2
@@ -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;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,9 +233,34 @@ export function AttributesPanel() {
|
||||
<ColorHexField value={sel.color} onChange={host.onSetSelectionColor} />
|
||||
</span>
|
||||
|
||||
{/* Strichstärke (mm) — Wand/Decke/Drawing2D, mit Nach-Ebene/Nach-Bauteil/
|
||||
eigener-Wert-Quelle; andere Elementarten erben stets aus der Kategorie. */}
|
||||
{sourceRow(
|
||||
{/* 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 && (
|
||||
<>
|
||||
<span className="attr-key">{t("attr.weight")}</span>
|
||||
<span className="attr-val">
|
||||
<input
|
||||
className="attr-num"
|
||||
style={{ width: 72 }}
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0}
|
||||
list="attr-pen-weights"
|
||||
value={sel.weightMm}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
sourceRow(
|
||||
"attr.weight",
|
||||
weightEditable,
|
||||
weightUi,
|
||||
@@ -250,9 +285,38 @@ export function AttributesPanel() {
|
||||
))}
|
||||
</datalist>
|
||||
</>,
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/etc. read-only). */}
|
||||
{/* 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 && (
|
||||
<>
|
||||
<span className="attr-key">{t("attr.opacity")}</span>
|
||||
<span className="attr-val">
|
||||
<input
|
||||
className="attr-num"
|
||||
style={{ width: 72 }}
|
||||
type="number"
|
||||
step={0.05}
|
||||
min={0}
|
||||
max={1}
|
||||
value={sel.opacity ?? 1}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) host.onSetSelectionOpacity(Math.max(0, Math.min(1, v)));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Linienstil — nur Drawing2D trägt ihn (Wand/Decke/Annotation etc.
|
||||
read-only), Rest wie gehabt. */}
|
||||
{!isAnnotation && (
|
||||
<>
|
||||
<span className="attr-key">{t("attr.lineStyle")}</span>
|
||||
<span className="attr-val">
|
||||
{isDrawing ? (
|
||||
@@ -270,11 +334,17 @@ export function AttributesPanel() {
|
||||
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 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. */}
|
||||
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 && (
|
||||
<>
|
||||
<div className="attr-section-label">{t("attr.fill")}</div>
|
||||
|
||||
{sourceRow(
|
||||
@@ -307,13 +377,21 @@ export function AttributesPanel() {
|
||||
]}
|
||||
/>,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Maße (informativ, aus der bbox) ───────────────────────── */}
|
||||
{/* ── 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 && (
|
||||
<>
|
||||
<div className="attr-section-label">{t("attr.size")}</div>
|
||||
<span className="attr-key">{t("attr.width")}</span>
|
||||
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
|
||||
<span className="attr-key">{t("attr.height")}</span>
|
||||
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) —
|
||||
|
||||
@@ -158,6 +158,17 @@ export function ObjectInfoPanel() {
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* 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" ? (
|
||||
<div className="objinfo-empty">{t("objinfo.layoutAnnotationHint")}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
|
||||
rechts daneben (Nutzer-Wunsch). */}
|
||||
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
|
||||
@@ -242,6 +253,8 @@ export function ObjectInfoPanel() {
|
||||
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Kontext-Objekt (importiertes Gebäude/Terrain/Konturen): reale LV95-
|
||||
Koordinate des Bbox-Mittelpunkts — direkt sichtbar, ob/wohin
|
||||
georeferenziert importiert wurde (Nutzer-Wunsch zum Prüfen). */}
|
||||
|
||||
@@ -156,6 +156,15 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
||||
<line x1="4.5" y1="11" x2="9" y2="11" />
|
||||
</svg>
|
||||
);
|
||||
case "image":
|
||||
// Bild-Rahmen mit Bergsilhouette + Sonne (klassisches Bild-Icon).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="3" width="11" height="10" fill="none" />
|
||||
<circle cx="6" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
<path d="M 3 12 L 7 8 L 9.5 10.5 L 11 9 L 13 12" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
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"];
|
||||
|
||||
+31
-1
@@ -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<PanelHostValue, "displayMode">,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+113
-4
@@ -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<PlanViewHandle, PlanViewProps>(
|
||||
// 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<PlanViewHandle, PlanViewProps>(
|
||||
}
|
||||
}
|
||||
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<PlanViewHandle, PlanViewProps>(
|
||||
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(
|
||||
</text>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<image
|
||||
href={p.src}
|
||||
x={toScreen(p.min).x}
|
||||
y={topLeft.y}
|
||||
width={w}
|
||||
height={h}
|
||||
opacity={(p.greyed ? 0.45 : 1) * p.opacity}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<text
|
||||
opacity={p.greyed ? 0.45 : undefined}
|
||||
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
|
||||
style={{ pointerEvents: "none", userSelect: "none" }}
|
||||
>
|
||||
{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 (
|
||||
<tspan key={k} x={x} y={lineYs[k]} textAnchor={anchor}>
|
||||
{line.tspans.length === 0
|
||||
? ""
|
||||
: line.tspans.map((ts, j) => (
|
||||
<tspan
|
||||
key={j}
|
||||
fontSize={ts.fontSize}
|
||||
fontWeight={ts.fontWeight}
|
||||
fontStyle={ts.fontStyle}
|
||||
fontFamily={ts.fontFamily ?? "var(--font-ui, sans-serif)"}
|
||||
fill={ts.fill}
|
||||
textDecoration={ts.textDecoration}
|
||||
>
|
||||
{ts.text}
|
||||
</tspan>
|
||||
))}
|
||||
</tspan>
|
||||
);
|
||||
})}
|
||||
</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;
|
||||
|
||||
+47
-2637
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, number>,
|
||||
colorByCode: Map<string, string>,
|
||||
catByCode: Map<string, LayerCategory>,
|
||||
): 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 `<circle>` (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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<DetailLevel, number> = {
|
||||
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<DetailLevel, number> = {
|
||||
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 `<circle>` 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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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<Primitive, { kind: "line" }>;
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
+48
-2752
File diff suppressed because it is too large
Load Diff
@@ -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<number>([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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, TruckCacheEntry>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Project, string[]>();
|
||||
export function wallMaterialColorMaps(project: Project): string[] {
|
||||
const cached = materialMapCache.get(project);
|
||||
if (cached) return cached;
|
||||
const seen = new Set<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HlrResult> {
|
||||
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 `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${sw}"${dash}/>`;
|
||||
};
|
||||
|
||||
const paths = all.map(line).join("\n ");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="800">
|
||||
<rect x="${minX}" y="${minY}" width="${w}" height="${h}" fill="#fff"/>
|
||||
${paths}
|
||||
</svg>`;
|
||||
}
|
||||
Vendored
-25
@@ -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<unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<T, A> {
|
||||
new (a: A): T;
|
||||
}
|
||||
interface Ctor3<T, A, B, C> {
|
||||
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<GpPnt, number, number, number>;
|
||||
|
||||
BRepPrimAPI_MakeBox_1: Ctor3<MakeBox, number, number, number>;
|
||||
BRepPrimAPI_MakeBox_3: Ctor1<MakeBox, GpPnt> & {
|
||||
new (origin: GpPnt, corner: GpPnt): MakeBox;
|
||||
};
|
||||
|
||||
BRepAlgoAPI_Fuse_3: {
|
||||
new (a: TopoDS_Shape, b: TopoDS_Shape): BooleanOp;
|
||||
};
|
||||
|
||||
HLRAppli_ReflectLines: Ctor1<ReflectLines, TopoDS_Shape>;
|
||||
|
||||
TopExp_Explorer_2: {
|
||||
new (s: TopoDS_Shape, toFind: unknown, toAvoid: unknown): TopExpExplorer;
|
||||
};
|
||||
|
||||
BRepAdaptor_Curve_2: Ctor1<BRepAdaptorCurve, TopoDS_Edge>;
|
||||
|
||||
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<unknown>;
|
||||
|
||||
let occtPromise: Promise<OpenCascadeInstance> | 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<OpenCascadeInstance> {
|
||||
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;
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
categoryDisplay: CategoryDisplayResolver & DisplayResolver;
|
||||
detail: DetailLevel;
|
||||
referenceLines: boolean;
|
||||
planColorMode: "color" | "mono";
|
||||
setExportOpen: (open: boolean) => void;
|
||||
setExportDxfOpen: (open: boolean) => void;
|
||||
}): {
|
||||
onExportPdf: (decision: ExportPdfDecision) => Promise<void>;
|
||||
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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<CommandEngine | null>,
|
||||
commandLineRef: RefObject<CommandLineHandle | null>,
|
||||
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<CommandEngine | null>,
|
||||
): void {
|
||||
const toolShortcutRef = useRef({ onSelectTool, floor: isFloor });
|
||||
toolShortcutRef.current = { onSelectTool, floor: isFloor };
|
||||
useEffect(() => {
|
||||
const MAP: Record<string, ToolId> = {
|
||||
"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<CommandEngine | null>,
|
||||
toolStateRef: MutableRefObject<ToolState>,
|
||||
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<CommandLineHandle | null>,
|
||||
): 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,
|
||||
]);
|
||||
}
|
||||
+119
-42
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ToolId, Tool> = {
|
||||
@@ -597,6 +613,7 @@ const TOOLS: Record<ToolId, Tool> = {
|
||||
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",
|
||||
];
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ export type ToolId =
|
||||
| "circle"
|
||||
| "arc"
|
||||
| "text"
|
||||
| "textbox";
|
||||
| "textbox"
|
||||
| "image";
|
||||
|
||||
// ── Snapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+587
-200
File diff suppressed because it is too large
Load Diff
+18
-2977
File diff suppressed because it is too large
Load Diff
+29
-3
@@ -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,10 +37,15 @@ export function StartScreen({
|
||||
onOpenRecent: (path: string) => void;
|
||||
}) {
|
||||
const [recent, setRecent] = useState<RecentProjectEntry[]>(() => getRecentProjects());
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void getRunningVersion().then(setVersion);
|
||||
}, []);
|
||||
const showRecent = isTauriRuntime() && recent.length > 0;
|
||||
|
||||
return (
|
||||
<div className="start-screen">
|
||||
<div className="start-main">
|
||||
<div className="start-brand">
|
||||
dossier<span className="brand-dot">.</span>
|
||||
</div>
|
||||
@@ -53,6 +74,7 @@ export function StartScreen({
|
||||
<span className="start-recent-name">{e.name}</span>
|
||||
<span className="start-recent-path">{e.path}</span>
|
||||
</button>
|
||||
<span className="start-recent-time">{formatRelativeTime(e.openedAt)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="start-recent-remove"
|
||||
@@ -71,5 +93,9 @@ export function StartScreen({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="start-footer">
|
||||
{t("about.version")} {version ?? APP_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<LayoutAnnotation, { kind: "line" }>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
+119
-8
@@ -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<LayoutAnnotation, { kind: "line" }>): 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<LayoutAnnotation, { kind: "line" }>,
|
||||
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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<button
|
||||
className="res-select res-material-btn"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("material.assign")}
|
||||
>
|
||||
{material?.color && (
|
||||
<span
|
||||
className="res-material-swatch"
|
||||
style={{ backgroundImage: `url(${material.color})` }}
|
||||
/>
|
||||
)}
|
||||
<span className="res-material-name">{label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<MaterialPicker
|
||||
material={material}
|
||||
onAssign={(m) => {
|
||||
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<ComponentMaterial>(() =>
|
||||
material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M },
|
||||
);
|
||||
const [sizeM, setSizeM] = useState<number>(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 (
|
||||
<div className="res-overlay mat-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="mat-dialog"
|
||||
role="dialog"
|
||||
aria-label={t("material.dialog.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="mat-head">
|
||||
<span className="mat-title">{t("material.dialog.title")}</span>
|
||||
<button className="res-close" onClick={onClose} aria-label={t("material.close")}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="mat-hint">{t("material.dialog.hint")}</div>
|
||||
|
||||
{/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */}
|
||||
<nav className="mat-modes" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "starter"}
|
||||
className={`mat-mode-btn${mode === "starter" ? " active" : ""}`}
|
||||
onClick={() => setMode("starter")}
|
||||
>
|
||||
{t("material.browse.starter")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "browse"}
|
||||
className={`mat-mode-btn${mode === "browse" ? " active" : ""}`}
|
||||
onClick={() => setMode("browse")}
|
||||
>
|
||||
{t("material.browse")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "upload"}
|
||||
className={`mat-mode-btn${mode === "upload" ? " active" : ""}`}
|
||||
onClick={() => setMode("upload")}
|
||||
>
|
||||
{t("material.upload")}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */}
|
||||
{mode === "starter" && (
|
||||
<div className="mat-grid">
|
||||
{MATERIAL_LIBRARY.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
className={`mat-tile${material?.libraryId === asset.id ? " active" : ""}`}
|
||||
title={asset.name}
|
||||
onClick={() => {
|
||||
onAssign(materialFromAsset(asset, sizeM));
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mat-thumb"
|
||||
style={{ backgroundImage: `url(${asset.maps.color})` }}
|
||||
/>
|
||||
<span className="mat-tile-name">{asset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live-Browse der kompletten ambientCG-Bibliothek. */}
|
||||
{mode === "browse" && (
|
||||
<AmbientBrowser
|
||||
sizeM={sizeM}
|
||||
activeId={material?.libraryId}
|
||||
onAssign={(m) => {
|
||||
onAssign(m);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload eigener Karten. */}
|
||||
{mode === "upload" && (
|
||||
<>
|
||||
<div className="mat-hint">{t("material.upload.hint")}</div>
|
||||
<div className="mat-upload-grid">
|
||||
{uploadFields.map((f) => (
|
||||
<label key={f.kind} className="mat-upload-row">
|
||||
<span className="mat-upload-label">{t(f.labelKey)}</span>
|
||||
<ImagePickButton onFile={pickFile(f.kind)} />
|
||||
{upload[f.kind] && <span className="mat-upload-ok">✓</span>}
|
||||
</label>
|
||||
))}
|
||||
<label className="mat-upload-row">
|
||||
<span className="mat-upload-label">{t("material.size")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={sizeM}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v) && v > 0) setSizeM(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mat-actions">
|
||||
<button className="res-add" onClick={applyUpload}>
|
||||
{t("material.upload.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */}
|
||||
{mode !== "upload" && (
|
||||
<div className="mat-actions mat-actions-browse">
|
||||
<label className="mat-size-inline">
|
||||
<span className="mat-upload-label">{t("material.size")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={sizeM}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v) && v > 0) setSizeM(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="res-add mat-danger"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("material.clear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AmbientResolution>("1K");
|
||||
const [items, setItems] = useState<AmbientMaterial[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// ID des gerade herunterladenden Materials (Spinner auf der Kachel).
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(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 (
|
||||
<div className="mat-browse">
|
||||
<div className="mat-hint">{t("material.browse.hint")}</div>
|
||||
<div className="mat-browse-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="res-input mat-search"
|
||||
value={query}
|
||||
placeholder={t("material.browse.searchPlaceholder")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitSearch();
|
||||
}}
|
||||
/>
|
||||
<button className="res-add" onClick={submitSearch}>
|
||||
{t("material.browse.search")}
|
||||
</button>
|
||||
<Dropdown
|
||||
value={category}
|
||||
onChange={(v) => setCategory(v)}
|
||||
options={[
|
||||
{ value: "", label: t("material.browse.category.all") },
|
||||
...AMBIENT_CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||
]}
|
||||
/>
|
||||
<Dropdown
|
||||
value={resolution}
|
||||
title={t("material.browse.resolution")}
|
||||
onChange={(v) => setResolution(v as AmbientResolution)}
|
||||
options={[
|
||||
{ value: "1K", label: "1K" },
|
||||
{ value: "2K", label: "2K" },
|
||||
{ value: "4K", label: "4K" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="mat-browse-error">{error}</div>}
|
||||
|
||||
<div className="mat-grid mat-browse-grid">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
className={`mat-tile${activeId === it.id ? " active" : ""}${
|
||||
downloadingId === it.id ? " loading" : ""
|
||||
}`}
|
||||
title={`${it.name}${it.category ? ` — ${it.category}` : ""}`}
|
||||
disabled={downloadingId !== null}
|
||||
onClick={() => pick(it.id)}
|
||||
>
|
||||
<span
|
||||
className="mat-thumb"
|
||||
style={{ backgroundImage: it.thumbUrl ? `url(${it.thumbUrl})` : undefined }}
|
||||
/>
|
||||
{downloadingId === it.id && <span className="mat-tile-spinner" />}
|
||||
<span className="mat-tile-name">{it.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="res-empty">{t("material.browse.empty")}</div>
|
||||
)}
|
||||
|
||||
<div className="mat-browse-foot">
|
||||
{loading && <span className="mat-browse-status">{t("material.browse.loading")}</span>}
|
||||
{!loading && items.length > 0 && (
|
||||
<span className="mat-browse-status">
|
||||
{t("material.browse.count", { shown: items.length, total })}
|
||||
</span>
|
||||
)}
|
||||
{!loading && items.length < total && (
|
||||
<button className="res-add" onClick={loadMore}>
|
||||
{t("material.browse.more")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleiner Farb-Swatch als Listen-Thumbnail für Bauteile ohne Schraffur. */
|
||||
function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) {
|
||||
return (
|
||||
<span
|
||||
className="res-swatch"
|
||||
style={{
|
||||
display: "block",
|
||||
width: size,
|
||||
height: size,
|
||||
background: color,
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLSpanElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>(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 (
|
||||
<span
|
||||
ref={ref}
|
||||
className="mat-sphere"
|
||||
title={asset.name || undefined}
|
||||
style={{ width: size, height: size, flex: "0 0 auto" }}
|
||||
>
|
||||
{previewUrl && <img src={previewUrl} alt="" draggable={false} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Detail-Panel EINES Bauteils (rechte Seite des Master-Detail-Layouts). */
|
||||
function ComponentDetail({
|
||||
component,
|
||||
hatches,
|
||||
onPatch,
|
||||
onDelete,
|
||||
}: {
|
||||
component: Component;
|
||||
hatches: Project["hatches"];
|
||||
onPatch: (patch: Partial<Component>) => 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 (
|
||||
<div className="res-md-detail-inner">
|
||||
<div className="res-md-head">
|
||||
<input
|
||||
className="res-input res-md-rename"
|
||||
value={component.name}
|
||||
placeholder={t("resources.components.placeholder")}
|
||||
onChange={(e) => onPatch({ name: e.target.value })}
|
||||
/>
|
||||
<DeleteButton
|
||||
onClick={onDelete}
|
||||
title={t("resources.delete.component", { name: component.name })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="res-md-preview">
|
||||
{component.material ? (
|
||||
<ComponentMaterialSphere material={component.material} size={128} />
|
||||
) : previewHatch ? (
|
||||
<HatchSwatch hatch={previewHatch} size={128} />
|
||||
) : (
|
||||
<ColorSwatch color={component.foreground ?? component.color} size={128} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="res-fields">
|
||||
<FieldRow label={t("resources.col.abbrev")} hint={t("resources.col.abbrev.hint")}>
|
||||
<TextField
|
||||
value={component.abbrev ?? ""}
|
||||
onChange={(v) => onPatch({ abbrev: v || undefined })}
|
||||
placeholder={t("resources.col.abbrev.placeholder")}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={t("resources.col.foreground")}
|
||||
hint={t("resources.col.foreground.hint")}
|
||||
>
|
||||
{/* Vordergrund = Farbe der Muster-/Schraffurlinien. Fehlt sie, greift
|
||||
als Anzeige-Fallback `color`; das Setzen definiert `foreground`. */}
|
||||
<ColorField
|
||||
color={component.foreground ?? component.color}
|
||||
onChange={(foreground) => onPatch({ foreground })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={t("resources.col.background")}
|
||||
hint={t("resources.col.background.hint")}
|
||||
>
|
||||
{/* Hintergrund = Füllung (Poché). Fehlt sie, gilt `color` als Fallback. */}
|
||||
<ColorField
|
||||
color={component.background ?? component.color}
|
||||
onChange={(background) => onPatch({ background })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.hatch")}>
|
||||
<SelectField
|
||||
value={component.hatchId}
|
||||
onChange={(hatchId) => onPatch({ hatchId })}
|
||||
options={hatchOptions}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.viewHatch")}>
|
||||
<SelectField
|
||||
value={component.viewHatchId ?? ""}
|
||||
onChange={(viewHatchId) => onPatch({ viewHatchId: viewHatchId || undefined })}
|
||||
options={viewHatchOptions}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.prio")} hint={t("resources.col.prio.hint")}>
|
||||
<NumberField
|
||||
value={component.joinPriority}
|
||||
onChange={(joinPriority) => onPatch({ joinPriority })}
|
||||
step={10}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.texture")}>
|
||||
<TextField
|
||||
value={component.texture3d ?? ""}
|
||||
onChange={(v) => onPatch({ texture3d: v || undefined })}
|
||||
placeholder={t("resources.texture.empty")}
|
||||
mono
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.material")}>
|
||||
<MaterialCell
|
||||
material={component.material}
|
||||
onAssign={(material) => onPatch({ material })}
|
||||
onClear={() => onPatch({ material: undefined })}
|
||||
/>
|
||||
</FieldRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentsTab({
|
||||
project,
|
||||
onPatchComponent,
|
||||
onAddComponent,
|
||||
onDeleteComponent,
|
||||
}: {
|
||||
project: Project;
|
||||
onPatchComponent: (id: string, patch: Partial<Component>) => void;
|
||||
onAddComponent: () => void;
|
||||
onDeleteComponent: (id: string) => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<div className="res-tab">
|
||||
<div className="res-hint">{t("resources.components.hint")}</div>
|
||||
<div className="res-md">
|
||||
<div className="res-md-list">
|
||||
{components.map((c) => {
|
||||
const hatch = project.hatches.find((h) => h.id === c.hatchId);
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`res-md-row${selected?.id === c.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
>
|
||||
<span className="res-md-row-thumb">
|
||||
{c.material ? (
|
||||
<ComponentMaterialSphere material={c.material} size={30} />
|
||||
) : hatch ? (
|
||||
<HatchSwatch hatch={hatch} size={30} />
|
||||
) : (
|
||||
<ColorSwatch color={c.foreground ?? c.color} />
|
||||
)}
|
||||
</span>
|
||||
<span className="res-md-row-name">{c.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{components.length === 0 && (
|
||||
<div className="res-md-empty">{t("resources.components.empty")}</div>
|
||||
)}
|
||||
<div className="res-md-listfoot">
|
||||
<button className="res-add" onClick={() => onAddComponent()}>
|
||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="res-md-detail">
|
||||
{selected ? (
|
||||
<ComponentDetail
|
||||
key={selected.id}
|
||||
component={selected}
|
||||
hatches={project.hatches}
|
||||
onPatch={(patch) => onPatchComponent(selected.id, patch)}
|
||||
onDelete={() => onDeleteComponent(selected.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="res-md-empty">{t("resources.components.empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<DoorType["frameKind"]>; 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<DoorType>) => void;
|
||||
onDeleteDoorType?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TypeLibraryTab
|
||||
types={project.doorTypes ?? []}
|
||||
hintKey="resources.doorTypes.hint"
|
||||
emptyKey="resources.doorTypes.empty"
|
||||
placeholderKey="resources.doorTypes.placeholder"
|
||||
deleteKeyPrefix="resources.delete.doorType"
|
||||
onAdd={onAddDoorType}
|
||||
onPatch={onPatchDoorType}
|
||||
onDelete={onDeleteDoorType}
|
||||
renderFields={(door, patch) => (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.doorKind")}>
|
||||
<SelectField
|
||||
value={door.kind}
|
||||
onChange={(kind) => patch({ kind })}
|
||||
options={DOOR_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.leafCount")}>
|
||||
<Segmented
|
||||
value={String(door.leafCount)}
|
||||
onChange={(v) => patch({ leafCount: (v === "2" ? 2 : 1) as 1 | 2 })}
|
||||
options={[
|
||||
{ value: "1", label: t("resources.leafCount.1") },
|
||||
{ value: "2", label: t("resources.leafCount.2") },
|
||||
]}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.leafStyle")}>
|
||||
<SelectField
|
||||
value={door.leafStyle}
|
||||
onChange={(leafStyle) => patch({ leafStyle })}
|
||||
options={DOOR_LEAF_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
{door.leafStyle === "glas" && (
|
||||
<FieldRow label={t("resources.field.glazingRatio")}>
|
||||
<NumberField
|
||||
value={door.glazingRatio ?? 0.6}
|
||||
onChange={(glazingRatio) => patch({ glazingRatio })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
max={1}
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow label={t("resources.field.frameThickness")}>
|
||||
<NumberField
|
||||
value={door.frameThickness}
|
||||
onChange={(frameThickness) => patch({ frameThickness })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.frameDepth")}>
|
||||
<NumberField
|
||||
value={door.frameDepth ?? 0}
|
||||
onChange={(frameDepth) => patch({ frameDepth })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.frameKind")}>
|
||||
<SelectField
|
||||
value={door.frameKind ?? "zarge"}
|
||||
onChange={(frameKind) => patch({ frameKind })}
|
||||
options={DOOR_FRAME_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.frameWidth")}>
|
||||
<NumberField
|
||||
value={door.frameWidth ?? 0.06}
|
||||
onChange={(frameWidth) => patch({ frameWidth })}
|
||||
step={0.005}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.insetFromFace")}>
|
||||
<NumberField
|
||||
value={door.insetFromFace ?? 0}
|
||||
onChange={(insetFromFace) => patch({ insetFromFace })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
{(door.insetFromFace ?? 0) > 0 && (
|
||||
<FieldRow label={t("resources.field.insetFace")}>
|
||||
<SelectField
|
||||
value={door.insetFace ?? "aussen"}
|
||||
onChange={(insetFace) => patch({ insetFace })}
|
||||
options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow label={t("resources.field.transomHeight")}>
|
||||
<NumberField
|
||||
value={door.transomHeight ?? 0}
|
||||
onChange={(transomHeight) => patch({ transomHeight })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.defaultWidth")}>
|
||||
<NumberField
|
||||
value={door.defaultWidth}
|
||||
onChange={(defaultWidth) => patch({ defaultWidth })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.defaultHeight")}>
|
||||
<NumberField
|
||||
value={door.defaultHeight}
|
||||
onChange={(defaultHeight) => patch({ defaultHeight })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.threshold")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!door.threshold}
|
||||
onChange={(e) => patch({ threshold: e.target.checked })}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HatchStyle>) => 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<NonNullable<HatchStyle["image"]>>) => {
|
||||
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 (
|
||||
<div className="res-md-detail-inner">
|
||||
<div className="res-md-head">
|
||||
<input
|
||||
className="res-input res-md-rename"
|
||||
value={hatch.name}
|
||||
placeholder={t("resources.hatches.placeholder")}
|
||||
onChange={(e) => onPatch({ name: e.target.value })}
|
||||
/>
|
||||
<DeleteButton
|
||||
onClick={onDelete}
|
||||
title={t("resources.delete.hatch", { name: hatch.name })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="res-md-preview">
|
||||
<HatchSwatch hatch={hatch} size={128} />
|
||||
</div>
|
||||
|
||||
<div className="res-fields">
|
||||
<FieldRow label={t("resources.field.type")}>
|
||||
<Segmented
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ value: "vector", label: t("resources.hatchKind.vector") },
|
||||
{ value: "image", label: t("resources.hatchKind.image") },
|
||||
]}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{kind === "vector" ? (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.lines")}>
|
||||
<Segmented
|
||||
value={lines}
|
||||
onChange={(v) => onPatch({ lines: v })}
|
||||
options={[
|
||||
{ value: "parallel", label: t("resources.lines.parallel") },
|
||||
{ value: "random", label: t("resources.lines.random") },
|
||||
]}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.pattern")}>
|
||||
<SelectField
|
||||
value={hatch.pattern}
|
||||
onChange={(pattern) => onPatch({ pattern })}
|
||||
options={PATTERN_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.scale")}>
|
||||
<NumberField
|
||||
value={hatch.scale}
|
||||
onChange={(scale) => onPatch({ scale })}
|
||||
step={0.1}
|
||||
min={0.01}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.angle")}>
|
||||
<NumberField
|
||||
value={hatch.angle}
|
||||
onChange={(angle) => onPatch({ angle })}
|
||||
step={5}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={t("resources.col.relativeToWall")}
|
||||
hint={t("resources.col.relativeToWall.hint")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!hatch.relativeToWall}
|
||||
onChange={(e) => onPatch({ relativeToWall: e.target.checked })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.lineStyle")}>
|
||||
<SelectField
|
||||
value={hatch.lineStyleId ?? NONE}
|
||||
onChange={(v) => onPatch({ lineStyleId: v === NONE ? undefined : v })}
|
||||
options={lineOptions}
|
||||
/>
|
||||
</FieldRow>
|
||||
{lines === "random" && (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.density")}>
|
||||
<NumberField
|
||||
value={hatch.density ?? 1}
|
||||
onChange={(density) => onPatch({ density })}
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.lengthMin")}>
|
||||
<NumberField
|
||||
value={lenMin}
|
||||
onChange={(v) => onPatch({ lengthMin: v, lengthMax: hatch.lengthMax ?? lenMax })}
|
||||
step={0.5}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.lengthMax")}>
|
||||
<NumberField
|
||||
value={lenMax}
|
||||
onChange={(v) => onPatch({ lengthMax: v, lengthMin: hatch.lengthMin ?? lenMin })}
|
||||
step={0.5}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.random.reroll")}>
|
||||
<button type="button" className="res-seg-btn" onClick={reroll}>
|
||||
{t("resources.random.reroll")}
|
||||
</button>
|
||||
</FieldRow>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.image")}>
|
||||
<ImagePickButton onFile={loadImage} />
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.scaleX")}>
|
||||
<NumberField
|
||||
value={hatch.image?.scaleX ?? 1}
|
||||
onChange={(scaleX) => patchImage({ scaleX })}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.scaleY")}>
|
||||
<NumberField
|
||||
value={hatch.image?.scaleY ?? 1}
|
||||
onChange={(scaleY) => patchImage({ scaleY })}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.rotation")}>
|
||||
<NumberField
|
||||
value={hatch.image?.rotation ?? 0}
|
||||
onChange={(rotation) => patchImage({ rotation })}
|
||||
step={5}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* .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<HatchStyle, "id">[] {
|
||||
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<HatchStyle>) => void;
|
||||
onAddHatch: () => void;
|
||||
onDeleteHatch: (id: string) => void;
|
||||
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<div className="res-tab">
|
||||
<div className="res-hint">{t("resources.hatches.hint")}</div>
|
||||
<div className="res-md">
|
||||
<div className="res-md-list">
|
||||
{hatches.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
className={`res-md-row${selected?.id === h.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(h.id)}
|
||||
>
|
||||
<span className="res-md-row-thumb">
|
||||
<HatchSwatch hatch={h} size={30} />
|
||||
</span>
|
||||
<span className="res-md-row-name">{h.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{hatches.length === 0 && (
|
||||
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
|
||||
)}
|
||||
<div className="res-md-listfoot">
|
||||
<button className="res-add" onClick={() => onAddHatch()}>
|
||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||
</button>
|
||||
<FileImportButton
|
||||
accept=".pat,text/plain"
|
||||
label={t("resources.import.pat")}
|
||||
onText={(text) => onImportHatches(patToHatches(text))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="res-md-detail">
|
||||
{selected ? (
|
||||
<HatchDetail
|
||||
key={selected.id}
|
||||
hatch={selected}
|
||||
lineStyles={project.lineStyles}
|
||||
onPatch={(patch) => onPatchHatch(selected.id, patch)}
|
||||
onDelete={() => onDeleteHatch(selected.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="res-md-empty">{t("resources.hatches.empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<LayeredStyle>) => 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<Layer>) =>
|
||||
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 (
|
||||
<div className="res-md-detail-inner">
|
||||
<div className="res-md-head">
|
||||
<input
|
||||
className="res-input res-md-rename"
|
||||
value={style.name}
|
||||
placeholder={namePlaceholder}
|
||||
onChange={(e) => onPatch({ name: e.target.value })}
|
||||
/>
|
||||
<DeleteButton onClick={onDelete} title={deleteTitle} />
|
||||
</div>
|
||||
|
||||
<div className="res-md-preview">
|
||||
<WallTypeSwatch
|
||||
layers={swatchLayersOf(style, project)}
|
||||
width={orientation === "wall" ? 240 : 132}
|
||||
height={orientation === "wall" ? 96 : 132}
|
||||
orientation={orientation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="res-layers">
|
||||
{layers.map((layer, i) => {
|
||||
const isInner = i === layers.length - 1;
|
||||
return (
|
||||
<div key={i} className="res-layer">
|
||||
<div className="res-layer-move">
|
||||
<button
|
||||
type="button"
|
||||
className="res-layer-btn"
|
||||
title={t("resources.layer.moveUp")}
|
||||
disabled={i === 0}
|
||||
onClick={() => moveLayer(i, -1)}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="res-layer-btn"
|
||||
title={t("resources.layer.moveDown")}
|
||||
disabled={isInner}
|
||||
onClick={() => moveLayer(i, 1)}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<div className="res-layer-fields">
|
||||
<FieldRow label={t("resources.col.component")}>
|
||||
<SelectField
|
||||
value={layer.componentId}
|
||||
onChange={(componentId) => patchLayer(i, { componentId })}
|
||||
options={compOptions}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.thickness")}>
|
||||
<NumberField
|
||||
value={Math.round(layer.thickness * 1000)}
|
||||
onChange={(mm) =>
|
||||
patchLayer(i, { thickness: Math.max(0, mm) / 1000 })
|
||||
}
|
||||
step={5}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.joint")}>
|
||||
{isInner ? (
|
||||
<span className="res-joint-none">–</span>
|
||||
) : (
|
||||
<SelectField
|
||||
value={layer.jointLineStyleId ?? DEFAULT}
|
||||
onChange={(v) =>
|
||||
patchLayer(i, {
|
||||
jointLineStyleId: v === DEFAULT ? undefined : v,
|
||||
})
|
||||
}
|
||||
options={jointOptions}
|
||||
/>
|
||||
)}
|
||||
</FieldRow>
|
||||
</div>
|
||||
<DeleteButton
|
||||
onClick={() => removeLayer(i)}
|
||||
title={t("resources.layer.remove")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" className="res-add" onClick={addLayer}>
|
||||
<span className="res-add-plus">+</span> {t("resources.layer.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<LayeredStyle>) => void;
|
||||
onAdd: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selected = types.find((s) => s.id === selectedId) ?? types[0];
|
||||
|
||||
return (
|
||||
<div className="res-tab">
|
||||
<div className="res-hint">{t(hintKey)}</div>
|
||||
<div className="res-md">
|
||||
<div className="res-md-list">
|
||||
{types.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className={`res-md-row${selected?.id === s.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
>
|
||||
<span className="res-md-row-thumb">
|
||||
<WallTypeSwatch
|
||||
layers={swatchLayersOf(s, project)}
|
||||
width={46}
|
||||
height={28}
|
||||
orientation={orientation}
|
||||
/>
|
||||
</span>
|
||||
<span className="res-md-row-name">{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{types.length === 0 && (
|
||||
<div className="res-md-empty">{t(emptyKey)}</div>
|
||||
)}
|
||||
<div className="res-md-listfoot">
|
||||
<button className="res-add" onClick={() => onAdd()}>
|
||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="res-md-detail">
|
||||
{selected ? (
|
||||
<LayeredStyleDetail
|
||||
key={selected.id}
|
||||
style={selected}
|
||||
project={project}
|
||||
orientation={orientation}
|
||||
onPatch={(patch) => onPatch(selected.id, patch)}
|
||||
onDelete={() => onDelete(selected.id)}
|
||||
deleteTitle={t(deleteKeyPrefix, { name: selected.name })}
|
||||
namePlaceholder={t(placeholderKey)}
|
||||
/>
|
||||
) : (
|
||||
<div className="res-md-empty">{t(emptyKey)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WallStylesTab({
|
||||
project,
|
||||
onPatchWallType,
|
||||
onAddWallType,
|
||||
onDeleteWallType,
|
||||
}: {
|
||||
project: Project;
|
||||
onPatchWallType: (id: string, patch: Partial<WallType>) => void;
|
||||
onAddWallType: () => void;
|
||||
onDeleteWallType: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<LayeredStylesTab
|
||||
project={project}
|
||||
types={project.wallTypes}
|
||||
orientation="wall"
|
||||
hintKey="resources.wallStyles.hint"
|
||||
emptyKey="resources.wallStyles.empty"
|
||||
placeholderKey="resources.wallStyles.placeholder"
|
||||
deleteKeyPrefix="resources.delete.wallType"
|
||||
onPatch={onPatchWallType}
|
||||
onAdd={onAddWallType}
|
||||
onDelete={onDeleteWallType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CeilingType>) => void;
|
||||
onAddCeilingType: () => void;
|
||||
onDeleteCeilingType: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<LayeredStylesTab
|
||||
project={project}
|
||||
types={project.ceilingTypes ?? []}
|
||||
orientation="ceiling"
|
||||
hintKey="resources.ceilingStyles.hint"
|
||||
emptyKey="resources.ceilingStyles.empty"
|
||||
placeholderKey="resources.ceilingStyles.placeholder"
|
||||
deleteKeyPrefix="resources.delete.ceilingType"
|
||||
onPatch={onPatchCeilingType}
|
||||
onAdd={onAddCeilingType}
|
||||
onDelete={onDeleteCeilingType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<RoofType>) => void;
|
||||
onAddRoofType: () => void;
|
||||
onDeleteRoofType: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<LayeredStylesTab
|
||||
project={project}
|
||||
types={project.roofTypes ?? []}
|
||||
orientation="ceiling"
|
||||
hintKey="resources.roofStyles.hint"
|
||||
emptyKey="resources.roofStyles.empty"
|
||||
placeholderKey="resources.roofStyles.placeholder"
|
||||
deleteKeyPrefix="resources.delete.roofType"
|
||||
onPatch={onPatchRoofType}
|
||||
onAdd={onAddRoofType}
|
||||
onDelete={onDeleteRoofType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<LineStyle["motif"]> = {
|
||||
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<LineStyle>) => 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<NonNullable<LineStyle["zigzag"]>>) => {
|
||||
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 (
|
||||
<div className="res-md-detail-inner">
|
||||
<div className="res-md-head">
|
||||
<input
|
||||
className="res-input res-md-rename"
|
||||
value={style.name}
|
||||
placeholder={t("resources.lines.placeholder")}
|
||||
onChange={(e) => onPatch({ name: e.target.value })}
|
||||
/>
|
||||
<DeleteButton
|
||||
onClick={onDelete}
|
||||
title={t("resources.delete.lineStyle", { name: style.name })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="res-md-preview">
|
||||
<LineSwatch style={style} width={220} height={40} />
|
||||
</div>
|
||||
|
||||
<div className="res-fields">
|
||||
<FieldRow label={t("resources.field.type")}>
|
||||
<Segmented
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ value: "dash", label: t("resources.lineKind.dash") },
|
||||
{ value: "zigzag", label: t("resources.lineKind.zigzag") },
|
||||
{ value: "custom", label: t("resources.lineKind.custom") },
|
||||
]}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.col.color")}>
|
||||
<ColorField color={style.color} onChange={(color) => onPatch({ color })} />
|
||||
</FieldRow>
|
||||
{kind === "zigzag" ? (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.amplitude")}>
|
||||
<NumberField
|
||||
value={style.zigzag?.amplitude ?? 1}
|
||||
onChange={(amplitude) => patchZig({ amplitude })}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.wavelength")}>
|
||||
<NumberField
|
||||
value={style.zigzag?.wavelength ?? 4}
|
||||
onChange={(wavelength) => patchZig({ wavelength })}
|
||||
step={0.5}
|
||||
min={0.5}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
) : kind === "custom" ? (
|
||||
<FieldRow label={t("resources.field.motif")}>
|
||||
<MotifEditor
|
||||
points={style.motif?.points ?? DEFAULT_MOTIF.points}
|
||||
length={style.motif?.length ?? DEFAULT_MOTIF.length}
|
||||
onChange={(points, length) => onPatch({ motif: { points, length } })}
|
||||
/>
|
||||
</FieldRow>
|
||||
) : (
|
||||
<>
|
||||
{/* Schnell-Presets: Volllinie / Strichlinie / Punktlinie / Strich-Punkt. */}
|
||||
<FieldRow label={t("resources.linePreset.label")}>
|
||||
<Segmented<string>
|
||||
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") },
|
||||
]}
|
||||
/>
|
||||
</FieldRow>
|
||||
{!isSolid && (
|
||||
<FieldRow
|
||||
label={t("resources.dash.segments")}
|
||||
hint={t("resources.dash.hint")}
|
||||
>
|
||||
<div className="line-seg-editor">
|
||||
{segments.map((seg, i) => (
|
||||
<div className="line-seg-row" key={i}>
|
||||
<SelectField
|
||||
value={seg.type}
|
||||
onChange={(type) => 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") },
|
||||
]}
|
||||
/>
|
||||
<span className="line-seg-num">
|
||||
{seg.type === "dot" ? (
|
||||
<span className="line-seg-dotlabel">0</span>
|
||||
) : (
|
||||
<NumberField
|
||||
value={seg.length}
|
||||
onChange={(x) => setSegLen(i, x)}
|
||||
step={0.5}
|
||||
min={0}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="res-seg-btn"
|
||||
title={t("resources.dash.remove")}
|
||||
onClick={() => removeSeg(i)}
|
||||
style={{ padding: "2px 6px", lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="line-seg-add">
|
||||
<button
|
||||
type="button"
|
||||
className="res-seg-btn"
|
||||
onClick={() => addSegOf("dash")}
|
||||
style={{ padding: "2px 8px", lineHeight: 1 }}
|
||||
>
|
||||
{t("resources.seg.addDash")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="res-seg-btn"
|
||||
onClick={() => addSegOf("dot")}
|
||||
style={{ padding: "2px 8px", lineHeight: 1 }}
|
||||
>
|
||||
{t("resources.seg.addDot")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="res-seg-btn"
|
||||
onClick={() => addSegOf("gap")}
|
||||
style={{ padding: "2px 8px", lineHeight: 1 }}
|
||||
>
|
||||
{t("resources.seg.addGap")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FieldRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** .lin-Text → id-lose Linienstile (schwarze Haarlinie 0.18 als Default). */
|
||||
function linToStyles(text: string): Omit<LineStyle, "id">[] {
|
||||
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<LineStyle>) => void;
|
||||
onAddLineStyle: () => void;
|
||||
onDeleteLineStyle: (id: string) => void;
|
||||
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const styles = project.lineStyles;
|
||||
const selected = styles.find((l) => l.id === selectedId) ?? styles[0];
|
||||
|
||||
return (
|
||||
<div className="res-tab">
|
||||
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
|
||||
<datalist id="pen-weights">
|
||||
{PEN_WEIGHTS.map((w) => (
|
||||
<option key={w} value={w} />
|
||||
))}
|
||||
</datalist>
|
||||
<div className="res-hint">{t("resources.lines.hint")}</div>
|
||||
<div className="res-md">
|
||||
<div className="res-md-list">
|
||||
{styles.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
className={`res-md-row res-md-row-line${
|
||||
selected?.id === l.id ? " active" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedId(l.id)}
|
||||
>
|
||||
<span className="res-md-row-name">{l.name}</span>
|
||||
<LineSwatch style={l} width={150} height={14} />
|
||||
</button>
|
||||
))}
|
||||
{styles.length === 0 && (
|
||||
<div className="res-md-empty">{t("resources.lines.empty")}</div>
|
||||
)}
|
||||
<div className="res-md-listfoot">
|
||||
<button className="res-add" onClick={() => onAddLineStyle()}>
|
||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||
</button>
|
||||
<FileImportButton
|
||||
accept=".lin,text/plain"
|
||||
label={t("resources.import.lin")}
|
||||
onText={(text) => onImportLineStyles(linToStyles(text))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="res-md-detail">
|
||||
{selected ? (
|
||||
<LineStyleDetail
|
||||
key={selected.id}
|
||||
style={selected}
|
||||
onPatch={(patch) => onPatchLineStyle(selected.id, patch)}
|
||||
onDelete={() => onDeleteLineStyle(selected.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="res-md-empty">{t("resources.lines.empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
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<HTMLButtonElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>(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 (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`mat-tile${active ? " active" : ""}`}
|
||||
title={`${asset.name} — ${asset.category}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="mat-sphere">
|
||||
{previewUrl && <img src={previewUrl} alt="" draggable={false} />}
|
||||
</span>
|
||||
<span className="mat-tile-name">{asset.name}</span>
|
||||
<span className="mat-tile-badge">{asset.category}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MaterialsTab() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<div className="res-tab">
|
||||
<div className="res-hint">{t("resources.materials.hint")}</div>
|
||||
|
||||
<div className="mat-lib-filterbar">
|
||||
<input
|
||||
type="text"
|
||||
className="res-input mat-search"
|
||||
value={query}
|
||||
placeholder={t("resources.materials.searchPlaceholder")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className="mat-lib-chips"
|
||||
role="group"
|
||||
aria-label={t("resources.materials.categoryLabel")}
|
||||
>
|
||||
<button
|
||||
className={`mat-chip${category === null ? " active" : ""}`}
|
||||
onClick={() => setCategory(null)}
|
||||
>
|
||||
{t("resources.materials.categoryAll")}
|
||||
</button>
|
||||
{MATERIAL_CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`mat-chip${category === c ? " active" : ""}`}
|
||||
onClick={() => setCategory((cur) => (cur === c ? null : c))}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="mat-lib-selected">
|
||||
{t("resources.materials.selected", {
|
||||
name: selected.name,
|
||||
category: selected.category,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mat-grid mat-lib-grid">
|
||||
{filtered.map((asset) => (
|
||||
<MaterialLibraryTile
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
active={selectedId === asset.id}
|
||||
onSelect={() => setSelectedId((cur) => (cur === asset.id ? null : asset.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && <EmptyState text={t("resources.materials.empty")} />}
|
||||
|
||||
<div className="mat-browse-foot">
|
||||
<span className="mat-browse-status">
|
||||
{t("resources.materials.count", {
|
||||
shown: filtered.length,
|
||||
total: MATERIAL_LIBRARY.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="res-field">
|
||||
<span className="res-field-label">
|
||||
<label className="res-ov-action-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
/>{" "}
|
||||
{label}
|
||||
</label>
|
||||
</span>
|
||||
<span className="res-field-control">
|
||||
{active ? children : <span className="res-joint-none">–</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Detail-Panel EINER Override-Regel (rechte Seite des Master-Detail-Layouts). */
|
||||
function OverrideRuleDetail({
|
||||
rule,
|
||||
project,
|
||||
onPatch,
|
||||
onDelete,
|
||||
}: {
|
||||
rule: OverrideRule;
|
||||
project: Project;
|
||||
onPatch: (patch: Partial<OverrideRule>) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const patchCondition = (patch: Partial<OverrideRule["condition"]>) =>
|
||||
onPatch({ condition: { ...rule.condition, ...patch } });
|
||||
const patchActions = (patch: Partial<OverrideRule["actions"]>) =>
|
||||
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 (
|
||||
<div className="res-md-detail-inner">
|
||||
<div className="res-md-head">
|
||||
<input
|
||||
className="res-input res-md-rename"
|
||||
value={rule.name}
|
||||
placeholder={t("resources.overrides.placeholder")}
|
||||
onChange={(e) => onPatch({ name: e.target.value })}
|
||||
/>
|
||||
<DeleteButton onClick={onDelete} title={t("resources.overrides.delete")} />
|
||||
</div>
|
||||
|
||||
<div className="res-hint">{t("resources.overrides.condition")}</div>
|
||||
<FieldRow label={t("resources.overrides.condType")}>
|
||||
<SelectField
|
||||
value={rule.condition.type}
|
||||
onChange={(type) => patchCondition({ type })}
|
||||
options={OVERRIDE_TYPE_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.overrides.operator")}>
|
||||
<SelectField
|
||||
value={rule.condition.operator}
|
||||
onChange={(operator) => patchCondition({ operator })}
|
||||
options={OVERRIDE_OP_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: t(o.labelKey),
|
||||
}))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.overrides.value")}>
|
||||
<TextField
|
||||
value={rule.condition.value}
|
||||
placeholder={t("resources.overrides.value.placeholder")}
|
||||
onChange={(value) => patchCondition({ value })}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<div className="res-hint">{t("resources.overrides.actions")}</div>
|
||||
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
|
||||
<datalist id="ov-pen-weights">
|
||||
{PEN_WEIGHTS.map((w) => (
|
||||
<option key={w} value={w} />
|
||||
))}
|
||||
</datalist>
|
||||
<OptionalAction
|
||||
label={t("resources.overrides.action.color")}
|
||||
active={rule.actions.color !== undefined}
|
||||
onToggle={(on) => patchActions({ color: on ? "#808080" : undefined })}
|
||||
>
|
||||
<ColorField
|
||||
color={rule.actions.color ?? "#808080"}
|
||||
onChange={(color) => patchActions({ color })}
|
||||
/>
|
||||
</OptionalAction>
|
||||
<OptionalAction
|
||||
label={t("resources.overrides.action.lineweight")}
|
||||
active={rule.actions.lineweight !== undefined}
|
||||
onToggle={(on) => patchActions({ lineweight: on ? 0.35 : undefined })}
|
||||
>
|
||||
<NumberField
|
||||
value={rule.actions.lineweight ?? 0.35}
|
||||
onChange={(lineweight) => patchActions({ lineweight: Math.max(0, lineweight) })}
|
||||
step={0.05}
|
||||
min={0}
|
||||
list="ov-pen-weights"
|
||||
/>
|
||||
</OptionalAction>
|
||||
<OptionalAction
|
||||
label={t("resources.overrides.action.linetype")}
|
||||
active={rule.actions.linetypeId !== undefined}
|
||||
onToggle={(on) =>
|
||||
patchActions({
|
||||
linetypeId: on ? project.lineStyles[0]?.id : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
value={rule.actions.linetypeId ?? NONE}
|
||||
onChange={(v) =>
|
||||
patchActions({ linetypeId: v === NONE ? undefined : v })
|
||||
}
|
||||
options={lineOptions}
|
||||
/>
|
||||
</OptionalAction>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string | null>(null);
|
||||
const selected = rules.find((r) => r.id === selectedId) ?? rules[0];
|
||||
|
||||
const setRules = (next: OverrideRule[]) => onSetOverrideRules?.(next);
|
||||
const patchRule = (id: string, patch: Partial<OverrideRule>) =>
|
||||
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 (
|
||||
<div className="res-tab">
|
||||
<div className="res-hint">{t("resources.overrides.hint")}</div>
|
||||
<div className="res-md">
|
||||
<div className="res-md-list">
|
||||
{rules.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`res-md-row res-ov-row${
|
||||
selected?.id === r.id ? " active" : ""
|
||||
}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
|
||||
onClick={() => setSelectedId(r.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setSelectedId(r.id);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={r.enabled}
|
||||
title={t("resources.overrides.enabled")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => patchRule(r.id, { enabled: e.target.checked })}
|
||||
/>
|
||||
<span
|
||||
className="res-md-row-name"
|
||||
style={{ flex: 1, opacity: r.enabled ? 1 : 0.5 }}
|
||||
>
|
||||
{r.name || t("resources.overrides.placeholder")}
|
||||
<span
|
||||
style={{ display: "block", fontSize: "0.85em", opacity: 0.65 }}
|
||||
>
|
||||
{overrideConditionSummary(r)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="res-layer-btn"
|
||||
title={t("resources.overrides.moveUp")}
|
||||
disabled={rules[0]?.id === r.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveRule(r.id, -1);
|
||||
}}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="res-layer-btn"
|
||||
title={t("resources.overrides.moveDown")}
|
||||
disabled={rules[rules.length - 1]?.id === r.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
moveRule(r.id, 1);
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<div className="res-md-empty">{t("resources.overrides.empty")}</div>
|
||||
)}
|
||||
<div className="res-md-listfoot">
|
||||
<button
|
||||
className="res-add"
|
||||
onClick={addRule}
|
||||
disabled={!onSetOverrideRules}
|
||||
>
|
||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="res-md-detail">
|
||||
{selected ? (
|
||||
<OverrideRuleDetail
|
||||
key={selected.id}
|
||||
rule={selected}
|
||||
project={project}
|
||||
onPatch={(patch) => patchRule(selected.id, patch)}
|
||||
onDelete={() => deleteRule(selected.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="res-md-empty">{t("resources.overrides.empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<StairType["railing"]>; 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<StairType>) => void;
|
||||
onDeleteStairType?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TypeLibraryTab
|
||||
types={project.stairTypes ?? []}
|
||||
hintKey="resources.stairTypes.hint"
|
||||
emptyKey="resources.stairTypes.empty"
|
||||
placeholderKey="resources.stairTypes.placeholder"
|
||||
deleteKeyPrefix="resources.delete.stairType"
|
||||
onAdd={onAddStairType}
|
||||
onPatch={onPatchStairType}
|
||||
onDelete={onDeleteStairType}
|
||||
renderFields={(stair, patch) => (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.structure")}>
|
||||
<SelectField
|
||||
value={stair.structure}
|
||||
onChange={(structure) => patch({ structure })}
|
||||
options={STAIR_STRUCTURE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.closedRisers")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!stair.closedRisers}
|
||||
onChange={(e) => patch({ closedRisers: e.target.checked })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.treadThickness")}>
|
||||
<NumberField
|
||||
value={stair.treadThickness}
|
||||
onChange={(treadThickness) => patch({ treadThickness })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.nosing")}>
|
||||
<NumberField
|
||||
value={stair.nosing ?? 0}
|
||||
onChange={(nosing) => patch({ nosing })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.railing")}>
|
||||
<SelectField
|
||||
value={stair.railing ?? "keine"}
|
||||
onChange={(railing) => patch({ railing })}
|
||||
options={STAIR_RAILING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.stairWidth")}>
|
||||
<NumberField
|
||||
value={stair.defaultWidth}
|
||||
onChange={(defaultWidth) => patch({ defaultWidth })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<T extends { id: string; name: string }>({
|
||||
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<T>) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
renderFields: (item: T, onPatch: (patch: Partial<T>) => void) => React.ReactNode;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selected = types.find((x) => x.id === selectedId) ?? types[0];
|
||||
|
||||
return (
|
||||
<div className="res-tab">
|
||||
<div className="res-hint">{t(hintKey)}</div>
|
||||
<div className="res-md">
|
||||
<div className="res-md-list">
|
||||
{types.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`res-md-row${selected?.id === item.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className="res-md-row-name">{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{types.length === 0 && (
|
||||
<div className="res-md-empty">{t(emptyKey)}</div>
|
||||
)}
|
||||
<div className="res-md-listfoot">
|
||||
<button className="res-add" onClick={() => onAdd?.()}>
|
||||
<span className="res-add-plus">+</span> {t("resources.add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="res-md-detail">
|
||||
{selected ? (
|
||||
<div className="res-md-detail-inner" key={selected.id}>
|
||||
<div className="res-md-head">
|
||||
<input
|
||||
className="res-input res-md-rename"
|
||||
value={selected.name}
|
||||
placeholder={t(placeholderKey)}
|
||||
onChange={(e) =>
|
||||
onPatch?.(selected.id, { name: e.target.value } as Partial<T>)
|
||||
}
|
||||
/>
|
||||
<DeleteButton
|
||||
onClick={() => onDelete?.(selected.id)}
|
||||
title={t(deleteKeyPrefix, { name: selected.name })}
|
||||
/>
|
||||
</div>
|
||||
<div className="res-fields">
|
||||
{renderFields(selected, (patch) => onPatch?.(selected.id, patch))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="res-md-empty">{t(emptyKey)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<WindowType["sillBoard"]>; 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<WindowType>) => void;
|
||||
onDeleteWindowType?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<TypeLibraryTab
|
||||
types={project.windowTypes ?? []}
|
||||
hintKey="resources.windowTypes.hint"
|
||||
emptyKey="resources.windowTypes.empty"
|
||||
placeholderKey="resources.windowTypes.placeholder"
|
||||
deleteKeyPrefix="resources.delete.windowType"
|
||||
onAdd={onAddWindowType}
|
||||
onPatch={onPatchWindowType}
|
||||
onDelete={onDeleteWindowType}
|
||||
renderFields={(win, patch) => (
|
||||
<>
|
||||
<FieldRow label={t("resources.field.windowKind")}>
|
||||
<SelectField
|
||||
value={win.kind}
|
||||
onChange={(kind) => patch({ kind })}
|
||||
options={WINDOW_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.wingCount")}>
|
||||
<NumberField
|
||||
value={win.wingCount}
|
||||
onChange={(wingCount) => patch({ wingCount: Math.round(wingCount) })}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.mullionRows")}>
|
||||
<NumberField
|
||||
value={win.mullionRows ?? 1}
|
||||
onChange={(mullionRows) => patch({ mullionRows: Math.max(1, Math.round(mullionRows)) })}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.mullionCols")}>
|
||||
<NumberField
|
||||
value={win.mullionCols ?? 1}
|
||||
onChange={(mullionCols) => patch({ mullionCols: Math.max(1, Math.round(mullionCols)) })}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.glazing")}>
|
||||
<SelectField
|
||||
value={win.glazing}
|
||||
onChange={(glazing) => patch({ glazing })}
|
||||
options={WINDOW_GLAZING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.frameThickness")}>
|
||||
<NumberField
|
||||
value={win.frameThickness}
|
||||
onChange={(frameThickness) => patch({ frameThickness })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.frameDepth")}>
|
||||
<NumberField
|
||||
value={win.frameDepth ?? 0}
|
||||
onChange={(frameDepth) => patch({ frameDepth })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.frameWidth")}>
|
||||
<NumberField
|
||||
value={win.frameWidth ?? 0.06}
|
||||
onChange={(frameWidth) => patch({ frameWidth })}
|
||||
step={0.005}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.insetFromFace")}>
|
||||
<NumberField
|
||||
value={win.insetFromFace ?? 0}
|
||||
onChange={(insetFromFace) => patch({ insetFromFace })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
{(win.insetFromFace ?? 0) > 0 && (
|
||||
<FieldRow label={t("resources.field.insetFace")}>
|
||||
<SelectField
|
||||
value={win.insetFace ?? "aussen"}
|
||||
onChange={(insetFace) => patch({ insetFace })}
|
||||
options={INSET_FACE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow label={t("resources.field.innerLayerRecess")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!win.innerLayerRecess}
|
||||
onChange={(e) => patch({ innerLayerRecess: e.target.checked })}
|
||||
title={t("resources.field.innerLayerRecess.hint")}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.transomHeight")}>
|
||||
<NumberField
|
||||
value={win.transomHeight ?? 0}
|
||||
onChange={(transomHeight) => patch({ transomHeight })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.defaultSillHeight")}>
|
||||
<NumberField
|
||||
value={win.defaultSillHeight}
|
||||
onChange={(defaultSillHeight) => patch({ defaultSillHeight })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.defaultWidth")}>
|
||||
<NumberField
|
||||
value={win.defaultWidth}
|
||||
onChange={(defaultWidth) => patch({ defaultWidth })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.defaultHeight")}>
|
||||
<NumberField
|
||||
value={win.defaultHeight}
|
||||
onChange={(defaultHeight) => patch({ defaultHeight })}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow label={t("resources.field.sillBoard")}>
|
||||
<SelectField
|
||||
value={win.sillBoard ?? "keine"}
|
||||
onChange={(sillBoard) => patch({ sillBoard })}
|
||||
options={WINDOW_SILL_BOARD_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<input
|
||||
type="text"
|
||||
className={`res-input${mono ? " mono" : ""}`}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => 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 <datalist>-ID mit Vorschlagswerten (z. B. Stiftstärken). */
|
||||
list?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
list={list}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v)) onChange(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Farb-Swatch mit nativem Color-Picker. Das <label> umschließt einen
|
||||
* sichtbaren Swatch und den unsichtbaren color-input — Klick öffnet den
|
||||
* Picker, der Swatch zeigt die Farbe live (nach DOSSIER ColorBar).
|
||||
*/
|
||||
export function ColorField({
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
color: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="res-color" title={color}>
|
||||
<span className="res-color-swatch" style={{ background: color }} />
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pill-Dropdown (nutzt die gemeinsame Dropdown-Komponente statt <select>). */
|
||||
export function SelectField<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Dropdown
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as T)}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Runder Löschen-Knopf (Mülleimer-Icon) in der letzten Spalte. */
|
||||
export function DeleteButton({ onClick, title }: { onClick: () => void; title: string }) {
|
||||
return (
|
||||
<button className="res-delete" title={title} onClick={onClick}>
|
||||
<svg
|
||||
width={15}
|
||||
height={15}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmentierter Umschalter (Pillen-Gruppe) für kleine, sich ausschließende
|
||||
* Auswahlen — z. B. Schraffur-Typ Vektor/Bild oder Linien-Typ Strich/Zickzack.
|
||||
*/
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
options: { value: T; label: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="res-seg" role="group">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className={`res-seg-btn${value === o.value ? " active" : ""}`}
|
||||
aria-pressed={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine beschriftete Detail-Zeile (Label links, Steuerelement rechts). Bewusst
|
||||
* ein <div> (kein <label>): Zeilen enthalten teils mehrere interaktive Elemente
|
||||
* (Segmented-Buttons), und ein umschließendes <label> würde den Klick an das
|
||||
* ERSTE labelbare Kind umleiten — das würde einen Segment-Klick sofort wieder
|
||||
* zurücksetzen. Die Steuerelemente sind selbst klickbar; ein Label ist unnötig.
|
||||
*/
|
||||
export function FieldRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="res-field" title={hint}>
|
||||
<span className="res-field-label">{label}</span>
|
||||
<span className="res-field-control">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Leerer Listen-/Ergebniszustand mit zentriertem Hinweistext. */
|
||||
export function EmptyState({ text }: { text: string }) {
|
||||
return <div className="res-empty">{text}</div>;
|
||||
}
|
||||
|
||||
/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */
|
||||
export function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<>
|
||||
<button className="res-add mat-pick" onClick={() => ref.current?.click()}>
|
||||
{t("material.upload.choose")}
|
||||
</button>
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
onFile(e.target.files?.[0] ?? null);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei-Import-Schaltfläche (verstecktes <input type=file>). Liest die gewählte
|
||||
* Datei als Text und reicht ihn nach oben. Für .lin/.pat-Import.
|
||||
*/
|
||||
export function FileImportButton({
|
||||
accept,
|
||||
label,
|
||||
onText,
|
||||
}: {
|
||||
accept: string;
|
||||
label: string;
|
||||
onText: (text: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<>
|
||||
<button className="res-add" onClick={() => ref.current?.click()}>
|
||||
{label}
|
||||
</button>
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept={accept}
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) {
|
||||
const r = new FileReader();
|
||||
r.onload = () => onText(String(r.result ?? ""));
|
||||
r.readAsText(f);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export function drawingGripVertices(d: 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 },
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Wand-Trefferprüfung im Modellraum fürs Grundriss-Kontextmenü — reine
|
||||
// Geometrie-Helfer ohne Komponenten-Bezug, ausgelagert aus App.tsx.
|
||||
|
||||
import type { Project, Vec2 } from "../model/types";
|
||||
import { getWallType, wallTypeThickness } from "../model/types";
|
||||
import { wallCorners } from "../model/geometry";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function hitTestWall(
|
||||
project: Project,
|
||||
floorId: string,
|
||||
visibleCodes: Set<string>,
|
||||
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. */
|
||||
export 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;
|
||||
}
|
||||
@@ -21,24 +21,6 @@ const wasmPath = fileURLToPath(
|
||||
),
|
||||
);
|
||||
|
||||
// opencascade.js (Welle-C-HLR-Spike) verpackt Emscripten-Glue + `.wasm` in
|
||||
// `dist/`, ohne sinnvolle `exports`. Analog zu LibreDWG mappen wir zwei
|
||||
// virtuelle Module auf die echten Dateien, damit Vite die (große) WASM als
|
||||
// gehashtes Asset ausgibt (Dev mit korrektem MIME, Build als Asset). Der Import
|
||||
// im Code ist dynamisch → die WASM bleibt aus dem Haupt-Bundle heraus.
|
||||
const occtGluePath = fileURLToPath(
|
||||
new URL(
|
||||
"./node_modules/opencascade.js/dist/opencascade.wasm.js",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const occtWasmPath = fileURLToPath(
|
||||
new URL(
|
||||
"./node_modules/opencascade.js/dist/opencascade.wasm.wasm",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -51,15 +33,8 @@ export default defineConfig({
|
||||
alias: {
|
||||
"virtual:libredwg-glue": gluePath,
|
||||
"virtual:libredwg-wasm-url": wasmPath + "?url",
|
||||
"virtual:occt-glue": occtGluePath,
|
||||
"virtual:occt-wasm-url": occtWasmPath + "?url",
|
||||
},
|
||||
},
|
||||
// Emscripten-Glue nicht vor-bündeln (esbuild kommt mit dem UMD-Wrapper +
|
||||
// Node-Shims nicht klar); der dynamische Import lädt sie zur Laufzeit.
|
||||
optimizeDeps: {
|
||||
exclude: ["opencascade.js"],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
// Fester Port für die Tauri-Integration (tauri.conf.json zeigt hierauf).
|
||||
|
||||
Reference in New Issue
Block a user