Compare commits
367 Commits
d9cbf2cb07
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e73c684c3 | |||
| 6637533162 | |||
| 3b79e49cc6 | |||
| a6c2c04736 | |||
| 956a85d93f | |||
| f95674aa09 | |||
| 2ac5c27fa7 | |||
| 68a0459d0e | |||
| 0ab0361e3d | |||
| 31b76a2f02 | |||
| 7d368786ba | |||
| a631ddaace | |||
| cc74396854 | |||
| d4575df29f | |||
| e61634c3b9 | |||
| dc241ba63e | |||
| e6a7738170 | |||
| ae47b4f024 | |||
| ee0f1dfb07 | |||
| 9705890fd2 | |||
| 35a6834072 | |||
| 7dc8f0d5c6 | |||
| a84dc7ac73 | |||
| 6b89ad5bdb | |||
| 814e9a8656 | |||
| a51fd212d3 | |||
| bd8612ee74 | |||
| d2758efc8f | |||
| 441e4a319f | |||
| 3e7ad1f4e5 | |||
| fb5ffb457c | |||
| d21af4ac5a | |||
| 4a01754b4d | |||
| ce7454039f | |||
| a5849111e3 | |||
| 3d66dc8967 | |||
| e65a6b7de1 | |||
| 6ac054ea71 | |||
| ed724be481 | |||
| 00bff27b02 | |||
| 61313777da | |||
| 7c3a6f3f35 | |||
| 0240a23a02 | |||
| e309e54af7 | |||
| 13cc6a0d6a | |||
| 3ecb8c45de | |||
| f98c962ba3 | |||
| cdae20acbe | |||
| 1c31e680c1 | |||
| d34e1cb1c1 | |||
| 5f1b38a420 | |||
| 31d7aefcb7 | |||
| bc2778562c | |||
| 06803206e5 | |||
| 38a4d8ca3a | |||
| ee9aeda01e | |||
| a612ae55cd | |||
| c02a02cffe | |||
| 983d061a0c | |||
| 1945fecd11 | |||
| 4c4c9907af | |||
| b96714641a | |||
| 242b850beb | |||
| 7898a0d158 | |||
| 8f85e135ee | |||
| 038054fee6 | |||
| 3980a06a6c | |||
| b86fca2c96 | |||
| fa59c0a9dc | |||
| a6db682e9d | |||
| d43e2c151e | |||
| ef7eb295d9 | |||
| 456ecc5445 | |||
| 975ce3f745 | |||
| 4b0f23d123 | |||
| 61c2a48f52 | |||
| 39bceedc17 | |||
| 3f042eb091 | |||
| a3a6b51db4 | |||
| ce728703f6 | |||
| 340f950f9c | |||
| 0e02c2b20f | |||
| 355c1d4dd1 | |||
| c91b9ab1f2 | |||
| f248e2ae80 | |||
| 9a00c8e2f2 | |||
| 83dcdd0501 | |||
| aee884639e | |||
| cdbef992f3 | |||
| 83abc1d87b | |||
| b029ce0139 | |||
| 37f4fe8947 | |||
| a8a1835b68 | |||
| 897a2dcd43 | |||
| db44317d95 | |||
| c9baff58b0 | |||
| 4319e12e35 | |||
| 3a986ecd2f | |||
| e99bb2413f | |||
| bfb80b363b | |||
| 9a38636bf2 | |||
| d131683304 | |||
| 82d5710ddb | |||
| e3cdc86f46 | |||
| 2e13ec3097 | |||
| c734802555 | |||
| 1bbe814ab1 | |||
| 4ef40a02c4 | |||
| 9b6dd80d39 | |||
| a409e8e6b4 | |||
| 826685ceaf | |||
| 64f61797d8 | |||
| eaf57e2292 | |||
| 7cfdf59c29 | |||
| 8cfe01837e | |||
| 80121a3f9a | |||
| 89112eff9b | |||
| 7b22f8fdf7 | |||
| 38bc36d36d | |||
| 31f2d63362 | |||
| 1195d2a054 | |||
| 89e737b25e | |||
| 4beae72eea | |||
| 586c1c99bf | |||
| 4ac2ecb504 | |||
| 973ac6d04f | |||
| d0b9d22141 | |||
| 938d6421f9 | |||
| 9a65900377 | |||
| 142ba7ec20 | |||
| 492e1f811a | |||
| dbe7d374cd | |||
| fa40429e63 | |||
| 35299307d6 | |||
| 889cbb2c12 | |||
| 9133c0961d | |||
| b640bbe606 | |||
| 0b56d777bc | |||
| 1c09b6e7c2 | |||
| 4b77047916 | |||
| 670823e98d | |||
| 71e75b99f0 | |||
| 3a2cef3886 | |||
| d01cb82a89 | |||
| 8d688b982e | |||
| 5b60136a19 | |||
| 39ddd9b501 | |||
| 9c911e6d43 | |||
| cdc71621c4 | |||
| b9b75f7ad1 | |||
| e82d4b084c | |||
| 12441d36fb | |||
| 2c8ad8fe34 | |||
| 3697b7e2d8 | |||
| a750e87517 | |||
| 456bf29984 | |||
| c40764a25f | |||
| ff68ec3a5c | |||
| 72a40ccaaf | |||
| fe22cbf0df | |||
| 4e3b074f5c | |||
| 033cabe80f | |||
| 456ebc8098 | |||
| 13c3294f4d | |||
| 85011cb8ef | |||
| 3a206060e3 | |||
| 9d6e86d4c0 | |||
| ace0dc62a8 | |||
| fe9f396750 | |||
| 00733d8180 | |||
| 077e774303 | |||
| bd2b12bfb7 | |||
| 5130a050ff | |||
| e454eab1a8 | |||
| 67195d7dd5 | |||
| dd76ec89fd | |||
| 376aa67566 | |||
| 4ac99d37cb | |||
| 45e19b7293 | |||
| c794feef9e | |||
| c5b5ca600c | |||
| 4ef73c9198 | |||
| 4b93ac9cbb | |||
| 05bc5aa6e1 | |||
| d36c84689f | |||
| 8f9123ce0d | |||
| a44572bf7b | |||
| 79ea6b0b3b | |||
| 83da278ed2 | |||
| 33c2c6c22e | |||
| b7551d4930 | |||
| 25daec6be9 | |||
| 5229174551 | |||
| df0e5e432a | |||
| 715e950d7c | |||
| dbf78a9d75 | |||
| 26fbd11b73 | |||
| 6e5998ce72 | |||
| b65c676643 | |||
| ae18766b01 | |||
| c7128fe669 | |||
| b37c9f467b | |||
| 08b0b23a68 | |||
| 4a26c34db1 | |||
| 903dc19cec | |||
| 09c5178c85 | |||
| 8750c8f547 | |||
| 028a3637b4 | |||
| 74eecf7a73 | |||
| 0ca3b1dd57 | |||
| ecefe61611 | |||
| 2d96a864da | |||
| dec431579e | |||
| 9520191bde | |||
| 9136cad0ee | |||
| c7e080f639 | |||
| aa4205bf0b | |||
| 9a80d9cf42 | |||
| ea86cf7456 | |||
| 05cfade483 | |||
| 87a30b7061 | |||
| 23dddf893e | |||
| 45ded83294 | |||
| 77f32f14ce | |||
| 07042ebc75 | |||
| ecf262ef07 | |||
| 9acce9d7cd | |||
| cf8b638f33 | |||
| 20cf870c4c | |||
| e095b51d15 | |||
| 65d6e843fb | |||
| c79d15dda4 | |||
| 6035b8a8c2 | |||
| 6c04935711 | |||
| 8c96a5dfb9 | |||
| 8547d38e9a | |||
| 41d8fafa4e | |||
| 0d3a0a081f | |||
| d1df9d4d5c | |||
| e11743d6e7 | |||
| c54a2f916c | |||
| fde27f6838 | |||
| 00c90857ad | |||
| 018fef56bf | |||
| 1db661f19a | |||
| 6ea562ceda | |||
| e137353b95 | |||
| 16d32223a4 | |||
| 8f4fac633f | |||
| d4065c5a41 | |||
| d0b94a75ec | |||
| 8b6b9291a1 | |||
| 7e8764b21b | |||
| efc9dd87ce | |||
| f724c088e0 | |||
| 44942e6976 | |||
| e0d71691e1 | |||
| 9f6d4e8858 | |||
| 19d002d403 | |||
| 2c9633438d | |||
| 19fcafbcab | |||
| 4aed168760 | |||
| 2eeafb9aca | |||
| 7d3e0943e1 | |||
| d0f26cd874 | |||
| 777e02c927 | |||
| c34b7e5771 | |||
| b55ab9eb8f | |||
| bd13495923 | |||
| 683242ba78 | |||
| e54bb318bb | |||
| a302d512d9 | |||
| f09ba0e49f | |||
| 07475cbe6c | |||
| 87ef7988d1 | |||
| f3966f99e9 | |||
| f3639cfb15 | |||
| 6288f755a8 | |||
| 724db0f9bf | |||
| c2d6893a59 | |||
| c6d2641664 | |||
| 3d4c4985a9 | |||
| 912f0bc16d | |||
| ac7af9b9ef | |||
| 400ec890b0 | |||
| 558a24cf98 | |||
| e29f5a2a7c | |||
| b03614c35b | |||
| 1086225f7b | |||
| ca1ed1b0d2 | |||
| 04c7334cc4 | |||
| 93b70fa95a | |||
| fae4f6fcb6 | |||
| d65d84183a | |||
| 1f6da09b2b | |||
| 5fd0161bc7 | |||
| f4e70902d0 | |||
| af0b044fca | |||
| 692dd3f719 | |||
| 4d721a62f7 | |||
| b028fc6b31 | |||
| 581ddccf1a | |||
| 1fd688896c | |||
| f9a5e763fd | |||
| ec2568276d | |||
| 2b6b99059d | |||
| 658536029b | |||
| 675c68de55 | |||
| e9dc423a12 | |||
| 6aaef8f46a | |||
| eaf6d8ed7b | |||
| d30f79e162 | |||
| bde1f3f8d1 | |||
| 2dd7fe339d | |||
| 3c4d983a7d | |||
| 094d8b4504 | |||
| 330c01bbbd | |||
| d2503c1191 | |||
| e3f460c9f8 | |||
| 28b5492def | |||
| 4a62dbf83c | |||
| 8a79f6cbaa | |||
| 9371b65b3b | |||
| e8f5e7272d | |||
| f95df75ea1 | |||
| 918f60c498 | |||
| 23410f9b9e | |||
| 98994c96aa | |||
| 0e484746de | |||
| 87d12b976f | |||
| 519735c782 | |||
| 47f1c24bb3 | |||
| 1bbcc7038f | |||
| e6466a5027 | |||
| 1ab8c7e496 | |||
| 0e839b50d6 | |||
| 623b243306 | |||
| 6a7bbeec2f | |||
| cbf54e6b65 | |||
| 543d06adb5 | |||
| a3b25da96d | |||
| 2a2b3b294c | |||
| 926dedca40 | |||
| 4311a7dfbd | |||
| 31ac91b2a7 | |||
| 0189eed5ac | |||
| 45f9d0c381 | |||
| faa84e98af | |||
| e82f0b6de1 | |||
| 128c04a5d0 | |||
| 19f99382f6 | |||
| ec181998ca | |||
| c400a96575 | |||
| b4c3c2de4a | |||
| 788f4d58ca | |||
| 229169cea1 | |||
| f4cd16b7ac | |||
| e7ea1eeddd | |||
| c96239a794 | |||
| 260320af2e | |||
| f08ef13fe8 | |||
| 3d2d4d6321 | |||
| cfe5249440 | |||
| b9731a4979 | |||
| ce3b594403 | |||
| 94a5af6b6f | |||
| ca859c4aa4 |
@@ -15,20 +15,3 @@ scripts/*.png
|
|||||||
# Generierte native-Viewport-Szenen (aus sampleProject via scripts/dump-native-scene.mjs)
|
# Generierte native-Viewport-Szenen (aus sampleProject via scripts/dump-native-scene.mjs)
|
||||||
src-tauri/assets/native2d_scene.json
|
src-tauri/assets/native2d_scene.json
|
||||||
src-tauri/assets/native3d_walls.json
|
src-tauri/assets/native3d_walls.json
|
||||||
|
|
||||||
# Interne Doku (Handover/Pendenzen/Design-Notizen) — nur lokal, nicht im
|
|
||||||
# öffentlichen Gitea-Code-Browser. README.md bleibt als Repo-Beschreibung.
|
|
||||||
/ARCHITECTURE.md
|
|
||||||
/CONVENTIONS.md
|
|
||||||
/HANDOVER.md
|
|
||||||
/PENDENZEN.md
|
|
||||||
/PORT_PLAN.md
|
|
||||||
/RESEARCH_BAUTEILE_RHINO.md
|
|
||||||
/RESEARCH_CAD_APPROACHES.md
|
|
||||||
/ROADMAP.md
|
|
||||||
/SPIKE_TEXTUR_render3d.md
|
|
||||||
/STATUS.md
|
|
||||||
/docs/*.md
|
|
||||||
/docs/design/*.md
|
|
||||||
/docs/research/*.md
|
|
||||||
/docs/welle-c-hlr-spike/*.md
|
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Architektur — Dossier (Desktop-CAAD)
|
||||||
|
|
||||||
|
> Stand: 2026-07-21 (grundlegend überarbeitet — siehe [STATUS.md](STATUS.md) für
|
||||||
|
> die volle Bestandsaufnahme inkl. Mist-Liste, die diese Überarbeitung begründet).
|
||||||
|
> Vision/Phasen (historisch, Tag-1-Stand): [ROADMAP.md](ROADMAP.md). Konventionen:
|
||||||
|
> [CONVENTIONS.md](CONVENTIONS.md). Detail-Designs (teils ebenfalls veraltet,
|
||||||
|
> siehe Hinweis in [docs/README.md](docs/README.md)): [docs/design/](docs/design/).
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt, **wie** Dossier tatsächlich gebaut ist — nicht wie
|
||||||
|
es am ersten Tag geplant war. Dossier ist die eigenständige Neuimplementierung
|
||||||
|
des Rhino-Plugins [DOSSIER](https://git.openbureau.ch/karim/dossier): dieselbe
|
||||||
|
Denkweise (Geschosse, Kategorie-Ebenen, mehrschichtige Bauteile,
|
||||||
|
Prioritäts-Verschneidung), aber als **native Desktop-App** mit einem **eigenen
|
||||||
|
typisierten Datenmodell in TypeScript** und **zwei eigenen Rust/WASM-Rendering-
|
||||||
|
Engines** („Nordstern") statt Rhino-Dokument/IronPython.
|
||||||
|
|
||||||
|
**Desktop-Rahmen (plattformabhängig, wegen WebGPU):** auf **macOS Tauri**
|
||||||
|
(WKWebView unterstützt WebGPU), auf **Linux Electron/Chromium** (Tauris
|
||||||
|
Linux-Webview WebKitGTK unterstützt WebGPU nicht zuverlässig — die
|
||||||
|
render2d/render3d-Engines brauchen es). Beide teilen dieselbe React-App und
|
||||||
|
randlose Titelleiste; Laufzeit-Erkennung über `window.__TAURI__` bzw.
|
||||||
|
`window.dossierWindow` (Electron-`contextBridge`). Details: STATUS.md §2.7.
|
||||||
|
|
||||||
|
Alle Bezeichner im Code sind **englisch**; Prosa und UI-Texte sind deutsch.
|
||||||
|
Einheiten intern in **Metern**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Leitprinzip — ein Modell, viele Darstellungen
|
||||||
|
|
||||||
|
Das semantische Gebäudemodell (`Project`) ist die **einzige Wahrheit**. Jede
|
||||||
|
Sicht (3D, Grundriss, Schnitt, Ansicht) ist eine **reine Ableitung** daraus.
|
||||||
|
Darstellung (Detailgrad, Stile, Schraffuren, Overrides) wird **beim Rendern**
|
||||||
|
angewandt, nie in die Geometrie eingebacken. Dieses Prinzip hat sich über drei
|
||||||
|
Wochen und ~125.000 Zeilen Code bewährt und wird strikt gehalten — es ist der
|
||||||
|
einzige Teil der ursprünglichen Architektur-Vision, der **unverändert** Bestand
|
||||||
|
hat. Alle konkreten Technologie-Entscheidungen darunter (Rendering-Engine,
|
||||||
|
State-Store, Schnitt-Mechanismus) sind anders gelaufen als am Tag 1 geplant;
|
||||||
|
Details dazu in [STATUS.md](STATUS.md) §5.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Project (semantisches Modell, JSON) │ ← einzige Wahrheit
|
||||||
|
│ resources · types · drawingLevels · │
|
||||||
|
│ layers · walls/doors/openings/stairs/… │
|
||||||
|
└──────────────┬───────────────────────────┘
|
||||||
|
│ pure derive()
|
||||||
|
┌───────────────────────┼────────────────────────────┬───────────────┐
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
3D-Viewport 2D-Plan (generatePlan) 3D-Live-Schnitt Export
|
||||||
|
Viewport3D (three.js) PlanView (SVG) · glPlan (GL2) render3d/section IFC/DXF/
|
||||||
|
ODER Wasm3DViewport ODER render2d (Rust/WGSL) (Rust, analytisch)PDF/STL
|
||||||
|
(Rust/wgpu, Default)
|
||||||
|
│ │ │ │
|
||||||
|
└────────── alle lesen dieselben joins/components/styles ─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Repo-Struktur (IST-Zustand)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
model/ Project-Schema (types.ts, ~2500 LOC), joins.ts (Wand-Gehrung/
|
||||||
|
-Prio-Stösse), parametricWalls.ts, roomStamp.ts, terrain.ts,
|
||||||
|
geoRebase.ts, sampleProject.ts
|
||||||
|
geometry/ 2D-Kernel (kernel2d.ts: offset/trim/fillet/split — LIVE, siehe
|
||||||
|
§3), ceiling/opening/roomArea/roomBoundary/stair/roof/column.ts,
|
||||||
|
polygonHoles.ts
|
||||||
|
commands/ Rhino-artiges Kommandosystem: types/registry/engine/parseInput.ts
|
||||||
|
+ cmds/ (ein Modul je Kommando: wall, opening, stair, roof,
|
||||||
|
column, room, line/rect/circle/arc, move/mirror/copy/offset/
|
||||||
|
trim/join, extrude, import, terrain, measure, georef, …)
|
||||||
|
tools/ Interaktive Zeichenwerkzeuge, snapping.ts, transform.ts (Grips)
|
||||||
|
compute/ Compute-Boundary: leitet Ops (aktuell nur computeJoins) unter
|
||||||
|
Tauri via `invoke` an Rust weiter, sonst TS-Fallback
|
||||||
|
plan/ generatePlan.ts (2D-Ableitung), PlanView.tsx (SVG + Pan/Zoom/
|
||||||
|
Grips), glPlan/ (eigener WebGL2-Renderer), toSection.ts/
|
||||||
|
toElevation.ts (Schnitt/Ansicht-Ableitung), toWalls3d.ts,
|
||||||
|
toRenderScene.ts (Szene für Rust-render2d), wallMeshCut.ts
|
||||||
|
viewport/ Viewport3D.tsx (three.js, „Free") UND Wasm3DViewport.tsx
|
||||||
|
(Rust/wgpu „Nordstern", Default/editierbar), raycast3d.ts
|
||||||
|
section/ TOTER Code (OCCT-WASM-HLR-Spike, keine Aufrufer mehr) — Schnitt
|
||||||
|
läuft über render3d/section.rs, siehe §4.3
|
||||||
|
export/ exportIfc.ts (IFC4), exportDxf.ts/dxfWriter.ts, exportPdf.ts,
|
||||||
|
layoutPdf.ts (Mehrseiten-PDF pro Ordner), exportMesh.ts (STL/OBJ),
|
||||||
|
exportSchedule.ts (CSV-Bauteilliste), sceneToPrintSvg.ts
|
||||||
|
materials/ ambientcg.ts (Live-Suche ambientCG-API), library.ts (13
|
||||||
|
gebündelte Starter-Materialien), runtime.ts (PBR-Material aus
|
||||||
|
ComponentMaterial via three.js TextureLoader)
|
||||||
|
io/ DXF/DWG-Import, Swisstopo (swissBUILDINGS3D/-ALTI3D/SWISSIMAGE,
|
||||||
|
LV95↔WGS84), OSM-Overpass, .lin/.pat-Parser, projectFile.ts
|
||||||
|
(.obp Speichern/Laden, Tauri-Lock)
|
||||||
|
overrides/ Regelbasierte Override-Engine (Bedingung → Aktion)
|
||||||
|
state/ Eigener Store auf `useSyncExternalStore` (KEIN Zustand/Redux):
|
||||||
|
store.ts + Slices (project/history/selection/view/layout/site/
|
||||||
|
notify), appStore.ts komponiert sie
|
||||||
|
panels/ Dock-/Floating-Panel-System (Dock/FloatingPanel/TabStrip/
|
||||||
|
registry/layout) + Panels (Tools, Attributes, ObjectInfo, Layers,
|
||||||
|
DrawingLevels, Site, RoomBalance, Elements, ViewSnapshots, Layouts)
|
||||||
|
ui/ App.tsx (Shell, **7.130 LOC — noch nicht auf dünne Shell
|
||||||
|
reduziert**, siehe STATUS.md §4.3), TopBar, ResourceManager.tsx
|
||||||
|
(Material-/Hatch-/Line-/Typ-Editoren, natives Fenster),
|
||||||
|
ContextMenu, CommandLine.tsx, LayoutSheet/-Menu, ribbon/
|
||||||
|
native/ NUR Tauri: native Fenster (Resources/Settings/DrawingLevels/
|
||||||
|
LayerSettings/ContextImport), macOS-Menüleiste, Fenster-Chrome —
|
||||||
|
jede Funktion no-opt via `isTauriRuntime()` im Browser
|
||||||
|
editors/ booleanOps.ts (2D-Boolean via polygon-clipping), splitJoin.ts
|
||||||
|
text/ Rich-Text (richText.ts, RichTextEditor.tsx, renderHtml.ts)
|
||||||
|
theme/ Hell/Dunkel, Akzentfarben
|
||||||
|
i18n/ de.ts/en.ts Wörterbücher, eigener t()-Mechanismus
|
||||||
|
engine/ Reine WASM-Lade-Glue: engine3d.ts (pkg3d), truckSolid.ts
|
||||||
|
(pkgTruck), plan/useWasmPlanRenderer.ts (pkg) — pkgGeometry und
|
||||||
|
pkgDwgImport sind gebaut, aber unbenutzt (siehe STATUS.md §4.1)
|
||||||
|
|
||||||
|
src-tauri/
|
||||||
|
src/ Tauri-Host (Fenster, native Dialoge, fs4-Exklusiv-Lock)
|
||||||
|
render2d/ 2D-Plan-GPU-Renderer (wgpu/WGSL, + glyphon-Text), auch → WASM
|
||||||
|
render3d/ „Nordstern" 3D-Engine (wgpu/WGSL): Wandextrusion + Schicht-
|
||||||
|
bänder + Gehrung, LIVE 2D=3D-Schnitt (section*.rs, analytisch,
|
||||||
|
kein HLR), Kanten-Extraktion, Materialtextur-Arrays,
|
||||||
|
Aerial-Drape, Render-Styles (Shaded/White/Textured/Wireframe/
|
||||||
|
Hidden/ShadedEdges) — auch → WASM
|
||||||
|
kernel2d/ Rust-Port von geometry/kernel2d.ts — NUR Paritätstest, nicht
|
||||||
|
produktiv (WASM war < 100 Wänden langsamer als TS)
|
||||||
|
geometry/ Wand-Join-Mathe — Rust-Port, UNBENUTZT (kein Aufrufer)
|
||||||
|
trucksolid/ CSG/Extrusion (`truck`-Crate + `csgrs`-Booleans) → WASM,
|
||||||
|
genutzt vom Extrude-Kommando; Boolean NICHT an Wände/
|
||||||
|
Öffnungen angeschlossen
|
||||||
|
dwgimport/ DXF-Parser-Spike (`acadrust`) → WASM, UNBENUTZT (DWG-Import
|
||||||
|
läuft über npm `@mlightcad/libredwg-web`)
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes Crate unter `src-tauri/` ausser dem Host ist ein **eigenständiges
|
||||||
|
Cargo-Package** (kein gemeinsamer Workspace), das sowohl headless
|
||||||
|
(`cargo test`) als auch per `wasm-pack --features web` baut und dann via
|
||||||
|
`src/engine/pkg*/` von der TS-Seite geladen wird (`npm run build:engine{,3d,
|
||||||
|
Geometry,Kernel2d,DwgImport}`/`build:truck`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datenmodell
|
||||||
|
|
||||||
|
### 2.1 Zwei unabhängige Achsen (unverändert gegenüber der Vision)
|
||||||
|
|
||||||
|
1. **Zeichnungsebenen** (`DrawingLevel`) — Geschoss (`kind:"floor"`,
|
||||||
|
`floorHeight`/`cutHeight`/`baseElevation`), Schnitt/Ansicht
|
||||||
|
(`kind:"section"|"elevation"`), oder freie Zeichnung (`kind:"drawing"`).
|
||||||
|
2. **Ebenen** (`LayerCategory`) — das Grafik-Kategorie-Schema (Baum,
|
||||||
|
`{code, name, color, lw, visible, locked, hatch?, children}`), in jedem
|
||||||
|
Geschoss gültig. Codes 1:1 aus DOSSIER (`00 Raster · 01 Vermessung ·
|
||||||
|
20 Wände (└21 Türen/Fenster, 25 Stützen) · 30 Decken · 31 Dächer ·
|
||||||
|
40 Treppen · 50 Text · 60 Räume · 80 Plangrafik …`).
|
||||||
|
|
||||||
|
### 2.2 Elemente — typisierte Arrays statt `Element[]`-Union
|
||||||
|
|
||||||
|
Anders als ursprünglich geplant hält `Project` (`src/model/types.ts:2095`)
|
||||||
|
**pro Bauteiltyp ein eigenes (meist optionales) Array**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Project {
|
||||||
|
walls: Wall[];
|
||||||
|
ceilings?: Ceiling[];
|
||||||
|
roofs?: Roof[];
|
||||||
|
doors: Door[]; // ÄLTER — siehe openings; bewusste Doppelspur
|
||||||
|
openings?: Opening[]; // NEUER, allgemeiner: kind:"window"|"door"
|
||||||
|
stairs?: Stair[];
|
||||||
|
rooms?: Room[];
|
||||||
|
columns?: Column[];
|
||||||
|
extrudedSolids?: ExtrudedSolid[];
|
||||||
|
drawings2d: Drawing2D[];
|
||||||
|
context?: ContextObject[]; // Terrain/Importe — NICHT semantisch
|
||||||
|
parametricWalls?: ParametricWall[];
|
||||||
|
overrideRules?: OverrideRule[];
|
||||||
|
viewSnapshots?: ViewSnapshot[];
|
||||||
|
viewSnapshotFolders?: ViewSnapshotFolder[];
|
||||||
|
layouts?: Layout[];
|
||||||
|
masterLayouts?: MasterLayout[];
|
||||||
|
layoutFolders?: LayoutFolder[];
|
||||||
|
// + Bibliotheken: lineStyles, hatches, components, wallTypes, roofTypes?,
|
||||||
|
// doorTypes?, windowTypes?, stairTypes?, ceilingTypes?
|
||||||
|
// + drawingLevels, layers, geoAnchor?, referenceElevationMasl?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein `Element`-Typalias existiert noch (`kind:"door"|"window"` etc.), wird aber
|
||||||
|
nirgends im Code referenziert — Selektion/Element-Baum arbeiten direkt auf den
|
||||||
|
typisierten Arrays. `doors`/`openings` sind eine **bekannte, noch nicht
|
||||||
|
konsolidierte Doppelspur** (PENDENZEN.md).
|
||||||
|
|
||||||
|
### 2.3 Ressourcen & Aufbauten (unverändert gegenüber der Vision)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Resources { lineStyles: LineStyle[]; hatches: Hatch[]; components: Component[]; }
|
||||||
|
interface WallType { id; name; layers: { componentId; thickness }[]; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`joinPriority` sitzt am `Component` (Daten statt Hardcode) und steuert die
|
||||||
|
Prioritäts-T-/X-Verschneidung — **fertig implementiert**, inklusive Rust-Port
|
||||||
|
für den 3D-Live-Schnitt (`section_boolean.rs` = Port von
|
||||||
|
`toSection.ts::subtractDominantBands`).
|
||||||
|
|
||||||
|
### 2.4 Layouts / Ausschnitte
|
||||||
|
|
||||||
|
`ViewSnapshot` (Kamera/Massstab/Detailgrad/Sichtbarkeiten/Override-Preset) in
|
||||||
|
`ViewSnapshotFolder`-Bäumen; `Layout` (Papierformat, mehrere
|
||||||
|
`LayoutViewport`s, freie `LayoutAnnotation`s, optionale `MasterLayout`-Vererbung)
|
||||||
|
in `LayoutFolder`-Bäumen. Beide gehören ins Dokument (nicht LocalStorage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State, Persistenz, Undo/Redo
|
||||||
|
|
||||||
|
### 3.1 Store — eigener `useSyncExternalStore`, nicht Zustand
|
||||||
|
|
||||||
|
Entgegen der ursprünglichen Empfehlung (Zustand-Library) wurde ein
|
||||||
|
**abhängigkeitsfreier Store** gebaut (`src/state/store.ts`, gleiches Muster wie
|
||||||
|
`src/i18n`): `createStore()` komponiert Slice-Fabriken über eine gemeinsame
|
||||||
|
`RootState`. `appStore.ts` fügt zusammen: `projectSlice` (Projekt + Undo/Redo,
|
||||||
|
`setProject` als einziger Mutations-Einstiegspunkt), `historySlice`,
|
||||||
|
`selectionSlice`, `viewSlice`, `layoutSlice`, `siteSlice`, `notifySlice`
|
||||||
|
(Toast/Confirm-Ersatz für `window.alert`, in Tauris WKWebView deaktiviert).
|
||||||
|
Komponenten lesen über `useStore(selector)`.
|
||||||
|
|
||||||
|
**Nicht umgesetzt** (geplant in `docs/design/state-architecture.md`): die
|
||||||
|
Extraktion von View-Routing nach `src/views/` und Kontextmenü-Aufbau nach
|
||||||
|
`src/menus/` — beide Ordner existieren nicht, diese Logik liegt weiterhin
|
||||||
|
inline in `App.tsx` (7.130 Zeilen). Siehe STATUS.md §4.3.
|
||||||
|
|
||||||
|
### 3.2 Persistenz
|
||||||
|
|
||||||
|
- **Datei:** eigenes `.obp`-Format (`src/io/projectFile.ts`), unter Tauri über
|
||||||
|
native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), im Browser
|
||||||
|
Blob-Fallback. Ältere `.json`-Projekte bleiben ladbar.
|
||||||
|
OS-Level-Exklusiv-Lock (`fs4`-Crate, `src-tauri/src/lock.rs`) verhindert
|
||||||
|
Doppelöffnen desselben Projekts.
|
||||||
|
- **Compute-Boundary:** `src/compute/index.ts` leitet einzelne Operationen
|
||||||
|
(aktuell nur `computeJoins`) unter Tauri via `invoke` an eine native Rust-
|
||||||
|
Implementierung weiter; im Browser bzw. für nicht migrierte Ops (Room-
|
||||||
|
Detection, DWG-Parsing) läuft die TS-Implementierung.
|
||||||
|
|
||||||
|
### 3.3 Undo/Redo
|
||||||
|
|
||||||
|
Eigener History-Ring im `projectSlice` (kein DOSSIER-Sticky-Bus, kein
|
||||||
|
Cache-Stale-Problem, da alle Sichten pure Ableitungen sind).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rendering
|
||||||
|
|
||||||
|
### 4.1 Zwei 3D-Viewports
|
||||||
|
|
||||||
|
- **`Viewport3D.tsx`** — three.js, die „Free"-Stufe.
|
||||||
|
- **`Wasm3DViewport.tsx`** — Rust/wgpu, „Nordstern", editierbar, **Default**.
|
||||||
|
Ein Settings-Schalter wählt die Engine; WebGL2/three.js nur noch als
|
||||||
|
explizite Wahl oder Fallback.
|
||||||
|
|
||||||
|
`render3d` (10.246 LOC) baut Wandextrusion inkl. Schichtbändern und
|
||||||
|
Prioritäts-Gehrung (`compute_wall_miters`), Materialtextur-Arrays (pro
|
||||||
|
Wandschicht) + separate Aerial-Drape-Textur, Render-Styles (Shaded/White/
|
||||||
|
Textured/Wireframe/Hidden/ShadedEdges via Kanten-Extraktion mit
|
||||||
|
Crease-Erkennung). **Dächer/Treppen/Stützen haben KEINE eigene Rust-Geometrie**
|
||||||
|
— sie werden vollständig in TypeScript erzeugt (`geometry/roof.ts`,
|
||||||
|
`emitRoofs`/`emitColumns`) und Rust nur als vorberechnetes Dreiecks-Mesh
|
||||||
|
(`MeshInput`/`append_context_mesh`, derselbe generische Importpfad wie für
|
||||||
|
swissBUILDINGS3D/DXF) übergeben.
|
||||||
|
|
||||||
|
### 4.2 Grundriss = drei koexistierende Renderer
|
||||||
|
|
||||||
|
1. **`PlanView.tsx`** (SVG) — bleibt immer im DOM (Hit-Testing/Grips),
|
||||||
|
unabhängig vom aktiven Zeichenpfad.
|
||||||
|
2. **`plan/glPlan/`** — eigener TypeScript-WebGL2-Renderer.
|
||||||
|
3. **`useWasmPlanRenderer.ts`** → Rust-`render2d` (WGSL, wgpu nativ,
|
||||||
|
WebGPU/WebGL2-Fallback im Web, echtes Text-Rendering via `glyphon`).
|
||||||
|
|
||||||
|
Beide GPU-Pfade fallen bei Init-Fehler still auf SVG zurück.
|
||||||
|
|
||||||
|
### 4.3 Schnitt/Ansicht — analytische Rust-Pipeline, KEIN HLR
|
||||||
|
|
||||||
|
Der ursprünglich geplante OCCT-WASM-HLR-Pfad (`src/section/hlr.ts`/`occt.ts`)
|
||||||
|
ist **toter Code** (keine Aufrufer mehr). Der tatsächlich funktionierende
|
||||||
|
Live-Schnitt nutzt aus, dass jedes Bauteil ein Prisma mit konstantem
|
||||||
|
Querschnitt ist — eine Schnittebene liefert dadurch **immer** ein
|
||||||
|
achsparalleles Rechteck, nie ein Trapez, wodurch HLR unnötig wird:
|
||||||
|
|
||||||
|
```
|
||||||
|
App.tsx (section3dCutId/section3dPlane)
|
||||||
|
→ Wasm3DViewport.tsx (section3d-Prop → setSectionPlane)
|
||||||
|
→ src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}
|
||||||
|
```
|
||||||
|
|
||||||
|
`section_boolean.rs` ist ein 1:1-Port von `toSection.ts::subtractDominantBands`
|
||||||
|
— 2D-Plan-Schnitt und 3D-Live-Schnitt nutzen dieselbe Prioritäts-Logik und
|
||||||
|
stimmen dadurch exakt überein. Kein Worker, kein Comlink (beides war geplant,
|
||||||
|
existiert nirgends im Projekt) — läuft synchron GPU-seitig.
|
||||||
|
|
||||||
|
### 4.4 Öffnungen als Löcher (kein Mesh-Boolean)
|
||||||
|
|
||||||
|
Fenster/Türen schneiden echte achsparallele Rechteck-Löcher aus dem Wandkörper
|
||||||
|
(`plan/toWalls3d.ts` `subtractSpans`, gespiegelt in `render3d::mesh.rs`).
|
||||||
|
Ein generisches Mesh-CSG-Boolean existiert bereits (`trucksolid::boolean_mesh`,
|
||||||
|
`csgrs`, für das Extrude-Kommando genutzt), ist aber nicht an die
|
||||||
|
Wand/Öffnungs-Pipeline angeschlossen.
|
||||||
|
|
||||||
|
### 4.5 Materialien
|
||||||
|
|
||||||
|
`materials/library.ts` (13 gebündelte PBR-Starter, ambientCG CC0) +
|
||||||
|
`materials/ambientcg.ts` (Live-Suche der kompletten ambientCG-Bibliothek,
|
||||||
|
1K/2K/4K-Auflösungswahl, On-Demand-Download via `jszip`, Proxy wegen CORS) →
|
||||||
|
`materials/runtime.ts` baut daraus gecachte `THREE.MeshStandardMaterial`s mit
|
||||||
|
physisch korrekter Kachelgrösse (UV in Weltmetern).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. React-Panel-Struktur
|
||||||
|
|
||||||
|
Dock-/Floating-Panel-System (`src/panels/`: Dock, FloatingPanel, TabStrip,
|
||||||
|
Registry) mit eingebauten Panels: Tools, Attributes, ObjectInfo,
|
||||||
|
DrawingLevels, Layers, Site (Kontext/Terrain-Import), RoomBalance
|
||||||
|
(SIA-416-CSV), Elements (Bauteilbaum), ViewSnapshots, Layouts.
|
||||||
|
|
||||||
|
Der **Resource Manager läuft bewusst NICHT als Dock-Panel**, sondern als
|
||||||
|
eigenständiges (unter Tauri natives) Fenster — ebenso Settings,
|
||||||
|
DrawingLevels-Detaileditor, LayerSettings und ContextImport
|
||||||
|
(`src/native/*Window.ts` + `*WindowApp.tsx`), jeweils mit
|
||||||
|
`isTauriRuntime()`-Gate und ohne separate Browser-Variante (im Browser bleibt
|
||||||
|
die entsprechende In-App-Overlay-Variante aktiv, wo vorhanden).
|
||||||
|
|
||||||
|
**Werkzeug-System:** `Tool`-Interface (`src/tools/types.ts`) für Zeichenwerkzeuge
|
||||||
|
mit Snap-Engine; daneben das umfangreichere **Kommandosystem** (`src/commands/`)
|
||||||
|
für Rhino-artige getippte Eingabe (`5,3`/`r5,3`/`5<45`, Tab-Feld-Zyklus in
|
||||||
|
`CommandLine.tsx`) — beide koexistieren, decken unterschiedliche
|
||||||
|
Interaktionsstile ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rhino → Dossier — Mapping-Tabelle (Kernkonzepte)
|
||||||
|
|
||||||
|
| DOSSIER (Rhino-Plugin) | Dossier (Tauri) | Anmerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Rhino `RhinoDoc` | `Project` (TS-Objekt im eigenen Store) | einzige Wahrheit |
|
||||||
|
| `doc.Strings[key]=json` | Feld im `Project`-JSON | persistiert in `.obp` |
|
||||||
|
| `.3dm`-Datei | **`.obp`**-Datei (Tauri `plugin-fs`) | + Blob-Fallback im Browser |
|
||||||
|
| `sc.sticky` (cross-modul Bus) | eigener `useSyncExternalStore`-Store | kein Polling |
|
||||||
|
| Rhino Layer-Tabelle | `LayerCategory[]`-Baum, Sichtbarkeit als `.visible`-Flag | pro Renderer umgesetzt |
|
||||||
|
| Clipping-Plane (`AddClippingPlane`) | `render3d::section.rs` (analytisch, Rechteck-Subtraktion) | KEIN HLR |
|
||||||
|
| `HLRBRep` | — (ungenutzt; OCCT-Spike ist toter Code) | ersetzt durch obiges |
|
||||||
|
| `Rhino.Geometry.Brep`-Booleans | `trucksolid::boolean_mesh` (csgrs) | existiert, nicht an Wände angeschlossen |
|
||||||
|
| Rhino-Grips + DisplayConduit | Pointer-Events + Grip-Overlay (2D vollständig, 3D nur Basis, kein Snap) | siehe STATUS.md §4.7 |
|
||||||
|
| Swisstopo via .NET HttpClient | `fetch()` (CORS-offen) | radiusgenau zugeschnitten (nicht ganze STAC-Kachel) |
|
||||||
|
| IronPython-Laufzeit-Risiken | entfällt | TS/Rust |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was übernommen wurde — und was bewusst anders lief
|
||||||
|
|
||||||
|
**Übernommen (Prinzipien, bewährt):**
|
||||||
|
- Zwei-Achsen-Dokumentmodell, Layer-Codes 1:1, Component/Hatch/Line-Manager
|
||||||
|
mit `joinPriority`, LoD (grob/mittel/fein), regelbasierte Overrides,
|
||||||
|
Ausschnitte, SIA-416-Räume, Norden-Rotation.
|
||||||
|
- Pure-Ableitungs-Architektur (kein Cache-Stale, kein Sticky-Bus).
|
||||||
|
|
||||||
|
**Anders gelaufen als geplant (siehe STATUS.md §5 für die volle Tabelle):**
|
||||||
|
- Eigene Rust/WASM-Rendering-Engines statt Three.js/OpenCascade.js/web-ifc.
|
||||||
|
- Typisierte Arrays pro Bauteiltyp statt `Element[]`-Union.
|
||||||
|
- Eigener Store statt Zustand-Library.
|
||||||
|
- Analytische Rust-Schnitt-Pipeline statt HLR/Worker/Comlink.
|
||||||
|
- App.tsx wurde **nicht** wie geplant auf eine dünne Shell reduziert
|
||||||
|
(`src/views/`/`src/menus/` wurden nie angelegt) — bekannter, unbereinigter
|
||||||
|
Punkt, siehe STATUS.md §4.3.
|
||||||
|
|
||||||
|
**Anti-Over-Engineering (weiterhin gültig):** keine Abstraktion ohne konkretes
|
||||||
|
Problem; erst lesen, dann editieren; Geometrie nie „nebenbei" refactoren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Reihenfolge bei Code-Arbeit
|
||||||
|
|
||||||
|
1. **STATUS.md** (aktueller Ist-Zustand) + dieses Dokument lesen.
|
||||||
|
2. **Das betroffene Modul** lesen (nicht raten) — bei Zweifel: welcher der
|
||||||
|
mehreren Renderer/Pfade ist gerade aktiv (§4.1/§4.2)?
|
||||||
|
3. **Erst danach editieren**; `Project` immer immutabel via den Store ändern.
|
||||||
|
4. **Verifizieren:** `npx tsc -b`, `npm test` (Vitest), `cargo test` in den
|
||||||
|
betroffenen Crates, bei Rust-Änderungen `npm run build:engine{,3d}` (WASM
|
||||||
|
neu bauen, nur `.rs` committen — `src/engine/pkg*/` ist gitignored). Bei
|
||||||
|
render3d/Wasm3DViewport-Änderungen testet der Nutzer selbst in der
|
||||||
|
Tauri-Dev-App (nicht per Puppeteer/Browser verifizierbar).
|
||||||
|
5. **Dieses Dokument aktuell halten**, wenn sich Patterns ändern — insbesondere
|
||||||
|
nicht wieder in „Ziel-Struktur"-Beschreibungen abdriften, die Monate lang
|
||||||
|
niemand nachführt.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Projekt-Konventionen — Dossier
|
||||||
|
|
||||||
|
Siehe [ARCHITECTURE.md](ARCHITECTURE.md) für die aktuelle Architektur,
|
||||||
|
[STATUS.md](STATUS.md) für den vollständigen Ist-Zustand und
|
||||||
|
[ROADMAP.md](ROADMAP.md) für die ursprüngliche (historische) Produktvision.
|
||||||
|
|
||||||
|
## Code-Konventionen (verbindlich)
|
||||||
|
|
||||||
|
- **Alle Bezeichner im Code sind ENGLISCH** — Funktionen, Variablen, Typen, Felder,
|
||||||
|
Datei-/Modulnamen. Keine deutschen Bezeichner. (Beispiel: `computeJoins`, nicht
|
||||||
|
`verschneidungBerechnen`.)
|
||||||
|
- **UI-Texte und Kommentare dürfen Deutsch sein** (Nutzeroberfläche ist deutsch).
|
||||||
|
- **Domänen-Begriffe** möglichst nach Vectorworks-Terminologie benennen (englisch):
|
||||||
|
Design Layer, Sheet/Drawing Layer, Component, Class, Hatch, Wall Style, Viewport.
|
||||||
|
- **Einheiten:** intern alles in **Metern** (number). Anzeige via `formatM`.
|
||||||
|
- **Geometrie-Konventionen:** Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`; bei
|
||||||
|
CCW-Wicklung zeigt `+n` nach innen. Schichten werden außen (−T/2) → innen (+T/2)
|
||||||
|
gestapelt.
|
||||||
|
|
||||||
|
## Code-Struktur (kein God-Component — bisher nur teilweise erreicht)
|
||||||
|
|
||||||
|
- **Ziel:** `App.tsx` bleibt ein dünner Shell (Store-Provider, Oberleiste,
|
||||||
|
Docks+View-Router, Statusleiste, Floating-Panels, Ressourcen-Overlay) — keine
|
||||||
|
Geschäftslogik darin. **Realität (Stand 2026-07-21):** `App.tsx` ist mit
|
||||||
|
~7.100 Zeilen die grösste Datei des Projekts und enthält weiterhin
|
||||||
|
View-Umschaltung und Kontextmenü-Aufbau inline — die geplante Auslagerung
|
||||||
|
nach `src/views/`/`src/menus/` (unten) ist **nie passiert**, siehe
|
||||||
|
[STATUS.md](STATUS.md) §4.3. Neue, grössere Features sollten trotzdem nicht
|
||||||
|
weiter in `App.tsx` wachsen; wo möglich in `src/panels/`, `src/editors/`
|
||||||
|
oder ein neues Modul auslagern statt die Datei weiter zu vergrössern.
|
||||||
|
- **Globaler Zustand in einem Store** (`src/state/`, eigener Store auf
|
||||||
|
`useSyncExternalStore` — **kein** Zustand/Redux/Immer — mit Slices:
|
||||||
|
project/history/selection/view/layout/site/notify). Komponenten lesen
|
||||||
|
Zustand über `useStore(selector)` statt Prop-Drilling.
|
||||||
|
- **Features als eigene Module:** `src/editors/` (Inline-Editoren),
|
||||||
|
`src/panels/`, `src/ui/`. `src/views/` und `src/menus/` waren geplant, wurden
|
||||||
|
aber nie angelegt — bei Bedarf gilt das als offener Aufräum-Punkt, nicht als
|
||||||
|
bestehende Struktur.
|
||||||
|
- Ziel: modular + parallel bearbeitbar (verschiedene Features ≠ dieselbe Datei).
|
||||||
|
|
||||||
|
## UI-Konventionen
|
||||||
|
|
||||||
|
- **Listen-/Manager-Ansichten als saubere Tabellen:** eine Kopfzeile mit
|
||||||
|
Spaltentiteln (sticky), darunter kompakte Datenzeilen mit Inline-Edit pro Zelle.
|
||||||
|
KEINE wiederholten Feld-Beschriftungen pro Zeile. Gilt für Component-/Hatch-/
|
||||||
|
Line-Manager und ähnliche Listen.
|
||||||
|
- Dunkler DOSSIER-Stil; kompakt, ruhig, viel Inhalt pro Fläche.
|
||||||
|
- **UI-Text immer übersetzbar (i18n):** KEINE hartcodierten sichtbaren Strings im
|
||||||
|
JSX. Alle Texte über eine Übersetzungsfunktion `t('key')` aus einem Wörterbuch
|
||||||
|
(Default-Sprache Deutsch). Keys wie bei DOSSIER (`common.delete`, `layers.settings`,
|
||||||
|
`topbar.resources`). Neue Komponenten gleich mit `t(...)` schreiben. Identifier/Keys
|
||||||
|
bleiben englisch; nur die Wörterbuch-Werte sind die übersetzbaren Texte.
|
||||||
|
|
||||||
|
## Native-App-Verhalten (kein Browser-Standard)
|
||||||
|
|
||||||
|
Die App soll sich wie ein natives Programm anfühlen, nicht wie eine Webseite:
|
||||||
|
- **Browser-Kontextmenü global unterdrücken** (`document` `contextmenu` → `preventDefault`).
|
||||||
|
Nur unser eigenes `ContextMenu` erscheint; auf Flächen ohne eigenes Menü passiert nichts.
|
||||||
|
- **Keine Textauswahl / „Alles markieren":** `user-select: none` global; `user-select: text`
|
||||||
|
NUR in echten Eingaben (`input`, `textarea`, `[contenteditable]`). Ctrl+A außerhalb von
|
||||||
|
Eingaben unterbinden.
|
||||||
|
- Bild-/Element-Drag aus (`draggable=false` wo nötig); keine Browser-Drag-Gesten.
|
||||||
|
|
||||||
|
## Architektur-Prinzip
|
||||||
|
|
||||||
|
Ein **semantisches Modell** ist die einzige Wahrheit; jede Ansicht (3D, Grundriss,
|
||||||
|
Schnitt) wird **abgeleitet**. Darstellung (Detailgrad, Stile, Schraffuren) wird beim
|
||||||
|
Rendern angewandt, nie in die Geometrie eingebacken.
|
||||||
|
|
||||||
|
## Arbeitsweise (für Beiträge)
|
||||||
|
|
||||||
|
- Substanzielle, mehrstufige Arbeit schrittweise in isolierten Schritten angehen.
|
||||||
|
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
|
||||||
|
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
|
||||||
|
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
|
||||||
|
- Dev-Server läuft via `npm run dev` (Vite, Port 5187). Nativer Rahmen
|
||||||
|
plattformabhängig: `npm run tauri:dev` auf macOS, `npm run electron` auf Linux
|
||||||
|
(WebKitGTK kann kein zuverlässiges WebGPU → dort Chromium/Electron).
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
> **Hinweis (2026-07-21):** Dieser Block (Stand 07-09) ist nicht mehr der
|
||||||
|
> neueste Stand — `git log`/[PENDENZEN.md](PENDENZEN.md) reichen bis 07-20/17.
|
||||||
|
> Für den aktuellen Ist-Zustand siehe [STATUS.md](STATUS.md). HANDOVER.md bleibt
|
||||||
|
> ein Append-Log (ältere Sessions unten); es wird nicht rückwirkend aktualisiert
|
||||||
|
> — bei neuen Übergaben oben einen neuen Block ergänzen, nicht diesen editieren.
|
||||||
|
|
||||||
|
# HANDOVER — Stand 2026-07-09 (autonome Session, ALLES committet)
|
||||||
|
|
||||||
|
Lange autonome Session (Nutzer-Auftrag: „arbeite die Pendenzen/Funktionen durch,
|
||||||
|
selbstständig committen, nicht mehr nachfragen"). **Abweichung vom bisherigen
|
||||||
|
Protokoll: in dieser Session wurde committet** (ausdrücklich beauftragt). Keine
|
||||||
|
KI-Spuren, deutsche Commits, keine Co-Authored-By-Trailer.
|
||||||
|
|
||||||
|
## Verifikations-Baseline (zuletzt grün)
|
||||||
|
`npx tsc --noEmit` sauber · `npx vitest run` **620**. (Rust/WASM unverändert →
|
||||||
|
kein Neubau nötig.) Der akkumulierte, über frühere Sessions gewachsene grüne
|
||||||
|
Stand wurde zuerst als EIN Basis-Commit `3529930` gelandet, danach jedes Feature
|
||||||
|
einzeln.
|
||||||
|
|
||||||
|
## In dieser Session gebaut + committet (neueste zuerst)
|
||||||
|
- **Dächer** (`1195d2a` Modell+Geometrie, `31f2d63` Werkzeug+2D+3D): neues
|
||||||
|
Roof-Element + `geometry/roof.ts` (roofGeometry für Flach/Pult/Sattel/Walm/
|
||||||
|
Mansarde/Zelt, auf der Umriss-BBox, First entlang X/Y). Befehl „Dach" (BIM-
|
||||||
|
Ribbon, Alias dach/rf): Rechteck aufziehen, Dachform als Inline-Option wählbar.
|
||||||
|
2D-Plan (Traufe/First/Grat/Knick, generatePlan) + 3D-Flächen (emitRoofs,
|
||||||
|
terrakotta). Demo-Satteldach RF1 im Seed. +19 Tests. **OFFEN (Folge-Increment):
|
||||||
|
Auswahl/Attribut-Editieren/Löschen platzierter Dächer** (Form/Neigung/Überstand
|
||||||
|
im Panel ändern, Klick-Auswahl, Delete) — mirror des Decken-Selektionspfads
|
||||||
|
über ~12 App.tsx-Stellen + PlanView-Pick + selectionInfo + host + Panel.
|
||||||
|
- **Fenster/Tür-Feedback** (`4beae72`, `89e737b`): (a) Fenster wirkten im 3D
|
||||||
|
flach → Rahmen füllt jetzt die VOLLE Wanddicke (tiefe Laibung), Scheibe dünn
|
||||||
|
mittig; Ursache war frameThickness (Profilbreite) fälschlich als Einbautiefe.
|
||||||
|
(b) Detailgrad grob/mittel/fein wirkt jetzt auch im 3D (grob=Loch+Scheibe,
|
||||||
|
mittel=Rahmen, fein=+Sprossen), durchgereicht bis projectToModel3d({detail}).
|
||||||
|
(c) Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige).
|
||||||
|
(d) **Kernursache „sieht im 3D gar nicht so aus": platzierte Öffnungen bekamen
|
||||||
|
keinen typeId** → resolveOpeningFrame lieferte null → keine Rahmen. appendOpening
|
||||||
|
weist jetzt den ersten Fenster-/Türtyp zu.
|
||||||
|
- **Öffnung ⚙-Knopf** (`586c1c9`): springt in den Tür-/Fenstertyp-Editor.
|
||||||
|
- **Decken-Griffe im 3D** (`973ac6d`): gewählte Decken haben im wgpu-Viewport
|
||||||
|
Eckpunkt- + Kanten-Mittelpunkt-Griffe (rautenförmig) + Verschiebe-Griff →
|
||||||
|
moveCeilingGrip/Edge/By. Prop-Kette Wasm3DViewport←Viewport3D←App. three.js-
|
||||||
|
Sicht unverändert (Default wgpu). **Visuell noch NICHT in Tauri abgenommen.**
|
||||||
|
- **Text-Styling-Leiste** (`d0b9d22`): selektierter Freitext/Textspalte ist jetzt
|
||||||
|
Formatier-Ziel der Oberleiste (Schrift/fett/kursiv/Farbe via Drawing2D-Text.marks;
|
||||||
|
Grösse bleibt über Modell-Höhe, nicht pt). generatePlan/PlanView rendern die Marks.
|
||||||
|
- **Schichttrennlinie als Wand-Referenzlinie** (`938d642`): Wall.referenceOffset
|
||||||
|
(freier Achsversatz) übersteuert left/center/right; Object-Info-Dropdown listet
|
||||||
|
je interne Fuge einen Eintrag. wallReferenceOffset bleibt EINE Quelle (2D/Schnitt/3D).
|
||||||
|
- **Mess-Werkzeug** (`492e1f8`): Polygonzug mit Länge + FLÄCHE (ab 3 Ecken),
|
||||||
|
Live-Werte dauerhaft im Objekt-Info-Panel (ToolDraft.measure→PanelHost.measurement),
|
||||||
|
Rechtsklick beendet Pfad + armiert neuen (mehrere nacheinander).
|
||||||
|
- **Tür/Fenster tief** (`fa40429` Modell/Seeds/RM-Editor, `142ba7e` 2D, `9a65900` 3D):
|
||||||
|
DoorType/WindowType um frameKind (Zarge/Blockrahmen), frameWidth, insetFromFace/
|
||||||
|
insetFace (Schichteinzug), transomHeight (Oberlicht), mullionRows (Kämpfer)
|
||||||
|
erweitert. 2D: opening-frame/-transom-Primitive. 3D: emitOpeningFrames (Rahmen/
|
||||||
|
Sprossen/Kämpfer) + einzugs-/oberlicht-bewusste Scheiben. **3D visuell noch NICHT
|
||||||
|
in Tauri abgenommen.**
|
||||||
|
- **LICENSE** (`dbe7d37`): offizieller AGPL-3.0-Text von gnu.org.
|
||||||
|
- **CSV-Element-Set (D2)** war bereits im Baseline-Stand vorhanden (verifiziert).
|
||||||
|
- **window.prompt/confirm-Sweep**: keine offenen Vorkommen mehr (bereits sauber).
|
||||||
|
|
||||||
|
## Offene Fäden / bewusst NICHT gemacht
|
||||||
|
1. **Tauri-Visualabnahme** der 3D-Änderungen (Tür/Fenster-Rahmen, Decken-Griffe)
|
||||||
|
steht aus — Nutzer testet selbst (render3d/Viewport nicht browserverifizierbar).
|
||||||
|
2. **Rechtsklick=Abbruch generell**: bewusst NICHT global von confirm→cancel
|
||||||
|
umgestellt (würde Polylinien-Abschluss brechen); fürs Mess-Werkzeug via onConfirm
|
||||||
|
gelöst (commit-frei → confirm≙abbruch).
|
||||||
|
3. **Bauteil-Einstellungs-Button** (Discoverability: von der gewählten Öffnung in
|
||||||
|
den RM-Typeditor springen) noch offen — Typeditor selbst ist über das
|
||||||
|
Ressourcen-Fenster (Tür-/Fenstertypen-Tabs) erreichbar.
|
||||||
|
4. Grosse Deferred-Items unverändert: 2D-Schnitt editierbar, Schnitt-Verschneidung
|
||||||
|
(wartet auf `.obp`), Feld-Controller 3D, Schnitt/Ansicht Phase 3, truck-Live-Wiring.
|
||||||
|
5. **Konventionen bleiben:** ab jetzt wieder Standard = NICHT committen, ausser der
|
||||||
|
Nutzer beauftragt es erneut. Keine KI-Spuren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# HANDOVER — Stand 2026-07-08 spät (Schnitt-Parität + Projektdatei, ALLES uncommittet)
|
||||||
|
|
||||||
|
Fortsetzungs-Session (nach dem älteren 2026-07-08-Handover weiter unten). **Alles uncommittet.** Schwerpunkt: 3D-Live-Schnitt = 2D-Schnitt + eigene Projektdatei.
|
||||||
|
|
||||||
|
## Verifikations-Baseline (zuletzt grün)
|
||||||
|
`npx tsc --noEmit` sauber · `npx vitest run` **588** · `cargo test -p render3d --features render` **76** · `cargo check --features native3d` grün · WASM `npm run build:engine3d` neu gebaut (nötig nach Rust/WGSL-Änderungen). **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren.**
|
||||||
|
|
||||||
|
## Was diese Session gebaut wurde
|
||||||
|
- **3D-Live-Schnitt = 2D-Schnitt (Kernfunktion):** (a) 45°-Schraffur-Verdrehung gefixt (Shader auf 2D-`parallelLines`-Konvention kalibriert, angle 0 = horizontal); (b) geschichteter Bodenaufbau (Decke per-Schicht statt Einkörper, `emitSlabs`); (c) **Prioritäts-Verschneidung** von 2D nach Rust portiert (`section_boolean.rs` = Port von `subtractDominantBands`/`subtractRect`; Bänder tragen `join_priority` via `CutBandMeta`); (d) `collectCeilingCutters` emittiert per-Schicht-Cutter → Backstein (50) schneidet Estrich/Dämmung (30/20) korrekt weg, nur Beton (100) kappt ihn; (e) **einstellbare Schichttrennlinien** aus `jointLineStyleId`; (f) **Schraffur-Strichstärke pro Hatch** aus deren `lineStyleId` (`hatch-hair` 0.02 / `thin` 0.13); (g) `relativeToWall`-Orientierung: Boden-Dämmung vertikal, Wand horizontal (via `resolveHatch(axisAngle)` wie 2D). Betrifft `src-tauri/render3d/src/{shaders,gpu,section,section_fill,section_boolean,types}.rs` + `src/plan/toWalls3d.ts`. `native.rs` `WallInput`-Literal um die additiven Felder ergänzt.
|
||||||
|
- **Projekt als Einzeldatei `.obp`** („openbureau project"): `src/io/projectFile.ts` (`PROJECT_EXT`, nativer Öffnen/Speichern-Dialog, alte `.json` weiter ladbar) + **OS-Lock gegen Doppelöffnung** (`src-tauri/src/lock.rs`, `fs4` exklusiver flock über gehaltenes Handle → Auto-Freigabe bei Absturz/Exit; `LockConflictDialog`). QA: Lock-Logik korrekt, einzige Lücke: „schreibgeschützt" == „erzwingen" auf Datenebene (kein app-weiter Read-only-Modus).
|
||||||
|
- **Identität:** Open-Source-CAAD statt „BIM für Wohnbau"; `about.desc` (de/en) + README + `package.json` (`license: AGPL-3.0-or-later`, `author`). **LICENSE-File fehlt noch** (README verlinkt darauf) — offizieller AGPL-Text muss rein (nicht fabrizieren; `curl gnu.org/licenses/agpl-3.0.txt`). About-Dialog-Umbruch gefixt (`.about-dialog { white-space: normal }` — er erbt `nowrap` von `.topbar`).
|
||||||
|
- **2D:** Text-Platzierungswerkzeug als 2D-Element (`text`-Command + Freitext-Eingabe in der Command-Engine); 2D-Zeichenwerkzeuge auf `"drawing"`-Ebenen freigegeben (ToolsPanel-Gating = `floorOnly`, Parität mit Ribbon).
|
||||||
|
- **Teil B — 2D-Annotationen auf Layout-Blättern:** `LayoutAnnotation` (line/rect/text in mm) + CRUD (`layoutModel.ts`) + Editor in `LayoutSheet.tsx` (zeichnen/selektieren/verschieben/löschen, `PromptDialog` für Text) + `layoutSheetMath.ts` (Hit-Tests). Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping.
|
||||||
|
|
||||||
|
## Offene Fäden
|
||||||
|
1. **Idee 1 — per-Wand „an Decke enden"** (Terminierung ≠ Priorität): AGENT LÄUFT gerade (per-Wand `sliceTermination`, Default off). Details/Design im Memory `design-schicht-verschneidung-3d-schnitt.md`. **Idee 2 (Schichteinzüge, per-Schicht Endversätze)** = größeres Folge-Feature, noch nicht gebaut.
|
||||||
|
2. **Ressourcen-Fenster als eigenes OS-Fenster** (Nutzer-Wunsch): machbar (Tauri `WebviewWindow` + BroadcastChannel-Sync, „Ausklappen"-Button, Browser bleibt intern). Noch NICHT gebaut — braucht Store-Sync-Architektur-Entscheidung.
|
||||||
|
3. **Visuelle Tauri-Abnahme** aller 3D-Schnitt-Änderungen steht aus (render3d nur naga-validiert + WASM gebaut; Nutzer testet selbst). Der Backstein hat prioritäts-korrekt noch ein Stück oberhalb des Betons — das ist Idee 1 (Option), nicht ein Bug.
|
||||||
|
4. **Konventionen:** wie immer — keine KI-Spuren, nicht committen, disjunkte Agent-Lanes, echte Eigenschaft prüfen (nicht nur grüne Tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# HANDOVER — Stand 2026-07-08 (Account-/Instanzwechsel, ALLES uncommittet)
|
||||||
|
|
||||||
|
Übergabe an eine frische Instanz (Nutzer wechselt Account, Budget aufgebraucht). **Alles unten ist UNCOMMITTET** (Bearbeiter committet nie selbst). Zuerst `PENDENZEN.md` lesen (Single Source of Truth, ✅-Erledigt-Liste + offene Items ganz konkret). Diese Session war eine sehr lange Feature-/Fix-Serie mit vielen Sub-Agents.
|
||||||
|
|
||||||
|
## Verifikations-Baseline (zuletzt grün)
|
||||||
|
`npx tsc --noEmit` sauber · `npx vitest run` **491/491** · `cargo check --manifest-path src-tauri/Cargo.toml` grün · `vite build` OK. (Der letzte Layout-Viewport-Agent meldete 491; nach dem interaktiven Ausschnitte-Fix waren es 465 → 491 ist der aktuelle Stand nach dem Layout-2b-Agenten.) **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren**, da alles uncommittet nebeneinander liegt.
|
||||||
|
|
||||||
|
## Was diese Session gebaut wurde (alles in PENDENZEN ✅ dokumentiert)
|
||||||
|
- **Interop-Export:** IFC4 (`exportIfc.ts`, jetzt `IfcTriangulatedFaceSet` mit ausgeschnittenen Fenster/Tür-Löchern + SOLIDE Wände — Winding-Fix), STL+OBJ (`exportMesh.ts`), gemeinsamer Loch-Ausschnitt `src/plan/wallMeshCut.ts` (Port von render3d `extrude_layer_segment_with_holes`, `isWatertight` via Gauss/Volumen). Schnellexport-Dialog (`ExportSaveDialog`, Format+Name).
|
||||||
|
- **Nativer Speichern-Dialog:** Tauri `plugin-dialog`+`plugin-fs` (Cargo/lib.rs/capabilities), `src/io/saveFile.ts` (`saveTextFile`: nativ unter Tauri, Blob-Fallback im Browser). App.tsx `downloadTextFile`→`saveTextFile`.
|
||||||
|
- **Ausschnitte (View-Snapshots) Phase 1:** `Project.viewSnapshots`, `state/viewSnapshots.ts`, `ViewSnapshotsPanel` (Speicher-Fix: Inline-Input statt window.prompt).
|
||||||
|
- **Layout-Blätter Phase 2a+2b:** Modell `Layout`/`LayoutViewport`/`MasterLayout` (`Project.layouts`/`masterLayouts`), In-Viewport-Editor (`activeLayoutId` in App.tsx → `LayoutSheetView`/`LayoutSheet.tsx`, Maus-Editing, `layoutSheetMath.ts`), `LayoutsPanel`. Floating `LayoutEditor` wurde ersetzt.
|
||||||
|
- **Tragwerk-Stützen:** `Column`/`ColumnProfile`, `geometry/column.ts`, Platzieren/2D/3D/Selektion/Attribute/move.
|
||||||
|
- **Weiteres:** A1 Override-Regel-Engine, kernel2d Phase 5 komplett, Bauteil-CSV volles Element-Set, SIA-416 AGF, Kamera-Presets Kardinal+Iso, Decken-UK/OK, BIM-Tree-Panel, ObjectInfo-Volumen, Basis-Material-Texturen, OSM 7 Kat., IndexedDB-Kern, Rich-Text super/sub, Auto-Zoom-Geo, Raumstempel Personen/Rundung, diverse TopBar-Feinschliffe (Export-Sammelmenü, Über-Dialog, Petrol-Punkt, Detailgrad+Darstellung gestapelt, Arbeitsumgebung in Settings).
|
||||||
|
|
||||||
|
## ⚠️ WICHTIG / offene Fäden (Details in PENDENZEN, Abschnitt „In Arbeit"/oben)
|
||||||
|
1. **NOCH NICHTS VISUELL IN TAURI ABGENOMMEN:** IFC/OBJ/STL-Geometrie, nativer Speichern-Dialog, Layout-im-Viewport, Ausschnitte — alle agent-/testverifiziert, aber der Nutzer hat sie im laufenden Fenster noch NICHT gesehen. **Tauri-Dev NEU STARTEN** (`npm run tauri:dev`, ggf. Vite separat auf Port 5187 — es gibt KEIN `beforeDevCommand`) wegen der neuen Rust-Plugins.
|
||||||
|
2. **`window.prompt`/`confirm`-SWEEP:** in Tauri-WKWebView DEAKTIVIERT (→ null). Gefixt: ViewSnapshotsPanel, LayoutsPanel. NOCH betroffen: `ComboMenu` (Kombinationen speichern), TopBar Massstab „frei" (`topbar.scale.prompt`), evtl. weitere. Grep `window.prompt|window.confirm` über `src/`, alle auf Inline-Eingaben umstellen.
|
||||||
|
3. **NÄCHSTES GROSSES ITEM (fertig gescoped, war startbereit als Opus-Agent):** „Layouts als Ordner-/Baumstruktur" — voller Auftrag steht in PENDENZEN (Ordner-Baum, ein „+" für Ordner/Layout/Masterlayout, Inline-Rename, Layout-Erstell-Dialog mit Master-Vorlage/freie Grösse, Masterlayout mit Grössenwahl, Ordner→Mehrseiten-PDF). Direkt umsetzbar.
|
||||||
|
4. **Konventionen:** keine Fremd-Tool-Spuren/Co-Authored-By im Repo; Sub-Agents in disjunkte Datei-Lanes schicken (i18n de.ts/en.ts + App.tsx sind Hotspots → exklusiv EINEM Agent pro Welle, sonst NUL-Byte-Kollision); grüne Tests NICHT als alleinigen Beweis nehmen (echte Eigenschaft prüfen — z. B. beim IFC-Solidity-Fix war der naive Manifold-Test falsch, Gauss/Volumen war richtig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# HANDOVER — Stand 2026-07-07 abends (Instanzwechsel, alles uncommittet)
|
||||||
|
|
||||||
|
Übergabe an eine frische Instanz (Nutzer wechselt bewusst die Instanz). **Alles unten Gelistete ist NOCH UNCOMMITTET** — Bearbeiter committet laut Arbeitsprotokoll nicht selbst. Zuerst `CONVENTIONS.md` + `PENDENZEN.md` lesen (dort steht die volle, aktuelle Aufgaben-Historie inkl. aller Details der truck-Integration/Deep-Review-Fixes unten — hier nur die Kurzfassung + der offene Faden).
|
||||||
|
|
||||||
|
## Sofort-Kontext für die neue Instanz
|
||||||
|
|
||||||
|
**Environment:** macOS-Gerät, Toolchain vollständig (node/npm/cargo/wasm-pack), `npm run tauri:build` läuft. Kein Blocker.
|
||||||
|
|
||||||
|
**Verifikations-Baseline (zuletzt geprüft, alles grün):** `npx tsc --noEmit` sauber · `npx vitest run` 341/341 · `cargo test` in `src-tauri/trucksolid` 15/15 · `cargo test --manifest-path src-tauri/Cargo.toml -p render3d` 58/58.
|
||||||
|
|
||||||
|
**Was in dieser Session geschah (chronologisch, Details je in PENDENZEN.md „truck-Integration" Phase 5 / Backlog):**
|
||||||
|
1. Vorherige Session ist wegen RAM-Absturz des Geräts abgebrochen (lief eine LOKALE LLM auf dem Laptop, nicht diese Instanz) — Stand war: truck-Integration Phasen 1–4 fertig (Extrusion + Verjüngung/Taper + Boolean-CSG via `csgrs`), aber noch uncommittet.
|
||||||
|
2. Diese Instanz hat den übernommenen Stand **deep-reviewt** (8-Winkel-Review: Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) und mehrere echte Bugs gefunden + gefixt — volle Liste in PENDENZEN.md unter „truck-Integration" → „Deep-Review + Fixes (2026-07-07)": konkave Profil-Triangulierung (Fächer→Ohr-Clipping), 6 fehlende Selektions-Resets für Extrusionen, Mirror/Copy ignorierten Extrusions-Auswahl, DXF-Export verlor den Layer, `truckMeshCache`-Leak + Flacker-Bug, unnötige `fitTargetDist`-Neuberechnung, fehlende i18n (Schnittebene-Button + Farbwähler-Tooltip).
|
||||||
|
3. Danach auf Nutzer-Wunsch **eigenständig durch PENDENZEN.md** gearbeitet (nur unblockierte Items, keine Design-Entscheidungen geraten): **Ortho-Ray-Picking gefixt** (`cameraRay` in `src/viewport/raycast3d.ts` konnte nur perspektivisch — jetzt echte parallele Strahlen bei `perspective:false`, rückwärtskompatibel; +2 Tests). Siehe PENDENZEN.md „3D-REST" Abschnitt.
|
||||||
|
|
||||||
|
## ✅ ERLEDIGT 2026-07-07 (Folge-Instanz) — Bauteil-CSV volles Element-Set (D2, vertieft A6), umgesetzt exakt nach dem Scope unten; Treppen-Fläche = null (leer), Aggregat-Zellen leer statt 0.00. Details/Status in PENDENZEN.md „✅ Erledigt".
|
||||||
|
|
||||||
|
`src/export/exportSchedule.ts` deckt aktuell nur **Wand + Decke** ab (Muster: `evalWall`/`evalCeiling` → `ScheduleRow`, siehe Datei). PENDENZEN-Backlog-Item „AUDIT (DOSSIER-Studie)" nennt D2 = „Bauteil-CSV mit vollem Element-Set". Ich habe das untersucht und **bewusst gescoped, aber NICHT umgesetzt** (Instanzwechsel kam dazwischen) — der Datei-Zustand ist unverändert/clean (ein Testballon-Import wurde wieder entfernt, `tsc` ist grün). Damit die nächste Instanz nicht neu recherchieren muss, hier die bereits getroffenen, gut begründeten Scope-Entscheidungen:
|
||||||
|
|
||||||
|
- **Aufnehmen:** `Door` (Tür), `Opening` (kind="window"→Fenster, kind="door"→Öffnung), `Stair` (Treppe), `ExtrudedSolid` (Extrusion, aus der truck-Integration).
|
||||||
|
- **NICHT aufnehmen: `Room`** — Räume haben bereits einen eigenen, dedizierten CSV-Export (`roomsToCsv` in `src/geometry/roomArea.ts`, SIA-Flächenbilanz-Spalten). Eine zweite Raum-Zeile in der allgemeinen Bauteilliste wäre Duplikation.
|
||||||
|
- **Spalten-Mapping** (bestehende feste Header „Länge/Höhe/Dicke/Fläche" — nur dort füllen, wo eine Ableitung wirklich verlässlich ist, sonst leer lassen wie es `evalCeiling` heute schon für Decken macht):
|
||||||
|
- Door/Opening: `length = width` (lichte Breite), `heightM = height`, `thickness = null` (keine sinnvolle Entsprechung), `area = width * height`. Geschoss auflösen über `hostWallId` → `project.walls.find(w => w.id === hostWallId)?.floorId` (neuer kleiner Helper nötig, Wand evtl. verwaist behandeln wie bei `evalWall`/`evalCeiling`).
|
||||||
|
- Stair: `length = runLength + (run2Length ?? 0)` (Näherung, bei Wendel evtl. wenig aussagekräftig — akzeptabel, da nur „verlässlich Ableitbares" Anspruch gilt), `heightM = stair.totalRise ?? null` (KEINE Geschosshöhen-Fallback-Auflösung nachbauen — Kopplung/Risiko vermeiden), `thickness = null`, `area`: **noch nicht entschieden** — eine echte Footprint-Fläche bräuchte `geometry/stair.ts`s `stairGeometry()` (Kopplung an Geschoss-Höhen-Fallback) → einfachster sicherer Weg ist vermutlich `area = null` lassen (ehrlich statt erfunden), aber nochmal gegen die bestehenden Tests/Konventionen prüfen, bevor final entschieden wird.
|
||||||
|
- ExtrudedSolid: `typeName = "Extrusion"`, Geschoss über `levelId`, `length = null`, `heightM = solid.height`, `thickness = null`, `area = polygonArea(solid.points)` (Import aus `geometry/roomArea.ts`, bereits im File importiert).
|
||||||
|
- Neue `kind`-Werte in `ScheduleRow`/`TypeAggregate`: `"Tür" | "Fenster" | "Öffnung" | "Treppe" | "Extrusion"` zusätzlich zu `"Wand" | "Decke"`.
|
||||||
|
- **Aggregat-Zeile (`aggregateByType`/Summary-Block):** die bestehende Zeile `fmtNum(agg.kind === "Wand" ? agg.totalLength : null)` sollte verallgemeinert werden zu `agg.totalLength > 0 ? fmtNum(agg.totalLength) : ""` — dann zeigen Tür/Fenster/Öffnung/Treppe (die jetzt auch eine `length`-Spalte befüllen) automatisch korrekt eine Summe, während Decke/Extrusion (deren `length` immer `null`→0 bleibt) weiterhin korrekt leer bleiben. Kein Sonderfall pro Kind nötig.
|
||||||
|
- **Tests:** `src/export/exportSchedule.test.ts` folgt einem klaren `fixtureProject()`-Muster (2 Wände + 1 Decke) — beim Erweitern eine Tür/ein Fenster/eine Treppe/eine Extrusion in die Fixture aufnehmen und je einen Zeilen-Assert wie bei den bestehenden Wand-/Decken-Zeilen ergänzen.
|
||||||
|
- Warum nicht in dieser Session fertig: Instanzwechsel kam mitten in der Umsetzung. **Kein Blocker, keine Nutzer-Entscheidung nötig** — reine Fleissarbeit nach obigem Schema, dann `tsc`/`vitest` grün prüfen und in PENDENZEN.md abhaken.
|
||||||
|
|
||||||
|
## Aus dieser Session bewusst NICHT angefasst (recherchiert, aber zu unsicher/blockiert)
|
||||||
|
|
||||||
|
- **Treppe Pfeil-Style „filled" + Tür `swing_invert`/`aussenseite`** (PENDENZEN „BAUTEILE… Gruppe B"): die Referenzquelle (`/tmp/dossier-ref/rhino/*.py`) existiert auf **diesem** Gerät/dieser Session NICHT (geprüft, `find` liefert nichts) — ohne das exakte Rhino-Verhalten zu kennen, wäre die visuelle Ausgestaltung geraten. Nicht angefasst, um nicht falsch zu raten.
|
||||||
|
- **E2b Schraffur-Kachel-Motiv:** `MotifEditor` (`src/ui/MotifEditor.tsx`) wird schon für Linienstil-Motive wiederverwendet (1D, entlang einer Linie) — ein Flächen-Kachel-Motiv für `HatchStyle` wäre ein NEUES Datenmodell (2D-Tile-Grid), kein reines Wiederverwenden. Braucht eigentlich einen Scope-Entscheid.
|
||||||
|
- Alles, was in PENDENZEN.md explizit „Mit Nutzer klären"/„Nutzer-Entscheid nötig" trägt (Ribbon 3D-Tab, kernel2d Phase 6, Feld-Controller, Schnitt/Ansicht Phase 3, Teamwork-Granularität, Geo-Block-Priorität, Textur-Pipeline-Scope, STRATEGIE-Priorisierung, Bildschraffur-Freigabe) — bewusst übersprungen, nicht neu bewertet.
|
||||||
|
|
||||||
|
## Wichtige Memory-Notiz (persistiert, gilt für zukünftige Instanzen dieses Repos)
|
||||||
|
Der Nutzer lässt teils eine **lokale LLM auf seinem Laptop autonom** an diesem Projekt arbeiten (kann abstürzen, z. B. RAM). Deren Output sah in dieser Session strukturell sauber + gut getestet aus, enthielt aber einen echten geometrischen Bug (Fächer-Triangulierung bricht bei konkaven Profilen), den die eigene Testsuite NICHT gefangen hatte (Tests prüften nur Form/Anzahl, nicht geometrische Korrektheit). **Bei Übernahme von Arbeit aus solchen Sessions: grüne Tests allein NICHT als Beweis nehmen, gezielt nachprüfen ob die Tests die richtige Eigenschaft testen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen)
|
||||||
|
|
||||||
|
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
|
||||||
|
|
||||||
|
> **➡️ Aufgaben-Queue steht in `PENDENZEN.md`** (Single Source of Truth, priorisierte Checkliste + Arbeitsprotokoll). HANDOVER = nur noch Kontext/Konventionen/Environment/Zielmodelle, KEIN Backlog mehr. Neue Pendenzen/Rückfragen dort eintragen, nicht hier.
|
||||||
|
|
||||||
|
## ⚠️ NACHTRAG 2026-07-04 abends — Unterbruch, 3 Slices verloren
|
||||||
|
|
||||||
|
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Alle Datei-Edits waren gelandet — unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
|
||||||
|
>
|
||||||
|
> **⚠️ PUSH BLOCKIERT:** keine Git-Credentials auf diesem Gerät (kein Helper, keine `~/.git-credentials`, kein SSH-Key; Remote HTTPS `git.openbureau.ch/karim/DOSSIER-STANDALONE`). `cb8fae5` + `c5a344d` (+ folgender Öffnungen-Commit) sind **lokal committet, NICHT gepusht** (`ahead of origin/master`). Sobald Gitea-PAT (repo-write) oder SSH-Key da ist: `git push origin master`. Bis dahin holt das andere Gerät die Arbeit nicht.
|
||||||
|
>
|
||||||
|
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
|
||||||
|
|
||||||
|
Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Diese Übergabe wurde nachträglich rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
|
||||||
|
|
||||||
|
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War fertig gebaut und quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit, 225/225 Tests grün), aber **nicht mehr committet**, bevor der Unterbruch kam. Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
|
||||||
|
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Die Umsetzung war weit fortgeschritten (schrieb bereits in `joins.ts`), dann abgebrochen. **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
|
||||||
|
3. **Öffnungen als echte Boolean-Löcher** (`RWall.holes`, `src/plan/toWalls3d.ts`, `src-tauri/render3d/src/mesh.rs`, additiv `types.rs`): EIN Wandkörper mit rechteckigen Löchern statt der heutigen Pfeiler/Brüstung/Sturz-Ersatzkörper (sichtbare Segment-Nähte, keine echten Löcher). Pflicht-Testfall (Nutzer-Direktive, Kernmotivation des Slices): zwei Fenster in derselben Wand mit überlappendem Achsen-Intervall, aber unterschiedlicher Höhe (z. B. u=[1.0,2.0]/z=[0.9,2.1] und u=[1.5,2.5]/z=[0.3,0.7]) — die alte Segment-Zerlegung kann das nicht abbilden, echte Löcher schon. Zusatz-Auftrag im selben Slice: `trimWallTopForCeilings` (Phase-3-Deckentrim, Commit `e4b8df6`) darf **nur** im 3D-Viewer-Pfad (`layeredWalls:true`) gelten, **nicht** im Schnitt-Pfad (`layeredWalls:false`) — der Schnitt braucht die volle, ungetrimmte Wandhöhe für seine eigene schichtweise Prioritäts-Subtraktion (`subtractDominantBands`), sonst zeigt der Schnitt die Wände fälschlich abgeschnitten. Fortschritt bei Abbruch **unklar** (letztes Status-Update deutete auf „noch in der Kartierungsphase" hin) — vermutlich am wenigsten weit von den dreien; komplett neu beauftragen. Vollständiger Auftragstext im Transkript bei „Agent:Oeffnungen als Boolean-Loecher" + die zwei Zusatz-Direktiven (Decken-Trim-Fix, Pflicht-Testfall versetzte Fenster) kurz danach.
|
||||||
|
|
||||||
|
**Zwei Engine-Direktiven vom alten Stand fehlen** (`priority-based-joins.md`, `openings-as-booleans.md`): die in Punkt 2/3 beschriebenen Zielverhalten waren gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit festhalten.
|
||||||
|
|
||||||
|
**Environment-Lücke auf diesem Gerät:** `node`/`npm`/`cargo`/`wasm-pack` waren zum Zeitpunkt dieser Übergabe **nicht auf dem PATH** (geprüft: kein Treffer, auch nicht unter der VSCodium-Flatpak-Datenlage). **Vermutliche Ursache: VSCodium lief als Flatpak** — Flatpak-Sandboxing blendet system-installierte Toolchains (Node/Rust unter `/usr/...` oder `~/.cargo`) typischerweise aus, auch wenn sie auf dem Host tatsächlich installiert sind. Der Nutzer wechselt gerade auf **natives VSCodium** (kein Flatpak mehr) — das könnte die Lücke von selbst beheben, MUSS aber nach dem Wechsel neu geprüft werden (`which node npm cargo wasm-pack`), bevor man von "Toolchain fehlt" auf "Toolchain neu installieren" schließt. Erst wenn nach dem Wechsel auf natives VSCodium immer noch nichts gefunden wird, wirklich neu installieren (Node LTS + Rust via rustup + `wasm-pack`, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml` durchlaufen lassen). Ohne Toolchain ist die in `CONVENTIONS.md` vorgeschriebene Verifikation (`tsc`/`vitest`/`cargo test`/`wasm-pack`) nicht möglich — das zuerst klären, bevor einer der drei Slices neu beauftragt wird, sonst baut ein Agent blind ohne Verifikation.
|
||||||
|
|
||||||
|
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung waren dirty/untracked. Die txt-Datei gehört nicht ins Repo — außerhalb verschieben oder löschen. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
|
||||||
|
|
||||||
|
### Empfohlene Reihenfolge zum Wiederaufsetzen
|
||||||
|
1. Nach dem Wechsel auf natives VSCodium zuerst `which node npm cargo wasm-pack` prüfen — evtl. war es nur die Flatpak-Sandbox, die die Toolchain versteckt hat. Nur falls immer noch nichts gefunden wird: Node + Rust + wasm-pack installieren + `npm install`. Blocker für alles Weitere.
|
||||||
|
2. Locked-Iso-Fix neu bauen (klein, unabhängig, Spezifikation im Transkript vollständig vorhanden).
|
||||||
|
3. Joins Phase 1c (Nutzer-Priorität dieser Session) — Direktive zuerst wieder ins Memory schreiben, dann Agent mit dem Auftragstext aus dem Transkript beauftragen.
|
||||||
|
4. Öffnungen als Boolean-Löcher + Deckentrim-nur-3D-Zusatz (dieselbe Rust-Crate wie Phase 1c — nacheinander, nicht parallel, wegen Datei-Overlap in `mesh.rs`/`toWalls3d.ts`).
|
||||||
|
5. Repo aufräumen (txt-Datei raus, `package-lock.json` klären), danach diesen Abschnitt auf „erledigt" aktualisieren.
|
||||||
|
|
||||||
|
## NACHTRAG Session 3 (2026-07-04 mittags) — Prioritaets-Joins + Schnitt-Sanierung
|
||||||
|
|
||||||
|
**Prioritäts-Join-Epic (Kern-Direktive, siehe unten „Verschneidungs-Zielmodell"):** Joins werden nach `Component.joinPriority` materialbewusst aufgelöst — konsistent in Grundriss, Schnitt UND 3D. Gelandet:
|
||||||
|
- `e1698b7` **Wand-T-Verbindungen** (T-Knoten 3 Enden + Mittelspannen-Stoss) in `computeJoins`; generatePlan wendet die Cuts unverändert an. `0ddf99d` Rust-Parität (geometry-Crate). `d74dda1` Sample: Querwand **W9** (neuer Wandtyp `iw` Innenputz/Backstein/Innenputz) als T-Demo, Tür/Fenster beiseite gerückt.
|
||||||
|
- `fc0a76f` **Phase 1 materialbewusst**: `resolveJoinPriority` (merge/coexist/trim), `WallCuts.layerCuts` (per-Schicht-Cuts + L-Seitenlinien), Backstein-Kern verschmilzt, Putz bricht als L. `bf97d50` **Phase 1b**: Kern sticht nur durch den Nah-Putz und stoppt an der Rückgrat-Nahfläche (mergeCut bei `offT + sign*(tT/2 − nearPlaster)`), KEINE Achs-Überlappung; Naht entfällt über stroke:none/noStrokeEdges/Zeichenreihenfolge. Rust jeweils synchron.
|
||||||
|
- `e4b8df6` **Phase 3 (3D)**: Wand wird an der Deckenunterkante getrimmt, wenn eine Decke höherer joinPriority bündig aufliegt (`trimWallTopForCeilings` in toWalls3d, BBox-Footprint-Näherung dokumentiert) → kein Z-Fighting Wand/Decke.
|
||||||
|
|
||||||
|
**Schnitt-Sanierung (Regressionen aus der 3D-Schicht-Umstellung `de7a26b` behoben):**
|
||||||
|
- `da39f0c` per-Schicht-3D-Bänder nutzten die RECHTE Normale statt `leftNormal` → Schichten in 3D+Schnitt gespiegelt (Backstein aussen). Eine Zeile in `pushSegment` gedreht.
|
||||||
|
- `6c47355` Schnitt bekam durch die per-Schicht-Boxen segments×layers Cut-Polygone → Owner-Index-Versatz → einheitliches Diagonal-Muster. Fix: `projectToModel3d(project, { layeredWalls:false })` für `computeSection` (Voll-Box pro Wand); 3D-Viewer behält Default true.
|
||||||
|
- `5ed3549` **Alle Schraffuren monochrom**: HATCH_INK `#0f0f0f` / HATCH_PAPER `#f0f0f0` (resolveHatch erzwingt Ink; Albedo-Fallback im Schnitt entfernt). Print-Mono separat/unangetastet.
|
||||||
|
- `c36a7e0` Wandrelative Muster drehen im Schnitt mit der Wand (`SECTION_WALL_AXIS_ANGLE_DEG=90` an resolveHatch) → Dämmung horizontal quer zur Dicke.
|
||||||
|
|
||||||
|
**Sonstiges:** `1855717` Renderer-Umschalter aus der Statusleiste in die **Settings**, Default **Nordstern** (WebGL2 nur explizit/Fallback). `32497bc` TopBar: Zoom-Cluster von Grid auf Flex (Phantom-Zeile +6px weg, jetzt bündig mit Zweizeilern), Bar 56→60px.
|
||||||
|
|
||||||
|
**IN FLIGHT bei Checkpoint:** (1) **3D-Live-Schnitt** (Opus-Agent): Clip-Plane-Uniform + discard in MESH/GRID_WGSL, `section_fill.rs` Cut-Caps aus `cut_section` mit prozeduraler 45°-Schraffur (CAP_WGSL), `set_section_plane` (web.rs cached walls/slabs), Viewport-Toggle; WASM-Rebuild nötig. (2) **Joins Phase 2** (Opus-Agent): Merge-Regel (gleiche Prio+Komponente) in der Schnitt-Dominanz `subtractDominantBands`. — `git status` prüfen, verifizieren, committen.
|
||||||
|
|
||||||
|
**NÄCHSTER Engine-Slice (Nutzer-Direktive, queued):** **Öffnungen als echte Boolean-Löcher** — Fenster/Türen sind heute Pfeiler/Brüstung/Sturz-Ersatzkörper (keine CSG, Triangulator lochfrei) → sichtbare Segment-Nähte. Ziel: EIN Wandkörper, Löcher via earcut-with-holes + Laibungsflächen; behebt auch die dokumentierte mesh↔cut_section-Öffnungs-Inkonsistenz. Läuft im selben Crate wie der 3D-Schnitt → erst danach starten.
|
||||||
|
|
||||||
|
**Verschneidungs-Zielmodell (Nutzer, verbindlich):** gleiche Komponente+Priorität = verschmelzen (Naht weg); schwächere Schicht bricht auf und schliesst als L; höhere Priorität schneidet bauteilübergreifend (Decke>Wand); Joins IMMER in allen drei Sichten identisch. Schraffur-Modell: Schnitt-Schraffur vs. Oberflächen-Schraffur, aktuell ALLE monochrom fg #0f0f0f auf bg #f0f0f0.
|
||||||
|
|
||||||
|
## NACHTRAG Session 2 (2026-07-04 nachmittags/abends) — TopBar-Redesign + 3D-Editierbarkeit
|
||||||
|
|
||||||
|
**TopBar/UI gelandet:** DOSSIER-Wortmarke + Ressourcen-Icon links (Rhino-Stil), Massstab/Zoom-Cluster bereinigt (Duplikat weg, Zoom-Aktionen unter Massstab-Dropdown gestapelt, gleiche Breite, Ring bei „am Massstab" statt Flächen-Aufhellung), PDF/DXF raus → **Datei-Burger-Menü** rechts (Speichern/Öffnen als echter Projekt-JSON-Download/-Upload, Import, Export). **Einstellungs-Fenster** (`SettingsDialog.tsx`): Accent-Palette (`src/theme/accents.ts`), Auswahlrahmen-Farbe (Default Yuyake #CEB188) + Snap-Farbe (vorläufig **Sora #5FA1C9** gewählt — „aki"-Rückfrage bleibt offen, per Picker änderbar), Projekt-MüM-Feld (`project.referenceElevationMasl`, nur gespeichert, Draping folgt). **Custom Fenstersteuerung** (_/□/X) für Electron (`electron-preload.cjs` + IPC, `WindowControls.tsx`, TopBar = Drag-Region). **Dark-Theme ist jetzt fester Standard** (vorher an OS-`prefers-color-scheme` gekoppelt → zeigte fälschlich Light; Light bleibt Opt-in via `:root[data-theme="light"]`). Alle TopBar-Pillen auf dieselbe stets-dunkle Kontextmenü-Fläche vereinheitlicht; Panel-Köpfe flach (kein hellerer Balken) + feste 40px-Höhe. **Undo/Redo** (`historySlice.ts`, Ctrl+Z/Y, Coalescing für Drags). **15 gebündelte OFL-Schriften** (`public/fonts/`, `src/text/fonts.css`) statt Systemfonts. **Farbfelder**: Hex separat editierbar (`ColorHexField.tsx`). **Text-Zeilenhöhe-Regler** ersetzt „+ Text" (Text wird eigenes Werkzeug, AUDIT B1). **Linienstil** im Attribute-Panel editierbar. 2D-Zoom-Grenzen stark erweitert (20000×/0.005×).
|
||||||
|
|
||||||
|
**3D-Editierbarkeit gelandet (Nordstern/render3d, NICHT three.js — Free=three.js-Viewer, Premium=editierbarer Nordstern, siehe Memory):**
|
||||||
|
- **R1** `85a721f` Boden-Referenzraster auf y=baseElevation des aktiven Geschosses, ein-/ausschaltbar (Overlay-Button, `viewSlice.grid3dVisible`; eigene LineList-Pipeline `grid.rs`, `set_ground_grid`). Kamera: Links=Orbit, **Mitte/Rechts=Pan** (vorher Pan nur auf Shift+Mitte versteckt), Rad=Zoom-to-Cursor.
|
||||||
|
- **R2** `6ef57b6` View-Styles **wireframe** + **hidden-line** + Fill-Light (`set_render_style`, `edges.rs` = Feature-Edge-Extraktion mit Crease-Dedup, Hidden-Line via Depth-Bias-Flächen + Kanten obenauf). `textured` → vorerst wie shaded (keine Textur-Pipeline).
|
||||||
|
- **R3** `ac0d9ef` **Klick-Auswahl** im 3D (`raycast3d.ts`: OBB-Wand-/Prisma-Slab-Schnitt → Store-Selektion → Attribute+Objekt-Info-Panel). `toWalls3d.pickGeometry()` mit wallId/ceilingId.
|
||||||
|
- **R4** `fabf97a` **Auswahl-Highlight**: orange Outline, immer sichtbar (No-Depth-Linien-Pipeline `set_highlight_lines`, `selectionHighlightLines()`).
|
||||||
|
- **R5** `02bd261` **Materialfarben** im 3D: Wände/Decken in Farbe der dicksten Schicht statt Einheitsgrau.
|
||||||
|
- **weiter:** `8bc472a` 2D **z-Anordnen** (Kontextmenü nach vorn/hinten, `reorderDrawings`); `de7a26b` **per-Schicht Wand-Bänder** (jede Materiallage als eigener farbiger Quader, wallId bleibt → Pick/Highlight ganze Wand); `e42c336` **AUDIT A6 Bauteil-Schedule-CSV** (Datei-Menü, `exportSchedule.ts`); `7dd324d` **Glasscheiben in Fenstern** (A5-Teil, RMesh in jeder Fensteröffnung).
|
||||||
|
- Alle Runden **visuell im Browser verifiziert** (Playwright + WebGPU-Flags: `--enable-unsafe-webgpu --enable-features=Vulkan --use-gl=angle --use-angle=vulkan --no-sandbox`, URL `?engine=wasm`, Iso-View klicken).
|
||||||
|
|
||||||
|
**3D-REST (engine-schwer, bewusst NICHT blind gemacht — mit Nutzer angehen):**
|
||||||
|
- **Schnitt-Schraffuren** (2D-Poché/Schraffur auf 3D-Schnittflächen; `section.rs`, ~66KB, komplex).
|
||||||
|
- **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern).
|
||||||
|
- **Wand-Schicht-Bänder** in 3D (Option B aus R5: jede Materiallage als eigener extrudierter Teilquader statt nur repräsentativer Farbe — pro-Schicht-Farben liegen in `RWall.layers` schon vor).
|
||||||
|
- **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; für Front/Top/Side-Presets vor der ersten Navigation leicht ungenau).
|
||||||
|
- **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||||
|
|
||||||
|
WASM-Workflow: `src/engine/pkg3d/` ist gitignore't → nach Rust-Änderung `npm run build:engine3d`, nur die `.rs` committen. DOSSIER-Audit-Details jetzt in `docs/design/dossier-feature-audit.md` (A1–A6/B1–B4/C/D/E) + `ROADMAP.md` §11.
|
||||||
|
|
||||||
|
## Konventionen (ZWINGEND — zuerst lesen)
|
||||||
|
- **Keine Fremd-Tool-Spuren** im Repo: vor jedem Commit Trace-Scan (`git diff | grep -iE "co-authored|generated with"`) → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
|
||||||
|
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
|
||||||
|
- **Verifikation:** `tsc --noEmit` clean + `vitest run` + trace-scan vor jedem Commit. Nur Task-Dateien stagen (Kollisionen: parallele Tasks disjunkte Datei-Lanes geben; i18n exklusiv einem Task).
|
||||||
|
- **Engine = „Nordstern"** (render2d/render3d/WASM). Referenz DOSSIER-Rhino: `https://git.openbureau.ch/karim/dossier` (Alias git.kgva.ch), geklont `/tmp/dossier-ref`.
|
||||||
|
|
||||||
|
## In dieser Session gelandet (Auswahl Commits)
|
||||||
|
Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custom Line, Component Vordergrund/Hintergrund), ResourceManager **Master-Detail** (Schraffuren/Linien/Wandstile/Deckenstile, `9695748`), Rendering neuer Typen (Bild-`<pattern>`, random modellraum-verankert, Zickzack, Custom-Motiv-Editor `src/ui/MotifEditor.tsx`), modulares Linien-Segment-System (Strich/Punkt/Lücke), Attribut Vordergrund/Hintergrund + **By-Layer/By-Object-Quellen** (Nach Ebene/Bauteil/eigener Wert für Vordergrund/Hintergrund/Strichstärke/Schraffur, `d02781e`).
|
||||||
|
Weiter: Gehrung spitze Winkel (2D+Schnitt+Rust), TopBar/Footer-Reorg, Zoom-% am Massstab, Tool-Shortcuts 1..0, Verschiebe-Dreieck flacher + Snap, Statusleiste „Nordstern", ResourceManager als **floating nicht-modales Fenster**, **swisstopo** Gebäude(extrudiert)+Terrain-Mesh in 3D (`f22970f`), Schnitt-Wandschicht-Orientierung (`7569524`), **Schnitt-Boolean-Dominanz** nach joinPriority (`23d81be`).
|
||||||
|
|
||||||
|
## Backlog & Rückfragen → `PENDENZEN.md`
|
||||||
|
Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# PENDENZEN — Aufgaben-Queue (Single Source of Truth)
|
||||||
|
|
||||||
|
> **Diese Datei ist die einzige verbindliche Aufgabenliste.** HANDOVER.md enthält nur
|
||||||
|
> noch Kontext/Konventionen/Environment/Zielmodelle — keinen Backlog mehr.
|
||||||
|
|
||||||
|
## Arbeitsprotokoll (ZWINGEND)
|
||||||
|
|
||||||
|
**Bearbeiter:**
|
||||||
|
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
|
||||||
|
2. Oberstes nicht-blockiertes Item aus **🔧 In Arbeit** bzw. sonst **⏭️ Als Nächstes** nehmen, **fertig** machen, verifizieren (`npx tsc --noEmit` + `npx vitest run` + trace-scan), abhaken, eine Ergebniszeile nach **✅ Erledigt** schreiben (mit Commit-Hash, sobald committet).
|
||||||
|
3. **Rückfrage-Recht:** Bei Unklarheit/Design-Entscheid NICHT raten — Item mit `❓` markieren, kurze Frage darunter notieren, nächstes freies Item nehmen. Fragen sammeln sich unter **❓ Offene Rückfragen**.
|
||||||
|
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
|
||||||
|
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
|
||||||
|
|
||||||
|
**Planer** (mit dem Nutzer, kuratiert):
|
||||||
|
- Nimmt Nutzer-Feedback, pflegt daraus die Liste (rein/umpriorisieren/aufspalten). Führt selbst keine Queue-Items aus.
|
||||||
|
- Prüft **❓**-Zeilen mit dem Nutzer, wandelt sie in konkrete Items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ Blocker (zuerst klären)
|
||||||
|
|
||||||
|
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
||||||
|
|
||||||
|
## 🔧 In Arbeit
|
||||||
|
|
||||||
|
- [ ] **GEO-BLOCK Folgepunkte (2026-07-12, nach dem swissBUILDINGS3D-DXF-Fix)** — Nutzer-Report, noch NICHT umgesetzt:
|
||||||
|
- ✅ **Georeferenzierung (`ae47b4f` + `e61634c`):** `Project.geoAnchor` (persistenter Modell↔LV95-Referenzpunkt) ergänzt — der ERSTE Import in einem Projekt setzt ihn, ALLE folgenden Importe verwenden denselben Anker statt je eigenständig `makeOrigin(center)` neu zu berechnen. Zusätzlich neuer Befehl **„georef"** (Button „Neuer Bezugspunkt" in SitePanel): verschiebt per Klick das GESAMTE Projekt (`model/geoRebase.ts::translateProject`, reine Translation über alle Bauteile/2D-Geometrie/Kontext-Schicht/Schnittlinien) so, dass der geklickte Punkt zum neuen Modell-Ursprung wird — `geoAnchor` wandert automatisch mit, der LV95-Bezug bleibt exakt erhalten (rekonstruierbar, auch für einen künftigen georeferenzierten Export). Löst das vom Nutzer beschriebene Workflow: Kataster/3D zuerst importieren (landet ggf. weit vom Ursprung), dann selbst einen praktischen Bezugspunkt am Modell wählen. **Bewusst NUR Translation, keine Rotation:** `Roof.ridgeAxis` ist hart auf `"x"|"y"` beschränkt (keine frei gedrehten Dächer im Datenmodell) und mehrere Winkel-Felder (`Column.rotation`, Text-/Bild-Rotation, Türschwenk) müssten bei einer Drehung mitgeführt werden — eine Rotation, damit „orthogonal zur Parzellengrenze" gezeichnet werden kann, ist ein eigener, grösserer und riskanterer Schritt (Datenmodell-Erweiterung nötig), bewusst nicht mitgemacht. +17 Tests (`geoRebase.test.ts`, `georef.test.ts`), Suite 820 grün.
|
||||||
|
- ✅ **Importierte Gebäude/Terrain sind jetzt anwählbare Meshes im 3D-Viewport (`e6a7738`):** Klick im WASM-3D-Viewport pickt Kontext-Meshes per Raycast (`raycast3d.ts`/`pickGeometry` in `toWalls3d.ts`), Auswahl bekommt einen eigenen Kanal (`selectedContextObjectIds`), Bounding-Box-Umriss als Highlight (volles Dreiecks-Wireframe wäre bei grossen Importen zu dicht), Entf-Taste löscht, `SitePanel`-Liste hebt die Auswahl hervor. **Bewusst NICHT umgesetzt:** die „auf einer Ebene"-Zuordnung (kein `categoryCode`/Layer-Feld an `ImportedMesh`/`TerrainMesh`, keine Sichtbarkeits-Kopplung an die Ebenen-Verwaltung) und keine volle Attribut-Bearbeitung (nur Name-Anzeige/Löschen, keine `deriveSelection()`-Integration ins Attribute-Panel — als zu gross für diesen Schritt eingeschätzt). **Ebenen-Zuordnung bleibt offen, ggf. eigener Design-Entscheid nötig** (eine feste „Import"-Ebene vs. frei zuweisbar).
|
||||||
|
- ✅ **Zwei echte Bugs im swissBUILDINGS3D-Pfad behoben (`35a6834`+`9705890`):** (1) Absturz bei riesigen Kacheln (JSZip „Invalid string length" — DXF komprimiert stark, eine Kachel unter dem 150-MB-Limit kann entpackt trotzdem >700 MB Text ergeben; nur `asset.size` aus der STAC-API zu prüfen reichte nicht, jetzt zusätzlich die JSZip-interne Grössenschätzung + genereller Try/Catch, übersprungene Kacheln landen sichtbar in `skippedTiles`/UI-Meldung statt stumm 0 Gebäude). (2) **Der eigentliche „Import funktioniert nicht"-Bug:** die installierte `dxf-parser`-Version liefert Polyface-Mesh-Face-Indizes als vier einzelne Felder (`faceA`/`faceB`/`faceC`/`faceD`, Gruppencodes 71–74) statt als `faces`-Array — unser Code prüfte auf das Array, das nie existierte, jede swissBUILDINGS3D-DXF-Kachel ergab dadurch 0 Dreiecke. Live gegen echte Kacheln verifiziert: jetzt tausende Dreiecke mit realistischen Höhenwerten. **Damit ist der Gebäude-Import technisch lauffähig — die beiden Design-Punkte oben (Georeferenzierung, Ebenen-Zuordnung) bleiben offen.**
|
||||||
|
|
||||||
|
- [ ] **3D-Kanten-Folgepunkte ("Schattiert mit Kanten", 2026-07-12)** — nach dem Fix der falschen Flächendiagonalen (`7dc8f0d`) zeigte der Nutzer drei weitere Beobachtungen, NOCH NICHT behoben:
|
||||||
|
- **Fenster-Rahmenecken überlappen statt sauber zu stossen** (Screenshot: doppelte/kreuzende Linien an den Blendrahmen-Ecken bei „fein"). Vermutlich KEIN Kanten-Rendering-Bug, sondern echte überlappende Geometrie — die Rahmen-/Sprossen-Riegel (`openingAxisBox` in `toWalls3d.ts`) sind separate Boxen ohne Gehrung/Union an den Stössen. **Nutzer-Folgewunsch dazu:** bei „fein" einstellbar machen, ob die Ecke vertikal-dominant, horizontal-dominant oder auf Gehrung gelöst wird.
|
||||||
|
- **Dach ebenfalls kein sauberer Verschnitt am First/Grat** (Screenshot: kleines Störtriangle exakt am First-Apex). Gleiche Kategorie — mehrere Dachflächen (`emitRoofs`) sind separate, nicht verschnittene Meshes.
|
||||||
|
- **Im OG viele vertikale Striche auf den Wandflächen** — noch NICHT diagnostiziert (nicht als Textur-Pipeline-Artefakt bestätigt, evtl. eine mehrschichtige Wandtyp-Verkleidung mit vielen dünnen Einzelelementen). **Braucht weitere Untersuchung**, idealerweise mit Angabe welcher Wandtyp/welche Schicht im betroffenen Projekt verwendet wird.
|
||||||
|
- Alle drei sind vermutlich Fälle für eine echte Boolean-Verschneidung (Kandidat: `csgrs`/`boolean_mesh`, bereits als WASM-Export vorhanden aus der truck-Integration, s. u.) statt nur Kanten-Heuristik — grösserer, eigener Task.
|
||||||
|
|
||||||
|
- [ ] **Warteschlange „für danach" (Nutzer 2026-07-12, noch nicht begonnen):**
|
||||||
|
- Luftbild/SWISSIMAGE wahlweise als Drapierung auf das Terrain-Mesh ODER als eigenständiges „Dokument" einfügbar (Bild/PDF generell als einfügbares Dokument-Objekt — noch keine Anforderungen präzisiert).
|
||||||
|
- Import-Mesh (swissBUILDINGS3D u. Ä.) wahlweise geglättet („gerundet") oder kantig/eckig belassen, wählbar beim Import oder am Objekt.
|
||||||
|
|
||||||
|
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** (Plan: [docs/design/truck-plan.md](docs/design/truck-plan.md)). Ziel: Nutzer zeichnet 2D-Querschnitt → truck-Extrusion → 3D-Körper + später Boolean gegen Wand/Decke. Umfang gesamt: ~4–6 Wochen. Fortschritt:
|
||||||
|
- [x] Phase 1: Geometrie-Schicht — Crate `src-tauri/trucksolid` (`extrude_polygon_core`/`extrude_circle_core`, `truck_modeling` nur zur B-Rep-Validierung/`try_attach_plane`/`tsweep`, Tessellierung manuell aus den Ring-Koordinaten — truck-rendimesh-Tessellierung für Solids in 0.3 nicht verfügbar, daher Abweichung vom Übergabe-Dokument), WASM-Bindings (Feature `web`) + `build:truck`-Script + `exclude`-Eintrag, TS-Wrapper `src/engine/truckSolid.ts` (`extrudePolygon`/`extrudeCircle`), `RMeshKind` um `"extrusion"` erweitert (`toWalls3d.ts`). Verifiziert: `cargo test` 5/5 grün, `npm run build:truck` sauber, `tsc --noEmit` 0 Fehler, `vitest run` 339/339 grün.
|
||||||
|
- [x] Phase 2 (MVP, Nutzer-Entscheid 2026-07-06: **"Nur Viewer-Wiring"**, kein Werkzeug/Store) — Ende-zu-Ende-Beweis, dass die Pipeline bis ins Bild funktioniert: ein fest verdrahtetes L-Profil (`emitTruckFixture` in `toWalls3d.ts`, 2 m abseits vom Ursprung) wird bei Bedarf per `trucksolid`-WASM extrudiert und erscheint im 3D-Viewport. **Zwei echte Lücken dabei gefunden und geschlossen:** (1) `render3d::types::MeshKind` kannte nur `Terrain`/`Imported` — ein `kind:"extrusion"` ohne passende Rust-Variante hätte die serde-Deserialisierung des GESAMTEN Modell-Pushes zum Absturz gebracht (nicht nur die Fixture); `Extrusion`-Variante + warmes Orange als Default-Farbe ergänzt (`cargo test` render3d 58/58 weiterhin grün). (2) `updateModel(project)` läuft nur bei Projektänderung (`useEffect`-Dep `project`) — die asynchron ladende Fixture hätte trotz Erfolg NIE einen Re-Push ausgelöst; Fix via Browser-Event `TRUCK_FIXTURE_READY_EVENT` (`toWalls3d.ts` dispatcht, `Wasm3DViewport.tsx` hört + stösst `updateModel` erneut an). **Visuell verifiziert** (Playwright, `?engine=wasm`, Chromium mit `--use-angle=metal` für echten WebGPU-Adapter headed): orangene Extrusion sichtbar neben dem Demo-Haus im Viewport. `tsc --noEmit` + `vitest run` 339/339 + `cargo test` render3d 58/58 grün. **Noch uncommittet** (Bearbeiter committet nicht selbst).
|
||||||
|
- [x] Phase 3: UI-Werkzeug + Store-Integration — **erledigt 2026-07-06:** `extrude`-Befehl (`src/commands/cmds/extrude.ts`, Alias „ex") nimmt Polylinie/Rechteck/Kreis als Profil (vorselektiert ODER Klick), Höhe tippen oder Enter für Default; Quell-Zeichnung wird beim Commit ENTFERNT (nicht dupliziert) — der Grundriss zeigt die Extrusions-Footprint-Kontur (`generatePlan.ts`, warmes Orange `#d98c40`) statt der alten Linie. `project.extrudedSolids`-Array statt Fixture. **Volle Auswahl-Parität mit Wand/Decke/Raum** (Nutzer-Entscheid „Voll: wie Wand/Decke/Raum"): eigener Selektionskanal, Klick-Priorität in `PlanView.tsx`, Attribute-Panel-Sektion (Höhe editierbar → löst Re-Extrusion aus, Grundfläche, Geschoss), Löschen, Ctrl+A, `move`-Befehl (`src/tools/transform.ts`). Kreis-Profil nutzt die echte `extrudeCircle`-WASM-Rundextrusion (kein N-Eck-Fallback) über eine 48-Eck-Tessellierung für 2D/Auswahl. Verifiziert: `tsc`/`vitest` 339/339 grün, Playwright-Durchlauf (zeichnen→extrudieren→auswählen→Höhe ändern→löschen) ohne Konsolenfehler. **Bewusst NICHT gebaut** (Konsistenz mit Raum/Decke/Treppe/Öffnung, die das auch nicht haben): Marquee-Auswahl, Klick-Drag-Verschieben. **Noch uncommittet.**
|
||||||
|
- [x] Phase 4: Boolean gegen Wand/Decke — Mesh-CSG statt B-Rep-Boolean. **Spike 1, 2026-07-06 (Monstertruck-Fork getestet, nicht nur Recherche):** eigenes Rust-Testprogramm gegen `monstertruck-solid` 0.3.2 (Fork von `ricosjp/truck`, wirbt mit gehärteten Booleans) — der Crate-eigene Smoke-Test (echter Überlapp, versetzt in allen 3 Achsen) läuft sauber (`or`/`and` liefern exakte Volumina). Aber: **jede Konstellation mit deckungsgleichen/berührenden Seitenflächen schlägt fehl** — exakt der Praxisfall „Extrusion flächenbündig an Wand" oder „Extrusion mit gleichem Wandquerschnitt eingebunden" (gleiche y/z-Ausdehnung wie die Wand, nur in x versetzt): 0 Überlapp → `Internal{operation:"or"/"and"}`-Fehler; 1e-4 UND sogar 0.1 m sichtbarer Überlapp bei deckungsgleichen Seitenflächen → derselbe interne Fehler; 1e-9 Gleitkomma-Rauschen (winziger Spalt statt exakt 0) → **kein Fehler, aber STILL FALSCHES Ergebnis** (2 unverschmolzene Shells, Volumen = Summe statt Vereinigung). Sobald y/z zusätzlich leicht versetzt sind (keine deckungsgleichen Seiten mehr), funktioniert derselbe x-Überlapp einwandfrei. **Schluss: B-Rep-Booleans (truck/monstertruck) sind für diesen Projekt-Praxisfall (Extrusion mit Wandbreite/-höhe eingebunden) grundsätzlich ungeeignet** — teils Absturz, teils stiller Falsch-Wert.
|
||||||
|
|
||||||
|
**Spike 2, 2026-07-06 (`csgrs`, Mesh-Ebenen-CSG/BSP-Baum statt B-Rep):** strukturell anderer Ansatz — rechnet auf Dreiecks-Ebene statt über analytische Kurven/Flächen-Schnitte, damit unempfindlich gegen genau die koinzidenten-Flächen-Fälle, an denen truck/monstertruck scheiterten. Gegen dieselben Testfälle geprüft: generischer Überlapp exakt korrekt (`or`/`and`); flächenbündig (0 Überlapp) → `or` exakt 2.0 (korrekt verschmolzen, kein doppeltes Volumen), `and` liefert korrekt LEER (als Trimesh-Fehler statt sauberem `None` — muss beim Aufrufer als "leer" interpretiert werden); 1e-9-Rauschen → weiterhin exakt korrekt (kein falsches Doppel-Volumen wie bei monstertruck); 1e-4 winziger Überlapp → exakt korrekt; **der kritische Praxisfall (0.1 m Einbindetiefe, deckungsgleicher Wandquerschnitt)** → `or`/`difference` beide exakt korrekt. Jedes Ergebnis analytisch exakt (nicht nur "nah dran").
|
||||||
|
- Packungs-Caveat: alle crates.io-Releases (0.16.0–0.20.1) sind wegen einer harten, zurückgezogenen `core2`-Abhängigkeit nicht installierbar — auf dem unveröffentlichten `main`-Branch bereits behoben. **Nutzerautorisiert** ("Git-Fetch für den Spike erlauben", danach "alles klar mach das" zur Umsetzung als echte Abhängigkeit): `trucksolid/Cargo.toml` pinnt `csgrs` auf Commit `5e7a37a8803d4e56617734687edc9b98f4ebeed7` (Git-Dependency, kein crates.io-Release — Risiko: kein durchnummeriertes/geprüftes Release, sollte bei Gelegenheit auf ein echtes `0.21.0`-Release umgestellt werden, sobald verfügbar).
|
||||||
|
- **Umgesetzt:** `src-tauri/trucksolid/src/boolean.rs` — `boolean_mesh_core(a, b, op)` baut aus flachen Positions-/Indices-Arrays `csgrs::mesh::Mesh`-Polygone (`Polygon::new`/`Vertex::new`, Normale je Dreieck aus dem Kreuzprodukt, NICHT aus den Eingabe-Normalen — die Ebenen-Orientierung fürs BSP kommt aus der Vertex-REIHENFOLGE/Winding, nicht aus einem mitgelieferten Normalenfeld), ruft `union`/`difference`/`intersection` (Trait `csgrs::csg::CSG`) auf, tesselliert das Ergebnis zurück über `Triangulated3D::visit_triangles`. `empty: bool` im Output normalisiert den "Trimesh-Fehler bei leerem Schnitt"-Fall. WASM-Export `boolean_mesh` (Feature `web`, JSON-Schnittstelle wie `extrude_polygon`) + TS-Wrapper `booleanMesh()` in `src/engine/truckSolid.ts`. **Wichtige Voraussetzung für Aufrufer:** beide Eingabe-Meshes müssen konsistent nach AUSSEN gewundene Dreiecke haben (Rechte-Hand-Regel) — falsches Winding liefert ein falsches Ergebnis, OHNE Fehler zu werfen (im eigenen Test zunächst selbst hineingetappt: alle 6 Quader-Seiten der Test-Fixture waren invertiert, dadurch schlugen 3 von 4 Boolean-Tests fehl, bis das Winding korrigiert wurde — reiner Test-Fixture-Bug, nicht im Produktivcode).
|
||||||
|
- Verifiziert: `cargo test` in `trucksolid` 11/11 grün (inkl. `wall_minus_embedded_extrusion_matching_cross_section`, `flush_touching_union_is_exact`), `cargo build --target wasm32-unknown-unknown --features web` sauber (csgrs + truck-modeling gemeinsam im selben WASM-Modul, keine Konflikte), `npm run build:truck` (echter `wasm-pack`-Build) sauber — `boolean_mesh` ist reell im generierten `.d.ts` exportiert.
|
||||||
|
- **Noch offen (bewusst NICHT gebaut, eigene Scope-Entscheidung nötig):** die eigentliche Verdrahtung in die Live-3D-Szene (WANN soll eine Wand automatisch um eine eingebundene Extrusion gekürzt werden? Bei jeder geometrischen Überlappung? Nur bei explizit markierten Fällen?) ist eine UX/Scope-Frage, keine technische — analog zum Feld-Controller-Rückstand unten nicht blind entschieden, sondern auf Nutzer-Entscheid wartend. Aktuell bleibt eine Extrusion weiterhin ein unabhängiges, nicht mit der Wand verschmolzenes Solid im 3D (wie seit Phase 2/3).
|
||||||
|
- [x] Phase 5: Verjüngung (Taper) — `taper` 0 (Prisma) … 1 (Spitze/Kegel-Pyramide), linear zum Profil-Schwerpunkt skaliert. Rust-Kern (`extrude_polygon_core`/`extrude_circle_core` in `lib.rs`), WASM/TS-Wrapper, dritte Befehls-Phase in `extrude.ts` (Höhe → Verjüngung, Enter für persistenten Default), `ExtrudedSolid.taper?`, Attribute-Panel-Feld (editierbar → Re-Extrusion). Tests: Kegel-Volumen (Divergenzsatz gg. analytisch), Pyramidenstumpf-Schwerpunkt-Skalierung, Range-Check.
|
||||||
|
- [x] **Deep-Review + Fixes (2026-07-07, nach Geräte-Absturz der vorherigen Session):** 8-Winkel-Review (Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) über den gesamten uncommitteten Stand ergab mehrere echte Bugs, alle gefixt:
|
||||||
|
- **Konkave Profile falsch trianguliert** (`trucksolid/src/lib.rs`): Deckel/Boden nutzten eine Fächer-Triangulierung ab Vertex 0 — korrekt nur für konvexe/von Vertex 0 aus sternförmige Polygone. Bei einem T-Träger (genau das im Plan genannte Zielprofil) erzeugte das nachweislich Phantom-Dreiecke quer durch die konkave Kerbe (nachgerechnet: 2 von 6 Deckel-Dreiecken lagen mit Schwerpunkt ausserhalb). Fix: Ohr-Clipping-Triangulierung (portiert von der bereits vorhandenen, robusten `render3d::mesh::triangulate`) ersetzt den Fächer; Schwerpunkt-Berechnung für die Verjüngung ebenfalls auf die korrekte flächen-gewichtete Formel umgestellt (vorher reiner Vertex-Mittelwert, bei asymmetrischen Profilen verzerrt). Neuer Regressionstest `t_beam_caps_stay_inside_polygon` (prüft, dass jeder Deckel-/Boden-Dreieck-Schwerpunkt im wahren Polygon liegt). `cargo test` trucksolid 15/15 grün.
|
||||||
|
- **Auswahl-Zustand (`selectedExtrudedSolidIds`) an 6 Stellen in `App.tsx` nicht zurückgesetzt**, wo alle anderen Auswahl-Arten es bereits werden: Geschosswechsel-Effekt, `onOpenStampEditor`, die vier alten three.js-Viewport-Pick-Handler (Wand/Treppe/Decke/Öffnung), `onViewport3dPick`. Ohne Fix: Extrusion bleibt nach Geschosswechsel/anderer Auswahl "geisterhaft" mit-selektiert (falsches Panel, Löschen träfe das falsche Objekt).
|
||||||
|
- **Mirror/Copy ignorierten eine reine Extrusions-Auswahl** (`hasSelection`/`toTransformSel` in `mirror.ts`/`copy.ts` kannten `extrudedSolidId` nicht, obwohl `transform.ts`/`move.ts` es längst unterstützen) → stiller No-op statt Spiegeln/Kopieren.
|
||||||
|
- **DXF-Export verlor den Layer von Extrusions-Footprints**: da `extrude` die Quell-Zeichnung (mit `categoryCode`) entfernt, fiel `layerFor()` in `exportDxf.ts` auf `LAYER_DEFAULT` zurück. Fix: eigener `EXTRUSION`-Layer (gleiches Orange wie 2D/3D-Darstellung).
|
||||||
|
- **`truckMeshCache` (toWalls3d.ts) ohne Eviction** — wuchs unbegrenzt über eine Session (Extrudieren→Kopieren→Löschen in Serie liess Mesh-Daten toter Körper im Speicher). Fix: gelöschte Ids werden bei jedem `emitExtrudedSolids`-Lauf aus dem Cache entfernt. **Gleichzeitig behoben:** sichtbares Flackern beim Tippen von Höhe/Verjüngung im Attribute-Panel (jeder Tastendruck = neue Signatur = Cache-Eintrag wurde sofort auf `mesh:null` gesetzt, bevor die neue Extrusion geladen war) — die alte Mesh bleibt jetzt sichtbar, bis die neue fertig ist.
|
||||||
|
- **`fitTargetDist` im 3D-Viewport** (Wasm3DViewport.tsx) rief bei JEDER Projekt-Änderung (auch während eines laufenden Griff-Drags) unnötig die komplette Wand-/Öffnungs-/Decken-Flatten-Pipeline auf, nur um eine Bounding-Box für die Schnittebene zu ziehen — die aber nur bei aktiver Schnittebene gebraucht wird. Fix: Aufruf hinter `sectionActive` gegated.
|
||||||
|
- **Nutzer-Report währenddessen:** Schnittebene-Umschalter (Wasm3DViewport.tsx) hatte `title`/`aria-label` hart auf Deutsch verdrahtet statt über `t()` — einzige Stelle im 3D-Viewport, die das i18n-System umging. Fix + Audit der ganzen App auf dasselbe Muster (title/aria-label/placeholder ausserhalb `t()`): genau eine weitere Stelle gefunden (`ColorHexField.tsx` Swatch-Tooltip „Farbe wählen"), beide jetzt über neue i18n-Keys (`viewport3d.section.*`, `attr.pickColor`).
|
||||||
|
- Verifiziert nach jedem Schritt: `tsc --noEmit` sauber, `cargo test` trucksolid grün. `vitest run`/`cargo test render3d` am Ende der ganzen Fix-Reihe erneut komplett gegengeprüft (339/339, 58/58, 15/15).
|
||||||
|
|
||||||
|
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
|
||||||
|
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d` — `a78e7c7`
|
||||||
|
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
|
||||||
|
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
|
||||||
|
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
|
||||||
|
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. ✅ **Phase 5 komplett (2026-07-07):** `opening` portiert (`wall_axis_length`/`opening_interval`/`wall_axis_frame`/`opening_jambs`/`opening_gap_quad`/`opening_center`/`door_symbol`/`window_symbol` + `along`-Helper, geflachte Signaturen nach PORT_PLAN; `openingVerticalExtent` bleibt bewusst TS — echte Modell-Kopplung an drawingLevels). +7 Parity-Tests (Suite 40 Parity). `cargo test` kernel2d 18/18, `build:kernel2d` sauber, vitest 348/348. Produktive Aufrufer unverändert (Phase 6 weiter offen/Nutzer-Entscheid). **Noch uncommittet.**
|
||||||
|
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
|
||||||
|
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
|
||||||
|
|
||||||
|
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
|
||||||
|
|
||||||
|
- [x] ~~**Snap an Wand-Schichttrennlinien**~~ — **gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
|
||||||
|
|
||||||
|
## ⏭️ Als Nächstes
|
||||||
|
|
||||||
|
- [ ] **BIM-Elemente „wirklich 1:1" (Tür/Fenster/Dach/Decke)** — volle Studie + Priorisierung: **[docs/design/bim-elements-depth-study.md](docs/design/bim-elements-depth-study.md)** (623 Zeilen, Referenzmatrix VW/ArchiCAD/Revit/Allplan gegen IST). ✅ **Bereits erledigt 2026-07-10:** Fenster-Schachtelung Blendrahmen→Flügelrahmen→Glas + fest/öffenbar + Einbaulage 2D (`9a38636`); Mansard-Untertypen Giebel/Walm/Zelt + Knick (`bfb80b3`); Dach-Dicke 3D (`e99bb24`) + **Dach-Schichtlogik RoofType/Layer[]** (`3a986ec`); zweiflügelige Türen leafCount 2D+3D (`4319e12`); getrennter Traufe/Ortgang-Überstand (`c9baff5`); Glas/glazingPanes 2D+3D + Rollladenkasten (`2e13ec3`/`d131683`). **Offen (nach Doc-Priorität):**
|
||||||
|
- [x] ~~**Decken-Aussparungen**~~ — **erledigt 2026-07-10 (`37f4fe8`+`cdbef99`+`aee8846`+`83dcdd0`):** `Ceiling.openings?: Vec2[][]` + **Brücken-Trick** (`mergeHoles` in `src/geometry/polygonHoles.ts`: Löcher über schmale Brücken in den Aussenring → EIN einfacher Ring, paritätskorrekt für SVG/Hatch/Ear-Clipping/Pick — KEIN Loch-Support in den Primitiven nötig, kein Rust-Change). 2D-Poché-Loch (Brückenkanten via noStrokeEdges) + 3D-Slab-Loch; Befehl „Deckenloch" (Aliase deckenloch/aussparung, BIM-Ribbon); Panel listet Aussparungen mit Entfernen. +11 Tests.
|
||||||
|
- [x] ~~**Dach in 2D-Aufsicht + Vertikalschnitt**~~ — **erledigt 2026-07-10 (`9a00c8e`+`f248e2a`):** Grundriss-Aufsicht trägt die Ansichts-Schraffur (viewHatchId) der Eindeckungs-Schicht; Vertikalschnitt via `appendRoofSections` (analytischer TS-Schnitt: Cyrus–Beck gegen die Aufsichts-Polygone, lineare Oberkante, Dicke/cos(Neigung), Schicht-Bänder aussen→innen mit Bauteil-Schraffur). **Offen:** Ansichts-/Silhouettenkanten des Dachs in Elevationen (nur Schnitt-Polygone bisher).
|
||||||
|
- [x] ~~**RoofType-ResourceManager-Tab**~~ — **erledigt `83abc1d`** (RoofStylesTab auf LayeredStylesTab-Rumpf; anlegen/editieren/löschen, Löschen geschützt bei Verwendung).
|
||||||
|
- [ ] **Decken-Randschicht-Override** (`Ceiling.edgeOverride`, Ringzone anderer Aufbau, Tropfkante/Randdämmung) — P1; **Deckentrenn-Werkzeug** für thermisch getrennte Auskragung (Isokorb, UI-only) — P1.
|
||||||
|
- [ ] **Tür 1:1 Rest:** `glazingRatio` (Teilverglasung), Kassettentür-Geometrie, `frameKind` im 3D (Zarge vs. Blockrahmen), echtes Schwellenprofil; Alt-`Door[]`-Pfad in `Opening` konsolidieren (technische Schuld, zwei Datenwege).
|
||||||
|
- [ ] **Fenster 1:1 Rest:** asymmetrische Rahmenbreiten, Bank/Nische, Laibungsverkleidung, Form (Rund/Spitz/Schräg). ~~echtes Sprossengitter~~ ✅ **erledigt 2026-07-12 (`00bff27`):** `mullionCols` (vertikale Sprossen-Spalten) spiegelbildlich zu `mullionRows` — 3D-Rahmen-Riegel, Ansichts-Trennlinien + Glasscheiben als echtes rows×cols-Raster, Eingabefelder in `OpeningEditorDialog`/`ResourceManager`.
|
||||||
|
- [ ] **Dach 1:1 Rest:** Kniestock/Drempel, Krüppelwalm, Kehlen bei L-Grundriss (Straight-Skeleton), Gauben/Dachfenster, Aufschieblinge. ~~Dach im Vertikalschnitt~~ ✅ `f248e2a`.
|
||||||
|
- [ ] **Geneigte Decke** (Rampe/Gefälle), Deckenspiegel (zweite abgehängte Fläche) — P2/P3.
|
||||||
|
- **Nicht bauen** (Doc §6): VW-Massketten-Apparat, volle Sichtbarkeitsmatrix, Eck-Fenster/-Dach generisch, Beschlags-Produktbibliothek, IFC-Void-Semantik nachrüsten.
|
||||||
|
|
||||||
|
- [ ] **`make2D`-Befehl (Sicht → 2D-Zeichnung mit Füllungen)** (Nutzer-Wunsch 2026-07-10). Aus der AKTUELLEN Sicht — egal ob Grundriss, Schnitt oder 3D — eine flache 2D-Zeichnung aus reinen 2D-Geometrien erzeugen (Linien + Füllungen/Schraffuren, „mit allem"). Zwei Ausgaben: (a) als neue `Drawing2D`-Elemente ins Modell einfügen (auf einer Ziel-Ebene), ODER (b) in die Zwischenablage kopieren (SVG/DXF-Fragment) zum Einfügen anderswo. Vorbild: Vectorworks „2D-Darstellung erzeugen" / Rhino `Make2D`. **Bausteine vorhanden:** Grundriss/Schnitt laufen bereits über `generatePlan()`/`generateSectionPlan()` → RScene → SVG (`sceneToPrintSvg`); für 3D braucht es eine Projektion (HLR/Silhouette) der `projectToModel3d`-Meshes auf die Bildebene (neuer Teil). MVP: Grundriss/Schnitt → Drawing2D + Clipboard; 3D-Projektion als zweite Phase. Scope/Format (SVG vs. DXF vs. native Drawing2D) mit Nutzer schärfen.
|
||||||
|
|
||||||
|
- [x] ~~**Dächer: Auswahl + Attribut-Editieren + Löschen**~~ — **erledigt (`80121a3` + Folge-Commits `7cfdf59`/`eaf57e2`/`64f6179`/`826685c`):** Klick-Auswahl (Traufe-Pick-Polygon + pickRoof), `selectedRoofIds`/`updateRoof`/`RoofInfo`/`roofSelection`; RoofSection-Panel voll editierbar (Form, Firstrichtung X/Y, Neigung(en), **Breite/Tiefe**, Überstand, Dicke, **Traufhöhe**) + Firsthöhe/Fläche read-only; **Auswahl-Hervorhebung im 2D UND 3D** (Draht-Umriss); Löschen; Abwählen an ALLEN Reset-Stellen. **Optional Folge:** ~~3D-Griffe zum Ziehen (Traufe/First)~~ ✅ **erledigt `4ef40a0`** (Eckpunkt-Resize + Verschieben + First-Griff für Neigung). **Noch offen:** Dachfenster, Kehlen bei nicht-rechteckigem Grundriss (Straight-Skeleton).
|
||||||
|
|
||||||
|
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + +-Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
|
||||||
|
|
||||||
|
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~ — **erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
|
||||||
|
|
||||||
|
## 📋 Backlog (Priorität grob absteigend)
|
||||||
|
|
||||||
|
- [x] ~~**Shift-Ortho beim Körper-Verschieben (2D) fehlte**~~ — **erledigt 2026-07-06:** Nutzer-Report — Shift zum H/V-Einrasten wirkte beim Ziehen eines Vertex-Griffs (`onGripMove`) und beim freien Kanten-Zug (`onEdgeMove`), aber NICHT beim Verschieben eines ganzen Elements per Körper-Griff (`onMoveBody` — Wand/2D-Element/Decke/Treppe/Raum als Ganzes): `PlanView.tsx` reichte `mods` an dieser einen Stelle schlicht nicht durch. Fix: `GripHandlers.onMoveBody` bekommt optionales drittes Argument `mods: ToolMods`, `App.tsx` zwingt bei `mods.shift` das Delta auf die dominante Achse (H/V) — dieselbe Regel wie beim freien Kanten-Zug. `tsc`/`vitest` 339/339 grün.
|
||||||
|
- [ ] **Feld-Controller (getippte Länge/Winkel relativ zum Ausgangspunkt) fehlt beim Körper-Verschieben + im 3D.** Nutzer-Wunsch 2026-07-06: „allgemein brauchen wir ein System bei 2D- und 3D-Elementen, dass wenn man einen Punkt anwählt, man ihn verschiebt — mit der Option, Länge und Winkel relativ zur alten Position zu wählen." Bestandsaufnahme: **existiert bereits** für den 2D-Vertex-Griff-Drag (`gripEditRef`/„Feld-Controller", Tab öffnet/zykelt Länge↔Winkel-Sperre, `gripEditPoint` in `App.tsx`) — fehlt aber (a) beim 2D-Körper-Verschieben (`onMoveBody`, ganzes Element ziehen) und (b) komplett im neuen 3D-Griffsystem (`Wasm3DViewport.tsx`, s. „3D-Griffe/Editieren" oben — weder Vertex- noch Verschiebe-Griff haben dort eine Locks/HUD-Eingabe). Für (b) zusätzlich zu klären: wie eine getippte Zahl im 3D-Viewport erfasst wird, ohne mit der freien Maus-Navigation (Orbit/Pan) zu kollidieren — eigenes UI-Element (z. B. ein kleines Eingabefeld neben dem gezogenen Griff-Button) naheliegend, aber nicht mit dem 2D-HUD-Muster identisch übertragbar. Mit Nutzer Scope/Reihenfolge klären (2D-Körper zuerst, da mechanische Erweiterung des bestehenden Feld-Controllers; 3D danach als grössere UI-Frage).
|
||||||
|
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
|
||||||
|
- [x] ~~**Kreis-Toolbar** (trivial)~~ — **erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
|
||||||
|
- [x] ~~**Bogen-Werkzeug** (mittel)~~ — **erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
|
||||||
|
|
||||||
|
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 1–6) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Fenster/Tür-Presets) — Feinpolish. ✅ **`swing_invert` gilt als abgedeckt** (2026-07-17): die Aufschlagseite ist über `Opening.swing` (links/rechts) UND der Scharnier-Pfosten über `Opening.hinge` (start/ende) bereits voll im Objekt-Info wählbar (4 Kombinationen = jede Schwenklage), ein separater Invert-Flag wäre redundant. ✅ **Treppen-Pfeil-Style `filled` erledigt 2026-07-17** (uncommittet): `Stair.arrowStyle?:"line"|"filled"` (additiv), gefülltes Dreieck-Polygon statt zwei offener Linien in `addStairSymbol` (`generatePlan.ts`), Segment-Umschalter im Objekt-Info (`onSetStairArrowStyle`), `StairInfo.arrowStyle`, +2 Tests (`generatePlan.stairArrow.test.ts`). tsc/vitest 869 grün. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
|
||||||
|
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT** — `parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings` → `{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen** — `Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
|
||||||
|
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
|
||||||
|
|
||||||
|
- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** ✅ **Phase 1 (Wand unter Schnittebene) erledigt (2026-07-05):** `generatePlan.addWallPoche` hat einen `viewOnly`-Zweig — erreicht eine Wand die Grundriss-Schnitthöhe nicht (`wall.height < floor.cutHeight`, z. B. 0.3-m-Brüstung bei 1 m), wird sie nur als Ansichts-Umriss (Haarlinie, `fill:"none"`, keine Schraffur) gezeichnet statt als Schnitt-Poché; normale Wände (≥ Schnitthöhe) unberührt. Segment-/Gehrungs-/Öffnungs-Logik geteilt. +2 Tests (`generatePlan.viewwall.test.ts`, Suite 336). ✅ **Phase 2 (Decke über Ebene = gestrichelte Überkopf-Linie) erledigt (2026-07-05, `39ddd9b`):** der freie Decken-Umriss (Überstände/Balkone, wo keine Wand verdeckt) ist jetzt gestrichelte Haarlinie (`OVERHEAD_DASH`) statt kräftiger Volllinie — BIM-Konvention „Aufsicht auf Bauteil über einem". Decken-FLÄCHE war schon Ansicht (viewHatchId, weiß). +1 Test. **Offen (Phase 3):** per-Component View-/Cut-Weight + eigene View-Schraffur (Slot-Trennung); cut/Aufsicht/Untersicht (Deckenspiegel/Reflected Ceiling Plan); Unterzüge; Feinheiten. Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
|
||||||
|
- Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
|
||||||
|
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
|
||||||
|
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
|
||||||
|
- **Passt konsistent zum bereits existierenden `viewHatchId` (Ansichts-Schraffur) vs. Schnitt-Schraffur** — die Linienstärke-Dualität (View-/Cut-Weight je Component) ist die natürliche Erweiterung derselben Logik.
|
||||||
|
- **Nicht nur cut/view, sondern cut / AUFSICHT / UNTERSICHT (Nutzer):** ein Bauteil sieht von oben anders aus als von unten. Der Grundriss (Blick nach unten) zeigt Bauteile UNTER der Ebene in **Aufsicht** (Oberseite); ein **Deckenspiegel/Reflected Ceiling Plan** (Blick nach oben) zeigt Bauteile ÜBER der Ebene in **Untersicht** (Unterseite — z. B. Kassettendecke, Leuchten). Also je Component potenziell **drei** Darstellungs-Slots (Schnitt / Aufsicht / Untersicht) × {Schraffur + Linienstärke}. Das heutige `viewHatchId` ist faktisch EIN View-Slot und vermischt Auf-/Untersicht; sauber wäre die Trennung. WELCHER Slot gilt, entscheidet die **Blickrichtung der Sicht** (Grundriss ↓ / Deckenspiegel ↑) UND die z-Lage relativ zur Schnittebene.
|
||||||
|
- **Verallgemeinert den Decken-Footprint-Clip** (`a2f6923`): „Decke unter Wand verdeckt" ist ein Spezialfall von „Ansichtsbauteil vs. schneidende/überdeckende Bauteile".
|
||||||
|
- Aufwand: mittel–groß, phasenweise machbar (1: z-Extent-vs-Schnitthöhe-Klassifikation cut/above/below; 2: Ansichtslinie-Weight je Component + Haarlinie/gestrichelt; 3: 30-cm-Wände & Slabs verdrahten). Nicht zu komplex im Konzept — es ist der reguläre CAD/BIM-Weg (ArchiCAD/Vectorworks/Revit). Mit Nutzer Detailgrade/Defaults festlegen.
|
||||||
|
|
||||||
|
|
||||||
|
- [x] ~~**Ebene-Schraffur editierbar**~~ — **gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
|
||||||
|
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
|
||||||
|
- ✅ **swissBUILDINGS3D 1:1 + swissALTI3D-Terrain erledigt 2026-07-12 (`ed724be`):** Root Cause war `swissTopo.ts::fetchBuildings` (generalisierter 1:25'000-Kartografie-Layer, nur Grundriss, `DEFAULT_BUILDING_HEIGHT=9`-Pauschalkiste) + grobe `profile.json`-Terrain-Näherung. Fix: neues `stacApi.ts` (gemeinsamer STAC-Client) + `swissBuildings3d.ts` (echte Wände/Dach-Meshes aus swissBUILDINGS3D-DXF-Kacheln, Generation **2.0 (stabil) UND 3.0 (Beta)** wählbar, über den bestehenden `dxfParser.ts` eingelesen — kein neuer Parser nötig) + `swissAlti3d.ts` (echtes swissALTI3D-Höhenraster, **0.5 m/2 m** wählbare Punktdichte statt Näherung). `ContextImportDialog.tsx` entsprechend erweitert (Gebäude-Modus aus/vereinfacht/2.0/3.0, Terrain-Auflösung). Referenz war das Rhino-Vorgänger-Plugin (`git.kgva.ch/karim/DOSSIER`, `rhino/swisstopo.py`). Pipeline nutzt **render3d** (nicht Three.js, Nutzer-Entscheid 2026-07-12 „scheiss auf three.js darstellung render3d ist fokus").
|
||||||
|
- ~~**Nordstern-Geo-Rendering**~~ — **war bereits erledigt** (`35299307d`, 2026-07-09, stand hier fälschlich noch als offen): `emitMeshes` in `toWalls3d.ts` liest `project.context` (`importedMesh`/`terrainMesh`) bereits und speist sie in `projectToModel3d` ein — keine weitere Verdrahtung nötig, war Voraussetzung für den swissBUILDINGS3D-Fix oben und stand schon.
|
||||||
|
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
|
||||||
|
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
||||||
|
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
|
||||||
|
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D bei manuellem Import — der Mesh-Pfad selbst ist über `dxfParser.ts` schon 3D-fähig, s. o.); Building-Draping; höhere DTM-Auflösung.
|
||||||
|
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~ — **bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
|
||||||
|
- [x] ~~**Einstellungs-Fenster (Rest)**~~ — **erledigt:** Verdrahtung war schon **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`); der einzig offene Punkt (Snap/Endpunkt-Default „aki") ist längst entschieden (2026-07-04: Sora #5FA1C9, s. „❓ Offene Rückfragen"). Zeile war stehen geblieben, obwohl die Frage schon geschlossen war.
|
||||||
|
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||||
|
- [x] ~~**Tragwerk-Start: Stützen (Column) — MVP end-to-end**~~ (DOSSIER-Audit A4) — **erledigt 2026-07-07:** `Column`/`ColumnProfile` (rect/round) als platzierte Profil-Extrusion, `Project.columns`, `columnFootprint()` (gemeinsame Geometrie 2D/3D/Selektion/Transform), `columnVerticalExtent` (UK/OK-Anker wie Wand). Platzieren via BIM-Ribbon-Befehl (`column`, Alias stütze; Default Rect 0.3×0.3, Höhe=Geschosshöhe, Rechteck/Rund-Toggle + Live-Vorschau), 2D-Poché auf Kat. 50 (`addColumnPoche`, Component/Hatch-Auflösung), 3D-Prisma (`emitColumns`, synchron, Rust unberührt), eigener Selektionskanal (ALLE Reset-Stellen gespiegelt), `ColumnSection` (Profil/Masse/Höhe/Drehung editierbar), Löschen/move/copy/mirror, DXF-Layer, i18n. +8 Tests (2D-Position/Rotation, 3D-Box/Prisma, vertikale Lage). tsc/vitest 422. **Bewusst ausgelassen:** 3D-Pick (wie ExtrudedSolid), Marquee, Schedule-Zeile, eigene ToolId (Extrude-Muster gefolgt). **Noch uncommittet. Nutzer prüft 3D/2D visuell.**
|
||||||
|
- [x] ~~**Schnellexport-Dialog (Format + Dateiname) für Topbar-Export**~~ (Nutzer-Wunsch) — **erledigt 2026-07-07:** Klick auf CSV/IFC/OBJ/STL im Topbar-Export-Menü öffnet jetzt `ExportSaveDialog` (Format-Dropdown + Dateiname-Feld, Endung folgt dem Format, Enter/Speichern), statt sofort mit Default-Namen zu laden. PDF/DXF behalten ihre eigenen Options-Dialoge. `runExport(format, filename)` in App.tsx erzeugt+lädt. +i18n `exportSave.*`. tsc/vitest 434. **Offen (bewusst, Nutzer nannte es selbst als Folge):** echter Speicherort-Picker („wo") braucht den nativen Tauri-Save-Dialog (plugin-dialog/fs, Rust) — aktuell Download-Ordner + frei wählbarer Dateiname; die späteren „Ausschnitte" bekommen vordefinierten Namen+Ort. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Nativer Tauri-Speichern-Dialog für Exporte**~~ (Nutzer „ausnahmsweise JA") — **erledigt 2026-07-07:** `@tauri-apps/plugin-dialog`+`plugin-fs` eingebunden (Cargo/lib.rs/capabilities `dialog:allow-save`+`fs:allow-write-text-file`, `cargo check` grün), Util `src/io/saveFile.ts` (`saveTextFile`: unter Tauri nativer „Speichern unter"-Dialog `save()`+`writeTextFile`, sonst Blob-Fallback mit octet-stream + verzögertem revoke; +2 Tests). App.tsx-`downloadTextFile` ruft jetzt `saveTextFile` (Import ergänzt). **Nutzer testet den nativen Dialog in Tauri.** **Scope-Hinweis:** falls fs-Scope-Fehler, `fs:scope` `$HOME/**` nachrüsten. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Ausschnitte liessen sich nicht speichern (Tauri)**~~ — **erledigt 2026-07-08:** Ursache `window.prompt` ist im Tauri-WKWebView DEAKTIVIERT (→ null, kein Name, nichts gespeichert). `ViewSnapshotsPanel` nutzt jetzt In-App-Inline-Eingaben statt prompt/confirm (Speichern + Umbenennen inline, Löschen ohne confirm). tsc/vitest 465.
|
||||||
|
- [x] ~~**Layouts als Ordner-/Baumstruktur**~~ — **erledigt 2026-07-08:** Modell additiv `LayoutFolder{id,name,parentId?}` + `Layout.folderId?` + `Project.layoutFolders?`; freie Grösse `customWidthMm?`/`customHeightMm?` an Layout UND MasterLayout (übersteuert paper/orientation), zentraler Helfer `effectiveSheetSizeMm()`. Pure Logik `layoutModel.ts`: `buildLayoutTree`, `collectFolderLayouts`, `deleteFolder` (Kinder reparent auf Elternebene), `folderPdfPages`. `LayoutsPanel.tsx` als Baum (auf/zuklappbare Ordner, Master als eigener Vorlagen-Abschnitt), EIN „+"-Dropdown {Ordner·Layout·Masterlayout}, neues Element landet SOFORT im Inline-Rename (ViewSnapshotsPanel-Muster, kein window.prompt). Zwei Erstell-Dialoge (`LayoutCreateDialogs.tsx`): Layout mit Master-Vorlage-Dropdown ODER freie Grösse (A4/A3/Custom-mm); Masterlayout mit Format/freie Grösse+Ausrichtung. **Ordner→Mehrseiten-PDF funktioniert:** `src/export/layoutPdf.ts` (`buildFolderPdf`/`saveFolderPdf`, jsPDF, `addPage` je Layout mit eigener Grösse, Viewports via generatePlan→planToPrintSvg, Master-Titelblock als Vektor), neuer `saveBinaryFile` in `saveFile.ts` (nativer Speichern-Dialog für PDF-Bytes). +14 Tests (Custom-Grösse, Ordner-CRUD, Baumaufbau inkl. verwaister Refs, Reparenting, PDF-Seitenplan). **Nachtrag (Orchestrator):** `LayoutSheet.tsx` (In-Viewport-Editor, vom Agent bewusst nicht angefasst) nutzte noch `sheetSizeMm(layout.paper,...)` ohne Custom-Grösse zu respektieren — auf `effectiveSheetSizeMm(master ?? layout)` umgestellt (identische Regel wie der PDF-Export), damit Blätter mit freier Grösse auch im Viewport korrekt dargestellt werden. tsc/vitest 505. **Noch uncommittet. Nutzer prüft visuell in Tauri** (+-Menü, Ordner-Zuordnung, Dialoge, Mehrseiten-PDF-Reihenfolge/Grössen).
|
||||||
|
- [x] ~~**Icons/A0-A6+B0-B6/aktiver Ordner/Drag&Drop im Layouts-Baum**~~ — **erledigt 2026-07-08:** eigene inline-SVG-Icons (`FolderIcon` auf/zu, `LayoutSheetIcon`, `MasterSheetIcon` mit Stern-Akzent), Muster wie die bestehenden Tab-Icons. `LayoutPaperFormat` volle ISO-216-Reihe A0–A6+B0–B6 (`PAGE_MM`/`PAPER_FORMATS`/`PAPER_FORMAT_GROUPS`), Dropdowns gruppiert A-/B-Reihe. „Aktiver Ordner" war schon korrekt (verifiziert, keine Änderung nötig). HTML5-Drag&Drop (Layout/Ordner zwischen Ordnern, Root-Drop, Ziel-Highlight) mit Zyklus-Schutz (`isDescendant`, `moveFolderToFolder` verwirft Selbst-Verschachtelung als No-op). `layoutPdf.ts`/`LayoutSheet.tsx` bekommen die neuen Formate automatisch (laufen über `effectiveSheetSizeMm`/`PAGE_MM`, kein Hardcoding). +14 Tests. tsc/vitest 519. **Noch uncommittet. Nutzer prüft visuell.**
|
||||||
|
- [x] ~~**Ausschnitte-Panel: Footer-Bar + Anwahl-Persistenz**~~ — **erledigt 2026-07-08 (Agent starb erst beim Schluss-Testlauf, aber vollständig verdrahtet + grün):** Footer im ViewSnapshotsPanel zeigt bei angewähltem Ausschnitt Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal `boolMapEqual`/`matchingComboName` gegen gespeicherte Combos), aktive Override-Namen, + Inline-Rename in der Footer-Bar. Anwahl bleibt markiert bis Abweichung: `selectedViewSnapshotId`-State + `snapshotMatchesLiveState`-Vergleich in App.tsx (bei Änderung eines erfassten Feldes → Auswahl weg). host.ts um `selectedViewSnapshotId`/`listLayerCombos`/`loadLayerCombo`/`listDrawingCombos`/`loadDrawingCombo` erweitert. tsc sauber, vitest 532 (+13). **Noch uncommittet. Nutzer prüft visuell.**
|
||||||
|
- [x] ~~**Ausschnitte-Panel: Ordner wie bei Layouts**~~ — **erledigt 2026-07-08:** echte Ordner-/Baumstruktur (spiegelbildlich zu Layouts). Modell additiv `ViewSnapshotFolder{id,name,parentId?}` + `ViewSnapshot.folderId?` (altes `folder?`-Namensfeld bleibt LEGACY-kompatibel), `Project.viewSnapshotFolders?`. Pure Baum-Logik `src/state/viewSnapshotFolders.ts` (`buildViewSnapshotTree`/`moveSnapshotToFolder`/`moveFolder` mit Zyklus-Schutz/`deleteFolder`-Reparent, +20 Tests). Panel: auf/zuklappbare Ordner, zwei Header-Buttons (FolderPlus/Plus), aktiver-Ordner-State, Inline-Rename, Löschen, Drag&Drop. **Footer-Bar + Anwahl-Persistenz UNVERÄNDERT erhalten** (unter dem Baum). +i18n. tsc/vitest 558. **Noch uncommittet. Nutzer prüft visuell.**
|
||||||
|
- [x] **2D-Zeichnen auf „Zeichnung"-Ebenen UND auf Layout-Blättern freigeben; 3D/BIM dort sperren (Nutzer-Wunsch 2026-07-08):** ✅ **ALLE drei Teile erledigt 2026-07-08.** **TEIL B erledigt 2026-07-08:** 2D-Annotationen (Linie/Rechteck/Text in mm-Papier) direkt auf Layout-Blättern — `LayoutAnnotation` an `Layout` (additiv) + CRUD (`layoutModel.ts`: create/add/remove/patchAnnotation) + Editor in `LayoutSheet.tsx` (Tool-Buttons, zeichnen/Vorschau/commit, `pickAnnotation`/`translateAnnotation` in `layoutSheetMath.ts`, Selektion+Verschieben+Löschen, HUD mit Farbe/Stärke, `PromptDialog` für Text — kein window.prompt) + Handler in App.tsx. Tests: `layoutModel.test.ts` + `layoutSheetMath.test.ts`, Suite 588 grün. Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping, Sheet-Clamping beim Verschieben. **TEIL A erledigt 2026-07-08** — die 2D-Zeichenwerkzeuge (select/line/polyline/rect/circle/arc/text) sind bereits nicht `floorOnly`, die zugehörigen Commands (line/rect/polyline/circle/arc/text) auch nicht → die Command-Engine erlaubt das Zeichnen auf jeder Ebene, und eine `"drawing"`-Ebene rendert schon die volle interaktive `LevelPlanView` (App.tsx:5515). Der einzige echte Blocker war das UI-Gating: die **ToolsPanel-Sidebar** sperrte ALLE Werkzeuge ausser „select", sobald `!toolsEnabled` (App.tsx `activeLevel.kind === "floor"`). Fix: `ToolsPanel.tsx` gatet jetzt per `tool.floorOnly && !toolsEnabled` — **Parität mit dem Ribbon** (`RibbonBar.tsx`, das schon so gatete). Damit sind auf „drawing"-Ebenen die 2D-Tools nutzbar, BIM/3D-Tools (wall/ceiling/window/door/stair/room/column, alle `floorOnly`) bleiben grau. `AttributesPanel.tsx:78` bleibt unverändert (gatet nur den Wandtyp/Deckentyp-Picker, der ohnehin nur bei floorOnly-Tools erscheint). Verifiziert: `tsc` sauber, `vitest` 558/558. TEIL B (grösser, OFFEN): **auf Layout-Blättern 2D zeichnen** — der `LayoutSheet`-Viewport-Editor kann bisher nur Viewports platzieren; für Annotationen (Linien/Text/Rechtecke direkt aufs Blatt) fehlt ein Annotations-Datenmodell am `Layout` + das Zeichnen/Rendern auf dem Blatt (mm-Koordinaten). Scope Teil B mit Nutzer/als eigene Phase. **TEIL C erledigt 2026-07-08 („endlich"): Text-Platzierungswerkzeug als 2D-Element.** Das `{shape:"text"}`-Drawing2D-Modell + Rendering (`generatePlan` → `drawingText`) + Transform (`transform.ts` `at`) existierten schon (DXF-Import-Weg); es fehlte nur das PLATZIER-Werkzeug. Umgesetzt: neues `text`-Command (`src/commands/cmds/text.ts`: Ankerpunkt klicken → Freitext in der Command-Line eingeben → commit `Drawing2D {shape:"text", height:0.25m, angle:0}`), Placeholder-Tool `text` (nicht floorOnly) gekoppelt via `TOOL_COMMAND`, in Registry + Aliase (tx/txt/beschriftung) + `ToolId` + `TOOL_ORDER` + Ribbon-2D-Gruppe + ToolIcon (Serifen-A) + i18n (de/en). Neu: die Command-Engine routet jetzt Freitext-Eingabe (`AcceptKind` um `"text"` erweitert, `engine.ts` `routeTypedInput` reicht bei `accepts:["text"]` die getippte Zeile 1:1 durch — vor der numerischen Auflösung, damit auch Zahlen/Kommas als Label ankommen). Nutzer-Test: Text-Tool wählen → in den Plan klicken → Label tippen → Enter. **Noch uncommittet, WASM-unabhängig (rein TS).**
|
||||||
|
- [ ] **Masterlayout im Viewport ansehen + Elemente darauf platzieren (Nutzer-Wunsch 2026-07-08) — NACH Rollback+Footer-Agenten (gleiche Dateien LayoutsPanel/LayoutSheet):** ein Masterlayout soll wie ein Layout im `LayoutSheet`-In-Viewport-Editor geöffnet werden können, um Plankopf-Elemente/Rahmen VISUELL darauf zu platzieren (statt nur über ein Formular mit festen Titelblock-Textfeldern). `LayoutSheet.tsx` arbeitet aktuell nur mit `Layout` (hat `viewports: LayoutViewport[]`); für Master fehlt sowohl das Öffnen im Viewport als auch ein Datenmodell für frei platzierbare Grafik-/Text-Elemente (Plankopf-Felder, Rahmen-Linien) auf dem Master. Scope/Umfang mit Nutzer klären, bevor gebaut wird: welche Elementarten (Text-Feld mit Platzhalter-Variable wie {ProjectName}/{Scale}/{Date}, Linie/Rechteck als Rahmen, Logo/Bild?), wie sie sich von den normalen `LayoutViewport`s unterscheiden (kein Ausschnitt-Bezug, rein grafisch), Doppelklick-Navigation vom `LayoutsPanel` aus zum Öffnen eines Masters im Viewport (analog `onOpenLayout`).
|
||||||
|
- [x] ~~**LayoutsPanel: Doppel-Name + zwei Buttons je Abschnitt + Master in Ordnern**~~ — **erledigt 2026-07-08:** Doppel-„LAYOUTS" behoben durch Umbenennung des ÄUSSEREN Panel-Titels auf „Mappe" (DE) / „Portfolio" (EN, neuer Key `layouts.panelTitle`); innere Abschnitte bleiben „Layouts"/„Masterlayouts". Kombiniertes „+"-Dropdown ersetzt durch zwei Icon-Buttons je Abschnitt: `FolderPlusIcon` (Ordner) + `PlusIcon` (Layout bzw. Masterlayout). Masterlayouts jetzt in eigenen Ordnern: `MasterLayout.folderId?` + `LayoutFolder.kind?:"layout"|"master"` (fehlt=layout, rückwärtskompatibel), getrennte Bäume (`buildMasterTree`, kind-Filter), eigener `activeMasterFolderId`. Bug gefixt: `deleteFolder`/`moveFolderToFolder` verloren `kind` beim Reparent → `stripParentId`. +6 Tests. PanelFrame unangetastet. tsc/vitest 538. **Bewusst offen:** Drag&Drop im Master-Baum (bestehende Master nur beim Anlegen in Ordner). **Noch uncommittet. Nutzer prüft visuell.**
|
||||||
|
- [x] ~~**Fixer, unlöschbarer „Masterlayout"-Ordner**~~ — **VERWORFEN 2026-07-08 (Nutzer-Kurskorrektur):** gebaut, dann sofort zurückgebaut — Nutzer sah die bestehende Lösung (Masterlayouts als separate Kategorie/Abschnitt ausserhalb der Ordnerstruktur, aus dem vorherigen Layouts-Agenten) live und wollte sie explizit BEHALTEN, keinen Ordner. Vollständig zurückgerollt (`layoutModel.ts`/`LayoutsPanel.tsx`/`App.tsx`/i18n), verifiziert tsc sauber + vitest exakt Baseline 519 (keine Regression). Masterlayouts bleiben separater Abschnitt.
|
||||||
|
- [x] ~~**`window.prompt`/`confirm` überall ersetzen (Tauri-WKWebView deaktiviert sie) — SWEEP**~~ — **erledigt 2026-07-08:** neue generische `src/ui/PromptDialog.tsx` (Muster ExportSaveDialog: Overlay+Dialog, Enter bestätigt, Esc schliesst) ersetzt ALLE verbliebenen `window.prompt`-Aufrufe: `ComboMenu` (TopBar, Ebenen-/Zeichnungskombination speichern), `LayoutMenu` (Arbeitsumgebung speichern), Text-Grösse „frei" (`TextGroup`), Massstab „frei" (`ViewRibbonTab`), sowie die Tastatur-Flows O/P (Array-Kopien/Verteilen-Anzahl, `App.tsx`: `promptCount()` entfernt, ersetzt durch `pendingCopyMode`-State + Dialog). `window.confirm` war bereits an allen Fundstellen vorher entfernt (ViewSnapshots/LayoutsPanel). Verifiziert: `grep -rn 'window\.prompt(\|window\.confirm('` über src/ liefert NICHTS mehr. tsc sauber, vitest 491/491. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Layout im Viewport editierbar (wie ArchiCAD/VW/OpenCADStudio) — Phase 2b**~~ — **erledigt 2026-07-08 (Agent-verifiziert, NICHT visuell abgenommen):** Blatt jetzt erstklassiger Ansichtsmodus im Haupt-Viewport (`activeLayoutId` in App.tsx → `LayoutSheetView` statt Content), schwebendes `LayoutEditor`-Fenster ENTFERNT. Maus-Editing (`LayoutSheet.tsx`): Viewport aufziehen→Ausschnitt-Bindung (Inline-Dropdown, kein prompt), auswählen/verschieben/8-Griff-Resize (Commit bei Pointer-Up, Live-Ghost), löschen (Entf), Massstab-HUD; Pan (Mitte/Space)/Zoom-to-Cursor/Einpassen. Pure Transform+Hit-Test `layoutSheetMath.ts` (+26 Tests). `LayoutsPanel` window.prompt/confirm ebenfalls auf Inline umgestellt. +i18n `layouts.*`, CSS `.layout-sheet-*`. tsc/vitest 491, vite build OK. **NOCH VISUELL IN TAURI ZU PRÜFEN.** Offen: Einrasten an Kanten/Nachbarn, Ribbon-Massstab wirkt im Layout-Modus nicht (nicht deaktiviert). **Noch uncommittet.**
|
||||||
|
- [x] ~~**Layout-Blätter mit Masterlayout — Phase 2a**~~ (A3, setzt auf Ausschnitte auf) — **erledigt 2026-07-08:** Modell `Layout`/`LayoutViewport`/`MasterLayout` + `Project.layouts`/`masterLayouts` (im Dokument). `LayoutsPanel` (Layouts+Master anlegen/umbenennen/löschen, im Rechts-Dock, `LAYOUT_VERSION` 10→11). `LayoutEditor` (schwebendes Fenster wie ResourceManager): Blatt im mm-Seitenverhältnis (Zoom/Einpassen), Titelblock (Master-Vererbung via `resolveTitleBlock`, leere Felder → Defaults Projektname/Blattname/Massstab/Datum), und jeder Viewport rendert ECHT den Plan seines gebundenen Ausschnitts via `generatePlan`→`planToPrintSvg` (viewBox-mm = Viewport-mm, 100%-Einpassung + Clipping). Viewport hinzufügen (Ausschnitt-Picker)/entfernen, Position/Grösse/Massstab numerisch; Papier/Ausrichtung/Master pro Blatt. +16 Tests (Blatt-Geometrie, immutables CRUD, Master-Vererbung, mm-Geometrie). +i18n `layouts.*`. **Ehrliche Grenzen:** Live-Viewport-Render nur per Code korrekt, NICHT im Browser abgenommen (Nutzer prüft); Ausschnitte auf Schnitt/Ansicht (activeLevelId≠Geschoss) → leerer Plan (nur Grundriss-Ausschnitte füllen den Viewport); Titelblock-Font ≠ PDF-Helvetica (Phase-2b-PDF baut es separat). **Bewusst Phase 2b:** Multi-Page-PDF-Export, Maus-Drag/Resize der Viewports, „Alle aktualisieren", erweiterte Kamera-POV (Augen-/Zielhöhe). tsc/vitest 465. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Ausschnitte / View-Snapshots — Phase 1**~~ (A2, Fundament) — **erledigt 2026-07-07:** `ViewSnapshot` (name/folder + viewType/view3d/fov/scaleDenominator/detail/activeLevelId + Ebenen-/Zeichnungs-Sichtbarkeit + aktive Override-Ids) am `Project.viewSnapshots` (im Dokument). Pure Capture/Apply-Logik `src/state/viewSnapshots.ts` (+8 Tests: Roundtrip, defensives Auffüllen, Override-Menge). App.tsx: Capture sammelt View-State + `snapshotLayer/DrawingVisibility` + aktive Overrides; Apply Reihenfolge Geschoss→View→Sichtbarkeit→Overrides, Massstab-Zoom per 1 rAF. Neues `ViewSnapshotsPanel` (Liste/Ordner, Speichern/Umbenennen/Löschen), registriert im Default-Rechts-Dock (`LAYOUT_VERSION` 9→10). +i18n `viewsnap.*`. **Bewusst offen Phase 2:** Layout-Blätter+Masterlayout (A3), Viewport-Platzierung + Ausschnitt-Bindung, „Alle aktualisieren", Multi-Page-PDF, erweiterte Kamera-POV (Augen-/Zielhöhe getrennt), Ordner-Drag&Drop; Apply erzeugt mehrere Undo-Schritte (bekannt). **Noch uncommittet.**
|
||||||
|
- [x] ~~**IFC-Wände nicht solid (oben/unten offen)**~~ — **erledigt 2026-07-07:** ECHTE Ursache war eine invertierte Wicklung, NICHT eine fehlende Fläche — der Y-up→Z-up-Achsen-Swap im IFC-Export (`(mx,my,mz)→(mx,mz,my−base)`) ist eine Reflexion (Determinante −1) und kehrte jedes Dreieck um → Normalen nach innen → Viewer cullt Vorderseiten → hohl. Fix: beim Swap Dreieck umdrehen (`i0,i2,i1`). `Closed=.T.` gesetzt, verifiziert über `isWatertight` (`wallMeshCut.ts`): NICHT der naive „jede Kante von 2 Dreiecken"-Test (der meldet die legitimen T-Stösse aus vollen Deckel/Boden-Streifen fälschlich als offen), sondern das T-Stoss-robuste Gauss-/Divergenz-Kriterium `∮n dA=0` + signiertes Volumen >0. STL/OBJ waren korrekt (kein Swap). +15 Tests (Wasserdicht/Winding, inkl. IFC-Face-Set-Volumen aus dem geparsten SPF). tsc/vitest 449, cargo check grün. **Noch uncommittet. Nutzer prüft im Viewer.**
|
||||||
|
- [x] ~~**Exporte an 3D angleichen: Öffnungen ausschneiden + Joins (STL/OBJ/IFC)**~~ (Nutzer: „es sollte so raus wie es im 3d ist") — **erledigt 2026-07-07:** neuer TS-Helfer `src/plan/wallMeshCut.ts` portiert `render3d/mesh.rs::extrude_layer_segment_with_holes` 1:1 (Koordinaten-Kompression `solidSubrects`, Langseiten/Deckel/Boden/Stirnkappen minus Loch-Intervalle, bis zu 4 Laibungsquads pro Loch, konsistentes Winding). STL/OBJ: Wände mit `holes` → ausgeschnittenes Mesh (sonst klassische Box); Joins kommen aus `pickGeometry`. IFC: Wände als `IfcTriangulatedFaceSet` (IFC4, gespeist aus demselben Loch-Mesh) → sichtbar wie 3D in JEDEM Viewer (behebt „Void nicht subtrahiert"); Tür/Fenster bleiben eigene `IfcDoor`/`IfcWindow`-Objekte, die das Loch füllen; IfcOpeningElement/Void/Fill entfallen (Loch steckt im Mesh). +12 Geometrie-Tests (Loch-Region hat keine Voll-Wand-Dreiecke, Laibungen vorhanden, dangling-refs grün). tsc/vitest 434. **Ehrliche Rest-Lücken:** echte Miter-Gehrungsflächen (Export nutzt die achsparallele pickGeometry-Näherung), Schicht-Farben (IFC-FaceSet ohne per-Vertex-Farbe/IfcStyledItem), Fensterglas/Rahmendetail. **Noch uncommittet. Nutzer prüft im Viewer.**
|
||||||
|
- [x] ~~**Layout-Auswahl in die Einstellungen, umbenannt „Arbeitsumgebung"**~~ — **erledigt 2026-07-07:** `LayoutMenu` (Fenster-/Dock-Layouts) aus der TopBar-Zeile in `SettingsDialog` verschoben (neue erste Sektion), `layoutMenu`-Prop von TopBar → SettingsDialog umgehängt (ReactNode-Import in TopBar entfernt, da sonst ungenutzt). i18n `layout.*` umbenannt (DE „Arbeitsumgebung", EN „Workspace") + neue `settings.section.workspace`. tsc/vitest 423. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Export-Sammelmenü + Über-Dialog + Petrol-Punkt (TopBar-Feinschliff)**~~ — **erledigt 2026-07-07:** (1) Die 6 einzeln aufgereihten Export-Icons (PDF/DXF/CSV/IFC/OBJ/STL) zu EINEM „Export"-Knopf (`ExportMenu`, `ios_share`) mit Dropdown-Popover zusammengefasst — entschlackt die Chrome-Zeile; Import bleibt separat. (2) Klick auf die Wortmarke „dossier" öffnet einen In-App-„Über"-Dialog (`AboutDialog`: Marke, Version 0.1.0, Kurzbeschreibung, OSS-Lizenzen; Esc/Klick-ausserhalb schliesst). Wortmarke ist jetzt `<button>` (Chrome zurückgesetzt, Optik identisch). +i18n `file.export`/`about.*`, CSS (`tb-menu`/`about-*`). tsc/vitest 422. **Fix (Nutzer-Report „IFC nicht anwählbar"):** Popover per `createPortal` nach body (entkommt dem Topbar-Stacking-Context, der die unteren Einträge unter dem Ribbon verdeckte) + zweiter `popRef` im Outside-Click-Handler (sonst schloss der mousedown das Menü vor dem Klick) — Muster wie `ContextMenu`/`Dropdown`. **Hinweis:** In-App-Dialog statt nativem macOS-About-Panel (das bräuchte ein Rust-Command in src-tauri) — falls der native Panel gewünscht ist, Folge-Item. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Brand-Punkt petrolgrün**~~ — **erledigt 2026-07-07:** `.brand-dot` fix `#0f766e` (petrolgrün wie DOSSIER-Rhino-Plugin) statt `var(--accent)`. Feinton per Nutzer justierbar.
|
||||||
|
- [x] ~~**OSM-Import auf 7 Kategorien**~~ — **erledigt 2026-07-07:** 3 neue Kontext-Kategorien im Overpass-Import — `parking` (amenity=parking, Fläche), `railway` (railway~rail|tram, Linie), `forest` (landuse=forest + natural=wood, aus `green` herausgelöst; `green` jetzt nur Parks/Wiesen). `OsmSelection`/`buildQuery`/`categorize`/`isClosedCategory` (osm.ts), `GeoCategory`/Labels/Layer (geoContext.ts), 3 Checkboxen (ContextImportDialog.tsx), i18n `ctxImport.src.*`. tsc/vitest 414. **Noch uncommittet.**
|
||||||
|
- [x] ~~**IndexedDB-Persistenz-Kern**~~ — **erledigt 2026-07-07:** `src/state/projectStore.ts` — async CRUD `saveProject`/`loadProject`/`listProjects`/`deleteProject` über IndexedDB (DB „dossier"/Store „projects", keyPath name, `{name,savedAt,project}`), guard-sicher (fehlt `indexedDB` → degradiert: load→null/list→[]/save+delete no-op, 1× warn) — läuft daher auch in vitest/node. +7 Guard-Tests (414). **UI-Wiring (Speichern/Öffnen-Menü, Recent-Liste, Auto-Save) NOCH OFFEN — Folge-Item.** **Noch uncommittet.**
|
||||||
|
- [x] ~~**ObjectInfo Wand: UK/OK-Reihenfolge vertauscht**~~ — **erledigt 2026-07-07:** im Wand-Abschnitt steht jetzt OK (Oberkante) oben, UK (Unterkante) darunter — räumlich passend (Nutzer-Report „stimmt visuell nicht überein"); Decken-Abschnitt hatte die Reihenfolge schon → jetzt konsistent. Reiner JSX-Reorder. tsc/vitest 414. **Noch uncommittet.**
|
||||||
|
- [x] ~~**STL- + OBJ-Export (3D-Mesh, Interop)**~~ — **erledigt 2026-07-07:** pures Modul `src/export/exportMesh.ts` (`exportObj`/`exportStl`, ASCII-STL) aus `pickGeometry(project)` (echte 3D-Geometrie inkl. Joins/Decken-Dominanz): Wand-Bänder als 12-Dreieck-Boxen, Decken/Extrusionen als triangulierte Prismen (bestehender `triangulate()` aus glPlanCompile, kein WASM), y-up rechtshändig. Voll verdrahtet: Quick-Access-Buttons (`view_in_ar`/`landscape`) + `onExportObj`/`onExportStl` in App.tsx (Blob-Download) + i18n `file.exportObj`/`file.exportStl`. 8 Kern-Tests (Index-Bounds, facet/vertex-Konsistenz, Tri-Zahl-Plausibilität). tsc/vitest 407. **Bewusste v1-Limitation:** Öffnungslöcher NICHT ausgeschnitten (Wände volle Boxen) — ladbar in Blender/MeshLab, aber nicht öffnungsgenau; öffnungsgenaue Variante = Folge-Item. Extrusionen nutzen Prisma-Triangulierung statt async truckSolid-WASM (bewusst, wegen synchronem pure-Kern). **Noch uncommittet.** (IFC-Import + DWG-Schreiben bleiben offen.)
|
||||||
|
- [x] ~~**Raumstempel: Personenzahl + Flächen-Rundung**~~ — **erledigt 2026-07-07:** `RoomStamp.occupancy?`/`roundingStep?` (additiv, optional). `formatStampArea(area, step?)` in `roomStamp.ts` rundet auf Vielfache von `step` (0.01/0.1/0.5/1 m²), sonst 2 Nachkommastellen wie bisher; Personenzahl als „{n} Pers."-Zeile im Stempel. Editor-Zeilen (Dropdown Rundung + Zahlfeld Personen). Drag&Drop-Builder bleibt out of scope. +6 Tests, +i18n. tsc/vitest 399. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Auto-Zoom nach Geo-Import**~~ — **erledigt 2026-07-07:** nach swisstopo/OSM/Terrain-Import (`onAddContextObjects`) passt die Plan-Ansicht automatisch auf die neuen Objekte ein. `PlanViewHandle.fitBounds(pts)` (dünner Wrapper um `fitBoxFor`), `contextObjectsBounds()` bildet die Gesamt-BBox über alle neuen ContextObjects (contourSet-Punkte + Mesh-/Terrain-Positions, Z ignoriert), `addContextObjectsAndFit` in App.tsx verdrahtet. DXF-Import-Pfad (eigene viewCenter-Logik) bewusst unberührt. Kein i18n, host.ts unverändert. tsc/vitest 393. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Rich-Text Hoch-/Tiefstellung (super/sub)**~~ — **erledigt 2026-07-07:** zwei Toolbar-Buttons in `RichTextEditor.tsx` (Muster der bestehenden B/I/U/S-Buttons; super/sub-Exklusivität war im Modell `richText.ts::applyMark` schon gelöst); +i18n `rt.super`/`rt.sub`. Teil des ROADMAP-Items „Rich-Text-Annotationen" (Maskierung/Rahmen bleiben offen/blockiert). tsc/vitest 393. **Noch uncommittet.**
|
||||||
|
- [x] ~~**B2 — Objekt-Info numerisch erweitern**~~ — **erledigt 2026-07-07:** im ObjectInfo-Panel sind X/Y jetzt editierbar (Commit verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die Koordinate wandert, via neuem Host-Callback `onMoveSelectionBy`), dazu ein Dreh-Feld (`RotateField`, Delta-Grad um den Anker, `onRotateSelectionAround`) und für einzelne Drawing2D-`line`/`circle` ein Längen- bzw. Radius-Feld (`onSetDrawingLineLength`/`onSetDrawingCircleRadius`). Move/Rotate nutzen die bestehende `commitTransform`-Maschinerie (transform.ts unverändert), wirken für wall/drawing2d/extrudedSolid; für ceiling/opening/stair/room ist der Aufruf No-op (Feld rendert, revertiert — gleiches Muster wie das bestehende Breite/Höhe-Resize). +i18n. tsc/vitest 393. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Komponenten-Thumbnail: PBR-Kugel-Vorschau (D3-Rest)**~~ — **erledigt 2026-07-07:** ComponentsTab-Listeneintrag + Detail-Preview in `ResourceManager.tsx` zeigen jetzt bei gesetztem `component.material` die live gerenderte PBR-Kugel (`ComponentMaterialSphere`, lazy via IntersectionObserver + `requestMaterialPreview`, Muster von `MaterialLibraryTile`); `materialAssetOf` löst `libraryId` gegen `MATERIAL_LIBRARY` auf, sonst Ad-hoc-Asset aus den eigenen Map-URLs. Fallback-Kette Material→Hatch→Color unverändert. Nur `ResourceManager.tsx`, kein i18n. tsc/vitest 393. **Noch uncommittet.**
|
||||||
|
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||||
|
- [x] ~~**Linienstile aufräumen**~~ — **erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line`→`thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive`→`thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
|
||||||
|
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||||
|
- [ ] **AUSSCHNITTE- + LAYOUT-SYSTEM (Nutzer-Vision 2026-07-07, grosser Hebel).** Zwei zusammenhängende Systeme (DOSSIER A2→A3, ROADMAP §7 Phase 3 + §11 Pläne):
|
||||||
|
- **Ausschnitte / View-Snapshots (A2):** benannte, gespeicherte Ansichten, die einen kompletten Darstellungszustand einfangen und wiederherstellen. **Kern-Einsicht des Nutzers: das muss NICHT neu erfunden werden — die Bausteine existieren schon und werden nur KOMPONIERT:** ein Ausschnitt = Referenz/Snapshot auf {**Ebenenkombination** (`src/state/visibilitySets.ts` `LayerCombo`) + **Zeichnungsebenen-Kombination/-Einstellungen** (`DrawingCombo` ebd.) + aktive **Overrides**-Regelmenge (`Project.overrideRules`, A1) + Kamera/Ansichtstyp/View3d + **Massstab** + **Detailgrad**}. Also im Wesentlichen ein Container, der diese bereits vorhandenen Zustände bündelt, benennt (Ordner/Presets) und per Klick wiederherstellt. Speicherort: Projekt (nicht localStorage — Ausschnitte gehören zum Dokument). Voraussetzung für die halbe Pläne-Tabelle (Layouts, Multi-Page-PDF, Detail-Bindung, Massstab-pro-Viewport).
|
||||||
|
- **Layout-System mit Masterlayout + Layouts (A3):** Druck-/Plan-Blätter (A4/A3-Papierformate), auf denen mehrere **Details/Viewports** platziert werden, jedes an einen Ausschnitt-Snapshot gebunden (bei „Alle aktualisieren" ziehen sie nach). Ein **Masterlayout** (gemeinsame Elemente: Titelblock/Planrahmen/Logo/Plankopf, wie Vorlagen-Master in InDesign/ArchiCAD) wird von den einzelnen **Layouts** geerbt/überlagert — Änderung am Master schlägt auf alle Layouts durch. Pro Layout eigener Massstab pro Viewport (Auto-DPI, Plotweight-/Schraffur-Skalierung, `sceneToPrintSvg.ts` als Basis), PDF-Export pro Blatt bzw. Multi-Page.
|
||||||
|
- **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen.
|
||||||
|
- **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen.
|
||||||
|
- [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten):
|
||||||
|
- ✅ **Phase 1 erledigt 2026-07-17 (Kopplung + Navigation, KEIN Rust — plan: `.claude/plans/cozy-bubbling-goose.md`):** der 3D-Live-Schnitt (`Wasm3DViewport`) folgt jetzt der gewählten Schnittlinie statt einer fest verdrahteten horizontalen Ebene — die Engine kann beliebige Ebenen längst (`set_section_plane(point,normal)`), es fehlte nur die Bindung. App-State `section3dCutId` + abgeleitete `section3dPlane` via `sectionPlaneFromLevel` (world point+normal aus `linePoints`/`directionSign`, `plan/toSection.ts`) → durch `Viewport3D`→`Wasm3DViewport` (neue Props `section3d`/`onClearSection3d`). Schalter „Im 3D schneiden" in der Schnittlinien-Sektion des Objekt-Info-Panels (`host.section3dCutActive`/`onToggleSection3dCut`); der Viewport-Overlay-Knopf ist an die aktive Linie gebunden und hebt sie auf. **Doppelklick auf die Schnittlinie im Grundriss → springt in die 2D-Schnittansicht** (`PlanView.onOpenSectionLevel` → `onSelectLevel`). 2D-Editieren (Endpunkte/Blickrichtung/Tiefe) existierte schon im Objekt-Info. `tsc` + `vitest` 866 grün. **Nutzer prüft visuell in Tauri** (render3d-Pfad).
|
||||||
|
- ✅ **Phase 2 Teil „eigene Ebene / Sichtbarkeit" erledigt 2026-07-17:** eine unsichtbare Schnitt-/Ansichtsebene (`visible===false`) zeigt ihre Führungslinie im Grundriss nicht mehr (`addSectionLines` in `generatePlan.ts`, +1 Test). Ein-/ausblendbar wie andere Ebenen (Zeichnungsebenen-Panel).
|
||||||
|
- **Offen Phase 2 (engine-schwer, mit Nutzer im Tauri-Loop):** Farbe der 3D-Schnittebene + **Cut-away-Schleier/Schraffur auf der weggeschnittenen Seite** (wgpu-Shader/`section_fill.rs`/`section.rs`) — blind über Nacht bewusst NICHT gebaut (unverifizierbar ohne Tauri-Sicht, s. Arbeitsstil „Nutzer prüft render3d visuell").
|
||||||
|
- **Offen Phase 3 (engine-schwer):** 3D-POV je Schnitt (Augen-/Zielhöhe/Winkel getrennt, pro Projektionsart) im Datenmodell + eigenes 3D-Schnittfenster (Doppelklick 3D-Symbol → Clip+Kamera); koppelt an das Ausschnitte-/Kamera-System. Kamera-Kopplung an `Wasm3DViewport`-Orbit → Tauri-Verifikation nötig.
|
||||||
|
- Restliche Vision-Bausteine (unverändert offen):
|
||||||
|
- **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln).
|
||||||
|
- **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D.
|
||||||
|
- **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion.
|
||||||
|
- **3D-Schnitt-POV-Parameter:** die 3D-Schnittebene definiert zusätzlich Blickpunkt (POV), **Augenhöhe** + **Zielhöhe** (getrennt) und **Blickwinkel** — dieselben erweiterten Kamera-Settings wie im [Ausschnitte-System oben] (dort pro Projektionsart mitgespeichert). Ein Schnitt ist damit ein Ausschnitt mit Clip-Ebene + Kamera-Definition.
|
||||||
|
- Verknüpfung: baut auf dem bestehenden 2D-Schnitt (`DrawingLevel kind:"section"`, `linePoints`/`directionSign`) + der 3D-Live-Schnittebene (`03f0c40`, Clip-Uniform) auf; koppelt eng mit dem Ausschnitte-/Layout-System (View-Snapshots) und den erweiterten Kamera-Settings. Grosses, phasenweises Feature — mit Nutzer Reihenfolge/Scope festlegen (zuerst: 3D-Schnittebene als 2D-Grundriss-Objekt platzier-/editierbar + Doppelklick-Navigation).
|
||||||
|
- [ ] **AUDIT (DOSSIER-Studie):** ~~A1 Override-Regel-Engine~~ ✅ (2026-07-07, s. Erledigt); A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt; D2 volles Element-Set erledigt 2026-07-07.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||||
|
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||||
|
|
||||||
|
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
|
||||||
|
- **Auth + Projekt-Liste:** Supabase Auth (Email/Magic-Link), Projektübersicht, Öffnen/Schliessen.
|
||||||
|
- **Lock-Service:** Tabelle `object_locks (project_id, object_id, user_id, locked_at)` mit Row-Level Security; `reserveObjects(ids[])` / `releaseObjects(ids[])` als Supabase-RPC; Optimistisches Check-in via DB-Constraint (doppelte Reserve → Fehler → UI-Feedback).
|
||||||
|
- **Realtime-Sync:** Supabase Realtime-Channel pro Projekt; bei Freigabe werden die veränderten Objekte (JSON-Patch oder ganzes Objekt-Payload, TBD) gepusht; lokaler Store merged incoming changes sofort.
|
||||||
|
- **Presence:** Wer ist online, wer hat welche Objekte reserviert (farbige User-Badges an reservierten Elementen im Plan).
|
||||||
|
- **Offline-Guard:** Beim Verbindungsabbruch Locks automatisch nach Timeout freigeben (DB-seitig: `locked_at + interval` prüfen).
|
||||||
|
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
|
||||||
|
- **Aufwand:** gross (2–4 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
|
||||||
|
|
||||||
|
- [x] ~~**2D-Zeichnungen (`drawings2d`) fehlen im nativen render3d/WASM-Pfad bei z=0.**~~ — **erledigt 2026-07-06:** Nutzer-Report — früher wurden 2D-Plan-Elemente auch im 3D als flache Referenz bei z=0 gezeigt, das gab es nur noch im alten three.js-Fallback (`addDrawing2DLines`), nicht im nativen `render3d`/wgpu-Pfad (heute Standard-Renderer). Fix OHNE Rust-Änderungen: neuer Emitter `emitDrawingLines` (`src/plan/toWalls3d.ts`) baut je 2D-Zeichnung (line/polyline/rect — Parität zu `drawing2DSegments` im three.js-Fallback, Kreis/Bogen/Text bewusst aussen vor) ein dünnes Ribbon-Mesh (2 Dreiecke je Segment, `DRAWING_LINE_HALF_WIDTH` 0.008 m) knapp über der Geschossebene (`DRAWING_LINE_ELEVATION_EPS` 0.01 m), Farbe via `drawingColor3d` (color→LineStyle→Kategorie, wie `drawing2DColor`) → hexToRgb. Läuft über den bereits bestehenden generischen `RMesh`/`MeshInput`-Kanal (`kind:"imported"`, `append_context_mesh` rendert ohnehin doppelseitig) — kein neuer Rust-Typ nötig. `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (2026-07-06, Tauri-Dev-App selbst getestet): Linien aus dem 2D-Grundriss sichtbar in der Perspektive. **Noch uncommittet.**
|
||||||
|
|
||||||
|
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
|
||||||
|
- [x] ~~**Wand-Joins in 3D**~~ — **erledigt 2026-07-07:** `toWalls3d.ts` wendet jetzt die per-Schicht-Cuts aus `computeJoins` (pro Geschoss, `computeJoinsByFloor`) auf den layered-3D-Pfad an — dieselben Zahlen wie generatePlan im 2D: End-Cuts/Merge an L-/T-Stössen (Band-Mittellinie geschnitten, Kern läuft bis zur Rückgrat-Nahfläche durch, Putz trimmt), Durchgangswand-Span-Cutouts (Band zerfällt in Achsen-Teilstücke). **Decken-Dominanz per Schicht** (Scope-Erweiterung): `trimWallTopForCeilings` (kappte die ganze Wand) ersetzt durch per-Schicht-z-Subtraktion analog `subtractDominantBands` — Wand behält volle Höhe, nur Bänder mit strikt niedrigerer joinPriority verlieren das Decken-z-Intervall (Kern läuft durch; dominiertes Band spaltet vertikal in unter/über). Öffnungs-Löcher je Teilstück korrekt rebased. Rust unverändert (Boxen parametrisch). Pick/Highlight jetzt konsistent verschnitten. vitest 383 (25 in toWalls3d.test). **Auslassung ehrlich:** Achsenbox bildet gekippte Miter-Stirnfläche nicht exakt ab (Band-Länge = Mittellinien-Schnitt) — passgenau bei rechten/stumpfen Winkeln, kleine Rest-Approximation an sehr spitzen. **Noch uncommittet.** **Nutzer prüft visuell in der Tauri-App.**
|
||||||
|
- [x] ~~**Decken-Schnitt in 3D schneidet zu viele Schichten („Kranz durch die Dämmung")**~~ — **erledigt 2026-07-07:** `collectCeilingCutters`/`emitWall` in `toWalls3d.ts` prüft jetzt PER BAND, ob die Decken-Outline die Band-Mittellinie (`wall.start + u·t + n·offset`) im Grundriss überdeckt — via vorhandenem `pointInOutline` (`geometry/ceiling.ts`), Achse in ~5-cm-Schritten gerastert + Bisektion an den Überdeckungsgrenzen (neue Helfer `bandCoverageIntervals`/`bisectBoundary`); nur überdeckte Achs-Teilstücke bekommen den Decken-z-Schnitt, unbedeckte behalten volle Höhe. Repliziert `subtractDominantBands` (echter geometrischer Überlapp statt wandweiter BBox). Am Sample: Backstein/Innenputz (innen) geschnitten, Dämmung/Aussenputz (aussen) laufen voll durch — kein Kranz. Die 3 falschen Alt-Tests des Vorgängers korrigiert (kodierten „alle Schichten geschnitten"), +2 neue (Voll-Überdeckung schneidet weiterhin alle; Teil-Längen-Überdeckung splittet entlang der Achse). Schnitt-Pfad/L-T-Joins/holes/Rust unberührt. tsc sauber, vitest 393. **Noch uncommittet. Nutzer prüft visuell.**
|
||||||
|
- [~] **3D-Live-Schnitt: echte Bauteil-Schraffuren statt prozeduralem 45°-Muster (Nutzer-Wunsch 2026-07-07).** — **implementiert 2026-07-08 (wartet auf visuelle Nutzer-Verifikation nach WASM-Rebuild).** Der ganze Weg von der 2D-Hatch-Definition bis in den wgpu-Cap-Pass ist gelegt: `toWalls3d.ts` bildet je Materiallage/Decke via neuem `hatchPatternId()`/`resolveComponentHatch()` (aus `getHatch(comp.hatchId)`) ein `Hatch{pattern,angle,scale}` → `WallInput.hatch`/`SlabInput.hatch` (additiv, `#[serde(default)]`) → `section.rs` reicht es über `Prism`→`CutPolygon.hatch` → `section_fill.rs` schreibt es je Cap-Vertex (`CAP_FLOATS_PER_VERTEX` 5→8: `[pos,u,v,pattern,angle_rad,scale]`) → `gpu.rs` Cap-Pipeline-Vertexlayout erweitert → `shaders.rs` `CAP_WGSL` wählt prozedural das Muster: `0 none`→weiss, `1 solid`→Vollton (Beton-Poché), `2 diagonal`→45° (bitgleich zum alten Muster bei angle=0/scale=1 → rückwärtskompatibel), `3 crosshatch`→Kreuz, `4 insulation`→Zickzack. Monochrom (Tinte auf Papier, nur MUSTER variiert — bewusst keine Farbe). Da der 3D-Viewer je Schicht EINE Box emittiert (`layeredWalls:true`), trägt jede geschnittene Schicht ihr eigenes Muster; ohne aufgelöste Schraffur → Fallback-Diagonale. `cargo test -p render3d --features render` 63 grün (+2 section_fill-Tests, WGSL-Validierung inkl. Cap-Shader). **WASM (`npm run build:engine3d`) noch NICHT gebaut (brauchte Netz) — Nutzer muss rebuilden + Tauri-Dev neu starten, sonst greift nichts.** `cargo check --target wasm32 --features web` grün. **Noch uncommittet.**
|
||||||
|
- [ ] **3D-Snapping beim Ziehen (Nutzer-Wunsch 2026-07-07): alle Elemente sollen im 3D auf andere Punkte einrasten.** Heute rastet das 3D-Griff-Ziehen NICHT ein — es projiziert die Maus nur frei auf eine Ebene (`rayPlaneY`/`rayPlane` in `src/viewport/Wasm3DViewport.tsx:576-582`); Snap wurde beim 3D-Griffsystem bewusst weggelassen (three.js-Snap blieb 2D-exklusiv). Nutzer nennt konkret: **Wände** beim Verschieben auf andere Punkte snappen, **Decken** ebenso, „eigentlich alle Elemente". Gewünscht ist also eine gemeinsame 3D-Snap-Schicht (Kandidatenpunkte: Wand-Endpunkte/Ecken, Decken-Eckpunkte, Extrusions-/Öffnungs-Anker, evtl. Rasterpunkte), gegen die der gezogene Griff/Körper im Weltraum einrastet — analog zur 2D-Snap-Infrastruktur (`computeSnap` in `src/tools/snapping.ts`). Zu klären mit Nutzer/Scope: welche Snap-Ziele in 3D (nur Endpunkte oder auch Kanten/Flächen/Raster?), Snap-Radius im Bildschirm- vs. Weltraum, visuelles Feedback (3D-Snap-Marker), und ob die 2D-`computeSnap`-Kandidatenlogik wiederverwendbar ist oder eine eigene 3D-Variante braucht. Betrifft `Wasm3DViewport.tsx` (Drag-Pfad) + neue 3D-Snap-Hilfsschicht; die Store-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) bleiben.
|
||||||
|
- [~] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich** (`0ca3b1d`, Schachbrett). ✅ **Farb-Textur-Array implementiert 2026-07-08 (wartet auf visuelle Nutzer-Verifikation nach WASM-Rebuild):** Statt Schachbrett zeigt eine Wand mit zugewiesenem Material jetzt dessen Farb-Map. Voller Weg: `WallInput.materialIndex: Option<u32>` (1-basiert; 0 = Schachbrett-Fallback) → `mesh.rs` `build_scene_mesh_textured()` füllt je Vertex die Material-Ebene (dieselben Extrusionsfns → Geometrie-Parität) → `gpu.rs`: Schachbrett-Textur zu `texture_2d_array` erweitert, `set_material_textures()` baut `[Schachbrett, Mat0, Mat1, …]`, zweiter Vertex-Buffer für die Ebene → `shaders.rs` `MESH_TEXTURED_WGSL` samplt die Array-Ebene je Band (`layer<0`→Schachbrett) → `web.rs` WASM-Export `set_material_textures(rgba, layer_count)` → TS: `wallMaterialColorMaps()`/`resolveComponentMaterialLayer()` (deterministische Array-Ordnung), neuer `src/viewport/materialTextures.ts` dekodiert die Farb-Maps im Browser (ImageBitmap→Canvas→getImageData 256²), `useWasm3dRenderer.updateModel` lädt sie asynchron hoch (Signatur-Cache, Fallback auf Schachbrett bis geladen). **Ehrlich als Rest:** nur Farb-Map (keine Normal-/Roughness-/Metalness → Beleuchtung wie Shaded); der native Tauri-Push (`nativeSync.ts`) lädt keine Texturen → dort Schachbrett-Fallback (das WASM/Nordstern-Viewport im „Texturiert"-Modus hat den vollen Weg). `cargo check --target wasm32 --features web` grün, `cargo test` 63 grün. **WASM noch NICHT gebaut (Netz) — Nutzer: `npm run build:engine3d` + Tauri-Neustart.** **Verbleibende PBR-Lücken (~15–25 PT):** Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~5–8 · Mipmaps (Blit-Pass) + anisotropes Filtern ~1–2 · Bild-Datei-Laden serverseitig statt Browser-Dekodierung ~1–2 · nativer Tauri-Textur-Push ~2–3 · UI/Persistenz-Feinschliff ~5–8. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~ — **bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
|
||||||
|
- [x] ~~**Ortho-Ray-Picking**~~ — **erledigt 2026-07-07:** `cameraRay` (`src/viewport/raycast3d.ts`) war IMMER ein perspektivischer Pinhole-Strahl (ein Ursprung `eye`, Richtung variiert je Pixel) — für Front/Top/Side/Iso-Presets (die render3d orthografisch zeichnet) nur eine Näherung. Fix: `cameraRay` nimmt jetzt optional `perspective`/`orthoHalfHeight` (neue optionale Felder auf `RayCamera`, rückwärtskompatibel — fehlt `perspective`, bleibt das Verhalten exakt wie vorher) und baut bei `perspective:false` echte PARALLELE Strahlen (Ursprung wandert lateral mit dem Pixel, Richtung immer `f` — exakte Umkehrung von `worldToScreen`s bereits vorhandenem Ortho-Zweig). Beide Aufrufer in `Wasm3DViewport.tsx` (Klick-Pick + Griff-Drag) reichten schon das volle `orbitCamera(o)`-Objekt (inkl. `perspective`/`orthoHalfHeight`) durch — der Bug sass rein in `cameraRay`, keine Änderung an den Aufrufstellen nötig. +2 Tests (`raycast3d.test.ts`: parallele Strahlen, Rückwärtskompatibilität ohne `perspective`-Feld). `tsc`/`vitest` 341/341 grün.
|
||||||
|
- [x] ~~**3D-Griffe/Editieren im nativen render3d/wasm-Pfad**~~ — **erledigt 2026-07-06:** existierte bereits vollständig im three.js-Fallback (`Viewport3D.tsx`/`drawGrips`), fehlte aber im nativen wasm-Pfad (`Wasm3DViewport.tsx`, seit `WASM_ENGINE_ACTIVE` der Standard-Renderer) — genau wie beim drawings2d-z0-Fund oben eine „existiert, aber auf dem falschen Pfad"-Lücke. Feasibility-Spike (Nutzer-Entscheid „Spike jetzt starten") zuerst nur Wand-Endpunkte, dann auf volle Parität ausgebaut: Wand-Endpunkt-/Höhen-/Verschiebe-Griff + 2D-Zeichnungs-Vertex-/Verschiebe-Griff, dieselben App.tsx-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) wie die three.js-Sicht. **Wichtige Design-Korrektur unterwegs:** erster Versuch zeichnete Griff-Marker als In-Szene-Geometrie (3-Achsen-Kreuz, dann Drahtgitter-Kugel) über den bestehenden `setHighlightLines`-Kanal — Nutzer-Feedback zweimal `"immernoch kein GUI button sondern geometrie"`: nicht klar genug als klickbarer Punkt erkennbar UND farblich identisch mit dem Auswahl-Umriss auf derselben Ecke. Gelöst durch Umstieg auf ECHTE DOM-Buttons (`position:absolute`-Kreise über dem Canvas, Farben wie drei.js: Vertex orange/Höhe blau/Verschieben grün), deren Bildschirmposition ein `requestAnimationFrame`-Loop aus der aktuellen Kamera nachführt (`worldToScreen`, neue Umkehrfunktion zu `cameraRay` in `raycast3d.ts`) — kein WASM/GPU-Push nötig, nur 2D-Projektionsmathe. Ziehen läuft über `rayPlaneY`/`rayPlane` (neu in `raycast3d.ts`) + dieselben Store-Callbacks. `drawingGripVertices`/`drawingMoveAnchor` nach `src/viewport/drawingGrips.ts` ausgelagert (gemeinsam von beiden Renderern genutzt, sonst zyklischer Import). **Bewusst NICHT gebaut:** Snap/Koinzidenz-Mitführen beim 3D-Ziehen (bleibt three.js-exklusiv). `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (Wand-Endpunkt-Drag funktional in der Tauri-App getestet, dann Marker-Sichtbarkeit korrigiert). **Noch uncommittet.**
|
||||||
|
|
||||||
|
## ❓ Offene Rückfragen (an den Nutzer)
|
||||||
|
|
||||||
|
- [x] ~~**„aki"** Snap/Endpunkt-Farbe~~ — **entschieden 2026-07-04: Sora #5FA1C9** als endgültiger Default festgeschrieben (`src/theme/accents.ts` `DEFAULT_SNAP_COLOR`, Doc aktualisiert). Frage geschlossen.
|
||||||
|
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
|
||||||
|
- [ ] Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering?
|
||||||
|
- [ ] Verifizieren/klären: Isometrie „echte" orthographische Iso (teilw. durch Locked-Iso `cb8fae5` adressiert)? · TopBar-Detailgrad (zoom% ganz aus Footer)? · DOSSIER-Audit welche Features konkret übernommen?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Erledigt (Verlauf, neueste oben)
|
||||||
|
|
||||||
|
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
||||||
|
|
||||||
|
- [x] 2026-07-17 **Schnittebenen 2D↔3D — Phase 1 (Kopplung + Navigation) + Phase-2-Sichtbarkeit** (uncommittet) — der 3D-Live-Schnitt folgt jetzt der gewählten Grundriss-Schnittlinie statt einer fest verdrahteten horizontalen Ebene (`Wasm3DViewport`/`Viewport3D` neue Props `section3d`/`onClearSection3d`; App-State `section3dCutId` → `sectionPlaneFromLevel`). „Im 3D schneiden"-Schalter im Objekt-Info (`host.section3dCutActive`/`onToggleSection3dCut`), Viewport-Knopf an die Linie gebunden. Doppelklick auf die Schnittlinie → 2D-Schnittansicht (`PlanView.onOpenSectionLevel`). Unsichtbare Schnitt-/Ansichtsebene blendet ihre Grundriss-Linie aus (`addSectionLines`). `tsc` + `vitest` 867 grün (+1). Cut-away-Schleier/Farbe (Phase 2) + POV/3D-Schnittfenster (Phase 3) bewusst offen (engine-schwer, Tauri-Verifikation nötig — s. Backlog-Eintrag). Nutzer prüft Phase 1 visuell in Tauri. UI-Nebenarbeit derselben Session (uncommittet): Werkzeug-Panel (Symbole/Liste-Umschalter, neue SIA-Icons, helle Auswahl), Topbar-Quick-Access-Icons entfernt (dupliziert das native Menü), Zahnrad→Einstellungen in Zeichnungsebenen/Ebenen-Köpfen, Footerbar + Snap-Marker + Maß-HUD auf die helle Pillen-Sprache umgestellt.
|
||||||
|
- [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün.
|
||||||
|
- [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün.
|
||||||
|
- [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen.
|
||||||
|
- [x] 2026-07-12 **Kontext-Meshes (importierte Gebäude/Terrain) im 3D-Viewport anwählbar** (`e6a7738`) — zweiter Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Raycast/`pickGeometry` (`raycast3d.ts`/`toWalls3d.ts`) um Kontext-Meshes erweitert (rohe Dreiecke aus `project.context`, kaputte Indizes robust übersprungen), neuer Auswahl-Kanal `selectedContextObjectIds` (Store-Slice + `onViewport3dPick`-Zweig, konsistent in ALLE bestehenden Auswahl-Reset-Stellen eingehängt), Highlight als achsenparallele Bounding-Box (volles Dreiecks-Wireframe wäre bei importierten Gebäuden mit tausenden Dreiecken unbrauchbar dicht), Entf-Taste löscht die Auswahl (reused `project.context`-Filter im bestehenden Lösch-Handler), `SitePanel`-Liste hebt die im 3D gewählte Zeile hervor (`host.selectedContextObjectIds`). +9 Tests (`raycast3d.test.ts`, `toWalls3d.test.ts`), Suite 803 grün, `tsc` sauber. **Bewusst nicht umgesetzt:** Ebenen-/Layer-Zuordnung (`categoryCode` an `ImportedMesh`/`TerrainMesh`), volle Attribut-Panel-Integration (`deriveSelection()`) — als eigener, grösserer Schritt eingeschätzt, s. „🔧 In Arbeit" oben.
|
||||||
|
- [x] 2026-07-12 **3D-Ansicht: „Schattiert mit Kanten" (BIM-Look) + Fix falscher Flächendiagonalen** (`a84dc7a`+`7dc8f0d`) — neuer `RenderStyle::ShadedEdges` (render3d): Bauteilfarben + dunkle Modell-Kanten obenauf, wie Revit/ArchiCAD. Direkt danach Nutzer-Report: sichtbare Dreiecks-Diagonalen auf Dach/Fensterglas im neuen Modus. Root Cause: Kontext-Meshes (Dach/Glas/Rahmen, auch swissBUILDINGS3D-Import) werden wegen aktivem Backface-Culling IMMER doppelseitig aufgebaut (`mesh.rs::push_ctx_tri`, Dreieck + gespiegelte Rückseite) — jede Kante bekam dadurch ein exakt entgegengesetztes Normalen-Paar, das die Knick-Erkennung fälschlich als Kante wertete. Fix in `edges.rs::should_draw`: Rückseiten-Duplikate (dot≈-1) werden vor der Rand-/Knick-Entscheidung zusammengeführt. +11 Rust-Tests, 88/88 grün (`--features render`). **Drei Folgepunkte dabei entdeckt, noch offen** (s. „🔧 In Arbeit" oben): Fenster-Rahmenecken/Dach-First ohne sauberen Verschnitt (vermutlich fehlende Boolean-Union, kein Kanten-Bug), OG-Wandflächen mit vielen vertikalen Strichen (nicht diagnostiziert).
|
||||||
|
- [x] 2026-07-12 **swissBUILDINGS3D-Import repariert: Absturz + eigentlicher „funktioniert nicht"-Bug** (`35a6834`+`9705890`) — zwei getrennte, echte Bugs gefunden und behoben. (1) Harter Absturz (JSZip „Invalid string length") bei Kacheln, deren ENTPACKTE Grösse (DXF komprimiert stark) die max. JS-String-Länge sprengt, obwohl die ZIP-Grösse selbst unter dem Limit lag — `downloadAssetText` prüft jetzt zusätzlich die JSZip-interne Grössenschätzung und fängt alle Fehler sicher ab; übersprungene Kacheln landen sichtbar in `skippedTiles`/einer UI-Meldung statt eines stummen Leer-Ergebnisses. (2) **Der eigentliche Grund, warum der Import nie Gebäude lieferte:** die installierte `dxf-parser`-Version liefert Polyface-Mesh-Face-Indizes als vier einzelne Felder (`faceA..faceD`, Gruppencodes 71–74) statt als erwartetes `faces`-Array — `addPolyfaceMesh` prüfte auf ein Array, das nie existierte, jede swissBUILDINGS3D-DXF-Kachel (exakt dieses Format) ergab dadurch 0 Dreiecke. Live gegen echte Kacheln verifiziert (vorher 0 Meshes, jetzt tausende Dreiecke mit realistischen Höhenwerten). +3 Tests mit rohem DXF-Text (deckt auch die zugrundeliegende Bibliothek ab), Suite 794 grün. **Georeferenzierung und „anwählbares Mesh auf Ebene statt Geo-Panel-Eintrag" bleiben als eigene, ungelöste Design-Fragen offen** (s. „🔧 In Arbeit" oben).
|
||||||
|
- [x] 2026-07-12 **Fenster-Grundriss komplett überarbeitet** (`e65a6b7`..`d2758ef`, iterativ über mehrere Live-Prüfungsrunden in Tauri) — Nutzer-Report mit SIA-Referenzbildern deckte mehrere übereinanderliegende Probleme auf, alle einzeln gefixt + verifiziert:
|
||||||
|
- Sims/Anschlag-Kerben nutzten pauschal die volle Wandfläche statt der tatsächlichen (ggf. per `insetFromFace` eingezogenen) Rahmen-Aussenkante.
|
||||||
|
- Stulp-Marken waren kleine, von der Rahmentiefe unabhängige Quadrate statt Profilquerschnitte über die GANZE Rahmentiefe; die Blendrahmen-Querschnittsblöcke an den Laibungs-Enden fehlten komplett — beide jetzt als `meetingMarks` in `windowSymbol` berechnet (inkl. Laibungs-Enden), `generatePlan.ts` nutzt sie direkt statt einer zweiten, abweichenden Neuberechnung.
|
||||||
|
- Zusätzliche `window-mullion`-Trennlinie lag redundant NEBEN dem neuen Rahmenblock (entfernt, für typisierte Fenster reicht der Block; Alt-Pfad ohne Typ behält die Linie).
|
||||||
|
- Glaslinie(n) im Feld (mittel: 1, fein: Doppellinie) komplett entfernt — Nutzer wollte dort keine Haarlinie, unabhängig von Detailgrad/Position.
|
||||||
|
- Pauschale Brüstungslinie (Wandachse, unabhängig von Rahmenposition) UND die „Oberlicht-Andeutung" (gestrichelt bei `transomHeight>0`, ebenfalls immer auf der Wandachse unabhängig davon ob die Schnittebene den Kämpfer trifft) beide entfernt — architektonisch nicht aussagekräftig für einen horizontalen Grundriss-Schnitt.
|
||||||
|
- **Ersatz:** `WindowType.sillLine` — konfigurierbare Auf-/Untersicht-Andeutung (Fläche aussen/innen/beide, Blickrichtung Auf-/Untersicht), an der echten Rahmenkante statt der Wandachse.
|
||||||
|
- **Neu:** `Opening.frameLine`/`sashLine`/`sillLineStyle` — Farbe/Strichstärke je Linien-Kategorie (Blendrahmen+Stulp / Flügelrahmen+Sprossen / Sims) im Objekt-Info-Panel einstellbar, Default = bisheriges Verhalten.
|
||||||
|
- +~30 Tests über mehrere Dateien, Suite 790 grün.
|
||||||
|
- [x] 2026-07-12 **Dach-Wand-Verschneidung (Z-Fighting im 3D behoben)** (`a584911`) — Nutzer-Report mit Screenshot (flackerndes Rausch-Muster wo Wand-Oberkante die Dachschräge kreuzt). Root Cause: Wände bekamen ihre Höhe unabhängig vom Dach, `collectCeilingCutters` kappte nur gegen `project.ceilings`, nie gegen `project.roofs` — wo die geneigte Dachfläche den flachen Wand-Top kreuzte, überlappten sich beide Volumen. Fix: `roofUndersideAt` (`geometry/roof.ts`, Ebenengleichung je Dachfläche + Punkt-in-Polygon) liefert die Dach-Unterkante an einem Grundriss-Punkt; `clipPieceToRoofs` (`toWalls3d.ts`) zerlegt betroffene Wand-Achsenstücke in feine ~15-cm-Schritte und klemmt jeden auf die dort gemessene Unterkante — Treppenstufen-Annäherung an eine echte geneigte Giebelwand-Stirnfläche (render3d-Wandkörper haben nur einen flachen Top; eine echte Schrägfläche bräuchte einen neuen Mesh-Pfad, bewusst nicht gebaut). Ohne Dach im selben Geschoss unverändertes Verhalten. +9 Tests (5 `roofUndersideAt`, 3 Integration inkl. Regressionsfund einer legitim gekappten Putz-Gehrungsspitze im Sample-Projekt), Suite 781 grün. Verwandt: [[design-schicht-verschneidung-3d-schnitt]] (gleiche Kategorie wie „Wand endet an Decke", hier für geneigte statt horizontale Cutter).
|
||||||
|
- [x] 2026-07-12 **Fenster-Grundriss grob/mittel/fein nach SIA 400 klar unterschieden** (`13cc6a0`) — Nutzer-Report „mittel und fein sind genau gleich, grob ist viel zu grob" behoben: `windowSymbol` (`geometry/opening.ts`) staffelt jetzt nach SIA 400 Anhang B.9.1 Fig. 36–38 statt DIN — grob (1:100) nur eine Glaslinie, mittel (1:50) Blendrahmen + Flügel-Trennlinien + 1 Stulp-Quadrat je Flügelstoss, fein (1:20) zusätzlich verschachtelte Flügelrahmen + Glas-Doppellinie (Isolierverglasung) + 2 Stulp-Quadrate. `generatePlan.ts` reicht `detail` durch, rendert die neuen Stulp-Marken (`window-stulp`) nur im typisierten Pfad (kein Fake-Stoss bei reinem `wingCount`-Altfall). Referenzdoku `docs/research/sia400-fenster-tueren.md` (Fig.-Transkription aus der SIA-400-PDF, lokal excluded). +9 Tests, Suite 753 grün. Visuell per Puppeteer verifiziert. ✅ **Folge-Fix `e309e54`:** Öffnungssymbol-Kommentare in `toElevation.ts`/`toWalls3d.ts` waren fälschlich „DIN" benannt (Grundlage ist SIA 400 B.9.1.3) — reine Doku-Korrektur, Symbol-Geometrie selbst war bereits korrekt (SIA-PDF zeigt nur Sinnbild-Namen, keine Pfeilgrafiken, daher keine Geometrieänderung). ✅ **Wand-Poché bei „grob" jetzt immer vollschwarz** (`0240a23`, 2026-07-12) — war zuvor nur schwarz, wenn das dominante Bauteil selbst `pattern:"solid"` hatte (bei mehrschichtigen Wandtypen mit Backstein/Dämmung/Verputz blieb es weiss). Fix (unconditional `HATCH_INK` bei grob) von Hermes/Qwen3 geliefert (zweiter Versuch, erster war die o. g. Fehleinschätzung), von mir verifiziert + um fehlenden Regressionstest + Aufräumen der toten `backbonePocheFill`-Hilfsfunktion ergänzt. +2 Tests, Suite 755 grün.
|
||||||
|
|
||||||
|
- [x] 2026-07-11 **Schnitt/Ansicht auf VW-Niveau (grosser Block)** — Schnitt ENTSPIEGELT (`983d061`, u-Achse = Betrachter-Rechts, Rust+TS koordiniert, Engine neu gebaut); Kanten geschnittener Bauteile gefiltert + verdeckte Kanten opt-in (`4c4c990`); Wand-Terminierung wirkt im Schnitt auch bei Decken-ANSCHLUSS ±5 cm (`a612ae5`); Dämmschraffur-Orientierung Wand/Decke korrekt (Sprossen quer zur Schicht, empirisch verifiziert, `a612ae5`+`38a4d8c`). Ansicht als LINIENZEICHNUNG (`c02a02c`): weisse Flächen + XOR-Silhouette (xorOutlineEdges — Teilbox-Innenkanten heben sich auf), Fenster mit Blendrahmen→Flügelprofil→Glas, Sims mit Tropfkante, DIN-Symbole exakt auf den Glasfeld-Ecken (`ee9aeda`). **3D-Öffnungen VW-fein** (Merge `31d7aef`): vorstehende Flügelrahmen, DIN-Symbole auf dem Glas (openingPlaneBox-Prismen), 3D-Fensterbank + Tropfkante, Zargen-Umgriff; Staffelung grob/mittel/fein. Display/Print-Umschalter in allen 2D-Darstellungen. **Offen:** Tauri-Visualabnahme 3D; Schnitt-Auswahl-UX (Treffer nur auf der Poché).
|
||||||
|
- [x] 2026-07-11 **Lineale + VW-Zeichen-Feedback komplett** (`1945fec`, `8f85e13`, `7898a0d`, `242b850`) — Lineale oben/links in allen 2D-Ansichten (Meter-Ticks, Cursor-Marker); Cursor-HUD im Programm-Look mit Live-Echo getippter Werte (oben+unten synchron), Tab-Feldwechsel, Direkt-Tippen; Δx/Δy beim Rechteck; lange Führungslinien + Winkelbogen mit 0°-Referenz.
|
||||||
|
|
||||||
|
- [x] 2026-07-11 **Schnitt ausgebaut** (`b86fca2`) — Befehl `sectionline`/`viewline` (Aliase schnittlinie/ansichtslinie, BIM-Ribbon „Schnitte"): zwei Klicks setzen die Schnitt-/Ansichtslinie (neue Ebene bei Bedarf). Schnittführungs-Symbol im Grundriss (Strichpunkt, Endmarken, Richtungspfeile, Label) + Pick-Band; Schnittlinie selektierbar mit Attribut-Sektion (Name, Blickrichtung umkehren, Endpunkte, Tiefe, Löschen/Entf). **Editieren IM Schnitt**: Cut-Polygone tragen die Quell-Element-Id (sourceId, durch Schicht-Zerlegung/Terminierung/Dominanz propagiert) → Klick im Schnitt wählt das Bauteil, Panels editieren, Schnitt rechnet neu. Schnitt-Tiefe `DrawingLevel.depth` (Owner-Distanz-Näherung, TODO exakte Kanten-Tiefe bräuchte section.rs). **Offen:** Endpunkt-Drag-Griffe der Schnittlinie im Grundriss.
|
||||||
|
- [x] 2026-07-11 **Ansicht (Elevation) als echte 2D-Darstellung** (`456ecc5`+`a6db682`) — `toElevation.ts`: Painter-Projektion (Fassaden/Decken fern→nah, Rückseiten-Culling, Tiefen-Graustaffelung), Fenster/Türen als Rahmen+Glas, Bodenlinie, **Dächer** (Flächen + Giebel aus roofGeometry) und **Schatten ein/aus** (45°-Schlagschatten auskragender Decken UND Dachflächen, Sutherland–Hodgman-geclippt; Toggle in der Ansichts-Leiste, `DrawingLevel.shadows`). Rein TS/synchron ohne WASM. **Offen:** exakter Hidden-Line statt Painter (dokumentierte Näherung), Material-/viewHatch-Füllungen je Fassade.
|
||||||
|
- [x] 2026-07-11 **VW-Zeichengefühl: Cursor-HUD + Winkelraster** (`975ce3f`) — beim Zeichnen L/W-Kästchen am Cursor (`L: 3.118m W: 60.000°`, VW-Stil), weiches Einrasten auf 15°-Vielfache (±2.5°, konfigurierbar) mit Winkel-Badge + gestrichelter Führungslinie; Vorrang Objekt-Snap > Shift/Ortho > Winkelraster > Raster; Toggle in der Fang-Leiste. Das bis dahin nie gerenderte `ToolDraft.hud` lebt jetzt (line/polyline/wall/rect/circle/arc).
|
||||||
|
- [x] 2026-07-11 **Dach im 3D anwählbar** (`355c1d4`) + **2D-Schnitt = 3D Wand-Terminierung** (`0e02c2b`) — Ray-Dreieck-Pick über Dachflächen/Giebel; applyWallTermination spiegelt terminateOrSubtractSpans im (u,v)-Schnittraum (below/above kappen an der Decken-UK/OK).
|
||||||
|
|
||||||
|
- [x] 2026-07-10 **Fenster-/Tür-Einstellungsdialog + reiche Öffnungs-Parameter** (`1bbe814`+`c734802`+`2e13ec3`, Studie [docs/design/window-editor-vectorworks-study.md](docs/design/window-editor-vectorworks-study.md)) — Nutzer-Feedback „Fenster/Türen mega mager". Das ⚙ der Öffnungs-Sektion öffnet neu einen dedizierten Dialog (`OpeningEditorDialog.tsx`): Kategorie-Sidebar (Basis/Grösse/Rahmen/Flügel/Sonnenschutz bzw. Türblatt), Flügeltabelle (Flügel/Pfosten + Öffnungsart + Anschlag je Flügel), Live-2D-Frontalansicht, Stil-Leiste mit „Als Stil speichern" (klont Typ → neuer benannter WindowType/DoorType). Modell additiv: `SashDef[]`/`shading`/`glazingPanes` + Helfer `sashesOfWindowType`/`glazingPanesOf`. Renderer konsumiert sie: 2D-Flügeltrennlinien aus Flügelbreiten + Öffnungsandeutung, glazingPanes-Glaslinien, gestrichelte Rollladenkasten-Kontur; 3D mehrscheibige Verglasung. +26 Tests, 659/659 grün. **Offen (P1+, Studie §4):** asymmetrische Rahmenbreiten, Oberlicht/Unterlicht als eigene Felder, Sprossengitter, Bank/Nische, Form (Rund/Spitz), echte wgpu-3D-Vorschau im Dialog, 3D-Rollladenkasten-Box.
|
||||||
|
- [x] 2026-07-10 **Dach-3D-Ziehgriffe** (`4ef40a0`) — gewählte Dächer haben im wgpu-Viewport Eckpunkt-Griffe (achsparalleles Resize, Gegenecke fix), einen Verschiebe-Griff und einen First-Griff (vertikal ziehen → Dachneigung, Firstlage bleibt). Store `moveRoofGrip`/`moveRoofBy` (coalescing) + `onEdit3dRoofPitch` (Höhe→Neigung). +3 Tests. Schliesst die „optionale Folge" des Dach-Features (s. u.). **Tauri-Visualabnahme offen.**
|
||||||
|
- [x] 2026-07-10 **Messwert im Objekt-Info** (`9b6dd80`) — Live-Messwerte erschienen nicht im Panel; Ursache war ein eingefrorenes Memo (`baseHost`-Deps ohne `draft`). Fix: `measurement` pro Render live in `hostWithMode` injiziert.
|
||||||
|
|
||||||
|
- [x] 2026-07-09 **Dächer (Grundfeature)** (`1195d2a`+`31f2d63`) — Roof-Element + `geometry/roof.ts` (Flach/Pult/Sattel/Walm/Mansarde/Zelt, BBox-basiert, First X/Y). Befehl „Dach" (BIM-Ribbon, Rechteck aufziehen, Form als Inline-Option), 2D-Plan (Traufe/First/Grat/Knick), 3D (emitRoofs, terrakotta), Demo-Dach RF1. +19 Tests. **Offen:** Auswahl/Attribut-Editieren/Löschen (s. „Als Nächstes").
|
||||||
|
- [x] 2026-07-09 **Fenster/Tür-Feedback** (`4beae72`+`89e737b`) — Fenster im 3D tiefer (Rahmen füllt Wanddicke, dünne Scheibe mittig); Detailgrad grob/mittel/fein wirkt im 3D; Fenster↔Tür-Umschalter entfernt; platzierte Öffnungen bekommen Standard-Typ (sonst kein Rahmen). Kernursache „sieht im 3D nicht so aus" = fehlender typeId behoben.
|
||||||
|
|
||||||
|
- [x] 2026-07-09 **Decken-Griffe im 3D** (`973ac6d`) — gewählte Decken haben im wgpu-Viewport Eckpunkt- + Kanten-Mittelpunkt-Griffe (rautenförmig, eigene Farbe) + Verschiebe-Griff → moveCeilingGrip/moveCeilingEdge/moveCeilingBy (Store-Actions existierten schon fürs 2D). Neu: 3D-Griffgeometrie (computeGrips), Kanten-Drag (GripDrag „edge"), Prop-Kette Wasm3DViewport←Viewport3D←App (editCeilings/onEditEdge, Ziel um ceilingId). three.js-Sicht unverändert. **Tauri-Visualabnahme offen.**
|
||||||
|
- [x] 2026-07-09 **Text-Styling-Leiste wirkt auf Freitext** (`d0b9d22`) — selektierter Freitext/Textspalte (Drawing2D shape „text") ist Formatier-Ziel der Oberleiste: Drawing2D-Text.marks (Schrift/fett/kursiv/Farbe); Grösse bleibt über Modell-Höhe (Meter, nicht pt). App.textTarget adaptiert via docFromText/plainText; generatePlan/PlanView rendern die Marks. +3 Tests.
|
||||||
|
- [x] 2026-07-09 **Schichttrennlinie als Wand-Referenzlinie** (`938d642`) — Wall.referenceOffset (freier Achsversatz) übersteuert left/center/right; Object-Info-Dropdown listet je interne Fuge einen Eintrag (kumulierte Schichtdicken in selectionInfo). wallReferenceOffset bleibt EINE Quelle → 2D/Schnitt/3D erben es. +3 Tests.
|
||||||
|
- [x] 2026-07-09 **Mess-Werkzeug: Polygonzug + Fläche + Objekt-Info** (`492e1f8`) — Länge je Segment + aufsummiert + FLÄCHE (ab 3 Ecken, Gauss); Live-Werte dauerhaft im Objekt-Info-Panel (ToolDraft.measure→PanelHost.measurement→ObjectInfoPanel); Rechtsklick beendet Pfad + armiert neuen (mehrere nacheinander). +4 Tests.
|
||||||
|
- [x] 2026-07-09 **Tür/Fenster tief ausgearbeitet** (`fa40429`/`142ba7e`/`9a65900`) — DoorType/WindowType um frameKind (Zarge/Blockrahmen), frameWidth, insetFromFace/insetFace (Schichteinzug), transomHeight (Oberlicht), mullionRows (Kämpfer). ResourceManager-Typeditor + Seeds (Haustür Blockrahmen/Oberlicht, Fenster 2-flügl.+Oberlicht). 2D: opening-frame/-transom-Primitive. 3D: emitOpeningFrames (Rahmen/Sprossen/Kämpfer) + einzugs-/oberlicht-bewusste Scheiben. +13 Tests. **3D-Visualabnahme offen.**
|
||||||
|
- [x] 2026-07-09 **LICENSE** (`dbe7d37`) — offizieller AGPL-3.0-Text (gnu.org).
|
||||||
|
|
||||||
|
- [x] 2026-07-07 **IFC4-Export (erste Scheibe)** — reiner Kern `src/export/exportIfc.ts` (`exportIfcSpf(project)→string`, kein WASM/Dependency, direkt IFC-SPF-Text, Muster wie exportDxf). Räumliche Hierarchie IfcProject→IfcSite→IfcBuilding→IfcBuildingStorey (je `kind:"floor"`), Elemente via IfcRelContainedInSpatialStructure. Geometrie durchgängig IfcExtrudedAreaSolid: Wand→IfcWall, Decke→IfcSlab, Öffnung→IfcOpeningElement+IfcRelVoidsElement + Tür/Fenster-Füllung+IfcRelFillsElement, Extrusion→IfcBuildingElementProxy, Treppe→IfcStair (vereinfachter Hüllkörper, keine Stufengeometrie). IFC-22-Zeichen-GUIDs deterministisch aus Element-id (FNV→128bit→IFC-Base64, gegen IfcOpenShell-Referenz verifiziert). SI-Einheiten, OwnerHistory. Quick-Access-Button (`deployed_code`) + `onExportIfc` in App.tsx (Blob `model/ifc`, `<name>.ifc`) + i18n `file.exportIfc`. 8 Tests (wichtigster: KEINE dangling refs — jede `#N`-Referenz definiert + eindeutig). vitest 391. **Bewusst offen/vereinfacht:** IfcMaterialLayerSet (DirectionSense/Offset-Semantik ohne Viewer zu riskant — Folge-Item), Treppen-Stufengeometrie, Tür/Fenster nutzt dieselbe Box wie die Öffnung (kein Rahmen/Blatt). `host.ts` bewusst NICHT angefasst (Export-Callbacks leben in TopBarProps, nicht PanelHostValue — konsistent mit DXF/Schedule). **⚠️ Unit-Tests beweisen nur STRUKTURELLE STEP-Validität — Nutzer muss die .ifc in echtem Viewer (BIMcollab Zoom / IFC.js / Revit) gegenprüfen.** **Noch uncommittet.** (STL/OBJ-Export + IFC-Import + DWG-Schreiben bleiben offen — s. Interop-Befund.)
|
||||||
|
- [x] 2026-07-07 **ObjectInfo: Volumen/Länge bei Wand + Volumen bei Decke** (Nutzer-Wunsch) — `selectionInfo.ts`: WallInfo um `length`/`grossVolume`/`openingVolume`/`netVolume` (Netto = Länge×Dicke×Höhe − Σ Öffnungen[Breite×Höhe×Dicke]), CeilingInfo um `volume` (Fläche×Dicke). Panel zeigt bei Wand Länge + Netto-Volumen (Tooltip Brutto−Öffnungen), bei Decke Fläche + Volumen. +i18n `objinfo.volume`/`objinfo.wall.length`/`objinfo.wall.volumeGross`. tsc/vitest 391. **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **UI-Feinschliff-Runde (Nutzer-Session, Tauri live abgenommen):** (1) Kamera-Settings (FOV-Popover) ZUSÄTZLICH oben in der TopBar-Chrome neben CSV, dafür UNTEN aus beiden Ribbon-Tabs entfernt → unten saubere 2×4 View-Icons (Nutzer-Korrektur: erst waren fälschlich alle Presets mit oben). (2) ObjectInfo: Bezugspunkt-Würfel fix quadratisch (64px, skaliert nicht mehr mit Panelbreite), X/Y/Z als Spalte rechts daneben (`objinfo-refrow`/`objinfo-xyzcol`). (3) Wand/Decken-UK/OK-Unterzeile in normale Label/Wert-Struktur: „Referenzgeschoss" + Dropdown in der Wertspalte, bei Eigene „Eigene Höhe" linksbündig (+2 i18n-Keys `objinfo.anchor.*`). (4) Ansichten-Ribbon: Detailgrad + Darstellung übereinander gestapelt (eine `tb-stack`-Gruppe), alte separate Darstellungs-Gruppe entfernt. tsc sauber, vitest 377/377. **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **Basis-Materialien mit Default-Textur** (Nutzer-Wunsch „nicht dass ich sie zuweisen muss und jeder Reset zerstört es") — Seed-Components in `src/model/sampleProject.ts` tragen ab Werk `material` (via `materialFromAsset`, exakt der ResourceManager-Zuweisungsweg): Aussen-/Innenputz→Plaster001, Backstein→Bricks104, Beton→Concrete048. Bewusst OHNE: Dämmung + Estrich (kein passendes Asset in der 12er-Bibliothek — nicht geraten). Alt-Projekte unberührt (Feld optional). three.js-Viewport rendert automatisch texturiert; nativer Renderer weiterhin ohne Texturen (bekannter 3D-REST). tsc sauber, vitest 377/377. **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **Element-Übersicht / BIM-Tree-Panel** (ROADMAP §11, Phase-1) — neues `src/panels/ElementTreePanel.tsx`: dreistufiger Baum Geschoss→Bauteilklasse→Element aus `scheduleRows()` (Textfilter mit Auto-Aufklappen; Klick = selektieren, Shift/Doppelklick = selektieren+zoomen). `ScheduleRow.floorId` additiv (CSV unverändert), Host-Callback `onSelectScheduleRow` (App.tsx setzt korrekten Selektionskanal, wechselt bei Bedarf Geschoss + defert 1 rAF gegen den Geschosswechsel-Reset). Registriert in `builtinPanels.tsx` + Default-Rechts-Dock (`LAYOUT_VERSION` 8→9). Legacy `project.doors` bewusst ausgefiltert (tot; echte Türen sind `Opening kind=door`). +i18n `elements.*`. vitest 377/377, tsc sauber. **Bekannte Grobheit:** Zoom nutzt `fit()` (ganzer Plan) statt `fitSelection()`, weil letzteres nur lokal geklickte Indizes kennt und `PlanView.tsx` gesperrt war — Element wird per Auswahl-Outline sichtbar. **Nebenbei:** NUL-Byte in `exportSchedule.ts` (Kollision paralleler Schreibzugriffe) repariert. **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **Decken-UK-Verdrahtung nachgezogen** (Orchestrator) — `onSetCeilingBottom`-Handler in App.tsx ergänzt (Muster `onSetCeilingTop`); das UI-Feld aus dem Decken-UK/OK-Item ist damit funktional statt inert. tsc sauber, vitest 377/377.
|
||||||
|
- [x] 2026-07-07 **Kamera-Presets: Kardinal + Iso-Oktanten** (Milestone B3-Teil, OHNE Nord-Rotation — die bleibt Nutzer-Entscheid) — `View3d` jetzt front/back/side(=Rechts)/left/top/iso + 3 weitere obere Iso-Oktanten (vorne-links/hinten-rechts/hinten-links; untere 4 bewusst weggelassen) + perspective. Rust `CameraPreset`/`preset_camera`/`web.rs` synchron erweitert (+2 Tests, cargo render3d 60/60), three.js-Pfad (`Viewport3D.tsx`) in Parität, `VIEW3D_ORBIT`-Seeds analytisch. UI: 4 Kardinal-Buttons + Iso-Split-Button mit Oktanten-Popover (ViewRibbonTab + Ribbon3dTab). WASM-Rebuild sauber, tsc sauber, vitest 372/372. **Visuelle Abnahme im Tauri offen.** **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **Decken UK/OK-Override** (ROADMAP §11, Phase-1-Item) — `Ceiling.bottom?: VerticalAnchor` (additiv), `ceilingVerticalExtent` löst bottom-Anchor vor `zTop−thickness` auf (exakt das Wand-Muster); wirkt automatisch in 3D (`toWalls3d`) UND Schnitt (`toSection`), da beide denselben Resolver konsumieren. ObjectInfo: UK-Zeile jetzt editierbare `VerticalAnchorRow` (i18n-Key existierte). Neue Tests `model/wall.test.ts` (5). vitest 377/377, tsc sauber. **⚠️ Folge-Zeile:** `onSetCeilingBottom`-Handler in App.tsx (~5 Zeilen, Muster onSetCeilingTop) war ausserhalb der Agent-Lane — wird vom Orchestrator nachverdrahtet, bis dahin ist das UI-Feld inert. **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **A1 Override-Regel-Engine** (Top-Item des DOSSIER-Audits) — `OverrideRule` (condition: layer_name|object_name × equals|contains|starts_with|not_equals → actions: color/lineweight/linetypeId) additiv an `Project.overrideRules`; pure Engine `src/overrides/engine.ts` (`effectiveOverrides`: additiv, oberste aktive Regel gewinnt pro Feld, case-insensitiv, layer_name matcht Name ODER Code); Einhängung als Render-Dekoration in `generatePlan.ts` NACH der By-Layer/By-Object-Kette (Wände/Decken/Räume/Öffnungen/Treppen/Drawing2D; Projektdaten unverändert, jederzeit reversibel); ResourceManager-Tab „Overrides" (Master-Detail, Prioritätsliste ▲/▼, Aktiv-Checkbox). +18 Tests (engine 12, generatePlan-Integration 6). vitest 372/372, tsc sauber. **Bewusst offen:** user_string-Bedingung (kein Tag-Feld am Element), 3D-/Schnitt-Pfad, Presets/Templates (C2), Drag-Reorder; Overrides-Tab im ANGEDOCKTEN ResourcesPanel-Adapter vorerst nur lesend (Handler optional gehalten — Mini-Folge-Item). **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **SIA-416: AGF-Kategorie ergänzt** (Lücken-Schluss aus dem Milestone-Abgleich) — `SiaCategory`+`AGF` in `geometry/roomArea.ts`, Rollup NUR in GF (nicht NF/NGF): Bilanz-Reihenfolge HNF·NNF·NF(Σ)·VF·FF·NGF(Σ)·KGF·AGF·GF(Σ), GF = NGF+KGF+AGF. Dropdown/Bilanz-Panel/CSV übernehmen generisch ohne Änderung. Neue Testdatei `roomArea.test.ts` (8 Tests). vitest 366/366 grün. **Noch uncommittet.**
|
||||||
|
- [x] 2026-07-07 **D2 Bauteil-CSV volles Element-Set** — `exportSchedule.ts` deckt jetzt Tür/Fenster/Öffnung/Treppe/Extrusion zusätzlich zu Wand/Decke ab, exakt nach dem in HANDOVER.md hinterlegten Scope (Tür/Öffnung: Breite/Höhe/Fläche, Geschoss via Wirtswand; Treppe: Lauflänge + totalRise, Fläche bewusst leer; Extrusion: Höhe + polygonArea, Geschoss via levelId; Räume weiterhin im eigenen Raum-CSV). Aggregat-Zeile generalisiert (`totalLength`/`totalArea` > 0 ? Wert : leer — kein irreführendes 0.00 bei Treppe). Tests erweitert (Fixture + Zeilen-Asserts je neuem Element). `tsc` sauber, `vitest` 341/341 grün. **Noch uncommittet.**
|
||||||
|
|
||||||
|
- [x] 2026-07-06 **`extrude`-Befehl im 3D-Ribbon-Tab** — `Ribbon3dTab` (`src/ui/TopBar.tsx`) kannte nur Kamera/Darstellungsart, nicht den seit Phase 3 existierenden `extrude`-Befehl; neue Gruppe mit Befehls-Button (gleiches Muster wie `RibbonButton`s Befehls-Zweig: `CommandIcon` + Label aus der Registry, deaktiviert ohne aktives Geschoss, aktiv-Highlight bei laufendem Befehl). `onRunCommand`/`activeCommand` neu durchgereicht (App.tsx). tsc sauber.
|
||||||
|
- [x] 2026-07-06 **truck-Integration Phase 4 (Boolean) — Mesh-CSG via `csgrs` umgesetzt, technisch bewiesen** (Details s. oben bei „Phase 4"): zweiter Spike (`csgrs`, Mesh-Ebenen-BSP statt B-Rep) löst exakt den Fall, an dem `monstertruck-solid` scheiterte; nutzerautorisiert als echte (Git-gepinnte) Abhängigkeit in `trucksolid` eingebaut (`boolean.rs`, WASM-Export `boolean_mesh`, TS-Wrapper `booleanMesh()`). `cargo test` 11/11, `wasm32-unknown-unknown`-Build + echter `wasm-pack`-Build sauber. UI-Verdrahtung (wann automatisch subtrahieren) bewusst offen gelassen — Scope-Entscheidung, kein Technik-Risiko mehr.
|
||||||
|
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
|
||||||
|
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
|
||||||
|
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
|
||||||
|
- [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus** — `DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen.
|
||||||
|
- [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar").
|
||||||
|
- [x] 2026-07-05 **Textur-Spike verifiziert erledigt** (`0ca3b1d`) — bei der Backlog-Abarbeitung festgestellt, dass die render3d-Textur-Spike (`RenderStyle::Textured`, Schachbrett, planare Meter-UVs, `MESH_TEXTURED_WGSL` group 1, `spike3d`-`T`-Toggle) bereits vollständig umgesetzt + committet war; `cargo test` 58/59 grün, keine neuen Deps, Alt-Pfad bitgleich. „Als Nächstes"-Eintrag war veraltet → abgehakt; PBR-Restlückenliste (~18–29 PT) in 3D-REST übernommen.
|
||||||
|
- [x] 2026-07-05 **Ribbon-Feinschliff (OCS-Zeile)** — Tabs in die TopBar-Zeile verlegt (`456ebc8`, eine Leiste Chrome+Tabs, Inhalt darunter; Tab-State in App); OCS-Chrome: kleine Wortmarke „dossier." + Quick-Access-Icons für ALLE Datei-/Export-Aktionen statt Burger-Menü, Zeile auf 26px (`4e3b074`); Text-/Font-Formatierung mit der Ansichts-/Zoom-Steuerung auf EINE Leiste gelegt, „Ansichten" als Standard-Tab zuerst + initial aktiv (`fe22cbf`). Alle tsc + 331 Tests grün. Visuelle Abnahme (26px, Icon-Sitz) noch offen.
|
||||||
|
- [x] 2026-07-05 **Ribbon Phase 2: TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll mergen") — die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid+Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart) als `ViewRibbonTab` (in `TopBar.tsx`) in den Ansichten-Tab verschoben; App reicht sie als `viewsContent`-Node an `RibbonBar` (analog Layout-Menü). TopBar ist jetzt schmale globale Leiste (Marke/Ressourcen · Text · Datei/Export/Einstellungen · Fensterknöpfe). `TopBarProps` entsprechend verschlankt. Visuelle Feinabstimmung offen.
|
||||||
|
- [x] 2026-07-05 **Ribbon-UI Phase 1** (`9d6e86d`) — datengetriebene Tab-Leiste (`src/ui/ribbon/`: `ribbonItems.ts` Registry, `RibbonBar.tsx`, `CommandIcon.tsx`) unter der TopBar, additiv (Sidebar bleibt). Tabs 2D·3D·BIM·Ansichten; 2D=Zeichnen(select/line/polyline/rect/circle/arc)+Ändern(move/copy/mirror/offset/trim/join), BIM=Bauteile(wall/window/door/stair/ceiling/room); 3D/Ansichten noch leer. Ein Aktivierungs-Pfad: tool→`onSelectTool`, command→`onRunCommand`(engine.start); Aktiv-Highlight über `activeTool`/`engineView.commandName`. `ToolIcon` aus ToolsPanel exportiert (wiederverwendet).
|
||||||
|
- [x] 2026-07-05 **Attribut-Panel: ein Grid, volle Breite** (`ace0dc6`) — drei getrennte Grids zu EINEM durchgehenden Zwei-Spalten-Grid gemerged (Wertspalte über alle Sektionen bündig), Dropdown-Pills füllen die Wertspalte (fixe Breiten raus), doppeltes Eigen-Padding + zweiter „Attribute"-Titel entfernt. **Nutzer-Report.**
|
||||||
|
- [x] 2026-07-05 **Eigenschaften-Grid OCS-Stil** (`00733d8`) — `.attr-*` als klares Grid: Sektion-Balken, Zeilentrenner, füllende linksbündige Wertfelder (Texte/Zahlen/Dropdowns einheitlich). Erste Stufe der Ribbon-UI-Vision.
|
||||||
|
- [x] 2026-07-05 **Zoom-Scroll-Fix** (`077e774`) — Dokument-Overscroll/Rubberband gesperrt (`html,body,#root { overflow:hidden; overscroll-behavior:none }`); Viewport-Zoom zieht die UI nicht mehr mit (macOS). **Nutzer-Report.**
|
||||||
|
- [x] 2026-07-05 **Kreis- + Bogen-Werkzeug** (`e454eab`, `bd2b12b`) — Kreis-Toolbar (circleCommand gekoppelt) + neues `arcCommand` (3-Klick CCW), beide mit Toolbar-Icon/i18n; rendern glatt via `drawingCircle`/`drawingArc`.
|
||||||
|
|
||||||
|
- [x] 2026-07-05 **DXF-Import: Platzierungsoption** (`dd76ec8`) — ImportDialog fragt „relativ zum Nullpunkt" ODER „in die Mitte der aktuellen Ansicht"; `PlanViewHandle.viewCenterModel()` neu; `runImport` verschiebt den Gesamt-Umriss (bbox-Mitte → Ansichtsmitte). tsc + Suite 331 grün.
|
||||||
|
- [x] 2026-07-05 **Zeichnungen Copy/Paste über Geschosse** (`376aa67`) — Ctrl/Cmd+C kopiert gewählte Drawing2D tief; Ctrl/Cmd+V fügt Klone (neue IDs) auf dem AKTIVEN Geschoss ein und wählt sie. Für z. B. importiertes Mobiliar. Textfeld-Fokus bleibt natives Copy/Paste.
|
||||||
|
- [x] 2026-07-05 **Tauri Drag&Drop + Import-Dialog-Fix** (`c5b5ca6`, `45e19b7`) — `import`-Befehl als `autoRun` (Datei-Dialog öffnet synchron in der Geste, HMR-Falle via Config-Relaunch behoben); `dragDropEnabled:false` (Tauris natives Drag-Drop fing HTML-Drops ab). **Nutzer-bestätigt: Dialog + Text gehen.**
|
||||||
|
- [x] 2026-07-05 **Layout: Befehlszeile in Mitte-Spalte** (`c794fee`) — nur Viewport-breit, Docks gewinnen Höhe. **Nutzer-bestätigt „sieht clean aus".**
|
||||||
|
|
||||||
|
- [x] 2026-07-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
|
||||||
|
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
|
||||||
|
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
|
||||||
|
- [x] 2026-07-04 **Snap-Farbe entschieden: Sora #5FA1C9** als endgültiger Default festgeschrieben (`DEFAULT_SNAP_COLOR`, Doc), „aki"-❓ geschlossen.
|
||||||
|
- [x] 2026-07-04 **Mac-Build via Tauri + Queue-Abgleich** — Toolchain auf macOS-Gerät verifiziert, `cad.app`/`cad_0.1.0_aarch64.dmg` gebaut & gestartet. Verifikations-Baseline grün (tsc/vitest 230/cargo 56). Queue-Reconciliation: Toolchain-Blocker, Snap-Slice (`339202b`), Ebene-Schraffur (`dcb6ed5`), Bauteile-Tab-Master-Detail, Einstellungs-Verdrahtung UND Wand-Schicht-Bänder 3D (Option B, `toWalls3d.ts`) waren allesamt **bereits gelandet**, aber in PENDENZEN noch als offen gelistet → abgehakt. (kein Code-Commit)
|
||||||
|
- [x] 2026-07-04 **Öffnungen als echte Boolean-Löcher** (+ Deckentrim-nur-3D) — Fenster/Türen = 1 Wandkörper mit rechteckigen `holes` (Rechteck-Gitter-Zerlegung + Laibungsquads), Segment-Boxen weg; Deckentrim nur noch im 3D-Pfad, Schnitt volle Höhe. Pflicht-Testfall versetzt-überlappende Fenster grün. cargo test 56, vitest 230, build:engine3d + tsc sauber — `1407c68`
|
||||||
|
- [x] 2026-07-04 **Joins Phase 1c** Durchgangswand am T-Stoss echt aufbrechen (spanCutouts) — `c5a344d`
|
||||||
|
- [x] 2026-07-04 **Locked-Iso-Fix** freie Kamera bleibt nach Ortho-Preset orthografisch — `cb8fae5`
|
||||||
|
- [x] 2026-07-04 **3D-Live-Schnittebene** mit schraffierten Schnittflächen — `03f0c40`
|
||||||
|
- [x] 2026-07-04 **Joins Phase 2** Merge-Regel im Schnitt (gleiche Komponente verschmilzt) — `82d354f`
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# PORT_PLAN — kernel2d nach Rust/WASM (hinter identischem TS-Interface)
|
||||||
|
|
||||||
|
Stand: 2026-07-04. **Dieser Plan ist der geforderte erste Schritt — noch kein Code.**
|
||||||
|
Ziel: `src/geometry/kernel2d.ts` (+ reine Geometrie aus room/ceiling/opening/stair) in
|
||||||
|
ein Rust-Crate portieren, zu WASM bauen, hinter einer TS-Fassade mit *exakt gleichen*
|
||||||
|
Signaturen einhängen. TS-Kernel bleibt als Referenz (`kernel2d.legacy.ts`).
|
||||||
|
Differential-Test: Rust-WASM == TS-Legacy auf identischen Eingaben (epsilon je Funktion).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope-Inventar (was wirklich portiert wird)
|
||||||
|
|
||||||
|
**Externe JS-Geometrie-Libs: KEINE.** Der Kernel ist handgeschriebene f64-Mathematik,
|
||||||
|
einzige Abhängigkeit sind die Vektor-Helfer aus `src/model/geometry.ts`
|
||||||
|
(`add/sub/scale/len/normalize/leftNormal/cross/dot/lineIntersect`) — trivial mit zu
|
||||||
|
portieren. `polygon-clipping`/`delaunator` werden im Kernel **nicht** genutzt.
|
||||||
|
|
||||||
|
### Voll im Scope (reine Geometrie)
|
||||||
|
- **`kernel2d.ts`** — komplett: Primitive, Schnitt, Offset, Trim/Split/Join, Kreis, Fläche/Orientierung, Fillet.
|
||||||
|
- **`roomBoundary.ts`** — komplett: `detectRooms`, `roomFromPointInside(Faces)`, `pointInPolygon`, `WallSegment`/`WallFace`/`DetectRoomsOptions` (generische Geometrie-Typen, `thickness: number`).
|
||||||
|
- **`ceiling.ts`** — komplett: `normalizeOutline`, `isValidOutline`, `ceilingArea`, `outlineBBox`, `outlineCentroid`, `pointInOutline` (generische Polygon-Utilities; Name irreführend, keine Decken-Semantik).
|
||||||
|
|
||||||
|
### Teilweise im Scope
|
||||||
|
- **`roomArea.ts`** — NUR `signedArea`, `polygonArea`, `perimeter`, `centroid`. Alles ab `SiaCategory` (`siaLabel`, `evaluateRoom`, `balance`, `roomsToCsv`) ist SIA-416-Domänenlogik/CSV → **bleibt TS**.
|
||||||
|
- **`stair.ts`** — portierbar, aber `stairGeometry` nimmt `Stair`. „Scheinkopplung": es werden nur geometrische Felder gelesen (`shape/start/dir/runLength/width/stepCount/…`), `totalRise` kommt bereits aufgelöst vom Aufrufer. Port via Rust-Struct `StairParams` (strukturgleich, null Model-Semantik).
|
||||||
|
|
||||||
|
### ⚠️ Scope-Spannung: `opening.ts` (im Auftrag genannt, aber stark model-gekoppelt)
|
||||||
|
7 von 9 Exporten binden `Wall`/`Opening` direkt ein; `getWallType`/`wallTypeThickness`/`wallReferenceOffset`/`wallVerticalExtent` lösen gegen `Project` auf.
|
||||||
|
- **Portierbar mit abgeflachter Signatur** (Vec2/number statt Wall/Opening): `wallAxisLength`, `openingInterval`, `wallAxisFrame`, `openingJambs`, `openingCenter`, `doorSymbol`, `openingGapQuad`/`windowSymbol` (letztere brauchen `thickness`/`refOff` als `number`-Parameter).
|
||||||
|
- **Bleibt TS** (braucht `Project`/Geschoss-Auflösung): `openingVerticalExtent` (via `wallVerticalExtent` → `Project.drawingLevels`).
|
||||||
|
- **→ Entscheidung nötig** (siehe §7): Der Auftrag verbietet Änderungen an Aufrufstellen außer dem Import-Pfad. Ein Port von `opening` würde die *Signaturen* ändern (Wall→Vec2/number) und damit die Aufrufstellen brechen — das widerspricht „keine Änderung an Aufrufstellen". Empfehlung: **opening in Phase 1 ausklammern**, nur den echt reinen Kern (kernel2d/roomBoundary/ceiling/roomArea-Flächen/stair) portieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Crate-vs-Portieren — pro Operation
|
||||||
|
|
||||||
|
Akzeptanzkriterium ist **Differential-Parität gegen die naive TS-Routine**, nicht „geometrisch besser". Jedes Fremd-Crate mit anderem Algorithmus bricht die Parität per Konstruktion → Default = **PORTIEREN** (TS-Routinen sind 5–40 Zeilen f64, 1:1 übersetzbar).
|
||||||
|
|
||||||
|
| Operation | geprüftes Crate | Entscheidung | Grund |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `offsetPolyline`/`offsetSegment` | cavalier_contours 0.6 | **PORTIEREN** | Crate liefert **Arcs (bulge)** statt `Vec2[]`, **heilt Selbstschnitte**, gibt mehrere Polylinien — TS heilt bewusst NICHT. Semantik nicht angleichbar. |
|
||||||
|
| Segment/Line-Schnitt | geo / robust | **PORTIEREN** | Cramer-Formel; jede andere denom-/Epsilon-Politik driftet in Parallel-Grenzfällen. |
|
||||||
|
| Trim/Split/`segmentPolylineHits` | geo `Relate` | **PORTIEREN** | Projektspezifisch (t-Dedup `1e-6`, Pick-nächster-Bogen, Wrap-Logik). |
|
||||||
|
| Kreis-Schnitte | — | **PORTIEREN** | Quadratik mit projekt-EPS-Disc-Klemmung. |
|
||||||
|
| `signedArea`/`isCCW` | geo `Area` | **PORTIEREN** | Shoelace-Summierung in **identischer Vertex-Reihenfolge** (f64 nicht assoziativ). |
|
||||||
|
| `filletCorner` | — | **PORTIEREN** | `acos/atan2/tan(θ/2)`-Kette + projekt-Cutoffs; liefert projekt-spezifische `Fillet`-Struktur. |
|
||||||
|
| `detectRooms` / `joinChains` | geo / i_overlay | **PORTIEREN** | Topologie-/reihenfolgeabhängig; andere Kantendurchlauf-Reihenfolge → andere (gleich gültige) Ringe → Parität bricht. |
|
||||||
|
| point-in-polygon | robust | **PORTIEREN** (robust nur intern, optional) | Siehe §3. |
|
||||||
|
|
||||||
|
Fremd-Crates (cavalier_contours/geo/i_overlay) wären für einen späteren *Tier-2-Rewrite* (echter Arc-Offset, Boolean-Ops) wertvoll — das ist ein **anderes Produkt**, nicht dieser paritätserhaltende Port.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `robust`-Prädikate vs. Differential-Parität (Spannung auflösen)
|
||||||
|
|
||||||
|
TS testet Orientierung überall via naives `cross()` gegen `EPS`. `robust::orient2d` liefert das **exakte** Vorzeichen — weicht von naiv **nur in der nahe-degenerierten Zone** ab (fast-parallele Segmente, Null-Fläche-Polygone, Punkt-auf-Kante). Genau dort schlägt der Diff-Test am ehesten an. Man kann nicht gleichzeitig „bit-Parität gegen naiv" und „robuste Prädikate" im *selben* Vergleich haben.
|
||||||
|
|
||||||
|
**Entscheidung:**
|
||||||
|
1. **v1 portiert die naiven `cross`-Vergleiche 1:1** (KEIN `robust`) → Zufalls-Diff-Test wird bit-nah grün. Robustheit kommt aus denselben f64-Formeln + demselben `EPS` wie TS.
|
||||||
|
2. **Additiv, getrennt:** `robust` nur *intern* in `detectRooms`/point-in-polygon hinter optionalem Feature `robust-predicates`, flankiert von **Golden-Cases, die die KORREKTE (robuste) Antwort asserten** (nicht TS-Parität). Diese Fälle sind aus dem Zufalls-Diff-Test ausgenommen.
|
||||||
|
3. Zwei Testklassen, nie gemischt: **Zufalls-Parität = naiv**, **Golden-Korrektheit = robust**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Crate-Setup & Vite (exakter Klon von `src-tauri/geometry`)
|
||||||
|
|
||||||
|
Ort: **`src-tauri/kernel2d`** (analog render2d/render3d/geometry; das gesamte Tooling ist auf `src-tauri/<crate>` + `../../src/engine/pkg<X>` verdrahtet). *Namens-Hinweis:* Auftrag sagt `crates/kernel2d` — siehe §7.
|
||||||
|
|
||||||
|
- `Cargo.toml`: `crate-type = ["cdylib","rlib"]`; Features `default=[]`, `web=[wasm-bindgen, serde_json, console_error_panic_hook]`, `robust-predicates=[robust]` (additiv, nicht in web-Default).
|
||||||
|
- `src-tauri/Cargo.toml`: `exclude = [..., "kernel2d"]` erweitern (sonst „multiple workspace roots").
|
||||||
|
- `src/lib.rs`: reiner f64-Rechenkern feature-frei (`cargo test`-bar, headless) + `#[cfg(feature="web")]` JSON-**Batch**-Fassade pro Operation (`offset_polylines_json`, `intersect_batch_json`, `fillet_batch_json`, `circle_intersect_batch_json`, `detect_rooms_json`), Muster `compute_joins_json`.
|
||||||
|
- `examples/parity.rs`: Klon von `geometry/examples/parity.rs` (Batch-JSON stdin→stdout) — nativer Diff-Kanal.
|
||||||
|
- `package.json`: `"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web"`.
|
||||||
|
- **Vite: kein Config-Eintrag nötig** — `--target web`-Pakete werden als normales ES-Modul importiert, Vite bündelt `kernel2d_bg.wasm` automatisch (`new URL(..., import.meta.url)`). `pkgKernel2d/` ist git-ignoriert (self-`.gitignore = *`) → vor `vitest`/`build` muss `build:kernel2d` laufen (CI-Schritt).
|
||||||
|
- **TS-Fassade** `src/geometry/kernel2d.ts` wird dünner Wrapper (init-WASM, JSON-Marshalling, gleiche Exports); Alt-Impl → `src/geometry/kernel2d.legacy.ts` (nicht löschen, ist die Diff-Referenz).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Differential-Test-Harness
|
||||||
|
|
||||||
|
- **Grobkörnige WASM-Grenze:** eine Batch-Funktion je Operation (N Polylinien rein, N Ergebnisse raus). Keine Per-Punkt-Calls (jeder Call marshallt einen String = O(n)-Kopie).
|
||||||
|
- **Diff-Kanal:** Der Auftrag verlangt **Rust-WASM** vs. TS-Legacy → primär WASM (via `initSync`). Zusätzlich `cargo run --example parity` (nativ) als schneller Sekundär-Kanal (bit-identisch zu WASM für `+ - * / sqrt`; **Ausnahme** `atan2/acos/tan` in Fillet: libm nativ ≠ wasm um letzte ULP → Winkel-Epsilon).
|
||||||
|
- **vitest-Init synchron:** `initSync({ module: readFileSync(pkgKernel2d/kernel2d_bg.wasm) })` (kein `fetch`), einmal in `beforeAll`.
|
||||||
|
- **Zufallsgeneratoren:** seed-basiert (Seed im Testnamen), Polylinien 3–20 Vertices, Koordinaten `[-100,100] m`, `closed`/`d` zufällig; zusätzlich Cluster nahe `0` und `1e-6..1e-3`, um Toleranzschwellen zu treffen.
|
||||||
|
- **Vergleichsreihenfolge:** zuerst **Struktur exakt** (Array-Längen, closed-Flags, Punktzahl, null/nicht-null), dann Werte mit op-Epsilon. Struktur ist der schärfste Paritäts-Wächter.
|
||||||
|
|
||||||
|
### Epsilon pro Funktion
|
||||||
|
| Größe | Toleranz | Grund |
|
||||||
|
|---|---|---|
|
||||||
|
| Punktkoordinaten (Offset/Trim/Split/Kreis) | `abs 1e-9` | Bestehender Paritätstest nutzt `1e-9` und besteht bit-nah. |
|
||||||
|
| Fläche (`signedArea`) | `rel 1e-9·max(1,\|A\|)` | Shoelace ∝ coord² → absolute ULP wächst mit Flächengröße; relativ skaliert korrekt. |
|
||||||
|
| Winkel (`filletCorner`) | `abs 1e-7 rad` | `atan2/acos` libm-abhängig (nativ↔wasm ULP-Drift); 1e-7 rad ≈ 5.7e-6°, weit unter Zeichenrelevanz. |
|
||||||
|
| Parameter t/s | `abs 1e-9` | TS dedupliziert erst ab `1e-6` → kleinere Diffs ändern nie die Struktur. |
|
||||||
|
| Struktur | **exakt** | Kein Epsilon. Hier bricht ein Fremd-Crate. |
|
||||||
|
|
||||||
|
### Golden-Cases (explizit, aus Zufallstest teils ausgenommen)
|
||||||
|
kollineare Tripel · Null-Länge-Segmente (Dublett-Vertex) · Offset-Selbstschnitt (enges U, großes d — hier bräche cavalier_contours) · spitze Fillet-Winkel (θ→0) · fast-paralleler Schnitt (denom knapp <>EPS → **Korrektheits-Golden/robust**) · konzentrische/tangentiale Kreise · Punkt exakt auf Polygonkante (**Korrektheits-Golden**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Kritische Paritäts-Details (MÜSSEN exakt repliziert werden)
|
||||||
|
- `len` = `Math.hypot` → **`f64::hypot`** (nicht `(x²+y²).sqrt()`).
|
||||||
|
- `normalize` Null-Guard: `len || 1` → `if l==0.0 {1.0} else {l}` (Ergebnis `{0,0}`, kein NaN).
|
||||||
|
- **Zwei Epsilons:** `EPS=1e-7` (kernel2d) UND hartkodiert **`1e-9`** in `lineIntersect` (Offset-Miter-Fallback hängt daran — geometrischer Sprung, nicht epsilon).
|
||||||
|
- Dedup-Schwelle `1e-6`, Fillet-Kollinearität `1e-4` (nicht EPS).
|
||||||
|
- **Stabile Sortierung** (JS `Array.sort` ist stabil): Rust `sort_by`, nicht `sort_unstable_by`.
|
||||||
|
- Term-Reihenfolge in `cross`, `signedArea`-Summierung, Kreis-Diskriminante `B*B−4*A*C` exakt beibehalten (f64 nicht assoziativ; kein Kahan/Reorder).
|
||||||
|
- Modulo-Indizierung `(i+len-1)%len` (usize-Unterlauf vermeiden).
|
||||||
|
- `joinChains`: greedy `i<j`-erster-Treffer-dann-Neustart exakt nachbilden (reihenfolgeabhängiges Ergebnis).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Offene Entscheidungen (vor Coding klären)
|
||||||
|
1. **`opening` im Scope?** Portieren würde Signaturen (Wall→Vec2/number) und damit Aufrufstellen ändern — widerspricht „keine Änderung an Aufrufstellen außer Import-Pfad". **Empfehlung: opening in Phase 1 ausklammern.**
|
||||||
|
2. **Crate-Ort:** Auftrag `crates/kernel2d` vs. Repo-Konvention `src-tauri/kernel2d` (analog render2d/render3d). **Empfehlung: `src-tauri/kernel2d`** (Tooling passt out-of-the-box).
|
||||||
|
3. **Diff-Kanal:** Auftrag verlangt Rust-**WASM**; nativer `parity`-Kanal ist schneller (kein wasm-pack in CI) und bit-identisch außer Fillet-Transzendente. **Empfehlung: WASM primär (Auftragskonform) + nativ sekundär.**
|
||||||
|
|
||||||
|
## 8. Phasen (Reihenfolge)
|
||||||
|
1. Crate-Skelett `src-tauri/kernel2d` + Build-Script + Workspace-exclude + leere WASM-Fassade → `build:kernel2d` grün.
|
||||||
|
2. Primitive + Schnitt + Fläche + Kreis portieren (trivial–mittel) + Batch-Fassade + Diff-Test-Harness (Zufall+Golden) → grün.
|
||||||
|
3. Offset (Miter+1e-9-Fallback) + Fillet portieren → Golden für Selbstschnitt/spitze Winkel grün.
|
||||||
|
4. Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — der Löwenanteil) → Struktur-Golden grün.
|
||||||
|
5. `roomBoundary` (`detectRooms`) + `ceiling` + `roomArea`-Flächen + `stair` (StairParams).
|
||||||
|
6. TS-Fassade umstellen (Alt → `.legacy.ts`), Import-Pfade der Aufrufstellen unverändert lassen, bestehende Suite grün, `npm run build` + WASM-Build sauber.
|
||||||
@@ -24,8 +24,9 @@ Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen
|
|||||||
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
|
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
|
||||||
Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit
|
Linien, Öffnungen → Lücken + Symbol). PDF-Export ist derselbe Plan, nur mit
|
||||||
echten mm-Stiftstärken statt Bildschirm-Hairlines. Schnitte und Ansichten laufen
|
echten mm-Stiftstärken statt Bildschirm-Hairlines. Schnitte und Ansichten laufen
|
||||||
über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig)
|
über eine eigene, analytische Rust-Pipeline (kein Hidden-Line-Removal nötig,
|
||||||
und sind seit Juli live im 3D-Viewport verdrahtet, nicht nur ein Spike.
|
siehe [ARCHITECTURE.md](ARCHITECTURE.md) §4.3) und sind seit Juli live im
|
||||||
|
3D-Viewport verdrahtet, nicht nur ein Spike.
|
||||||
|
|
||||||
## Stand heute
|
## Stand heute
|
||||||
|
|
||||||
@@ -33,10 +34,11 @@ Aus dem ursprünglichen Risiko-Spike ist binnen weniger Wochen ein
|
|||||||
**funktionsreiches Desktop-BIM-Tool** geworden: eigenes semantisches
|
**funktionsreiches Desktop-BIM-Tool** geworden: eigenes semantisches
|
||||||
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines, IFC/DXF/PDF/STL/OBJ-
|
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines, IFC/DXF/PDF/STL/OBJ-
|
||||||
Export, Swisstopo-Import, SIA-416-Flächen, Materialbibliothek, Layouts/
|
Export, Swisstopo-Import, SIA-416-Flächen, Materialbibliothek, Layouts/
|
||||||
Plansätze, native Tauri-Fenster. ~91.000 Zeilen TypeScript + ~19.500 Zeilen
|
Plansätze, native Tauri-Fenster. Eine vollständige, ehrliche Bestandsaufnahme
|
||||||
Rust (ohne Tests), 891 Vitest-Tests, alle grün.
|
(inklusive offener Punkte und bekannter technischer Schulden) steht in
|
||||||
|
**[STATUS.md](STATUS.md)**.
|
||||||
|
|
||||||
**Funktioniert (Auszug, nicht abschliessend):**
|
**Funktioniert (Auszug — volle Liste in STATUS.md §3):**
|
||||||
- Semantisches Modell mit **mehrschichtigen Wänden**, L-Eck-Gehrung **und**
|
- Semantisches Modell mit **mehrschichtigen Wänden**, L-Eck-Gehrung **und**
|
||||||
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — konsistent in
|
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — konsistent in
|
||||||
Grundriss, 3D-Viewport und 3D-Live-Schnitt.
|
Grundriss, 3D-Viewport und 3D-Live-Schnitt.
|
||||||
@@ -63,7 +65,7 @@ Rust (ohne Tests), 891 Vitest-Tests, alle grün.
|
|||||||
- **Resource Manager** (Component/Hatch/Line/Typ-Editoren), regelbasierte
|
- **Resource Manager** (Component/Hatch/Line/Typ-Editoren), regelbasierte
|
||||||
Overrides, Panel-System (dockbar/floatend), i18n (de/en).
|
Overrides, Panel-System (dockbar/floatend), i18n (de/en).
|
||||||
|
|
||||||
**Bewusst noch offen** (Auszug):
|
**Bewusst noch offen** (Details + volle Liste: STATUS.md §4.7):
|
||||||
- **Echtes Mesh-Boolean für Öffnungen** — funktioniert heute über achsparallele
|
- **Echtes Mesh-Boolean für Öffnungen** — funktioniert heute über achsparallele
|
||||||
Rechteck-Löcher; ein generisches CSG-Boolean existiert bereits
|
Rechteck-Löcher; ein generisches CSG-Boolean existiert bereits
|
||||||
(`trucksolid`/`csgrs`), ist aber nicht an die Wand-Pipeline angeschlossen.
|
(`trucksolid`/`csgrs`), ist aber nicht an die Wand-Pipeline angeschlossen.
|
||||||
@@ -88,9 +90,11 @@ Rust (ohne Tests), 891 Vitest-Tests, alle grün.
|
|||||||
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
|
| Import | `dxf-parser`, `@mlightcad/libredwg-web` (DWG), eigene `.lin`/`.pat`-Parser |
|
||||||
| Materialien | `jszip` (ambientCG-Zip-Entpacken), `three.js` `TextureLoader` |
|
| Materialien | `jszip` (ambientCG-Zip-Entpacken), `three.js` `TextureLoader` |
|
||||||
|
|
||||||
Der ursprünglich geplante OCCT/`opencascade.js`-HLR-Pfad für Schnitte ist
|
`opencascade.js` steht noch als Dependency in `package.json`, wird aber nur
|
||||||
komplett entfernt (weder Dependency noch Code) — abgelöst durch die
|
noch von totem Code (`src/section/hlr.ts`, superseded durch die Rust-
|
||||||
analytische Rust-Schnitt-Pipeline (siehe Grundgedanke oben).
|
Schnitt-Pipeline) importiert. Details zu allen Rust-Crates (inkl. zwei
|
||||||
|
aktuell unbenutzten WASM-Builds) in [ARCHITECTURE.md](ARCHITECTURE.md) §1
|
||||||
|
und [STATUS.md](STATUS.md) §4.1.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
@@ -126,16 +130,17 @@ src/
|
|||||||
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
|
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
|
||||||
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2) + Rust-Bindings
|
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG) + glPlan (WebGL2) + Rust-Bindings
|
||||||
viewport/ Viewport3D (Three.js) + Wasm3DViewport (Rust/wgpu „Nordstern", Default)
|
viewport/ Viewport3D (Three.js) + Wasm3DViewport (Rust/wgpu „Nordstern", Default)
|
||||||
|
section/ TOTER Code (OCCT-HLR-Spike) — Schnitt läuft über render3d, siehe ARCHITECTURE.md §4.3
|
||||||
export/ IFC4-, PDF-, DXF-, STL/OBJ-, CSV-Export aus derselben Plan-/Modell-Struktur
|
export/ IFC4-, PDF-, DXF-, STL/OBJ-, CSV-Export aus derselben Plan-/Modell-Struktur
|
||||||
materials/ ambientCG-Live-Suche + gebündelte Starter-Bibliothek + PBR-Runtime
|
materials/ ambientCG-Live-Suche + gebündelte Starter-Bibliothek + PBR-Runtime
|
||||||
panels/ dockbares Panel-System + die einzelnen Paletten
|
panels/ dockbares Panel-System + die einzelnen Paletten
|
||||||
state/ eigener Store (useSyncExternalStore) + Slices (project/selection/view/layout/…)
|
state/ eigener Store (useSyncExternalStore) + Slices (project/selection/view/layout/…)
|
||||||
native/ Tauri-only: native Fenster, macOS-Menüleiste, Fenster-Chrome
|
native/ Tauri-only: native Fenster, macOS-Menüleiste, Fenster-Chrome
|
||||||
ui/ App.tsx-Shell, Top-Bar, Resource Manager, Kontextmenü, Kommandozeile
|
ui/ App.tsx-Shell, Top-Bar, Resource Manager, Kontextmenü, Kommandozeile, Ribbon
|
||||||
io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext
|
io/ Import/Export (DXF, DWG, .lin, .pat), Swisstopo/LV95, OSM-Kontext
|
||||||
i18n/ Wörterbücher de/en
|
i18n/ Wörterbücher de/en
|
||||||
src-tauri/ 5 eigenständige Rust-Crates (render2d/render3d/kernel2d/trucksolid/
|
src-tauri/ 6 Rust-Crates (render2d/render3d/geometry/kernel2d/trucksolid/dwgimport,
|
||||||
dwgimport, je headless UND per wasm-pack baubar) + der Tauri-Host selbst
|
je headless UND per wasm-pack baubar) + der Tauri-Host selbst
|
||||||
```
|
```
|
||||||
|
|
||||||
## Konventionen
|
## Konventionen
|
||||||
@@ -144,14 +149,18 @@ src-tauri/ 5 eigenständige Rust-Crates (render2d/render3d/kernel2d/trucksol
|
|||||||
(Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer
|
(Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer
|
||||||
über `t('key')` — keine hartcodierten Strings im JSX.
|
über `t('key')` — keine hartcodierten Strings im JSX.
|
||||||
- Intern alles in **Metern**; Anzeige via `formatM`.
|
- Intern alles in **Metern**; Anzeige via `formatM`.
|
||||||
|
- Verbindliches in [CONVENTIONS.md](CONVENTIONS.md).
|
||||||
|
|
||||||
## Weiterlesen
|
## Weiterlesen
|
||||||
|
|
||||||
Dieses README ist die einzige Doku im öffentlichen Repo — `STATUS.md`,
|
- [STATUS.md](STATUS.md) — **vollständige, ehrliche Bestandsaufnahme**: Zahlen,
|
||||||
`ARCHITECTURE.md`, `ROADMAP.md`, `HANDOVER.md`, `PENDENZEN.md`,
|
Feature-Inventar, Mist-Liste, Doku-Widersprüche, Tag-1-Vision vs. heute
|
||||||
`CONVENTIONS.md` und `docs/` sind bewusst per `.gitignore` ausgeschlossen
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — technische Architektur im Detail (Ist-Zustand)
|
||||||
(interne Arbeitsnotizen/Backlog, kein öffentlicher Anspruch auf Vollständigkeit
|
- [ROADMAP.md](ROADMAP.md) — ursprüngliche Produktvision (Tag-1-Stand, historisch)
|
||||||
oder Aktualität) und daher hier absichtlich **nicht** verlinkt.
|
- [HANDOVER.md](HANDOVER.md) / [PENDENZEN.md](PENDENZEN.md) — laufendes
|
||||||
|
Arbeitsprotokoll bzw. Backlog (Single Source of Truth für offene Punkte)
|
||||||
|
- [docs/](docs/) — Design-Specs (teils ebenfalls Tag-1-Vision, siehe Hinweis
|
||||||
|
in `docs/README.md`)
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# RESEARCH — Bauteile Treppe/Fenster/Tür: Rhino-Plugin als Vorbild
|
||||||
|
|
||||||
|
Stand: 2026-07-05. Vergleich der authoritativen Rhino-Plugin-Implementierung
|
||||||
|
(`/Users/karim/PROJECTS/DOSSIER/rhino/`, C#/Python + RhinoCommon-B-Rep) mit
|
||||||
|
DOSSIER-STANDALONEs vereinfachter TS-Umsetzung. Ziel: Bauteile aufs Rhino-Niveau
|
||||||
|
heben. Belege = Rhino-Funktionsnamen.
|
||||||
|
|
||||||
|
**Grundsatz:** Rhino hat echten B-Rep-Kernel (3D). DOSSIER hat extrudierte Meshes +
|
||||||
|
`kernel2d` (2D). Darum getrennt: **2D-Symbol-Logik** (leicht in TS/kernel2d
|
||||||
|
übernehmbar) vs. **3D** (braucht Mesh-Pipeline bzw. später truck).
|
||||||
|
|
||||||
|
## Treppe (Rhino `treppe.py`, 1784 Z. — massiv reicher als `stair.ts`)
|
||||||
|
- **Typen:** gerade / L (3- und 4-Punkt mit Podest) / Wendel (inkl. Spindel-Cone bei r_in<0.05). TS hat alle drei, aber simpler.
|
||||||
|
- **3D-Querschnitt-Modi** `massiv`/`flach`/`plattenrand` (`_treppe_profile_2d`) — TS: nur Stufenboxen. **fehlt.**
|
||||||
|
- **2D-Plansymbol** (`_make_treppe_2d_symbol`): Tritte, Lauflinie (4 Pfeil-Styles: klassisch/filled/breit/voll, visuell zentriert via `mid_off`), **Aussenlinie/Outline** (alle 3 Typen), Bruchlinie, Podest-Hexagon. TS: Tritte+Bruch+Lauflinie(1 Style) da; **Aussenlinie fehlt komplett**, Referenz links/mitte/rechts fehlt.
|
||||||
|
- **Soll-Schrittmass** (2S+A, Lock), Show-Flags, Grips — fehlen in TS.
|
||||||
|
|
||||||
|
## Fenster (Rhino `elements.py` `_make_oeffnung_pieces`)
|
||||||
|
- **3D:** Rahmen (BooleanDiff), Mittelpfosten je Flügel (1–4), Glas (einfach 12mm / Doppel 2×6+16), Sims aussen (4 Styles), Rahmen-Offset innen/mitte/aussen. TS: nur 2D-Rahmen+Glaslinien, Wand-Boolean-Loch (`1407c68`); kein Rahmen-3D.
|
||||||
|
- **2D:** TS hat Rahmen + 1–2 Glaslinien (grob/mittel/fein). **Fehlt:** Brüstungslinie im Plan, Flügel-Mittelpfosten, Anschlag-Striche (nur Tür hat sie), Flügelanzahl.
|
||||||
|
|
||||||
|
## Tür (Rhino `elements.py`)
|
||||||
|
- **Typen:** `normal` / `wandoeffnung` (reiner Durchbruch, kein Blatt). **Rahmen:** `zarge` (3-seitig, im Wandquerschnitt) / `block` (+5cm Überhang).
|
||||||
|
- **2D-Schwung** (`_make_tuer_swing_curves`): Blatt+Arc, hinge_side/open_angle/aussenseite/swing_invert; Bogen-Anlage am Rahmen (standard/detail). **2D-Sturzlinien** (`_make_tuer_sturz_curves`, gestrichelt, Modi keine/innen/aussen/beide — SIA: Sturz über Schnittebene → gestrichelt). TS: Blatt+Arc+Anschlag (fein) da; **Sturzlinien fehlen komplett**, `wandoeffnung`-Typ + swing_invert fehlen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gesamtreihenfolge „was zuerst" (Agenten-Empfehlung)
|
||||||
|
|
||||||
|
**Gruppe A — 2D-leicht, hoher Plan-Wert, kernel2d-kompatibel:**
|
||||||
|
1. **Treppe Aussenlinie/Outline** (alle 3 Typen; gerade=4 Linien, L via Linienschnitt `_line_intersect_xy`, Wendel=2 Bögen+radiale). Ohne sie wirkt der Plan unfertig.
|
||||||
|
2. **Fenster Brüstungslinie** im Plan (1 gepunktete Linie bei sillHeight>0). XS.
|
||||||
|
3. **Tür Sturzlinien** (gestrichelt, SIA) — Schulfall der geplanten Schnitt/Ansichts-Logik.
|
||||||
|
4. **Treppe Referenz links/mitte/rechts** + Lauflinie visuell zentriert.
|
||||||
|
5. **Fenster Flügel-Mittelpfosten** (2D, `wingCount` ins Modell).
|
||||||
|
6. **Tür `wandoeffnung`-Typ** (reine Öffnung ohne Blatt/Symbol).
|
||||||
|
7. Treppe Show-Flags + `obere_dashed`; 8. Fenster Anschlag-Striche.
|
||||||
|
|
||||||
|
**Gruppe B — 2D mittel:** Tür-Schwung am Rahmen; Treppe Pfeil-Style `filled`; Fenster/Tür Styles/Presets; swing_invert.
|
||||||
|
|
||||||
|
**Gruppe C — 3D (nach Mesh-/B-Rep-Pipeline):** Treppe `flach`-Querschnitt; Rahmen als Mesh (Fenster/Tür); Türblatt+Glas; Sims; L-Podest-Hexagon; Wendel helikoidale Unterseite (truck).
|
||||||
|
|
||||||
|
**Bezug PENDENZEN:** Gruppe-A-Items 1–3 (Aussenlinie/Brüstung/Sturz) fallen in das Backlog-Item „Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe" — Sturzlinie = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion. Gruppe C wartet auf den Textur-/Mesh-Pipeline-Nachfolger bzw. truck.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# RESEARCH — Von BIM-Tool zu „echtem CAD": Ansätze aus fortgeschrittenen Programmen
|
||||||
|
|
||||||
|
Stand: 2026-07-05. Direkt am Quellcode studiert (Repos nach `/tmp/cad-study` geklont,
|
||||||
|
liegen NICHT im Projekt). Ziel: konkrete, verfolgbare Ansätze, um DOSSIER von einem
|
||||||
|
spezialisierten BIM/Architektur-Werkzeug zu einem allgemeineren CAD zu erweitern.
|
||||||
|
|
||||||
|
## Studierte Programme
|
||||||
|
|
||||||
|
| Repo | Was | Lizenz | Stack | Für uns |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [OpenCADStudio](https://github.com/HakanSeven12/OpenCADStudio) | Eigenständige 2D/3D-CAD-App, ~200k LOC Rust | GPL-3.0 | Iced (GUI) + wgpu + WASM-Web-Build | **App-Architektur-Blaupause** (Entity-Modell, Command-System, Snap, Grips). Fast unser Stack. |
|
||||||
|
| [truck](https://github.com/ricosjp/truck) | Reiner Rust-CAD-**Kernel** (NURBS/B-Rep/Mesh/Boolean/STEP) | Apache-2.0 | 16 Crates, wgpu-Rendering + `truck-js` (WASM) | **3D-Kernel-Blaupause**. OpenCADStudio nutzt ihn als 3D-Backend. |
|
||||||
|
| [acadrust](https://github.com/HakanSeven12/acadrust) | Pure-Rust **DWG+DXF** R13–R2018 read+write, 41 Entity-Typen | **MPL-2.0** | nom/byteorder/flate2/nalgebra/encoding_rs (alles pure Rust) | **DWG/DXF-Rückgrat**, MPL-2.0 nutzbar, sehr wahrscheinlich WASM-baubar. |
|
||||||
|
|
||||||
|
**Lizenz-Klarstellung:** `acadrust` ist **MPL-2.0** (file-level copyleft — für uns nutzbar,
|
||||||
|
keine GPL-Ansteckung), NICHT GPL. `truck` ist Apache-2.0 (frei nutzbar). Nur OpenCADStudio
|
||||||
|
selbst ist GPL-3.0 — wir lesen es als **Architektur-Referenz**, kopieren keinen Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die zentrale Lektion: was ein „echtes CAD" architektonisch ausmacht
|
||||||
|
|
||||||
|
DOSSIER ist heute **domänenzentriert** (Wand/Öffnung/Component/Plan-Pipeline, TS-Modell).
|
||||||
|
Ein echtes CAD (Rhino/AutoCAD-artig) hat stattdessen einen **generischen Kern**. Aus
|
||||||
|
OpenCADStudios `src/` destilliert:
|
||||||
|
|
||||||
|
### 1. Generisches Entity-Modell mit Traits (statt Wand-Spezialfall)
|
||||||
|
`src/entities/` = 41 Entity-Typen (line, arc, circle, lwpolyline, spline, ellipse, hatch,
|
||||||
|
text, mtext, dimension, leader, insert/block, table, viewport, mesh, solid3d, …). Jeder
|
||||||
|
Typ implementiert ein **Trait-Set** statt einer Sonderbehandlung:
|
||||||
|
- `TruckConvertible` → 3D-Kernel-Geometrie
|
||||||
|
- `FallbackTess` → Display-Geometrie (Punkte/Snap-Vertices)
|
||||||
|
- `Grippable` → editierbare Griffe + Hover-Menü (Add Vertex / Convert to Arc / Reverse / Lengthen…)
|
||||||
|
- `PropertyEditable` → Eigenschaften-Panel
|
||||||
|
- `Transformable` → Move/Rotate/Scale/Mirror
|
||||||
|
- `MassProps` → Fläche/Umfang für Abfragen
|
||||||
|
|
||||||
|
→ **DOSSIER-Lehre:** ein generisches `Entity`-Modell mit Trait-Dispatch neben das BIM-Modell
|
||||||
|
stellen. Wir haben Ansätze (`Drawing2D`, splitJoin-Editoren, kernel2d), aber keine einheitliche
|
||||||
|
Entity-Taxonomie mit Traits.
|
||||||
|
|
||||||
|
### 2. Modeless Command-System — der eine `StepInput`-Step-Machine
|
||||||
|
`src/command.rs`: EIN Enum `StepInput { Point / Text / EntityPick / StructurePick /
|
||||||
|
SelectionComplete }`. **Jede** Eingabequelle (Kommandozeile, Viewport-Klick, Pick, Selektion,
|
||||||
|
Dynamic Input, Plugin-API, Headless-Script) übersetzt in `StepInput` und läuft durch **eine**
|
||||||
|
`feed_command`-Funktion → treibt die aktive `CadCommand`-Step-Machine, egal woher der Schritt kam.
|
||||||
|
|
||||||
|
→ **DOSSIER-Lehre:** Das ist der größte UX-Unterschied. Echtes CAD = „Befehl tippen → Punkte
|
||||||
|
picken → Optionen". DOSSIER ist heute Tool-Button + Direktmanipulation. Ein `StepInput`-Funnel +
|
||||||
|
Kommandozeile wäre der Hebel, um beliebige Werkzeuge einheitlich, scriptbar und headless-testbar
|
||||||
|
zu machen.
|
||||||
|
|
||||||
|
### 3. Object-Snap-Engine (eigenständig, groß)
|
||||||
|
`src/snap.rs` = 70 KB nur für OSNAP (endpoint/midpoint/center/intersection/tangent/
|
||||||
|
perpendicular/…). Wir haben Teil-Snapping; ein echtes CAD hat eine dedizierte, vollständige
|
||||||
|
Snap-Schicht.
|
||||||
|
|
||||||
|
### 4. Universelle Grip-Editierung
|
||||||
|
Jede Entity liefert Griffe + kontextuelle Griff-Menüs mit teils numerischem Follow-up
|
||||||
|
(„Lengthen" fragt Wert an der Kommandozeile ab). Einheitlich über das `Grippable`-Trait.
|
||||||
|
|
||||||
|
### 5. `CadDocument` als Dokumentmodell = das DWG/DXF-Objektmodell
|
||||||
|
OpenCADStudio nutzt `acadrust::CadDocument` DIREKT als sein Datenmodell (Entities + Objects +
|
||||||
|
Tables: Layer/Linetype/Style/Blocks + XData). D. h. das native CAD-Austauschformat IST das
|
||||||
|
interne Modell → verlustfreier Round-Trip „for free".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## truck — 3D-Kernel-Blaupause (+ harte WASM-Realität)
|
||||||
|
|
||||||
|
Saubere Schichtung (Ship-of-Theseus, kleine ersetzbare Crates):
|
||||||
|
|
||||||
|
```
|
||||||
|
truck-base Basistraits/Toleranz (cgmath)
|
||||||
|
truck-geotrait ParametricCurve / ParametricSurface Traits
|
||||||
|
truck-geometry Knot-Vektor, B-Spline, NURBS ← Kurven/Flächen
|
||||||
|
truck-topology vertex/edge/wire/face/shell/solid ← B-Rep-Topologie
|
||||||
|
truck-modeling Geometrie + Topologie integriert ← Solids bauen
|
||||||
|
truck-polymesh Polygon-Datenstruktur + Meshing
|
||||||
|
truck-meshalgo Tessellation der Shapes
|
||||||
|
truck-shapeops Boolean-Ops auf Solids (v0.4 — früh)
|
||||||
|
truck-stepio STEP read/write (v0.3)
|
||||||
|
truck-platform wgpu-Grafik-Utility ┐ Rendering — brauchen wir NICHT
|
||||||
|
truck-rendimpl Shape/Mesh-Visualisierung ┘ (wir haben eigenen wgpu-Renderer „Nordstern")
|
||||||
|
truck-js WASM-Wrapper (v0.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
**WASM-Realität (zwei Befunde abgeglichen):**
|
||||||
|
- OpenCADStudio pinnt `truck-meshalgo` 0.4 und schaltet den `solid3d`-Feature **im WASM-Build ab**,
|
||||||
|
weil dessen ACIS-/vtkio-Pfad über `xz2 → lzma-sys` (C-Bibliothek) nicht nach wasm32 kommt →
|
||||||
|
**im Web ist OpenCADStudio nur 2D.**
|
||||||
|
- Neuere `truck-meshalgo` (0.6) gated `rayon`/`vtkio` selbst per `cfg(not(target_arch="wasm32"))`
|
||||||
|
aus → **baut zu wasm32, aber single-threaded**. Der reine Geometrie-Stack
|
||||||
|
(base → geotrait → geometry → topology → polymesh → meshalgo → modeling → shapeops) hat keine
|
||||||
|
native-only-Deps und ist **sauber vom wgpu-Rendering trennbar** (truck-platform/-rendimpl weglassen).
|
||||||
|
|
||||||
|
**Reifegrad (Apache-2.0, aktiv, ~1 Hauptentwickler; ausdrücklich KEIN Produktionskern):**
|
||||||
|
- NURBS/B-Spline + B-Rep-Topologie: **stabil** (v0.5/0.6).
|
||||||
|
- Booleans (`truck-shapeops`): `and`/`or` vorhanden, aber **instabil bei coincident geometry**
|
||||||
|
(Issue #114); **`difference` fehlt** (#85); **Fillets fehlen komplett**. Der Fork
|
||||||
|
[`monstertruck`](https://github.com/virtualritz/monstertruck) hat difference + rolling-ball-Fillet.
|
||||||
|
→ Für unsere Wandöffnungen (boolean holes) haben wir das ohnehin schon selbst gelöst (`spanCutouts`).
|
||||||
|
- `truck-stepio`: STEP **schreiben** (aber NICHT für boolean-operierte Shapes) + **lesen** (v0.6 beta,
|
||||||
|
neues `truck_stepio::in`).
|
||||||
|
- `truck-js`: fertiger wasm-bindgen-Wrapper (kein npm-Paket → selbst per `wasm-pack` bauen), bündelt
|
||||||
|
modeling/shapeops/meshalgo/stepio; `IntoWasm`-Muster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## acadrust — DWG/DXF-Rückgrat
|
||||||
|
|
||||||
|
Pure Rust, **MPL-2.0**, `CadDocument`-Objektmodell (entities/objects/tables/xdata/classes),
|
||||||
|
41 Entity-Typen, DWG „208/208 roundtrip-perfect", serde optional, failsafe-Parsing, ~40 Codepages.
|
||||||
|
**Alle Deps pure Rust** (nom/byteorder/flate2-miniz/nalgebra/indexmap/ahash/encoding_rs) → sehr
|
||||||
|
wahrscheinlich zu wasm32 baubar (Datei-I/O-API müsste auf Bytes/Reader statt `std::fs` umgestellt
|
||||||
|
werden — vermutlich schon vorhanden). Heute nutzen wir JS `dxf-parser` (nur 2D) + `libredwg-web`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web-Import-Landkarte (für den GEO-BLOCK / Import-Backlog)
|
||||||
|
|
||||||
|
Pro Format die aktive, web-/WASM-taugliche Lib (Stand Mitte 2026):
|
||||||
|
|
||||||
|
| Format | Empfohlene Lib | Lizenz | Anmerkung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DWG/DXF (browser) | `@mlightcad/cad-viewer` / `libredwg-web` (nutzen wir) **oder** `acadrust` (Rust) | MIT / GPL-3.0 / **MPL-2.0** | acadrust = Rust-Weg, MPL-2.0 |
|
||||||
|
| STEP/IGES/BREP | `occt-wasm` (OCCT V8, ~4.5 MB) oder unser `opencascade.js` | LGPL-2.1 | occt-wasm = moderne TS-API |
|
||||||
|
| **IFC** (BIM!) | `web-ifc` (ThatOpen) | MPL-2.0 | De-facto-Standard, read+write, sehr aktiv |
|
||||||
|
| SHP/GIS | `shpjs` + `proj4` | MIT | direkt für GEO-BLOCK (SWISSIMAGE/Terrain) |
|
||||||
|
| Punktwolken LAS/LAZ | `@loaders.gl/las` (+ `laz-perf`) | MIT/Apache | Rendering: `potree-core` oder deck.gl |
|
||||||
|
| E57 | `e57-js` (Emscripten/libE57Format) | MIT | neu, funktional, sehr obskur |
|
||||||
|
| glTF/OBJ/STL | three.js-Loader (haben wir via `three`) | MIT | |
|
||||||
|
| 3MF | `lib3mf` (WASM) oder `THREE.3MFLoader` | BSD-2 / MIT | |
|
||||||
|
| PDF-Vektor | `pdfjs-dist` `getOperatorList()` | Apache-2.0 | kein fertiges PDF→DXF; roher Pfad-Stream |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priorisierte Ansätze für DOSSIER (die eigentliche Antwort)
|
||||||
|
|
||||||
|
Reihenfolge = Nutzen × Machbarkeit, ohne die BIM-Stärke zu opfern.
|
||||||
|
|
||||||
|
1. **Generisches Entity-Modell + Trait-Dispatch** (Fundament). Neben das BIM-Modell eine
|
||||||
|
generische Entity-Schicht (line/arc/circle/polyline/spline/text/dimension/hatch/block) mit
|
||||||
|
Traits à la OpenCADStudio (Display/Grips/Props/Transform). Unser `kernel2d` liefert schon die
|
||||||
|
Geometrie-Operationen dafür. **Größter struktureller Hebel.**
|
||||||
|
2. **Modeless Command-System** (`StepInput`-Funnel + Kommandozeile). Ein Eingabe-Enum, eine
|
||||||
|
`feed_command`-Schleife, `CadCommand`-Step-Machines. Macht Werkzeuge einheitlich, scriptbar,
|
||||||
|
headless-testbar. **Größter UX-Hebel Richtung „echtes CAD".**
|
||||||
|
3. **DWG/DXF-Round-Trip über `acadrust`** (Rust/WASM). MPL-2.0, pure Rust → passt in unseren
|
||||||
|
neuen `src-tauri`-Kern. Ersetzt langfristig die JS-Parser, bringt echten Import/Export.
|
||||||
|
Erst: WASM-Baubarkeit + Bytes-API verifizieren (Spike wie beim Textur-Spike).
|
||||||
|
4. **Vollständige Object-Snap-Schicht** als eigenes Modul (endpoint/mid/center/intersection/
|
||||||
|
tangent/perp/…), aufbauend auf `kernel2d`.
|
||||||
|
5. **3D-B-Rep später, selektiv** (truck-geometry/-topology/-modeling als reine Crates, OHNE
|
||||||
|
truck-Rendering — wir haben Nordstern). NUR wenn wir echte NURBS/Solids/Booleans brauchen; der
|
||||||
|
Meshing-/Boolean-Pfad ist WASM-problematisch (native C-Deps) und Booleans sind noch früh.
|
||||||
|
Bis dahin: truck als **Referenz** lesen, nicht einbinden.
|
||||||
|
|
||||||
|
**Kernaussage:** DOSSIERs Rust-nach-WASM-Kurs (den wir mit `kernel2d` gerade gehen) ist genau
|
||||||
|
richtig — OpenCADStudio bestätigt ihn. Der Weg zu „echtem CAD" führt über (1) generisches
|
||||||
|
Entity-Modell und (2) modeless Command-System; (3) `acadrust` bringt echten DWG/DXF-Austausch.
|
||||||
|
3D-B-Rep (truck) ist ein späterer, selektiver Schritt mit klaren WASM-Vorbehalten.
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
# Browser-BIM für Wohnbau — Architektur & Produkt-Roadmap
|
||||||
|
|
||||||
|
> **Historisches Dokument (Tag-1-Vision, Stand 2026-06-28).** Vieles hier als
|
||||||
|
> „Phase 2–5"/„Backlog" gelistete ist inzwischen längst gebaut (Treppen, Dächer,
|
||||||
|
> Stützen, SIA-416, Swisstopo, OSM, Layouts, Ausschnitte, Kamera-Presets …), und
|
||||||
|
> mehrere Technologie-Entscheidungen liefen anders (eigene Rust/WASM-Engines
|
||||||
|
> statt Three.js/OpenCascade.js/web-ifc, siehe unten §4/§8). Für den aktuellen
|
||||||
|
> Ist-Zustand: **[STATUS.md](STATUS.md)** (Bestandsaufnahme + Mist-Liste) und
|
||||||
|
> **[ARCHITECTURE.md](ARCHITECTURE.md)** (aktuelle Architektur). Dieses Dokument
|
||||||
|
> bleibt als ursprüngliche Produktvision/Phasenplan stehen, wird aber nicht mehr
|
||||||
|
> laufend nachgeführt.
|
||||||
|
>
|
||||||
|
> Arbeitstitel: **cad** (Name später: **Dossier**)
|
||||||
|
> Ausrichtung: **BIM-first** · Nische: **Wohnbau / Einfamilienhäuser**
|
||||||
|
> Stand: 2026-06-28
|
||||||
|
|
||||||
|
## 1. Produktvision
|
||||||
|
|
||||||
|
Ein **browserbasiertes BIM-Werkzeug** für Wohnbau, das zwei Dinge verbindet:
|
||||||
|
|
||||||
|
1. **Einfaches 3D-Gebäudemodell** — aus semantischen Bauteilen: Wände, Türen, Fenster, Treppen, Decken, Dächer, Räume.
|
||||||
|
2. **Schöne, normgerechte 2D-Pläne** — Grundrisse, Schnitte, Ansichten — automatisch aus dem Modell abgeleitet.
|
||||||
|
|
||||||
|
**Kernversprechen:** *Das schönste und einfachste Werkzeug, um ein Wohnhaus zu modellieren und daraus perfekte Pläne zu ziehen.* Nicht Revit nachbauen — radikaler Fokus auf Wohnbau + Plan-Qualität.
|
||||||
|
|
||||||
|
**Markt-Beleg:** Arcol, Snaptrude, TestFit zeigen, dass browserbasiertes BIM real ist und Nutzer schlanke, schöne Tools wollen statt der schwerfälligen Giganten (Revit/ArchiCAD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Das mentale Modell — warum BIM anders ist als CAD
|
||||||
|
|
||||||
|
| | mechanisches CAD | **BIM (unser Weg)** |
|
||||||
|
|---|---|---|
|
||||||
|
| Bausteine | generische Volumenkörper | **semantische Bauteile** (Wand, Tür, Fenster…) |
|
||||||
|
| Beziehungen | keine | Tür *hostet* in Wand & schneidet Öffnung; Wände *verbinden* sich |
|
||||||
|
| Geschosse | — | **Stockwerke** als erste Klasse |
|
||||||
|
| 2D-Plan | Hidden-Line-Projektion | **symbolische Darstellung** (Schwenkbögen, Schraffuren, Lauflinien) |
|
||||||
|
| Standard | STEP | **IFC** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2b. Kern-Prinzip: ein Modell, viele Darstellungen
|
||||||
|
|
||||||
|
Das **wichtigste Architektur-Prinzip**: Das semantische Modell ist die *eine
|
||||||
|
Wahrheit*; jede Ansicht (3D, Grundriss, Schnitt) ist eine **abgeleitete
|
||||||
|
Darstellung**. Im Spike steht das bereits. Daraus folgen direkt die Kern-Wünsche:
|
||||||
|
|
||||||
|
- **Modelldarstellungen / Detailgrade** — derselbe Tür/Fenster wird je nach
|
||||||
|
`detailLevel` (grob / mittel / fein) unterschiedlich gezeichnet (≙ Revit
|
||||||
|
„Detailgrad", ArchiCAD „Modelldarstellung"). Grob: Öffnung + Linie. Fein:
|
||||||
|
Rahmen, Blatt, Schwenkbogen, Anschlag.
|
||||||
|
- **Editierbare Stile** — Wandfarben, Linienstärken, Türlinien, Schraffuren als
|
||||||
|
**Stil-Schicht**, erst beim Rendern angewandt (nicht in die Geometrie
|
||||||
|
eingebacken). Pro Kategorie *und* pro Element überschreibbar.
|
||||||
|
- **Mehrschichtige Bauteile** — Wände/Decken mit **Schichtaufbau** (`layers[]`:
|
||||||
|
Material + Dicke + Priorität). 3D und Plan lesen dieselben Schichten.
|
||||||
|
- **In 2D *und* 3D zeichnen** — beide sind editierbare Sichten auf *ein* Modell;
|
||||||
|
Werkzeuge mutieren das Modell, alle Sichten re-derivieren reaktiv.
|
||||||
|
|
||||||
|
Schwierigkeit: Detailgrade/Stile/Schichten sind 🟢 gut machbar; 2D+3D-Editieren
|
||||||
|
🟡 mittel; **mehrschichtige Wand-Verschneidung** 🔴 der härteste Teil (Risiko #1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2c. Arbeitsweise & Dokumentmodell (DOSSIER-Modell) ⭐
|
||||||
|
|
||||||
|
Referenz: **DOSSIER** (Rhino-Plugin des Nutzers, https://git.kgva.ch/karim/DOSSIER).
|
||||||
|
Dieses Projekt ist die **Standalone-Browser-Variante** davon. Zwei *unabhängige* Achsen:
|
||||||
|
|
||||||
|
- **Zeichnungsebenen** — die obersten Dokument-Abschnitte, zwei Arten:
|
||||||
|
- **Geschosse** (EG, 1OG …): `hoehe` (Geschosshöhe), `schnitthoehe` (Schnitthöhe),
|
||||||
|
`okff` (Niveau, akkumuliert), `visible`/`locked`.
|
||||||
|
- **Schnitte / Ansichten** (`type:"schnitt"`): Schnittlinie `linePts`, Richtung
|
||||||
|
`dirSign`, Höhenbereich, Tiefe.
|
||||||
|
- Ein **Geschoss** wird **im 3D-View ODER im Plan-View** betrachtet (Umschalter,
|
||||||
|
*keine* getrennten Daten). Plan-View = Clipping-Ebene auf `okff + schnitthoehe`.
|
||||||
|
- **Ebenen** — das **Grafik-Kategorien-Schema**, in *jedem* Geschoss vorhanden;
|
||||||
|
Baum-Knoten mit pro Ebene einstellbaren **Darstellungseinstellungen** (in den
|
||||||
|
„Ebeneneinstellungen…"): **Stift** = Typ/Linienstil + Farbe + Dicke (lw);
|
||||||
|
**Schraffur** = Typ + Skalierung + Rotation + **Stiftstärke der Schraffurlinien**.
|
||||||
|
Modell: `{code, name, visible, locked, lineStyleId|{type,color,lw}, hatchId|{type,scale,angle,lineWeight}, children}`.
|
||||||
|
Codes 1:1 wie DOSSIER:
|
||||||
|
`00 Raster · 01 Vermessung · 20 Wände (└21 Türen/Fenster) · 30 Decken · 31 Dächer
|
||||||
|
· 40 Treppen (└41 Treppen-2D) · 50 Tragwerk · 60 Räume · 80 Plangrafik …`
|
||||||
|
- **Elemente** (Wand, Decke, Treppe, Öffnung, Plangrafik, Text) liegen **auf den
|
||||||
|
Ebenen** und kennen ihr **Geschoss** *und* ihre **Ebene (Code)**.
|
||||||
|
- **2D-Zeichnen** (Linie, Polylinie, Rechteck, Kreis, Bogen, Text) findet auf der
|
||||||
|
Ebene `80 Plangrafik` (bzw. passender Kategorie) statt.
|
||||||
|
|
||||||
|
**Ansichtstypen = Kamera-Projektion + optionaler Schnitt** (vereinheitlicht):
|
||||||
|
|
||||||
|
| Typ | Projektion | Schnitt |
|
||||||
|
|---|---|---|
|
||||||
|
| **Grundriss** | Top-View (orthogonal) | horizontal auf `okff + schnitthöhe` |
|
||||||
|
| **Schnitt** | Front-View in eine Richtung (orthogonal) | vertikale Schnittebene (Geschnittenes + dahinter) |
|
||||||
|
| **Ansicht** | Front-View in eine Richtung (orthogonal) | kein Schnitt (Fassade außen) |
|
||||||
|
| **Perspektive** | 3D perspektivisch | — |
|
||||||
|
|
||||||
|
Sichtbarkeit pro Ansicht über Ein-/Ausschalten von Ebenen & Zeichnungsebenen.
|
||||||
|
|
||||||
|
Persistenz (DOSSIER): zwei getrennte JSON-Bäume `dossier_zeichnungsebenen` und
|
||||||
|
`dossier_ebenen`; Elemente tragen `geschoss`-id + Ebenen-`code`. Plan-View nutzt
|
||||||
|
eine Clipping-Ebene; weitere Konzepte: Overrides (regelbasiert), Ausschnitte
|
||||||
|
(View-Snapshots), Massstab (pro Viewport), Layer-Kombinationen, SIA-Räume.
|
||||||
|
|
||||||
|
## 2d. Resource Manager & Prioritäts-Verschneidung 🔴
|
||||||
|
|
||||||
|
Verwaltete Ressourcen-Bibliotheken wie in Vectorworks, jeweils mit eigenem Manager:
|
||||||
|
|
||||||
|
- **Line Manager** — Linienstile (Stärke, Farbe, Strichelung), wiederverwendbar.
|
||||||
|
- **Hatch Manager** — Schraffurstile (Muster, Maßstab, Winkel, Linienstil).
|
||||||
|
- **Component Manager** — Baustoffe mehrschichtiger Bauteile. Pro Component:
|
||||||
|
**Schraffur** (→ Hatch Manager), **3D-Textur**, Farbe und
|
||||||
|
**Verschneidungs-Priorität** (`joinPriority`).
|
||||||
|
|
||||||
|
Alles verweist per id auf diese Bibliotheken (Components nutzen Hatches, Hatches
|
||||||
|
nutzen Linienstile, 2D-Objekte & Ebenen-Defaults nutzen Linienstile/Schraffuren) —
|
||||||
|
zentral änderbar.
|
||||||
|
|
||||||
|
Regel: **höhere Priorität verschneidet sich zuerst / läuft durch.** Beispiel an
|
||||||
|
einer T-Ecke (Beton-Wand mit Innen- und Außenputz):
|
||||||
|
|
||||||
|
- **Beton** (höchste Prio) läuft in der Mitte **durch** den Stoß.
|
||||||
|
- **Putze** (niedrige Prio) **verbinden** sich jeweils auf ihrer Seite mit dem
|
||||||
|
angrenzenden Putz, gehen aber **nirgends durch** den Beton.
|
||||||
|
|
||||||
|
Das ist die anspruchsvollste Verschneidungs-Logik (Revit „Layer Priority /
|
||||||
|
Wrapping", Vectorworks „Component-Verschneidung"). Wir bauen sie stufenweise auf
|
||||||
|
der bereits funktionierenden L-Ecken-Gehrung auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zwei Wege zum 2D-Plan (zentrale Architektur-Erkenntnis)
|
||||||
|
|
||||||
|
Architektur-Pläne entstehen auf **zwei verschiedenen Wegen** — das prägt die ganze Engine:
|
||||||
|
|
||||||
|
**A) Grundriss = aus dem semantischen 2D-Footprint + Symbolik**
|
||||||
|
Ein Grundriss ist ein horizontaler Schnitt auf ~1 m. Statt ein 3D-Mesh zu zerschneiden, generieren wir ihn **direkt aus den Parametern**: Wand-Achsen + Dicken → Linien; Öffnungen → Lücken + Tür-/Fenstersymbol; Treppe → Lauflinie. Schnell, exakt, sauber, vektorbasiert.
|
||||||
|
|
||||||
|
**B) Schnitt & Ansicht = aus 3D-Projektion (HLR)**
|
||||||
|
Vertikale Schnitte und Ansichten brauchen echte 3D-Projektion mit verdeckten Kanten (Hidden Line Removal) durch das zusammengebaute Gebäude.
|
||||||
|
|
||||||
|
→ Wir brauchen **beides**: einen sauberen 2D-Symbol-Renderer *und* einen Projektions-Pfad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tech-Stack
|
||||||
|
|
||||||
|
| Schicht | Wahl | Begründung |
|
||||||
|
|---|---|---|
|
||||||
|
| BIM-Datenmodell | **eigenes parametrisches Gebäudemodell** (TS) | web-ifc ist stark beim *Lesen/Anzeigen* von IFC, schwächer beim *Authoring/Editieren*. Für ein Editier-Tool brauchen wir ein eigenes, editierbares Modell. |
|
||||||
|
| IFC-Interop | **web-ifc** (ThatOpen, WASM) | Import/Export nach IFC — Brücke zu Revit/ArchiCAD. |
|
||||||
|
| 3D-Rendering | **Three.js** | Standard; ThatOpen baut darauf auf, also kompatibel. |
|
||||||
|
| Geometrie-Booleans | **OpenCascade.js** (Öffnungen) *oder* Manifold | Tür/Fenster schneidet Loch in Wand. OCC = exakt (B-Rep), Manifold = schnell (Mesh). Entscheidung in Phase 0. |
|
||||||
|
| Projektion/HLR | **OpenCascade.js** (`HLRBRep`) | Saubere Linien für Schnitte/Ansichten. |
|
||||||
|
| 2D-Pläne | **SVG** + eigener Symbol-Renderer | Vektor, druckbar, exportierbar (DXF/PDF). |
|
||||||
|
| Frontend | **React + TypeScript + Vite** | Schnelles HMR, großes Ökosystem. |
|
||||||
|
| Worker-Bridge | **Comlink** | Schwere Geometrie im Web Worker, UI bleibt flüssig. |
|
||||||
|
| State | **Zustand** o.ä. | Passt zum komplexen Dokumentmodell. |
|
||||||
|
| Persistenz (später) | Postgres + Object Storage | Versionierbare Projekte. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Datenmodell (grob)
|
||||||
|
|
||||||
|
Vectorworks-orientiert: **Design Layers** (Modell-Eingabe) und **Drawing Layers**
|
||||||
|
(abgeleitete Ausgabe). Bauteile beziehen ihren Aufbau aus **Components** (verwaltet
|
||||||
|
im Component Manager).
|
||||||
|
|
||||||
|
```
|
||||||
|
Project
|
||||||
|
├─ Resources // verwaltete Bibliotheken (Vectorworks-Stil)
|
||||||
|
│ ├─ LineStyles[] // Line Manager: { id, name, weight, color, dash }
|
||||||
|
│ ├─ Hatches[] // Hatch Manager: { id, name, pattern, scale, angle, lineStyleId }
|
||||||
|
│ └─ Components[] // Component Manager: wiederverwendbare Baustoffe
|
||||||
|
│ Component { id, name, hatchId, texture3d, color, joinPriority }
|
||||||
|
│ // joinPriority: höher = verschneidet sich zuerst (geht durch)
|
||||||
|
├─ Grids (Achsraster) (optional)
|
||||||
|
├─ Types // mehrschichtige Aufbauten
|
||||||
|
│ ├─ WallType { id, name, layers: Layer[] }
|
||||||
|
│ └─ SlabType { id, name, layers: Layer[] }
|
||||||
|
│ Layer = { componentId, thickness } // Priorität liegt am Component
|
||||||
|
│
|
||||||
|
├─ DesignLayers ("Ebenen") // hier wird modelliert & 2D gezeichnet
|
||||||
|
│ DesignLayer { id, name, elevation(z), height(Δz),
|
||||||
|
│ defaultLineStyle, defaultHatch,
|
||||||
|
│ elements: Wall | Door | Window | Slab | Stair | Roof | Space ,
|
||||||
|
│ draw2d: Line | Polyline | Rect | Circle | Arc | Text }
|
||||||
|
│ Wall { axis, wallTypeId, height }
|
||||||
|
│ Door { hostWall, position, width, height, swing, symbolId }
|
||||||
|
│ Window { hostWall, position, width, height, sill }
|
||||||
|
│ Slab { boundary, slabTypeId } · Space { boundary, name } // Fläche auto
|
||||||
|
│
|
||||||
|
└─ DrawingLayers ("Zeichnungsebenen") // abgeleitete Ausgabe
|
||||||
|
DrawingLayer { id, name,
|
||||||
|
type: plan | section | elevation | drawing,
|
||||||
|
cutHeight(z), // bei plan: Schnitthöhe
|
||||||
|
sectionLine, // bei section
|
||||||
|
sourceDesignLayers[],
|
||||||
|
detailLevel: coarse|medium|fine,
|
||||||
|
scale, styleOverrides, dims[], labels[], annotations[] }
|
||||||
|
```
|
||||||
|
3D *und* jede Drawing Layer werden **aus den Design Layers abgeleitet**. `cutHeight`,
|
||||||
|
`detailLevel`, `Styles` und `Component`-Eigenschaften steuern, *wie* abgeleitet wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Die harten Risiken (früh angehen)
|
||||||
|
|
||||||
|
1. **Wand-Verbindungen / Cleanup** ⚠️ — Wo Wände aufeinandertreffen, müssen sie sauber verschneiden. **L-Ecken-Gehrung: ✅ erledigt.** Offen & berüchtigt schwer: **Prioritäts-basierte T-/X-Stöße bei mehrschichtigen Wänden** (Beton durch, Putz verbindet seitlich, geht nicht durch — siehe 2d). → stufenweise auf der Gehrung aufbauen.
|
||||||
|
2. **Gehostete Öffnungen** — Tür/Fenster muss synchron mit der Wand bleiben (verschieben, schneiden). → Saubere Host-Beziehung im Modell.
|
||||||
|
3. **Symbolischer Plan-Renderer** — normgerechte Darstellung (Schwenkbögen, Schraffuren der geschnittenen Bauteile, Lauflinien). → Eigenes Regelwerk; früh prototypen.
|
||||||
|
4. **Schnitt-/Ansichts-Projektion (HLR)** durch ganzes Gebäude — Performance. → Worker + Caching.
|
||||||
|
5. **IFC-Treue** — verlustarmer Round-Trip. → Früh mit echten IFC-Dateien testen.
|
||||||
|
6. **Geschoss-übergreifende Elemente** (Treppen, Lufträume).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Phasen-Roadmap
|
||||||
|
|
||||||
|
### Phase 0 — Spike: das größte Risiko zuerst
|
||||||
|
**Ziel:** Beweisen, dass der symbolische Plan-Pfad im Browser funktioniert.
|
||||||
|
- Eine Wand zeichnen, eine Tür platzieren → Öffnung wird geschnitten (3D).
|
||||||
|
- Daraus **Grundriss als SVG** generieren: Wand-Schnittlinien + Tür-**Schwenkbogen**.
|
||||||
|
- ✅ *Erfolg:* sauberer, schöner Grundriss-Ausschnitt aus einem semantischen Modell.
|
||||||
|
|
||||||
|
### Phase 1 — MVP: durchgehende Wohnbau-Scheibe
|
||||||
|
- **Dokumentmodell (Vectorworks-Stil):** Design Layers ("Ebenen") + Drawing Layers
|
||||||
|
("Zeichnungsebenen", Typ plan/section/elevation, mit `cutHeight`).
|
||||||
|
- **Resource Manager:** Line Manager, Hatch Manager, Component Manager (mit
|
||||||
|
`joinPriority`, Schraffur, 3D-Textur).
|
||||||
|
- **Wände** mehrschichtig (✅) mit Eck-Gehrung (✅); **Prioritäts-T-Stöße** (Risiko #1).
|
||||||
|
- **Türen & Fenster** gehostet in Wänden (Risiko #2).
|
||||||
|
- **2D-Zeichnen:** Linie, Polylinie, Rechteck, Kreis, Bogen mit Stilen.
|
||||||
|
- **Decken/Böden** (Slabs); 3D-Viewport + **live Grundriss**, Basis-Bemaßung.
|
||||||
|
- → aus DOSSIER (§11): Wand-Referenzlage (mid/left/right), Öffnungs-Detailgrad mit Dokument-Override, Decken-Aussparungen, Element-Übersicht (BIM-Tree).
|
||||||
|
- ✅ Ein einfaches Haus modellieren → saubere Pläne pro Geschoss.
|
||||||
|
|
||||||
|
### Phase 2 — Vollständiger Bauteil-Satz Wohnbau
|
||||||
|
- **Treppen** (mit Lauflinie im Plan), **Dächer**, Geländer.
|
||||||
|
- **Räume/Spaces** mit automatischer Flächenberechnung & Raumstempel.
|
||||||
|
- Stützen/Unterzüge (falls nötig).
|
||||||
|
- Materialien & einfache Visualisierung.
|
||||||
|
- → aus DOSSIER (§11): Treppen-Typen (gerade/L/Wendel) + geschossübergreifend, Dach-Typen (Pult/Sattel/Walm/Mansarde), Stützen-Profile, **SIA-416-Räume** + CSV, Raumstempel-Builder, Stil-Kataloge (Wände/Öffnungen).
|
||||||
|
|
||||||
|
### Phase 3 — Plan-/Dokumentations-Modul ⭐ (Differenzierung)
|
||||||
|
**Hier gewinnen wir. Maximale Politur.**
|
||||||
|
- **Grundrisse, Schnitte (HLR, Risiko #4), Ansichten.**
|
||||||
|
- **Automatische Bemaßung** (Außenketten, Achsen, Öffnungen) + manuelle.
|
||||||
|
- Schraffuren geschnittener Bauteile, Raumstempel, Beschriftungen, Symbole.
|
||||||
|
- **Plansätze/Sheets** mit Titelblock, Maßstäben, Layout.
|
||||||
|
- Schöne Typografie & Linienführung — genau das, was die Großen vermasseln.
|
||||||
|
- → aus DOSSIER (§11): **Massstab pro Viewport** (Auto-DPI, Plotweight-/Schraffur-Skalierung), Section-Style (3D-Schnittflächen), Ausschnitte (View-Snapshots) + Layer-Kombinationen, Kamera-Presets (Kardinal/Iso, **Norden-Rotation**), Detail-Bindung an Ausschnitt, Multi-Page-PDF @DPI, regelbasierte Overrides, Rich-Text-Annotationen.
|
||||||
|
|
||||||
|
### Phase 4 — Interop (Import/Export)
|
||||||
|
- **Import:** **DWG/DXF** (2D-Pläne/Bestand), **IFC** (BIM-Bestand), **STL/OBJ**
|
||||||
|
(Mesh-Referenzmodelle), **XYZ** (Punktwolken aus Vermessung).
|
||||||
|
- **Export:** **IFC** (Brücke zu Revit/ArchiCAD), **DWG/DXF**, **glTF/OBJ**.
|
||||||
|
- Round-Trip-Tests mit echten Dateien (Risiko #5).
|
||||||
|
- → aus DOSSIER (§11): **Swisstopo-Import** (swissBUILDINGS3D / swissALTI3D / SWISSIMAGE, LV95↔WGS84) ⭐ CH, OSM-Overpass-Kontext, Terrain-Mesh-Generator.
|
||||||
|
|
||||||
|
### Phase 5 — Persistenz & Konten
|
||||||
|
- Accounts, Projekte speichern/laden, Versionierung, Auto-Save.
|
||||||
|
- → aus DOSSIER (§11): Projekt-Persistierung auf Browser-Storage migrieren (IndexedDB statt doc.Strings); Presets/Favoriten cross-projekt (LocalStorage, Export/Import).
|
||||||
|
|
||||||
|
### Phase 6 — Export & Kollaboration
|
||||||
|
- Export: **PDF**, **DXF** (Pläne); **IFC**, **glTF** (3D).
|
||||||
|
- Teilen per Link, Kommentare, später Echtzeit-Co-Editing.
|
||||||
|
|
||||||
|
### Phase 7 — Produktisierung
|
||||||
|
- Performance-Härtung (große Modelle), Onboarding, Pricing, PWA/Offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Offene Fragen
|
||||||
|
- Booleans: OpenCascade (exakt) vs. Manifold (schnell) — Entscheidung in Phase 0.
|
||||||
|
- Welche Normen für Plandarstellung (SIA / DIN / …)? → beeinflusst Symbolik. *(Hinweis: User ist in der Schweiz → SIA prüfen.)*
|
||||||
|
- Wie viel Statik/Bauphysik (gar nicht / später)?
|
||||||
|
- Pricing-Modell (Freemium, pro Seat?).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Nächster konkreter Schritt
|
||||||
|
**Phase 0 starten:** Projekt scaffolden + Spike bauen — Wand + Tür mit geschnittener Öffnung → schöner Grundriss-Ausschnitt als SVG (mit Schwenkbogen). Das entschärft Risiko #1–#3 (Modell, Hosting, Symbolik) auf einmal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Leitentscheidungen & UI-Architektur
|
||||||
|
|
||||||
|
### 10a. Leitentscheidungen aus der Recherche (Details in `docs/`, Index `docs/README.md`)
|
||||||
|
1. **Pure-Ableitungs-Architektur** als Fundament — ein semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern (Store + Undo früh).
|
||||||
|
2. **OCCT/replicad im Web Worker** früh als Spike — kritischer Pfad für Schnitt/Ansicht (HLR), exakte Wand-Booleans und IFC. WASM-Größe + HLR-Kosten (pro Ansicht cachen) validieren.
|
||||||
|
3. **Component-getriebene Prioritäts-Verschneidung** (`joinPriority` am Component): höchstes gemeinsames Material läuft durch, Rest mitert. 2D-Plan analytisch, exakte 3D-Booleans im Worker.
|
||||||
|
4. **SVG/Paper-Space-Maßstabsmodell** — Strichstärke/Text/Hatch in mm, `dpi=96·devicePixelRatio`, ein Serializer für Bildschirm + PDF + DXF.
|
||||||
|
5. **CH-Spezifika als Differenzierer** ohne Backend — SIA-416 (reine Logik) + serverloser Swisstopo-Flow (CORS-offen, Parzelle/EGRID, Norden-Rotation, Origin-Shift).
|
||||||
|
|
||||||
|
### 10b. Panel-System (dockbar, Tabs, erweiterbar) — NEU
|
||||||
|
- **Docks links & rechts**; Panels in **Tab-Strips** gruppierbar (z. B. Zeichnungsebenen & Ebenen als Tabs eines Docks).
|
||||||
|
- **Panel-Registry** → eigene Panels und spätere **Plugins** registrieren sich und erscheinen als Panel.
|
||||||
|
- **Verschiebbar & floatend (am Tab gegriffen):** Ein Panel wird **am Tab selbst** (im Tab-Strip) gezogen → innerhalb des Docks **umsortieren**, ins andere Dock ziehen, oder aus dem Dock lösen. Andocken am **linken/rechten Rand** (Andock-Zonen beim Ziehen hervorheben); wird nicht angedockt, **schwebt** das Panel als freies (verschieb- und größenveränderbares) **Floating-Fenster** über dem Arbeitsbereich. Float-Position/Größe + Dock-Zustand werden gespeichert.
|
||||||
|
- **Fenster-Layouts speicherbar** (localStorage, benannte Layouts; Standard-Layout als Default).
|
||||||
|
- Der **Ressourcen-Manager** wird ebenfalls ein Panel (rechtes Dock).
|
||||||
|
- Pro Layer-Panel oben ein **Anzeige-Modus-Dropdown**: *nur aktive · alle anzeigen · aktive + andere grau* (DOSSIER/Vectorworks „Layer Options") — wirkt auf Plan & 3D.
|
||||||
|
|
||||||
|
### 10c. Plan-Navigation
|
||||||
|
- Grundriss/Schnitt/Ansicht: **Pan** (ziehen), **Zoom** (Mausrad zum Cursor), **Einpassen** — analog zum 3D-Viewport. SVG-`viewBox`-Transform.
|
||||||
|
|
||||||
|
### 10d. Backend & Kollaboration (Details: `docs/backend.md`)
|
||||||
|
- **Jetzt:** client-only (IndexedDB + Datei-Export/Import), kein Server. Modell JSON-serialisierbar + Edits als Operationen → **CRDT-fähig** halten.
|
||||||
|
- **Phase 5:** **Supabase self-hosted** (Docker Compose: Postgres + Auth + Storage) für Konten/Projekte/Dateien.
|
||||||
|
- **Phase 6:** **Yjs + Hocuspocus** (CRDT-Sync-Container, persistiert nach Postgres) für Echtzeit-Kollaboration. Alles self-hosted.
|
||||||
|
- Empfehlung: Stack **noch nicht** aufsetzen (würde den Modellierer ausbremsen); Weiche ist gestellt, Einführung additiv.
|
||||||
|
|
||||||
|
### 10e. Top-Bar & Footer/Status-Leiste (Vectorworks-Stil) — NEU
|
||||||
|
- **Top-Bar (Oberleiste, wie DOSSIER `toolbar.py`/`ToolbarApp.jsx`):** Ansichts-Umschalter (Grundriss/Perspektive/Schnitt/Ansicht), Render-/Darstellungsmodus, aktives Geschoss + aktive Ebene, Snapping-Schalter, Massstab, Werkzeug-Kontext, Einstellungen/Ressourcen.
|
||||||
|
- **Footer/Status-Leiste (wie Vectorworks unten):** Cursor-Koordinaten **X/Y/Z**, Einheit, aktueller **Massstab** (1:N) + **Zoom %**, aktives Geschoss/Ebene, **Snap-Status**, kurzer Werkzeug-Hinweis links.
|
||||||
|
- Beide an das Panel-/Dock-Layout angedockt; Inhalte aus dem Modell abgeleitet.
|
||||||
|
|
||||||
|
### 10f. Maus-Interaktion & Kontextmenü — NEU
|
||||||
|
- **Maus-Schema:** **Mitte = navigieren** (Plan: Pan · 3D: Orbit, `Shift`+Mitte: Pan) · **Links = Auswahl** (Einzelklick + **Markierrahmen/Aufziehrahmen** für Mehrfachauswahl im 2D) · **Rechts = Kontextmenü** · **Rad = Zoom** (zum Cursor).
|
||||||
|
- Marquee: Aufziehen von links→rechts = nur vollständig umschlossene Elemente; rechts→links = auch berührte (wie CAD-üblich).
|
||||||
|
- **Eigenes Kontextmenü-System** (gestylt, dunkel, wiederverwendbar) — kein Browser-Menü.
|
||||||
|
- **Ebenen-Kontextmenü 1:1 wie DOSSIER** (Einträge aus `layers_panel.py`/`DrawingLevelsApp.jsx` übernehmen) — auch auf Zeichnungsebenen.
|
||||||
|
- Kontextmenü generisch, damit Plan-Elemente, Panels & spätere Plugins eigene Einträge registrieren können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Aus DOSSIER übernehmen — Backlog
|
||||||
|
|
||||||
|
Konkret im DOSSIER-Rhino-Plugin umgesetzte Features, die sich für den Standalone-Port lohnen. Bereits abgedeckt (Layer-Modell, mehrschichtige Wände, Prioritäts-Stöße, Component-/Line-/Hatch-Manager, Ansichtstypen, Detailgrade) ist hier **nicht** erneut gelistet — nur das Zusätzliche. Aufwand: S/M/L. Phase verweist auf §7.
|
||||||
|
|
||||||
|
> **⭐ CH-Schätze (Schweiz-spezifisch, kaum woanders verfügbar):**
|
||||||
|
> - **Swisstopo-Geodaten** — swissBUILDINGS3D (3D-Bestand), swissALTI3D (präzises Höhenmodell), SWISSIMAGE (10-cm-Orthofoto), offene STAC-APIs ohne Auth, inkl. **LV95↔WGS84**-Transformation. Echter Standort-Kontext per Knopfdruck statt manuellem CAD-Import.
|
||||||
|
> - **SIA-416-Flächen** — Raum-Klassifikation (HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV/Excel-Export. Pflicht für CH-Energie-/Flächennachweise.
|
||||||
|
> - **Norden-Rotation** bei Kamera-Presets — Georeferenzierung passend zu Swisstopo/swissBUILDINGS.
|
||||||
|
|
||||||
|
### Bauteile
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Wand-Referenzlage (mid/left/right) | Achse intuitiv auf Aussenkante/Mitte legen; hilft beim Import fremder Dateien | S | 1 |
|
||||||
|
| Öffnungs-Detailgrad + Dokument-Override (`aktive_darstellung`) | LoD je Massstab (1:500 Rechteck → 1:50 Glas/Sims) global umschaltbar; kritisch für Mixed-Scale | M | 2 |
|
||||||
|
| Fenster/Tür mit Rahmen, Brüstung, Sims, Glas, Flügelzahl | Öffnung als vollwertiges Bauteil statt nur Loch; Render-Realismus | M | 2 |
|
||||||
|
| Tür-Schwenkbogen (Öffnungswinkel + Anschlagseite) | Öffnungsbahnen für Möblierung/Kollision; Standard-Plansymbol | M | 2 |
|
||||||
|
| Decken-Aussparungen (Treppenauge, Schächte, Kamin) | Konstruktiv echte Deckenöffnungen, nicht nur sichtbar | M | 1–2 |
|
||||||
|
| Decken UK/OK-Override | Abhängungen, schräge Brüstungen, abweichende Raumhöhen | S | 1 |
|
||||||
|
| Treppen-Typen gerade/L/Wendel + Stufen/Lauflinie/Podest | Volle Vertikalerschliessung, volumetrisch korrekt, Plan-Symbole | M | 2 |
|
||||||
|
| Treppe geschossübergreifend (`geschoss_end`, Höhen-Override) | Atrien, Rampen, Mehr-Geschoss-Läufe (Risiko #6) | S | 2 |
|
||||||
|
| Treppen-2D-Symbol mit Auf-/Abpfeil + Schnitt | Normgerechtes Plansymbol (Richtung, Stufenzahl, Lauflinie) | M | 2 |
|
||||||
|
| Dach-Typen Pult/Sattel/Walm/Mansarde + Neigung(en) | 3D-Volumen mit Gefälle, Kubatur, Material; Mansarde später (L) | M–L | 2–3 |
|
||||||
|
| Stützen-Profile (Quadrat/Rechteck/Rund/I/Rohr) + Drehung | Beton- und Stahltragwerk mit echtem Querschnitt | M | 2 |
|
||||||
|
| Träger achs-basiert, hängt unter Decken-OK | Unterzug folgt Deckenoberkante, weniger Fehler bei Updates | S | 2 |
|
||||||
|
| **SIA-416-Räume** (HNF/NNF/VF/FF/GF/AGF) + Bilanz-CSV ⭐ | CH-Flächennachweis, Excel-Export | M | 2 |
|
||||||
|
| Raum-Stempel-Builder (Drag-&-Drop-Felder) + Fläche-Rundung + Personen | Projekt-eigene Stempel-Layouts ohne Code; lesbare Listen; Brandschutz | M | 2–3 |
|
||||||
|
| Element-Übersicht (BIM-Tree Geschoss→Kind→Element, Suche, Zoom) | Inhaltsverzeichnis bei 100+ Elementen; Shift-Klick = Zoom | S | 1 |
|
||||||
|
| Stil-Kataloge Wände & Öffnungen (Presets) | Standard-Typen 1-Klick; globaler Stilwechsel | M | 2 |
|
||||||
|
| Grip-Editing (Wand-Endpunkte, Schnitt-Symbole im Plan) | Direktes Ziehen statt Dialog; 2D/3D-Sync; wichtig im Browser | L | 3–4 |
|
||||||
|
|
||||||
|
### Darstellung / Ressourcen
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Regelbasierte Overrides (Layer-/Tag-/Name-Regel, Priorität, Templates) | Automatische Farb-/Strich-/Linientyp-Anpassung; wiederverwendbar (vertieft §2c „Overrides") | M | 3 |
|
||||||
|
| Section-Style für 3D-Schnittflächen (Schraffur + Schnittkante/Silhouette) | 3D-Schnitt-Rendering im Viewport, nicht nur 2D-Plan | M | 4 |
|
||||||
|
| Massstabs-abhängige Linientyp-/Plotweight-Skalierung | Linientypen & Strichstärken bei 1:N korrekt sichtbar; PDF-Treue | M | 3–4 |
|
||||||
|
| Material-Bibliothek mit PBR (Rauheit/Reflexion/Transparenz) + Templates | Vertieft Component-Manager um Renderqualität; Seeds Beton/Holz/Dämmung | M | 3 |
|
||||||
|
| Rich-Text-Annotationen (Bold/Italic/Hoch-/Tiefstellung, Maskierung, Rahmen) | Bemaßungs-Indizes, formatierte Beschriftungen auf Canvas | M | 3 |
|
||||||
|
| LoD-bewusste Stil-UI (zeigt nur passende Controls je Geometrie-Typ) | Weniger kognitive Last (keine Füll-Optionen bei 3D-Auswahl) | S | 1 |
|
||||||
|
|
||||||
|
### Pläne / Output
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Massstab pro Viewport** mit Auto-DPI + Schraffur-/Strich-Skalierung | Exakte Masse & lesbare Strichstärken ohne manuelle Kalibrierung | M | 3 |
|
||||||
|
| Ausschnitte / View-Snapshots (Kamera + Darstellung + Massstab) | Navigation über 50+ Ansichten; ersetzt Ordner-Wildwuchs (vertieft §2c) | M | 3 |
|
||||||
|
| Layer-Kombinationen als Presets (live oder eingefroren) | Bauphasen/Varianten/MEP per Klick statt manuellem Toggling (vertieft §2c) | S | 3 |
|
||||||
|
| Kamera-Presets (Kardinal N/O/S/W, Iso-Oktanten, **Norden-Rotation** ⭐) | Schnelle Ansichtswechsel; Georeferenzierung für Swisstopo | S | 3 |
|
||||||
|
| Detail↔Ausschnitt-Bindung + „Alle aktualisieren" | Titelblock/Detail synchron umbenennen; 1-Klick-Sync aller Schnitte | M | 3 |
|
||||||
|
| Multi-Page-PDF-Export @DPI (Vektor) | Druckfertige Plansätze — Kern-Output (ergänzt Phase-3-Sheets) | M | 3 |
|
||||||
|
| 9-Punkt-Bemaßung/Objekt-Info (lesen + verschieben/skalieren/rotieren) | Direktes Dimensionieren ohne Properties-Panel | M | 3 |
|
||||||
|
|
||||||
|
### Kontext / Daten
|
||||||
|
|
||||||
|
| Feature | Nutzen | Aufwand | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Swisstopo-Import** (swissBUILDINGS3D/ALTI3D/SWISSIMAGE) ⭐ CH | Authentischer Standort-Kontext, offene APIs, kein Auth | M | 4 |
|
||||||
|
| LV95↔WGS84-Transformation ⭐ CH | Karten-Anzeige + präzise CH-Koordinaten; Formeln direkt portierbar | S | 4 |
|
||||||
|
| OSM-Overpass-Import (Strassen/Gebäude/Wasser/Grün, 7 Kategorien) | Weltweiter, kostenloser Kontext; ergänzt Swisstopo | M | 4 |
|
||||||
|
| Terrain-Mesh-Generator (Mesh/TIN/NURBS-Patch/Höhenlinien, Volumen für Schnitt) | Geländemodell aus Höhendaten; Section-Cut-Füllung; portierbar zu Three.js | L | 4 |
|
||||||
|
| Auto-Zoom auf Import + Nullpunkt-Verschiebung (LV95→0/0/0) | Modellierungsgenauigkeit trotz Millionen-Koordinaten; UX-Standard | S | 4 |
|
||||||
|
| Projekt-Persistierung browser-nativ (IndexedDB statt doc.Strings) | Projekt kapselt seine Einstellungen lokal | M | 5 |
|
||||||
|
| Cross-Projekt-Presets (LocalStorage-Favoriten, Export/Import, Team-Sharing) | Einmal speichern, überall nutzen | S | 5 |
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen)
|
||||||
|
|
||||||
|
Stand: 2026-07-05. **Auftrag/Übergabe für einen Agenten. Kleinster ehrlicher
|
||||||
|
Durchstich — kein Produktfeature, keine Integration.**
|
||||||
|
|
||||||
|
Ziel: Beweisen, dass der bestehende wgpu-3D-Renderer echte **Bild-Texturen** auf
|
||||||
|
Wandflächen darstellen kann, sichtbar im `spike3d`-Fenster. Am Ende steht eine
|
||||||
|
belastbare Aussage, wie viel Arbeit „richtig gutes texturiertes 3D" wirklich ist —
|
||||||
|
statt Spekulation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Ausgangslage (verifiziert am 2026-07-05)
|
||||||
|
|
||||||
|
- `render3d` ist **kein** Three.js-Wrapper, sondern ein eigenständiger wgpu-Renderer
|
||||||
|
(~6400 LOC): echte GPU-Pipeline (wgpu 29), Tiefenpuffer, MSAA, WGSL-Shader.
|
||||||
|
Läuft nativ (winit-Spike), headless (naga-validiert) und im Browser (WebGPU).
|
||||||
|
- **`RenderStyle::Textured` existiert bereits als Stub** (`gpu.rs:34` Enum-Variante,
|
||||||
|
`gpu.rs:47` Parse aus `"textured"`) — es gibt aber **kein echtes Texturing**:
|
||||||
|
kein Sampler, keine Textur-Bind-Group, keine Bilddaten.
|
||||||
|
- Die `cap_pipeline` mit Layout `[pos vec3, uv vec2]` + `CAP_WGSL` ist **nicht** für
|
||||||
|
Bildtexturen, sondern für die **Schnittflächen-Kappen** (prozedurale Schraffur);
|
||||||
|
die UVs steuern dort den Schraffur-Abstand. **Nicht damit verwechseln.**
|
||||||
|
- **Günstig für uns:** Die Muster, die der Spike braucht, existieren schon —
|
||||||
|
ein UV-tragendes Vertex-Layout und eine zweite/dritte Pipeline, die sich die
|
||||||
|
`Globals`-Bind-Group teilt (`grid`, `cap`). Der texturierte Mesh-Pfad reiht sich
|
||||||
|
1:1 in dieses Muster ein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Echte Symbole — vor dem Coding lesen
|
||||||
|
|
||||||
|
| Was | Ort |
|
||||||
|
|---|---|
|
||||||
|
| Vertex heute interleaved `[pos.xyz, normal.xyz, color.rgb]`, `FLOATS_PER_VERTEX`, `Mesh`-Struct | `src-tauri/render3d/src/types.rs:322` ff. |
|
||||||
|
| Quad-Emitter (Normale + Farbe je Vertex, Reihenfolge `(0,1,2)+(0,2,3)`) | `src-tauri/render3d/src/mesh.rs:886` |
|
||||||
|
| Wand-Extrusion / Mesh-Bau | `src-tauri/render3d/src/mesh.rs` — `extrude_wall` (`:91`), `build_walls_mesh` (`:911`) |
|
||||||
|
| Haupt-Pipeline + `Globals`-Bind-Group (group 0) | `src-tauri/render3d/src/gpu.rs:232`–`:320` |
|
||||||
|
| Vorlage „zweite Pipeline teilt sich Globals" — Grid | `src-tauri/render3d/src/gpu.rs:329` |
|
||||||
|
| Vorlage „Pipeline mit UV-Layout `[pos vec3, uv vec2]`" — Cap | `src-tauri/render3d/src/gpu.rs:408` |
|
||||||
|
| `RenderStyle`-Enum + Stub `Textured` | `src-tauri/render3d/src/gpu.rs:34`, `:47` |
|
||||||
|
| Pipeline-Bindung im Render-Pass (Muster für Stil-Umschaltung) | `src-tauri/render3d/src/gpu.rs:847` |
|
||||||
|
| Shader als WGSL-Konstanten (`MESH_WGSL`, `CAP_WGSL`) | `src-tauri/render3d/src/shaders.rs` |
|
||||||
|
| Beleuchtungsmodell (hemisphärisch + Directional + Fill) — Doku | `src-tauri/render3d/src/shaders.rs:1`–`40` |
|
||||||
|
| naga-WGSL-Validierung headless (Test-Vorlage) | `src-tauri/render3d/src/lib.rs:924` (`cap_module`) |
|
||||||
|
| Fenster-Spike mit Orbit-Kamera | `src-tauri/render3d/src/bin/spike3d.rs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Umfang — exakt das, nicht mehr
|
||||||
|
|
||||||
|
### 2.1 UVs auf Wandflächen
|
||||||
|
- In der Quad-Emitter-Funktion (`mesh.rs:886`) je Vertex eine **UV** berechnen:
|
||||||
|
planare Projektion in **Metern** — `u` = Distanz entlang der Wandachse,
|
||||||
|
`v` = Höhe (z). Textur-Raster damit weltmassstäblich (z. B. 1 Kachel = 1 m).
|
||||||
|
- Den bestehenden `[pos, normal, color]`-Pfad **bitgleich unangetastet** lassen.
|
||||||
|
Zwei zulässige Wege (Agent wählt begründet):
|
||||||
|
1. **Separates additives UV-Array** in `Mesh` (Default leer/None), oder
|
||||||
|
2. **Paralleler `build_walls_mesh_textured`** → interleaved
|
||||||
|
`[pos.xyz, normal.xyz, uv.xy]`.
|
||||||
|
- Regressionstests für den Alt-Pfad müssen grün bleiben (siehe §4).
|
||||||
|
|
||||||
|
### 2.2 Test-Textur prozedural (kein Asset, keine `image`-Crate)
|
||||||
|
- Ein **256×256 RGBA-Schachbrett/Grid im Code** generieren (`Vec<u8>`).
|
||||||
|
- `device.create_texture` + `queue.write_texture` + `Sampler`
|
||||||
|
(`FilterMode::Linear`, `AddressMode::Repeat`). Mipmaps optional (nice-to-have für
|
||||||
|
flache Blickwinkel; kein Muss für den Spike).
|
||||||
|
- Selbstständig, damit der Spike ohne Dateipfade/Asset-Pipeline läuft.
|
||||||
|
|
||||||
|
### 2.3 Textur-Bind-Group (group 1)
|
||||||
|
- Neue Bind-Group-Layout mit `texture_view` (`TextureSampleType::Float`) +
|
||||||
|
`sampler`. **`Globals` bleibt group 0** und unverändert.
|
||||||
|
|
||||||
|
### 2.4 Textured-Pipeline + WGSL (`MESH_TEXTURED_WGSL`)
|
||||||
|
- Vertex-Layout `[pos vec3, normal vec3, uv vec2]`, `TriangleList`.
|
||||||
|
- **Dieselbe Beleuchtung wie `MESH_WGSL`** (hemisphärisches Ambient + Directional +
|
||||||
|
Fill) — nur **Albedo = `textureSample(tex, samp, uv)`** statt Vertex-Farbe.
|
||||||
|
- Depth-Format, MSAA (`SAMPLE_COUNT`) und Color-Target **identisch** zur
|
||||||
|
Haupt-Pipeline (sonst inkompatibler Render-Pass).
|
||||||
|
- Pipeline-Layout bindet group 0 (Globals) **und** group 1 (Textur).
|
||||||
|
|
||||||
|
### 2.5 Verdrahten
|
||||||
|
- Bei `RenderStyle::Textured` im Render-Pass die neue Pipeline + beide Bind-Groups
|
||||||
|
setzen (Muster: `cap_pipeline`-Bindung bei `gpu.rs:847`).
|
||||||
|
|
||||||
|
### 2.6 Spike sichtbar machen
|
||||||
|
- `spike3d.rs` so erweitern, dass der Stil auf `Textured` schaltbar ist
|
||||||
|
(Tastendruck, z. B. `T`, **oder** Startkonstante). Die Demo-Wände sollen
|
||||||
|
texturiert im Orbit erscheinen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Randbedingungen (hart)
|
||||||
|
|
||||||
|
- **Nur** die `render3d`-Crate. `src/web.rs` und die Tauri-/`native3d`-Oberfläche
|
||||||
|
**nicht** anfassen.
|
||||||
|
- Feature-gegatet unter dem bestehenden `render`/`window`-Feature.
|
||||||
|
**Default-Build und Default-Darstellung bleiben unverändert.**
|
||||||
|
- **Keine neuen Dependencies** (insbesondere **kein `image`-Crate**) für den Spike.
|
||||||
|
- Term-/Reihenfolge-sensible Geometrie (Parität) wird **nicht** berührt — es kommt
|
||||||
|
nur additiv ein UV-Kanal + ein zweiter Render-Pfad dazu.
|
||||||
|
- Kommentar-Stil und Sprache (Deutsch, ausführliche Begründungs-Kommentare) wie im
|
||||||
|
umgebenden Code beibehalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Akzeptanz / Verifikation
|
||||||
|
|
||||||
|
1. `cargo test` (im Crate-Verzeichnis `src-tauri/render3d`) **grün**, inklusive:
|
||||||
|
- bestehende Mesh-Regression (Alt-Pfad `[pos,normal,color]` unverändert),
|
||||||
|
- **neuer naga-Validierungstest** für `MESH_TEXTURED_WGSL` (Vorlage:
|
||||||
|
`lib.rs:924`).
|
||||||
|
2. `cargo run --features window --bin spike3d` zeigt die Demo-Wände mit
|
||||||
|
**erkennbarer, korrekt gemappter** Schachbrett-Textur:
|
||||||
|
- Raster weltmassstäblich (in Metern), keine Verzerrung an Gehrungen/Ecken,
|
||||||
|
- beleuchtet wie im Shaded-Modus (Volumen bleibt ablesbar).
|
||||||
|
3. Umschalten Shaded ↔ Textured zur Laufzeit (oder per Startkonstante) funktioniert
|
||||||
|
ohne Re-Meshing-Crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Abschlussbericht (vom Agenten am Ende zu liefern)
|
||||||
|
|
||||||
|
- Welche Dateien geändert/hinzugefügt wurden und warum.
|
||||||
|
- Wie die UVs projiziert werden (Achswahl, Massstab, Verhalten an Gehrungen).
|
||||||
|
- Welcher der beiden UV-Wege (§2.1) gewählt wurde und weshalb.
|
||||||
|
- **Ehrliche Lückenliste für „richtig gutes" Texturing:** Asset-/Bild-Datei-Laden,
|
||||||
|
Material→Textur-Zuordnung (Wandtyp/Layer → Material), Normal-/Roughness-Maps
|
||||||
|
(PBR), anisotropes Filtern + Mipmaps, Web-Pfad (`web.rs`/WebGPU), UI zum
|
||||||
|
Zuweisen. Grobschätzung Aufwand je Punkt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Nicht im Scope
|
||||||
|
|
||||||
|
Asset-/Bild-Datei-Laden · Material-System · mehrere Texturen gleichzeitig ·
|
||||||
|
PBR/Normal-Maps · Web-Pfad (`web.rs`) · jegliche UI · Anbindung unter die Webview.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# STATUS — Codebase-Analyse
|
||||||
|
|
||||||
|
> Stand: 2026-07-21 · Vollständige Bestandsaufnahme von Code + Dokumentation.
|
||||||
|
> Ersetzt NICHT [ROADMAP.md](ROADMAP.md)/[ARCHITECTURE.md](ARCHITECTURE.md) (die wurden
|
||||||
|
> im gleichen Zug überarbeitet), sondern begründet die Überarbeitung mit Zahlen und
|
||||||
|
> Befunden. [HANDOVER.md](HANDOVER.md) und [PENDENZEN.md](PENDENZEN.md) bleiben die
|
||||||
|
> laufenden Arbeitsprotokolle (nicht rückwirkend umgeschrieben).
|
||||||
|
|
||||||
|
## 0. TL;DR
|
||||||
|
|
||||||
|
„Dossier" (Arbeitstitel `cad`, Rhino-Vorbild `DOSSIER`) ist in **3 Wochen**
|
||||||
|
(erster Commit 2026-06-30, 362 Commits bis 2026-07-20) von einem Risiko-Spike zu
|
||||||
|
einem **funktionsreichen Desktop-CAD/BIM-Tool** gewachsen: eigenes semantisches
|
||||||
|
Gebäudemodell, zwei eigene Rust/WASM-Rendering-Engines („Nordstern"), ein
|
||||||
|
Rhino-artiges Kommandosystem, IFC/DXF/PDF/STL/OBJ-Export, Swisstopo-Import,
|
||||||
|
SIA-416-Flächen, Materialbibliothek (statisch + live von ambientCG), Layouts/
|
||||||
|
Plansätze, Ausschnitte, native Tauri-Fenster. **~125.000 Zeilen Code** (TS+Rust,
|
||||||
|
inkl. Tests), gebaut über viele autonome Agent-Sessions.
|
||||||
|
|
||||||
|
Die drei zentralen Vision-Dokumente (ARCHITECTURE.md, README.md, ROADMAP.md)
|
||||||
|
stammen aus der **allerersten Woche** (Stand 28./29.6.) und beschreiben einen
|
||||||
|
Plan, der in der Zwischenzeit an vielen Stellen überholt, anders gelöst oder
|
||||||
|
längst umgesetzt wurde (z. B. HLR/OCCT→eigene Rust-Schnitt-Pipeline,
|
||||||
|
„Booleans noch offen"→teilweise längst gelöst). Diese Doku-Drift war der Auslöser
|
||||||
|
für diese Analyse; die Docs sind im gleichen Zug revidiert worden.
|
||||||
|
|
||||||
|
## 1. Kennzahlen
|
||||||
|
|
||||||
|
### Code-Umfang
|
||||||
|
|
||||||
|
| Bereich | Dateien | LOC (ohne Tests) | Tests |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| `src/` (TypeScript, gesamt) | ~230 | 87.736 | 869 (Vitest, 71 Dateien) / 16.855 LOC |
|
||||||
|
| `src-tauri/render3d` („Nordstern" 3D) | 14 | 10.246 | 97 `#[test]` |
|
||||||
|
| `src-tauri/render2d` (2D-WGSL-Renderer) | 11 | 3.530 | 18 `#[test]` |
|
||||||
|
| `src-tauri/kernel2d` (Rust-Geometriekern, Paritätstest) | 1 | 3.383 | 18 `#[test]` |
|
||||||
|
| `src-tauri/geometry` (Wand-Join-Mathe, **unbenutzt**) | 1 | 1.057 | 8 `#[test]` |
|
||||||
|
| `src-tauri/trucksolid` (CSG/Extrusion, `truck`+`csgrs`) | 2 | 758 | 15 `#[test]` |
|
||||||
|
| `src-tauri/dwgimport` (DXF-Parser-Spike, **unbenutzt**) | 1 | 284 | 1 `#[test]` |
|
||||||
|
| `src-tauri/src` (Tauri-Host: Fenster, Dialoge, Lock) | 4 | 1.044 | — |
|
||||||
|
| **Gesamt** | | **~108.000** (ohne Tests) / **~125.000** (mit Tests) | 869 Vitest + 157 Rust-Tests |
|
||||||
|
|
||||||
|
Größte TS-Bereiche: `plan/` (23.626 LOC — Plan-Ableitung + 3 Renderer),
|
||||||
|
`ui/` (12.897 LOC — App-Shell, ResourceManager, Ribbon), `panels/` (9.538 LOC),
|
||||||
|
`model/` (7.043 LOC inkl. Tests), `commands/` (6.973 LOC), `io/` (5.299 LOC),
|
||||||
|
`state/` (5.297 LOC), `geometry/` (5.559 LOC), `viewport/` (5.165 LOC),
|
||||||
|
`export/` (4.280 LOC). `src/App.tsx` allein ist **7.130 Zeilen**.
|
||||||
|
|
||||||
|
### Tempo
|
||||||
|
|
||||||
|
362 Commits in 3 Wochen; Woche 27 (30.6.–6.7.): 233 Commits, Woche 28: 126,
|
||||||
|
danach starker Rückgang (Woche 30 bislang 3) — die Session-Dichte hat spürbar
|
||||||
|
abgenommen, nicht das Projekt gestoppt (siehe PENDENZEN.md, weiterhin aktiv).
|
||||||
|
|
||||||
|
## 2. Architektur, wie sie WIRKLICH ist (nicht wie geplant)
|
||||||
|
|
||||||
|
### 2.1 Datenmodell — kein `Element[]`-Union, sondern typisierte Arrays
|
||||||
|
|
||||||
|
`ARCHITECTURE.md` (alt) plante eine diskriminierte Union `Element = Wall | Door |
|
||||||
|
Window | …`. Tatsächlich hält `Project` (`src/model/types.ts:2095`) **pro
|
||||||
|
Bauteiltyp ein eigenes optionales Array**: `walls`, `ceilings?`, `roofs?`,
|
||||||
|
`doors`, `openings?` (Fenster/Türen gehostet in Wänden, `kind:"window"|"door"`),
|
||||||
|
`stairs?`, `rooms?`, `columns?`, `extrudedSolids?`, `drawings2d`, `context?`
|
||||||
|
(Importe/Terrain), plus die Bibliotheks-/Typtabellen (`lineStyles`, `hatches`,
|
||||||
|
`components`, `wallTypes`, `roofTypes?`, `doorTypes?`, …) und die
|
||||||
|
Dokument-Ebene (`viewSnapshots?`, `layouts?`, `masterLayouts?`, …). Der Typ-Alias
|
||||||
|
`Element` (types.ts:1601) existiert zwar noch, wird aber **nirgends** verwendet
|
||||||
|
(Element-Baum/Selektion arbeiten direkt auf den typisierten Arrays). Praktisch
|
||||||
|
funktioniert das gut (jeder Bauteiltyp hat sein eigenes, spezifisches Interface),
|
||||||
|
ist aber eine bewusste Abweichung vom ursprünglichen Uniform-Union-Plan.
|
||||||
|
|
||||||
|
### 2.2 State — kein Zustand/Redux/Immer, sondern ein Eigenbau
|
||||||
|
|
||||||
|
`docs/design/state-architecture.md` empfahl **Zustand**. Gebaut wurde stattdessen
|
||||||
|
ein **abhängigkeitsfreier Store auf `useSyncExternalStore`** (`src/state/store.ts`,
|
||||||
|
gleiches Muster wie `src/i18n`). `createStore()` komponiert Slice-Fabriken
|
||||||
|
(`projectSlice` inkl. Undo/Redo, `historySlice`, `selectionSlice`, `viewSlice`,
|
||||||
|
`layoutSlice`, `siteSlice`, `notifySlice`) über eine gemeinsame `RootState`.
|
||||||
|
Funktioniert, aber: der geplante Folgeschritt „App.tsx wird dünne Shell,
|
||||||
|
View-Routing nach `src/views/`, Kontextmenüs nach `src/menus/`" ist **nicht**
|
||||||
|
passiert — `src/views/` und `src/menus/` existieren nicht, View-Umschaltung und
|
||||||
|
Kontextmenü-Aufbau liegen weiterhin inline in `App.tsx` (7.130 Zeilen). Das ist
|
||||||
|
der deutlichste Doku-vs-Code-Widerspruch im ganzen Repo (CONVENTIONS.md
|
||||||
|
verlangt explizit das Gegenteil).
|
||||||
|
|
||||||
|
### 2.3 Rendering — drei 2D-Pfade, zwei 3D-Viewports
|
||||||
|
|
||||||
|
**2D-Plan:** es gibt tatsächlich **drei** koexistierende Renderer, nicht einen:
|
||||||
|
1. `PlanView.tsx` (SVG) — Referenz-/Fallback-Pfad, bleibt IMMER im DOM für
|
||||||
|
Hit-Testing/Grips, unabhängig davon was zeichnet.
|
||||||
|
2. `plan/glPlan/` — eigener TypeScript-WebGL2-Renderer (`glPlanCompile/-Render/
|
||||||
|
-Shaders/-Hatch.ts`).
|
||||||
|
3. `useWasmPlanRenderer.ts` → Rust-`render2d`-Crate (WGSL, nativ via wgpu,
|
||||||
|
Web via WebGPU/WebGL2-Fallback, inkl. echtem Text-Rendering via `glyphon`).
|
||||||
|
|
||||||
|
Beide GPU-Pfade fallen bei Initialisierungsfehler still auf SVG zurück.
|
||||||
|
|
||||||
|
**3D:** ebenfalls zwei Viewports: `Viewport3D.tsx` (three.js, „Free"-Stufe) und
|
||||||
|
`Wasm3DViewport.tsx` (Rust/wgpu „Nordstern", editierbar, **Default**). Ein
|
||||||
|
Settings-Schalter wählt die Engine.
|
||||||
|
|
||||||
|
### 2.4 Schnitt/Section — NICHT über HLR, sondern eigene Rust-Pipeline
|
||||||
|
|
||||||
|
`src/section/hlr.ts` + `occt.ts` (der ursprüngliche OpenCascade.js-HLR-Spike aus
|
||||||
|
Phase 0) hat **keinen einzigen Aufrufer mehr** im gesamten `src/` — toter Code.
|
||||||
|
Der tatsächliche, funktionierende Live-Schnitt läuft über einen völlig anderen,
|
||||||
|
analytischen Mechanismus: `App.tsx` (`section3dCutId`/`section3dPlane`) →
|
||||||
|
`Wasm3DViewport.tsx` (`section3d`-Prop → `setSectionPlane`) →
|
||||||
|
`src-tauri/render3d/src/{section.rs, section_boolean.rs, section_fill.rs}`.
|
||||||
|
Die Rust-Seite nutzt aus, dass jedes Bauteil ein Prisma mit konstantem
|
||||||
|
Querschnitt ist — eine Schnittebene liefert dadurch immer ein
|
||||||
|
achsparalleles Rechteck, nie ein Trapez; `section_boolean.rs` ist ein 1:1-Port
|
||||||
|
von `toSection.ts::subtractDominantBands`, damit 2D-Plan-Schnitt und
|
||||||
|
3D-Live-Schnitt exakt übereinstimmen. Kein Worker, kein Comlink (beides war
|
||||||
|
geplant, keines existiert) — läuft synchron/GPU-seitig.
|
||||||
|
|
||||||
|
### 2.5 Öffnungen als Löcher — echt, aber kein Mesh-Boolean
|
||||||
|
|
||||||
|
Fenster/Türen schneiden echte achsparallele Rechteck-Löcher aus dem Wandkörper
|
||||||
|
(`plan/toWalls3d.ts` `RHole`/`subtractSpans`, gespiegelt in
|
||||||
|
`render3d/{mesh.rs,section.rs}`) — funktioniert, ist aber KEIN generisches
|
||||||
|
Mesh-Boolean. Ein echtes CSG-Boolean existiert bereits (`trucksolid::boolean_mesh`,
|
||||||
|
`csgrs`-basiert, 15 Rust-Tests) und wird von `src/engine/truckSolid.ts` für das
|
||||||
|
Extrusions-Kommando genutzt — ist aber **nicht** an die Wand/Öffnungs-Pipeline
|
||||||
|
angeschlossen (bestätigt: `booleanMesh` hat ausserhalb von `truckSolid.ts`
|
||||||
|
keinen Aufrufer).
|
||||||
|
|
||||||
|
### 2.6 Rust-Workspace: sechs unabhängige Crates, nicht ein Workspace
|
||||||
|
|
||||||
|
`src-tauri/Cargo.toml` bindet nur den Tauri-Host (`cad-tauri`) als Workspace-
|
||||||
|
Mitglied; `render2d/render3d/geometry/kernel2d/trucksolid/dwgimport` sind
|
||||||
|
**eigenständige Cargo-Packages**, die dem Host nur optional (Features
|
||||||
|
`native2d`/`native3d`, standardmässig AUS) als Path-Dependency zugespielt
|
||||||
|
werden. Jedes Crate muss headless (`cargo test`) UND per `wasm-pack --features
|
||||||
|
web` bauen, ohne den Tauri-Toolchain-Zwang zu erben — bewusst so geschnitten.
|
||||||
|
Geteilte Abhängigkeiten: `wgpu 29`/`naga 29` (render2d+render3d, versionsgekoppelt
|
||||||
|
wegen `glyphon 0.11`), `truck-modeling`+`csgrs`(gepinnter Git-Rev)+`nalgebra`
|
||||||
|
nur in `trucksolid`.
|
||||||
|
|
||||||
|
### 2.7 Zwei Desktop-Rahmen: Tauri (macOS) + Electron (Linux)
|
||||||
|
|
||||||
|
Die App läuft plattformabhängig in **zwei verschiedenen nativen Rahmen** —
|
||||||
|
das ist Absicht, kein Wildwuchs, und hängt an **WebGPU**:
|
||||||
|
|
||||||
|
- **macOS → Tauri.** WKWebView unterstützt WebGPU, das die render2d/render3d-
|
||||||
|
WASM-Engines brauchen. `src-tauri/tauri.conf.json` (Identifier
|
||||||
|
`ch.dossier.cad`, eigene Titelleiste, `trafficLightPosition`) +
|
||||||
|
`isTauriRuntime()`-Gates an 6+ Stellen in `App.tsx` + vier eigene native
|
||||||
|
Zusatzfenster (`src/native/`: Resources, Settings, DrawingLevels,
|
||||||
|
LayerSettings, ContextImport).
|
||||||
|
- **Linux → Electron.** Tauris Linux-Webview **WebKitGTK unterstützt WebGPU
|
||||||
|
nicht zuverlässig** → dort läuft die App über eine Electron/Chromium-Shell
|
||||||
|
(`scripts/electron-main.cjs` + `electron-preload.cjs`, gestartet via
|
||||||
|
`npm run electron`). Der Kommentar in `electron-main.cjs` sagt es explizit:
|
||||||
|
„Ersetzt WebKitGTK durch Chromium, damit WebGPU zuverlässig läuft."
|
||||||
|
|
||||||
|
Beide teilen sich **dieselbe** React-App und dieselbe randlose eigene
|
||||||
|
Titelleiste; die Laufzeit erkennt den Host über `window.__TAURI__` (Tauri)
|
||||||
|
bzw. `window.dossierWindow` (Electron, per `contextBridge` injiziert). Die
|
||||||
|
Fenstersteuerung (`src/ui/WindowControls.tsx`) ist an beide Wege angebunden.
|
||||||
|
Kein Rust-Backend nötig auf dem Electron-Pfad — `computeJoins` hat einen
|
||||||
|
TS-Fallback (`src/compute/index.ts`). Electron ist also **kein** totes Gleis,
|
||||||
|
sondern der aktive Linux-Zielrahmen.
|
||||||
|
|
||||||
|
## 3. Feature-Inventar (was tatsächlich funktioniert)
|
||||||
|
|
||||||
|
### Modell & Bauteile
|
||||||
|
- Mehrschichtige Wände (`WallType.layers[]`) mit L-Eck-Gehrung UND
|
||||||
|
Prioritäts-T-/X-Stössen (`joinPriority` am Component) — **fertig**, in 2D-Plan,
|
||||||
|
3D-Viewport UND 3D-Live-Schnitt konsistent (Rust-Port `section_boolean.rs`).
|
||||||
|
- Parametrische Wände (`ParametricWall`: Grid/Modul/Sequenz/Referenzlinie/
|
||||||
|
bedingte Dicke) lösen sich zu konkreten `Wall[]` auf.
|
||||||
|
- Decken (Slabs, `ceilings?`) mit Aussparungen, eigenem Typkatalog.
|
||||||
|
- Türen/Fenster gehostet in Wänden, mit Rahmen/Zarge/Blockrahmen, Kämpfer,
|
||||||
|
Oberlicht, Detailgrad grob/mittel/fein (2D UND 3D), Schwenkbogen; daneben
|
||||||
|
existiert weiterhin ein **älteres, separates `Door[]`** neben `Opening[]` —
|
||||||
|
laut PENDENZEN.md explizit als offene Doppelspur/Aufräum-Punkt vermerkt.
|
||||||
|
- Treppen (gerade/L/Wendel), geschossübergreifend, 2D-Symbol mit Lauflinie/Pfeil.
|
||||||
|
- Dächer (Flach/Pult/Sattel/Walm/Mansarde/Zelt) über Rechteck-Umriss, First/
|
||||||
|
Traufe/Grat im 2D-Plan.
|
||||||
|
- Stützen (Column) mit Profilbibliothek (Quadrat/Rechteck/Rund/I/Rohr).
|
||||||
|
- Räume (SIA-416: HNF/NNF/VF/FF/GF/AGF) mit automatischer Bilanz + CSV-Export,
|
||||||
|
Raumstempel-Editor (Drag&Drop-Felder).
|
||||||
|
- Extrudierte Volumenkörper (truck-Integration: konkave Profile, Verjüngung).
|
||||||
|
- Kontext-Layer (Terrain-TIN, importierte Meshes, Konturen) — semantisch getrennt.
|
||||||
|
|
||||||
|
### Zeichnen & Bedienung
|
||||||
|
- Rhino-artiges Kommandosystem (`commands/`): getippte Koordinaten
|
||||||
|
(`5,3`/`r5,3`/`5<45`), Tab-Feld-Zyklus für Präzisionseingabe
|
||||||
|
(`src/ui/CommandLine.tsx`), Alias/Autocomplete, ~25 Kommandos (wall, ceiling,
|
||||||
|
opening, stair, column, roof, room, line/polyline/rect/circle/arc, text,
|
||||||
|
move/mirror/copy/offset/trim/join, extrude, import, terrain, measure,
|
||||||
|
Schnittlinie, Georef).
|
||||||
|
- Snapping (Endpunkt/Mitte/Schnittpunkt/Lot/Raster/Ortho), Grips, Array,
|
||||||
|
Trim/Split/Join, 2D-Booleans (Union/Subtract/Intersect via `polygon-clipping`).
|
||||||
|
- Messwerkzeug (Polygonzug, Länge + Fläche).
|
||||||
|
- Rich-Text-Annotationen (Bold/Kursiv/Hoch-/Tiefstellung).
|
||||||
|
|
||||||
|
### Darstellung / Ressourcen
|
||||||
|
- Resource Manager: Line/Hatch/Component-Manager, Wand-/Decken-/Tür-/Fenster-/
|
||||||
|
Treppen-/Dach-Typeditoren — als eigenständiges natives Fenster (nicht als
|
||||||
|
Dock-Panel).
|
||||||
|
- Materialbibliothek: 13 fest gebündelte PBR-Starter (ambientCG, lokale
|
||||||
|
Texturen) **plus** Live-Suche der kompletten ambientCG-Bibliothek (Auflösung
|
||||||
|
1K/2K/4K, on-demand Download+Entpacken via `jszip`, Proxy wegen CORS) — heute
|
||||||
|
bereinigt (siehe Commit-Historie dieser Session).
|
||||||
|
- Regelbasierte Overrides (Bedingung → Farbe/Strichstärke/Schraffur/Sichtbarkeit).
|
||||||
|
- Detailgrad grob/mittel/fein je Bauteil + Dokument-Override.
|
||||||
|
- Hell-/Dunkel-Theme, Akzentfarben.
|
||||||
|
|
||||||
|
### Pläne / Output
|
||||||
|
- Ausschnitte (View-Snapshots: Kamera, Massstab, Detailgrad, Sichtbarkeiten,
|
||||||
|
Override-Preset) in Ordnerstruktur.
|
||||||
|
- Layout-Blätter (Plansätze): Papierformat/-grösse, mehrere Viewports pro
|
||||||
|
Blatt, Masterlayout-Vererbung (Titelblock), Ordnerstruktur, freie 2D-Annotationen.
|
||||||
|
- Vektor-Export: PDF (Einzelblatt UND **Mehrseiten pro Ordner**,
|
||||||
|
`layoutPdf.ts::buildFolderPdf`), DXF, IFC4 (mit echten Fenster-/Tür-Löchern,
|
||||||
|
deterministischen GUIDs), STL, OBJ, CSV-Bauteil-Schedule (volles Element-Set).
|
||||||
|
- Kamera-Presets (Kardinal + Iso), Norden-Rotation.
|
||||||
|
|
||||||
|
### Import / Kontext
|
||||||
|
- DXF (Konturen), DWG (`@mlightcad/libredwg-web`, WASM), `.lin`/`.pat`.
|
||||||
|
- Swisstopo: swissBUILDINGS3D (radiusgenau zugeschnitten, nicht die ganze
|
||||||
|
STAC-Kachel), swissALTI3D, SWISSIMAGE-Orthofoto-Draping, LV95↔WGS84,
|
||||||
|
Georeferenzierung über EINEN Vermessungspunkt (E/N/H, `geoAnchor`).
|
||||||
|
- OSM/Overpass-Kontextimport (7 Kategorien).
|
||||||
|
- Terrain-Mesh-Generator.
|
||||||
|
|
||||||
|
### Desktop-Integration (Tauri)
|
||||||
|
- Eigene randlose Fenster mit nativer Titelleiste (macOS-Ampel-Position).
|
||||||
|
- Native Speichern/Öffnen-Dialoge (`plugin-fs`/`plugin-dialog`), eigenes
|
||||||
|
`.obp`-Projektdateiformat.
|
||||||
|
- OS-Level-Exklusiv-Lock gegen Doppelöffnen desselben Projekts (`fs4`-Crate).
|
||||||
|
- Vier eigenständige native Zusatzfenster (Resources, Settings, DrawingLevels,
|
||||||
|
LayerSettings, ContextImport) statt Overlay/Modal.
|
||||||
|
- Native macOS-Menüleiste.
|
||||||
|
- i18n de/en durchgängig, eigener `t()`-Mechanismus (kein i18next).
|
||||||
|
|
||||||
|
## 4. Mist-Liste — Befunde, Doku-Widersprüche, offene Fäden
|
||||||
|
|
||||||
|
### 4.1 Verwaiste WASM-Crates (gebaut, aber nirgends importiert)
|
||||||
|
- **`src-tauri/geometry`** (1.057 LOC, 8 Tests) → `pkgGeometry` — **null**
|
||||||
|
Importstellen in `src/`. Wand-Join-Mathe existiert redundant als TS
|
||||||
|
(`src/model/joins.ts`) UND als Rust-Port, aber nur die TS-Version läuft.
|
||||||
|
- **`src-tauri/dwgimport`** (284 LOC) → `pkgDwgImport` — **null** Importstellen;
|
||||||
|
DWG-Import läuft stattdessen über `@mlightcad/libredwg-web` (npm-Paket).
|
||||||
|
- **`src-tauri/kernel2d`** (3.383 LOC, 18 Tests) → `pkgKernel2d` — wird nur von
|
||||||
|
einem Paritätstest (`kernel2d.parity.test.ts`) konsumiert, nicht produktiv.
|
||||||
|
Die TS-Version `src/geometry/kernel2d.ts` (886 Zeilen) ist die tatsächlich
|
||||||
|
laufende Implementierung. Laut PENDENZEN.md bewusst so belassen: ein
|
||||||
|
Join-Benchmark zeigte WASM unter ~100 Wänden **langsamer** als die naive
|
||||||
|
TS-Routine. Kein Bug, aber die drei Crates zusammen sind ~4.700 Zeilen Rust
|
||||||
|
(+44 Tests), die aktuell nichts zur Laufzeit beitragen ausser einem
|
||||||
|
Korrektheits-Cross-Check für kernel2d.
|
||||||
|
|
||||||
|
**Empfehlung:** entweder (a) `geometry`- und `dwgimport`-Crate + ihre
|
||||||
|
`build:*`-Scripts entfernen (kein Nutzen, nur Wartungslast), oder (b)
|
||||||
|
explizit als „Referenzimplementierung/Zukunftsoption" in ARCHITECTURE.md
|
||||||
|
dokumentieren, damit niemand sie für aktiv hält.
|
||||||
|
|
||||||
|
### 4.2 Toter Code
|
||||||
|
- `src/section/hlr.ts` + `occt.ts` + `occt-wasm.d.ts` (473 LOC) — OCCT-WASM-
|
||||||
|
HLR-Spike aus Phase 0, **keine Aufrufer mehr**. Ersetzt durch die analytische
|
||||||
|
Rust-Schnitt-Pipeline (§2.4). `opencascade.js` bleibt als npm-Dependency
|
||||||
|
bestehen, obwohl nur noch dieser tote Code sie importiert.
|
||||||
|
- `src/export/planToPrintSvg.ts` — Kommentar im Code selbst sagt „ERSETZT durch
|
||||||
|
`sceneToPrintSvg.ts`", ist aber noch im Baum.
|
||||||
|
- `Element`-Typalias (`src/model/types.ts:1601`) — definiert, nirgends benutzt.
|
||||||
|
|
||||||
|
### 4.3 Das grösste Doku-vs-Code-Problem: App.tsx
|
||||||
|
CONVENTIONS.md verlangt seit Tag 1 „App.tsx bleibt dünner Shell, keine
|
||||||
|
Geschäftslogik". `docs/design/state-architecture.md` plante explizit die
|
||||||
|
Extraktion nach `src/views/` (View-Routing) und `src/menus/`
|
||||||
|
(Kontextmenü-Aufbau). Beide Ordner **existieren nicht**. `App.tsx` ist mit
|
||||||
|
**7.130 Zeilen** die grösste Einzeldatei des Projekts und enthält weiterhin
|
||||||
|
View-Umschaltung und Kontextmenü-Konstruktion inline. Das ist der genaue
|
||||||
|
„God-Component"-Zustand, den die Doku von Anfang an vermeiden wollte.
|
||||||
|
|
||||||
|
### 4.4 Bekannte Doppelspur: `Door[]` vs. `Opening[]`
|
||||||
|
`Project` führt sowohl ein älteres `doors: Door[]` als auch das neuere,
|
||||||
|
allgemeinere `openings?: Opening[]` (`kind:"door"|"window"`). Laut
|
||||||
|
PENDENZEN.md ist das erkannt und als Aufräum-Punkt vorgemerkt, aber nicht
|
||||||
|
konsolidiert.
|
||||||
|
|
||||||
|
### 4.5 Doku-Widersprüche (README/ARCHITECTURE/CONVENTIONS vs. Realität)
|
||||||
|
| Dokument | Behauptung | Realität |
|
||||||
|
|---|---|---|
|
||||||
|
| README.md | Shell = Electron, Tauri „ausrangiert" | Falsch andersrum gedacht: **beide** sind aktiv — Tauri auf macOS (WKWebView+WebGPU), Electron auf Linux (WebKitGTK kann kein WebGPU); siehe §2.7 |
|
||||||
|
| README.md | „HLR noch nicht ans UI verdrahtet, Views sind Stubs" | Der OCCT-HLR-Pfad stimmt (tot), aber Schnitte/Ansichten funktionieren real über eine andere, eigene Rust-Pipeline |
|
||||||
|
| README.md | „Prioritäts-T-/X-Stösse … Risiko #1" unter „bewusst offen" | Seit 7.7. erledigt, inkl. Rust-Port |
|
||||||
|
| README.md | „PDF-Export ist noch single-sheet" | `layoutPdf.ts::buildFolderPdf` erzeugt echte Mehrseiten-PDFs pro Ordner |
|
||||||
|
| README.md | Engine-Liste nennt nur render2d/render3d | Es gibt 6 Rust-Crates (+kernel2d/geometry/trucksolid/dwgimport) |
|
||||||
|
| CONVENTIONS.md | Struktur-Ziel `src/views/`, `src/menus/` | Existieren nicht; Logik liegt in `App.tsx` |
|
||||||
|
| CONVENTIONS.md | Dev-Port 5173 | Tatsächlich 5187 (`vite.config.ts`, `tauri.conf.json`) |
|
||||||
|
| ARCHITECTURE.md | Ziel-Struktur `store/`, `sheets/`, `workers/geometry.worker.ts` (Comlink) | Tatsächlich `state/`, `panels/layoutModel.ts`; kein Worker/Comlink irgendwo im Projekt |
|
||||||
|
| ARCHITECTURE.md | HLR „im Web Worker (Comlink)" | Kein Comlink im Projekt; Schnitt läuft synchron GPU-seitig in Rust |
|
||||||
|
| HANDOVER.md | Neuester Block: „Stand 2026-07-09" | Jüngster Commit + PENDENZEN.md sind vom 17.–20.7. — 8+ Tage/mehrere Sessions veraltet |
|
||||||
|
|
||||||
|
### 4.6 Codequalität — besser als der Tempo vermuten lässt
|
||||||
|
Trotz 3 Wochen / 362 Commits über viele autonome Sessions: **keine** FIXME/HACK/
|
||||||
|
XXX-Marker im ganzen Projekt; nur 2 TODOs (beide bekannt/harmlos: Geländer in
|
||||||
|
`Viewport3D.tsx:2649`, Kanten-Tiefe in `toSection.ts:1093`); `eslint-disable`
|
||||||
|
fast ausschliesslich `react-hooks/exhaustive-deps` (bewusst); keine
|
||||||
|
`_v2`/`_old`/`_backup`-Dateileichen. Die eigentliche Aufgabenliste lebt
|
||||||
|
diszipliniert in PENDENZEN.md statt in Code-Kommentaren verstreut — gesünder
|
||||||
|
als der Durchschnitt für dieses Bau-Tempo.
|
||||||
|
|
||||||
|
### 4.7 Genuine offene Punkte (Auszug aus PENDENZEN.md, Details dort)
|
||||||
|
- Geo-Block: Layer-Zuordnung für importierte Gebäude/Terrain, reale Höhen +
|
||||||
|
Projekt-müM-Draping, SWISSIMAGE-Draping.
|
||||||
|
- 3D-Feinschliff: Fensterrahmen-Ecken bei „fein" überlappen (kein Gehrungs-
|
||||||
|
Union), Dach-First/Grat hat unverschmolzene Dreiecke, unerklärte
|
||||||
|
Vertikalstreifen auf oberen Wandflächen (undiagnostiziert).
|
||||||
|
- truck-Boolean nicht an die Wand/Öffnungs-Pipeline angeschlossen (Scope-
|
||||||
|
Entscheid mit Nutzer ausstehend).
|
||||||
|
- Feld-Controller (Tab-Zyklus) fehlt für Body-Move und für 3D-Griffe generell
|
||||||
|
(nur 2D-Einzelpunkt-Drag hat ihn); 3D-Griff-Drag hat gar kein Snapping.
|
||||||
|
- `make2D`-Kommando (3D→flacher 2D-Plan mit Füllungen) ungebaut.
|
||||||
|
- Ribbon-3D-Tab leer; einige Punkte visuell noch nicht in Tauri abgenommen
|
||||||
|
(u. a. Materialfarben-Textur-Array, Schraffur-Schnittfüllung — laut
|
||||||
|
PENDENZEN als „[~] implementiert, aber unverifiziert" markiert).
|
||||||
|
- DWG/DXF-Domänen-Mapping (Entitäten → Wände/Öffnungen) unbegonnen; DWG-Schreiben
|
||||||
|
fehlt (Lesen über `libredwg-web` vorhanden).
|
||||||
|
- Teamwork/Kollaboration (Supabase) bewusst nicht begonnen, gilt als späte Phase.
|
||||||
|
|
||||||
|
## 5. Vergleich: Tag-1-Vision vs. heute
|
||||||
|
|
||||||
|
| Vision (28./29.6.) | Heute |
|
||||||
|
|---|---|
|
||||||
|
| Three.js als einziger 3D-Renderer, OpenCascade.js für Booleans/HLR | Eigene Rust/WASM-Engines („Nordstern") für 2D+3D; three.js nur noch „Free"-Fallback; OCCT-Pfad tot |
|
||||||
|
| Ein `Element[]`-Union | Typisierte Arrays pro Bauteiltyp auf `Project` |
|
||||||
|
| Zustand-Store | Eigener `useSyncExternalStore`-Store |
|
||||||
|
| Web Worker + Comlink für HLR | Synchrone, analytische Rust-GPU-Schnitt-Pipeline |
|
||||||
|
| „Booleans: Entscheidung in Phase 0" | Trucksolid/csgrs-CSG existiert, ist getestet, aber nicht an Wände angeschlossen |
|
||||||
|
| web-ifc für IFC | Eigener IFC4-Writer (`exportIfc.ts`) |
|
||||||
|
| Phase 2–5 grösstenteils „Backlog" | Treppen, Dächer, Stützen, SIA-416, Swisstopo, OSM, Kamera-Presets, Layouts, Ausschnitte, Terrain — alles bereits gebaut |
|
||||||
|
|
||||||
|
Kurz: die **Prinzipien** (ein Modell, viele Ableitungen; Darstellung erst beim
|
||||||
|
Rendern; keine Cache-Stale-Bugs) haben gehalten und wurden korrekt umgesetzt.
|
||||||
|
Die **konkreten Technologie-Entscheidungen** sind fast durchgängig anders
|
||||||
|
gelaufen als geplant — meist zugunsten einer eigenen, schnelleren Rust/WASM-
|
||||||
|
Lösung statt einer Drittbibliothek.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# ARCHITEKTUR-BRIEFING: Tauri + wgpu (Korrigiert)
|
||||||
|
|
||||||
|
**Stand:** 2026-07-01 — **KORREKTUR** (vorherige Dokumente waren unvollständig)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Entscheidung (FINAL)
|
||||||
|
|
||||||
|
**Browser-CAD (alt) → Desktop Tauri-App mit Rust-Backend + wgpu-Rendering (neu)**
|
||||||
|
|
||||||
|
| Aspekt | Alt | Neu |
|
||||||
|
|---|---|---|
|
||||||
|
| **App-Form** | Browser-Tab | Desktop Tauri-Window |
|
||||||
|
| **Frontend** | React/Vite (TS) | React/Vite (TS) — UNVERÄNDERT |
|
||||||
|
| **2D-Rendering** | SVG-Plan | SVG-Plan — UNVERÄNDERT |
|
||||||
|
| **3D-Rendering** | three.js/WebGL | **wgpu** (Vulkan/Metal/DX12) |
|
||||||
|
| **Rechenintensive Ops** | TS in Browser | **Rust im Backend** |
|
||||||
|
| **Betriebssystem** | cross-platform browser | Windows/macOS/Linux Desktop App |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum dieser Umstieg?
|
||||||
|
|
||||||
|
**Problem mit three.js/WebGL:**
|
||||||
|
- komplexe Möbel = 100k+ Polygone
|
||||||
|
- mehrere parallele Ops (kernel2d, bool-Ops, DXF-Parser, SIA-Raumerkennung)
|
||||||
|
- WebGL hat harte Limits (GPU VRAM, draw calls, single-threaded)
|
||||||
|
- → Laggy, nicht skalierbar für Professional CAD
|
||||||
|
|
||||||
|
**Lösung: Tauri + wgpu + Rust**
|
||||||
|
- **wgpu** = low-level GPU API (direkt zu Vulkan/Metal/DX12, nicht WebGL)
|
||||||
|
- **Rust** = rechenintensive Ops parallelisiert + native performance
|
||||||
|
- **Desktop** = native App, nicht Browser (bessere Kontrolle, bessere Perf)
|
||||||
|
- **React-Frontend bleibt** = UI/Sketching/Panels unverändert (wgpu nur für 3D-Display)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Der neue Stack (FINAL)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Desktop Tauri-Window │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ React/Vite (UI-Shell, State, Panels) │
|
||||||
|
│ ├─ SVG 2D-Plan (Grundriss) │
|
||||||
|
│ └─ wgpu 3D-Viewport (3D-Ansicht + Rendering) │
|
||||||
|
│ │
|
||||||
|
│ ↓ invoke (IPC) ↓ │
|
||||||
|
│ │
|
||||||
|
│ Rust-Backend (src-tauri/) │
|
||||||
|
│ ├─ computeJoins() — Wand-Eckverbindungen │
|
||||||
|
│ ├─ kernel2d() — Offset/Trim/Extend/… │
|
||||||
|
│ ├─ parseShapeFromDwg() — Geometrie-Import │
|
||||||
|
│ ├─ detectRooms() — SIA-Raumerkennung │
|
||||||
|
│ └─ booleanOps() — Union/Differenz/Schnitt │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nicht verändert:**
|
||||||
|
- App.tsx, state/, commands/, panels/, model/types.ts
|
||||||
|
- UI-Logik, Befehlssystem, Sketching-Tools
|
||||||
|
|
||||||
|
**Neu/geändert:**
|
||||||
|
- `src-tauri/` — Rust-Crate für Backend
|
||||||
|
- `src/compute/index.ts` — Boundary (invoke + TS-Fallback)
|
||||||
|
- Rendering-Engine: **three.js → wgpu**
|
||||||
|
- Build: `npm run tauri:build` statt `npm run build`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Aufgabe (vier Agents)
|
||||||
|
|
||||||
|
**Siehe: `docs/design/tauri-migration-plan.md`**
|
||||||
|
|
||||||
|
Vier parallele Agents:
|
||||||
|
|
||||||
|
1. **Agent: Tauri-Shell Setup** → `src-tauri/` + Cargo.toml + main.rs (Tauri window registrieren)
|
||||||
|
2. **Agent: Compute-Boundary (TS)** → `src/compute/index.ts` (invoke-Wrapper + TS-Fallbacks)
|
||||||
|
3. **Agent: Rust compute_joins Impl** → `src-tauri/src/geometry.rs` (erste Op, Proof-of-Concept)
|
||||||
|
4. **Agent: Vite/Package-Integration** → vite.config.ts + package.json (Tauri-Plugins, scripts)
|
||||||
|
|
||||||
|
**Deliverable:** lauffähige Desktop-App, wand-Edit triggert Rust-Op, Output identisch TS-Version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev-Workflow (post-Tauri)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev: Terminal 1 (Vite)
|
||||||
|
npm run dev # localhost:5173
|
||||||
|
|
||||||
|
# Dev: Terminal 2 (Tauri)
|
||||||
|
npm run tauri:dev # öffnet Desktop-Window, zeigt auf localhost:5173
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run tauri:build # → Windows .exe / macOS .app / Linux .deb
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rendering: three.js → wgpu (Wichtig!)
|
||||||
|
|
||||||
|
**3D-Viewport wird NEU implementiert in wgpu:**
|
||||||
|
- Nicht: "drei.js mit Rust-Fallback"
|
||||||
|
- **Ja:** wgpu als native GPU-Renderer (skaliert auf 100k+ Polygone)
|
||||||
|
|
||||||
|
**2D-Plan bleibt SVG** (keine Änderung).
|
||||||
|
|
||||||
|
**Timeline für wgpu-Impl:**
|
||||||
|
- Milestone 1 (jetzt): Tauri-Shell + Rust-Compute-Ops
|
||||||
|
- Milestone 2 (nächst): wgpu 3D-Viewport-Impl (ersetzt drei.js/WebGL)
|
||||||
|
- Milestone 3: Live clipping/sectioning in wgpu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration-Reihenfolge
|
||||||
|
|
||||||
|
**Phase 1 (Tauri-Shell + Proof-of-Concept):**
|
||||||
|
- [ ] `computeJoins` (Wand-Ecken) → Rust
|
||||||
|
|
||||||
|
**Phase 2 (Rendering-Umbau):**
|
||||||
|
- [ ] wgpu Viewport-Impl (ersetzt drei.js)
|
||||||
|
- [ ] Instancing/LOD für Möbel-Geometrie
|
||||||
|
|
||||||
|
**Phase 3 (weitere Ops):**
|
||||||
|
- [ ] `kernel2d` (Offset/Trim) → Rust
|
||||||
|
- [ ] DXF/DWG-Parser → Rust
|
||||||
|
- [ ] detectRooms → Rust
|
||||||
|
- [ ] booleanOps → Rust
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was sich NICHT ändert
|
||||||
|
|
||||||
|
- `src/App.tsx`, `src/state/`, `src/commands/`
|
||||||
|
- UI-Panels, Zeichenwerkzeuge, Befehlssystem
|
||||||
|
- Semantisches Modell (types.ts)
|
||||||
|
- i18n, Styling
|
||||||
|
|
||||||
|
## Was ändert sich
|
||||||
|
|
||||||
|
- **Engine:** three.js/WebGL → wgpu
|
||||||
|
- **Backend:** TS-Only → Tauri + Rust
|
||||||
|
- **Distribution:** Browser → Desktop App
|
||||||
|
- **Build-Prozess:** `npm run build` → `npm run tauri:build`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerquellen (zur Klarheit)
|
||||||
|
|
||||||
|
❌ **FALSCH:** "Wir bleiben bei three.js"
|
||||||
|
✅ **RICHTIG:** "three.js → wgpu, Rust-Compute + Desktop Tauri"
|
||||||
|
|
||||||
|
❌ **FALSCH:** "Tauri + three.js als Frontend-Engine"
|
||||||
|
✅ **RICHTIG:** "Tauri-Shell + React UI + wgpu 3D-Rendering + Rust-Compute"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
|
||||||
|
2. **Spawn:** vier Agents (parallel, unabhängig)
|
||||||
|
3. **Verifizieren:** `npm run tauri:dev` → App läuft, erste Op in Rust funktioniert
|
||||||
|
4. **Aktualisieren:** HANDOVER.md mit Milestone-1-Status
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragen?
|
||||||
|
|
||||||
|
- **"Warum Desktop statt Browser?"** → Skalierbarkeit (native GPU + Rust-Compute)
|
||||||
|
- **"Warum wgpu statt drei.js?"** → wgpu skaliert auf 100k+ Polygone, three.js/WebGL hat Limits
|
||||||
|
- **"Bleibt React?"** → Ja, React/Vite UI bleibt, nur 3D-Engine wechselt
|
||||||
|
- **"Wann ist wgpu-Impl fertig?"** → Milestone 2 (nach Tauri-Shell)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Dokumentation — Standalone Browser-BIM (cad)
|
||||||
|
|
||||||
|
> Stand: 2026-06-29 · **Historische Recherche-/Design-Dokumente aus der ersten
|
||||||
|
> Woche.** Mehrere Kern-Empfehlungen hier (replicad/OCCT als B-Rep-Kernel,
|
||||||
|
> Manifold, web-ifc, `three/webgpu`) wurden im tatsächlichen Bau **nicht**
|
||||||
|
> umgesetzt — stattdessen entstanden eigene Rust/WASM-Rendering-Engines
|
||||||
|
> („Nordstern"). Für den aktuellen Ist-Zustand: **[../STATUS.md](../STATUS.md)**
|
||||||
|
> und **[../ARCHITECTURE.md](../ARCHITECTURE.md)**. Die Dokumente unten sind als
|
||||||
|
> Recherche-Hintergrund weiterhin lesenswert, aber nicht mehr aktueller Plan.
|
||||||
|
> Übergeordnet: [ROADMAP.md](../ROADMAP.md) (Vision & Phasen, ebenfalls historisch) ·
|
||||||
|
> [CONVENTIONS.md](../CONVENTIONS.md) (Konventionen) · [ARCHITECTURE.md](../ARCHITECTURE.md).
|
||||||
|
|
||||||
|
Dieses Verzeichnis bündelt die Recherche- und Design-Dokumente für `cad`, die
|
||||||
|
eigenständige Browser-Variante des DOSSIER-Rhino-Plugins (React + TypeScript +
|
||||||
|
Three.js + SVG, alles client-side). **Leitprinzip aller Dokumente:** ein
|
||||||
|
semantisches Modell ist die einzige Wahrheit; jede Sicht (3D, Grundriss, Schnitt)
|
||||||
|
wird **abgeleitet**, Darstellung erst beim Rendern angewandt. Bezeichner im Code
|
||||||
|
englisch (Vectorworks-Terminologie), Prosa deutsch, Einheiten intern in Metern.
|
||||||
|
|
||||||
|
Die Dokumente sind in vier Gruppen geordnet: **Tech** (Bibliotheken/Kernel),
|
||||||
|
**Architektur/Design** (Aufbau & Bauteile), **UX** (Oberfläche & Interaktion),
|
||||||
|
**Swisstopo/SIA** (CH-Geodaten & Flächenstandards).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech — Technologie- & Bibliotheksauswahl
|
||||||
|
|
||||||
|
### [research/tech-selection.md](research/tech-selection.md)
|
||||||
|
Evaluiert den kompletten Client-Stack für ein serverloses BIM-Werkzeug und
|
||||||
|
empfiehlt **`replicad`** (idiomatische TS-Schicht über `opencascade.js`/OCCT, MIT)
|
||||||
|
als primären B-Rep-Kernel im Web Worker, ergänzt durch **`Manifold`** (Apache-2.0)
|
||||||
|
für schnelle, robuste Mesh-Booleans auf Importgeometrie — denn nur ein echter
|
||||||
|
B-Rep-Kernel liefert exakte 2D-Ableitungen, und genau das löst replicads
|
||||||
|
`drawProjection` (OCC-HLR, `{visible, hidden}`-Kanten direkt im Browser). Weitere
|
||||||
|
Wahl: Import via **web-ifc + Fragments** (IFC), `dxf-parser` (DXF) und
|
||||||
|
`libredwg-web` (DWG, aber **GPL-3.0 → vorab klären/kapseln**); Vektor-Export über
|
||||||
|
**`svg2pdf.js` + `jsPDF`** (PDF) und **`@tarikjabiri/dxf`** (echte Hatch-Entities);
|
||||||
|
Schraffuren als SVG-`<pattern>` mit `userSpaceOnUse` (maßstabskorrekt); Rendering
|
||||||
|
über **`three/webgpu`** mit automatischem WebGL2-Fallback. Top-Risiken: DWG-Lizenz,
|
||||||
|
OCCT-WASM-Größe, HLR-Kosten (pro Ansicht cachen), WebGPU vor Migration benchmarken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur/Design — Aufbau, Datenmodell & Bauteile
|
||||||
|
|
||||||
|
### [../ARCHITECTURE.md](../ARCHITECTURE.md)
|
||||||
|
Die übergreifende Standalone-Architektur und die systematische Übersetzung jedes
|
||||||
|
DOSSIER-Konzepts in ein Browser-Äquivalent (30-zeilige **Rhino→Browser-Mapping-
|
||||||
|
Tabelle**). Kern: das semantische `Project` (JSON) als einzige Wahrheit mit pure
|
||||||
|
`derive()` zu Scene3D/Plan/Section; ein **Zwei-Achsen-Datenmodell**
|
||||||
|
(`drawingLevels` × `layers`) plus `Resources`/`WallType`/`Element`/`Sheet`; ein
|
||||||
|
**Zustand-Store** ersetzt DOSSIERs `sc.sticky`-Bus, **`.cad.json`** (File System
|
||||||
|
Access API) + IndexedDB-Autosave ersetzen `doc.Strings`, und ein **Immer-Patch-
|
||||||
|
Undo/Redo** eliminiert die Cache-Stale-Bugs strukturell. Ziel-Repo-Struktur mit
|
||||||
|
**kleinen Bauteil-Modulen** statt des 7244-LOC-`elemente.py`-Monolithen; Rendering
|
||||||
|
über einen `THREE.Group`-Baum, der den Ebenen-Baum spiegelt.
|
||||||
|
|
||||||
|
### [design/parametric-walls.md](design/parametric-walls.md)
|
||||||
|
Regelbasierte Wandgenerierung als Alternative zum Direktzeichnen. Vier Regel-Varianten
|
||||||
|
(`GridRule`, `ModuleRule`, `ConditionalRule`, `PolylineRule`) erzeugen `Wall[]`-Arrays
|
||||||
|
über einen reinen Auflöser (`resolveParametricWall`). Deckungsbereich: Schweizer 3-m-
|
||||||
|
Wohnraster, bedingte Außen-/Innenwand-Dicken, 6-m-Jochbauweise. Phase A: Typsystem +
|
||||||
|
Resolver isoliert, kein UI. Phase B: Command + Formular-Editor. Phase C: Grid-Ressource
|
||||||
|
und IFC-Export.
|
||||||
|
|
||||||
|
### [design/elements.md](design/elements.md)
|
||||||
|
Legt **Daten, Generierung (3D + Plan) und Grip-Editing pro Bauteil** fest. Wichtigste
|
||||||
|
Empfehlung: die **Prioritäts-T-/X-Verschneidung mehrschichtiger Wände** (Backbone-
|
||||||
|
Algorithmus, Port von `_t_junction_layer_overrides`) — das höchstpriorisierte
|
||||||
|
gemeinsame Material läuft durch, der Rest mitert an; Priorität sitzt am **Component**
|
||||||
|
(`joinPriority` als Daten, nicht Hardcode). Deckt zudem gehostete Öffnungen mit
|
||||||
|
LoD-Stufen (`_OEFF_PIECE_DEFS`), Decken mit Aussparungen, Treppen (gerade/L/Wendel,
|
||||||
|
geschossübergreifend, normgerechtes 2D-Symbol), Dächer, Tragwerk und **SIA-416-Räume**
|
||||||
|
(Shoelace-Fläche, Stempel, Färbung über Override-Preset) ab; das `Tool`-Interface +
|
||||||
|
Snap-Engine ersetzt DOSSIERs Rhino-Command-Aliases.
|
||||||
|
|
||||||
|
### [design/plans-output.md](design/plans-output.md)
|
||||||
|
Der Weg zu **schönen, normgerechten, druckfertigen 2D-Plänen** (Vektor-PDF). Zentrale
|
||||||
|
Erkenntnis: Ansichten = Kamera + optionaler Schnitt, und es gibt **zwei Plan-Pfade**
|
||||||
|
(symbolischer Grundriss aus Parametern vs. Schnitt/Ansicht via **HLR im Worker**,
|
||||||
|
gecacht). Empfiehlt SVG/Paper-Space als Maßstabsmodell — Strichstärke/Schraffur sind
|
||||||
|
direkt in mm definiert (`dpi = 96·devicePixelRatio`, Hatch-Faktor `sqrt(N)/10`), was
|
||||||
|
DOSSIERs fragiles Plotweight-Rescaling überflüssig macht. Behandelt außerdem
|
||||||
|
Ausschnitte/View-Snapshots, Layer-Kombinationen, Kamera-Presets + Norden-Rotation,
|
||||||
|
Bemaßung sowie Sheets + Vektor-PDF-Export (`svg2pdf.js`/`jsPDF`, `PAPER_MM`).
|
||||||
|
|
||||||
|
### [design/resources-graphics.md](design/resources-graphics.md)
|
||||||
|
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Component-/Hatch-/Line-
|
||||||
|
Manager, alles per id referenziert), die `resolveStyle`-Kette
|
||||||
|
(ByLayer → Element-Style → Override) und die **regelbasierte Overrides-Engine**.
|
||||||
|
Schlüssel-Empfehlung: Overrides als **reine Render-Reads** modellieren (kein
|
||||||
|
Backup/Restore wie in DOSSIER, da nichts mutiert wird) — inklusive eines
|
||||||
|
**SIA-416-Presets** statt hartcodierter Färbung. Ergänzt Symbol-Bibliothek,
|
||||||
|
Rich-Text-Annotationen, den LoD-Resolver (`resolveDetail`) und den Section-Style für
|
||||||
|
geschnittene Bauteile; eine Tabelle zeigt, was der Browser hier gegenüber DOSSIER
|
||||||
|
vereinfacht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX — Oberfläche, Interaktion & gefühlte Geschwindigkeit
|
||||||
|
|
||||||
|
### [research/ux-patterns.md](research/ux-patterns.md)
|
||||||
|
Untersucht UX-Muster moderner Browser-CAD/BIM-Tools (Arcol, Snaptrude, TestFit,
|
||||||
|
Onshape, Vectorworks, Figma) und leitet **priorisierte Leitplanken** ab. Empfehlung
|
||||||
|
für die Grundstruktur: eine feste, Figma-artige **3-Zonen-Shell**
|
||||||
|
(Navigator/Viewport/Inspector) — explizit gegen Paletten-Wildwuchs —, mit
|
||||||
|
Vectorworks-Navigation-Tabs für unsere zwei Achsen und einem zwei/drei-spaltigen
|
||||||
|
Resource-Manager als Vorbild. Größte Differenzierungs-Hebel laut Doku:
|
||||||
|
**Snapping/Inferencing** im Onshape-Stil (Vertex-Highlights, Achsenlinien, Shift
|
||||||
|
unterdrückt) und **Grip-Editing über Sicht-Grenzen** (Schnittlinie im Plan ziehen);
|
||||||
|
dazu perceived-performance-Muster (Skeletons, optimistic UI, 150-ms-Delay-then-show),
|
||||||
|
eine Command-Palette (Cmd/Ctrl-K) und learn-by-doing-Onboarding am Sample-Projekt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swisstopo/SIA — Schweizer Geodaten & Flächenstandards
|
||||||
|
|
||||||
|
### [research/swisstopo-sia.md](research/swisstopo-sia.md)
|
||||||
|
Dokumentiert die **live getesteten** geo.admin.ch-Dienste und die SIA-Flächenlogik.
|
||||||
|
Überraschendster Befund: **alles ist ohne eigenen Backend-Proxy nutzbar** — alle vier
|
||||||
|
Hosts senden `access-control-allow-origin: *`, und der Height-Service antwortet
|
||||||
|
faktisch frei. Schlüssel fürs Browser-Gelände-Mesh ist **swissALTI3D als Cloud-
|
||||||
|
Optimized GeoTIFF** (Range-Requests via `geotiff.js`, kein Full-Download); die
|
||||||
|
**Parzelle** kommt direkt als LV95-Polygon + EGRID aus dem Identify-Service. Empfiehlt
|
||||||
|
einen konkreten Library-Satz (`proj4`, `geotiff`, `3DTilesRendererJS`/`loaders.gl`)
|
||||||
|
und ordnet die Umsetzung in ROADMAP-Phasen ein (Phase 2 SIA-Räume = reine Logik →
|
||||||
|
Phase 4a Koordinaten → 4b Gelände/Orthofoto → 4c Nachbargebäude). SIA-Teil:
|
||||||
|
verifizierte SIA-416-Formeln, DOSSIERs SIA-Logik 1:1 portierbar (Shoelace,
|
||||||
|
`compute_sia_bilanz`, CSV mit BOM); Origin-Shift (LV95 → 0/0/0) ist Pflicht wegen
|
||||||
|
float32-Jitter, Caching über IndexedDB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-5 Querschnitts-Empfehlungen für die ROADMAP
|
||||||
|
|
||||||
|
Diese fünf Punkte tauchen in mehreren Dokumenten auf und sollten die ROADMAP-Planung
|
||||||
|
und Priorisierung leiten:
|
||||||
|
|
||||||
|
1. **Pure-Ableitungs-Architektur als unverhandelbares Fundament** — ein
|
||||||
|
semantisches Modell, alle Sichten abgeleitet, Darstellung erst beim Rendern.
|
||||||
|
Trägt ARCHITECTURE.md, beide Plan-/Stil-Designs und die UX-Doku (billiger
|
||||||
|
Split-View, optimistic Edits, kein Cache-Stale-/Override-Restore-Aufwand). Muss
|
||||||
|
früh stehen (Store + Undo, Phase 0–1), weil sie alles Spätere prägt.
|
||||||
|
|
||||||
|
2. **OCCT/replicad im Web Worker früh als Spike absichern** — der B-Rep-Kernel und
|
||||||
|
sein `drawProjection`-HLR sind der kritische Pfad für Schnitt/Ansicht (Risiko #4)
|
||||||
|
*und* für exakte Wand-Booleans (Risiko #1) *und* für IFC. WASM-Größe, HLR-Kosten
|
||||||
|
(pro Ansicht cachen) und das Worker-Pattern sollten vor Phase 3 mit einer echten
|
||||||
|
Szene validiert werden.
|
||||||
|
|
||||||
|
3. **Component-getriebene Prioritäts-Verschneidung (Backbone-T/X) als zentrales
|
||||||
|
Geometrie-Risiko** — `joinPriority` als Daten am Component; höchstes gemeinsames
|
||||||
|
Material läuft durch, Rest mitert. Verbindet elements.md + resources-graphics.md;
|
||||||
|
2D-Plan rein analytisch, exakte 3D-Booleans im Worker. Stufenweise umsetzen
|
||||||
|
(Risiko #1, Phase 1).
|
||||||
|
|
||||||
|
4. **SVG/Paper-Space-Maßstabsmodell + maßstabskorrekte Schraffuren durchgängig** —
|
||||||
|
Strichstärke/Text/Hatch in mm, `dpi = 96·devicePixelRatio`, Hatch `sqrt(N)/10`,
|
||||||
|
SVG-`<pattern>` mit `userSpaceOnUse`. Eliminiert DOSSIERs Plotweight-Rescaling und
|
||||||
|
speist denselben Serializer für Bildschirm, PDF und DXF (tech-selection +
|
||||||
|
plans-output + resources-graphics).
|
||||||
|
|
||||||
|
5. **Schweiz-Spezifika als Differenzierer ohne Backend-Last** — SIA-416-Bilanz
|
||||||
|
(reine Logik, Phase 2, ⭐) und der serverlose Swisstopo-Flow (CORS-offen,
|
||||||
|
COG-Terrain, Parzelle/EGRID, Norden-Rotation, Origin-Shift). Klein im Aufwand,
|
||||||
|
groß im CH-Marktwert; SIA-Färbung läuft über das Override-Preset, nicht über
|
||||||
|
Sonderpfade.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Backend & Kollaboration — Architekturentscheidung
|
||||||
|
|
||||||
|
> Stand: 2026-06-29 · Ziel: komplett self-hosted, kollaborations-offen
|
||||||
|
|
||||||
|
## Grundsatz
|
||||||
|
So lange wie möglich **client-only** bleiben; das Backend additiv einführen, ohne
|
||||||
|
den Kern umzubauen. Die Pure-Ableitungs-Architektur (ein serialisierbares Modell,
|
||||||
|
alle Sichten abgeleitet) ist bereits kollaborations-freundlich.
|
||||||
|
|
||||||
|
## Phasen
|
||||||
|
| Phase | Persistenz / Backend |
|
||||||
|
|---|---|
|
||||||
|
| **0–3** (Modellierer) | **Client-only**: IndexedDB + Datei-Export/Import (JSON). Offline-fähig (PWA möglich). Kein Server. |
|
||||||
|
| **5** (Konten/Persistenz) | **Supabase self-hosted** (Docker Compose): Postgres + Auth + Storage. Projekte, Versionen, Dateien (IFC/Pläne/Assets). Row-Level-Security pro Nutzer/Projekt. |
|
||||||
|
| **6** (Kollaboration) | **Yjs (CRDT)** + **Hocuspocus** Sync-Server (Container), persistiert Snapshots nach Postgres. Presence/Cursors. Optional Supabase-Realtime nur für leichte Broadcasts. |
|
||||||
|
|
||||||
|
## Warum Yjs/Hocuspocus statt reinem Supabase-Realtime
|
||||||
|
Gleichzeitiges Editieren eines strukturierten Dokuments braucht Konfliktauflösung
|
||||||
|
(CRDT). Yjs ist dafür Standard; Hocuspocus ist der self-hostbare Server dazu und
|
||||||
|
kann nach Postgres (Supabase) persistieren. Supabase-Realtime allein wäre nur
|
||||||
|
Pub/Sub ohne Merge-Semantik.
|
||||||
|
|
||||||
|
## Was wir JETZT schon richtig machen (damit Collab nicht blockiert)
|
||||||
|
- Dokumentmodell rein **JSON-serialisierbar**, keine Zyklen, stabile IDs.
|
||||||
|
- Edits immutable über `setProject` → später leicht auf Yjs-Doc abbildbar
|
||||||
|
(`Y.Map`/`Y.Array` je Sammlung: drawingLevels, layers, components, walls …).
|
||||||
|
- Kein Wahrheits-Zustand im Three.js-Scene-Graph oder im DOM — alles ableitbar.
|
||||||
|
- Ressourcen (Components/Hatches/Lines) als referenzierte Bibliotheken (IDs) →
|
||||||
|
gut mergebar.
|
||||||
|
|
||||||
|
## Self-hosted Stack (Skizze, Phase 5/6)
|
||||||
|
```
|
||||||
|
docker-compose:
|
||||||
|
supabase (postgres, gotrue auth, storage, kong gateway, studio)
|
||||||
|
hocuspocus (yjs websocket sync, persist -> postgres)
|
||||||
|
web (vite build, statisch via nginx/caddy)
|
||||||
|
```
|
||||||
|
Alles auf eigener Infrastruktur lauffähig; keine externe Cloud nötig.
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
- Granularität der CRDT-Struktur (pro Sammlung vs. pro Element).
|
||||||
|
- Datei-Storage (Supabase Storage vs. S3-kompatibel/MinIO im selben Stack).
|
||||||
|
- Auth-Modell (E-Mail, OIDC/SSO fürs Büro).
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
# BIM-Elementtiefe — Tür, Fenster, Dach, Decke ("wirklich 1:1")
|
||||||
|
|
||||||
|
## 0. Zweck und Abgrenzung
|
||||||
|
|
||||||
|
DOSSIER modelliert Bauteile heute semantisch (kein reines Zeichenprogramm) und
|
||||||
|
hat für Wand/Decke bereits eine mehrschichtige Aufbaulogik (`Component[]` via
|
||||||
|
`WallType`/`CeilingType`). Türen und Fenster haben seit
|
||||||
|
`docs/design/window-editor-vectorworks-study.md` einen dedizierten,
|
||||||
|
phasierten Ausbauplan (Flügeltabelle, Verglasung, Sonnenschutz). Was fehlt,
|
||||||
|
ist die gleiche Tiefe für **Dach** (keine Schichtlogik, keine Mansard-
|
||||||
|
Untertypen, kein Kehl-/Gaubenmodell) und für die **Decken-Randterminierung**
|
||||||
|
(Deckenrand als reines Polygon ohne Kantendetail).
|
||||||
|
|
||||||
|
Dieses Dokument nimmt die vier Bauteile Tür, Fenster, Dach, Decke und hält
|
||||||
|
sie gegen die reale Tiefe vollständiger BIM-Programme (Vectorworks Architektur,
|
||||||
|
ArchiCAD, Revit, Allplan). Ziel ist NICHT, jedes Feature dieser Programme zu
|
||||||
|
kopieren, sondern zu benennen, welche Lücken einen echten "1:1-Zuwachs" für
|
||||||
|
ein Einfamilienhaus-/Wohnbau-Tool wie DOSSIER bringen — und welche reiner
|
||||||
|
Ballast wären (Abschnitt 6).
|
||||||
|
|
||||||
|
Für Fenster/Tür dupliziert dieses Dokument NICHT die Mapping-Tabelle aus
|
||||||
|
`window-editor-vectorworks-study.md` — es referenziert sie und ergänzt, was
|
||||||
|
dort fehlt (v. a. Tür-Tiefe, die die Studie nur am Rand behandelt, und die
|
||||||
|
architektonischen Grenzen des heutigen Öffnungsmodells). Für Wand-Schichtlogik
|
||||||
|
siehe `docs/design/parametric-walls.md` (Raster/Modul-Regeln, nicht
|
||||||
|
Gegenstand hier) und `docs/design/elements.md` (ältere Gesamtplanung, Stand
|
||||||
|
vor der aktuellen `Component`/`Layer`-Implementierung — dort abweichende
|
||||||
|
Typnamen wie `Slab`/`ProfileDef`, hier durchgängig der IST-Code zitiert).
|
||||||
|
|
||||||
|
Alle IST-Aussagen sind mit Datei:Zeile belegt (verifiziert per Lesen des
|
||||||
|
Codes, Stand dieses Commits). Status-Legende der Lücken-Tabellen: **✓**
|
||||||
|
vorhanden und gerendert · **~** Feld existiert, Renderer liest es nicht oder
|
||||||
|
nur grob · **✗** fehlt vollständig. Priorität P0 (grösster 1:1-Zuwachs, bald)
|
||||||
|
… P3 (Ballast, nur auf Nachfrage). Aufwand grob in Personentagen (PT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gemeinsames Fundament: das Schicht-Muster
|
||||||
|
|
||||||
|
Der zentrale Baustein, den DOSSIER bereits hat und der sich wiederverwenden
|
||||||
|
lässt, ist `Component`/`Layer`:
|
||||||
|
|
||||||
|
- `Component` (`src/model/types.ts:196`) — ein Bauteil-Material: Poché-Farbe
|
||||||
|
(`color`/`foreground`/`background`), Schnitt-Schraffur (`hatchId`) UND
|
||||||
|
Ansichts-Schraffur (`viewHatchId`, für unaufgeschnittene Aufsicht),
|
||||||
|
optionales PBR-Material (`material`), Kürzel (`abbrev`) und ein
|
||||||
|
Verschneidungs-Rang (`joinPriority`, `types.ts:241`) für die Boolean-
|
||||||
|
Dominanz am Stoss.
|
||||||
|
- `Layer` (`types.ts:245`) — eine Schicht: `componentId` + `thickness` +
|
||||||
|
optionaler Fugen-Linienstil (`jointLineStyleId`).
|
||||||
|
- `WallType` (`types.ts:260`) und `CeilingType` (`types.ts:273`) sind BEIDE
|
||||||
|
nur `{ id, name, layers: Layer[] }` — bewusst derselbe Typ, "das
|
||||||
|
horizontale Gegenstück zum WallType" (Kommentar `types.ts:266-271`).
|
||||||
|
|
||||||
|
Das Muster ist also bereits zweimal (Wand, Decke) verifiziert:
|
||||||
|
`ceilingThickness()` (`types.ts:2104`) summiert die Layer-Dicken,
|
||||||
|
`emitSlabs()` (`src/plan/toWalls3d.ts:1352-1419`) stapelt sie im 3D als
|
||||||
|
einzelne `RSlab`-Scheiben proportional in `[zBottom, zTop]`, `addCeilingPoche`
|
||||||
|
(`src/plan/generatePlan.ts:2453`) zeichnet die Aufsicht mit der
|
||||||
|
Ansichts-Schraffur der ERSTEN Schicht, und `toSection.ts:633`
|
||||||
|
(`splitSlabLayers`) zerlegt die Decke im ECHTEN Schnitt in
|
||||||
|
Einzel-Bänder je Schicht (mit `resolveCeilingSectionStyle`,
|
||||||
|
`generatePlan.ts:591`, als Schraffur-/Farbquelle).
|
||||||
|
|
||||||
|
**Der Dach-Vorschlag in Abschnitt 4 ist im Kern: dasselbe Muster ein drittes
|
||||||
|
Mal anwenden.** Das ist der günstigste Weg zu echter Dach-1:1-Tiefe, weil
|
||||||
|
Layer-Resolver, Schraffur-Ketten (`resolveHatch`/`resolveForeground` etc.,
|
||||||
|
`generatePlan.ts:409-536`) und die 3D-Stapel-Logik bereits bestehen und nur
|
||||||
|
auf einen neuen Aufbau-Typ angewendet werden müssen statt neu erfunden.
|
||||||
|
|
||||||
|
Architektonische Randbemerkung: `Project` führt bereits `wallTypes`,
|
||||||
|
`ceilingTypes?`, `doorTypes?`, `windowTypes?`, `stairTypes?`
|
||||||
|
(`types.ts:1936-1958`) als eigene Bibliotheken — aber **kein `roofTypes?`**.
|
||||||
|
`Roof` (`types.ts:1022`) trägt nur ein einzelnes `thickness: number`
|
||||||
|
(`types.ts:1044`), keinen Aufbau-Verweis. Das ist die strukturelle Lücke,
|
||||||
|
die Abschnitt 4 schliesst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Tür (Door)
|
||||||
|
|
||||||
|
### 2.1 Was ein vollständiges BIM-Tool bietet
|
||||||
|
|
||||||
|
| Bereich | Typische Parameter (VW/ArchiCAD/Revit/Allplan) |
|
||||||
|
|---|---|
|
||||||
|
| Bauart | Dreh-, Schiebe- (auf/vor Wand), Falt-, Pendel-, Karusselltür, reiner Durchbruch |
|
||||||
|
| Blattzahl/-teilung | 1-/2-flügelig, Gangflügel + Standflügel (unterschiedliche Breite), Seitenteile links/rechts |
|
||||||
|
| Blattausführung | glatt, kassettiert, Glasfüllung (Anteil/Sprossenbild), Brandschutz-/Schallschutz-Kennwert |
|
||||||
|
| Rahmen/Zarge | Zarge vs. Blockrahmen, Rahmenbreite je Kante, Zargentiefe, Bekleidung/Abdeckleiste, Falz |
|
||||||
|
| Schwelle | ohne, Alu-Flachschwelle, Anschlagdichtung, Bodenanschluss/Gefälle bei Aussentüren |
|
||||||
|
| Sturz/Oberlicht | festverglastes Oberlicht mit eigenem Rahmen, Kämpfer, Sprossenbild |
|
||||||
|
| Seitenteile | fest verglaste Seitenteile links/rechts, eigene Breite |
|
||||||
|
| Form | rechteckig, Rundbogen, Segmentbogen, Stichbogen — bei Aussen-/Haustüren verbreitet |
|
||||||
|
| Beschlag | Drücker/Knauf-Typ, Schild, Schliesszylinder, Bänder sichtbar/verdeckt |
|
||||||
|
| 2D-Darstellung | Blatt + Schwenkbogen (Grundriss), eigene Ansichtssymbolik in Schnitt/Elevation, Sturzlinien |
|
||||||
|
| Material/Schichten | Blatt-Kernaufbau (bei Brand-/Schallschutztüren mehrschichtig, analog Wand) |
|
||||||
|
| IFC-Rolle | `IfcDoor` mit `IfcDoorType` (PredefinedType), `OverallWidth/Height`, `IfcDoorPanelProperties`, Void in der Wirtswand |
|
||||||
|
|
||||||
|
### 2.2 IST in DOSSIER
|
||||||
|
|
||||||
|
`Opening` mit `kind: "door"` (`src/model/types.ts:1068`) referenziert
|
||||||
|
optional einen `DoorType` (`types.ts:296`). Vorhanden am Typ: `kind`
|
||||||
|
("dreh"/"schiebe"/"wandoeffnung", :303), `leafCount` (1|2, :305), `leafStyle`
|
||||||
|
("glatt"/"kassette"/"glas", :307), `glazingRatio` (:309), `frameThickness`/
|
||||||
|
`frameDepth`/`frameKind`("zarge"/"blockrahmen")/`frameWidth`
|
||||||
|
(:311-328), `insetFromFace`/`insetFace` (:335-337), `transomHeight` (:343),
|
||||||
|
`threshold` (:349). Am Element selbst: `swing`/`hinge`/`swingAngle`/
|
||||||
|
`openingDir` (:1104-1108), `lintelLines` (Sturzlinien, :1119) und
|
||||||
|
`doorType: "normal"|"wandoeffnung"` (:1100) — ein zweites, mit `DoorType.kind`
|
||||||
|
teilweise redundantes Feld.
|
||||||
|
|
||||||
|
Renderer-Konsum, real geprüft:
|
||||||
|
|
||||||
|
- **2D-Blatt ist IMMER einflügelig.** `addOpeningSymbol` (Tür-Zweig,
|
||||||
|
`src/plan/generatePlan.ts:2091-2213`) zeichnet genau EINE Blattlinie
|
||||||
|
(`sym.hinge → sym.openEnd`) und EINEN Schwenkbogen — `DoorType.leafCount`
|
||||||
|
wird an keiner Stelle in `generatePlan.ts` gelesen (kein Treffer für
|
||||||
|
`leafCount` im ganzen Plan-Renderer). Eine zweiflügelige Tür sieht im Plan
|
||||||
|
aus wie eine einflügelige.
|
||||||
|
- **3D genauso**: `resolveOpeningFrame` (`src/plan/toWalls3d.ts:1861-1886`)
|
||||||
|
setzt für Türen hart `wingCount: 1`, unabhängig von `leafCount`.
|
||||||
|
- `leafStyle` wirkt NUR binär: `"glas"` schaltet eine volle Verglasung frei
|
||||||
|
(`glazed: dt.leafStyle === "glas"`, `toWalls3d.ts:1883`) — `glazingRatio`
|
||||||
|
(Teilverglasung, z. B. 60 % Glasanteil im oberen Blattbereich) wird an
|
||||||
|
keiner Stelle gelesen. "kassette" (Kassettentür) hat keine eigene Geometrie,
|
||||||
|
fällt auf dieselbe Quader-Darstellung wie "glatt" zurück.
|
||||||
|
`frameKind: "blockrahmen"` wirkt NUR im 2D-Rahmenband
|
||||||
|
(`generatePlan.ts:1996`, `isBlock`), im 3D gibt es keinen Unterschied zur
|
||||||
|
Zarge (`frameMeshesForOpening`, `toWalls3d.ts:1936`, kennt kein
|
||||||
|
`frameKind`).
|
||||||
|
- `threshold` steuert `hasSill` im 3D-Rahmen (`toWalls3d.ts:1880`), was einen
|
||||||
|
einfachen Schwellen-Riegel zeichnet — kein eigenes Schwellenprofil,
|
||||||
|
keine Gefälle-/Dichtungsdarstellung.
|
||||||
|
- Es gibt eine ZWEITE, ältere Tür-Repräsentation: `Door`
|
||||||
|
(`types.ts:1333`, `project.doors: Door[]`) mit eigenem Symbol-Renderer
|
||||||
|
`addDoorSymbol` (`generatePlan.ts:1840-1899`) — strukturell identisch zum
|
||||||
|
`Opening`-Pfad, aber ohne jeden Typ-Bezug. Zwei parallele Datenwege für
|
||||||
|
dieselbe Bauteilart sind selbst technische Schuld, nicht nur ein
|
||||||
|
BIM-Feature-Gap.
|
||||||
|
- Form (Rundbogen etc.) existiert nicht: `Opening.width`/`height` sind ein
|
||||||
|
reines Rechteck, `wallGaps`/`buildWallFootprints`
|
||||||
|
(`generatePlan.ts:1363-1457`) schneiden nur rechteckige Bänder aus der
|
||||||
|
Wand-Poché.
|
||||||
|
- IFC-Export: `IfcDoor` wird erzeugt (`src/export/exportIfc.ts:30-31`), aber
|
||||||
|
als reine Box-Geometrie ohne `IfcDoorType`/`PredefinedType` und ohne
|
||||||
|
`IfcOpeningElement`-Void (bewusste Design-Entscheidung, siehe Kommentar
|
||||||
|
`exportIfc.ts:18-31`: das Loch steckt bereits im geschnittenen Wand-Mesh).
|
||||||
|
|
||||||
|
### 2.3 Lücken (Tür)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Zweiflügelige Tür rendert 2 Blätter (2D+3D) | ✗ (`leafCount` ungelesen) | **P0** | 2 PT |
|
||||||
|
| Teilverglasung nach `glazingRatio` (2D-Linie + 3D-Split) | ✗ | P1 | 1.5 PT |
|
||||||
|
| Kassettentür eigene Blattgeometrie (Füllungsfelder) | ✗ | P2 | 2 PT |
|
||||||
|
| `frameKind` (Blockrahmen) auch im 3D wirksam | ~ | P1 | 1 PT |
|
||||||
|
| Seitenteile (feste Verglasung links/rechts der Tür) | ✗ | P1 | 2 PT |
|
||||||
|
| Rundbogen-/Segmentbogen-Türform | ✗ | P2 | 4 PT (braucht gekrümmten Wandausschnitt, s. §5.3) |
|
||||||
|
| Schwellenprofil (Alu-Flachschwelle, Dichtung) statt Riegel | ✗ | P2 | 1 PT |
|
||||||
|
| `Door`/`Opening`-Doppelpfad konsolidieren | technische Schuld | P1 | 3 PT (Migration) |
|
||||||
|
| Beschlag (Drücker/Knauf) als Mesh + 2D-Symbol | ✗ | P2 | 1.5 PT |
|
||||||
|
| IFC `IfcDoorType`/PredefinedType/OverallWidth-Height-Properties | ~ | P2 | 1 PT |
|
||||||
|
|
||||||
|
### 2.4 Umsetzungsvorschlag
|
||||||
|
|
||||||
|
**Modell**: `DoorType.leafs?: { width: number; hingeSide: "left"|"right";
|
||||||
|
fixed?: boolean }[]` analog `WindowType.sashes` (`SashDef`, `types.ts:367`) —
|
||||||
|
bewusst dieselbe Struktur, damit `resolveSashSpans` (`toWalls3d.ts:2019`) UND
|
||||||
|
die 2D-Pfostenlinien-Logik direkt wiederverwendet werden können, statt eine
|
||||||
|
Tür-eigene Variante zu bauen. Ein `fixed: true`-Leaf ist das Seitenteil.
|
||||||
|
`glazingRatio` wandert vom Skalar zu einer klaren Geometrie: Kämpferhöhe
|
||||||
|
innerhalb des Blatts, gerendert wie das bestehende Oberlicht
|
||||||
|
(`transomHeight`), nur INNERHALB des Blattrahmens statt darüber.
|
||||||
|
|
||||||
|
**2D**: `addOpeningSymbol` (Tür-Zweig) über die Leaf-Liste iterieren statt
|
||||||
|
einer festen Blattlinie; pro Leaf ein eigenes `hinge`/`swing` (Default:
|
||||||
|
alternierend wie bei Fenstern, `sashesOfWindowType`, `types.ts:2132`).
|
||||||
|
|
||||||
|
**3D**: `resolveOpeningFrame` liefert `wingCount = leafs.length` statt hart 1;
|
||||||
|
`frameMeshesForOpening` (bereits generisch über `params.sashes`) übernimmt
|
||||||
|
die Mehrflügel-Darstellung ohne Änderung — das ist der Vorteil der
|
||||||
|
Struktur-Wiederverwendung.
|
||||||
|
|
||||||
|
**UI**: Im Tür-Editor (sofern nach dem Muster von
|
||||||
|
`window-editor-vectorworks-study.md` gebaut) eine Flügeltabelle wie bei
|
||||||
|
Fenstern, nur mit Tür-Vokabular (Gangflügel/Standflügel statt Flügel 1/2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fenster (Window)
|
||||||
|
|
||||||
|
Für Fenster existiert bereits eine vollständige Referenzmatrix in
|
||||||
|
`docs/design/window-editor-vectorworks-study.md` §2 (Basiseinstellungen,
|
||||||
|
Grösse, Brüstung, Rahmen, Flügeltabelle, Laibung/Form/Ober-Unterlicht,
|
||||||
|
Sonnenschutz/Beschlag, Attribute/IFC) mit eigener P0–P3-Phasierung. Diese
|
||||||
|
Studie ist der massgebliche Bezugspunkt; hier nur die Delta-Punkte, die dort
|
||||||
|
fehlen oder seither vom IST abweichen.
|
||||||
|
|
||||||
|
### 3.1 IST-Ergänzung (was die Studie nicht/knapp behandelt)
|
||||||
|
|
||||||
|
- `WindowType.glazing` (einfach/zweifach/dreifach) ist bis heute NICHT
|
||||||
|
renderwirksam — bestätigt weiterhin: `glazingPanesOf()` (`types.ts:2149`)
|
||||||
|
wird von `glassPanesForOpening` in `toWalls3d.ts` konsumiert, aber die
|
||||||
|
Studie selbst vermerkt (Zeile 61-65 dort), dass der 2D-Pfad die
|
||||||
|
Scheibenzahl weiterhin allein aus `DetailLevel` ableitet, nicht aus
|
||||||
|
`glazingPanes`. Das ist über ein Jahr nach der Studie noch offen — ein
|
||||||
|
Hinweis, dass P0/P1-Posten aus Fenster-Studien real liegen bleiben, wenn
|
||||||
|
niemand sie explizit nachzieht.
|
||||||
|
- `sillBoard` (Fensterbank keine/innen/aussen/beide, `types.ts:447`) ist
|
||||||
|
weiterhin ungenutzt (kein Treffer für `sillBoard` in `toWalls3d.ts` oder
|
||||||
|
`generatePlan.ts` ausserhalb der Typ-Definition und des Editors) —
|
||||||
|
entspricht dem in der Studie als P1 markierten "Fensterbank erstellen".
|
||||||
|
|
||||||
|
### 3.2 Architektonische Grenze: gekrümmte Öffnungen
|
||||||
|
|
||||||
|
Sowohl die Fenster-Studie (§2.6, "Form Eckig/Schräg/Spitz/Rund", P2, 4 PT)
|
||||||
|
als auch dieser Auftrag nennen Rundbogen-/Spitzbogenfenster. Das ist teurer,
|
||||||
|
als die Aufwandschätzung suggeriert, weil das gesamte Öffnungsmodell auf
|
||||||
|
GERADEN Bändern basiert: `buildWallFootprints`/`wallGaps`
|
||||||
|
(`generatePlan.ts:1363-1457`) schneiden ein rechteckiges Intervall `[from,to]`
|
||||||
|
entlang der Wandachse aus der Poché, `openingAxisBox`
|
||||||
|
(`toWalls3d.ts:1739`) baut im 3D ebenso einen achsparallelen Quader. Eine
|
||||||
|
Bogenform braucht entweder (a) eine gekrümmte Zusatzkontur, die die
|
||||||
|
Rechteck-Aussparung oben kappt (2D: zusätzliche Polygon-Boolean gegen die
|
||||||
|
Poché; 3D: gekrümmte Deckfläche statt ebenem Sturz) oder (b) ein komplett
|
||||||
|
neues, polygonbasiertes Öffnungsmodell. Vorschlag: (a) zuerst — ein
|
||||||
|
`headShape: "eckig"|"segment"|"spitz"|"rund"` mit Zusatzparametern
|
||||||
|
(Stichhöhe/Radius), das NUR die obere Kante der bestehenden Rechteck-Öffnung
|
||||||
|
ersetzt, während Pfosten/Sohlbank rechteckig bleiben. Deckt die reale
|
||||||
|
Mehrheit der Fälle (Haustür mit Rundbogen, Dachflächenfenster-Giebel) ohne
|
||||||
|
das Kernmodell umzubauen.
|
||||||
|
|
||||||
|
### 3.3 Lücken (Fenster, Delta zur Studie)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `glazing`/`glazingPanes` 2D-wirksam (Scheibenzahl statt nur DetailLevel) | ~ (weiter offen seit Studie) | **P0** | 1 PT |
|
||||||
|
| `sillBoard` gerendert (2D-Kontur + 3D-Box) | ✗ | P1 | 2 PT |
|
||||||
|
| Kopfform (Rundbogen/Spitzbogen/Schräge) über Zusatzkontur | ✗ | P2 | 5 PT (s. §3.2) |
|
||||||
|
| Echtes Sprossengitter (Glasteilung UNABHÄNGIG von Flügelrahmen) | ✗ | P1 | 2 PT |
|
||||||
|
| Alle übrigen Fenster-Lücken | siehe window-editor-vectorworks-study.md §2/§4 | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dach (Roof)
|
||||||
|
|
||||||
|
### 4.1 Was ein vollständiges BIM-Tool bietet
|
||||||
|
|
||||||
|
| Bereich | Typische Parameter |
|
||||||
|
|---|---|
|
||||||
|
| Grundform | Pult, Sattel, Walm, Krüppelwalm, Zeltdach, Mansarde (mit Untertyp Giebel-/Walm-/Zeltmansarde), Flach/Terrassendach, Sheddach, Tonnendach, freie Neigungsflächen je Kante |
|
||||||
|
| Grundriss | beliebiges Polygon (nicht nur Rechteck), automatische Kehlen/Grate über Straight-Skeleton bei L-/T-/U-Grundrissen |
|
||||||
|
| Neigung | je Dachfläche einzeln editierbar, unterschiedliche Neigungen je Seite |
|
||||||
|
| Schichtaufbau | Eindeckung (Ziegel/Blech/Bitumen), Lattung, Konterlattung, Unterdach/-spannbahn, Sparren/Dämmung zwischen Sparren, Dampfbremse, Innenverkleidung — analog Wandaufbau, mit Deckenanschluss |
|
||||||
|
| Überstand | Traufe und Ortgang UNABHÄNGIG editierbar (Betrag + Ausbildung), Aufschiebling (Neigungsknick am Traufende), Ortganddetail (Windbrett, Blech) |
|
||||||
|
| Kniestock/Drempel | vertikale Wandaufkantung zwischen Deckenoberkante und Dach-Traufpunkt, definiert First-/Trauf-Geometrie mit |
|
||||||
|
| Öffnungen im Dach | Dachflächenfenster (schräg, in der Dachebene), Dachgauben (Schlepp-, Sattel-, Walm-, Spitzgaube, Fledermausgaube) als eigenständige Sekundärdächer mit eigenem First |
|
||||||
|
| First/Grat/Kehl/Ortgang | eigene Linientypen in 2D-Ansicht (Dachaufsicht) UND im Schnitt sichtbar (Sparrenlage, Dämmstärke) |
|
||||||
|
| Material/Poché | Dachfläche in der Aufsicht mit Eindeckungs-Symbol/-Schraffur (Ziegel-Textur o. Ä.), im Schnitt Vollschichten wie eine geneigte Wand |
|
||||||
|
| IFC-Rolle | `IfcRoof` (aggregiert `IfcRoofType`), Dachflächen selbst oft als `IfcSlab`-artige Elemente mit `IfcMaterialLayerSetUsage`, Gauben als eigene `IfcRoof`/`IfcBuildingElementProxy`-Unterobjekte |
|
||||||
|
|
||||||
|
### 4.2 IST in DOSSIER
|
||||||
|
|
||||||
|
`Roof` (`src/model/types.ts:1022-1047`) rechnet ausschliesslich auf der
|
||||||
|
**Bounding-Box** des Umrisses (Kommentar `types.ts:1016-1020`: "First entlang
|
||||||
|
einer Hauptachse ... die gängige, intuitive Vereinfachung"). Geometrie kommt
|
||||||
|
aus `roofGeometry()`/`computeCanonical()` (`src/geometry/roof.ts:80-267`),
|
||||||
|
explizit **"ohne Straight-Skeleton"** (Kommentar `roof.ts:5`). Unterstützte
|
||||||
|
`RoofShape` (`types.ts:1013`): flach/pult/sattel/walm/mansarde/zelt — je EINE
|
||||||
|
feste Berechnung, keine Untertypen. Mansarde hat eine feste, nicht editierbare
|
||||||
|
Knick-Geometrie (`d1 = halfD * 0.4`, `roof.ts:181` — 40 % der Tiefe von der
|
||||||
|
Traufe, hart codiert, keine Möglichkeit den Umbruchpunkt zu verschieben).
|
||||||
|
|
||||||
|
Konkrete, verifizierte Lücken:
|
||||||
|
|
||||||
|
- **Kein Schichtaufbau.** `Roof.thickness` (`types.ts:1044`) ist ein
|
||||||
|
einzelner Skalar. Er wird an KEINER Stelle im Code gelesen (`grep
|
||||||
|
"roof.thickness"` über `toWalls3d.ts`/`generatePlan.ts`/`roof.ts` liefert
|
||||||
|
null Treffer) — das Feld existiert im Typ, ist aber komplett tot. Die
|
||||||
|
3D-Dachfläche ist eine unendlich dünne, einfarbige Fläche
|
||||||
|
(`emitRoofs()`, `src/plan/toWalls3d.ts:1670-1684`: nur `plane.pts`/
|
||||||
|
`gable`-Fans, EINE Farbe `ROOF_RGB` bzw. `roof.color`, keine Dicke, keine
|
||||||
|
Materialschichten).
|
||||||
|
- **Kein `RoofType`.** `Project` hat `wallTypes`, `ceilingTypes?`,
|
||||||
|
`doorTypes?`, `windowTypes?`, `stairTypes?` (`types.ts:1936-1958`), aber
|
||||||
|
kein `roofTypes?`. Es gibt keinen Bauteil-Bibliothekseintrag für Dächer.
|
||||||
|
- **2D-Grundriss zeigt keine Poché.** `addRoof()`
|
||||||
|
(`src/plan/generatePlan.ts:2549-2605`) zeichnet AUSSCHLIESSLICH Linien
|
||||||
|
(Traufe/First/Grat/Knick, je feste Strichstärke `ROOF_EAVES_MM`/
|
||||||
|
`ROOF_RIDGE_MM`/`ROOF_HIP_MM`) — keine Fläche, keine Schraffur, keine
|
||||||
|
Materialkennzeichnung. Deckungsgleich mit Wand/Decke, die BEIDE eine
|
||||||
|
Poché-Füllung mit Schraffur haben (`addWallPoche`, `addCeilingPoche`),
|
||||||
|
bleibt das Dach in der Aufsicht ein reines Liniendiagramm.
|
||||||
|
- **Kein Schnitt.** `src/plan/toSection.ts` hat KEINE Roof-Behandlung (kein
|
||||||
|
Treffer für `roof`/`Roof` im gesamten Datei-Grep). Ein Vertikalschnitt
|
||||||
|
durch ein Gebäude mit Satteldach zeigt heute keine Dachlinie, keine
|
||||||
|
Sparrenlage, keine Firstprojektion — ein Kernstück der BIM-1:1-Erwartung
|
||||||
|
fehlt vollständig.
|
||||||
|
- **Kein IFC.** `exportIfc.ts` erzeugt `IfcWall`, `IfcSlab`, `IfcDoor`/
|
||||||
|
`IfcWindow`, `IfcStair`, `IfcBuildingElementProxy` (Kommentar
|
||||||
|
`exportIfc.ts:18-36`) — `Roof`/`IfcRoof` ist in der Abbildungsliste NICHT
|
||||||
|
aufgeführt und wird beim Export komplett übersprungen.
|
||||||
|
- **Kein Straight-Skeleton, kein L-Grundriss mit Kehle.** Ein L-förmiger
|
||||||
|
Baukörper mit durchgehendem Satteldach (Kehle an der Innenecke) lässt sich
|
||||||
|
nicht abbilden — die BBox-Rechnung würde ein Rechteck über die ganze
|
||||||
|
L-Ausdehnung legen.
|
||||||
|
- **Keine Gauben, keine Dachfenster.** Kein Treffer für Gaube/Dormer im
|
||||||
|
gesamten `src`-Baum (verifiziert per Suche).
|
||||||
|
- **Kein Kniestock als Bauteilbeziehung.** `baseElevation` (`types.ts:1042`)
|
||||||
|
erlaubt zwar, die Traufhöhe manuell über die Geschoss-Oberkante zu heben
|
||||||
|
(ein Kommentar in `ObjectInfoPanel.tsx:549` erwähnt das explizit als
|
||||||
|
Nutzungsmuster), aber es gibt keine Wand-Dach-Kopplung, die einen
|
||||||
|
Drempel/Kniestock als eigenes, vermasstes Bauteil führt — der Nutzer muss
|
||||||
|
die Zahl manuell abstimmen.
|
||||||
|
- **Traufe/Ortgang nicht unabhängig.** `overhang` (`types.ts:1038`) ist EIN
|
||||||
|
Wert "ringsum" — Traufüberstand und Ortgangüberstand (oft unterschiedlich,
|
||||||
|
z. B. 0.5 m Traufe / 0.3 m Ortgang) sind nicht trennbar.
|
||||||
|
- **UI**: `ObjectInfoPanel.tsx:479-561` bietet volle Instanz-Bearbeitung
|
||||||
|
(shape/ridgeAxis/pitch/pitchUpper/width/depth/overhang/thickness/
|
||||||
|
baseElevation), aber keine Typ-/Stil-Verwaltung (kein `ResourceManager`-
|
||||||
|
Eintrag für Dächer, anders als Wand/Decke/Tür/Fenster/Treppe).
|
||||||
|
|
||||||
|
### 4.3 Dach-Schichtlogik — konkreter Vorschlag (Kernthema dieses Dokuments)
|
||||||
|
|
||||||
|
Der Vorschlag überträgt exakt das `WallType`/`CeilingType`-Muster:
|
||||||
|
|
||||||
|
```
|
||||||
|
export interface RoofType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Aussen (Eindeckung) → innen (Verkleidung), analog WallType.layers. */
|
||||||
|
layers: Layer[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein neuer Layer-Typ nötig — `Layer` (`types.ts:245`) ist bereits
|
||||||
|
"Bauteil + Dicke + optionaler Fugen-Linienstil", unabhängig davon ob sie
|
||||||
|
horizontal (Decke), vertikal (Wand) oder GENEIGT (Dach) gestapelt wird, weil
|
||||||
|
die Stapel-Richtung beim jeweiligen Renderer entschieden wird, nicht im
|
||||||
|
Datentyp. Typische Schichtfolge eines Steildachs (Eindeckung → Konterlattung
|
||||||
|
→ Lattung → Unterdach/Unterdeckbahn → Sparren+Dämmung → Dampfbremse →
|
||||||
|
Innenverkleidung/GKB) bildet sich 1:1 auf `Layer[]` ab, jede Schicht bekommt
|
||||||
|
ein `Component` mit eigener Schraffur/Farbe/Material wie bei Wand/Decke.
|
||||||
|
|
||||||
|
`Roof.thickness: number` wird zu `Roof.roofTypeId?: string` (Verweis, analog
|
||||||
|
`Ceiling.ceilingTypeId`) mit optionaler `thicknessOverride?: number` — exakt
|
||||||
|
das Muster aus `Ceiling.thickness?` (`types.ts:952-953`, "Optionale
|
||||||
|
Übersteuerung der Gesamtdicke ... sonst Typ-Dicke"). `Project.roofTypes?:
|
||||||
|
RoofType[]` ergänzt die Bibliothek.
|
||||||
|
|
||||||
|
**3D-Konsum**: `emitRoofs()` (`toWalls3d.ts:1670`) baut heute EINE
|
||||||
|
Dreiecksfläche je Dachfläche+Giebel. Mit Layern wird daraus — analog
|
||||||
|
`emitSlabs()` (`toWalls3d.ts:1352-1419`, das bereits genau diese
|
||||||
|
Proportional-Stapel-Logik für Decken hat) — ein Stapel PARALLEL versetzter
|
||||||
|
Flächen entlang der Flächennormalen (nicht entlang Z wie bei der Decke,
|
||||||
|
sondern entlang der Dachflächen-Normalen `n`, siehe `RoofGeometry.planes`
|
||||||
|
in `roof.ts:19-21`). Jede Schicht wird zum eigenen `RMesh` mit eigener Farbe/
|
||||||
|
Schraffur-Metadaten (`RCutMeta`, wie bei `emitSlabs`). Der Versatz macht
|
||||||
|
zugleich die Dachdicke sichtbar (heute unendlich dünn) — ein Nebengewinn ohne
|
||||||
|
Mehraufwand.
|
||||||
|
|
||||||
|
**2D-Konsum (Grundriss/Aufsicht)**: `addRoof()` bekommt eine Poché-Fläche
|
||||||
|
analog `addCeilingPoche` — Füllung mit der `viewHatchId` der obersten Schicht
|
||||||
|
(Eindeckungssymbol), gerahmt von den bestehenden Traufe/First/Grat/Knick-
|
||||||
|
Linien (die bleiben unverändert, sie sind flächen-unabhängig).
|
||||||
|
|
||||||
|
**2D-Konsum (Schnitt, der grössere Umbau)**: `toSection.ts` braucht einen
|
||||||
|
neuen Roof-Zweig. Ansatz: die Dachebene mit der Schnittebene schneiden
|
||||||
|
(Ebene-Ebene-Schnitt, da `RoofPlane` eben ist), daraus ein Liniensegment je
|
||||||
|
betroffener Dachfläche gewinnen, dann `Layer[]` senkrecht ZUR
|
||||||
|
Dachneigung als Bandsequenz auftragen (wie `resolveWallBands`,
|
||||||
|
`toWalls3d.ts:521`, aber gedreht um den Neigungswinkel `pitchDeg`) und mit
|
||||||
|
`splitSlabLayers`-Logik (`toSection.ts:633`) füllen. Das ist der aufwendigste
|
||||||
|
Einzelposten dieses Dokuments (siehe Prioritätsliste), aber ohne ihn bleibt
|
||||||
|
"Dach im Schnitt" eine reine Lücke.
|
||||||
|
|
||||||
|
**UI**: Ein `RoofType`-Eintrag im `ResourceManager`
|
||||||
|
(`src/ui/ResourceManager.tsx`), identisch zum bestehenden Ceiling-Typ-Editor
|
||||||
|
(Schicht-Liste, Dicke, Bauteil-Zuweisung) — kein neues UI-Paradigma.
|
||||||
|
|
||||||
|
### 4.4 Dach 1:1 — Formen, Kehlen, Gauben (konkreter Vorschlag)
|
||||||
|
|
||||||
|
Reihenfolge nach Aufwand/Nutzen, NICHT alles auf einmal:
|
||||||
|
|
||||||
|
1. **Traufe/Ortgang trennen**: `overhang` → `{ eaves: number; gable: number
|
||||||
|
}`. Kleine, lokale Änderung in `roof.ts` (zwei statt einer Offset-Variable
|
||||||
|
je nach Kantentyp), grosser optischer Gewinn (das ist die häufigste
|
||||||
|
Rückmeldung "sieht nicht echt aus" bei Steildächern mit gleich langem
|
||||||
|
Überstand allseitig).
|
||||||
|
2. **Mansard-Untertyp** (`mansardVariant: "walm"|"giebel"|"zelt"`, wie in der
|
||||||
|
älteren Planung `elements.md:298` bereits vorgesehen, aber nie gebaut):
|
||||||
|
steuert nur, wie die STIRNSEITE der Mansarde behandelt wird (heute IMMER
|
||||||
|
`gables` = vertikale Giebelfläche, `roof.ts:200-203`) — bei "walm" wird
|
||||||
|
daraus eine geneigte Fläche wie beim Walmdach. Mittlerer Aufwand, da die
|
||||||
|
Mansard-Berechnung (`roof.ts:176-206`) bereits alle Eckpunkte hat, nur die
|
||||||
|
Gable-Erzeugung muss konditional werden.
|
||||||
|
3. **Editierbarer Mansard-Knickpunkt** (`kinkDepthRatio?: number` statt hart
|
||||||
|
`0.4`, `roof.ts:181`) — 0.5 PT, reine Parametrisierung einer bestehenden
|
||||||
|
Konstante.
|
||||||
|
4. **Krüppelwalm** (`hipTruncation?: number`, 0 = voller Walm, 1 = voller
|
||||||
|
Giebel/Sattel): der First bleibt voll lang, nur ein kleines Walmstück am
|
||||||
|
First-Ende — technisch eine Variation der bestehenden `walm`-Berechnung
|
||||||
|
(`roof.ts:147-174`, der First-Verkürzungs-Faktor `halfD` wird
|
||||||
|
parametrisiert statt fix).
|
||||||
|
5. **Dachflächenfenster** (kein neues Bauteil — ein `Opening`-ähnliches
|
||||||
|
Element, das in eine Dachfläche statt eine Wand einschneidet): neuer
|
||||||
|
`hostRoofId`-Pfad, eigenständiger Vorschlag; lohnt sich erst NACH der
|
||||||
|
Schichtlogik, weil das Fenster sonst nicht "in die Dämmebene" passt.
|
||||||
|
6. **Gauben** (Schlepp-/Sattelgaube als eigenständiges Sekundär-`Roof` mit
|
||||||
|
eigenem `outline`/`baseElevation`, das in die Hauptdachfläche einschneidet):
|
||||||
|
grösster Einzelposten, weil er eine echte Boolean-Verschneidung zwischen
|
||||||
|
zwei Dachkörpern braucht (ähnlich der bestehenden Wand-Boolean-Dominanz
|
||||||
|
über `joinPriority`, aber räumlich in 3D). Realistisch erst nach einem
|
||||||
|
Mesh-Boolean-Werkzeug (die begonnene truck-Integration,
|
||||||
|
`src-tauri/trucksolid/`, ist ein Kandidat dafür).
|
||||||
|
7. **L-/T-Grundriss mit Kehle (Straight-Skeleton)**: bewusst NICHT vor 6,
|
||||||
|
weil es die Bounding-Box-Vereinfachung komplett ersetzt (neue
|
||||||
|
Geometrie-Engine, kein inkrementeller Ausbau von `roof.ts`) — separates,
|
||||||
|
grosses Vorhaben, siehe Prioritätsliste.
|
||||||
|
|
||||||
|
### 4.5 Lücken (Dach)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RoofType`/`Layer[]`-Schichtaufbau (Modell) | ✗ (`thickness` toter Skalar) | **P0** | 3 PT |
|
||||||
|
| 3D-Schichten (gestapelte Flächen entlang Normalen) | ✗ | **P0** | 3 PT |
|
||||||
|
| 2D-Aufsicht-Poché (Eindeckungssymbol) | ✗ | P1 | 1.5 PT |
|
||||||
|
| Dach im Vertikalschnitt (Ebene-Ebene-Schnitt + Bänder) | ✗ | **P0** | 5 PT |
|
||||||
|
| Traufe/Ortgang getrennter Überstand | ✗ | **P0** | 1 PT |
|
||||||
|
| Mansard-Untertyp (Walm/Giebel/Zelt-Stirn) | ✗ | P1 | 2 PT |
|
||||||
|
| Editierbarer Mansard-Knick | ✗ | P2 | 0.5 PT |
|
||||||
|
| Krüppelwalm | ✗ | P2 | 1.5 PT |
|
||||||
|
| Dachflächenfenster | ✗ | P2 | 4 PT |
|
||||||
|
| Gauben (Boolean-Einschnitt) | ✗ | P3 | 8+ PT |
|
||||||
|
| L-/T-Grundriss, Straight-Skeleton-Kehle | ✗ | P3 | 10+ PT |
|
||||||
|
| `RoofType`-Ressourcen-UI | ✗ | P1 (folgt aus RoofType) | 1 PT |
|
||||||
|
| IFC `IfcRoof`-Export | ✗ | P2 | 1.5 PT |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Decke (Ceiling / Slab)
|
||||||
|
|
||||||
|
### 5.1 Was ein vollständiges BIM-Tool bietet
|
||||||
|
|
||||||
|
| Bereich | Typische Parameter |
|
||||||
|
|---|---|
|
||||||
|
| Grundfläche | beliebiges Polygon inkl. Aussparungen (Treppenauge, Schacht, Kamindurchbruch) |
|
||||||
|
| Schichtaufbau | Rohdecke, Trittschalldämmung, Estrich, Bodenbelag — analog Wand, mit Deckenspiegel/Untersicht (abgehängte Decke, Akustikplatten) als eigene Schicht(en) |
|
||||||
|
| Randausbildung | gerader Rand, auskragender Balkon-/Vordachrand mit thermischer Trennung (Isokorb/Randdämmstreifen), Randschalung/Abschalungsprofil, Attika-Anschluss, Tropfkante |
|
||||||
|
| Deckenspiegel | abgehängte Untersicht mit eigener Höhe/Raster (Akustik-/Gipskarton-Decke), UNABHÄNGIG von der tragenden Rohdecke |
|
||||||
|
| Öffnungen | Deckenaussparungen als eigene, editierbare Polygone (nicht nur Gesamtumriss) |
|
||||||
|
| Neigung | geneigte Decke (Garagenrampe, Terrasse mit Gefälle) — nicht nur horizontal |
|
||||||
|
| 2D-Darstellung | Aufsicht mit Ansichts-Poché (unaufgeschnitten), Schnitt mit Vollschichten je Lage, Deckenspiegel-Plan (reflected ceiling plan) als eigene Zeichnungsart |
|
||||||
|
| IFC-Rolle | `IfcSlab` (PredefinedType FLOOR/ROOF/BASESLAB), `IfcMaterialLayerSetUsage` für den Schichtaufbau, `IfcCovering` für abgehängte Decken |
|
||||||
|
|
||||||
|
### 5.2 IST in DOSSIER
|
||||||
|
|
||||||
|
`Ceiling` (`types.ts:928-1000`) hat bereits die stärkste Tiefe der vier
|
||||||
|
Bauteile in diesem Dokument: geschlossenes Umriss-Polygon (`outline`),
|
||||||
|
`ceilingTypeId` mit Legacy-Fallback auf `wallTypeId` (`getCeilingType`,
|
||||||
|
`types.ts:2089`), volle Attribut-Override-Kette (`foreground`/`background`/
|
||||||
|
`strokeWeight`/`hatchId` + `*Source`, wie bei `Wall`) und unabhängige
|
||||||
|
vertikale Bindung von OK/UK über `VerticalAnchor` (`top?`/`bottom?`,
|
||||||
|
`types.ts:990-999` — "floor"-gebunden oder "custom"-Z, exakt wie bei
|
||||||
|
`Wall.top`/`Wall.bottom`, `types.ts:887-893`). 3D-Schichtstapel ist
|
||||||
|
implementiert (`emitSlabs`, `toWalls3d.ts:1352-1419`), Schnitt-Schichtsplit
|
||||||
|
ebenfalls (`splitSlabLayers`, `toSection.ts:633`).
|
||||||
|
|
||||||
|
Verifizierte Lücken:
|
||||||
|
|
||||||
|
- **Keine Aussparungen.** `Ceiling.outline: Vec2[]` ist EIN geschlossenes
|
||||||
|
Polygon (`types.ts:936-939`) — kein `openings?: Vec2[][]` für
|
||||||
|
Treppenauge/Schacht/Kamin. Ein Treppenloch in der Decke muss heute über
|
||||||
|
die Aussenkontur der Decke "herumgeschnitten" werden (Decke als
|
||||||
|
komplexes, nicht-konvexes Polygon), nicht als saubere Innenaussparung.
|
||||||
|
(`elements.md:217` sah dieses Feld in der älteren Planung explizit vor,
|
||||||
|
es wurde nie in `types.ts` übernommen.)
|
||||||
|
- **Kein Randdetail.** Die Decke ist über die gesamte `outline` exakt
|
||||||
|
`thickness` dick, EINHEITLICH. Es gibt kein Feld für eine abweichende
|
||||||
|
Randausbildung (Aufkantung, Randdämmstreifen, Tropfkante, andere Dicke am
|
||||||
|
Balkonrand). Ein auskragender Balkon lässt sich zwar über eine erweiterte
|
||||||
|
`outline` modellieren, bekommt aber zwangsläufig denselben Vollschicht-
|
||||||
|
Aufbau wie die Innendecke — eine thermisch getrennte Balkonplatte
|
||||||
|
(Isokorb) ist nicht abbildbar.
|
||||||
|
- **Kein Deckenspiegel.** Abgehängte Untersicht (Akustik-/GKB-Decke mit
|
||||||
|
eigener, tieferer Kote) existiert nicht als eigenes Konzept — nur der
|
||||||
|
tragende Aufbau über `ceilingTypeId`.
|
||||||
|
Ein "Deckenspiegel-Plan" (reflected ceiling plan) fehlt als Zeichnungsart
|
||||||
|
komplett (`DrawingLevelKind`, `types.ts:722`, kennt nur "floor"/"section"/
|
||||||
|
"elevation"/"drawing").
|
||||||
|
(Randbemerkung: Beleuchtungsplanung/Deckenspiegel ist ein Elektro-Thema und
|
||||||
|
damit bewusst ausserhalb des DOSSIER-Kernscopes — siehe "nicht bauen",
|
||||||
|
Abschnitt 6. Die reine Geometrie einer zweiten, tiefer liegenden Fläche
|
||||||
|
bleibt aber ein legitimer BIM-1:1-Punkt.)
|
||||||
|
- **Keine Neigung.** `top`/`bottom` sind je EIN `VerticalAnchor` (ein
|
||||||
|
Z-Wert), keine Neigungsebene — eine geneigte Garagen-/Terrassendecke ist
|
||||||
|
nicht modellierbar, nur über mehrere ebene Teildecken behelfsweise
|
||||||
|
annäherbar.
|
||||||
|
- **2D-Aufsicht nutzt nur die erste Schicht.** `addCeilingPoche`
|
||||||
|
(`generatePlan.ts:2453-2536`) liest `wt.layers[0]` (Kommentar/Code
|
||||||
|
`generatePlan.ts:2465`) für die Ansichts-Schraffur — bei einer
|
||||||
|
mehrschichtigen Decke (z. B. Beton unten, Dämmung oben) zeigt die Aufsicht
|
||||||
|
immer nur die OBERSTE (erste) Schicht, was für eine unaufgeschnittene
|
||||||
|
Draufsicht baupraktisch korrekt ist (man sieht von unten die
|
||||||
|
Untersicht/erste Lage), aber nicht konfigurierbar ist, welche Lage als
|
||||||
|
"sichtbare" gilt, falls der Deckenaufbau umgekehrt sortiert wäre.
|
||||||
|
- IFC: `IfcSlab` wird erzeugt (`exportIfc.ts:28`), aber laut Kopfkommentar
|
||||||
|
(`exportIfc.ts:38-41`) bewusst OHNE `IfcMaterialLayerSet`/-`Usage` — der
|
||||||
|
Export verliert den Schichtaufbau, den DOSSIER intern bereits hat.
|
||||||
|
|
||||||
|
### 5.3 Deckenrand/Randterminierung — vertieft
|
||||||
|
|
||||||
|
Der Nutzer nennt explizit "Deckenränder, Stirnabschlüsse, Anschluss an Wand/
|
||||||
|
Aussenkante, auskragende Ränder, Randabschalung" als Schwerpunkt. IST-Bild:
|
||||||
|
keines davon existiert als eigenes Konzept — die Decke ist ein reines
|
||||||
|
Extrusions-Polygon. Konkreter Vorschlag, dreistufig nach Aufwand:
|
||||||
|
|
||||||
|
1. **Aussparungen** (`Ceiling.openings?: Vec2[][]`) — niedrigster Aufwand,
|
||||||
|
grösster praktischer Nutzen (Treppenauge ist im Wohnbau der Regelfall,
|
||||||
|
nicht die Ausnahme). 2D: zusätzliche Ausschnitts-Polygone in
|
||||||
|
`addCeilingPoche` (Loch im Fill, zusätzliche Randlinien). 3D: `emitSlabs`
|
||||||
|
bekommt Löcher im Extrusions-Profil (analog dem bereits vorhandenen
|
||||||
|
Loch-Schnitt bei Wand-Öffnungen, `wallMeshCut.ts`, als Vorlage).
|
||||||
|
2. **Randschicht-Override** (`Ceiling.edgeOverride?: { ringOffset: number;
|
||||||
|
ceilingTypeId: string }` — ein schmaler Innenring der Decke entlang des
|
||||||
|
Randes bekommt einen ANDEREN Layer-Aufbau, z. B. mit zusätzlicher
|
||||||
|
Randdämmschicht oder reduzierter Dicke für eine Tropfkante). Technisch:
|
||||||
|
`emitSlabs` erzeugt für den Ringbereich einen zweiten Layer-Stapel mit dem
|
||||||
|
Override-Typ, geometrisch als Offset-Polygon-Differenz (`outline` minus
|
||||||
|
`outline.offset(-ringOffset)`), eine Operation, die für Wandbänder
|
||||||
|
bereits ähnlich existiert (`buildWallFootprints`).
|
||||||
|
3. **Thermisch getrennte Auskragung (Isokorb-Fall)**: ein eigenes,
|
||||||
|
sekundäres `Ceiling`-Objekt für den auskragenden Teil mit eigenem
|
||||||
|
`ceilingTypeId` (dünnerer/anderer Aufbau) UND eigener `top`/`bottom`-
|
||||||
|
Bindung, das an die Hauptdecke stösst — kein neues Feld nötig, nur eine
|
||||||
|
UI-Erleichterung ("Deckenrand abtrennen"-Werkzeug, das die Decke entlang
|
||||||
|
einer gewählten Kante in zwei `Ceiling`-Objekte teilt). Niedrigster
|
||||||
|
Modell-Aufwand, weil er das bestehende Mehrfach-Decken-Prinzip nutzt statt
|
||||||
|
ein neues Konzept einzuführen.
|
||||||
|
|
||||||
|
### 5.4 Lücken (Decke)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Aussparungen (`openings?: Vec2[][]`) in 2D+3D | ✗ | **P0** | 3 PT |
|
||||||
|
| Randschicht-Override (Ringzone anderer Aufbau) | ✗ | P1 | 3 PT |
|
||||||
|
| Deckentrenn-Werkzeug für Isokorb-Fall (UI, kein neues Modellfeld) | ✗ | P1 | 1.5 PT |
|
||||||
|
| Geneigte Decke (Rampe/Gefälle) | ✗ | P2 | 3 PT |
|
||||||
|
| Deckenspiegel (zweite, abgehängte Fläche) | ✗ | P3 | 2 PT (reine Geometrie) |
|
||||||
|
| IFC `IfcMaterialLayerSetUsage` für Decke (UND Wand) | ~ (bewusst ausgelassen) | P2 | 2 PT |
|
||||||
|
| Konfigurierbare "oberste Schicht" für Aufsicht-Poché | ✓ implizit (Layer-Reihenfolge = Sortierung) | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Konsolidierte Priorisierung (über alle vier Bauteile)
|
||||||
|
|
||||||
|
Sortiert nach "1:1-Zuwachs pro Aufwand", nicht nach Aufwand allein:
|
||||||
|
|
||||||
|
1. **Dach-Schichtaufbau (Modell + 3D-Stapel)** — schliesst die grösste
|
||||||
|
strukturelle Lücke (totes `thickness`-Feld, kein `RoofType`) und liefert
|
||||||
|
sofort sichtbaren Tiefengewinn im 3D (§4.3, ~6 PT gesamt).
|
||||||
|
2. **Traufe/Ortgang getrennter Überstand** — 1 PT, sofortiger optischer
|
||||||
|
Sprung bei jedem Steildach-Projekt (§4.4 Punkt 1).
|
||||||
|
3. **Zweiflügelige Tür rendert wirklich 2 Blätter** — das `leafCount`-Feld
|
||||||
|
existiert seit der Türtyp-Einführung, wird aber komplett ignoriert; sehr
|
||||||
|
sichtbarer Bug-artiger Gap (§2.3, 2 PT).
|
||||||
|
4. **Deckenaussparungen** — Treppenauge ist der Wohnbau-Regelfall, heute nur
|
||||||
|
über Umweg (nicht-konvexes Aussenpolygon) lösbar (§5.3 Punkt 1, 3 PT).
|
||||||
|
5. **Fenster-Glazing 2D-wirksam** — seit über einem Jahr als P0 in der
|
||||||
|
Fenster-Studie dokumentiert und weiterhin offen; niedriger Aufwand,
|
||||||
|
sollte nicht liegen bleiben (§3.1, 1 PT).
|
||||||
|
6. **Dach im Vertikalschnitt** — grösster Einzelposten (5 PT), aber ohne ihn
|
||||||
|
bleibt jeder Gebäudeschnitt mit Steildach unvollständig; das ist die
|
||||||
|
Art Lücke, die bei einer Bemusterung/Baueingabe sofort auffällt.
|
||||||
|
7. **Deckenrand-Override / Isokorb-Trennwerkzeug** — folgt danach, weil er
|
||||||
|
auf demselben Mehrfach-Decken-Prinzip aufbaut wie Punkt 4.
|
||||||
|
8. **Mansard-Untertyp + editierbarer Knick** — mittlere Priorität, weil
|
||||||
|
Mansarde in der Schweiz/Süddeutschland baupraktisch häufig ist und die
|
||||||
|
heutige starre 40 %-Konstante sichtbar unrealistisch wirkt.
|
||||||
|
9. **Tür/Fenster-Rahmentiefe** (asymmetrische Rahmenbreiten, Beschlag,
|
||||||
|
Kopfform) — bewusst NACH den strukturellen Lücken, weil sie additive
|
||||||
|
Detailverbesserungen an einem bereits funktionierenden Pfad sind, während
|
||||||
|
1–7 fehlende oder falsch dargestellte Kernfunktionen betreffen.
|
||||||
|
10. **Gauben, L-Grundriss/Kehle, Dachflächenfenster** — grösste Einzel-
|
||||||
|
Aufwände (8–10+ PT), architektonisch am voraussetzungsreichsten (Boolean-
|
||||||
|
Werkzeug bzw. neue Geometrie-Engine); erst nach 1–9 angehen.
|
||||||
|
|
||||||
|
### 6.1 NICHT bauen (VW/Revit-Ballast ohne Nutzen für DOSSIER)
|
||||||
|
|
||||||
|
- **Vollständiger Massketten-/Bezugsapparat** (VW B1..B5/H1..H7,
|
||||||
|
Roh-/Fertigmass-Umschaltung) — DOSSIER arbeitet mit lichten Massen, das
|
||||||
|
genügt für ein Schweizer Wohnbau-/Kleinprojekt-Tool (bereits so in
|
||||||
|
`window-editor-vectorworks-study.md` §5 entschieden, hier bestätigt für
|
||||||
|
Dach/Decke: keine Dach-Rohmass-/Fertigmass-Unterscheidung).
|
||||||
|
- **Sichtbarkeitsmatrix 3D-Objekte × Ansichten** (Augen-Tabelle je
|
||||||
|
Kategorie×Ansicht×Detailstufe) — DOSSIERs Layer-Sichtbarkeit +
|
||||||
|
`DetailLevel` deckt den praktischen Bedarf; eine volle Matrix ist
|
||||||
|
Verwaltungsaufwand ohne Mehrwert für Einzelprojekte.
|
||||||
|
- **Eckfenster/Eckdach als generischer Sonderfall über zwei Wirtsbauteile**
|
||||||
|
— seltene Geometrie, hoher Modellierungsaufwand (zwei Hosts, ein Element);
|
||||||
|
bei Bedarf als manueller Workaround (zwei separate Öffnungen) lösbar.
|
||||||
|
Ebenso: freie Neigungsflächen je Dachkante (VW erlaubt jede Kante einzeln
|
||||||
|
zu kippen) — für die abgedeckten Standardformen (Pult/Sattel/Walm/
|
||||||
|
Mansarde/Zelt/Krüppelwalm) nicht nötig; wer eine Freiform-Dachlandschaft
|
||||||
|
braucht, ist besser mit den `ExtrudedSolid`/truck-Werkzeugen bedient.
|
||||||
|
- **Vollständige Beschlags-/Baubeschlag-Bibliothek** (Marken-Beschlagsätze,
|
||||||
|
Schliessplan) — Beschlag als generisches Griff-Mesh (§2.4) genügt für die
|
||||||
|
visuelle 1:1-Wirkung; eine Beschlags-PRODUKTBIBLIOTHEK ist Kataloggeschäft,
|
||||||
|
kein CAD-Kernfeature.
|
||||||
|
- **Deckenspiegel als vollwertige Beleuchtungsplanung** (Leuchtenraster,
|
||||||
|
Lichtberechnung) — reine Geometrie einer zweiten Fläche ist ok (P3), die
|
||||||
|
Elektro-/Lichtplanungslogik selbst liegt ausserhalb des Tool-Zwecks.
|
||||||
|
- **IFC `IfcOpeningElement`/Void-Semantik nachrüsten** — bewusste
|
||||||
|
Design-Entscheidung im bestehenden Export (`exportIfc.ts:22-27`), NICHT
|
||||||
|
revidieren: die heutige "Loch steckt im Mesh"-Lösung liefert visuelle
|
||||||
|
Parität in jedem Viewer ohne Boolean-Pflicht beim Empfänger; der reine
|
||||||
|
IFC4-Purismus (Wand als parametrische Extrusion + Void) würde
|
||||||
|
bestehende, bewusst getroffene Trade-offs zunichtemachen.
|
||||||
|
- **Straight-Skeleton/Kehlen und Gauben SOFORT** — nicht "nicht bauen", aber
|
||||||
|
bewusst zurückgestellt (§6, Punkt 10): ohne die Schichtlogik (Punkt 1)
|
||||||
|
vorher zu bauen, würde jede Kehlen-/Gauben-Lösung auf dem unendlich dünnen,
|
||||||
|
ungeschichteten Dach aufsetzen und müsste bei Einführung der Schichten
|
||||||
|
ohnehin neu gefasst werden.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Kontextmenü & Anzeige-Modi — 1:1 wie DOSSIER
|
||||||
|
|
||||||
|
> Quelle: DOSSIER `src/components/ContextMenu.jsx` + `DrawingLevelsApp.jsx`/Ebenen-Panel.
|
||||||
|
> Maus-Schema (unsere Festlegung): **Mitte = navigieren** (Plan Pan / 3D Orbit, Shift+Mitte Pan) ·
|
||||||
|
> **Links = Auswahl** · **Rechts = Kontextmenü** · **Rad = Zoom**.
|
||||||
|
|
||||||
|
## ContextMenu-Komponente (generisch, wiederverwendbar)
|
||||||
|
`ContextMenu({ x, y, items, onClose, title })`
|
||||||
|
- **item**: `{ label, icon?, onClick, disabled?, danger?, shortcut?, divider? }`
|
||||||
|
- Fixed-Position mit Rand-Clamp (4px); min-width 200px; Radius 13px; weicher Schatten;
|
||||||
|
Mount-Animation `scale(.94) translateY(-5px)` 100ms; Item-Hover = `--accent-dim`;
|
||||||
|
`danger` = rote Schrift; `divider` = 1px Trenner; Titel oben (caps, 10px, muted).
|
||||||
|
- **Schließen:** Klick außerhalb · Escape · erneuter Rechtsklick · nach Item-Klick.
|
||||||
|
- z-index ~300 (unter Modals).
|
||||||
|
|
||||||
|
## Ebenen-Kontextmenü (Rechtsklick auf Ebenen-Zeile)
|
||||||
|
1. **Ebeneneinstellungen…** (`settings`) — Ebenen-Dialog *(Rhino-spez. → vorerst Stub)*
|
||||||
|
2. — Trenner —
|
||||||
|
3. **Sub-Ebene hinzufügen…** (`add`)
|
||||||
|
4. **Selektion hierher übertragen** (`move_down`) *(braucht Auswahl → später)*
|
||||||
|
5. — Trenner —
|
||||||
|
6. **Duplizieren** (`content_copy`) — Klon mit Suffix „ KOPIE"
|
||||||
|
7. **Eigenschaften kopieren** (`colorize`) — Farbe + Linienstärke
|
||||||
|
8. **Eigenschaften einfügen** (`format_paint`) — disabled wenn Clipboard leer
|
||||||
|
9. — Trenner —
|
||||||
|
10. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
|
||||||
|
Titel = Ebenenname/-code.
|
||||||
|
|
||||||
|
## Zeichnungsebenen-Kontextmenü (Rechtsklick auf Geschoss/Schnitt/Zeichnung)
|
||||||
|
1. **Einstellungen…** (`settings`)
|
||||||
|
2. — Trenner —
|
||||||
|
3. **Duplizieren** (`content_copy`) — Klon mit Suffix „ Kopie"
|
||||||
|
4. — Trenner —
|
||||||
|
5. **Löschen** (`delete`, danger) — disabled wenn ≤1 Ebene
|
||||||
|
Titel = Name.
|
||||||
|
|
||||||
|
## „+"-Menü (Zeichnungsebenen)
|
||||||
|
**Geschoss** (`layers`) · **Schnitt / Ansicht** (`content_cut`) · — · **Zeichnung** (`edit_note`)
|
||||||
|
|
||||||
|
## Anzeige-Modi (Dropdown oben im Panel) — **5 Modi** (für Ebenen UND Zeichnungsebenen)
|
||||||
|
| Wert | Label | Verhalten |
|
||||||
|
|---|---|---|
|
||||||
|
| `all_force` | **Alle anzeigen** | alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge → wechselt zu „Ausgewählte" |
|
||||||
|
| `all` | **Ausgewählte** | sichtbar nach per-Zeile-Flag |
|
||||||
|
| `active` | **Nur aktive** | nur aktive sichtbar; andere stark gedimmt |
|
||||||
|
| `grey` | **Andere grau** | aktive normal, andere 45% (Sichtbarkeits-Flags gelten) |
|
||||||
|
| `grey_locked` | **Andere grau & gesperrt** | wie grey + andere gesperrt |
|
||||||
|
Regel: Klick aufs Auge in `all_force`/`active` schaltet automatisch auf `all`.
|
||||||
|
*(Hinweis: Panel-System-Workflow hatte vorerst nur 3 Modi — beim Kontextmenü-Build auf diese 5 angleichen.)*
|
||||||
|
|
||||||
|
## Migration (was sofort geht / was Stub bleibt)
|
||||||
|
- Sofort: ContextMenu-Komponente 1:1; Duplizieren, Eigenschaften kopieren/einfügen, Löschen,
|
||||||
|
+Menü, 5 Anzeige-Modi (alles reine JSON-State-Operationen).
|
||||||
|
- Stub/später: „Ebeneneinstellungen…"/„Einstellungen…" (Dialog), „Sub-Ebene hinzufügen", „Selektion hierher übertragen".
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, E)
|
||||||
|
|
||||||
|
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
|
||||||
|
Einzeilern verweist. Abgleich DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen
|
||||||
|
den aktuellen Browser-Port. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
||||||
|
|
||||||
|
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
|
||||||
|
|
||||||
|
## A — Grösserer Funktionsumfang
|
||||||
|
|
||||||
|
**A1 · ❌ Grafische Overrides — Regel-Engine** (`overrides.py:7-40`)
|
||||||
|
ArchiCAD/Vectorworks-Stil: Regeln der Form `condition{type: layer_name|user_string|
|
||||||
|
object_name, operator: equals|contains|starts_with|not_equals, value, key} →
|
||||||
|
actions{color, lineweight, linetype}`, additiv angewendet, oberste Regel gewinnt,
|
||||||
|
reversibel. Cross-Doc-Presets + Rule-Templates (`list_presets:147`,
|
||||||
|
`list_rule_templates:218`). Der Port hat nur manuelle Per-Instanz-Farbe (By-Layer/
|
||||||
|
By-Object/eigener Wert, kein Regelwerk). Wert: enorm für Architektur-Pläne
|
||||||
|
(Bestand grau, Brandabschnitte, Bauphasen farblich markieren). Aufwand: M-L.
|
||||||
|
|
||||||
|
**A2 · ❌ Ausschnitte / View-Snapshots** (`ausschnitte.py:466-531`)
|
||||||
|
Benannte, gespeicherte Ansichten, die Kamera-Zustand + Layer-Sichtbarkeit +
|
||||||
|
Massstab + Detailgrad einfangen und wiederherstellen; organisiert in Ordnern +
|
||||||
|
Presets (`_capture_camera:78`, `_capture_layers:157`, `_capture:466`). Wert:
|
||||||
|
zentral für Wiederverwendbarkeit und Voraussetzung für A3. Aufwand: L.
|
||||||
|
|
||||||
|
**A3 · ❌ Print-Layout-Blätter** (`layouts.py:7-8`)
|
||||||
|
Layout-Seiten mit mehreren Details, jedes Detail an einen Ausschnitt-Snapshot
|
||||||
|
gebunden (`_BIND_KEY:28`), Papierformate A4/A3, Ordnerstruktur, PDF-Export pro
|
||||||
|
Layout-Blatt. Der Port exportiert aktuell nur eine einzelne Zeichnung nach PDF.
|
||||||
|
Aufwand: L, hängt an A2.
|
||||||
|
|
||||||
|
**A4 · ❌ Tragwerk-Elemente** (`elemente.py`)
|
||||||
|
Stütze, Träger, Unterzug, I-Profil als eigene Elementtypen. Der Port kennt nur
|
||||||
|
Wand/Decke/Öffnung/Treppe/Raum. Wert: vervollständigt den BIM-Elementsatz.
|
||||||
|
Aufwand: M je Typ.
|
||||||
|
|
||||||
|
**A5 · 🟡 Öffnungen viel reicher** (`elemente.py:59-68`)
|
||||||
|
Mehrere Flügel (`OEFF_FLUEGEL`), Sims innen+aussen mit eigenen Stilen
|
||||||
|
(`SIMS_AUS`/`SIMS_IN`), Glas-Toggle (`OEFF_GLAS`), Rahmen-Lage aussen/mittig/innen
|
||||||
|
(`RAHMEN_POS`), Rahmen-Profilbreite/-tiefe, Detailgrad einfach/standard/detail =
|
||||||
|
SIA-400-Darstellung (`OEFF_DARSTELLUNG:68`). Der Port hat nur swing/hinge/
|
||||||
|
frameDepth/frameThickness. Aufwand: M.
|
||||||
|
|
||||||
|
**A6 · 🟡 Element-Schedule / Bauteilliste** (`elemente_uebersicht.py:45,214`)
|
||||||
|
Voller Element-Überblick + SIA-Flächenbilanz + CSV-Export
|
||||||
|
(`_export_bilanz:214`). Der Port hat nur einen Raum-CSV-Export. Aufwand: M.
|
||||||
|
|
||||||
|
## B — Werkzeuge & Objekt-Info
|
||||||
|
|
||||||
|
**B1 · ❌ Text-Platzierungs-Werkzeug** (`text_create.py:972,847,105`)
|
||||||
|
On-Canvas-Text-Objekte als eigenständiges Zeichenwerkzeug (nicht nur
|
||||||
|
Raumstempel): Textstile, Fonts, Rich-Text fett/kursiv/unterstrichen,
|
||||||
|
Ausrichtung, Symbol-Einfügung, „auf Selektion anwenden". Der Port hat den
|
||||||
|
Rich-Text-Editor (`src/text/RichTextEditor.tsx`) bereits, aber kein
|
||||||
|
Platzierungs-Werkzeug, um damit ein freistehendes Textobjekt zu zeichnen
|
||||||
|
(Shortcut „1" ist bewusst noch unbelegt). Aufwand: M.
|
||||||
|
|
||||||
|
**B2 · 🟡 Object-Info numerisch erweitern** (`dimensions.py:342-350,240,267`)
|
||||||
|
Position, Rotation um Z-Achse (`_rotate_around_axis:240`), Kreis-Radius,
|
||||||
|
Linien-Länge (`_set_line_length:267`), Rechteck Breite/Höhe,
|
||||||
|
Koordinatensystem World/CPlane, 9-Punkt-Referenz. Der Port kann nur per
|
||||||
|
Anker-Griff skalieren. Aufwand: S-M.
|
||||||
|
|
||||||
|
**B3 · ❌ Kamera-Presets + Nordwinkel** (`kamera.py:28,36-45,87`)
|
||||||
|
Nicht in HANDOVER.md gelistet, aber im Audit gefunden: Kardinal-Presets
|
||||||
|
(N/O/S/W), Iso-Oktanten, Rotation um einen einstellbaren Nordwinkel
|
||||||
|
(georeferenziert, relevant für Swisstopo-Kontext).
|
||||||
|
|
||||||
|
**B4 · ❌ Massstab-Toolbar-Funktionen** (nicht separat referenziert, Teil der
|
||||||
|
Toolbar-Logik) — Zoom 1:1/auf Selektion, Linienstärken-Set, Grid/Ortho/
|
||||||
|
Referenzlinien-Toggles direkt aus der Symbolleiste.
|
||||||
|
|
||||||
|
## C — Zusätzlich gefunden, nicht in HANDOVER.md
|
||||||
|
|
||||||
|
**C1 · LoD pro Ansicht** — Darstellung einfach/standard/detail je Ansicht
|
||||||
|
umschaltbar (SIA-400-Detailgrad-Konvention), nicht nur global.
|
||||||
|
|
||||||
|
**C2 · Override-Presets + Rule-Templates als Bibliothek** — vertieft A1: die
|
||||||
|
Regeln selbst sind wiederverwendbare, benannte Presets/Templates, nicht nur
|
||||||
|
Ad-hoc-Zustand pro Dokument.
|
||||||
|
|
||||||
|
**C3 · Mass-Style-Presets** — Dezimalstellen/Rundung als benannte,
|
||||||
|
wiederverwendbare Bemassungs-Stile.
|
||||||
|
|
||||||
|
## D — Weitere Backlog-Ergänzungen aus dem Audit
|
||||||
|
|
||||||
|
**D1** Per-Layout-PDF-Export (vertieft A3).
|
||||||
|
**D2** Bauteil-CSV mit vollem Element-Set (vertieft A6).
|
||||||
|
**D3** Komponenten-Thumbnails (visuelle Vorschau im Component-Manager).
|
||||||
|
|
||||||
|
## E — Bewusst NICHT zu portieren
|
||||||
|
|
||||||
|
Rhino-/Windows-gebundene Implementierungsdetails ohne Browser-Äquivalent:
|
||||||
|
Window-Layout-XML-Persistierung, Auto-DPI via CoreGraphics, Custom-Grips-Code
|
||||||
|
(Rhino-SDK-spezifisch), nativer `.3dm`-Geometrie-Import.
|
||||||
|
|
||||||
|
## Priorisierung (aus dem Original-Audit)
|
||||||
|
|
||||||
|
1. A1 (Override-Regel-Engine)
|
||||||
|
2. A2 (View-Snapshots)
|
||||||
|
3. A3 (Print-Layout-Blätter)
|
||||||
|
4. A5 (reichere Öffnungen)
|
||||||
|
5. B1 (Text-Platzierungs-Werkzeug)
|
||||||
|
6. A4 (Tragwerk)
|
||||||
|
7. B2 (Object-Info numerisch)
|
||||||
|
8. A6 (Bauteil-Schedule)
|
||||||
|
|
||||||
|
Siehe auch `ROADMAP.md` §11 für eine noch breitere, unabhängig entstandene
|
||||||
|
Backlog-Liste (4-Wege-Survey über Bauteile/Darstellung/Pläne/Kontext) mit
|
||||||
|
teilweiser Überschneidung.
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
# Aktive Zeichen- und Bearbeitungs-Werkzeuge
|
||||||
|
|
||||||
|
Status: Entwurf. Dieses Dokument spezifiziert das **Tool-System** für das aktive
|
||||||
|
Erzeugen von Modell-Elementen durch Zeichnen im Grundriss: Wände (Achs-Polylinie
|
||||||
|
→ `Wall` eines `WallType`) sowie reine 2D-Geometrie (Linie, Polylinie, Rechteck,
|
||||||
|
Kreis, Bogen, Text). Es definiert die Werkzeug-Zustandsmaschine, die Live-Vorschau
|
||||||
|
(Rubber-Band), das **Snapping** mit Bildschirm-Markern, die Ebenen-/Kategorie-/
|
||||||
|
Stil-Zuordnung neuer Elemente und das neue Element `Drawing2D` samt Ableitung in
|
||||||
|
`generatePlan`.
|
||||||
|
|
||||||
|
Bezugsdokumente: [elements.md](elements.md) (Wand-/Tür-Modell),
|
||||||
|
[resources-graphics.md](resources-graphics.md) (Stil-Auflösung),
|
||||||
|
[plans-output.md](plans-output.md) (Papier-Maßstab, mm-Strichstärken),
|
||||||
|
[context-menu.md](context-menu.md) (Maus-Schema).
|
||||||
|
|
||||||
|
## 0. Architektur-Prinzip (Bezug zum Repo)
|
||||||
|
|
||||||
|
Die App folgt der Regel **ein semantisches Modell ist die einzige Wahrheit; jede
|
||||||
|
Ansicht ist abgeleitet** (CONVENTIONS.md, `App.tsx`). Werkzeuge greifen darum NUR über
|
||||||
|
`setProject` immutabel auf das `Project`-Modell zu; sie schreiben NIE Geometrie
|
||||||
|
direkt in den Plan. Der `PlanView` bleibt eine reine Darstellungs-/Eingabe-
|
||||||
|
Schicht. Das Tool-System setzt genau an der bestehenden Naht in `PlanView` an:
|
||||||
|
|
||||||
|
- **Modell↔Screen.** `PlanView` rechnet bereits Cursor-Pixel → viewBox-Einheiten
|
||||||
|
(`clientToView`) → Modell-Meter (`viewToModel`). Diese Umrechnung ist die
|
||||||
|
Grundlage; Werkzeuge arbeiten ausschließlich in **Modell-Metern** (CONVENTIONS.md:
|
||||||
|
intern alles in Metern). Für Snap-Marker brauchen Werkzeuge zusätzlich die
|
||||||
|
Rückrichtung Modell → viewBox (`toScreen`, existiert bereits) bzw. Modell →
|
||||||
|
Client-Pixel.
|
||||||
|
- **Pointer-Handling.** `PlanView` besitzt heute drei Gesten an der linken Taste/
|
||||||
|
Mitte/rechts: Auswahl/Marquee, Pan, Kontextmenü. Das Tool-System schiebt sich
|
||||||
|
VOR diese Logik: ist ein aktives Zeichenwerkzeug gewählt (≠ `select`), übernimmt
|
||||||
|
das Werkzeug `pointerdown/move/up`; das `select`-Werkzeug delegiert an die heute
|
||||||
|
schon vorhandene Auswahl-/Marquee-Logik (kein Verhaltensbruch).
|
||||||
|
- **Pan/Zoom bleiben immer aktiv.** Mittlere Maustaste (Pan) und Mausrad (Zoom)
|
||||||
|
laufen unverändert weiter, auch während ein Zeichenwerkzeug aktiv ist — sonst
|
||||||
|
kann man beim Zeichnen nicht navigieren.
|
||||||
|
|
||||||
|
## 1. Datenfluss-Überblick
|
||||||
|
|
||||||
|
```
|
||||||
|
TopBar (Werkzeugleiste) --activeTool--> App-State
|
||||||
|
│
|
||||||
|
┌──── activeTool, wallTypeId, defaultCategoryCode ────┐
|
||||||
|
▼ ▼
|
||||||
|
PlanView ── pointerdown/move/up (Modellpunkt) ──> ToolController
|
||||||
|
▲ │
|
||||||
|
Snap-Marker + Rubber-Band-Overlay <── DraftState (Vorschau) ──┘
|
||||||
|
│ │
|
||||||
|
└──────────────── commit ──> onToolCommit(Element) ──> setProject
|
||||||
|
```
|
||||||
|
|
||||||
|
`activeTool` und die Werkzeug-Parameter (aktiver `WallType`, Default-Kategorie)
|
||||||
|
liegen als **View-State** in `App.tsx` — wie `viewType`, `detail`, `selectedWallIds`
|
||||||
|
bereits dort liegen. Der `ToolController` ist **frameworkfrei** (reines TS, kein
|
||||||
|
React-State pro Mausbewegung — analog zu `drag`/`marquee` als `useRef` in
|
||||||
|
`PlanView`), damit die Live-Vorschau ohne Re-Render des ganzen Baums läuft. Nur
|
||||||
|
beim **Commit** wird `setProject` (Re-Render) ausgelöst.
|
||||||
|
|
||||||
|
## 2. Koordinaten & Hilfsfunktionen
|
||||||
|
|
||||||
|
`PlanView` exportiert künftig zwei reine Konverter (heute intern vorhanden),
|
||||||
|
plus die effektive Pixel-pro-Meter-Skala für die Snap-Toleranz:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// PlanView-intern bereits da; wird als stabile Callbacks nach außen gereicht.
|
||||||
|
type ToModel = (clientX: number, clientY: number) => Vec2; // Pixel → Meter
|
||||||
|
type ToClient = (m: Vec2) => { x: number; y: number }; // Meter → Pixel
|
||||||
|
type PxPerMeter = () => number; // aktuelle meet-Skala * PX_PER_M (Snap-Toleranz)
|
||||||
|
```
|
||||||
|
|
||||||
|
`PxPerMeter` ergibt sich aus `meetScale(view) * PX_PER_M` (beides in `PlanView`
|
||||||
|
vorhanden). Snap-Toleranzen werden in **Bildschirm-Pixeln** definiert (z. B. 10 px)
|
||||||
|
und über `pxPerMeter` in Meter umgerechnet — so ist der Fangradius zoom-unabhängig
|
||||||
|
konstant am Bildschirm.
|
||||||
|
|
||||||
|
## 3. Tool-System
|
||||||
|
|
||||||
|
### 3.1 Werkzeug-Identität und Registry
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ToolId =
|
||||||
|
| "select" // Default: Auswahl/Marquee (heutiges Verhalten)
|
||||||
|
| "wall" // Wand-Achs-Polylinie → Wall je Segment
|
||||||
|
| "line" // einzelne 2D-Strecke
|
||||||
|
| "polyline" // offene 2D-Polylinie
|
||||||
|
| "rect" // 2D-Rechteck (zwei Ecken)
|
||||||
|
| "circle" // 2D-Kreis (Zentrum + Radius)
|
||||||
|
| "arc" // 2D-Bogen (3-Punkt oder Zentrum-Start-Ende)
|
||||||
|
| "text"; // 2D-Textmarke
|
||||||
|
|
||||||
|
/** Live-Kontext, den ein Werkzeug bei jedem Schritt erhält. */
|
||||||
|
export interface ToolContext {
|
||||||
|
project: Project;
|
||||||
|
/** Aktives Geschoss/Zeichnungsebene (Ziel der neuen Elemente). */
|
||||||
|
level: DrawingLevel;
|
||||||
|
/** Default-Kategorie-Code für neue Elemente (siehe §6). */
|
||||||
|
defaultCategoryCode: string;
|
||||||
|
/** Aktiver Wandtyp für das Wand-Werkzeug. */
|
||||||
|
activeWallTypeId: string;
|
||||||
|
/** Aktiver Linienstil-Code für 2D-Primitive (Line Manager). */
|
||||||
|
activeLineStyleId: string;
|
||||||
|
/** Snapping-Einstellungen (an/aus je Typ, ortho, grid). */
|
||||||
|
snap: SnapSettings;
|
||||||
|
/** Pixel pro Meter (für Snap-Toleranz in Metern). */
|
||||||
|
pxPerMeter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein an einer Modellposition ausgelöstes Pointer-Ereignis. */
|
||||||
|
export interface ToolPointer {
|
||||||
|
/** Roher Modellpunkt (vor Snapping), in Metern. */
|
||||||
|
raw: Vec2;
|
||||||
|
/** Gesnappter Punkt + Marker-Info (siehe §5). null = kein Snap. */
|
||||||
|
snap: SnapResult | null;
|
||||||
|
/** Effektiver Punkt = snap?.point ?? raw. */
|
||||||
|
point: Vec2;
|
||||||
|
/** Modifikatoren (Shift = Ortho erzwingen, Ctrl = Snap aus, Alt = …). */
|
||||||
|
shift: boolean;
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
button: number; // 0 links, 2 rechts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was ein Werkzeug-Schritt nach außen meldet. */
|
||||||
|
export interface ToolResult {
|
||||||
|
/** Neuer Vorschau-Zustand (Rubber-Band-Geometrie); null = nichts zu zeigen. */
|
||||||
|
draft: ToolDraft | null;
|
||||||
|
/** Bei Abschluss: Mutation, die App über setProject anwendet. */
|
||||||
|
commit?: (p: Project) => Project;
|
||||||
|
/** true → Werkzeug ist fertig und kehrt in seinen Ruhezustand zurück. */
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Werkzeug-Schnittstelle (reine Funktionen über einen internen State). */
|
||||||
|
export interface Tool {
|
||||||
|
id: ToolId;
|
||||||
|
/** UI-Label-Key (i18n), z. B. "tool.wall". */
|
||||||
|
labelKey: string;
|
||||||
|
/** Material-Symbol-Name für die Werkzeugleiste. */
|
||||||
|
icon: string;
|
||||||
|
/** Statuszeilen-Hinweis-Key je Phase (z. B. "tool.wall.firstPoint"). */
|
||||||
|
hintKey: (state: ToolState) => string;
|
||||||
|
|
||||||
|
/** Initialer Ruhezustand. */
|
||||||
|
init(): ToolState;
|
||||||
|
/** Klick/Tap (pointerdown→up ohne Drag, bzw. „setze Punkt"). */
|
||||||
|
onClick(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
/** Bewegung (Hover/Drag): nur Vorschau, nie Commit. */
|
||||||
|
onMove(state: ToolState, p: ToolPointer, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
/** Doppelklick/Enter: mehrteilige Werkzeuge abschließen (z. B. Polylinie). */
|
||||||
|
onCommitGesture(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
/** Esc: aktuellen Entwurf verwerfen, zurück in den Ruhezustand. */
|
||||||
|
onCancel(state: ToolState): [ToolState, ToolResult];
|
||||||
|
/** Backspace: letzten gesetzten Punkt zurücknehmen (mehrteilig). */
|
||||||
|
onUndoPoint?(state: ToolState, ctx: ToolContext): [ToolState, ToolResult];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ToolState` ist je Werkzeug ein Discriminated Union (Beispiel Wand in §4). Der
|
||||||
|
`ToolController` hält genau eine aktive `Tool`-Instanz + deren `ToolState` in
|
||||||
|
einem `useRef` und ist die einzige Stelle, die diese Methoden aufruft.
|
||||||
|
|
||||||
|
### 3.2 Vorschau-Geometrie (Rubber-Band)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Darstellbare Vorschau — dieselben Primitive wie der Plan, plus Marker. */
|
||||||
|
export interface ToolDraft {
|
||||||
|
/** Vorschau-Primitive (gestrichelt/halbtransparent gezeichnet). */
|
||||||
|
preview: Primitive[];
|
||||||
|
/** Bereits gesetzte „feste" Stützpunkte (kleine Quadrate). */
|
||||||
|
vertices: Vec2[];
|
||||||
|
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. "3.20 m, 90°"). */
|
||||||
|
hud?: { at: Vec2; text: string };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: Die Vorschau benutzt **dieselben `Primitive`-Typen** wie `generatePlan`
|
||||||
|
(`polygon | line | arc`). Damit kann der Vorschau-Layer mit derselben
|
||||||
|
`PrimitiveShape`-Renderlogik gezeichnet werden (DRY) — nur mit einer
|
||||||
|
Vorschau-CSS-Klasse (gestrichelt, Akzentfarbe). Für die Wand-Vorschau kann das
|
||||||
|
Werkzeug sogar `generatePlan` auf einem **temporären Projekt** (Original + die in
|
||||||
|
Bau befindliche Wand) aufrufen, um echte gehrte Poché live zu zeigen; in der
|
||||||
|
ersten Phase reicht eine einfache Bandvorschau (`wallCorners`).
|
||||||
|
|
||||||
|
### 3.3 Zustandsmaschine (allgemein)
|
||||||
|
|
||||||
|
Jedes Werkzeug ist eine kleine Maschine über `pointerdown → move → up`. Da
|
||||||
|
`PlanView` Pointer-Capture nutzt, kommen `move`/`up` zuverlässig an. Generisches
|
||||||
|
Muster:
|
||||||
|
|
||||||
|
```
|
||||||
|
ruht ──pointerdown──> (Werkzeug setzt 1. Punkt / startet Drag)
|
||||||
|
▲ │
|
||||||
|
│ ├──move──> Vorschau (rubber-band), kein Commit
|
||||||
|
│ │
|
||||||
|
│ (mehrteilig) pointerdown──> Punkt anhängen, Vorschau weiter
|
||||||
|
│ │
|
||||||
|
└──Esc/Cancel─────────────┤
|
||||||
|
▼
|
||||||
|
Doppelklick/Enter/letzter Punkt ──> commit(project) ──> ruht
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Klick-vs-Drag.** Wie heute in `PlanView` (`MARQUEE_THRESHOLD_PX`): unter der
|
||||||
|
Schwelle ist es ein „Punkt setzen" (Klick), darüber ein Drag. Rechteck/Kreis/
|
||||||
|
Linie unterstützen BEIDE Bedienarten: Zwei-Klick (Punkt, Punkt) ODER Drücken-
|
||||||
|
Ziehen-Loslassen. Polyline/Wall sind reine Klickfolgen mit Abschluss per
|
||||||
|
Doppelklick/Enter.
|
||||||
|
- **Esc** verwirft den Entwurf (`onCancel`) und bleibt im selben Werkzeug.
|
||||||
|
Zweites Esc (im Ruhezustand) schaltet zurück auf `select`.
|
||||||
|
- **Rechtsklick** während eines aktiven Entwurfs = „abschließen/abbrechen"
|
||||||
|
(CAD-üblich), KEIN Kontextmenü; im Ruhezustand öffnet Rechtsklick wie bisher
|
||||||
|
das Plan-Kontextmenü.
|
||||||
|
|
||||||
|
### 3.4 Einbettung in PlanView (Pointer-Routing)
|
||||||
|
|
||||||
|
`PlanView` bekommt zwei neue Props:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface PlanViewProps {
|
||||||
|
// … bisherige Props …
|
||||||
|
/** Aktives Werkzeug; "select" = bisheriges Verhalten. */
|
||||||
|
activeTool?: ToolId;
|
||||||
|
/**
|
||||||
|
* Werkzeug-Treiber. PlanView ruft diese Callbacks mit fertig gesnappten
|
||||||
|
* Modellpunkten auf und rendert den zurückgegebenen Draft als Overlay.
|
||||||
|
*/
|
||||||
|
toolHandlers?: {
|
||||||
|
onToolDown(p: ToolPointer): void;
|
||||||
|
onToolMove(p: ToolPointer): void;
|
||||||
|
onToolUp(p: ToolPointer): void;
|
||||||
|
onToolDoubleClick(): void;
|
||||||
|
/** liefert die zu zeichnende Vorschau (von App/Controller gehalten). */
|
||||||
|
draft: ToolDraft | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Routing in `onPointerDown` (Ergänzung der bestehenden Methode):
|
||||||
|
|
||||||
|
```
|
||||||
|
onPointerDown(e):
|
||||||
|
if e.button === 1: → bestehender Pan (unverändert)
|
||||||
|
if e.button === 0:
|
||||||
|
if activeTool === "select": → bestehende Auswahl-/Marquee-Geste
|
||||||
|
else:
|
||||||
|
setPointerCapture
|
||||||
|
p = makeToolPointer(e) // raw → snap → point (§5)
|
||||||
|
toolHandlers.onToolDown(p)
|
||||||
|
if e.button === 2 (rechts):
|
||||||
|
if activeTool !== "select" && entwurf aktiv: toolHandlers.onToolUp({button:2,…}) // abschließen
|
||||||
|
else: bestehendes Kontextmenü
|
||||||
|
```
|
||||||
|
|
||||||
|
`onPointerMove`/`onPointerUp` analog: bei aktivem Zeichenwerkzeug an
|
||||||
|
`onToolMove`/`onToolUp` routen statt an Pan/Marquee. Der Cursor wird auf
|
||||||
|
`crosshair` gesetzt. `makeToolPointer` führt das Snapping aus (§5) und liefert den
|
||||||
|
fertigen `ToolPointer`.
|
||||||
|
|
||||||
|
Die **Snap-Marker** und der **Draft** werden als zusätzliche SVG-Gruppe NACH den
|
||||||
|
Plan-Primitiven, aber vor der Auswahl-Hervorhebung gerendert (immer obenauf,
|
||||||
|
`pointerEvents="none"`). Marker werden in viewBox-Einheiten über `toScreen`
|
||||||
|
positioniert (existiert bereits).
|
||||||
|
|
||||||
|
## 4. Werkzeug: Wand (Wall)
|
||||||
|
|
||||||
|
Das Wand-Werkzeug zeichnet eine **Achs-Polylinie**; jedes Segment wird zu einem
|
||||||
|
eigenständigen `Wall`-Element des aktiven `WallType` auf dem aktiven Geschoss.
|
||||||
|
Aufeinanderfolgende Segmente teilen sich einen Knoten → die bestehende
|
||||||
|
`computeJoins`-Verschneidung (in `generatePlan`) erzeugt automatisch saubere
|
||||||
|
Gehrungen an den Ecken. Kein zusätzlicher Join-Code nötig.
|
||||||
|
|
||||||
|
### 4.1 Zustand
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type WallToolState =
|
||||||
|
| { phase: "idle" }
|
||||||
|
| {
|
||||||
|
phase: "drawing";
|
||||||
|
/** Bisher gesetzte Achs-Knoten (in Metern). */
|
||||||
|
points: Vec2[];
|
||||||
|
/** Aktuelle Cursor-Position (gesnappt) für die Rubber-Band-Vorschau. */
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Pseudocode
|
||||||
|
|
||||||
|
```
|
||||||
|
WallTool.onClick(state, p, ctx):
|
||||||
|
if state.phase === "idle":
|
||||||
|
return [{phase:"drawing", points:[p.point], cursor:p.point}, {draft: draftFor([p.point], p.point, ctx)}]
|
||||||
|
else: // weiteren Knoten anhängen
|
||||||
|
pts = [...state.points, p.point]
|
||||||
|
# Ortho/Snap haben p.point bereits ausgerichtet (§5).
|
||||||
|
return [{phase:"drawing", points: pts, cursor: p.point}, {draft: draftFor(pts, p.point, ctx)}]
|
||||||
|
|
||||||
|
WallTool.onMove(state, p, ctx):
|
||||||
|
if state.phase !== "drawing": return [state, {draft:null}]
|
||||||
|
return [{...state, cursor:p.point}, {draft: draftFor(state.points, p.point, ctx)}]
|
||||||
|
|
||||||
|
WallTool.onCommitGesture(state, ctx): // Doppelklick / Enter / Rechtsklick
|
||||||
|
if state.phase !== "drawing" || state.points.length < 2:
|
||||||
|
return [{phase:"idle"}, {draft:null, done:true}]
|
||||||
|
pts = state.points
|
||||||
|
return [{phase:"idle"}, {
|
||||||
|
draft: null, done: true,
|
||||||
|
commit: (proj) => appendWalls(proj, pts, ctx)
|
||||||
|
}]
|
||||||
|
|
||||||
|
WallTool.onCancel(state):
|
||||||
|
return [{phase:"idle"}, {draft:null, done:true}]
|
||||||
|
|
||||||
|
WallTool.onUndoPoint(state):
|
||||||
|
if state.phase==="drawing" && state.points.length>1:
|
||||||
|
return [{...state, points: state.points.slice(0,-1)}, {draft: …}]
|
||||||
|
return [{phase:"idle"}, {draft:null}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`draftFor` baut die Vorschau: feste Segmente zwischen `points` + ein „lebendes"
|
||||||
|
Segment `points[last] → cursor`. Pro Segment werden die vier Band-Eckpunkte über
|
||||||
|
`wallCorners(a, b, thickness)` (vorhanden) berechnet und als Vorschau-`polygon`
|
||||||
|
gezeichnet; zusätzlich ein HUD mit Länge `|b−a|` und Winkel. `thickness =
|
||||||
|
wallTypeThickness(getWallType(...))`.
|
||||||
|
|
||||||
|
### 4.3 Commit ins Modell
|
||||||
|
|
||||||
|
```
|
||||||
|
appendWalls(project, pts, ctx):
|
||||||
|
newWalls = []
|
||||||
|
for i in 0 .. pts.length-2:
|
||||||
|
a = pts[i]; b = pts[i+1]
|
||||||
|
if |b-a| < EPS: continue // Null-Segmente überspringen
|
||||||
|
newWalls.push({
|
||||||
|
id: uniqueId("W"), // siehe §8 (ID-Vergabe)
|
||||||
|
type: "wall",
|
||||||
|
floorId: ctx.level.id, // aktives Geschoss
|
||||||
|
categoryCode: ctx.defaultCategoryCode, // §6
|
||||||
|
start: a, end: b,
|
||||||
|
wallTypeId: ctx.activeWallTypeId,
|
||||||
|
height: ctx.level.floorHeight ?? 2.6, // Geschosshöhe als Default
|
||||||
|
})
|
||||||
|
return { ...project, walls: [...project.walls, ...newWalls] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
- **Höhe** erbt die lichte Geschosshöhe (`DrawingLevel.floorHeight`), Fallback 2.6 m.
|
||||||
|
- **Geschossbindung**: Das Wand-Werkzeug ist nur aktiv, wenn `level.kind === "floor"`
|
||||||
|
(sonst gibt es keine Wände). In `drawing`-Ebenen ist das Wand-Werkzeug
|
||||||
|
deaktiviert (nur 2D-Werkzeuge); siehe §6.
|
||||||
|
- Die Wicklung wird NICHT erzwungen — `leftNormal`-Konvention (CONVENTIONS.md) und
|
||||||
|
`computeJoins` arbeiten richtungsunabhängig pro Segment.
|
||||||
|
|
||||||
|
## 5. Snapping
|
||||||
|
|
||||||
|
Snapping läuft in `makeToolPointer` (PlanView) BEVOR der Punkt an das Werkzeug
|
||||||
|
geht. Es prüft mehrere Snap-Quellen, wählt die nächstgelegene innerhalb der
|
||||||
|
Toleranz und liefert sowohl den gefangenen Punkt als auch eine **Marker-Art** für
|
||||||
|
die Bildschirmdarstellung.
|
||||||
|
|
||||||
|
### 5.1 Typen
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type SnapKind =
|
||||||
|
| "endpoint" // Wand-Achsenende, Polylinien-Knoten, Primitiv-Endpunkt
|
||||||
|
| "midpoint" // Mitte einer Strecke/Wandachse
|
||||||
|
| "intersection" // Schnittpunkt zweier Achsen/Linien
|
||||||
|
| "center" // Kreis-/Bogenzentrum
|
||||||
|
| "quadrant" // Kreis-Quadrantenpunkte (0/90/180/270°)
|
||||||
|
| "onEdge" // nächster Punkt AUF einer Wandachse/Linie (Lot)
|
||||||
|
| "grid" // Rasterpunkt
|
||||||
|
| "ortho" // orthogonal/winkelrastriert zum vorigen Punkt
|
||||||
|
| "extension"; // Verlängerung einer Achse (gestrichelte Hilfslinie)
|
||||||
|
|
||||||
|
export interface SnapResult {
|
||||||
|
point: Vec2; // gefangener Punkt (Meter)
|
||||||
|
kind: SnapKind;
|
||||||
|
/** Quell-Element (für Marker/Hilfslinien), optional. */
|
||||||
|
refA?: Vec2;
|
||||||
|
refB?: Vec2;
|
||||||
|
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
|
||||||
|
distPx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SnapSettings {
|
||||||
|
enabled: boolean; // Master-Schalter (Ctrl invertiert temporär)
|
||||||
|
endpoint: boolean;
|
||||||
|
midpoint: boolean;
|
||||||
|
intersection: boolean;
|
||||||
|
center: boolean;
|
||||||
|
onEdge: boolean;
|
||||||
|
grid: boolean;
|
||||||
|
gridSize: number; // Rasterweite in Metern, z. B. 0.10
|
||||||
|
ortho: boolean; // Shift erzwingt zusätzlich
|
||||||
|
angleStep: number; // Winkelraster in Grad (z. B. 45)
|
||||||
|
tolerancePx: number; // Fangradius am Bildschirm, z. B. 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Snap-Kandidaten sammeln
|
||||||
|
|
||||||
|
Quellen pro Geschoss (gefiltert auf sichtbare Kategorien, wie der Plan):
|
||||||
|
|
||||||
|
| Snap | Quelle |
|
||||||
|
|------|--------|
|
||||||
|
| endpoint | `wall.start`, `wall.end` aller sichtbaren Wände; Knoten bereits gesetzter Draft-Punkte; `Drawing2D`-Vertices |
|
||||||
|
| midpoint | Mitte jeder Wandachse und jedes 2D-Segments |
|
||||||
|
| intersection | paarweise `lineIntersect` der Wandachsen (nur Paare, deren Boxen sich am Cursor nähern) |
|
||||||
|
| center/quadrant | Kreise/Bögen aus `Drawing2D` |
|
||||||
|
| onEdge | Lotfußpunkt des Cursors auf jede nahe Wandachse/2D-Linie |
|
||||||
|
| grid | Rundung des Cursors auf `gridSize` |
|
||||||
|
| ortho | Ausrichtung relativ zum letzten Draft-Punkt (§5.4) |
|
||||||
|
|
||||||
|
Performance: Kandidaten werden je `move` neu erzeugt, aber **früh nach
|
||||||
|
Bildschirm-Distanz gefiltert** (nur Punkte innerhalb ~`2·tolerancePx`). Bei
|
||||||
|
großen Modellen kann eine grobe Bounding-Box-Vorauswahl je Wand vorgeschaltet
|
||||||
|
werden; in den ersten Phasen genügt lineares Scannen (Wandzahl ist klein).
|
||||||
|
|
||||||
|
### 5.3 Auswahl-Pseudocode
|
||||||
|
|
||||||
|
```
|
||||||
|
computeSnap(rawModel, ctx, draftPoints, lastPoint):
|
||||||
|
if ctrl(): return null # Snap temporär aus
|
||||||
|
s = ctx.snap
|
||||||
|
tolM = s.tolerancePx / ctx.pxPerMeter # px-Toleranz → Meter
|
||||||
|
cands: SnapResult[] = []
|
||||||
|
|
||||||
|
if s.endpoint: cands += endpoints(...) filtered to within tolM
|
||||||
|
if s.midpoint: cands += midpoints(...)
|
||||||
|
if s.intersection: cands += intersections(...)
|
||||||
|
if s.center: cands += centers/quadrants(...)
|
||||||
|
if s.onEdge: cands += perpendicularFeet(...) # niedrigere Priorität
|
||||||
|
|
||||||
|
# Punkt-Snaps haben Vorrang vor Linien-/Raster-Snaps:
|
||||||
|
pick = argmin(cands, by distPx within tolM, tie-break by priority)
|
||||||
|
if pick: rawModel = pick.point
|
||||||
|
|
||||||
|
# Ortho/Winkelraster wirkt RELATIV zum letzten Punkt und ÜBERLAGERT:
|
||||||
|
if (s.ortho || shift()) && lastPoint:
|
||||||
|
rawModel = applyAngleConstraint(lastPoint, rawModel, s.angleStep)
|
||||||
|
# Wenn dabei auch ein Punkt-Snap nahe der Ortho-Linie liegt → bevorzugen.
|
||||||
|
|
||||||
|
if !pick && s.grid:
|
||||||
|
g = snapToGrid(rawModel, s.gridSize)
|
||||||
|
if dist(g, rawModel) within tolM: return {point:g, kind:"grid", …}
|
||||||
|
|
||||||
|
return pick ?? null
|
||||||
|
```
|
||||||
|
|
||||||
|
Prioritätsreihenfolge bei gleichem Abstand: `endpoint > intersection > midpoint >
|
||||||
|
center/quadrant > onEdge > grid`. Ortho/Winkelraster ist eine **Projektion**, kein
|
||||||
|
Punkt-Kandidat: es verschiebt den (ggf. schon gesnappten) Punkt auf die nächste
|
||||||
|
erlaubte Richtung vom letzten Knoten.
|
||||||
|
|
||||||
|
### 5.4 Ortho / Winkelraster
|
||||||
|
|
||||||
|
```
|
||||||
|
applyAngleConstraint(from, to, stepDeg):
|
||||||
|
d = to - from
|
||||||
|
ang = atan2(d.y, d.x)
|
||||||
|
k = round(ang / rad(stepDeg)) * rad(stepDeg)
|
||||||
|
len = |d|
|
||||||
|
return from + (cos(k), sin(k)) * len
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit `stepDeg = 90` ist das klassisches Ortho (H/V); `45` erlaubt Diagonalen.
|
||||||
|
`Shift` erzwingt Ortho temporär unabhängig von der Einstellung.
|
||||||
|
|
||||||
|
### 5.5 Bildschirm-Marker
|
||||||
|
|
||||||
|
Pro aktivem Snap zeichnet `PlanView` ein Marker-Glyph an `toScreen(snap.point)`
|
||||||
|
(`pointerEvents="none"`, eigene CSS-Klassen, papierkonstante Größe via
|
||||||
|
non-scaling):
|
||||||
|
|
||||||
|
- `endpoint` → kleines Quadrat ▫
|
||||||
|
- `midpoint` → Dreieck �△
|
||||||
|
- `intersection` → ✕
|
||||||
|
- `center` → ○, `quadrant` → ◇
|
||||||
|
- `onEdge` → ⟂-Glyph
|
||||||
|
- `grid` → feiner Punkt
|
||||||
|
- `ortho`/`extension` → zusätzlich eine **gestrichelte Hilfslinie** von `refA`
|
||||||
|
(Bezugspunkt) zum Cursor
|
||||||
|
|
||||||
|
Marker erscheinen NUR während ein Zeichenwerkzeug aktiv ist. i18n-Tooltips/Status
|
||||||
|
(„Endpunkt", „Mittelpunkt", …) über `t('snap.endpoint')` etc.
|
||||||
|
|
||||||
|
## 6. Ebene, Kategorie und Stil neuer Elemente
|
||||||
|
|
||||||
|
Neue Elemente brauchen eine **Zeichnungsebene** (DrawingLevel) und eine
|
||||||
|
**Kategorie** (LayerCategory `code`) sowie — bei 2D-Primitiven — einen Stift/
|
||||||
|
Schraffur-Stil.
|
||||||
|
|
||||||
|
### 6.1 Zeichnungsebene (Ziel)
|
||||||
|
|
||||||
|
- Ziel ist **immer das aktive Geschoss/die aktive Zeichnungsebene** (`activeLevelId`
|
||||||
|
in `App.tsx`). Wände nur auf `kind === "floor"`. 2D-Primitive (`Drawing2D`) auf
|
||||||
|
jeder Ebene, also auch auf `kind === "drawing"` (freie 2D-Zeichnung).
|
||||||
|
|
||||||
|
### 6.2 Kategorie (categoryCode)
|
||||||
|
|
||||||
|
- Es gibt eine **aktive Kategorie** als View-State (`activeCategoryCode` in App,
|
||||||
|
neu). Default beim Start: der Code der gewählten Wand-Kategorie (im Sample „20"
|
||||||
|
Wände), bzw. die erste sichtbare Kategorie. Die Statusleiste zeigt heute schon
|
||||||
|
die „aktive Ebene" (`activeLayerName`); diese wird künftig von `activeCategoryCode`
|
||||||
|
gespeist statt nur aus der Auswahl abgeleitet.
|
||||||
|
- Neue Wände: `categoryCode = activeCategoryCode` (z. B. „20").
|
||||||
|
- Neue 2D-Primitive: ebenfalls `activeCategoryCode`. Sinnvoll ist eine eigene
|
||||||
|
2D-/Hilfslinien-Kategorie (z. B. „90 Zeichnung"); diese wird über die
|
||||||
|
Kategorie-Auswahl in der Statusleiste/Werkzeugleiste gesetzt.
|
||||||
|
- Die Kategorie liefert Farbe + Strichstärke (`LayerCategory.color`, `.lw`), genau
|
||||||
|
wie `generatePlan` es heute für Wände via `categoryLwMap` nutzt.
|
||||||
|
|
||||||
|
### 6.3 Stift/Schraffur
|
||||||
|
|
||||||
|
- **Wände** erhalten KEINEN eigenen Stift — ihr Erscheinungsbild kommt aus dem
|
||||||
|
`WallType` (Component → Hatch → LineStyle) und der Kategorie-`lw` (bestehender
|
||||||
|
Pfad in `generatePlan`).
|
||||||
|
- **2D-Primitive** referenzieren optional einen `LineStyle` aus dem Line Manager
|
||||||
|
(`activeLineStyleId`). Ohne expliziten Stil erben sie Farbe/Strichstärke aus der
|
||||||
|
Kategorie (`color`, `lw`). Flächige 2D-Primitive (geschlossenes Rechteck/Kreis/
|
||||||
|
Polyline) können optional eine Schraffur (`hatchId`) tragen.
|
||||||
|
|
||||||
|
## 7. Speicherung der 2D-Primitive: `Drawing2D`
|
||||||
|
|
||||||
|
2D-Geometrie wird als neues Modell-Element `Drawing2D` gespeichert — analog zu
|
||||||
|
`Wall`/`Door` ein semantisches Element, das beim Rendern abgeleitet wird (KEINE
|
||||||
|
vorab erzeugten Primitive im Modell). Damit bleibt die Architektur „Modell →
|
||||||
|
abgeleitete Ansicht" intakt.
|
||||||
|
|
||||||
|
### 7.1 Typ
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Geometrie-Form eines 2D-Zeichenelements. */
|
||||||
|
export type Drawing2DGeom =
|
||||||
|
| { shape: "line"; a: Vec2; b: Vec2 }
|
||||||
|
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
|
||||||
|
| { shape: "rect"; min: Vec2; max: Vec2 } // achsparallel
|
||||||
|
| { shape: "circle"; center: Vec2; r: number }
|
||||||
|
| {
|
||||||
|
shape: "arc";
|
||||||
|
center: Vec2;
|
||||||
|
r: number;
|
||||||
|
/** Start-/Endwinkel in Radiant (math. Konvention, CCW positiv). */
|
||||||
|
a0: number;
|
||||||
|
a1: number;
|
||||||
|
}
|
||||||
|
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
|
||||||
|
|
||||||
|
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
|
||||||
|
export interface Drawing2D {
|
||||||
|
id: string;
|
||||||
|
type: "drawing2d";
|
||||||
|
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
|
||||||
|
levelId: string;
|
||||||
|
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
|
||||||
|
categoryCode: string;
|
||||||
|
geom: Drawing2DGeom;
|
||||||
|
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
|
||||||
|
lineStyleId?: string;
|
||||||
|
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
|
||||||
|
hatchId?: string;
|
||||||
|
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergänzung am `Project`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Project {
|
||||||
|
// … bisher …
|
||||||
|
drawings2d: Drawing2D[]; // NEU
|
||||||
|
}
|
||||||
|
export type Element = Wall | Door | Drawing2D; // erweitert
|
||||||
|
```
|
||||||
|
|
||||||
|
`sampleProject` bekommt ein leeres `drawings2d: []`. Lösch-/Referenz-Regeln:
|
||||||
|
beim Löschen einer Zeichnungsebene werden auch deren `Drawing2D` entfernt (analog
|
||||||
|
zur bestehenden Wand-/Tür-Bereinigung in `deleteLevel`).
|
||||||
|
|
||||||
|
### 7.2 Ableitung in `generatePlan`
|
||||||
|
|
||||||
|
`generatePlan` rendert künftig zusätzlich die `Drawing2D` des Geschosses (gefiltert
|
||||||
|
wie Wände auf sichtbare Kategorien + `categoryDisplay`). Neue Funktion
|
||||||
|
`addDrawing2D(out, project, d, greyed, lwMm)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
addDrawing2D(out, project, d):
|
||||||
|
color = d.color ?? categoryColor(d.categoryCode)
|
||||||
|
weight = lineStyle(d.lineStyleId)?.weight ?? categoryLw(d.categoryCode)
|
||||||
|
dash = lineStyle(d.lineStyleId)?.dash ?? null
|
||||||
|
switch d.geom.shape:
|
||||||
|
"line": out.push({kind:"line", a, b, cls:"draw2d", weightMm:weight, dash})
|
||||||
|
"polyline": for each segment → line-Primitive (closed → Schluss-Segment)
|
||||||
|
"rect": vier Kanten als line-Primitive (oder polygon, falls hatchId)
|
||||||
|
"circle": → als zwei 180°-Bögen (arc-Primitive) ODER neues Primitiv (s. u.)
|
||||||
|
"arc": → arc-Primitive (center/from/to/r aus a0,a1)
|
||||||
|
"text": → neues text-Primitiv (s. u.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dabei wird, wo möglich, der **vorhandene** `Primitive`-Vorrat (`line`, `arc`,
|
||||||
|
`polygon`) wiederverwendet — die Strichstärke kommt in mm Papier (wie der Rest des
|
||||||
|
Plans), Farbe über eine CSS-Klasse oder ein neues optionales `color`-Feld am
|
||||||
|
`line`-Primitive.
|
||||||
|
|
||||||
|
Zwei `Primitive`-Erweiterungen sind nötig:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// kreisförmige Vollkurve (Kreis) — sonst muss man sie in zwei Bögen zerlegen:
|
||||||
|
| { kind: "circle"; center: Vec2; r: number; cls: string; weightMm: number;
|
||||||
|
dash?: number[] | null; fill?: string; greyed?: boolean }
|
||||||
|
// Textmarke:
|
||||||
|
| { kind: "text"; at: Vec2; text: string; heightMm: number; angle: number;
|
||||||
|
cls: string; color?: string; greyed?: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
`PlanView.renderPrimitive` bekommt entsprechende `case`-Zweige (`<circle>`,
|
||||||
|
`<text>`). Text wird in **Papier-Millimetern** dimensioniert (Höhe → `mmToPx`,
|
||||||
|
non-scaling), damit die Schrifthöhe beim Zoomen papierkonstant bleibt (analog zu
|
||||||
|
Strichstärken in `plans-output.md`).
|
||||||
|
|
||||||
|
Das `arc`-Primitiv zeichnet heute nur Kurzbögen (≤180°, `large-arc=0`). Für
|
||||||
|
beliebige 2D-Bögen wird es um ein `largeArc`-Flag erweitert (aus `|a1−a0|`
|
||||||
|
berechnet); abwärtskompatibel (Default 0).
|
||||||
|
|
||||||
|
## 8. ID-Vergabe & Immutabilität
|
||||||
|
|
||||||
|
- Neue IDs über einen kleinen Helfer `uniqueId(prefix)` (z. B.
|
||||||
|
`\`${prefix}-${Date.now()}-${counter++}\``), konsistent mit der bestehenden
|
||||||
|
Praxis in `App.tsx` (`floor-${Date.now()}` usw.). Wand-Präfix „W", 2D-Präfix
|
||||||
|
„dr2d".
|
||||||
|
- Alle Mutationen laufen über `setProject` immutabel (CONVENTIONS.md / App-Konvention).
|
||||||
|
Der `commit(project)` eines Werkzeugs ist eine reine Funktion `Project →
|
||||||
|
Project`; App ruft `setProject(prev => result.commit(prev))`.
|
||||||
|
|
||||||
|
## 9. App- und PlanView-Verdrahtung (konkret)
|
||||||
|
|
||||||
|
Neuer View-State in `App.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
||||||
|
const [activeCategoryCode, setActiveCategoryCode] = useState<string>(/* erste Wand-Kat */);
|
||||||
|
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(project.wallTypes[0].id);
|
||||||
|
const [activeLineStyleId, setActiveLineStyleId] = useState<string>(project.lineStyles[0].id);
|
||||||
|
const [snap, setSnap] = useState<SnapSettings>(DEFAULT_SNAP);
|
||||||
|
const toolStateRef = useRef<ToolState>(getTool(activeTool).init());
|
||||||
|
const [draft, setDraft] = useState<ToolDraft | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
Der `ToolController` ist eine kleine Hook/Klasse, die `toolStateRef` hält und die
|
||||||
|
`PlanView.toolHandlers` implementiert:
|
||||||
|
|
||||||
|
```
|
||||||
|
onToolDown(p): [st, res] = tool.onClick(toolStateRef.current, p, ctx)
|
||||||
|
toolStateRef.current = st; setDraft(res.draft)
|
||||||
|
if res.commit: setProject(res.commit)
|
||||||
|
if res.done: toolStateRef.current = tool.init()
|
||||||
|
onToolMove(p): [st, res] = tool.onMove(...); toolStateRef.current=st; setDraft(res.draft)
|
||||||
|
onToolDoubleClick(): [st,res]=tool.onCommitGesture(...); apply commit/done; setDraft(null)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keyboard (global, nur wenn ein Zeichenwerkzeug aktiv ist):
|
||||||
|
`Esc → onCancel`, `Enter → onCommitGesture`, `Backspace → onUndoPoint`. Beim
|
||||||
|
Wechsel von `activeLevelId`/`viewType` wird der laufende Entwurf verworfen (analog
|
||||||
|
zur bestehenden Auswahl-Bereinigung in den `useEffect`s).
|
||||||
|
|
||||||
|
`ctx` (ToolContext) wird in App via `useMemo` aus Project + aktiven Selektionen
|
||||||
|
gebaut und an PlanView/Controller gereicht.
|
||||||
|
|
||||||
|
### 9.1 Werkzeugleiste (TopBar)
|
||||||
|
|
||||||
|
Eine neue Werkzeug-Gruppe in der `TopBar` (links, vor den Ansichts-Toggles), als
|
||||||
|
i18n-beschriftete Icon-Buttons (`t('tool.select')`, `t('tool.wall')`, …). Aktiv-
|
||||||
|
Zustand hervorgehoben. Daneben: Auswahl des aktiven `WallType` (für Wand) und der
|
||||||
|
aktiven Kategorie/des Linienstils (Dropdowns), sowie Snap-Toggles (kleines
|
||||||
|
Snap-Menü mit Checkboxen je `SnapKind`, Grid-Größe, Winkelraster). Wand-/2D-
|
||||||
|
Werkzeuge werden je nach `level.kind` aktiviert/deaktiviert (Tooltip nennt den
|
||||||
|
Grund — wie die bestehenden disabled-Menüpunkte in `App.tsx`).
|
||||||
|
|
||||||
|
### 9.2 i18n-Keys (neu, Auszug)
|
||||||
|
|
||||||
|
```
|
||||||
|
tool.select / tool.wall / tool.line / tool.polyline / tool.rect /
|
||||||
|
tool.circle / tool.arc / tool.text
|
||||||
|
tool.wall.firstPoint / tool.wall.nextPoint / tool.wall.finish
|
||||||
|
snap.endpoint / snap.midpoint / snap.intersection / snap.center /
|
||||||
|
snap.quadrant / snap.onEdge / snap.grid / snap.ortho
|
||||||
|
snap.settings / snap.gridSize / snap.angleStep
|
||||||
|
status.activeWallType / status.activeCategory / status.activeTool
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle sichtbaren Strings über `t(...)` (CONVENTIONS.md). Identifier bleiben englisch.
|
||||||
|
|
||||||
|
## 10. Übrige Werkzeuge (Kurzspezifikation)
|
||||||
|
|
||||||
|
| Werkzeug | Eingabe | Zustand | Commit |
|
||||||
|
|----------|---------|---------|--------|
|
||||||
|
| **Line** | 2 Punkte (Klick-Klick oder Drag) | `{a?}` | `Drawing2D{shape:"line"}` |
|
||||||
|
| **Polyline** | n Punkte, Abschluss Doppelklick/Enter; `closed` per „C" oder Klick auf Start | `{pts}` | `Drawing2D{shape:"polyline"}` |
|
||||||
|
| **Rectangle** | 2 Ecken (Drag oder Klick-Klick) | `{p0?}` | `Drawing2D{shape:"rect"}` (min/max sortiert) |
|
||||||
|
| **Circle** | Zentrum + Radius-Punkt | `{center?}` | `Drawing2D{shape:"circle"}` |
|
||||||
|
| **Arc** | 3 Punkte (Start, durch, Ende) ODER Zentrum-Start-Ende (Modus-Toggle) | `{p0?,p1?}` | `Drawing2D{shape:"arc"}` (a0/a1 aus Punkten) |
|
||||||
|
| **Text** | 1 Punkt → Inline-Eingabefeld (wie `InlineEditor` in App) | `{at?}` | `Drawing2D{shape:"text"}` |
|
||||||
|
|
||||||
|
Alle nutzen dasselbe `Tool`-Interface, dasselbe Snapping und denselben Draft-/
|
||||||
|
Commit-Pfad. Text öffnet beim Setzen des Ankerpunkts ein kleines Overlay-Eingabe-
|
||||||
|
feld (an `toClient(at)` positioniert) und committet bei Enter/Blur.
|
||||||
|
|
||||||
|
3-Punkt-Bogen → Zentrum: Umkreismittelpunkt der drei Punkte (Schnitt der
|
||||||
|
Mittelsenkrechten via `lineIntersect`), `r`, `a0/a1` aus Start-/Endwinkel; Drehsinn
|
||||||
|
aus dem mittleren Punkt.
|
||||||
|
|
||||||
|
## 11. Phasenplan
|
||||||
|
|
||||||
|
**Phase 1 — Gerüst + Select + Wall + Line (MVP).**
|
||||||
|
1. `ToolId`, `Tool`, `ToolContext`, `ToolPointer`, `ToolDraft`, `ToolResult`,
|
||||||
|
`SnapResult`, `SnapSettings`, `Drawing2D`(+`Project.drawings2d`) als Typen.
|
||||||
|
2. `PlanView`: `toScreen`/`viewToModel`/`pxPerMeter` als Callbacks nach außen;
|
||||||
|
Pointer-Routing für `activeTool !== "select"`; Draft-/Marker-Overlay-Rendering;
|
||||||
|
Crosshair-Cursor.
|
||||||
|
3. `ToolController` + App-State (`activeTool`, `activeCategoryCode`,
|
||||||
|
`activeWallTypeId`, `snap`) + Keyboard (Esc/Enter/Backspace).
|
||||||
|
4. **WallTool** voll funktionsfähig (Polylinie → Wände, Live-Band-Vorschau, HUD,
|
||||||
|
Commit via `appendWalls`). Verschneidung kommt automatisch aus `computeJoins`.
|
||||||
|
5. **LineTool** als erstes 2D-Werkzeug; `generatePlan.addDrawing2D` für `line`;
|
||||||
|
`Drawing2D`-Löschung beim Geschoss-Löschen.
|
||||||
|
6. **Snapping Stufe 1**: endpoint + grid + ortho (Shift), mit Bildschirm-Markern.
|
||||||
|
7. TopBar-Werkzeuggruppe (select/wall/line) + WallType-/Kategorie-Auswahl;
|
||||||
|
i18n-Keys; Statusleiste zeigt aktives Werkzeug + Kategorie.
|
||||||
|
8. Verifizieren: `npx tsc -b`, `npm run build`, Screenshot via `scripts/probe.mjs`
|
||||||
|
(Wand zeichnen, Gehrung prüfen).
|
||||||
|
|
||||||
|
**Phase 2 — Snapping vervollständigen + 2D-Grundformen.**
|
||||||
|
- Snap: midpoint, intersection, onEdge (Lot), extension-Hilfslinien, Winkelraster
|
||||||
|
(45°), Snap-Einstellungsmenü in der TopBar.
|
||||||
|
- Werkzeuge: Polyline, Rectangle (inkl. optionaler Schraffur für geschlossene
|
||||||
|
Formen). `Primitive`-Erweiterung nur für tatsächlich gebrauchte Formen.
|
||||||
|
|
||||||
|
**Phase 3 — Kurven + Text.**
|
||||||
|
- `Primitive` um `circle` (+ `arc` `largeArc`) und `text` erweitern; PlanView-
|
||||||
|
Renderzweige; Text papierkonstant.
|
||||||
|
- Werkzeuge: Circle, Arc (3-Punkt), Text (Inline-Eingabe). Snap: center/quadrant.
|
||||||
|
|
||||||
|
**Phase 4 — Bearbeitung (Folge-Doku).**
|
||||||
|
- Grips/Editieren bestehender Elemente (Wand-Enden ziehen, 2D-Vertices verschieben),
|
||||||
|
Verschieben/Kopieren/Rotieren der Auswahl, numerische Direkteingabe von
|
||||||
|
Länge/Winkel im HUD. Baut auf demselben Snapping + Draft-Pfad auf. (Eigenes
|
||||||
|
Design-Dokument; hier nur als Ausblick.)
|
||||||
|
|
||||||
|
## 12. Architektur-Garantien (Checkliste)
|
||||||
|
|
||||||
|
- Modell bleibt einzige Wahrheit; Werkzeuge schreiben nur `Project`, nie Plan-
|
||||||
|
Primitive. Ansichten (Plan/3D) leiten ab.
|
||||||
|
- Alle Bezeichner englisch; alle UI-Texte über `t(...)`; Einheiten in Metern,
|
||||||
|
Anzeige via `formatM`; Strichstärken/Texthöhen in mm Papier (non-scaling).
|
||||||
|
- Native-App-Verhalten: kein Browser-Kontextmenü während des Zeichnens; keine
|
||||||
|
Textauswahl (außer Text-Eingabefeld); Pan/Zoom immer verfügbar.
|
||||||
|
- DRY: Vorschau nutzt dieselben `Primitive` + Renderlogik wie der Plan; Snapping
|
||||||
|
und Commit-Pfad sind werkzeugübergreifend geteilt.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# Design — Bauteile (Elements)
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Output/Pläne: [plans-output.md](plans-output.md). Ressourcen/Stile:
|
||||||
|
> [resources-graphics.md](resources-graphics.md).
|
||||||
|
|
||||||
|
Dieses Dokument legt die **Daten**, die **Generierung** (3D-Geometrie + Plan-
|
||||||
|
Symbolik) und das **Grip-Editing** je Bauteil fest und übersetzt DOSSIERs
|
||||||
|
`elemente.py` (7244 LOC, Monolith) in **kleine Bauteil-Module** (`src/model/
|
||||||
|
elements/wall.ts`, `opening.ts`, …). Bezeichner englisch, Prosa deutsch, Meter.
|
||||||
|
|
||||||
|
DOSSIERs Architektur dort: pro Element eine **Achse/Outline-Source** (editierbar)
|
||||||
|
+ ein **auto-generiertes Volumen** (`wand_axis`+`wand_volume`, Outline+Brep).
|
||||||
|
Browser-Äquivalent: das **semantische Element ist die Source**; Geometrie wird per
|
||||||
|
`generate*()` **abgeleitet** (nie persistiert). Das ist sauberer als DOSSIERs
|
||||||
|
zwei-Objekt-Modell und kennt kein Cache-Stale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Gemeinsames Fundament
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/model/elements/base.ts
|
||||||
|
interface ElementBase {
|
||||||
|
id: string;
|
||||||
|
type: ElementType; // "wall" | "window" | "door" | "slab" | "stair" | "roof"
|
||||||
|
// | "column" | "beam" | "space" | "draw2d"
|
||||||
|
floorId: string; // Zeichnungsebene (Geschoss); bei gehosteten via Host
|
||||||
|
categoryCode: string; // Ebene (Grafik-Kategorie), z.B. "20"
|
||||||
|
styleId?: string; // optionaler Element-Override-Stil (resources-graphics.md)
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Geometrie-Konvention** (aus CONVENTIONS.md, im Spike etabliert): Wand-Normale
|
||||||
|
`n = leftNormal(u) = (-u.y, u.x)`; bei CCW-Wicklung zeigt `+n` nach innen.
|
||||||
|
Schichten werden außen (`-T/2`) → innen (`+T/2`) gestapelt (`generatePlan.addWallPoche`,
|
||||||
|
`Viewport3D.addWallMeshes`).
|
||||||
|
|
||||||
|
**Detailgrad** (LoD) — DOSSIERs `darstellung` (`auto|einfach|standard|detail`):
|
||||||
|
```ts
|
||||||
|
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
|
||||||
|
// Auflösung: Element-Wert "auto" → Dokument-/Snapshot-Wert; sonst Element-Wert.
|
||||||
|
function resolveDetail(el: ElementBase, doc: { detailLevel: DetailLevel }): DetailLevel
|
||||||
|
```
|
||||||
|
≙ DOSSIER `_resolve_oeff_darstellung` + `get_aktive_darstellung`. Steuert, *wie
|
||||||
|
viel* Symbolik gezeichnet wird (1:500 Rechteck → 1:50 Glas/Sims/Schwenkbogen).
|
||||||
|
|
||||||
|
**Generierungs-Signaturen** (jedes Modul exportiert beides):
|
||||||
|
```ts
|
||||||
|
function build3d(project, el, ctx): THREE.Object3D // Volumen (Schichten/Brep)
|
||||||
|
function generatePlan(project, el, ctx, lod): Primitive[] // Schnittflächen + Symbol
|
||||||
|
// ctx trägt baseElevation, joins, sichtbare Codes, resolver für Components/Styles
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Wand (Wall) — mehrschichtig
|
||||||
|
|
||||||
|
### 1.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Wall extends ElementBase {
|
||||||
|
type: "wall";
|
||||||
|
start: Vec2; end: Vec2; // Achse (Centerline) im Grundriss [im Spike]
|
||||||
|
wallTypeId: string; // → WallType.layers (außen→innen)
|
||||||
|
height: number;
|
||||||
|
reference: "mid" | "left" | "right"; // Referenzlage der Achse (DOSSIER _wand_referenz)
|
||||||
|
baseOffset?: number; // UK relativ zu OKFF (default 0)
|
||||||
|
topOffset?: number; // OK-Override (default = floorHeight)
|
||||||
|
jointRole?: "auto" | "through" | "butt"; // T-Stoss-Rolle (DOSSIER wand_joint_rolle)
|
||||||
|
// Mehrsegment-Wände (Polyline): optional axisPoints statt start/end
|
||||||
|
axisPoints?: Vec2[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`reference` verschiebt die Achse auf Außenkante/Mitte (DOSSIER
|
||||||
|
`_wall_offsets_from_referenz`): hilft beim Modellieren *und* beim Import fremder
|
||||||
|
Pläne (ROADMAP §11). Offsets: `mid → [+T/2, -T/2]`, `left → [0, -T]`, `right → [+T, 0]`.
|
||||||
|
|
||||||
|
### 1.2 Generierung — 3D + Plan (Status: im Spike, einschichtig→mehrschichtig ✅)
|
||||||
|
Beide Sichten extrudieren/füllen **dasselbe gehrte Band-Polygon** pro Schicht.
|
||||||
|
Heute schon vorhanden:
|
||||||
|
- `geometry.clippedBand(start, end, offA, offB, startCut, endCut)` — Band mit
|
||||||
|
Gehrungsschnitt.
|
||||||
|
- `generatePlan.addWallPoche` — pro Schicht ein gefülltes Polygon (Component-Fill +
|
||||||
|
Schraffur), Öffnungen ausgespart.
|
||||||
|
- `Viewport3D.addLayerPrism` — dasselbe Polygon via `ExtrudeGeometry`.
|
||||||
|
|
||||||
|
### 1.3 Wand-Verschneidung (Joins) — Risiko #1
|
||||||
|
|
||||||
|
**Status: L-Ecken-Gehrung ✅** (`joins.computeJoins` → `miterLine`, robust gegen
|
||||||
|
Wicklung + ungleiche Dicken). **Offen: Prioritäts-T-/X-Stöße** bei mehrschichtigen
|
||||||
|
Wänden.
|
||||||
|
|
||||||
|
DOSSIERs gelöste Logik (`elemente._t_junction_layer_overrides`, `_wand_should_apply_t_miter`),
|
||||||
|
die wir portieren:
|
||||||
|
|
||||||
|
1. **Knoten finden:** Endpunkte auf Gitter runden (`roundKey`, existiert),
|
||||||
|
gruppieren. `==1` freies Ende, `==2` L-Ecke (Gehrung, ✅), `>2` T/X.
|
||||||
|
2. **Through-Wand bestimmen:** an einem T-Stoß läuft genau **eine** Wand durch.
|
||||||
|
Auswahl nach `jointRole` (DOSSIER-Regel), sonst nach Component-`joinPriority`:
|
||||||
|
```
|
||||||
|
my.role="through" → ich laufe durch (kein Miter)
|
||||||
|
my.role="butt" → ich stoße an (Miter)
|
||||||
|
beide "auto" → höhere joinPriority = Through-Wand
|
||||||
|
```
|
||||||
|
3. **Schicht-Durchdringung (Backbone):** **nur das Material mit der höchsten
|
||||||
|
gemeinsamen `joinPriority`** in *beiden* Wänden läuft durch und unioniert
|
||||||
|
(T-Form). Beispiel ROADMAP §2d: Beton (800) läuft mittig durch; Putze (100)
|
||||||
|
verbinden sich seitlich, gehen aber nirgends durch den Beton. Alle Nicht-
|
||||||
|
Backbone-Schichten der anstoßenden Wand mitern an der Through-Außenkante
|
||||||
|
(`standard_miter`). Ergebnis: gleichfarbige Außenlagen bilden automatisch
|
||||||
|
saubere L-Stöße.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// joins.ts — Erweiterung der bestehenden API
|
||||||
|
interface WallCuts { startCut: Line|null; endCut: Line|null;
|
||||||
|
// neu: pro-Schicht Overrides am T-Stoss
|
||||||
|
layerExtensions?: number[]; // wie weit jede Schicht in Through-Body drillt
|
||||||
|
layerMiters?: (Line|null)[]; // pro-Schicht Mitre (null = Backbone, läuft durch)
|
||||||
|
}
|
||||||
|
function computeJoins(project, walls): Map<string, WallCuts> // erweitert
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementierungsplan (stufenweise, Risiko #1):**
|
||||||
|
- (a) ✅ L-Gehrung bleibt.
|
||||||
|
- (b) T-Stoß ohne Schichten: Backbone = ganze Wand; Through union, Stem mitert.
|
||||||
|
- (c) T-Stoß mit Schichten: Backbone-Material-Logik wie oben (Port von
|
||||||
|
`_t_junction_layer_overrides`).
|
||||||
|
- (d) X-Stoß: paarweise als zwei T behandeln.
|
||||||
|
- **Booleans:** Union/Extension der Backbone-Säule via **OpenCascade.js/Manifold**
|
||||||
|
im Worker (`workers/geometry.worker.ts`), nur für 3D + exakten B-Rep-Export; der
|
||||||
|
2D-Plan bleibt rein analytisch (Polygon-Clipping, kein Kernel) — schnell.
|
||||||
|
- **Validierung:** Screenshot-Probe der T-Ecke (Beton durch, Putz seitlich).
|
||||||
|
|
||||||
|
### 1.4 Grip-Editing (Risiko #L, Phase 3–4)
|
||||||
|
DOSSIER: Display-Conduit zeichnet dicke Marker an Achs-Endpunkten, MouseCallback
|
||||||
|
fängt Klick → `GetPoint` mit Snap → `_replace_axis_vertex` → Volumen regeneriert
|
||||||
|
(`wand_grips.py`). Browser-Port:
|
||||||
|
- **Marker:** SVG-Kreise (r ≈ 7 px) an Endpunkten/Knicks der *selektierten* Wand,
|
||||||
|
als Overlay über dem Plan (unabhängig von Ebenen-Sichtbarkeit) — exakt DOSSIERs
|
||||||
|
Conduit-Idee.
|
||||||
|
- **Hit-Test:** Pointer-Distanz < 14 px (DOSSIER `_HIT_RADIUS_PX`).
|
||||||
|
- **Drag:** `pointerdown` auf Marker → Live-Preview-Linien zu Nachbar-Vertices →
|
||||||
|
Snap (Endpunkt/Ortho/Raster) → `pointerup` → `store.apply(p => wall.start = newPt)`.
|
||||||
|
Abgeleitete Sichten (Plan + 3D) re-derivieren reaktiv — kein manuelles Regen.
|
||||||
|
- Funktioniert für Line (2 Grips) und Polyline (jeder Knick ein Grip), wie DOSSIER.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Öffnungen (Window / Door) — gehostet, LoD
|
||||||
|
|
||||||
|
### 2.1 Daten
|
||||||
|
```ts
|
||||||
|
interface OpeningBase extends ElementBase {
|
||||||
|
hostWallId: string; // Host-Wand (Geschoss ergibt sich daraus) [im Spike]
|
||||||
|
position: number; // Abstand entlang Wandachse vom Wand-Start (m)
|
||||||
|
width: number; height: number;
|
||||||
|
reference: "mid" | "left" | "right"; // Lage des Klickpunkts in der Öffnung
|
||||||
|
detailLevel: DetailLevel | "auto";
|
||||||
|
frame?: { width: number; depth: number; pos: "outer"|"mid"|"inner"; offset: number };
|
||||||
|
outerSide: "left" | "right"; // welche Wandseite ist außen
|
||||||
|
}
|
||||||
|
interface Window extends OpeningBase {
|
||||||
|
type: "window";
|
||||||
|
sill: number; // Brüstungshöhe
|
||||||
|
sashes: 1|2|3|4; // Flügelzahl
|
||||||
|
sillProfileOut?: "none"|"narrow"|"standard"|"wide"; // Sims außen (DOSSIER _OEFF_SIMS_STYLES)
|
||||||
|
sillProfileIn?: "none"|"narrow"|"standard"|"wide";
|
||||||
|
glass: boolean;
|
||||||
|
}
|
||||||
|
interface Door extends OpeningBase {
|
||||||
|
type: "door";
|
||||||
|
swing: "left" | "right"; // Anschlagseite [im Spike]
|
||||||
|
hinge: "start" | "end"; // Scharnierpfosten [im Spike]
|
||||||
|
openAngle: number; // Plan-Öffnungswinkel 0–180 (default 90)
|
||||||
|
doorType: "normal" | "wall-opening"; // Wandöffnung = ohne Blatt
|
||||||
|
frameType: "casing" | "block"; // Zarge | Blockrahmen
|
||||||
|
lintel?: "none"|"inner"|"outer"|"both";// Sturzlinien-Anzeige (DOSSIER _OEFF_STURZ)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Felder 1:1 aus DOSSIERs `_OEFF_*`-Keys + `_OEFF_STYLE_FIELDS`. Presets (Fenster
|
||||||
|
Standard/Gross/Bandlage, Tür Innen/Eingang/Verglast, Wandöffnung) als
|
||||||
|
Style-Katalog (resources-graphics.md), seed wie `_OEFF_DEFAULT_STYLES`.
|
||||||
|
|
||||||
|
### 2.2 Host-Beziehung (Risiko #2)
|
||||||
|
Die Öffnung kennt ihre Wand (`hostWallId`); ihre Geometrie wird **relativ zur
|
||||||
|
Wandachse** berechnet (`opening.axisFrame(wall, position)` → Punkt + Tangente +
|
||||||
|
Normale, ≙ DOSSIER `_oeff_axis_frame`). Verschiebt sich die Wand, folgt die
|
||||||
|
Öffnung automatisch (sie hält keinen absoluten Punkt). Beim Plan/3D wird die
|
||||||
|
Wand an `[position, position+width]` ausgespart — steht im Spike (`addWallPoche`
|
||||||
|
Segmentierung, `addWallMeshes` Sturz).
|
||||||
|
|
||||||
|
### 2.3 Generierung nach LoD
|
||||||
|
| LoD | Plan-Symbol | 3D |
|
||||||
|
|---|---|---|
|
||||||
|
| **coarse** (1:200/500) | Öffnung als Lücke + dünne Linie | Aussparung, kein Rahmen |
|
||||||
|
| **medium** (1:100) | + Rahmenlinien, Tür-Schwenkbogen (`addDoorSymbol` ✅), Sturz gestrichelt | Aussparung + einfacher Rahmen-Quader |
|
||||||
|
| **fine** (1:50) | + Glas-Doppellinie, Sims, Flügel-Teilung, Anschlag | Rahmen + Blatt + Glas (transparent) + Sims (DOSSIER `_OEFF_PIECE_DEFS`) |
|
||||||
|
|
||||||
|
- **Tür-Schwenkbogen:** im Spike (`generatePlan.addDoorSymbol` — Blatt + Arc).
|
||||||
|
Ausbau: `openAngle`, lichte vs. volle Breite je LoD (Port von
|
||||||
|
`_make_tuer_swing_curves`).
|
||||||
|
- **3D-Stücke** (Rahmen/Glas/Flügel/Sims/Sturz) ≙ DOSSIER `_make_oeffnung_pieces`
|
||||||
|
/ `_OEFF_PIECE_DEFS` — jeweils eigene Component (Farbe + Transparenz: Glas
|
||||||
|
α≈0.88, IOR 1.5). Pieces landen auf Unter-Ebenen von `21 Türen/Fenster`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Decke / Boden (Slab) — mit Aussparungen
|
||||||
|
|
||||||
|
### 3.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Slab extends ElementBase {
|
||||||
|
type: "slab";
|
||||||
|
boundary: Vec2[]; // geschlossener Umriss (CCW)
|
||||||
|
slabTypeId: string; // mehrschichtig (analog WallType)
|
||||||
|
openings?: Vec2[][]; // Aussparungen: Treppenauge, Schacht, Kamin (DOSSIER aussp)
|
||||||
|
ukOverride?: number; okOverride?: number; // UK/OK statt auto (Abhängung, schräge Brüstung)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Generierung
|
||||||
|
- **Z-Auflösung:** `okOverride ?? (baseElevation_oberes_Geschoss)`,
|
||||||
|
`ukOverride ?? (ok - thickness)` — Port von `_resolve_decke_z`. Decke sitzt
|
||||||
|
standardmäßig zwischen zwei Geschossen.
|
||||||
|
- **3D:** `boundary` als `THREE.Shape`, Aussparungen als `shape.holes` (`THREE.Path`),
|
||||||
|
`ExtrudeGeometry` über die Schichten (≙ `_make_decke_volume(outline, holes)`).
|
||||||
|
- **Plan:** im Schnitt unter `cutHeight` meist nur Kante; Aussparungs-Ränder als
|
||||||
|
Linien; geschnittene Decke (in Schnitt-Ansicht) bekommt Schraffur.
|
||||||
|
- **Aussparung↔Decke:** Aussparung als geschlossene Curve, die räumlich in der
|
||||||
|
Decke liegt (`_find_decke_containing_point` / `_find_aussparungen_for_decke`).
|
||||||
|
Bei uns: `Slab.openings` direkt im Slab — keine separate Source nötig (einfacher
|
||||||
|
als DOSSIERs Parent-Child).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Treppe (Stair) — Typen, Lauflinie, geschossübergreifend
|
||||||
|
|
||||||
|
### 4.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Stair extends ElementBase {
|
||||||
|
type: "stair";
|
||||||
|
kind: "straight" | "l-shaped" | "spiral"; // gerade | L | Wendel (DOSSIER _TREPPE_ARTEN)
|
||||||
|
run: Vec2[]; // Lauflinien-Stützpunkte (gerade: 2; L: 3; Wendel: Zentrum+Start)
|
||||||
|
width: number;
|
||||||
|
reference: "mid" | "left" | "right"; // Lage der Lauflinie zur Treppe
|
||||||
|
steps: number; // Anzahl Steigungen
|
||||||
|
mode: "solid" | "flat" | "slab-edge"; // massiv | flach | Plattenrand
|
||||||
|
runSlabThickness?: number; // Lauf-Plattendicke
|
||||||
|
floorEndId?: string; // Zielgeschoss (geschossübergreifend, Risiko #6)
|
||||||
|
heightOverride?: number; ukOverride?: number;
|
||||||
|
rules?: { riser:[lo,hi,on]; tread:[lo,hi,on]; stepGo:[lo,hi,on] }; // SIA-Komfortregeln
|
||||||
|
lockRiser?: { value: number }; // Schrittmass-Lock (S fix, N passt sich an)
|
||||||
|
// Plan-Symbol-Flags (DOSSIER _KEY_TREPPE_SHOW_*)
|
||||||
|
show?: { treads; runLine; outline; breakLine };
|
||||||
|
upperDashed?: boolean; // obere Stufen gestrichelt (über Schnitthöhe)
|
||||||
|
arrowStyle?: "classic"|"filled"|"double"|"line";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Generierung
|
||||||
|
- **Steigung/Auftritt:** `riser = height/steps`; `tread` aus Lauflinienlänge /
|
||||||
|
(steps−1). SIA-Komfort: `2·riser + tread ∈ [0.60, 0.65]` (DOSSIER
|
||||||
|
`_TREPPE_SOLL_DEFAULT`). Lock: ist `lockRiser` gesetzt, wird `steps` neu
|
||||||
|
berechnet statt `riser` zu ändern.
|
||||||
|
- **3D je `kind`:** gerade → Stapel von Tritt-Quadern oder massive Rampe;
|
||||||
|
L → zwei Läufe + Podest (`podestMin`); Wendel → um Zentrum rotierte Tritte
|
||||||
|
(Port `_make_treppe_*_preview` / Volume-Funktionen). `mode` steuert massiv vs.
|
||||||
|
Lauf-Platte.
|
||||||
|
- **Geschossübergreifend (Risiko #6):** Höhe = `(baseElevation[floorEndId] -
|
||||||
|
baseElevation[floorId])` falls `floorEndId` gesetzt; sonst Geschosshöhe. Treppe
|
||||||
|
taucht dann in beiden Geschoss-Grundrissen auf (mit Schnitt an `cutHeight`).
|
||||||
|
- **Plan-Symbol (normgerecht):** Lauflinie mit **Auf-/Abpfeil** (`arrowStyle`),
|
||||||
|
Stufenkanten, Bruchlinie an `cutHeight` (untere durchgezogen, obere gestrichelt
|
||||||
|
via `upperDashed`), Außenkante. ≙ DOSSIERs 2D-Treppensymbol; liegt auf Ebene
|
||||||
|
`40 Treppen`/`41 Treppen-2D`.
|
||||||
|
|
||||||
|
### 4.3 Grip-Editing
|
||||||
|
Lauflinien-Stützpunkte als Grips (wie Wand-Vertices, §1.4); Ziehen ändert
|
||||||
|
Geometrie + Stufenzahl reaktiv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dach (Roof)
|
||||||
|
|
||||||
|
### 5.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Roof extends ElementBase {
|
||||||
|
type: "roof";
|
||||||
|
outline: Vec2[]; // Grundriss-Umriss
|
||||||
|
roofType: "mono" | "gable" | "hip" | "mansard"; // Pult|Sattel|Walm|Mansarde
|
||||||
|
thickness: number;
|
||||||
|
slope: number; // Grad (Hauptneigung)
|
||||||
|
eaveIndex?: number; // Index der Traufkante (Pult)
|
||||||
|
ridge?: "long" | "short"; // Firstrichtung (Sattel)
|
||||||
|
// Mansarde:
|
||||||
|
slopeLower?: number; kinkHeight?: number;
|
||||||
|
mansardVariant?: "hip" | "gable" | "hip-gable";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Generierung
|
||||||
|
Port von DOSSIERs `_make_pultdach/_satteldach/_walmdach/_mansardendach*` +
|
||||||
|
`_thicken_roof_inward`. Aufwand M–L (Mansarde später). Reihenfolge: Pult →
|
||||||
|
Sattel → Walm → Mansarde. 3D als Brep/Mesh über OpenCascade.js (Worker), da
|
||||||
|
Schräg-Verschneidung Booleans braucht. Plan: Firstlinien + Traufe + ggf.
|
||||||
|
Höhenkoten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tragwerk (Column / Beam)
|
||||||
|
|
||||||
|
### 6.1 Daten
|
||||||
|
```ts
|
||||||
|
interface ProfileDef {
|
||||||
|
shape: "square"|"rect"|"round"|"i-beam"|"tube"; // DOSSIER _TRAG_PROFILE
|
||||||
|
b?: number; h?: number; d?: number; t?: number; // Breite/Höhe/Durchm./Wanddicke
|
||||||
|
angle: number; // Rotation um Z
|
||||||
|
}
|
||||||
|
interface Column extends ElementBase { type:"column"; point: Vec2; profile: ProfileDef;
|
||||||
|
uk?: number; ok?: number; }
|
||||||
|
interface Beam extends ElementBase { type:"beam"; axis:[Vec2,Vec2]; profile: ProfileDef;
|
||||||
|
zTop?: number; // hängt unter Decken-OK (zTop = ok der Decke)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Generierung
|
||||||
|
- **Querschnitt:** `profileCurve(shape, b,h,d,t, angle)` (Port `_trag_profile_curve`)
|
||||||
|
→ für Stütze entlang Z extrudieren (`_make_stuetze_volume`), für Träger entlang
|
||||||
|
der Achse (`_make_traeger_volume`, Profil in der Schnitt-Ebene).
|
||||||
|
- **Träger achs-basiert unter Decke:** `zTop` default = OK der darüberliegenden
|
||||||
|
Decke → Unterzug folgt automatisch (weniger Update-Fehler, ROADMAP §11).
|
||||||
|
- Stützen liegen auf `25 Stützen`, Träger auf `35 Träger`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Raum (Space) — SIA-416 + Stempel
|
||||||
|
|
||||||
|
### 7.1 Daten
|
||||||
|
```ts
|
||||||
|
interface Space extends ElementBase {
|
||||||
|
type: "space";
|
||||||
|
boundary: Vec2[]; // geschlossener Umriss
|
||||||
|
number?: string; spaceName?: string; function?: string;
|
||||||
|
sia?: "" | "HNF"|"NNF"|"VF"|"FF"|"GF"|"AGF"; // SIA-416-Klasse
|
||||||
|
persons?: number; // Personenbelegung (Brandschutz)
|
||||||
|
areaRounding: "exact"|"0.01"|"0.1"|"0.5"|"1";
|
||||||
|
stamp: StampConfig; // Raumstempel-Layout (s.u.)
|
||||||
|
fill?: string; // Füll-Hatch-Id (optional)
|
||||||
|
}
|
||||||
|
interface StampConfig { // ≙ DOSSIER Stempel-Builder
|
||||||
|
layout: FieldId[][]; // Zeilen × Felder, z.B. [["number","name"],["function"],["area"]]
|
||||||
|
font; bold; italic; textHeight; textMode:"fixed"|"scale"; align:"left"|"mid"|"right";
|
||||||
|
offset: Vec2; // Stempel-Position relativ zum Centroid (User-Move)
|
||||||
|
}
|
||||||
|
type FieldId = "number"|"name"|"function"|"area"|"sia";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Generierung & Bilanz
|
||||||
|
- **Fläche:** Shoelace-Formel über `boundary`, gerundet nach `areaRounding`
|
||||||
|
(`_resolve_raum_rundung`). Umfang analog.
|
||||||
|
- **Stempel:** als SVG-Text-Block aus `layout`-Zeilen am Centroid + `offset`
|
||||||
|
(User kann verschieben; Offset persistiert wie DOSSIER `stamp_dx/dy`).
|
||||||
|
`textMode:"scale"` → Texthöhe in Paper-mm × Massstab (plans-output.md).
|
||||||
|
- **SIA-Färbung:** über die **Overrides-Engine** (regelbasiert), nicht hartcodiert
|
||||||
|
— DOSSIER `_build_sia_preset_rules` erzeugt 4 Regeln `userString sia == hnf|nnf|vf|ff`
|
||||||
|
→ Farbe + Solid-Hatch. Bei uns: ein Override-Preset „SIA-416" (resources-graphics.md),
|
||||||
|
das auf `space.sia` matcht. Toggle = Preset aktivieren.
|
||||||
|
- **SIA-Bilanz + CSV:** `panels/SiaBalance.tsx` summiert Flächen je Klasse je
|
||||||
|
Geschoss → Tabelle + CSV-Export (`HNF/NNF/VF/FF/GF/AGF`). Pflicht für
|
||||||
|
CH-Flächennachweis (ROADMAP ⭐).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Werkzeuge (Tools) — ersetzt Rhino-Command-Aliases
|
||||||
|
|
||||||
|
DOSSIER hat pro Bauteil ein Command-Alias (`rhino/aliases/cmd/wand.py`, `tuer.py`,
|
||||||
|
`treppe.py`, …) das `GetPoint`-Interaktionen fährt. Browser: ein **Tool-Interface**
|
||||||
|
mit Pointer-Handlern + Snap.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Tool {
|
||||||
|
id: ToolId;
|
||||||
|
onPointerDown(pt: Vec2, snap: SnapResult, state): void;
|
||||||
|
onPointerMove(pt: Vec2, snap: SnapResult, state): Primitive[]; // Live-Preview
|
||||||
|
onPointerUp(pt: Vec2, snap: SnapResult, state): void;
|
||||||
|
commit(store): void; // ruft store.apply()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tool | DOSSIER-Alias | Kurzbeschrieb |
|
||||||
|
|---|---|---|
|
||||||
|
| `wall` | `cmd/wand` | Achse zeichnen (Linie/Polyline), Dicke/Referenz/Typ aus „last used" |
|
||||||
|
| `door`/`window` | `cmd/tuer`,`fenster` | Punkt auf Wandachse → hosten (Snap an Wand) |
|
||||||
|
| `slab` | `cmd/decke` | Umriss klicken; Aussparung als Loch |
|
||||||
|
| `stair` | `cmd/treppe` | Lauflinie + Breite + Stufen |
|
||||||
|
| `roof` | `cmd/dach` | Umriss + Typ + Neigung |
|
||||||
|
| `column`/`beam` | `cmd/stuetze`,`traeger` | Punkt / Achse + Profil |
|
||||||
|
| `space` | `cmd/raum` | Umriss → Fläche auto, Stempel |
|
||||||
|
| `draw2d` | `cmd/symbol`,`stempel` | Linie/Polyline/Rect/Kreis/Bogen/Text auf `60 Plangrafik` |
|
||||||
|
| `pipette` | `cmd/pipette` | Stil/Typ von Element übernehmen |
|
||||||
|
|
||||||
|
**Snap-Engine** (`tools/snap.ts`): Endpunkt, Mitte, Schnitt, senkrecht, Raster,
|
||||||
|
Ortho — ersetzt Rhinos OSnap. T-Snap an andere Wandachsen (Port
|
||||||
|
`_t_snap_to_wand_axis`, `_snap_endpoint_to_other_wand_axis`) sorgt für saubere
|
||||||
|
Knoten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Element-Übersicht (BIM-Tree)
|
||||||
|
`panels/ElementTree.tsx`: Baum Geschoss → Bauteiltyp → Element, mit Suche und
|
||||||
|
Shift-Klick = Zoom (DOSSIER ELEMENTE-ÜBERSICHT). Inhaltsverzeichnis bei 100+
|
||||||
|
Elementen — reine Ableitung aus `project.elements`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Reihenfolge der Umsetzung (verweist auf ROADMAP-Phasen)
|
||||||
|
|
||||||
|
1. **Phase 1:** Wand mehrschichtig ✅ + L-Gehrung ✅ → **Prio-T-Stoß** (§1.3);
|
||||||
|
Tür/Fenster gehostet (§2); Decke + Aussparung (§3); Wand-Referenzlage (§1.1);
|
||||||
|
Element-Übersicht (§9).
|
||||||
|
2. **Phase 2:** Treppe (§4), Dach (§5), Tragwerk (§6), SIA-Räume + Stempel (§7),
|
||||||
|
Stil-Kataloge.
|
||||||
|
3. **Phase 3–4:** Grip-Editing (§1.4/§4.3), exakte B-Rep-Booleans im Worker.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Engine-Nordstern — Headless-Rendering (PNG-Export & Golden-Image-Tests)
|
||||||
|
|
||||||
|
> Betrifft `src-tauri/render2d` (Feature `headless`). Bezug: HANDOVER.md,
|
||||||
|
> Abschnitt ENGINE-NORDSTERN, Punkt 3 ("deterministisches Headless-Rendering,
|
||||||
|
> PNG-Export und Golden-Image-Tests ohne Fenster").
|
||||||
|
|
||||||
|
## Warum
|
||||||
|
|
||||||
|
`render2d` trennt die serde-only-Tessellierung (Feature-los, headless testbar)
|
||||||
|
von der GPU-Schicht (Feature `render`, wgpu). Der Fenster-Pfad (`gpu::Renderer`,
|
||||||
|
Feature `window`) braucht dafür bislang eine `wgpu::Surface` — also ein echtes
|
||||||
|
Fenster mit Wayland-/X11-Session. Für deterministische PNG-Exporte und
|
||||||
|
Golden-Image-Tests (CI, Regressions-Screenshots) ist das unnötig: wgpu kann
|
||||||
|
genauso gut in eine `wgpu::Texture` rendern, ganz ohne Surface/Fenster.
|
||||||
|
|
||||||
|
`gpu::Renderer::render` nahm bereits vorher nur `Device`/`Queue`/`TextureView`
|
||||||
|
entgegen — Surface- oder Offscreen-Textur macht für den Draw-Code keinen
|
||||||
|
Unterschied. Der Offscreen-Pfad (`src/headless.rs`) dupliziert daher NICHTS,
|
||||||
|
sondern baut nur Device/Queue ohne Surface sowie eine eigene Ziel-Textur +
|
||||||
|
Buffer-Readback drumherum.
|
||||||
|
|
||||||
|
## Feature-Gating
|
||||||
|
|
||||||
|
Neues Cargo-Feature `headless = ["render", "dep:image"]`:
|
||||||
|
- zieht `render` (wgpu/glyphon/bytemuck/pollster) plus die `image`-Crate
|
||||||
|
(nur der PNG-Codec, `default-features = false, features = ["png"]`).
|
||||||
|
- **berührt den wasm/web-Build nicht**: `cargo check --target wasm32-unknown-unknown
|
||||||
|
--no-default-features --features web` zieht `image` nicht mit.
|
||||||
|
- Binary `render_png` und Test `golden` sind zusätzlich per
|
||||||
|
`required-features = ["headless"]` in `Cargo.toml` abgesichert.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct HeadlessRenderer { /* Device, Queue, gpu::Renderer */ }
|
||||||
|
|
||||||
|
impl HeadlessRenderer {
|
||||||
|
/// Instance/Adapter/Device OHNE Surface (Backend fest auf Vulkan gepinnt).
|
||||||
|
/// `Err`, wenn kein Adapter verfügbar ist (z.B. CI-Runner ohne GPU) — die
|
||||||
|
/// Aufrufer (CLI, Golden-Test) behandeln das, statt zu paniken.
|
||||||
|
pub fn new() -> Result<Self, String>;
|
||||||
|
|
||||||
|
/// Rendert `scene` in ein `width`x`height`-Bild (Papier-Maßstab
|
||||||
|
/// `paper_scale_n`, z.B. `100.0` für 1:100). Lädt die Szene bei jedem Aufruf
|
||||||
|
/// neu hoch (kein Zwischenzustand nötig für CLI-/Test-Anwendungsfall).
|
||||||
|
pub fn render_to_image(
|
||||||
|
&mut self,
|
||||||
|
scene: &Scene,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
view_box: ViewBox,
|
||||||
|
paper_scale_n: f32,
|
||||||
|
) -> RgbaImage; // { width, height, pixels: Vec<u8> (straff gepackt, RGBA8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RgbaImage {
|
||||||
|
pub fn encode_png(&self) -> Vec<u8>;
|
||||||
|
/// Gegenstück für den Golden-Test: Referenz-PNG -> straff gepacktes RGBA8.
|
||||||
|
pub fn decode_png(bytes: &[u8]) -> Result<Self, String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend bewusst auf **Vulkan** gepinnt (nicht das Standard-Backend-Set): Vulkan
|
||||||
|
rendert offscreen ohne jede Fenster-/Display-Server-Abhängigkeit. GL bräuchte auf
|
||||||
|
Linux i.d.R. einen EGL/GLX-Kontext, der ohne aktive Display-Session (X11/Wayland)
|
||||||
|
Sonderfälle hat — auf dieser Maschine ist ohnehin ein echter Vulkan-ICD (RADV)
|
||||||
|
vorhanden, daher kein Rückgriff auf `lavapipe`/`llvmpipe` nötig gewesen.
|
||||||
|
|
||||||
|
## Row-Alignment-Falle (wichtig für zukünftige Agenten)
|
||||||
|
|
||||||
|
`wgpu::Queue::copy_texture_to_buffer` / `CommandEncoder::copy_texture_to_buffer`
|
||||||
|
verlangt, dass `bytes_per_row` im `ImageDataLayout` ein Vielfaches von
|
||||||
|
`wgpu::COPY_BYTES_PER_ROW_ALIGNMENT` (256) ist. Bei RGBA8 (4 Byte/Pixel) trifft
|
||||||
|
das **nur zufällig** zu — z.B. Breite 300 px → 1200 Byte/Zeile, kein Vielfaches
|
||||||
|
von 256, der Copy schlägt sonst mit einem Validierungsfehler fehl (oder liefert
|
||||||
|
verzerrte Zeilen, je nach Backend).
|
||||||
|
|
||||||
|
Lösung in `headless::HeadlessRenderer::read_pixels`:
|
||||||
|
1. `padded_bytes_per_row = align_up(width * 4, 256)` — der Zielpuffer wird auf
|
||||||
|
diese (größere oder gleiche) Zeilenbreite alloziert.
|
||||||
|
2. Nach dem Mapping wird **jede Zeile einzeln** von `padded_bytes_per_row` auf
|
||||||
|
die echte Breite (`width * 4`) zurückgeschnitten und in einen straff
|
||||||
|
gepackten `Vec<u8>` kopiert — sonst hätte das Ausgabebild pro Zeile
|
||||||
|
Garbage-Padding am rechten Rand.
|
||||||
|
|
||||||
|
## Referenzbild neu erzeugen
|
||||||
|
|
||||||
|
Bei einer **gewollten** Rendering-Änderung (z.B. neue Shader, neue Demo-Szene,
|
||||||
|
geänderte Antialiasing-Parameter) weicht das Golden-Bild ab. Referenzbild
|
||||||
|
danach bewusst neu erzeugen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd src-tauri/render2d
|
||||||
|
cargo run --features headless --bin render_png -- --width 990 --height 630 --out target/headless-demo.png
|
||||||
|
cp target/headless-demo.png tests/golden/demo.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Vorher unbedingt das erzeugte PNG (`target/headless-demo.png`) visuell prüfen
|
||||||
|
(z.B. mit einem Bildbetrachter oder Read-Tool eines Agenten) — nicht blind
|
||||||
|
kopieren. Bei einem fehlgeschlagenen Testlauf schreibt der Golden-Test
|
||||||
|
automatisch ein Diff-Bild nach `target/golden-diff.png` (abweichende Pixel
|
||||||
|
signalrot markiert, Rest unverändert) — das hilft beim Einordnen, ob die
|
||||||
|
Abweichung erwartet (Layout-/Farbänderung) oder ein Bug ist (z.B. verschobene
|
||||||
|
Geometrie, fehlender Text).
|
||||||
|
|
||||||
|
## Golden-Test
|
||||||
|
|
||||||
|
`tests/golden.rs`, Test `demo_szene_entspricht_referenzbild`:
|
||||||
|
- rendert die Demo-Szene (`demo::demo_scene`, dieselbe wie der Fenster-Spike)
|
||||||
|
in 990×630 und vergleicht sie gegen `tests/golden/demo.png`.
|
||||||
|
- Toleranz: ein Pixel gilt als "abweichend", wenn irgendein Kanal-Delta > 2 ist
|
||||||
|
(deckt AA-Rundungsrauschen ab); der Test schlägt fehl, wenn mehr als 0,5%
|
||||||
|
aller Pixel abweichen.
|
||||||
|
- `#[ignore]`, weil eine Vulkan-fähige GPU auf CI-Runnern nicht garantiert ist
|
||||||
|
(und ein Software-Rasterizer wie llvmpipe anderes AA liefern würde als das
|
||||||
|
Referenzbild). Lokal explizit ausführen:
|
||||||
|
`cargo test --features headless -- --include-ignored`.
|
||||||
|
- Bei Abweichung über der Toleranz schreibt der Test zusätzlich zum Diff-Bild
|
||||||
|
auch das Ist-Bild nach `target/golden-actual.png` — nach visueller Prüfung
|
||||||
|
ist das die Vorlage für eine gewollte Referenz-Aktualisierung.
|
||||||
|
- `HeadlessRenderer::new()` liefert bei fehlendem Adapter `Err` statt Panik —
|
||||||
|
der Test überspringt sich dann sauber (`eprintln!` + `return`) statt rot zu
|
||||||
|
schlagen, falls doch mal ohne `--ignored`/ohne GPU ausgeführt.
|
||||||
|
|
||||||
|
## Offener Punkt: Text-Determinismus zwischen GPU-Treibern
|
||||||
|
|
||||||
|
Der Textpass läuft über `glyphon`/`cosmic-text` (Systemfonts, `Inter`). Die
|
||||||
|
exakte Glyphen-Rasterisierung (Subpixel-Antialiasing, Hinting) kann je nach
|
||||||
|
installierter Font-Version, Fontconfig-Konfiguration und GPU-Treiber leicht
|
||||||
|
variieren — auch bei identischer Geometrie. Auf **derselben** Maschine (gleicher
|
||||||
|
Treiber, gleiche Font-Version) ist der Test wie erwartet bit-nah deterministisch
|
||||||
|
(hier: 0 abweichende Pixel bei zwei aufeinanderfolgenden Läufen). Bei einem
|
||||||
|
Wechsel der Maschine/des Treibers/der Fontconfig-Version ist nicht
|
||||||
|
auszuschließen, dass die 0,5%-Toleranz für Textkanten knapp wird — sollte das
|
||||||
|
in der Praxis auftreten: entweder Toleranz für den Text-Bereich lockern, oder
|
||||||
|
den Text testweise aus der Golden-Szene ausklammern und separat (z.B. nur
|
||||||
|
Zeilenbreite/Position, nicht Pixel) prüfen.
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
# Engine-Nordstern 1 — Strichbreiten-Audit (Papier-mm end-to-end)
|
||||||
|
|
||||||
|
> Bezug: HANDOVER.md, Abschnitt ENGINE-NORDSTERN Punkt 1 ("Papier-mm-exakte
|
||||||
|
> Strichbreiten überall — Bildschirm bei jedem Massstab/Zoom = Druck"). Dieses
|
||||||
|
> Dokument ist ein Prüfbericht, kein Umbau — es fasst zusammen, wie die
|
||||||
|
> Strichbreite heute vom Modell bis zum Papier läuft, wo sie divergieren kann,
|
||||||
|
> und was konkret zu tun wäre. Nichts hieraus wurde umgesetzt.
|
||||||
|
|
||||||
|
Betroffene Pfade: `src/plan/PlanView.tsx` (SVG-Default + Print-Vorschau),
|
||||||
|
`src/plan/glPlan/*` (WebGL2, aktueller Default-GPU-Pfad), `src-tauri/render2d`
|
||||||
|
(WASM/WebGPU, `?engine=wasm`), `src/export/sceneToPrintSvg.ts` +
|
||||||
|
`src/export/exportPdf.ts` (Vektor-PDF). Referenz-Baseline für die
|
||||||
|
2D-Engine-Parität ist laut Commit `ce6bd26` **`?gl=0`** (reiner SVG-Pfad) —
|
||||||
|
NICHT der App-Default (der ist WebGL2, siehe Finding 1).
|
||||||
|
|
||||||
|
## 1. Modell der Wahrheit
|
||||||
|
|
||||||
|
So sollte eine Strichbreite in diesem Code korrekt fliessen:
|
||||||
|
|
||||||
|
1. **Quelle**: `generatePlan.ts` legt jede Strichbreite als `weightMm` /
|
||||||
|
`strokeWidthMm` in **echten Papier-Millimetern** ab (Kommentarkopf,
|
||||||
|
`generatePlan.ts:76-79`: "Alle Stricharten sind in mm Papier definiert").
|
||||||
|
Diese Zahl ist unabhängig von Zoom, Massstab und Render-Pfad — eine 0.18 mm
|
||||||
|
Wand-Umrisslinie bleibt 0.18 mm, ganz gleich wo sie später landet.
|
||||||
|
2. **Bildschirm bei Massstab 1:N**: 1 Modell-Meter entspricht auf Papier
|
||||||
|
`1000/N` mm. Der Bildschirm zeigt `PX_PER_M = 90` viewBox-Einheiten je
|
||||||
|
Modell-Meter (`PlanView.tsx:23`). Eine `mm`-Breite belegt daher
|
||||||
|
`mm · N/1000 · PX_PER_M` viewBox-Einheiten — das ist exakt
|
||||||
|
`printStrokeVb()` (`PlanView.tsx:2598-2600`). Multipliziert mit der
|
||||||
|
Geräte-px-je-viewBox-Einheit-Skala (`meet`, `PlanView.tsx:2074-2078`) ergibt
|
||||||
|
das die tatsächliche Bildschirmbreite in Geräte-Pixeln. Reinzoomen (kleinere
|
||||||
|
viewBox-Breite, grösseres `meet`) macht die Linie dicker — genau wie beim
|
||||||
|
Herausvergrössern eines gedruckten Plans mit der Lupe. Das gilt für JEDE
|
||||||
|
Zoomstufe gleichermassen: 250 % Zoom bei 1:50 zeigt exakt die 5-fache
|
||||||
|
Pixelbreite von 100 % Zoom bei 1:50, und bei gegebenem Zoom ist 1:50 exakt
|
||||||
|
doppelt so dick wie 1:100 (halber Nenner → doppelt so viele Weltmeter je
|
||||||
|
Papiermm).
|
||||||
|
3. **Druck/PDF**: dieselbe Formel, nur ohne Geräte-px-Zwischenschritt — direkt
|
||||||
|
`mm` bleibt `mm` — die Seite ist direkt in echten
|
||||||
|
Papiermillimetern aufgespannt (`sceneToPrintSvg.ts:99-125`). Zusätzlich wird
|
||||||
|
auf ISO-nahe Stiftstufen gerundet (`PEN_STEPS`,
|
||||||
|
`sceneToPrintSvg.ts:44`), weil ein reales Zeichengerät/Plotter nur endlich
|
||||||
|
viele Stiftbreiten kennt.
|
||||||
|
4. **Eine Wahrheit**: Seit `382771b` bauen sowohl der Viewport
|
||||||
|
(`useWasmPlanRenderer.ts`/`nativeSync.ts`) als auch der PDF-Export
|
||||||
|
(`exportPdf.ts:73`) dieselbe `planToRenderScene(plan)`-Szene — der PDF-Pfad
|
||||||
|
ist nur ein anderes *Ziel* derselben Szene, kein zweiter Interpret.
|
||||||
|
|
||||||
|
Korrekt hiesse also: **jede** der vier Anzeige-/Exportarten (SVG-Default,
|
||||||
|
SVG-Print-Vorschau, GPU-Viewport, PDF) muss aus **derselben** `weightMm`, für
|
||||||
|
**denselben** `N`, exakt dieselbe Papier-mm-Breite ergeben — bis auf die
|
||||||
|
bewusste PEN_STEPS-Rundung im Druckpfad, die dokumentiert und überall
|
||||||
|
gleichermassen sichtbar sein sollte (ist sie nicht, siehe Finding 2).
|
||||||
|
|
||||||
|
Ausdrücklich **kein** Teil dieses Modells: der Haarlinien-Modus
|
||||||
|
(`lineMode: "display"`, App-Default, `viewSlice.ts:38,74`). Er ist als
|
||||||
|
bewusster Papier-mm-*Ausstieg* gedacht ("Display: all lines as constant
|
||||||
|
hairlines (calm editing)", `en.ts:173`) — 1 Geräte-px, konstant, unabhängig
|
||||||
|
von Zoom/Massstab. Er muss also NICHT der Papier-mm-Formel folgen, aber er
|
||||||
|
muss in JEDEM Render-Pfad *gleichermassen* als Ausstieg wirken. Tut er nicht
|
||||||
|
(Finding 1).
|
||||||
|
|
||||||
|
## 2. Pfad-für-Pfad-Trace
|
||||||
|
|
||||||
|
### 2a. SVG-Default (App-Start ohne `?gl=0`, `lineMode:"display"`)
|
||||||
|
|
||||||
|
Nur aktiv, wenn WebGL2 fehlschlägt (`wantGl` ist sonst `true`, s. Finding 1) —
|
||||||
|
de facto der reine Fallback-Pfad.
|
||||||
|
|
||||||
|
- `PlanView.tsx:2613`: `print = !hairline && paperScale != null && paperScale > 0`
|
||||||
|
— mit `hairline=true` (Default) ist `print` immer `false`.
|
||||||
|
- `PlanView.tsx:2617-2618`:
|
||||||
|
```ts
|
||||||
|
const weight = (mm: number): number =>
|
||||||
|
hairline ? HAIRLINE_PX : print ? printStrokeVb(mm, paperScale!) : mmToPx(mm);
|
||||||
|
```
|
||||||
|
`HAIRLINE_PX = 1` (`PlanView.tsx:2583`), Einheit: Geräte-unabhängiger
|
||||||
|
CSS-Pixel, gezeichnet mit `vector-effect="non-scaling-stroke"`
|
||||||
|
(`PlanView.tsx:2621`, `vfx`) → bleibt beim Pan/Zoom optisch exakt 1 px, egal
|
||||||
|
wie stark reingezoomt wird. Papier-mm spielt hier explizit KEINE Rolle.
|
||||||
|
|
||||||
|
### 2b. SVG-Print-Vorschau (`lineMode:"print"`, weiterhin `?gl=0` oder
|
||||||
|
WebGL2-Fallback)
|
||||||
|
|
||||||
|
- `print = true`, `weight(mm) = printStrokeVb(mm, paperScale!)`
|
||||||
|
(`PlanView.tsx:2598-2600`):
|
||||||
|
```ts
|
||||||
|
function printStrokeVb(mm: number, n: number): number {
|
||||||
|
return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`vectorEffect` entfällt (`vfx = undefined`, `PlanView.tsx:2621`) — die Linie
|
||||||
|
skaliert MIT der Geometrie beim Zoomen, wie physisches Papier unter der
|
||||||
|
Lupe. `paperScale` selbst ist der **stabile, gemessene** 1:N-Nenner
|
||||||
|
(`PlanView.tsx:458-460`), nicht der live aus jedem Radzoom abgeleitete Wert
|
||||||
|
— sonst würde Reinzoomen die Linien nicht dicker, sondern konstant halten
|
||||||
|
(das wäre wieder Haarlinien-Verhalten). Keine PEN_STEPS-Rundung — die rohe
|
||||||
|
`mm`-Zahl wird 1:1 in viewBox-Einheiten übersetzt.
|
||||||
|
|
||||||
|
### 2c. GPU-Viewport (WebGL2 `useGlPlanRenderer.ts` — App-DEFAULT; WASM
|
||||||
|
`useWasmPlanRenderer.ts` bei `?engine=wasm`)
|
||||||
|
|
||||||
|
- Beide Hooks reichen `paperScaleN` unverändert an die Engine durch:
|
||||||
|
`useWasmPlanRenderer.ts:131-149` (`render(viewBox, paperScaleN=100,
|
||||||
|
textScaleN=100)` → `r.set_paper_scale(paperScaleN)`); WebGL2 analog
|
||||||
|
(`useGlPlanRenderer.ts:102-116`, kein `textScaleN`-Parameter überhaupt, s.
|
||||||
|
Finding 3).
|
||||||
|
- `PlanView.tsx` ruft in JEDEM Render-Aufruf
|
||||||
|
(`PlanView.tsx:494`,`502`,`1080`) `renderGl(view, paperScaleForGl(view),
|
||||||
|
textScaleForGl())`. `paperScaleForGl` (`PlanView.tsx:475-476`):
|
||||||
|
```ts
|
||||||
|
const paperScaleForGl = (v: ViewBox): number =>
|
||||||
|
paperScaleRef.current ?? scaleFromView(v, svgRef.current) ?? 100;
|
||||||
|
```
|
||||||
|
**Kein `hairline`-Zweig.** Egal ob `lineMode` "display" oder "print" ist,
|
||||||
|
hier kommt immer ein echter 1:N-Papier-Nenner heraus.
|
||||||
|
- Rust-Seite (`gpu.rs:606-616`):
|
||||||
|
```rust
|
||||||
|
let mm_px = mm_to_device_px(view_box, vw, vh, self.paper_scale_n);
|
||||||
|
```
|
||||||
|
`mm_to_device_px` (`ortho.rs:96-99`) reproduziert exakt `printStrokeVb`:
|
||||||
|
`(paper_scale_n/1000.0) * PX_PER_M * meet` — `PX_PER_M = 90.0`
|
||||||
|
(`tessellate.rs:22`, identisch zu TS). Die reine Mathematik ist also
|
||||||
|
deckungsgleich zum SVG-Print-Pfad (2b) — aber sie läuft **immer**, auch wenn
|
||||||
|
der Nutzer "Display: Haarlinien" gewählt hat. Siehe Finding 1.
|
||||||
|
- Stiftbreite pro Batch im Shader (`shaders.rs:91`, `LINE_WGSL`):
|
||||||
|
`width_px = max(0.6, stroke_px * stroke_scale) * miter` — harte Untergrenze
|
||||||
|
0.6 Geräte-px, die weder die SVG- noch die PDF-Seite kennt (Finding 6).
|
||||||
|
Identische Formel für Bögen (`shaders.rs:209`, `ARC_WGSL`) und im
|
||||||
|
WebGL2-Pfad (`glPlanRender.ts:243-249`, Kommentar "klemmt bei ~0.6 px").
|
||||||
|
- Schraffur-Musterlinien laufen NICHT über `mm_px`, sondern über
|
||||||
|
`width_screen`/`px_per_screen` (`gpu.rs:661-665`): Geräte-px = Breite ×
|
||||||
|
`meet`-Skala statt × mm→px — bewusst zoom-skalierend wie das SVG-`<pattern>`,
|
||||||
|
nicht papierkonstant (s. `toRenderScene.ts:371-375`, Finding 4).
|
||||||
|
|
||||||
|
### 2d. Vektor-PDF (`exportPdf.ts` → `sceneToPrintSvg.ts`)
|
||||||
|
|
||||||
|
- `exportPdf.ts:73`: `const scene = planToRenderScene(plan);` — dieselbe
|
||||||
|
Szene wie 2c, unabhängig vom aktuell gewählten `lineMode` der Ansicht.
|
||||||
|
- `sceneToPrintSvg.ts:243-244`:
|
||||||
|
```ts
|
||||||
|
const effectiveMm = widthScreen ? (widthMm / PX_PER_M) * mmPerM : widthMm;
|
||||||
|
const strokeMm = quantizePen(effectiveMm);
|
||||||
|
```
|
||||||
|
`PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0]`, `MIN_PEN_MM = 0.13`
|
||||||
|
(`sceneToPrintSvg.ts:44,47`). JEDE Linie/Umriss/Bogen/Schraffur wird auf die
|
||||||
|
nächsthöhere Stufe gerundet, bevor sie ins SVG/PDF geht
|
||||||
|
(`sceneToPrintSvg.ts:251,274,301`). Das ist der EINZIGE der vier Pfade, der
|
||||||
|
überhaupt quantisiert.
|
||||||
|
- `widthScreen`-Konvertierung: `widthMm` (hier eigentlich viewBox-Einheiten,
|
||||||
|
siehe 2c) wird über `/PX_PER_M * mmPerM` zurück in Weltmeter und dann in
|
||||||
|
Papier-mm beim GEWÄHLTEN `opts.scaleDenominator` übersetzt — nicht beim
|
||||||
|
Massstab, den die Live-Ansicht gerade zeigt (Finding 4).
|
||||||
|
- Text: `sizeMm` kommt unverändert aus `RText.sizeMm`
|
||||||
|
(`sceneToPrintSvg.ts:325`, `t.sizeMm`, keine Nachskalierung) — aber die
|
||||||
|
VERTIKALE Zeilenposition dieser Texte wurde in `toRenderScene.ts` mit einem
|
||||||
|
festen `STAMP_REF_N = 100` in Weltmeter umgerechnet (`toRenderScene.ts:163-165,
|
||||||
|
481-483`), unabhängig vom tatsächlich gewählten Export-`N` (Finding 3).
|
||||||
|
|
||||||
|
## 3. Findings (nach Schwere geordnet)
|
||||||
|
|
||||||
|
### 1. Haarlinien-Modus (App-Default) wird vom GPU-Renderer komplett ignoriert — betrifft den Standard-Zustand der App
|
||||||
|
|
||||||
|
`lineMode` startet auf `"display"` (`viewSlice.ts:74`), und WebGL2 ist der
|
||||||
|
Default-Renderer (`wantGl` ist `true`, ausser `?gl=0`,
|
||||||
|
`PlanView.tsx:433-437`) — **d. h. im frisch geladenen, unkonfigurierten App-
|
||||||
|
Zustand rendert bereits der GPU-Pfad, und die "Display: Haarlinien"-Option
|
||||||
|
tut nichts.** `paperScaleForGl` (`PlanView.tsx:475-476`) liest nur
|
||||||
|
`paperScaleRef.current ?? scaleFromView(...) ?? 100` — der `hairline`-Boolean
|
||||||
|
aus den Props (`PlanView.tsx:2571`) wird an dieser Stelle nie geprüft, obwohl
|
||||||
|
das benachbarte `textScaleForGl` (`PlanView.tsx:485-488`) exakt diesen Zweig
|
||||||
|
korrekt hat:
|
||||||
|
```ts
|
||||||
|
const textScaleForGl = (): number => {
|
||||||
|
const print = !hairline && paperScale != null && paperScale > 0;
|
||||||
|
return print ? paperScale! : 100;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
Ergebnis: Text (Raumstempel) fällt im Haarlinien-Modus korrekt auf die
|
||||||
|
Referenzskala 100 zurück, aber jede Wand-/Tür-/2D-Zeichenlinie wird trotzdem
|
||||||
|
mit dem echten (gemessenen oder geschätzten) `paperScale` gezeichnet — sie
|
||||||
|
wird beim Reinzoomen dicker statt konstant zu bleiben, exakt das Gegenteil
|
||||||
|
dessen, was der Menüpunkt verspricht ("calm editing", `en.ts:173`). Der Bug
|
||||||
|
betrifft sowohl `useGlPlanRenderer.ts` (Default) als auch
|
||||||
|
`useWasmPlanRenderer.ts` (`?engine=wasm`) — keiner der beiden `render()`-
|
||||||
|
Aufrufe kennt einen `hairline`-Parameter überhaupt.
|
||||||
|
|
||||||
|
### 2. PEN_STEPS-Quantisierung existiert NUR im PDF-Export — beide Live-Vorschauen (SVG-Print und GPU) zeigen unquantisierte Rohwerte
|
||||||
|
|
||||||
|
Konkret, mit den tatsächlichen Konstanten aus `generatePlan.ts`:
|
||||||
|
|
||||||
|
- `LAYER_LINE_MM = 0.13` (`generatePlan.ts:82`), `LAYER_DETAIL_FACTOR.fein =
|
||||||
|
0.7` (`generatePlan.ts:90-93`) → Schichtfuge im Detailgrad "fein":
|
||||||
|
`0.13 · 0.7 = 0.091 mm` (`generatePlan.ts:1022`,
|
||||||
|
`strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail]`). Die
|
||||||
|
Bildschirm-Print-Vorschau zeigt genau diese 0.091 mm (`printStrokeVb(0.091,
|
||||||
|
N)`); der PDF-Export klemmt via `quantizePen` auf `MIN_PEN_MM = 0.13` mm —
|
||||||
|
**+43 % dicker im Druck als in der Vorschau.**
|
||||||
|
- Türschwenkbogen: `doorLwMm * 0.6` (`generatePlan.ts:902`) mit dem üblichen
|
||||||
|
Fallback `doorLwMm = LAYER_LINE_MM = 0.13` (`generatePlan.ts:354`) ergibt
|
||||||
|
`0.078 mm`. Screen zeigt 0.078 mm (nahezu unsichtbar dünn bei kleinen
|
||||||
|
Massstäben), PDF klemmt auf 0.13 mm — **+67 %.**
|
||||||
|
- Wand-Umriss im Detailgrad "grob": `outlineMm = wallLwMm ·
|
||||||
|
OUTLINE_DETAIL_FACTOR.grob (1.6)` (`generatePlan.ts:84-88, 648, 796`); beim
|
||||||
|
häufigen Fallback `WALL_FALLBACK_MM = 0.18` (`generatePlan.ts:97`) ergibt das
|
||||||
|
`0.288 mm`. `quantizePen(0.288)` rundet auf die nächste Stufe `0.35`
|
||||||
|
(`PEN_STEPS`, da `0.25 < 0.288 ≤ 0.35`) — **+21.5 % im Druck.**
|
||||||
|
|
||||||
|
Das systematische Muster: der Detailgrad "fein" existiert explizit, um
|
||||||
|
Nebenlinien DÜNNER zu machen (`generatePlan.ts:52-58`,
|
||||||
|
`LAYER_DETAIL_FACTOR.fein = 0.7`) — aber sobald der berechnete Wert unter
|
||||||
|
`MIN_PEN_MM` fällt, hebt die PDF-Quantisierung ihn wieder auf die Untergrenze
|
||||||
|
an, ohne dass die Bildschirm-Vorschau (weder SVG-Print noch GPU) davon
|
||||||
|
irgendetwas zeigt. Wer die Druckvorschau am Bildschirm beurteilt, sieht NICHT,
|
||||||
|
was tatsächlich gedruckt wird.
|
||||||
|
|
||||||
|
### 3. Stempel-Zeilenabstand ist nur bei Massstab 1:100 exakt — SVG- und RenderScene-Pfad nutzen zwei verschiedene Formeln für dieselbe Grösse
|
||||||
|
|
||||||
|
Bereits als bekannte Alt-Lücke in HANDOVER.md (Commit `382771b`, Punkt 6)
|
||||||
|
vermerkt; hier präzise verortet:
|
||||||
|
|
||||||
|
- SVG (`PlanView.tsx:2738-2739`):
|
||||||
|
```ts
|
||||||
|
const nRef = print ? paperScale! : 100;
|
||||||
|
const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M;
|
||||||
|
```
|
||||||
|
`nRef` folgt im Druckmodus dem TATSÄCHLICH aktiven `paperScale` — der
|
||||||
|
Zeilenabstand (`lineGap = baseFs * 1.3`, `PlanView.tsx:2741`) ist für jedes
|
||||||
|
`N` korrekt in viewBox-Einheiten, weil er direkt aus `unitPerPt` (das `N`
|
||||||
|
enthält) abgeleitet wird.
|
||||||
|
- RenderScene (`toRenderScene.ts:163-165`):
|
||||||
|
```ts
|
||||||
|
const STAMP_REF_N = 100;
|
||||||
|
const MM_TO_M = STAMP_REF_N / 1000;
|
||||||
|
```
|
||||||
|
und (`toRenderScene.ts:481-483`):
|
||||||
|
```ts
|
||||||
|
const baseH = baseMm * MM_TO_M; // Basisgröße in Modell-Metern
|
||||||
|
const lineGap = baseH * 1.3;
|
||||||
|
```
|
||||||
|
Hier ist der Umrechnungsfaktor `MM_TO_M` FEST auf `N = 100` verdrahtet,
|
||||||
|
unabhängig davon, mit welchem `N` der WASM-Viewport oder der PDF-Export
|
||||||
|
tatsächlich rendert (`paper_scale_n` in `gpu.rs`, `opts.scaleDenominator` in
|
||||||
|
`sceneToPrintSvg.ts`). Bei `N = 100` sind beide Formeln identisch (das war
|
||||||
|
offenbar der Verifikationsfall in `382771b`); bei jedem anderen `N` — z. B.
|
||||||
|
1:50 — driftet der vertikale Zeilenabstand der Stempel-Mehrzeiler
|
||||||
|
proportional zum Verhältnis `N_wahr/100` auseinander, während die einzelne
|
||||||
|
Zeilenhöhe (`sizeMm`) selbst korrekt bleibt. Betroffen: WASM-Viewport (2c)
|
||||||
|
UND PDF-Export (2d) gleichermassen (beide beziehen `RText` aus derselben
|
||||||
|
`toRenderScene`-Funktion); die WebGL2-Ansicht ist NICHT betroffen, weil sie
|
||||||
|
Text grundsätzlich nicht selbst zeichnet, sondern die SVG-Overlay-Ebene
|
||||||
|
weiterverwendet (`PlanView.tsx:1592`,
|
||||||
|
`useGpuRenderer ? wantWasm || p.kind !== "text" : ...` — bei WebGL2
|
||||||
|
(`wantWasm=false`) werden Text-Primitive NIE aus dem SVG-Rendering
|
||||||
|
herausgefiltert).
|
||||||
|
|
||||||
|
### 4. Schraffur-Strichbreite im PDF hängt vom GEWÄHLTEN Export-Massstab ab, nicht vom Massstab der Live-Vorschau
|
||||||
|
|
||||||
|
`hatchPx` wird in `toRenderScene.ts:374-375` genau einmal, massstabsunabhängig
|
||||||
|
berechnet:
|
||||||
|
```ts
|
||||||
|
const hatchMm = p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13;
|
||||||
|
const hatchPx = Math.max(0.6, hatchMm * (1 / 0.13));
|
||||||
|
```
|
||||||
|
— eine reine viewBox-Grösse (analog zum SVG-`<pattern>`-Strich, der
|
||||||
|
absichtlich mit dem Zoom mitskaliert, damit die Schraffur bei jedem Zoom
|
||||||
|
gleich dicht aussieht: Kommentar `toRenderScene.ts:371-373`). Erst beim
|
||||||
|
Export wird daraus in `sceneToPrintSvg.ts:243`
|
||||||
|
`effectiveMm = (widthMm / PX_PER_M) * mmPerM` — und `mmPerM = 1000 /
|
||||||
|
opts.scaleDenominator` (`sceneToPrintSvg.ts:101`) verwendet den vom
|
||||||
|
Export-Dialog gewählten Massstab, NICHT den `paperScale`, den der Nutzer
|
||||||
|
gerade in der "Print"-Live-Vorschau sieht (`exportPdf.ts` ruft
|
||||||
|
`sceneToPrintSvg` mit `opts.scaleDenominator` aus den Export-Optionen,
|
||||||
|
`exportPdf.ts:61-79`, völlig unabhängig vom `PlanView`-State).
|
||||||
|
|
||||||
|
Beispiel: `lineWeight = 0.13` mm (Standard-Fuge, `getHatch`-Default) →
|
||||||
|
`hatchPx = max(0.6, 0.13 · (1/0.13)) = 1.0` viewBox-Einheiten.
|
||||||
|
- Export bei 1:50 (`mmPerM = 20`): `effectiveMm = (1.0/90)·20 = 0.222 mm` →
|
||||||
|
`quantizePen` → **0.25 mm**.
|
||||||
|
- Export bei 1:200 (`mmPerM = 5`): `effectiveMm = (1.0/90)·5 = 0.056 mm` →
|
||||||
|
auf `MIN_PEN_MM` geklemmt → **0.13 mm**.
|
||||||
|
|
||||||
|
Fast die doppelte Strichstärke allein durch die Wahl des Export-Massstabs,
|
||||||
|
bei UNVERÄNDERTER Quell-`lineWeight` — und ohne dass die Bildschirm-
|
||||||
|
"Print"-Vorschau (die ja mit dem live gemessenen `paperScale` arbeitet,
|
||||||
|
nicht mit `opts.scaleDenominator`) das anzeigen könnte, solange beide Werte
|
||||||
|
nicht zufällig übereinstimmen.
|
||||||
|
|
||||||
|
### 5. Dieselbe Formel existiert vierfach, nur durch Kommentare (nicht durch Typen/Code) synchron gehalten
|
||||||
|
|
||||||
|
`PX_PER_M = 90` ist unabhängig deklariert in: `PlanView.tsx:23` (SVG),
|
||||||
|
`glPlan/glPlanRender.ts:12` (WebGL2), `tessellate.rs:22` (Rust, geteilt
|
||||||
|
zwischen WASM-Viewport und Headless/Golden-Test) und `sceneToPrintSvg.ts:64`
|
||||||
|
(PDF-Serializer, mit explizitem Kommentar "MUSS mit dem dortigen
|
||||||
|
uebereinstimmen, sonst driftet die Schraffur-Dichte des PDFs vom Viewport
|
||||||
|
ab", `sceneToPrintSvg.ts:61-62`). Die Hatch-Dichte-Formel
|
||||||
|
`Math.max(0.6, weightMm · (1/0.13))` ist separat dupliziert in
|
||||||
|
`PlanView.tsx:2344-2345` (`hatchStrokePx`, SVG-`<pattern>`) und
|
||||||
|
`toRenderScene.ts:375` (`hatchPx`, GPU + PDF) — beide mit demselben
|
||||||
|
"magischen" Faktor `1/0.13`, ohne gemeinsame Konstante. Aktuell sind alle vier
|
||||||
|
Kopien konsistent; das Risiko ist rein prospektiv (nächste Tuning-Änderung an
|
||||||
|
einer Stelle vergisst die anderen drei) — aber genau das ist der
|
||||||
|
Mechanismus, über den Findings wie 1-4 überhaupt erst entstehen können, ohne
|
||||||
|
dass ein Typfehler oder Test anschlägt.
|
||||||
|
|
||||||
|
### 6. Drei unabhängige, nicht aufeinander abgestimmte Mindestbreiten-Politiken
|
||||||
|
|
||||||
|
- SVG-Print: `Math.max(1e-4, ...)` (`PlanView.tsx:2599`) — praktisch keine
|
||||||
|
Untergrenze, verlässt sich auf das Antialiasing des Browsers.
|
||||||
|
- GPU (WebGL2 UND WASM, Linien UND Bögen): harter Floor von **0.6
|
||||||
|
Geräte-Pixel** (`shaders.rs:91,209`; `glPlanRender.ts:243-249`,
|
||||||
|
Kommentar "klemmt bei ~0.6 px").
|
||||||
|
- PDF: **`MIN_PEN_MM = 0.13` mm** (`sceneToPrintSvg.ts:38` in
|
||||||
|
`planToPrintSvg.ts`, äquivalent `sceneToPrintSvg.ts:47`) — eine
|
||||||
|
Papier-mm-Grösse, kein Pixelwert, konzeptionell etwas anderes als die
|
||||||
|
GPU-Pixel-Untergrenze.
|
||||||
|
|
||||||
|
Auswirkung: bei sehr kleinem Massstab (weit rausgezoomt oder grosses `N`,
|
||||||
|
z. B. Übersichtsplan 1:500) hält der GPU-Pfad sehr dünne Linien künstlich bei
|
||||||
|
0.6 px sichtbar, während dieselbe Linie im SVG-Pfad fast verschwindet und im
|
||||||
|
PDF auf eine ganz andere (mm-basierte, massstabsabhängige) Grösse geklemmt
|
||||||
|
wird. Kein Pfad kennt die Politik der anderen beiden. Niedrigere Priorität
|
||||||
|
als 1-4, weil hier keine der drei Politiken die *exportierte* Papier-mm-Wahrheit
|
||||||
|
verändert (die bleibt PDF-exklusiv über `MIN_PEN_MM`) — es geht nur um
|
||||||
|
Bildschirm-Konsistenz zwischen SVG/GPU bei Extremzoom.
|
||||||
|
|
||||||
|
### 7. Toter Zweitpfad `planToPrintSvg.ts` dupliziert PEN_STEPS/mm-Logik komplett, unbenutzt aber vorhanden
|
||||||
|
|
||||||
|
`src/export/planToPrintSvg.ts` trägt seit `382771b` einen Kopfkommentar
|
||||||
|
"ERSETZT durch sceneToPrintSvg.ts … wird vom PDF-Export NICHT MEHR
|
||||||
|
verwendet" (`planToPrintSvg.ts:1-6`) und ist tatsächlich nirgends mehr
|
||||||
|
importiert (verifiziert per Grep über `src/`). Er enthält jedoch weiterhin
|
||||||
|
eine eigene, unabhängige Kopie von `PEN_STEPS`/`MIN_PEN_MM`/`quantizePen`
|
||||||
|
(`planToPrintSvg.ts:35,38,40-45`) und einer kompletten Plan→SVG-Serialisierung
|
||||||
|
inkl. eigener `PX_PER_M`-Handhabung (`planToPrintSvg.ts:326`). Kein aktiver
|
||||||
|
Bug, aber eine Falle: Copy-Paste-Wiederverwendung dieses Altpfads (z. B. für
|
||||||
|
einen zukünftigen DXF/PNG-Export) würde eine dritte, potenziell abweichende
|
||||||
|
Quantisierungs-Tabelle in den Baum ziehen.
|
||||||
|
|
||||||
|
## 4. Vorgeschlagene Fixes
|
||||||
|
|
||||||
|
### zu Finding 1 (Haarlinien-Modus im GPU-Pfad)
|
||||||
|
|
||||||
|
Kleinste, korrekte Lösung: den `hairline`-Zustand als eigenen Parameter bis
|
||||||
|
in den Shader durchreichen, analog zu `paper_scale_n`/`text_scale_n`.
|
||||||
|
|
||||||
|
- **Rust (`src-tauri/render2d/src/gpu.rs`)**: neues Feld
|
||||||
|
`pub hairline: bool` auf `Renderer` (Default `false`, neben `paper_scale_n`
|
||||||
|
bei `gpu.rs:422`). In `Renderer::render` (`gpu.rs:606-616`): wenn
|
||||||
|
`self.hairline`, `mm_px` NICHT aus `mm_to_device_px(...)` berechnen, sondern
|
||||||
|
auf einen konstanten Geräte-px-Wert setzen (z. B. `1.0`), UND dafür sorgen,
|
||||||
|
dass der Shader `width_mm` in diesem Fall ignoriert (sonst bleibt die
|
||||||
|
RELATIVE Differenz zwischen z. B. 0.13 mm und 0.35 mm bestehen, nur global
|
||||||
|
skaliert — nicht das gewünschte "alle Linien exakt 1 px"). Sauberster Weg:
|
||||||
|
ein neues `hairline: u32`-Feld in die pro-Frame-Uniform (`Globals`,
|
||||||
|
`shaders.rs:20-23` und `ArcGlobals`), im Fragment-/Vertex-Shader
|
||||||
|
(`shaders.rs:91`, `LINE_WGSL`; `shaders.rs:209`, `ARC_WGSL`) per
|
||||||
|
`select(...)` auf einen festen `1.0`-px-Wert umschalten statt
|
||||||
|
`max(0.6, stroke_px * stroke_scale)`.
|
||||||
|
- **`src-tauri/render2d/src/web.rs`**: neue Methode `set_hairline(&mut self,
|
||||||
|
on: bool)` neben `set_paper_scale`/`set_text_scale`
|
||||||
|
(`web.rs:146-155`-Nachbarschaft).
|
||||||
|
- **`src/plan/useWasmPlanRenderer.ts`**: `render()`
|
||||||
|
(`useWasmPlanRenderer.ts:131-155`) um einen `hairline: boolean`-Parameter
|
||||||
|
erweitern, `r.set_hairline(hairline)` vor `r.render()` aufrufen.
|
||||||
|
- **`src/plan/useGlPlanRenderer.ts`**: analog — `render()`
|
||||||
|
(Signatur aktuell `(viewBox, paperScaleN=100)`) um `hairline` erweitern;
|
||||||
|
im WebGL2-Shader-Uniform-Pfad (`glPlanRender.ts:159-165,213-215`)
|
||||||
|
`mmToDevicePx` bei `hairline===true` durch einen konstanten Wert ersetzen
|
||||||
|
und im Fragment-Shader denselben `select`-Trick wie oben anwenden
|
||||||
|
(`glPlanShaders.ts`, dort wo "~0.6 px"-Klemmung passiert, siehe Finding 6).
|
||||||
|
- **`src/plan/PlanView.tsx`**: `paperScaleForGl` (`PlanView.tsx:475-476`)
|
||||||
|
bleibt wie sie ist (wird weiter für den Massstab-Nenner gebraucht, sobald
|
||||||
|
der Nutzer zurück auf "Print" schaltet); stattdessen an allen drei
|
||||||
|
`renderGl(...)`-Aufrufstellen (`PlanView.tsx:494,502,1080`) zusätzlich
|
||||||
|
`hairline` durchreichen: `renderGl(view, paperScaleForGl(view),
|
||||||
|
textScaleForGl(), hairline)`.
|
||||||
|
|
||||||
|
### zu Finding 2 (PEN_STEPS nur im PDF)
|
||||||
|
|
||||||
|
Zwei mögliche Richtungen, je nach gewünschtem Produktverhalten:
|
||||||
|
|
||||||
|
- **Option A (Vorschau matcht Druck)**: `quantizePen`
|
||||||
|
(`sceneToPrintSvg.ts:50-54`) in ein gemeinsames Modul auslagern (z. B.
|
||||||
|
`src/plan/penSteps.ts`, exportiert `PEN_STEPS`, `MIN_PEN_MM`,
|
||||||
|
`quantizePen`), von `sceneToPrintSvg.ts` UND von `PlanView.tsx`
|
||||||
|
(`printStrokeVb`, `PlanView.tsx:2598-2600`) importieren und dort ebenfalls
|
||||||
|
anwenden: `printStrokeVb(mm, n) = Math.max(1e-4, (quantizePen(mm) * n) /
|
||||||
|
1000) * PX_PER_M`. Für den GPU-Pfad müsste die Quantisierung dann VOR dem
|
||||||
|
Scene-Bau passieren (in `toRenderScene.ts`, auf `widthMm` jeder
|
||||||
|
`RLine`/`ROutline`/`RPolyline`/`RArc`, ausgenommen `widthScreen`-Einträge),
|
||||||
|
damit WASM/WebGL2-Viewport dieselben Stufen zeigen wie SVG und PDF.
|
||||||
|
- **Option B (Vorschau bleibt Rohgrösse, aber sichtbar gemacht)**: falls die
|
||||||
|
feinkörnige Vorschau bewusst erhalten bleiben soll, zumindest den
|
||||||
|
Stiftstufen-Sprung an der Stelle sichtbar machen, an der die Detailgrad-
|
||||||
|
Faktoren definiert werden (`generatePlan.ts:84-93`) — z. B. ein
|
||||||
|
Entwickler-/Lint-Test, der bei jedem `OUTLINE_DETAIL_FACTOR`/
|
||||||
|
`LAYER_DETAIL_FACTOR`-Wert prüft, ob das Produkt mit den üblichen
|
||||||
|
Fallback-`weightMm`-Werten (`WALL_FALLBACK_MM`, `LAYER_LINE_MM`) exakt auf
|
||||||
|
einer `PEN_STEPS`-Stufe landet, und sonst warnt.
|
||||||
|
|
||||||
|
Empfehlung: Option A — sie erfüllt den Nordstern wörtlich ("Bildschirm bei
|
||||||
|
jedem Massstab = Druck").
|
||||||
|
|
||||||
|
### zu Finding 3 (STAMP_REF_N)
|
||||||
|
|
||||||
|
In `toRenderScene.ts` `STAMP_REF_N = 100` (`toRenderScene.ts:163`) durch den
|
||||||
|
tatsächlichen Ziel-Massstab ersetzen. `planToRenderScene(plan)` kennt aktuell
|
||||||
|
keinen `N`-Parameter (`exportPdf.ts:73` ruft es ohne Massstabsangabe auf) —
|
||||||
|
die Funktion müsste ein optionales `paperScaleN`-Argument bekommen (Default
|
||||||
|
100, um den Viewport-Aufruf ohne Massstabskontext — `nativeSync.ts` — nicht
|
||||||
|
zu brechen, dort ist 100 ohnehin die richtige Referenz für den WASM-Viewport
|
||||||
|
im Anzeigemodus, s. `gpu.rs:422`), und `exportPdf.ts:73` müsste
|
||||||
|
`planToRenderScene(plan, opts.scaleDenominator)` aufrufen. `MM_TO_M`
|
||||||
|
(`toRenderScene.ts:165`) dann aus diesem Parameter statt aus der Konstante
|
||||||
|
ableiten.
|
||||||
|
|
||||||
|
### zu Finding 4 (Hatch-mm im PDF folgt dem Export-N, nicht dem Vorschau-N)
|
||||||
|
|
||||||
|
Kein Bug im engeren Sinn (die Formel ist in sich konsistent — die Schraffur
|
||||||
|
soll ja pro *gewähltem Blatt-Massstab* eine sinnvolle Dichte haben), aber die
|
||||||
|
Bildschirm-„Print"-Vorschau sollte denselben `N` verwenden, den der
|
||||||
|
Export-Dialog tatsächlich benutzen wird, sonst lügt die Vorschau. Fix:
|
||||||
|
`paperScale` in `PlanView.tsx` beim Öffnen des Export-Dialogs (bzw. der
|
||||||
|
Export-Dialog selbst) mit `opts.scaleDenominator` vorbelegen/synchronisieren,
|
||||||
|
statt zwei unabhängige State-Quellen zu pflegen — Ort: dort, wo der
|
||||||
|
Export-Dialog `scaleDenominator` initialisiert (App.tsx, PDF-Export-UI) einen
|
||||||
|
Default aus `paperScale`/`liveScale` (`App.tsx`, `onScale`-Callback,
|
||||||
|
`PlanView.tsx:794`) übernehmen.
|
||||||
|
|
||||||
|
### zu Finding 5 (vierfache Formel-Duplikation)
|
||||||
|
|
||||||
|
Eine gemeinsame TS-Konstantendatei `src/plan/renderConstants.ts` mit
|
||||||
|
`PX_PER_M = 90`, `HATCH_DENSITY_FACTOR = 1/0.13`, `HATCH_MIN_PX = 0.6`
|
||||||
|
anlegen; `PlanView.tsx`, `glPlan/glPlanRender.ts`, `toRenderScene.ts`,
|
||||||
|
`sceneToPrintSvg.ts` importieren daraus statt eigener Literale. Für die
|
||||||
|
Rust-Seite (`tessellate.rs:22`, `shaders.rs:142`) ist echtes Teilen über die
|
||||||
|
Sprachgrenze hinweg nicht trivial — dort bleibt nur ein Kommentar-Link auf die
|
||||||
|
TS-Konstante plus ein Parity-Test (siehe Abschnitt 5), der bei Abweichung
|
||||||
|
fehlschlägt statt bei stillem Drift.
|
||||||
|
|
||||||
|
### zu Finding 6 (drei Mindestbreiten-Politiken)
|
||||||
|
|
||||||
|
Niedrige Priorität, aber falls angegangen: den 0.6-px-Floor aus den Shadern
|
||||||
|
(`shaders.rs:91,209`, `glPlanShaders.ts`) als benannte Konstante
|
||||||
|
exportieren/dokumentieren und explizit von `1e-4` (SVG) und `MIN_PEN_MM=0.13`
|
||||||
|
(PDF) abgrenzen — mindestens per Kommentar klarstellen, dass die drei
|
||||||
|
UNTERSCHIEDLICHE Zwecke haben (GPU: Pixel-Sichtbarkeits-Floor;
|
||||||
|
PDF: Papier-mm-Stiftgrenze) und NICHT synchronisiert werden müssen, damit
|
||||||
|
niemand versehentlich versucht, sie anzugleichen und dabei die jeweils
|
||||||
|
andere Semantik bricht.
|
||||||
|
|
||||||
|
### zu Finding 7 (toter Pfad)
|
||||||
|
|
||||||
|
`src/export/planToPrintSvg.ts` entfernen (nach kurzer Rücksprache, ob der
|
||||||
|
Kopfkommentar-Vermerk "bleibt nur als Referenz stehen" noch gewollt ist) oder,
|
||||||
|
falls er als Referenz bleiben soll, seine `PEN_STEPS`/`quantizePen`-Kopie
|
||||||
|
durch einen Import aus der in Finding 2 vorgeschlagenen gemeinsamen
|
||||||
|
`penSteps.ts` ersetzen.
|
||||||
|
|
||||||
|
## 5. Verifikations-Rezept
|
||||||
|
|
||||||
|
Was schon existiert:
|
||||||
|
|
||||||
|
- `scripts/probe-engine-parity.mjs` vergleicht `?gl=0` gegen `?engine=wasm`
|
||||||
|
rein visuell (Screenshot-Diff von Auge, `probe-parity-svg.png` vs.
|
||||||
|
`probe-parity-wasm.png`) — prüft NICHT quantitativ, ob Strichbreiten in
|
||||||
|
Pixeln übereinstimmen, und deckt weder den Haarlinien-Modus noch den
|
||||||
|
WebGL2-Default-Pfad noch den PDF-Export ab.
|
||||||
|
- `src-tauri/render2d/tests/golden.rs` vergleicht die Demo-Szene
|
||||||
|
Pixel-für-Pixel gegen `tests/golden/demo.png` (Toleranz: Kanal-Delta > 2 auf
|
||||||
|
< 0.5 % der Pixel, `docs/design/engine-headless.md`) — ein reiner
|
||||||
|
GPU-Regressionstest, ohne SVG- oder PDF-Vergleich.
|
||||||
|
- Die einzige bisher dokumentierte mm-genaue Messung ist manuell:
|
||||||
|
HANDOVER.md, Commit `382771b` — `pdftoppm`-Messung eines exportierten PDFs
|
||||||
|
(53.51×43.69 mm gegen erwartete 53.45×43.45 mm bei 1:100), einmalig, nicht
|
||||||
|
als wiederholbares Skript hinterlegt.
|
||||||
|
|
||||||
|
Um den Nordstern ("Bildschirm bei jedem Zoom/Massstab = Druck") tatsächlich
|
||||||
|
beweisbar zu machen, fehlt ein quantitativer End-to-End-Test, der:
|
||||||
|
|
||||||
|
1. Für eine feste Test-Szene (idealerweise die bestehende `demo::demo_scene`
|
||||||
|
aus `src-tauri/render2d/src/demo.rs`, die bereits sowohl einen `width_mm`-
|
||||||
|
als auch einen `width_screen`-Strich enthält, `demo.rs:34,49-50`) bei
|
||||||
|
mehreren `N` (1:10, 1:50, 1:100) UND mehreren Zoomstufen:
|
||||||
|
- den PDF-Export erzeugt und via `pdftoppm -r <dpi>` in ein PNG rendert,
|
||||||
|
dort die Strichbreite in Pixeln misst und in mm zurückrechnet (bekannte
|
||||||
|
DPI ⇒ bekannte mm/px);
|
||||||
|
- denselben Frame headless über `HeadlessRenderer::render_to_image`
|
||||||
|
(`src-tauri/render2d/src/headless.rs`) mit identischem `paper_scale_n`
|
||||||
|
rendert und dieselbe Pixel-Breite misst;
|
||||||
|
- beide mm-Werte gegen den erwarteten `mm · N/1000`-Sollwert UND
|
||||||
|
gegeneinander vergleicht, mit einer Toleranz, die die PEN_STEPS-Rundung
|
||||||
|
(Finding 2, sobald behoben: siehe Option A) einbezieht.
|
||||||
|
2. Den Haarlinien-Modus separat abdeckt: ein Screenshot-Vergleich bei zwei
|
||||||
|
verschiedenen Zoomstufen im `lineMode:"display"` — die gemessene
|
||||||
|
Pixelbreite MUSS bei beiden Zoomstufen identisch sein (Beweis, dass
|
||||||
|
Finding 1 behoben ist), sowohl für WebGL2 (`?gl` ohne `=0`, App-Default)
|
||||||
|
als auch für WASM (`?engine=wasm`).
|
||||||
|
3. `probe-engine-parity.mjs` um eine tatsächliche Pixel-Differenz-Metrik
|
||||||
|
erweitert (statt nur Kindanzahl/Warnungen zu loggen wie aktuell
|
||||||
|
`probe-engine-parity.mjs:36-44`) — z. B. eine bekannte Referenzlinie im
|
||||||
|
Testmodell an fester Bildschirmposition, deren Strichbreite in beiden
|
||||||
|
Screenshots per Pixel-Sampling gemessen und verglichen wird.
|
||||||
|
|
||||||
|
Bis dieser Test existiert, bleibt jede Aussage über "Bildschirm = Druck" eine
|
||||||
|
Behauptung, die nur durch manuelles `pdftoppm`-Nachmessen einzelner
|
||||||
|
Stichproben gedeckt ist — die in diesem Audit gefundenen Divergenzen (v. a.
|
||||||
|
Finding 1 und 2) hätten mit den heutigen Probes nicht auffallen können, weil
|
||||||
|
keiner von ihnen den Default-Zustand der App (`lineMode:"display"`, WebGL2)
|
||||||
|
gegen den Druckpfad misst.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Schnitt/Ansicht-Pipeline: analytische Prismen-Projektion (`render3d::section`)
|
||||||
|
|
||||||
|
> Gegenstueck zum OCCT-WASM-HLR-Spike (`docs/welle-c-hlr-spike/FEASIBILITY.md`):
|
||||||
|
> statt eines generischen CAD-Kernels nutzt dieser Ansatz eine Invariante des
|
||||||
|
> Modells, um Schnitt/Ansicht rein analytisch (kein Hidden-Line-Removal-Solver)
|
||||||
|
> zu berechnen. Implementiert in `src-tauri/render3d/src/section.rs`.
|
||||||
|
|
||||||
|
## Ansatz
|
||||||
|
|
||||||
|
Jedes Bauteil in diesem Modell ist ein **Prisma**: ein 2D-Grundriss-Fussabdruck-
|
||||||
|
Polygon (Wand-Band bzw. Decken-Umriss, siehe `mesh.rs`), konstant extrudiert
|
||||||
|
ueber ein Hoehenintervall `[z0, z1]`. Diese Einschraenkung — konstanter
|
||||||
|
Querschnitt ueber die gesamte Hoehe — macht die Schnittgeometrie trivial im
|
||||||
|
Vergleich zu generischem HLR:
|
||||||
|
|
||||||
|
- **Schnitt einer vertikalen Ebene mit einem Prisma** = 2D-Geraden/Polygon-
|
||||||
|
Clipping IM GRUNDRISS (die Schnittebene projiziert im Grundriss auf eine
|
||||||
|
Linie) → ein oder mehrere u-Intervalle, in denen die Linie das Fussabdruck-
|
||||||
|
Polygon durchquert. Jedes Intervall × `[z0, z1]` ist das Cut-Rechteck. Da die
|
||||||
|
Hoehe unabhaengig von der Grundriss-Position ist, ist das Ergebnis **immer
|
||||||
|
ein Rechteck**, nie ein Trapez — auch bei diagonalen Wandachsen.
|
||||||
|
- **Projektion/Ansicht** (Kanten hinter der Ebene, in Blickrichtung) reduziert
|
||||||
|
sich auf ein **2D-Sichtbarkeitsproblem im Grundriss** kombiniert mit einem
|
||||||
|
Hoehen-Ueberlappungstest: da alle Seitenflaechen der Prismen vertikal sind,
|
||||||
|
genuegt ein Sichtstrahl-Test im Grundriss (verdeckt ein naeheres Prisma-
|
||||||
|
Fussabdruckpolygon die Sichtlinie?) plus Ueberlappung der Hoehenintervalle.
|
||||||
|
|
||||||
|
Das ersetzt einen 66-MB-WASM-CAD-Kernel durch closed-form Arithmetik in reinem
|
||||||
|
Rust, ohne zusaetzliches Laufzeitgewicht ueber `render3d` hinaus.
|
||||||
|
|
||||||
|
## `SectionOutput`-Format
|
||||||
|
|
||||||
|
Modul: `render3d::section`. Alle Groessen in **Metern**.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SectionPlane { pub point: [f32; 3], pub normal: [f32; 3] }
|
||||||
|
|
||||||
|
pub struct SectionOutput {
|
||||||
|
pub cut_polygons: Vec<CutPolygon>, // JSON: "cutPolygons"
|
||||||
|
pub visible_edges: Vec<SectionEdge>, // JSON: "visibleEdges"
|
||||||
|
pub hidden_edges: Vec<SectionEdge>, // JSON: "hiddenEdges"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CutPolygon { pub component: ComponentRef, pub color: Rgb, pub pts: Vec<[f32; 2]> }
|
||||||
|
pub struct SectionEdge { pub component: ComponentRef, pub a: [f32; 2], pub b: [f32; 2] }
|
||||||
|
pub struct ComponentRef { pub kind: ComponentKind /* Wall | Slab */, pub index: usize }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Koordinatensystem der Ausgabe (u, v):**
|
||||||
|
|
||||||
|
- Ursprung: `SectionPlane::point`, projiziert.
|
||||||
|
- `u` (horizontal): Strecke entlang der Schnittebene, senkrecht zur
|
||||||
|
Blickrichtung, berechnet als `normalize(cross(normal, world_up))` — dieselbe
|
||||||
|
rechtshaendige Konvention wie `math::look_at`s `right`-Vektor. Bei den vier
|
||||||
|
Standard-Konstruktoren (`looking_plus_x/minus_x/plus_y/minus_y`) ist `u`
|
||||||
|
direkt die jeweils andere Grundriss-Achse.
|
||||||
|
- `v` (vertikal, "Hoehe"): `v = world.y`, ABSOLUT. Diese Codebasis ist
|
||||||
|
durchgaengig Y-up (`types.rs`/`mesh.rs`/`math.rs`: „world.y = Hoehe"); `v`
|
||||||
|
folgt bewusst dieser etablierten Konvention statt einer wortwoertlichen
|
||||||
|
„world.z"-Lesart, um modulübergreifend konsistent zu bleiben.
|
||||||
|
- Nur **vertikale** Schnittebenen (Normale ohne Hoehen-Komponente) sind
|
||||||
|
unterstuetzt — Grundriss-/Horizontalschnitte bleiben Sache der bestehenden
|
||||||
|
2D-Plan-Pipeline.
|
||||||
|
|
||||||
|
`cut_polygons` sind bei diesem Modell immer Rechtecke (siehe oben), aber als
|
||||||
|
generischer Punktering abgelegt — kompatibel zu `render2d::types::FillPolygon`
|
||||||
|
(`pts: Vec<Point>`), dem vorgesehenen Zielformat fuer die spaetere 1:1-
|
||||||
|
Uebersetzung in die Plan-/Schnitt-Ansicht.
|
||||||
|
|
||||||
|
## Oeffnungen (Tueren/Fenster)
|
||||||
|
|
||||||
|
`WallInput` hat seit diesem Nachtrag ein Feld `openings: Vec<Opening>`
|
||||||
|
(`types.rs`), unabhaengig von der reinen Achse/Dicke/Hoehe: je Oeffnung ein
|
||||||
|
Intervall entlang der Wandachse (`from`/`to`, Meter ab `start`) plus Bruestungs-
|
||||||
|
und Kopfhoehe (`sill`/`height`, relativ zu `base_elevation`). Eine Tuer ist der
|
||||||
|
Sonderfall `sill == 0.0`.
|
||||||
|
|
||||||
|
**Cut-Polygone.** Trifft die Schnittlinie eine Wand exakt im Bereich einer
|
||||||
|
Oeffnung, splittet `wall_cut_rectangles` das sonst einzelne Vollrechteck
|
||||||
|
`[z0,z1]` in bis zu ZWEI Teil-Rechtecke: Bruestung `[z0, z0+sill]` und Sturz
|
||||||
|
`[z0+sill+height, z1]`. Gewaehlt wurde diese Mehrfach-Rechteck-Darstellung
|
||||||
|
BEWUSST gegenueber einem Loch-Polygon (Ring mit Aussparung): mehrere einfache,
|
||||||
|
konvexe Ringe sind direkt kompatibel zum Zielformat `render2d::types::
|
||||||
|
FillPolygon` (nur einfache Ringe, kein Loch-Format), waehrend ein
|
||||||
|
Loch-Polygon eine zusaetzliche Datenstruktur (Ring-mit-Loch) noetig gemacht
|
||||||
|
haette, die die Zielstruktur (noch) nicht kennt. Ausserhalb einer Oeffnung
|
||||||
|
bleibt es beim einzelnen Vollrechteck (Regressionsfall).
|
||||||
|
|
||||||
|
Die Zuordnung "welche Wandachsen-Position entspricht dieser Schnittposition"
|
||||||
|
ist fuer den STANDARDFALL (Schnittebene senkrecht zur Wandachse — der uebliche
|
||||||
|
architektonische Wandquerschnitt) EXAKT konstant ueber das Cut-Intervall. Fuer
|
||||||
|
schraege Wand/Ebene-Kombinationen wird sie als AFFINE Funktion (`axis_map`)
|
||||||
|
exakt mitgefuehrt (keine Naeherung noetig, da die Abbildung linear ist).
|
||||||
|
|
||||||
|
**Projektion/Ansicht.** Eine Wand mit Oeffnungen bekommt zusaetzlich zur
|
||||||
|
Boxen-Drahtsilhouette die vier Rahmenkanten jeder Oeffnung (zwei Leibungen,
|
||||||
|
Sturz, Bruestung), berechnet auf der Wandachsen-Mittellinie (dieselbe
|
||||||
|
Vereinfachung wie die Bounding-Box-Verdeckung generell — keine eigene
|
||||||
|
Dicken-Aufloesung der Leibungsflaeche). Fuer die Verdeckung wird jede
|
||||||
|
Wand-Bounding-Box um ihre Oeffnungen als "Loch" reduziert
|
||||||
|
(`PrismBounds::opening_voids`): ein anderes (oder dasselbe) Bauteil hinter der
|
||||||
|
Wand wird in genau dem (u, Hoehe)-Rechteck der Oeffnung NICHT von dieser Wand
|
||||||
|
verdeckt — "Durchblick". Die eigenen Rahmenkanten einer Oeffnung werden dabei
|
||||||
|
NICHT gegen das eigene Bauteil auf Verdeckung geprueft (ein Loch kann sich
|
||||||
|
nicht selbst verdecken); gegen alle anderen Bauteile gilt die normale
|
||||||
|
Verdeckungslogik unveraendert (inkl. der bereits dokumentierten
|
||||||
|
Selbstverdeckung der Rueckseite eines ANDEREN Bauteils durch dessen eigene
|
||||||
|
Vorderseite).
|
||||||
|
|
||||||
|
GENAUIGKEIT (bewusste Vereinfachung): die u-Zuordnung fuer den Durchblick ist
|
||||||
|
nur DANN aussagekraeftig, wenn die Wandachse hinreichend parallel zur u-Achse
|
||||||
|
der Schnittebene steht — das ist GENAU der Fall, in dem man die Wand als
|
||||||
|
Elevation/Ansicht von vorne sieht (und ein Fenster ueberhaupt als Durchblick
|
||||||
|
sichtbar waere). Steht die Wand naeher an "senkrecht zur u-Achse" (Wand auf
|
||||||
|
Kante gesehen bzw. der reine Cut-Fall), wird die Durchblick-Berechnung
|
||||||
|
uebersprungen und die Wand bleibt fuer die Verdeckung VOLL UNDURCHSICHTIG
|
||||||
|
(konservativ hidden) — Schwelle `AXIS_ALIGN_EPS = 1e-3` in `section.rs`. Ein
|
||||||
|
Kante-auf-Kante gesehenes Fenster liefert ohnehin keine sinnvolle
|
||||||
|
Durchblick-Flaeche in der Projektion.
|
||||||
|
|
||||||
|
Getestet in `section::tests` (u. a. `schnitt_durch_fenster_liefert_bruestung_
|
||||||
|
und_sturz`, `schnitt_neben_dem_fenster_liefert_volles_rechteck`,
|
||||||
|
`ansicht_zeigt_fensterrahmen_sichtbar_und_durchblick_bei_dahinterliegender_
|
||||||
|
kante`) und im Beweis-SVG (`examples/section_svg.rs`): Schenkel A der L-Wand
|
||||||
|
hat dort ein Fenster, eine kurze zusaetzliche Wand steht dahinter genau im
|
||||||
|
Fensterband und erscheint im SVG als durchgezogene (sichtbare) Linie zwischen
|
||||||
|
Bruestungs- und Sturzhoehe, gestrichelt (verdeckt) darueber/darunter.
|
||||||
|
|
||||||
|
## Bekannte Luecken
|
||||||
|
|
||||||
|
- **Keine Wandknoten-Verschneidung (T-/X-Stoesse).** Wie in `mesh.rs`
|
||||||
|
(M1-Stand) werden Waende als eigenstaendige, stumpf abgeschlossene Quader
|
||||||
|
behandelt, die sich an Ecken UEBERLAPPEN statt sich zu vereinen (kein
|
||||||
|
Miter-Join). Der Schnitt-Extraktor erbt das: an einem L-/T-Knoten kann eine
|
||||||
|
Wand als „in die andere eingebettet" verdeckt erscheinen (im Testmodul
|
||||||
|
`section::tests` bewusst als reales, erwartetes Verhalten dokumentiert und
|
||||||
|
geprueft — kein Bug dieses Moduls, sondern ein Artefakt der fehlenden
|
||||||
|
Verschneidungslogik weiter oben in der Pipeline).
|
||||||
|
- **Oeffnungen sind NICHT in der Vollkoerper-Extrusion (`mesh.rs`)
|
||||||
|
nachgezogen.** `wall_prism`/`wall_cut_rectangles`/die Verdeckung in
|
||||||
|
`section.rs` werten `WallInput::openings` vollstaendig aus (siehe Abschnitt
|
||||||
|
"Oeffnungen" oben), aber `mesh::extrude_wall` extrudiert weiterhin die volle
|
||||||
|
Wandflaeche ohne Aussparung (`mesh.rs`-Moduldoc: „Oeffnungen kommen in
|
||||||
|
spaeteren Milestones"). Cut-/Ansichts-Pipeline und 3D-Solid-Mesh sind bis zum
|
||||||
|
Nachziehen von `mesh.rs` also bewusst inkonsistent — eine bekannte, separate
|
||||||
|
Luecke (nicht Gegenstand dieses Nachtrags).
|
||||||
|
- **Oeffnungs-Durchblick nutzt dieselbe achsparallele Bounding-Box-Naeherung**
|
||||||
|
wie die allgemeine Verdeckung (kein exaktes Polygon-Clipping der Lochflaeche
|
||||||
|
gegen dahinterliegende Kanten) und wird bei Wand-Orientierungen nahe
|
||||||
|
"senkrecht zur u-Achse" konservativ auf "kein Durchblick" zurueckgestuft
|
||||||
|
(siehe Abschnitt "Oeffnungen" oben, `AXIS_ALIGN_EPS`).
|
||||||
|
- **Keine echte Component-/Material-Id.** `WallInput`/`SlabInput` haben aktuell
|
||||||
|
keine eigene Id; `ComponentRef` referenziert daher nur `(Art, Index im
|
||||||
|
Eingabe-Array)` + die rohe Albedo-Farbe als Material-Platzhalter. Sobald ein
|
||||||
|
echtes Ressourcen-/Material-System existiert, sollte `ComponentRef` auf eine
|
||||||
|
stabile Id umgestellt werden (Indizes sind nicht stabil ueber Modell-Edits).
|
||||||
|
- **Verdeckungstest nutzt Bounding-Boxen, nicht die exakte Fussabdruckform.**
|
||||||
|
Fuer Wand-Baender und (i. d. R. konvexe) Deckenumrisse ist das exakt; bei
|
||||||
|
stark konkaven oder diagonalen Grundrissen kann es zu Ueberverdeckung
|
||||||
|
fuehren (ein Prisma blockiert dann auch Bereiche seiner eigenen „Nischen").
|
||||||
|
- **Kein generisches HLR.** Der Ansatz funktioniert NUR, weil alle Bauteile
|
||||||
|
Prismen mit konstantem Querschnitt sind. Fuer echte gekrümmte oder nicht-
|
||||||
|
prismatische Geometrie (Bogenwaende, Freiformdaecher, …) waere er nicht
|
||||||
|
anwendbar — dafuer bliebe der OCCT-Weg (oder eine eigene, generischere
|
||||||
|
HLR-Implementierung) die Referenz.
|
||||||
|
- **Keine robuste Sonderfallbehandlung fuer Vertices exakt auf der
|
||||||
|
Schnittlinie** (Toleranz-basiert, kein Tie-Breaking/Pertubation) — die
|
||||||
|
Testszenarien vermeiden diesen Fall bewusst.
|
||||||
|
|
||||||
|
## Vergleich zum OCCT-WASM-Spike
|
||||||
|
|
||||||
|
| | OCCT-WASM (`docs/welle-c-hlr-spike`) | Dieser Ansatz (`render3d::section`) |
|
||||||
|
|---|---|---|
|
||||||
|
| Verfahren | Generisches HLR (`HLRAppli_ReflectLines`) | Analytische Prismen-Projektion |
|
||||||
|
| Anwendbarkeit | Beliebige BREP-Geometrie | Nur Prismen (konstanter Querschnitt über Höhe) |
|
||||||
|
| Zusaetzliches Gewicht | ~62,8 MB WASM (~19,6 MB gzip), separater Lazy-Chunk | Keins — reines Rust in `render3d`, kein weiteres WASM-Asset |
|
||||||
|
| Modul-Init | ~450 ms (Browser) / ~680 ms (Node) | Kein Initialisierungsschritt (kein Fremd-Modul zu laden) |
|
||||||
|
| Rechenzeit (L-Wand + Platte) | ~21 ms (reiner HLR-Lauf, gemessen im Browser) | Nicht separat gemessen (kein Millisekunden-Timer im Test); die Operationen sind reine Vektor-/Intervall-Arithmetik über wenige Kanten (O(Anzahl-Prismen × Kanten-pro-Prisma) mit kleinen Konstanten) und liegen der Groessenordnung nach klar unter 1 ms fuer Szenen dieser Groesse — eine belastbare Messung steht noch aus |
|
||||||
|
| Sichtbarkeit (verdeckte Kanten) | Exakt (echter HLR-Solver) | Naeherung über Bounding-Boxen im Grundriss + Hoehenintervall-Ueberlappung; exakt fuer achsparallele/konvexe Fussabdruecke |
|
||||||
|
| Reifegrad | Isolierter Spike, nicht verdrahtet | Isolierter Spike (dieses Modul), nicht in `render2d`/die Plan-Ansicht verdrahtet |
|
||||||
|
|
||||||
|
**Fazit:** Fuer den ueberwiegenden Regelfall dieses Projekts (Waende, Decken —
|
||||||
|
alles Prismen) ist die analytische Loesung der pragmatischere Weg: kein
|
||||||
|
zusaetzliches WASM-Gewicht, keine Fremd-Bibliothek, headless testbar wie der
|
||||||
|
Rest von `render3d`. Der OCCT-Weg bleibt die Referenz, falls/sobald echte
|
||||||
|
generische Volumenkoerper (Booleans, gekrümmte Flaechen) ins Modell kommen.
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# Design — Ebenen-Darstellung (Layer Display Settings)
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Ressourcen/Stile: [resources-graphics.md](resources-graphics.md). Output/Pläne:
|
||||||
|
> [plans-output.md](plans-output.md). Kontextmenü/Inline-Editor:
|
||||||
|
> [context-menu.md](context-menu.md).
|
||||||
|
|
||||||
|
Dieses Dokument spezifiziert die **per-Ebene Darstellungseinstellungen** auf der
|
||||||
|
`LayerCategory` (Grafik-Kategorie) und den dazugehörigen Editor
|
||||||
|
„Ebeneneinstellungen…", der aus dem Ebenen-Kontextmenü geöffnet wird.
|
||||||
|
|
||||||
|
Heute trägt jede `LayerCategory` nur eine flache Strichstärke (`lw`), eine `color`
|
||||||
|
und eine optionale `hatch` (ein freier String, der nirgends aufgelöst wird). Das
|
||||||
|
reicht nicht: Eine Ebene soll — wie in Vectorworks/DOSSIER — einen vollständigen
|
||||||
|
**Stift (PEN)** und eine vollständige **Standard-Schraffur (HATCH)** definieren,
|
||||||
|
die beim Rendern angewandt werden. Bezeichner englisch, Prosa/UI-Text deutsch
|
||||||
|
(CONVENTIONS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Zielbild
|
||||||
|
|
||||||
|
Jede Ebene definiert zwei Darstellungs-Aspekte, die in den Grundriss-Generator
|
||||||
|
einfließen:
|
||||||
|
|
||||||
|
- **PEN** — Linienstil der Ebene: `type` (durchgezogen / gestrichelt / …),
|
||||||
|
`color` und `lw` (Strichstärke in mm Papier). Steuert alle Umriss-/Symbol-Linien
|
||||||
|
der Elemente dieser Ebene (Wand-Umriss, Tür-Symbol, Referenzlinie).
|
||||||
|
- **HATCH** — Standard-Schraffur der Ebene: `pattern`, `scale`, `angle` und die
|
||||||
|
`lineWeight` der Musterlinien. Wird angewandt, wo ein Element keine eigene
|
||||||
|
Schraffur aus einem `Component` mitbringt (z. B. einschichtige/„grob"-Flächen,
|
||||||
|
reine 2D-Zeichnungsobjekte einer Ebene).
|
||||||
|
|
||||||
|
Beides folgt dem Architektur-Prinzip: **Darstellung wird beim Rendern aufgelöst,
|
||||||
|
nie in die Geometrie eingebacken.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Reference vs. Inline — Entscheidung
|
||||||
|
|
||||||
|
Es gibt drei Modelle, ein Datum für PEN/HATCH einer Ebene zu halten:
|
||||||
|
|
||||||
|
1. **Pure inline** — die Ebene trägt `{type,color,lw}` und `{pattern,scale,angle,
|
||||||
|
lineWeight}` direkt. Einfach, aber: kein Wiederverwenden, kein zentrales
|
||||||
|
Ändern; widerspricht der Ressourcen-Architektur (resources-graphics.md §1:
|
||||||
|
„alles verweist per id, zentral änderbar").
|
||||||
|
2. **Pure reference** — die Ebene trägt nur `lineStyleId` / `hatchId`. Konsistent,
|
||||||
|
zentral, aber unflexibel: Eine Ebene kann z. B. nicht „den Stil X, aber in
|
||||||
|
ihrer eigenen Farbe" wollen, ohne einen Klon-Stil anzulegen.
|
||||||
|
3. **Reference + optionale per-Ebene Overrides** (EMPFOHLEN) — die Ebene
|
||||||
|
**verweist** auf eine `LineStyle`- bzw. `HatchStyle`-Ressource und darf
|
||||||
|
**einzelne Felder lokal überschreiben**. Das ist exakt das DOSSIER/Vectorworks-
|
||||||
|
Muster: ein Stil als Basis, regelbasierte/lokale Overrides obendrauf
|
||||||
|
(resources-graphics.md, `overrides.py`).
|
||||||
|
|
||||||
|
### Empfehlung: Reference + optionale Overrides
|
||||||
|
|
||||||
|
Begründung:
|
||||||
|
|
||||||
|
- **Zentrale Pflege bleibt erhalten:** Ändert man den Linienstil „Wand stark" im
|
||||||
|
Line Manager, ziehen alle Ebenen nach, die ihn referenzieren und das jeweilige
|
||||||
|
Feld nicht überschreiben.
|
||||||
|
- **Lokale Freiheit ohne Stil-Wildwuchs:** Eine Ebene kann punktuell `color` oder
|
||||||
|
`lw` anpassen (häufigster Fall: gleiche Strichart, andere Farbe), ohne einen
|
||||||
|
fast identischen Stil zu duplizieren.
|
||||||
|
- **Migrationsfähig:** Die heutige flache `{color, lw}` der Ebene wird zu reinen
|
||||||
|
Overrides über einem neutralen Basis-Stil — verlustfrei (siehe §6).
|
||||||
|
- **Konsistent mit der bestehenden Kette:** `Component → Hatch → LineStyle`
|
||||||
|
verweist bereits per id; Ebenen reihen sich nahtlos ein.
|
||||||
|
|
||||||
|
Die Overrides sind **sparse**: nur gesetzte Felder überschreiben. Ein leeres
|
||||||
|
Override-Objekt (oder `undefined`) bedeutet „komplett dem Stil folgen".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Datenmodell (TS)
|
||||||
|
|
||||||
|
### 3.1 LineStyle erweitern um `type`
|
||||||
|
|
||||||
|
`LineStyle` trägt heute schon `weight`, `color`, `dash`. Wir machen die
|
||||||
|
Strichart explizit benennbar (statt nur via `dash`-Array), damit der Editor ein
|
||||||
|
sauberes Dropdown anbietet und `dash` daraus ableiten kann.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Benannte Strichart eines Stifts (für UI-Dropdown). */
|
||||||
|
export type LineKind = "solid" | "dashed" | "dotted" | "dashdot";
|
||||||
|
|
||||||
|
/** mm-Strichmuster je Strichart (relativ zur Papier-mm). */
|
||||||
|
export const LINE_DASH: Record<LineKind, number[] | null> = {
|
||||||
|
solid: null,
|
||||||
|
dashed: [0.6, 0.4],
|
||||||
|
dotted: [0.1, 0.25],
|
||||||
|
dashdot: [0.6, 0.25, 0.1, 0.25],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LineStyle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** NEU: benannte Strichart; `dash` wird daraus abgeleitet, falls nicht gesetzt. */
|
||||||
|
kind: LineKind;
|
||||||
|
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
|
||||||
|
weight: number;
|
||||||
|
color: string;
|
||||||
|
/** Strichmuster in mm; `null` = durchgezogen. Optional — sonst aus `kind`. */
|
||||||
|
dash: number[] | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Hinweis: `kind` ist additiv; bestehende `LineStyle`-Daten setzen es per Migration
|
||||||
|
> aus `dash` (§6).
|
||||||
|
|
||||||
|
### 3.2 PEN- und HATCH-Override-Typen
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Per-Ebene Stift (PEN). Verweist auf einen LineStyle; einzelne Felder dürfen
|
||||||
|
* lokal überschrieben werden. Alle Override-Felder optional (sparse).
|
||||||
|
*/
|
||||||
|
export interface LayerPen {
|
||||||
|
/** Basis-Linienstil (Line Manager). */
|
||||||
|
lineStyleId: string;
|
||||||
|
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
|
||||||
|
override?: {
|
||||||
|
kind?: LineKind;
|
||||||
|
color?: string;
|
||||||
|
/** Strichstärke in mm Papier. */
|
||||||
|
lw?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-Ebene Standard-Schraffur (HATCH). Verweist auf einen HatchStyle; einzelne
|
||||||
|
* Felder dürfen lokal überschrieben werden. `enabled=false` = Ebene hat keine
|
||||||
|
* Default-Schraffur (Umriss-only).
|
||||||
|
*/
|
||||||
|
export interface LayerHatch {
|
||||||
|
/** Aktiv? false = keine Default-Schraffur dieser Ebene. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Basis-Schraffur (Hatch Manager). */
|
||||||
|
hatchId: string;
|
||||||
|
/** Lokale Overrides — nur gesetzte Felder gewinnen. */
|
||||||
|
override?: {
|
||||||
|
pattern?: HatchPattern;
|
||||||
|
scale?: number;
|
||||||
|
/** Drehung in Grad. */
|
||||||
|
angle?: number;
|
||||||
|
color?: string;
|
||||||
|
/** Strichstärke der Musterlinien in mm Papier. */
|
||||||
|
lineWeight?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 LayerCategory erweitern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface LayerCategory {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
|
||||||
|
// ── NEU: vollständige Darstellung ──────────────────────────────────────────
|
||||||
|
/** Stift der Ebene (PEN) — Linien aller Elemente dieser Ebene. */
|
||||||
|
pen: LayerPen;
|
||||||
|
/** Standard-Schraffur der Ebene (HATCH). */
|
||||||
|
hatch: LayerHatch;
|
||||||
|
|
||||||
|
/** Unterkategorien (Baum). */
|
||||||
|
children?: LayerCategory[];
|
||||||
|
|
||||||
|
// ── DEPRECATED (nur Übergang; siehe Migration §6) ──────────────────────────
|
||||||
|
/** @deprecated → pen.override.color. */
|
||||||
|
color?: string;
|
||||||
|
/** @deprecated → pen.override.lw. */
|
||||||
|
lw?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`color` und `lw` bleiben als optionale, deprecatete Felder bestehen, bis alle
|
||||||
|
Lesepfade auf den Resolver (§4) umgestellt sind, und werden dann entfernt. Die
|
||||||
|
Panel-Swatch (`LayersPanel`) liest künftig die **aufgelöste** Stift-Farbe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolver — vom Modell zur Render-Entscheidung
|
||||||
|
|
||||||
|
Der Resolver löst PEN/HATCH einer Ebene gegen die Ressourcen-Bibliotheken auf und
|
||||||
|
wendet die Overrides an. Er ist die **einzige** Stelle, an der „Stil + Override"
|
||||||
|
zusammenfließen; Generator und Panel rufen nur ihn.
|
||||||
|
|
||||||
|
### 4.1 Aufgelöste Render-Typen
|
||||||
|
|
||||||
|
`HatchRender` existiert bereits in `generatePlan.ts`. Wir ergänzen ein paralleles
|
||||||
|
`PenRender` und exportieren beide Resolver aus einem neuen Modul
|
||||||
|
`src/model/layerStyle.ts` (damit Panel und Generator teilen).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Aufgelöster Stift einer Ebene — alles, was die Linie zu zeichnen braucht. */
|
||||||
|
export interface PenRender {
|
||||||
|
color: string;
|
||||||
|
/** Strichstärke in mm Papier. */
|
||||||
|
lw: number;
|
||||||
|
/** Strichmuster in mm Papier; null = durchgezogen. */
|
||||||
|
dash: number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HatchRender: bereits in generatePlan.ts definiert (pattern, scale, angle,
|
||||||
|
// color, lineWeight, dash). Wird nach layerStyle.ts gezogen und re-exportiert.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Resolver-Funktionen (Pseudocode)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function resolvePen(project: Project, layer: LayerCategory): PenRender {
|
||||||
|
const ls = getLineStyle(project, layer.pen.lineStyleId); // wirft, falls fehlend
|
||||||
|
const o = layer.pen.override ?? {};
|
||||||
|
const kind = o.kind ?? ls.kind;
|
||||||
|
return {
|
||||||
|
color: o.color ?? ls.color,
|
||||||
|
lw: o.lw ?? ls.weight,
|
||||||
|
// Override-kind setzt das dash neu; sonst Stil-dash bzw. aus kind abgeleitet.
|
||||||
|
dash: o.kind ? LINE_DASH[o.kind] : (ls.dash ?? LINE_DASH[ls.kind]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLayerHatch(project: Project, layer: LayerCategory): HatchRender | null {
|
||||||
|
if (!layer.hatch.enabled) return null; // Ebene ohne Default-Schraffur
|
||||||
|
const h = getHatch(project, layer.hatch.hatchId); // wirft, falls fehlend
|
||||||
|
const o = layer.hatch.override ?? {};
|
||||||
|
// Musterlinien-Stärke: Override > LineStyle der Schraffur > Default 0.13 mm.
|
||||||
|
const baseLs = h.lineStyleId ? getLineStyle(project, h.lineStyleId) : null;
|
||||||
|
return {
|
||||||
|
pattern: o.pattern ?? h.pattern,
|
||||||
|
scale: o.scale ?? h.scale,
|
||||||
|
angle: o.angle ?? h.angle,
|
||||||
|
color: o.color ?? h.color,
|
||||||
|
lineWeight: o.lineWeight ?? baseLs?.weight ?? 0.13,
|
||||||
|
dash: baseLs?.dash ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide bauen eine `Map<code, …>` über den ganzen Baum, analog zur heutigen
|
||||||
|
`categoryLwMap`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function penMap(project: Project): Map<string, PenRender> {
|
||||||
|
const m = new Map<string, PenRender>();
|
||||||
|
for (const c of flattenCategories(project.layers)) m.set(c.code, resolvePen(project, c));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
export function layerHatchMap(project: Project): Map<string, HatchRender | null> {
|
||||||
|
const m = new Map<string, HatchRender | null>();
|
||||||
|
for (const c of flattenCategories(project.layers))
|
||||||
|
m.set(c.code, resolveLayerHatch(project, c));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Einfluss auf `generatePlan`
|
||||||
|
|
||||||
|
Heute (generatePlan.ts):
|
||||||
|
|
||||||
|
- `categoryLwMap(project.layers)` liefert nur `lw` je Code; die Umriss-Strichstärke
|
||||||
|
kommt daraus, **Farbe** der Umrisse ist fest `POCHE_STROKE`.
|
||||||
|
- Schraffur kommt ausschließlich aus dem `Component` der jeweiligen Schicht
|
||||||
|
(`resolveHatch(project, comp.hatchId)`); die Ebenen-`hatch` wird **nicht** genutzt.
|
||||||
|
|
||||||
|
Änderungen (minimal-invasiv, additiv):
|
||||||
|
|
||||||
|
### 5.1 Pens ersetzen `lwByCode`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pens = penMap(project); // statt categoryLwMap
|
||||||
|
const layerHatches = layerHatchMap(project);
|
||||||
|
…
|
||||||
|
const pen = pens.get(wall.categoryCode) ?? FALLBACK_PEN; // {color, lw, dash}
|
||||||
|
```
|
||||||
|
|
||||||
|
`addWallPoche` und `addDoorSymbol` bekommen statt `wallLwMm: number` /
|
||||||
|
`doorLwMm: number` jeweils das ganze `pen: PenRender`:
|
||||||
|
|
||||||
|
- **Wand-Umrisslinie:** `stroke: pen.color` (statt fix `POCHE_STROKE`),
|
||||||
|
`strokeWidthMm: pen.lw * OUTLINE_DETAIL_FACTOR[detail]`, `dash: pen.dash`.
|
||||||
|
→ Das `Primitive` „polygon" braucht ein optionales `dash?: number[] | null`
|
||||||
|
(Schichtfugen bleiben durchgezogen; nur die Umriss-Kontur nutzt `pen.dash`).
|
||||||
|
- **Schichtfugen:** behalten `POCHE_STROKE` und ihre dünne `LAYER_LINE_MM`
|
||||||
|
(interne Hilfslinien sind bewusst neutral, nicht stift-gefärbt).
|
||||||
|
- **Tür-Symbol / Referenzlinie:** `cls` bleibt, aber `weightMm` aus `pen.lw`,
|
||||||
|
und die PlanView darf die Stift-Farbe nutzen (`door-leaf` etc. erhalten optional
|
||||||
|
ein `stroke`-Feld am line/arc-Primitive; ansonsten greift die CSS-Klasse wie
|
||||||
|
bisher).
|
||||||
|
|
||||||
|
### 5.2 Default-Schraffur der Ebene
|
||||||
|
|
||||||
|
Die Ebenen-Schraffur greift dort, wo **keine Component-Schraffur** vorliegt:
|
||||||
|
|
||||||
|
- **`detail === "grob"`** (eine Sammelfläche, heute `NO_HATCH`): statt `NO_HATCH`
|
||||||
|
nun `layerHatches.get(wall.categoryCode) ?? NO_HATCH`. So bekommt die grobe
|
||||||
|
Poché die Standard-Schraffur der Ebene (z. B. ein leichtes Diagonalmuster),
|
||||||
|
falls die Ebene eine definiert; sonst bleibt sie ungeschraffiert.
|
||||||
|
- **mittel/fein, mehrschichtig:** unverändert — die Component-Schraffur je Schicht
|
||||||
|
hat Vorrang (spezifischer als die Ebene). Die Ebenen-Schraffur ist der
|
||||||
|
*Fallback*, nicht der Default-Override.
|
||||||
|
- **Reine 2D-Zeichnungsobjekte** (künftige `drawing`-Ebenen-Elemente ohne
|
||||||
|
Component): nutzen direkt `resolveLayerHatch` als ihre Füllschraffur.
|
||||||
|
|
||||||
|
Auflöse-Reihenfolge der Schraffur einer gezeichneten Fläche:
|
||||||
|
|
||||||
|
```
|
||||||
|
Component.hatch > LayerCategory.hatch (enabled) > keine Schraffur
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Geänderte Signaturen (Zusammenfassung)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vorher: addWallPoche(out, project, wall, doors, cuts, greyed, detail, wallLwMm)
|
||||||
|
function addWallPoche(out, project, wall, doors, cuts, greyed, detail,
|
||||||
|
pen: PenRender, layerHatch: HatchRender | null): void
|
||||||
|
|
||||||
|
// vorher: addDoorSymbol(out, wall, door, greyed, detail, doorLwMm)
|
||||||
|
function addDoorSymbol(out, wall, door, greyed, detail, pen: PenRender): void
|
||||||
|
```
|
||||||
|
|
||||||
|
`Primitive` (polygon) erhält optional `dash?: number[] | null`; line/arc erhalten
|
||||||
|
optional `stroke?: string`, damit Pen-Farbe durchschlagen kann (CSS-Klasse bleibt
|
||||||
|
Default).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Editor „Ebeneneinstellungen…"
|
||||||
|
|
||||||
|
Geöffnet wie heute über `layerMenuItems → openLayerEditor(code)` →
|
||||||
|
`setEditor({ kind: "layer", code, x, y })`. Der bestehende `InlineEditor`-Rahmen
|
||||||
|
(dunkel, am Anker, Esc/Außenklick schließt) und die `EditorField`-Zeilen bleiben;
|
||||||
|
der Inhalt wächst von 3 Feldern auf zwei kompakte Abschnitte **PEN** und **HATCH**.
|
||||||
|
|
||||||
|
Da der Editor jetzt mehr Felder trägt, wird er als **kompakte Sektions-Form**
|
||||||
|
gestaltet (zwei Gruppen mit Trenn-Überschrift), gemäß CONVENTIONS.md UI-Konventionen
|
||||||
|
(saubere Form, keine wiederholten Beschriftungen, DOSSIER-Stil, alles via `t()`).
|
||||||
|
|
||||||
|
### 6.1 Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ Ebene 20 ───────────────── ×
|
||||||
|
│ Name [ Wände ]
|
||||||
|
│
|
||||||
|
│ ── Stift (PEN) ──────────────
|
||||||
|
│ Linienstil [ Wand stark ▾ ] ← Dropdown über project.lineStyles
|
||||||
|
│ Strichart [ durchgezogen ▾ ] ← override.kind (leer = "vom Stil")
|
||||||
|
│ Farbe [■] [↺] ← override.color; ↺ = Override entfernen
|
||||||
|
│ Stärke [ 0.35 ] mm [↺] ← override.lw
|
||||||
|
│
|
||||||
|
│ ── Schraffur (HATCH) ────────
|
||||||
|
│ [✓] aktiv
|
||||||
|
│ Schraffur [ Beton ▾ ] ← Dropdown über project.hatches
|
||||||
|
│ Muster [ vom Stil ▾ ] ← override.pattern
|
||||||
|
│ Maßstab [ 1.00 ] [↺]
|
||||||
|
│ Drehung [ 45 ] ° [↺]
|
||||||
|
│ Farbe [■] [↺]
|
||||||
|
│ Linienst. [ 0.13 ] mm [↺]
|
||||||
|
└──────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Override-Semantik im UI:** Jedes Override-Feld zeigt entweder „vom Stil"
|
||||||
|
(Override leer → Platzhalter mit dem aufgelösten Stil-Wert als Hint) oder einen
|
||||||
|
konkreten Wert. Ein kleiner **Reset-Knopf `↺`** je Override-Feld löscht das
|
||||||
|
Override (setzt es zurück auf `undefined` → Feld folgt wieder dem Stil).
|
||||||
|
- **Live, kein Bestätigen:** wie der heutige Editor — jede Änderung ruft sofort
|
||||||
|
`patchCategory(code, patch)`.
|
||||||
|
- **i18n:** alle Labels über `t()`. Neue Keys (Beispiele):
|
||||||
|
`editor.pen`, `editor.lineStyle`, `editor.lineKind`, `editor.color`,
|
||||||
|
`editor.lineWeight`, `editor.hatch`, `editor.hatchEnabled`, `editor.pattern`,
|
||||||
|
`editor.scale`, `editor.rotation`, `editor.fromStyle`, `editor.resetOverride`.
|
||||||
|
Strichart-/Muster-Werte: `lineKind.solid`, `lineKind.dashed`, …,
|
||||||
|
`hatchPattern.solid`, `hatchPattern.diagonal`, … . Menü-Label bleibt
|
||||||
|
`ctx.layerSettings`.
|
||||||
|
|
||||||
|
### 6.2 Patch-Helfer
|
||||||
|
|
||||||
|
`patchCategory(code, patch: Partial<LayerCategory>)` bleibt die Schnittstelle.
|
||||||
|
Für die verschachtelten Overrides nutzt der Editor schmale Helfer (im App-Scope),
|
||||||
|
die sparse mergen und leere Overrides auf `undefined` kollabieren:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function setPenOverride(cat: LayerCategory, patch: Partial<LayerPen["override"]>) {
|
||||||
|
const next = pruneEmpty({ ...cat.pen.override, ...patch });
|
||||||
|
patchCategory(cat.code, { pen: { ...cat.pen, override: next } });
|
||||||
|
}
|
||||||
|
function setHatchOverride(cat, patch) { /* analog für cat.hatch.override */ }
|
||||||
|
// pruneEmpty: entfernt undefined-Felder; gibt undefined zurück, wenn leer.
|
||||||
|
```
|
||||||
|
|
||||||
|
`setLineStyleId` / `setHatchId` setzen nur die Referenz; `hatch.enabled` ist ein
|
||||||
|
Checkbox-Patch.
|
||||||
|
|
||||||
|
### 6.3 „Eigenschaften kopieren / einfügen"
|
||||||
|
|
||||||
|
Der bestehende `layerClipboard` (heute `{ color, lw }`) wird auf die volle
|
||||||
|
Darstellung erweitert: `{ pen, hatch }` (die Override-tragenden Strukturen, ohne
|
||||||
|
`code/name/visible/locked`). „Kopieren" liest `{ pen, hatch }` der Quelle,
|
||||||
|
„Einfügen" patcht sie auf das Ziel. So überträgt sich der komplette Stift +
|
||||||
|
Schraffur einer Ebene auf eine andere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration bestehender Beispieldaten
|
||||||
|
|
||||||
|
Bestehende Projekte/Sample-Daten haben `LayerCategory { color, lw, hatch?: string }`
|
||||||
|
und `LineStyle { weight, color, dash }` (ohne `kind`). Eine reine Lese-Zeit-
|
||||||
|
Migration (`migrateProject(project)`), idempotent, beim Laden:
|
||||||
|
|
||||||
|
1. **LineStyle.kind ableiten** — aus `dash`:
|
||||||
|
```
|
||||||
|
dash == null || dash.length === 0 → "solid"
|
||||||
|
sonst, wenn min(dash) sehr klein → "dotted" (heuristisch)
|
||||||
|
sonst → "dashed"
|
||||||
|
```
|
||||||
|
(Eine genaue Zuordnung ist nicht nötig; `dash` bleibt führend, `kind` ist nur
|
||||||
|
für das Dropdown.)
|
||||||
|
|
||||||
|
2. **Neutralen Basis-Linienstil sicherstellen** — falls die Bibliothek noch keinen
|
||||||
|
generischen „Standard"-Stift hat, einen `lineStyle` mit
|
||||||
|
`{ id: "ls-default", name: "Standard", kind: "solid", weight: <Ebenen-lw>, color: "#000", dash: null }`
|
||||||
|
anlegen. (Pro Ebene wird der Stift referenziert; die Ebenen-spezifischen
|
||||||
|
`color`/`lw` wandern in das **Override**, nicht in den Stil — so bleibt der
|
||||||
|
Stil wiederverwendbar.)
|
||||||
|
|
||||||
|
3. **Pro LayerCategory `pen` bauen:**
|
||||||
|
```ts
|
||||||
|
pen = {
|
||||||
|
lineStyleId: "ls-default",
|
||||||
|
override: pruneEmpty({ color: cat.color, lw: cat.lw }),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Damit ist die Darstellung **pixelgenau wie vorher** (gleiche Farbe, gleiche lw),
|
||||||
|
nur jetzt über die Resolver-Kette.
|
||||||
|
|
||||||
|
4. **Pro LayerCategory `hatch` bauen** — aus dem alten `hatch?: string`:
|
||||||
|
- War `hatch` ein gültiger `HatchStyle.id` → `{ enabled: true, hatchId: hatch }`.
|
||||||
|
- War es ein Pattern-Name oder leer/unbekannt → `{ enabled: false, hatchId:
|
||||||
|
<erste Hatch-id der Bibliothek> }` (Referenz muss existieren, aber inaktiv).
|
||||||
|
So entsteht **keine** unbeabsichtigte Schraffur (Default heute: keine).
|
||||||
|
|
||||||
|
5. **Deprecated-Felder belassen** für eine Übergangsphase; nach Umstellung aller
|
||||||
|
Lesepfade (`generatePlan`, `LayersPanel`-Swatch, Clipboard) in einem zweiten
|
||||||
|
Schritt `color`/`lw` aus `LayerCategory` und der alte `hatch: string` entfernen.
|
||||||
|
|
||||||
|
Migration ist **idempotent**: Liegt `pen`/`hatch` bereits vor, wird die Ebene
|
||||||
|
unverändert durchgereicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Build-Plan (phasiert)
|
||||||
|
|
||||||
|
**Phase 1 — Datenmodell & Resolver (keine UI-Sichtbarkeit).**
|
||||||
|
- `LineKind` + `LINE_DASH`, `LineStyle.kind`, `LayerPen`, `LayerHatch`,
|
||||||
|
`LayerCategory.pen/hatch` in `types.ts`.
|
||||||
|
- `src/model/layerStyle.ts`: `PenRender`, `resolvePen`, `resolveLayerHatch`,
|
||||||
|
`penMap`, `layerHatchMap`; `HatchRender` hierher ziehen + re-exportieren.
|
||||||
|
- `migrateProject()` (Schritte §7) + Aufruf beim Laden/Seed.
|
||||||
|
- `npx tsc -b` grün.
|
||||||
|
|
||||||
|
**Phase 2 — Generator umstellen.**
|
||||||
|
- `generatePlan` nutzt `penMap`/`layerHatchMap` statt `categoryLwMap`.
|
||||||
|
- `Primitive`-polygon `dash?`, line/arc `stroke?` ergänzen; `addWallPoche`/
|
||||||
|
`addDoorSymbol`-Signaturen auf `PenRender` + `HatchRender|null`.
|
||||||
|
- Ebenen-Default-Schraffur in „grob" und für Schicht-lose Flächen verdrahten.
|
||||||
|
- Visuell prüfen via `node scripts/probe.mjs` (Geometrie unverändert, Farben/lw
|
||||||
|
identisch zur Migration).
|
||||||
|
|
||||||
|
**Phase 3 — Panel.**
|
||||||
|
- `LayersPanel`-Swatch liest aufgelöste Stift-Farbe (`resolvePen(...).color`).
|
||||||
|
|
||||||
|
**Phase 4 — Editor.**
|
||||||
|
- `InlineEditor`-Inhalt für `kind: "layer"` auf die PEN/HATCH-Sektionen erweitern
|
||||||
|
(§6), mit Dropdowns über `project.lineStyles` / `project.hatches`, Reset-Knöpfen,
|
||||||
|
neuen i18n-Keys.
|
||||||
|
- `layerClipboard` auf `{ pen, hatch }` erweitern; Kopieren/Einfügen anpassen.
|
||||||
|
|
||||||
|
**Phase 5 — Aufräumen.**
|
||||||
|
- Deprecatete `color`/`lw`/`hatch: string` aus `LayerCategory` entfernen, sobald
|
||||||
|
kein Lesepfad sie mehr nutzt; Sample-Daten direkt im neuen Format ablegen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Offene Punkte / bewusst nicht jetzt
|
||||||
|
|
||||||
|
- **Pro-Geschoss-Overrides der Ebene** (eine Ebene anders je `DrawingLevel`):
|
||||||
|
nicht in dieser Iteration; das Schema gilt geschossübergreifend (types.ts).
|
||||||
|
Falls später nötig, als zweite Override-Ebene über demselben Resolver.
|
||||||
|
- **Regelbasierte Overrides** (resources-graphics.md, `overrides.py`): orthogonal;
|
||||||
|
würden nach der Ebenen-Auflösung greifen.
|
||||||
|
- **Linienstil-Endkappen/Joins** und feinere Dash-Skalierung: bleiben in der
|
||||||
|
PlanView (Darstellung), nicht im Modell.
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
# Parametrische Wände
|
||||||
|
|
||||||
|
Status: Implementiert (Phase A — Typ-System und Resolver in `src/model/`, kein UI).
|
||||||
|
Dieses Dokument spezifiziert die **Parametrischen Wände**: regelbasierte Definitionen,
|
||||||
|
die beim Auflösen eine Liste von `Wall`-Elementen erzeugen, anstatt sie einzeln vom
|
||||||
|
Nutzer zeichnen zu lassen.
|
||||||
|
|
||||||
|
Bezugsdokumente: [elements.md](elements.md) (Wand-/Türmodell),
|
||||||
|
[drawing-tools.md](drawing-tools.md) (Werkzeugsystem, Direktzeichnen),
|
||||||
|
[state-architecture.md](state-architecture.md) (Projekt-Store),
|
||||||
|
[resources-graphics.md](resources-graphics.md) (WallType/Component-Auflösung).
|
||||||
|
|
||||||
|
Implementierungsdateien:
|
||||||
|
- `src/model/types.ts` — `ParametricWall`, `ParametricRule` und alle Regel-Varianten.
|
||||||
|
- `src/model/parametricWalls.ts` — `resolveParametricWall()`, `applyRule()` und
|
||||||
|
Hilfsfunktionen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Überblick
|
||||||
|
|
||||||
|
Eine **parametrische Wand** (`ParametricWall`) ist kein festes `Wall`-Element, sondern
|
||||||
|
ein **Regelwerk**, das beim Auflösen (`resolveParametricWall`) eine Menge von `Wall[]`-
|
||||||
|
Elementen generiert. Die erzeugten Wände sind gewöhnliche `Wall`-Objekte; sie
|
||||||
|
unterscheiden sich lediglich in ihrer Herkunft. Das semantische Modell (`Project`)
|
||||||
|
bleibt die einzige Wahrheit — parametrische Wände sind eine Ressource in der
|
||||||
|
Ressourcen-Bibliothek, nicht eine separate Laufzeit-Geometrie-Schicht.
|
||||||
|
|
||||||
|
```
|
||||||
|
Project.parametricWalls: ParametricWall[]
|
||||||
|
│
|
||||||
|
│ resolveParametricWall(pw, floorId, context, defaultWallType)
|
||||||
|
▼
|
||||||
|
Wall[] ──→ normales Rendering über generatePlan / Viewport3D
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugte Wände können entweder **temporär** (zur Laufzeit, als Ergänzung zu
|
||||||
|
`project.walls` im Rendering-Pfad) oder **eingebacken** (als `Wall[]` fest in
|
||||||
|
`Project.walls` gespeichert) behandelt werden. Phase A legt nur den Auflöser fest;
|
||||||
|
die Auswahl liegt bei der aufrufenden Komponente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
### 1.1 Schnellere Modellierung von Regelgrundrissen
|
||||||
|
|
||||||
|
Schweizer Wohnbauten folgen häufig einem 3-m-Achsraster (SIA-Norm, Modul-/
|
||||||
|
Skelettbauweise). Zwanzig Wände eines Rasters von Hand zu zeichnen ist fehleranfällig
|
||||||
|
und verhindert spätere parametrische Änderungen (z. B. Geschossanzahl, Rasterweite,
|
||||||
|
Wandtyp).
|
||||||
|
|
||||||
|
Eine `GridRule` erzeugt dieses Muster aus wenigen Parametern (Achsabstand, Richtung,
|
||||||
|
Bereich) und lässt sich mit einer einzigen Zahl auf „4-m-Büroraster" umstellen.
|
||||||
|
|
||||||
|
### 1.2 Kongruenz mit FreeCAD BIM / IFC
|
||||||
|
|
||||||
|
FreeCAD BIM kennt **ParametricObjects**, die ihre Geometrie aus Regeln ableiten (z. B.
|
||||||
|
`ArchWall` mit `Length`, `Width`, `Height`). Obwohl das Datenformat hier kein IFC ist,
|
||||||
|
schafft ein ähnliches Abstraktionsniveau eine spätere Brücke: Beim IFC-Export können
|
||||||
|
parametrische Wände als `IfcWallStandardCase` mit konstanten Attributen exportiert
|
||||||
|
werden — kein Informationsverlust gegenüber manuell gezeichneten Wänden.
|
||||||
|
|
||||||
|
### 1.3 Bedingte Wandtypen ohne manuelle Klassifizierung
|
||||||
|
|
||||||
|
Außenwände sind dicker als Innenwände; Trennwände zwischen Einheiten erfordern
|
||||||
|
Schallschutz. Eine `ConditionalThicknessRule` (`condition: "exterior" → thickType`)
|
||||||
|
weist den richtigen Wandtyp automatisch aus der geometrischen Lage zu — ohne dass der
|
||||||
|
Nutzer jeden Wandabschnitt einzeln klassifizieren muss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur
|
||||||
|
|
||||||
|
### 2.1 Typen (`src/model/types.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Eine parametrische Wand-Regel — generiert automatisch Wall[]-Einträge für
|
||||||
|
* ein gegebenes Geschoss. Lebt in Project.parametricWalls[].
|
||||||
|
*/
|
||||||
|
export interface ParametricWall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* Geordnete Liste der anzuwendenden Regeln. Spätere Regeln können die
|
||||||
|
* Ausgabe früherer verfeinern (z. B. Dickenzuweisung nach Raster).
|
||||||
|
*/
|
||||||
|
rules: ParametricRule[];
|
||||||
|
/**
|
||||||
|
* Rückfall-Wandtyp, falls eine Regel keinen eigenen `wallTypeId` nennt.
|
||||||
|
*/
|
||||||
|
defaultWallTypeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diskriminierte Union aller Regel-Varianten. */
|
||||||
|
export type ParametricRule =
|
||||||
|
| GridRule
|
||||||
|
| ModuleRule
|
||||||
|
| ConditionalThicknessRule
|
||||||
|
| ReferenceLineRule
|
||||||
|
| SequenceRule;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Einbettung ins Projekt
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Project {
|
||||||
|
// … bestehende Felder …
|
||||||
|
/**
|
||||||
|
* Parametrische Wanddefinitionen (Ressourcen-Bibliothek). Optional, damit
|
||||||
|
* bestehende Projekte/Tests ohne `parametricWalls` gültig bleiben.
|
||||||
|
*/
|
||||||
|
parametricWalls?: ParametricWall[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Resolver-Kontext (`src/model/parametricWalls.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ParametricContext {
|
||||||
|
/** Das Ziel-Geschoss. */
|
||||||
|
floor: DrawingLevel;
|
||||||
|
/**
|
||||||
|
* Optionale Rasterachsen (Phase C: verlinkter Grid-Ressource). Fehlen sie,
|
||||||
|
* berechnet die Engine die Achsen aus GridRule.spacing.
|
||||||
|
*/
|
||||||
|
gridAxes?: { x: number[]; y: number[] };
|
||||||
|
/**
|
||||||
|
* Optionales Clipping-Polygon (Meter). Fehlt es, reicht das Raster über
|
||||||
|
* einen Standardbereich (0 … spacing × 10).
|
||||||
|
*/
|
||||||
|
boundaryGeometry?: { boundary: Vec2[] };
|
||||||
|
/**
|
||||||
|
* Bereits im Projekt vorhandene Wände des Geschosses. Werden von
|
||||||
|
* refinierenden Regeln (ConditionalThicknessRule, ReferenceLineRule) genutzt.
|
||||||
|
*/
|
||||||
|
existingWalls?: Wall[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Regel-Varianten
|
||||||
|
|
||||||
|
### 3.1 GridRule — Achsraster
|
||||||
|
|
||||||
|
Erzeugt parallele Wände auf einem gleichmäßigen Raster. Typischer Einsatz: Schweizer
|
||||||
|
Wohnbau-Achsraster (3 m), Büro-Konstruktionsraster (6 m), strukturelle Raster mit
|
||||||
|
fester Stützweite.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface GridRule {
|
||||||
|
type: "grid";
|
||||||
|
/**
|
||||||
|
* Optionaler Verweis auf eine Grid-Ressource (Phase C). Für Phase A wird
|
||||||
|
* stattdessen `spacing` genutzt.
|
||||||
|
*/
|
||||||
|
gridId?: string;
|
||||||
|
/** Rasterabstand in Metern (Default: 3.0). */
|
||||||
|
spacing?: number;
|
||||||
|
/**
|
||||||
|
* Achsrichtungen: „x" = nur Wände entlang der Y-Achse,
|
||||||
|
* „y" = nur Wände entlang der X-Achse, „both" = Vollraster.
|
||||||
|
*/
|
||||||
|
directions: "x" | "y" | "both";
|
||||||
|
/** Optionaler Verweis auf Clipping-Polygon. */
|
||||||
|
boundaryId?: string;
|
||||||
|
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
|
||||||
|
wallTypeId?: string;
|
||||||
|
/** Lage der Wandachse über die Dicke (Vectorworks-Stil). */
|
||||||
|
referenceLine?: WallReferenceLine;
|
||||||
|
/** Optionale Höhenübersteuerung in Metern; sonst Geschosshöhe. */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Geometrieausgabe (top-down Grundriss):**
|
||||||
|
|
||||||
|
```
|
||||||
|
directions: "x", spacing: 3.0, Bereich 0…12 m:
|
||||||
|
|
||||||
|
y
|
||||||
|
│
|
||||||
|
12 ──────────────────────────
|
||||||
|
│
|
||||||
|
9 ──────────────────────────
|
||||||
|
│
|
||||||
|
6 ──────────────────────────
|
||||||
|
│
|
||||||
|
3 ──────────────────────────
|
||||||
|
│
|
||||||
|
0 ──────────────────────────
|
||||||
|
│
|
||||||
|
└──────────────────────────► x
|
||||||
|
0 12
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Tragende Wände auf fester Stützweite (Wohnbau 3 m, Büro 6 m).
|
||||||
|
- Vollraster (`"both"`) für strukturelle Rastersysteme.
|
||||||
|
- In Kombination mit `ConditionalThicknessRule` zur automatischen Außen/Innen-Klassifizierung.
|
||||||
|
|
||||||
|
**Beispiel: Schweizer 3-m-Wohnraster**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pw: ParametricWall = {
|
||||||
|
id: "pw-eg-raster",
|
||||||
|
name: "EG Längswände 3m-Raster",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "grid",
|
||||||
|
spacing: 3.0,
|
||||||
|
directions: "x", // Wände in X-Richtung (y = 0, 3, 6, 9, 12)
|
||||||
|
wallTypeId: "wt-innen-15",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auflösung:
|
||||||
|
const walls = resolveParametricWall(pw, "floor-eg", {
|
||||||
|
floor: egFloor,
|
||||||
|
boundaryGeometry: { boundary: rectBoundary(0, 0, 12, 12) },
|
||||||
|
}, defaultWallType);
|
||||||
|
// → 5 Wände bei y = 0, 3, 6, 9, 12, je 12 m lang
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 ModuleRule — Bay-/Jochbauweise
|
||||||
|
|
||||||
|
Unterteilt eine Referenzspanne in gleiche Module und erzeugt Querwände an jedem
|
||||||
|
Teilungspunkt. Typisch für Bürogebäude (6-m-Joch) oder Reihenhäuser mit modularer
|
||||||
|
Erschließung.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ModuleRule {
|
||||||
|
type: "module";
|
||||||
|
/** Modulmaß in Metern (z. B. 6.0, 3.6). */
|
||||||
|
moduleSize: number;
|
||||||
|
/** Ausrichtung der Trennwände: „x" = Querwände senkrecht zu X, „y" = zu Y. */
|
||||||
|
direction: "x" | "y";
|
||||||
|
/**
|
||||||
|
* Optionaler Verweis auf eine Referenzwand, die die Spannweite definiert.
|
||||||
|
* Fehlt er, wird die Geschoss-Ausdehnung (Bounding-Box) genutzt.
|
||||||
|
*/
|
||||||
|
referenceWallId?: string;
|
||||||
|
/** Optionale Wandtyp-Übersteuerung; sonst defaultWallTypeId. */
|
||||||
|
wallTypeId?: string;
|
||||||
|
referenceLine?: WallReferenceLine;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Wenn sich Querwände aus einer Referenzspanne (Fassade, Achswand) ergeben.
|
||||||
|
- Vorzug vor `GridRule`, wenn nur in eine Richtung unterteilt wird und eine
|
||||||
|
Referenzwand die Spanne definiert.
|
||||||
|
|
||||||
|
**Beispiel: 6-m-Bay-Bürogebäude**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pw: ParametricWall = {
|
||||||
|
id: "pw-buero-joch",
|
||||||
|
name: "Büro 6m-Joch",
|
||||||
|
defaultWallTypeId: "wt-beton-20",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
moduleSize: 6.0,
|
||||||
|
direction: "x", // Querwände senkrecht zur X-Achse
|
||||||
|
// referenceWallId: "W-sudfassade" → Spanne aus der Südwand ableiten
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30 (bei 36-m-Fassade)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 ConditionalThicknessRule — Bedingte Wandtypen
|
||||||
|
|
||||||
|
Weist bereits erzeugten Wänden (aus vorherigen Regeln in der Sequenz) einen anderen
|
||||||
|
Wandtyp zu — abhängig von einer Bedingung. Gibt modifizierte **Kopien** zurück; die
|
||||||
|
Eingabe-Wände werden nicht mutiert.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ConditionalThicknessRule {
|
||||||
|
type: "conditional-thickness";
|
||||||
|
/**
|
||||||
|
* Bedingung für den Treffer:
|
||||||
|
* • „exterior" — Wand liegt am Außenrand (Bounding-Box des Kontexts).
|
||||||
|
* • „interior" — Wand liegt im Inneren.
|
||||||
|
* • „bearing" — tragende Wand (Heuristikum: Wand läuft ±10° zu X/Y-Achse).
|
||||||
|
* • beliebiger String — benutzerdefiniertes Tag (Phase C: Wall.tags[]).
|
||||||
|
*/
|
||||||
|
condition: "exterior" | "interior" | "bearing" | string;
|
||||||
|
/** Ziel-Wandtyp, der bei Treffer gesetzt wird. */
|
||||||
|
wallTypeId: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Immer in Kombination mit `GridRule` oder `ModuleRule` (als zweite Regel in
|
||||||
|
`ParametricWall.rules`): Raster erzeugt, Dicke verfeinert.
|
||||||
|
- Wenn Außen- und Innenwände denselben geometrischen Ursprung haben, aber
|
||||||
|
verschiedene Aufbauten benötigen.
|
||||||
|
|
||||||
|
**Beispiel: Außen dick, Innen dünn**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const pw: ParametricWall = {
|
||||||
|
id: "pw-eg-komplett",
|
||||||
|
name: "EG Vollraster mit Außenwand-Differenzierung",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "grid", spacing: 3.0, directions: "both",
|
||||||
|
wallTypeId: "wt-innen-15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "conditional-thickness",
|
||||||
|
condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36", // Außenwände erhalten dicken Aufbau
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 ReferenceLineRule — Wandachsen-Lage
|
||||||
|
|
||||||
|
Setzt `referenceLine` bei passenden Wänden einheitlich (Vectorworks-Stil: Achse
|
||||||
|
links/rechts/mittig). Gibt modifizierte Kopien zurück.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ReferenceLineRule {
|
||||||
|
type: "reference-line";
|
||||||
|
/** Neue Lage der Wandachse, die einheitlich gesetzt wird. */
|
||||||
|
referenceLine: WallReferenceLine; // "left" | "center" | "right"
|
||||||
|
/**
|
||||||
|
* Filterziel:
|
||||||
|
* • „all" — alle Wände im aktuellen Satz.
|
||||||
|
* • „exterior" — nur Außenwände.
|
||||||
|
* • beliebiger String — benutzerdefiniertes Tag (Phase C).
|
||||||
|
*/
|
||||||
|
target: "all" | "exterior" | string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Außenwände auf `"left"` setzen (Achse liegt auf der Fassadenfläche).
|
||||||
|
- Als abschließende Regel in einer `SequenceRule` nach Raster und Dickenzuweisung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 SequenceRule — Zusammenfassung von Unterregeln
|
||||||
|
|
||||||
|
Fasst mehrere Regeln als atomare Einheit zusammen. Jede Unterregel erhält die Ausgabe
|
||||||
|
der vorherigen als `existingWalls` — so können spätere Regeln frühere verfeinern.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SequenceRule {
|
||||||
|
type: "sequence";
|
||||||
|
rules: ParametricRule[];
|
||||||
|
/**
|
||||||
|
* Wenn true: Abbruch nach der ersten Unterregel, die mindestens eine Wand
|
||||||
|
* generiert/verändert hat (Short-Circuit-Fallback).
|
||||||
|
*/
|
||||||
|
stopOnMatch?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wann verwenden:**
|
||||||
|
- Um eine zusammengehörige Kombination (Raster → Dicke → Referenzlinie) als
|
||||||
|
Untermodul wiederzuverwenden — z. B. in unterschiedlichen Geschossen mit leicht
|
||||||
|
abweichenden Parametern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolver-API (`src/model/parametricWalls.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Löst ein ParametricWall-Regelwerk zu einem Wall[]-Array für ein gegebenes
|
||||||
|
* Geschoss auf.
|
||||||
|
*
|
||||||
|
* Ablauf:
|
||||||
|
* 1. Regelwerk sequenziell ausführen; jede Regel erhält die Ausgabe der
|
||||||
|
* vorherigen als existingWalls (ermöglicht Verfeinerung).
|
||||||
|
* 2. Duplikate (gleicher Start-/Endpunkt innerhalb tolerance) entfernen.
|
||||||
|
* 3. Bereinigte Wall[]-Liste zurückgeben.
|
||||||
|
*
|
||||||
|
* Die Ausgabe ist sofort bereit zur Einfügung in project.walls. Es werden
|
||||||
|
* keine Seiteneffekte erzeugt — kein Store, kein Dispatch, kein React.
|
||||||
|
*
|
||||||
|
* @param parametricWall Das Regelwerk.
|
||||||
|
* @param floorId ID des Ziel-Geschosses.
|
||||||
|
* @param context Kontext (Geschoss-Objekt, Grid-Achsen, Grenzen, …).
|
||||||
|
* @param defaultWallType Fallback-Wandtyp, wenn eine Regel keinen nennt.
|
||||||
|
* @param tolerance Näherungstoleranz für Duplikat-Erkennung (Meter, Default 0.01).
|
||||||
|
* @returns Wall[]-Array, bereit zur Einfügung.
|
||||||
|
*/
|
||||||
|
export function resolveParametricWall(
|
||||||
|
parametricWall: ParametricWall,
|
||||||
|
floorId: string,
|
||||||
|
context: ParametricContext,
|
||||||
|
defaultWallType: WallType,
|
||||||
|
tolerance?: number,
|
||||||
|
): Wall[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcher: delegiert eine Regel an die passende Implementierung.
|
||||||
|
* Exportiert für Unit-Tests und erweiterbare Regeltypen.
|
||||||
|
*/
|
||||||
|
export function applyRule(rule: ParametricRule, ctx: RuleCtx): Wall[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt doppelte Wände: zwei Wände gelten als Duplikat, wenn Start- und
|
||||||
|
* Endpunkt jeweils innerhalb tolerance übereinstimmen (vorwärts und rückwärts).
|
||||||
|
*/
|
||||||
|
export function deduplicateWalls(walls: Wall[], tolerance?: number): Wall[];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Höhenauflösung
|
||||||
|
|
||||||
|
Die Wandhöhe (`Wall.height`) ergibt sich nach folgender Priorität:
|
||||||
|
|
||||||
|
1. `rule.height`, falls an der einzelnen Regel gesetzt.
|
||||||
|
2. `context.floor.floorHeight` des Zielgeschosses.
|
||||||
|
3. Fallback: 2.6 m (globaler Default, CONVENTIONS.md).
|
||||||
|
|
||||||
|
### 4.2 ID-Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
"pw-<floorId>-gx-<counter>" // GridRule, X-Achse
|
||||||
|
"pw-<floorId>-gy-<counter>" // GridRule, Y-Achse
|
||||||
|
"pw-<floorId>-mx-<counter>" // ModuleRule, X-Teilung
|
||||||
|
"pw-<floorId>-ct-<counter>" // ConditionalThicknessRule
|
||||||
|
"pw-<floorId>-rl-<counter>" // ReferenceLineRule
|
||||||
|
```
|
||||||
|
|
||||||
|
IDs sind sessionlokal (Zähler startet bei 0 je Modullade). Eingebrannte Wände
|
||||||
|
erhalten beim Commit neue stabile IDs über `uniqueId("W")` — konsistent mit dem
|
||||||
|
Wand-Werkzeug (vgl. [drawing-tools.md §8](drawing-tools.md#8-id-vergabe--immutabilität)).
|
||||||
|
|
||||||
|
### 4.3 Duplikat-Erkennung
|
||||||
|
|
||||||
|
`deduplicateWalls` vergleicht Start-/Endpunkte beider Wände (vorwärts: A→B == A→B,
|
||||||
|
und rückwärts: A→B == B→A) innerhalb einer Toleranz von 1 cm (0.01 m). Die **erste**
|
||||||
|
Instanz wird behalten; spätere Duplikate werden verworfen. Dies ist wichtig bei
|
||||||
|
Vollrastern (`"both"`), bei denen X- und Y-Wände exakt auf einem Rasterpunkt
|
||||||
|
zusammentreffen könnten.
|
||||||
|
|
||||||
|
### 4.4 Verhalten bei ungültigen Eingaben
|
||||||
|
|
||||||
|
| Situation | Verhalten |
|
||||||
|
|-----------|-----------|
|
||||||
|
| `spacing <= 0` oder `moduleSize <= 0` | `[]` |
|
||||||
|
| `referenceWallId` nicht in `existingWalls` | Fallback auf Bounding-Box, kein Fehler |
|
||||||
|
| Unbekannter `condition`-String | `matchesCondition` gibt `false` zurück (kein Treffer) |
|
||||||
|
| Unbekannter `SequenceRule`-Untertyp | TypeScript exhaustiveness-Guard, `[]` |
|
||||||
|
| Segment mit `|end - start| < 1e-6` m | Kann durch deduplicateWalls entfernt werden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Integration ins Projekt
|
||||||
|
|
||||||
|
### 5.1 Ressourcen-Speicherung
|
||||||
|
|
||||||
|
`ParametricWall`-Einträge leben unter `Project.parametricWalls` (optionales Array).
|
||||||
|
Sie sind Teil des `.cad.json`-Dokuments und werden mit dem Rest des Projekts gespeichert.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// sampleProject.ts — Beispieleintrag
|
||||||
|
export const sampleProject: Project = {
|
||||||
|
// …
|
||||||
|
parametricWalls: [
|
||||||
|
{
|
||||||
|
id: "pw-eg-raster",
|
||||||
|
name: "EG Längswände 3m-Raster",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{ type: "grid", spacing: 3.0, directions: "x" },
|
||||||
|
{ type: "conditional-thickness", condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Rendering ohne UI (Phase A)
|
||||||
|
|
||||||
|
In Phase A werden parametrische Wände **nicht** automatisch gerendert. Der Auflöser
|
||||||
|
ist eine reine Funktion; Aufrufer müssen ihn explizit einbinden. Mögliche Verwendung
|
||||||
|
in `generatePlan` oder `Viewport3D`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// generatePlan.ts (Ergänzung, Phase A)
|
||||||
|
const defaultWallType = project.wallTypes[0];
|
||||||
|
const extraWalls = (project.parametricWalls ?? []).flatMap((pw) =>
|
||||||
|
resolveParametricWall(pw, activeLevelId, {
|
||||||
|
floor: activeFloor,
|
||||||
|
boundaryGeometry: projectBoundary,
|
||||||
|
}, defaultWallType)
|
||||||
|
);
|
||||||
|
const allWalls = [...project.walls, ...extraWalls];
|
||||||
|
// … allWalls statt project.walls in der Rendering-Pipeline verwenden
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Keine UI in Phase A
|
||||||
|
|
||||||
|
Kein Command, kein Panel, kein Formular. `ParametricWall`-Einträge werden in Phase A
|
||||||
|
ausschließlich **programmatisch** (Unit-Tests, `sampleProject`, direkte JSON-Bearbeitung
|
||||||
|
des Projekts) erstellt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ausblick: Folge-Phasen
|
||||||
|
|
||||||
|
### Phase B — UI und Command-Schnittstelle
|
||||||
|
|
||||||
|
- Neues Command (z. B. `PWWALL`) oder Ressourcen-Manager-Tab „Parametrische Wände"
|
||||||
|
mit Formular-Editor je Regeltyp.
|
||||||
|
- „Einbrennen" (Flatten): `ParametricWall` → feste `Wall[]` in `Project.walls`
|
||||||
|
einfügen und den `ParametricWall`-Eintrag entfernen (unidirektional, Undo über Store).
|
||||||
|
- Auswahl parametrischer Wände im Plan (als Gruppe); Grip-Editing der Raster-Parameter
|
||||||
|
und Spannweiten.
|
||||||
|
|
||||||
|
### Phase C — Grid-Ressource und Schnittpunkt-Clipping
|
||||||
|
|
||||||
|
- `GridResource`: ein projektweites, benanntes Koordinatenraster (LV95-Offset,
|
||||||
|
Rasterweite, Drehung), auf das mehrere `GridRule`-Instanzen via `gridId` verweisen.
|
||||||
|
- Präzises Clipping: erzeugte Wände werden am Gebäudeumriss getrimmt — exakte
|
||||||
|
`lineIntersect`-Berechnung statt Bounding-Box-Approximation.
|
||||||
|
- Benutzerdefinierte Tags (`Wall.tags[]`) für komplexe `ConditionalThicknessRule`-
|
||||||
|
Bedingungen jenseits von „exterior/interior/bearing".
|
||||||
|
- IFC-Export: `ParametricWall`-Gruppen → `IfcWallStandardCase` mit parametrischen
|
||||||
|
Attributen und `IfcRelDefinesByType`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Vollständige Anwendungsbeispiele
|
||||||
|
|
||||||
|
### 7.1 Schweizer Wohnbau: 3-m-Raster EG + 1.OG
|
||||||
|
|
||||||
|
Zwei-Geschoss-Wohnhaus, typisches CH-Wohnbauraster. Die Längswände beider Geschosse
|
||||||
|
entstehen aus zwei `ParametricWall`-Einträgen mit identischen Regeln, unterschieden
|
||||||
|
nur durch `floorId` beim Auflösen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Top-down (Grundriss):
|
||||||
|
|
||||||
|
y=12 ──────────────────────────── (W5)
|
||||||
|
y=9 ──────────────────────────── (W4)
|
||||||
|
y=6 ──────────────────────────── (W3)
|
||||||
|
y=3 ──────────────────────────── (W2)
|
||||||
|
y=0 ──────────────────────────── (W1)
|
||||||
|
↑
|
||||||
|
x=0 x=12
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const rasterRegel: ParametricWall = {
|
||||||
|
id: "pw-laengswand-raster",
|
||||||
|
name: "Längswände 3m-Raster",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{ type: "grid", spacing: 3.0, directions: "x" },
|
||||||
|
// Außenwände (y=0 und y=12) erhalten den dicken Aufbau:
|
||||||
|
{ type: "conditional-thickness", condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36" },
|
||||||
|
// Außenwände: Achse liegt auf der Fassadenfläche:
|
||||||
|
{ type: "reference-line", referenceLine: "left", target: "exterior" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// EG auflösen:
|
||||||
|
const wallsEG = resolveParametricWall(rasterRegel, "floor-eg",
|
||||||
|
{ floor: egFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
|
||||||
|
project.wallTypes[0]);
|
||||||
|
|
||||||
|
// 1.OG auflösen (gleiche Regel, anderes Geschoss):
|
||||||
|
const wallsOG = resolveParametricWall(rasterRegel, "floor-og1",
|
||||||
|
{ floor: ogFloor, boundaryGeometry: { boundary: rect(0,0,12,12) } },
|
||||||
|
project.wallTypes[0]);
|
||||||
|
// Änderung spacing: 3.5 → beide Geschosse sofort konsistent.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Vollraster mit Außen/Innen-Differenzierung
|
||||||
|
|
||||||
|
Gebäudeumriss als Rechteck; die Randwände erhalten automatisch den dicken
|
||||||
|
Außenwand-Typ, alle anderen den dünnen Innenwand-Typ:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const vollraster: ParametricWall = {
|
||||||
|
id: "pw-eg-vollraster",
|
||||||
|
name: "EG Vollraster mit Differenzierung",
|
||||||
|
defaultWallTypeId: "wt-innen-15",
|
||||||
|
rules: [
|
||||||
|
{ type: "grid", spacing: 3.0, directions: "both" },
|
||||||
|
{ type: "conditional-thickness", condition: "exterior",
|
||||||
|
wallTypeId: "wt-aussen-36" },
|
||||||
|
{ type: "conditional-thickness", condition: "interior",
|
||||||
|
wallTypeId: "wt-innen-15" },
|
||||||
|
{ type: "reference-line", referenceLine: "left", target: "exterior" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
─────┬─────┬─────┬─────
|
||||||
|
│ │ │ │ │
|
||||||
|
─────┼─────┼─────┼─────
|
||||||
|
│ │ │ │ │
|
||||||
|
─────┴─────┴─────┴─────
|
||||||
|
|
||||||
|
Rand-Segmente: wt-aussen-36 (dicker Aufbau)
|
||||||
|
Innen-Segmente: wt-innen-15 (dünner Aufbau)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Modulbauweise: 6-m-Joch, Bürogebäude
|
||||||
|
|
||||||
|
Längliches Bürogebäude, 36 m × 12 m, 6-m-Joch. Querwände entstehen automatisch;
|
||||||
|
Entwurfsänderung (z. B. auf 7.2-m-Joch) erfordert eine einzige Zahl:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const joch: ParametricWall = {
|
||||||
|
id: "pw-buero-joch",
|
||||||
|
name: "Büro 6m-Joch",
|
||||||
|
defaultWallTypeId: "wt-beton-20",
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
moduleSize: 6.0,
|
||||||
|
direction: "x", // Querwände senkrecht zur X-Achse
|
||||||
|
// referenceWallId: "W-sudfassade" → Spanne aus Referenzwand
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// resolveParametricWall → Querwände bei x = 6, 12, 18, 24, 30
|
||||||
|
// (bei Bounding-Box minX=0, maxX=36, Enden selbst ausgespart)
|
||||||
|
|
||||||
|
// Änderung auf 7.2-m-Joch: moduleSize: 7.2
|
||||||
|
// → 4 Trennwände bei x ≈ 7.2, 14.4, 21.6, 28.8 — automatisch neu berechnet.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Architektur-Garantien
|
||||||
|
|
||||||
|
- **Modell bleibt einzige Wahrheit.** `ParametricWall`-Definitionen sind Daten in
|
||||||
|
`Project.parametricWalls`; `resolveParametricWall` ist eine **reine Funktion** ohne
|
||||||
|
Side-Effects. Keine globale Laufzeit-Geometrie-Schicht.
|
||||||
|
- **Erzeugte Wände sind gewöhnliche `Wall`-Objekte.** Alle nachgelagerten Systeme
|
||||||
|
(`generatePlan`, `Viewport3D`, `computeJoins`) arbeiten unverändert; sie müssen
|
||||||
|
nicht zwischen „parametrisch erzeugten" und „direkt gezeichneten" Wänden
|
||||||
|
unterscheiden.
|
||||||
|
- **Fehlertoleranz statt Absturz.** Unbekannte Regeltypen liefern `[]`; der TypeScript-
|
||||||
|
exhaustiveness-Guard fängt fehlende `case`-Zweige zur Compilezeit. Unbekannte
|
||||||
|
Bedingungsstrings in `ConditionalThicknessRule` geben `false` (kein Treffer) statt
|
||||||
|
zu werfen.
|
||||||
|
- **Keine vorzeitige Generalisierung.** Phase A liefert fünf Regel-Varianten und
|
||||||
|
einen Auflöser. UI, Command-Schnittstelle und Grid-Ressource folgen in Phase B/C.
|
||||||
|
- **Immutabilität.** Verfeinerungsregeln (`ConditionalThicknessRule`,
|
||||||
|
`ReferenceLineRule`) geben modifizierte **Kopien** zurück; `existingWalls` werden
|
||||||
|
nie mutiert — konsistent mit der `setProject`-Konvention (CONVENTIONS.md).
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# Design — Pläne & Output
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Bauteile: [elements.md](elements.md). Ressourcen/Stile: [resources-graphics.md](resources-graphics.md).
|
||||||
|
|
||||||
|
Hier gewinnen wir (ROADMAP §3, Phase 3 ⭐): **schöne, normgerechte 2D-Pläne**,
|
||||||
|
automatisch aus dem Modell abgeleitet, druckfertig als Vektor-PDF. Dieses Dokument
|
||||||
|
übersetzt DOSSIERs `schnitte.py`, `massstab.py`, `ausschnitte.py`, `kamera.py`,
|
||||||
|
`dimensionen.py`, `layouts.py` in Browser-Module. Bezeichner englisch, Prosa
|
||||||
|
deutsch, Meter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ansichtstypen = Kamera-Projektion + optionaler Schnitt
|
||||||
|
|
||||||
|
Vereinheitlichtes Modell (ROADMAP §2c, im Spike als `DrawingLevelKind` angelegt):
|
||||||
|
|
||||||
|
| Typ | Projektion | Schnitt | Erzeugung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Grundriss** | Ortho Top | horizontal auf `okff + cutHeight` | symbolisch aus Footprint (Pfad A) |
|
||||||
|
| **Schnitt** | Ortho Front (Richtung) | vertikale Schnittebene + Tiefe | 3D-Projektion/HLR (Pfad B) |
|
||||||
|
| **Ansicht** | Ortho Front (Richtung) | kein Schnitt (Fassade außen) | 3D-Projektion/HLR (Pfad B) |
|
||||||
|
| **Perspektive** | 3D perspektivisch | — | Three.js direkt |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ViewType = "plan" | "section" | "elevation" | "perspective";
|
||||||
|
interface DerivedView { // was der Viewport gerade zeigt
|
||||||
|
type: ViewType;
|
||||||
|
levelId?: string; // Geschoss (plan) bzw. Schnitt/Ansicht (DrawingLevel)
|
||||||
|
camera: CameraState;
|
||||||
|
cut?: CutSpec; // Clipping-Spezifikation (s.u.)
|
||||||
|
detailLevel: DetailLevel;
|
||||||
|
}
|
||||||
|
interface CutSpec {
|
||||||
|
planes: { point: Vec3; normal: Vec3 }[]; // 1 (plan/elevation) oder 2 (section: cut+back)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zwei Wege zum Plan** (zentrale Architektur-Erkenntnis, ROADMAP §3) — wir bauen
|
||||||
|
**beide**:
|
||||||
|
- **A) Grundriss = symbolisch** aus den Parametern (`plan/generatePlan.ts`, im
|
||||||
|
Spike). Schnell, exakt, vektorbasiert. Kein Mesh-Schnitt.
|
||||||
|
- **B) Schnitt & Ansicht = 3D-Projektion mit Hidden-Line-Removal** (`plan/
|
||||||
|
generateSection.ts`, §4). Durch das zusammengebaute Gebäude.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Schnitt & Ansicht — Datenmodell & Aktivierung
|
||||||
|
|
||||||
|
DOSSIER speichert Schnitte als Zeichnungsebenen-Eintrag (`type:"schnitt"`) mit
|
||||||
|
`linePts/dirSign/depthBack/cutAtLine/heightMin/heightMax/projection`
|
||||||
|
(`schnitte.create_schnitt_entry`). Im Spike sind die Felder als `DrawingLevel`
|
||||||
|
(`kind:"section"|"elevation"`, `linePoints`, `directionSign`) angelegt — wir
|
||||||
|
ergänzen:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SectionLevel extends DrawingLevel { // kind: "section" | "elevation"
|
||||||
|
linePoints: [Vec2, Vec2];
|
||||||
|
directionSign: 1 | -1; // Blickrichtung (Pfeil im Plan)
|
||||||
|
depthBack: number; // Tiefe hinter der Schnittlinie (default 8)
|
||||||
|
cutAtLine: boolean; // true=Schnitt (cut+back), false=Ansicht (nur back)
|
||||||
|
heightMin: number; heightMax: number;
|
||||||
|
projection: "parallel" | "perspective";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aktivierung** (Port `schnitte.activate_schnitt`):
|
||||||
|
1. `view_dir` = senkrecht zur Linie in XY, Richtung = `directionSign`.
|
||||||
|
2. **3D-Vorschau:** `THREE.Plane`s setzen —
|
||||||
|
- Cut (nur `cutAtLine`): auf der Linie, Normale `+view_dir`.
|
||||||
|
- Back (immer): um `depthBack` in `+view_dir` versetzt, Normale `−view_dir`.
|
||||||
|
- via `renderer.localClippingEnabled = true`, `material.clippingPlanes`.
|
||||||
|
3. **Kamera:** `OrthographicCamera`, Position `mid − view_dir·dist`, Target `mid`,
|
||||||
|
Up `+Z`; Zoom auf BBox (`linePoints` + Höhenbereich + `depthBack`). Bei
|
||||||
|
`perspective`: `PerspectiveCamera` + FOV.
|
||||||
|
4. **Vektor-Ergebnis:** HLR (§4).
|
||||||
|
|
||||||
|
**2D-Plan-Symbol** (Schnittmarke im Grundriss, Port `make_schnitt_symbol`): Linie
|
||||||
|
+ Endpfeile in `view_dir`, Beschriftung. Bleibt im Grundriss sichtbar (liegt auf
|
||||||
|
einer eigenen Ebene, z.B. `18 Schnittlinien`). **Doppelklick** auf das Symbol
|
||||||
|
aktiviert den Schnitt (`onDoubleClick` auf das SVG-Symbol → `setActiveLevel(id)`,
|
||||||
|
≙ DOSSIER `_SchnittDoubleClickHandler`).
|
||||||
|
|
||||||
|
**Grip-Editing der Schnittlinie:** Endpunkte als Grips im Grundriss; Ziehen
|
||||||
|
aktualisiert `linePoints` + Symbol + (falls aktiv) Clipping — ohne Re-Zoom der
|
||||||
|
3D-View (DOSSIER `skip_view`-Flag-Äquivalent: Drag aktualisiert nur die Clip-
|
||||||
|
Ebenen, nicht die Kamera).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Massstab (Scale) — pro Viewport, Auto-DPI
|
||||||
|
|
||||||
|
### 3.1 Mathematik (Port `massstab._compute_scale`, identisch im Browser)
|
||||||
|
```
|
||||||
|
frustumWidth_world = ortho-Kamera-Breite in Modell-Einheiten (Meter)
|
||||||
|
frustumWidth_mm = frustumWidth_world * 1000 (Meter→mm)
|
||||||
|
screenWidth_mm = canvasWidthCssPx * 25.4 / dpi
|
||||||
|
N (1:N) = frustumWidth_mm / screenWidth_mm
|
||||||
|
```
|
||||||
|
- **Nur bei Orthografie** sinnvoll; in Perspektive zeigt die UI „—" (wie DOSSIER).
|
||||||
|
- **DPI:** Browser kennt das nativ — `dpi = 96 * window.devicePixelRatio` (CSS
|
||||||
|
definiert 1 px = 1/96 inch). Das ersetzt DOSSIERs CoreGraphics-JXA-Detection
|
||||||
|
komplett und ist exakter. Optional manuell kalibrierbar (Eingabe in den
|
||||||
|
Settings), persistiert pro Projekt.
|
||||||
|
- **Massstab setzen** (1:N → Zoom): `frustumWidth_world = screenWidth_mm · N /
|
||||||
|
1000`; bei `THREE.OrthographicCamera` `camera.zoom = canvasWidthCssPx /
|
||||||
|
(frustumWidth_world / metersPerPixelAtZoom1)` bzw. direkt `left/right` setzen.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plan/scale.ts
|
||||||
|
function computeScale(view: { frustumWidthWorld; canvasCssWidthPx; dpi }): number|null // 1:N
|
||||||
|
function applyScale(camera: THREE.OrthographicCamera, n: number, canvasCssWidthPx, dpi): void
|
||||||
|
const SCALE_PRESETS = [1,5,10,20,25,50,100,200,500,1000]; // 1:N Dropdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Massstabs-abhängige Skalierung (DOSSIER-Stärke)
|
||||||
|
Bei 1:N müssen **Strichstärken** und **Schraffuren** lesbar bleiben:
|
||||||
|
- **Plotweight → SVG stroke-width:** `strokeWidthPx = lwMm / 25.4 · dpi`
|
||||||
|
(Welt-unabhängig; die Linie ist im Plan immer z.B. 0.25 mm dick). DOSSIER
|
||||||
|
skaliert dafür die PlotWeights (`_apply_scaled_lineweights`); im SVG-Modell
|
||||||
|
rechnen wir die mm-Strichstärke direkt in Pixel — **viel einfacher**, da SVG
|
||||||
|
von Natur aus papierbezogen ist.
|
||||||
|
- **Schraffur-Skalierung:** DOSSIER nutzt `factor = sqrt(N)/10` (1:100 ⇒ 1.0,
|
||||||
|
1:50 ⇒ 0.71, 1:500 ⇒ 2.24; `apply_scaled_hatches`). Port: SVG `<pattern>`-
|
||||||
|
`patternTransform="scale(factor)"` bzw. `patternUnits` so wählen, dass das Muster
|
||||||
|
die gewünschte Paper-Dichte hat. Formel 1:1 übernehmen.
|
||||||
|
- **Linetype-Dash:** `stroke-dasharray` in mm→px, ebenfalls papierbezogen.
|
||||||
|
|
||||||
|
> **Kernvorteil gegenüber DOSSIER:** Weil der Plan **SVG/Paper-Space** ist,
|
||||||
|
> entfällt das fragile Welt↔Bildschirm-Plotweight-Rescaling (DOSSIER `write_plotweight`,
|
||||||
|
> `read_plotweight`, Print-Display-Toggle). Strichstärke und Maßlinien sind direkt
|
||||||
|
> in mm definiert und werden 1:1 gedruckt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Schnitt/Ansicht-Projektion (HLR) — Risiko #4
|
||||||
|
|
||||||
|
Vertikale Schnitte/Ansichten brauchen **echte 3D-Projektion mit verdeckten
|
||||||
|
Kanten** durch das zusammengebaute Gebäude.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plan/generateSection.ts (läuft im Web Worker via Comlink)
|
||||||
|
interface SectionRequest { meshes: SerializedBrep[]; cut: CutSpec; camera: CameraState; }
|
||||||
|
interface SectionResult {
|
||||||
|
cutLines: Primitive[]; // Schnittkanten (dick) — geschnittene Bauteile
|
||||||
|
cutFaces: Primitive[]; // Schnittflächen → Component-Schraffur (Poché)
|
||||||
|
visibleLines: Primitive[]; // sichtbare Projektion (dünn)
|
||||||
|
hiddenLines?: Primitive[]; // verdeckte (gestrichelt, optional)
|
||||||
|
}
|
||||||
|
function generateSection(req: SectionRequest): SectionResult
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Kernel:** **OpenCascade.js** `HLRBRep_Algo` / `HLRBRep_HLRToShape` (B-Rep →
|
||||||
|
sichtbare/verdeckte Kanten). Eingabe = die Bauteil-Breps (Wände/Decken/Treppen…),
|
||||||
|
Projektionsrichtung aus `camera`. Alternativ Mesh-basiert (langsamer, weniger
|
||||||
|
sauber).
|
||||||
|
- **Schnittflächen-Schraffur (Section-Style):** wo die Cut-Plane ein Bauteil
|
||||||
|
durchschneidet, entsteht eine Fläche → mit der Component-Schraffur füllen
|
||||||
|
(resources-graphics.md). ≙ DOSSIER `SectionStyle` (Hatch + Schnittkante +
|
||||||
|
Silhouette), nur dass wir es als SVG-Fill rendern statt als Rhino-Layer-Property.
|
||||||
|
- **Performance:** schwer → **Worker + Cache**. Cache-Key =
|
||||||
|
hash(sichtbare Element-IDs + Geometrie-Hash + CutSpec + camera). Nur neu rechnen,
|
||||||
|
wenn sich relevante Eingaben ändern (ROADMAP Risiko #4). Geschnittene vs. dahinter
|
||||||
|
liegende Geometrie über die Back-Plane begrenzen (`depthBack`).
|
||||||
|
- **Stufenweise:** (a) Ansicht ohne Verdeckung (einfache Projektion) → (b) HLR
|
||||||
|
sichtbar → (c) verdeckte Kanten gestrichelt → (d) Schnittflächen-Poché.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ausschnitte (View-Snapshots)
|
||||||
|
|
||||||
|
Navigation über 50+ Ansichten ohne Ordner-Wildwuchs (DOSSIER `ausschnitte.py`).
|
||||||
|
Ein Snapshot speichert **Kamera + Sichtbarkeit + Massstab + Darstellung + Overrides**.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// in Project: viewSnapshots: ViewSnapshot[]
|
||||||
|
interface ViewSnapshot {
|
||||||
|
id; name; folder?: string;
|
||||||
|
camera: CameraState; // pos/target/up/parallel/fov + frustumWidth (Zoom!)
|
||||||
|
scale: number; // 1:N (DOSSIER speichert "1:50"-String)
|
||||||
|
detailLevel: DetailLevel; // LoD-Override (DOSSIER darstellung)
|
||||||
|
visibility: VisibilityState; // pro Geschoss + pro Ebene visible/locked
|
||||||
|
layerCombinationId?: string; // ODER Verweis auf Layer-Kombi (live) — s.u.
|
||||||
|
overrides?: { presetId?: string; enabled: boolean };
|
||||||
|
}
|
||||||
|
interface CameraState { position; target; up; parallel; fov?; frustumWidth?; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Save:** aktuellen `ui`-Zustand einfrieren (Port `_capture`: Kamera inkl.
|
||||||
|
Frustum-Breite für exakten Zoom-Restore, Layer-Sichtbarkeit, Massstab, LoD).
|
||||||
|
- **Restore:** Snapshot → `ui` + ggf. `project`-Sichtbarkeit anwenden (Port
|
||||||
|
`_restore`): Kamera, Sichtbarkeit (oder referenzierte Layer-Kombi), LoD,
|
||||||
|
optional Overrides-Preset. Da alles im Store liegt, ist das ein einfacher
|
||||||
|
State-Set — kein Multi-Panel-Force-Send-Tanz wie in DOSSIER.
|
||||||
|
- **Ordner, Umbenennen, Duplizieren, Settings-Drawer** wie DOSSIER (`_duplicate`,
|
||||||
|
`_set_field`, `_open_settings_window` → React-Drawer statt Eto-Form).
|
||||||
|
|
||||||
|
### 5.1 Layer-Kombinationen (Presets)
|
||||||
|
```ts
|
||||||
|
interface LayerCombination { id; name; visibility: VisibilityState; }
|
||||||
|
```
|
||||||
|
Bauphasen/Varianten/MEP per Klick (DOSSIER `_save_preset`/`apply_layer_preset_by_name`).
|
||||||
|
Snapshot kann **live** auf eine Kombi verweisen (folgt Änderungen) **oder**
|
||||||
|
eingefroren den `visibility`-Stand halten — genau DOSSIERs Wahl (`layerCombination`
|
||||||
|
vs. `layers`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Kamera-Presets & Norden-Rotation ⭐
|
||||||
|
|
||||||
|
Port `kamera.py`. Schnelle Ansichtswechsel + Georeferenzierung (Swisstopo, Phase 4).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// viewport/camera.ts
|
||||||
|
function setCardinal(cam, dir: "N"|"E"|"S"|"W", northAngle: number): void
|
||||||
|
function setIso(cam, octant: "NE"|"SE"|"SW"|"NW"|..., northAngle: number): void
|
||||||
|
function setTop(cam, northAngle: number): void // Plan-Norden zeigt nach oben
|
||||||
|
// northAngle = Grad im Uhrzeigersinn von +Y (DOSSIER dossier_north_angle, default 0)
|
||||||
|
const north = (deg) => ({ x: Math.sin(rad(deg)), y: Math.cos(rad(deg)) });
|
||||||
|
interface CameraPreset { id; name; camera: CameraState; } // benutzerdefiniert, gespeichert
|
||||||
|
```
|
||||||
|
- **Norden-Rotation:** alle Kardinal-/Iso-Richtungen werden um `northAngle`
|
||||||
|
rotiert (Port `set_cardinal_view`, `_set_iso`, `set_top_view`). `northAngle`
|
||||||
|
liegt im `Project` (georeferenziert zu swissBUILDINGS).
|
||||||
|
- **Benutzer-Presets:** speichern/laden wie DOSSIER (`_load_presets`/`_save_presets`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bemaßung (Dimensions)
|
||||||
|
|
||||||
|
Port `dimensionen.py`. Maße werden **aus dem Modell abgeleitet** (Wand-Dicken,
|
||||||
|
Geschoss-Höhen, Öffnungen) + manuelle Maßketten.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Dimension {
|
||||||
|
id; floorId; categoryCode; // liegt auf einer Ebene
|
||||||
|
kind: "linear" | "chain" | "aligned" | "level"; // Einzel|Kette|ausgerichtet|Höhenkote
|
||||||
|
refs: DimRef[]; // Bezugspunkte (frei ODER an Element gebunden)
|
||||||
|
offset: number; // Abstand der Maßlinie vom Objekt
|
||||||
|
style: DimStyleId; // Pfeile, Texthöhe, Einheiten
|
||||||
|
}
|
||||||
|
type DimRef = { point: Vec2 } | { elementId: string; anchor: "start"|"end"|"jamb"|... };
|
||||||
|
```
|
||||||
|
- **Auto-Bemaßung** (Phase 3): Außenketten (Gebäude-Hülle), Achsketten (Achsraster),
|
||||||
|
Öffnungs-Ketten — aus der Geometrie generiert, dann editierbar.
|
||||||
|
- **9-Punkt-Objekt-Info** (DOSSIER ROADMAP §11): Bounding-Box-Maße lesen +
|
||||||
|
Element via Greifen verschieben/skalieren/rotieren — direkt im Plan.
|
||||||
|
- **Rich-Text-Indizes** (Bold/Hoch-/Tiefstellung) für Maßzahlen — als SVG
|
||||||
|
`<tspan>` mit `baseline-shift` (resources-graphics.md §Rich-Text).
|
||||||
|
- **Massstabsbezug:** Texthöhe/Pfeilgröße in **Paper-mm**, rendern × Massstab —
|
||||||
|
konsistent mit §3.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Plansätze (Sheets) & PDF-Export
|
||||||
|
|
||||||
|
DOSSIER nutzt Rhinos `RhinoPageView` + `Detail`-Viewports + `FilePdf`
|
||||||
|
(`layouts.py`). Browser-Äquivalent: eigenes Sheet-Modell + SVG → PDF.
|
||||||
|
|
||||||
|
### 8.1 Datenmodell
|
||||||
|
```ts
|
||||||
|
interface Sheet {
|
||||||
|
id; name; folder?;
|
||||||
|
paper: "A0"|"A1"|"A2"|"A3"|"A4"|"Letter"; landscape: boolean;
|
||||||
|
viewports: SheetViewport[];
|
||||||
|
titleBlock?: TitleBlock; // Titelblock (Projekt/Plan/Massstab/Datum)
|
||||||
|
}
|
||||||
|
interface SheetViewport { // ≙ DOSSIER Detail + gebundener Ausschnitt
|
||||||
|
id; rect: { x; y; w; h }; // Position auf dem Blatt (mm)
|
||||||
|
source: { kind: "level"; levelId } | { kind: "snapshot"; snapshotId };
|
||||||
|
scale: number; // 1:N
|
||||||
|
clipToRect: boolean;
|
||||||
|
}
|
||||||
|
const PAPER_MM = { A0:[841,1189], A1:[594,841], A2:[420,594], A3:[297,420],
|
||||||
|
A4:[210,297], Letter:[216,279] }; // Port PAPER_SIZES_MM
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Sheet-Editor
|
||||||
|
`sheets/SheetEditor.tsx`: Blatt als SVG in mm, Viewports per Drag platzieren/
|
||||||
|
skalieren, Quelle (Geschoss/Snapshot) + Massstab zuweisen. Ein Viewport rendert
|
||||||
|
den abgeleiteten Plan/Schnitt **bei seinem Massstab** in sein `rect` (≙ DOSSIER
|
||||||
|
`apply_snapshot_to_detail`). Bei Änderung der Quelle re-derivieren (live), kein
|
||||||
|
manuelles Re-Sync nötig (DOSSIER war Snapshot-Mode).
|
||||||
|
|
||||||
|
### 8.3 Detail↔Ausschnitt-Bindung
|
||||||
|
`SheetViewport.source.snapshotId` ist die Bindung (DOSSIER `_BIND_KEY`). „Alle
|
||||||
|
aktualisieren" = alle Viewports neu rendern; weil rein abgeleitet, ist das
|
||||||
|
automatisch. Umbenennen synchronisiert Titelblock + Schnitt-Symbol (DOSSIER
|
||||||
|
Detail↔Ausschnitt-Sync).
|
||||||
|
|
||||||
|
### 8.4 PDF-Export (Vektor, Multi-Page, @DPI)
|
||||||
|
Port `layouts._export_pdf`, aber **vektorbasiert** (DOSSIER rasterte via
|
||||||
|
`ViewCaptureToFile` @DPI — wir bleiben Vektor → schärfer, kleiner):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// sheets/exportPdf.ts
|
||||||
|
async function exportSheetsPdf(sheets: Sheet[], opts: { vector: boolean }): Promise<Blob>
|
||||||
|
```
|
||||||
|
- **Vektor-Pfad (bevorzugt):** jeder Sheet-Viewport rendert seinen Plan als SVG;
|
||||||
|
SVG → PDF via **`svg2pdf.js` + `jsPDF`** (oder `pdf-lib` mit eigenem Pfad-
|
||||||
|
Emit). Eine PDF-Seite pro Sheet, Größe = `PAPER_MM`. Strichstärken/Schraffuren
|
||||||
|
sind bereits in mm (§3.2) → 1:1 druckbar.
|
||||||
|
- **Raster-Fallback** (Perspektiven/3D-Inhalte): Three.js `renderer` → Canvas →
|
||||||
|
PNG @DPI → in PDF-Seite (`px = mm/25.4·dpi`, Port der DOSSIER-Pixelrechnung).
|
||||||
|
- **Speichern:** Blob → File System Access API (`showSaveFilePicker`) / Download.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Primitive & SVG-Serializer (gemeinsame Basis)
|
||||||
|
|
||||||
|
Alle Pläne (Grundriss, Schnitt, Ansicht, Sheet-Viewport) sprechen dieselbe
|
||||||
|
`Primitive`-Sprache (heute in `generatePlan.ts`), erweitert um Schraffur/Text:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Primitive =
|
||||||
|
| { kind:"polygon"; pts:Vec2[]; fill:string; stroke:string; strokeWidthMm:number; hatchId?:string }
|
||||||
|
| { kind:"line"; a:Vec2; b:Vec2; styleId:string } // styleId → LineStyle (mm, dash)
|
||||||
|
| { kind:"arc"; center:Vec2; from:Vec2; to:Vec2; r:number; styleId:string }
|
||||||
|
| { kind:"text"; at:Vec2; text:string; heightMm:number; align; font; rich?:RichRun[] }
|
||||||
|
| { kind:"symbol"; at:Vec2; symbolId:string; scale:number; angle:number }; // Symbol-Bibliothek
|
||||||
|
interface Plan { primitives: Primitive[]; bounds: Rect; }
|
||||||
|
```
|
||||||
|
- **SVG-Serializer** (`plan/primitives.ts`): Primitive → SVG-Elemente.
|
||||||
|
`strokeWidthMm` → px via `mm·dpi/25.4`; `hatchId` → `<pattern>`-Referenz;
|
||||||
|
`styleId` → `stroke`/`stroke-dasharray`. Derselbe Serializer für Bildschirm
|
||||||
|
*und* PDF.
|
||||||
|
- **DXF-Export** (Phase 4): dieselben Primitive → DXF-Entities (`dxf`-Writer-lib).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
|
||||||
|
|
||||||
|
1. **Phase 1 (MVP):** Grundriss-Generator ✅ ausbauen (Schraffuren, LoD), Live-
|
||||||
|
Grundriss neben 3D, Basis-Bemaßung; Massstab pro Viewport (§3).
|
||||||
|
2. **Phase 3 ⭐:** Schnitt/Ansicht via HLR (§4, Worker), Auto-Bemaßung (§7),
|
||||||
|
Ausschnitte + Layer-Kombinationen (§5), Kamera-Presets + Norden (§6),
|
||||||
|
Sheets + Vektor-PDF (§8).
|
||||||
|
3. **Phase 4:** DXF-Export (§9), Detail↔Ausschnitt-Sync-Politur.
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Design — Ressourcen & Grafik
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Bauteile: [elements.md](elements.md). Output/Pläne: [plans-output.md](plans-output.md).
|
||||||
|
|
||||||
|
Die **Stil-Schicht**: verwaltete Ressourcen-Bibliotheken (Vectorworks-Stil),
|
||||||
|
regelbasierte Overrides, Symbol-/Text-Bibliotheken und Detailgrad-Steuerung. Sie
|
||||||
|
wird **beim Rendern angewandt, nie in die Geometrie eingebacken** (ROADMAP §2b).
|
||||||
|
Dieses Dokument übersetzt DOSSIERs `styles.py`/`gestaltung.py`, `mass_style.py`,
|
||||||
|
`overrides.py`, `library.py`, `text_create.py`. Bezeichner englisch, Prosa
|
||||||
|
deutsch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resource Manager — verwaltete Bibliotheken
|
||||||
|
|
||||||
|
Drei Bibliotheken im `Project.resources`-Block; **alles verweist per id**, zentral
|
||||||
|
änderbar (ROADMAP §2d). Verweis-Kette: 2D-Objekte/Ebenen → LineStyle/Hatch;
|
||||||
|
Hatch → LineStyle; Component → Hatch (+3D-Material).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Resources {
|
||||||
|
lineStyles: LineStyle[];
|
||||||
|
hatches: Hatch[];
|
||||||
|
components: Component[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineStyle { // Line Manager
|
||||||
|
id; name;
|
||||||
|
weight: number; // Strichstärke in mm (≙ Rhino PlotWeight)
|
||||||
|
color: string; // hex
|
||||||
|
dash: number[]; // Strichmuster in mm ([] = durchgezogen)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Hatch { // Hatch Manager
|
||||||
|
id; name;
|
||||||
|
pattern: PatternId; // "solid" | "diagonal" | "insulation" | "concrete" | ...
|
||||||
|
scale: number; // Grundmaßstab des Musters
|
||||||
|
angle: number; // Grad
|
||||||
|
lineStyleId: string; // Linien der Schraffur → Line Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Component { // Component Manager (= DOSSIER-Material, erweitert)
|
||||||
|
id; name;
|
||||||
|
hatchId: string; // Schnitt-Schraffur → Hatch Manager
|
||||||
|
color3d: string; // 3D-Diffusfarbe
|
||||||
|
texture3d?: TextureRef; // optionale PBR-Textur (Phase 3)
|
||||||
|
pbr?: { roughness; metalness; opacity; ior }; // Material-Bibliothek (ROADMAP §11)
|
||||||
|
joinPriority: number; // Verschneidungs-Rang (DOSSIER _MATERIAL_PRIO als Daten)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration vom heutigen Stand:** Der Spike hat `Material { color, planFill, hatch }`
|
||||||
|
und `Layer { materialId, thickness, priority }`. Ziel: `Material → Component`
|
||||||
|
(`color→color3d`, `planFill→` Fill aus `hatch.pattern==solid`+Farbe, `hatch`-Enum
|
||||||
|
→ `hatchId`), `Layer.priority → Component.joinPriority` (Priorität wandert vom
|
||||||
|
Layer zum Component, damit man sie nur einmal pflegt — siehe elements.md §1.3).
|
||||||
|
|
||||||
|
### 1.1 Manager-UI
|
||||||
|
`managers/ComponentManager.tsx`, `HatchManager.tsx`, `LineManager.tsx` — je eine
|
||||||
|
Liste mit CRUD + Vorschau (Three-Sphere für Component-3D, SVG-Swatch für
|
||||||
|
Hatch/Line). **Seeds** beim ersten Projekt (DOSSIER-Defaults):
|
||||||
|
- LineStyles: 0.13 / 0.18 / 0.25 / 0.35 / 0.50 mm (aus `DEFAULT_LAYER_SCHEMA`-lw).
|
||||||
|
- Hatches: `solid`, `diagonal`, `concrete`, `insulation` (Dämmung).
|
||||||
|
- Components: Stahlbeton (prio 800), Beton (800), Mauerwerk (600), Ziegel (550),
|
||||||
|
Holzständer (400), Dämmung (200), Putz (100) — exakt DOSSIER `_MATERIAL_PRIO`
|
||||||
|
(elements.md §1.3). Plus Glas (transparent), Holz-Türblatt (DOSSIER
|
||||||
|
`_OEFF_PIECE_DEFS`).
|
||||||
|
|
||||||
|
### 1.2 Render-Anwendung
|
||||||
|
- **3D:** Component → `MeshStandardMaterial` (`color3d`/`pbr`), pro `componentId`
|
||||||
|
gecacht (`viewport/scene.ts`).
|
||||||
|
- **Plan/Schnitt:** geschnittene Schicht → Polygon mit `fill` (Component-Farbe) +
|
||||||
|
`<pattern>` aus `hatchId`. Pattern als SVG `<pattern>` mit `patternTransform`
|
||||||
|
für Massstab (plans-output.md §3.2). Der Hatch nutzt seinen `lineStyleId` für
|
||||||
|
die Musterlinien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mehrschichtige Aufbauten ↔ Ressourcen
|
||||||
|
`WallType.layers[].componentId` / `SlabType.layers[].componentId` verweisen auf
|
||||||
|
Components. 3D und Plan lesen dieselben Schichten (elements.md §1). Die
|
||||||
|
**Prioritäts-Verschneidung** (Risiko #1) liest `Component.joinPriority`:
|
||||||
|
höhere Priorität läuft am Stoß durch (Backbone), niedrigere stößt seitlich an —
|
||||||
|
Algorithmus in elements.md §1.3 (Port DOSSIER `_t_junction_layer_overrides`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stile & Element-Override (Selektions-Attribute)
|
||||||
|
|
||||||
|
DOSSIERs GESTALTUNG-Panel (`styles.py`) setzt Farbe/Lineweight/Linetype/Hatch auf
|
||||||
|
die *Selektion*. Browser-Äquivalent — zwei Ebenen, in Render-Reihenfolge:
|
||||||
|
|
||||||
|
```
|
||||||
|
ByLayer (Ebenen-Default) → Element-Style (styleId) → Override-Regeln → gerendert
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Effektiver Stil eines Elements (resolve beim Rendern, nie persistiert)
|
||||||
|
interface EffectiveStyle { color; lineStyleId; hatchId?; }
|
||||||
|
function resolveStyle(project, el, doc): EffectiveStyle {
|
||||||
|
// 1) Default aus LayerCategory(categoryCode)
|
||||||
|
// 2) überschrieben durch el.styleId (Element-Override, optional)
|
||||||
|
// 3) überschrieben durch passende Override-Regeln (§4)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Wall-/Opening-Stil-Kataloge** (Presets, ROADMAP §11): benannte Sätze von
|
||||||
|
Default-Werten (Wandtyp + Farbe + lw; Öffnung mit Rahmen/Sims/…). 1-Klick-
|
||||||
|
Anwendung, globaler Stilwechsel. Speicherung: pro Projekt + cross-Projekt
|
||||||
|
(LocalStorage), Seed wie DOSSIER `_OEFF_DEFAULT_STYLES` (elements.md §2.1).
|
||||||
|
- **LoD-bewusste Stil-UI** (DOSSIER ROADMAP §11): das Stil-Panel zeigt nur
|
||||||
|
passende Controls je Geometrietyp (keine Füll-Optionen bei einer 3D-/Linien-
|
||||||
|
Auswahl). `panels/StylePanel.tsx` schaltet Felder nach `selection`-Typ.
|
||||||
|
- **Pipette:** Stil/Typ von einem Element auf ein anderes übernehmen (DOSSIER
|
||||||
|
`cmd/pipette`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Regelbasierte Overrides (Engine)
|
||||||
|
|
||||||
|
Port `overrides.py` (ArchiCAD Graphical Overrides / Vectorworks
|
||||||
|
Datenvisualisierung). Im Browser **viel einfacher**, weil Overrides reine
|
||||||
|
**Render-Transformationen** sind — kein UserString-Backup/Restore nötig (DOSSIER
|
||||||
|
musste Originalwerte sichern, weil es echte Rhino-Objekte mutierte; wir mutieren
|
||||||
|
nichts).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OverrideConfig { enabled: boolean; rules: OverrideRule[]; activePresetId?: string; }
|
||||||
|
interface OverrideRule {
|
||||||
|
id; name; enabled: boolean;
|
||||||
|
conditions: Condition[]; conditionsLogic: "and" | "or";
|
||||||
|
actions: { color?: string; lineWeight?: number; lineStyleId?: string;
|
||||||
|
hatchId?: string; hatchScale?: number };
|
||||||
|
}
|
||||||
|
interface Condition {
|
||||||
|
type: "category" | "userField" | "name" | "elementType"; // ≙ layer_name/user_string/object_name
|
||||||
|
operator: "equals"|"notEquals"|"contains"|"startsWith"|"endsWith";
|
||||||
|
value: string;
|
||||||
|
key?: string; // nur für userField (z.B. "sia")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auswertung** (Port `_compose_overrides`):
|
||||||
|
```ts
|
||||||
|
function composeOverrides(el, project, cfg): Partial<Actions> {
|
||||||
|
// additive: Actions aller matchenden, aktiven Regeln kombinieren;
|
||||||
|
// bei Konflikt für dieselbe Property gewinnt die Regel WEITER OBEN (kleinerer Index).
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Anwendung:** `resolveStyle` (§3) ruft `composeOverrides` — Override liegt
|
||||||
|
über Element-Style. Reines Read beim Rendern → kein `apply_all`/`restore_all`,
|
||||||
|
kein Backup, **keine reversibilität nötig**. Toggle `enabled` rendert neu.
|
||||||
|
- **Live:** Da abgeleitet, schlägt jede Modell-/Regel-Änderung sofort durch (kein
|
||||||
|
`install_listeners`/`AddRhinoObject`-Hook wie DOSSIER).
|
||||||
|
- **Presets & Templates** (cross-Projekt): Preset = Satz Regeln, Template =
|
||||||
|
einzelne Regel; LocalStorage statt `~/Library/.../override_presets.json`
|
||||||
|
(`save_preset`/`load_preset`/`list_rule_templates` → `resources/overridePresets.ts`).
|
||||||
|
- **SIA-416-Preset** (elements.md §7): vier Regeln `userField sia == HNF|NNF|VF|FF`
|
||||||
|
→ Farbe + Solid-Hatch, Port `_build_sia_preset_rules`. Aktivieren = Preset
|
||||||
|
`activePresetId` setzen.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// resources/overrides.ts
|
||||||
|
function composeOverrides(el, project, cfg): Partial<OverrideAction>
|
||||||
|
function setActivePreset(project, presetId): Project // immutabel
|
||||||
|
const PRESETS_NS = "cad.presets.overrides"; // LocalStorage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Symbol-Bibliothek
|
||||||
|
|
||||||
|
Port `library.py` + `cmd/symbol`. Wiederverwendbare 2D-Symbole (Möbel, Sanitär,
|
||||||
|
Bäume, Nordpfeil, Pflanzen) für den Plan.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Symbol {
|
||||||
|
id; name; category: string; // "furniture" | "sanitary" | "vegetation" | "annotation"
|
||||||
|
svgPath: string; // Pfad-/Gruppen-Markup im Symbol-Koordinatensystem (m)
|
||||||
|
defaultScale: number;
|
||||||
|
}
|
||||||
|
interface SymbolInstance extends ElementBase { // type:"draw2d", subtype:"symbol"
|
||||||
|
symbolId: string; at: Vec2; scale: number; angle: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Speicherung:** mitgelieferte Symbole als statische Assets (SVG); Nutzer-
|
||||||
|
Symbole im Projekt + cross-Projekt (LocalStorage). DOSSIER nutzt Block-
|
||||||
|
Definitionen; bei uns SVG-Definition + Instanz-Transform (`<use>`-artig).
|
||||||
|
- **Picker:** `panels/SymbolPicker.tsx` (≙ DOSSIER `SymbolPicker.jsx`) — Grid mit
|
||||||
|
Vorschau, Drag in den Plan; Instanz auf Ebene `60 Plangrafik` (bzw. `22 Möbel`).
|
||||||
|
- **Render:** `Primitive{ kind:"symbol", ... }` → SVG `<g transform>` mit dem
|
||||||
|
Symbol-Markup (plans-output.md §9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Text & Rich-Text-Annotationen
|
||||||
|
|
||||||
|
Port `text_create.py` + `text_editor.py`. Formatierte Beschriftungen auf Canvas.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TextElement extends ElementBase { // type:"draw2d", subtype:"text"
|
||||||
|
at: Vec2; runs: RichRun[];
|
||||||
|
heightMm: number; // Paper-mm (rendern × Massstab) ODER Modell-m
|
||||||
|
heightMode: "paper" | "model"; // DOSSIER raum_txt_modus fix|masstab
|
||||||
|
font: string; align: "left"|"mid"|"right"; angle: number;
|
||||||
|
mask?: boolean; // Hintergrund-Maskierung (verdeckt Linien darunter)
|
||||||
|
frame?: boolean; // Rahmen um den Text
|
||||||
|
}
|
||||||
|
interface RichRun {
|
||||||
|
text: string;
|
||||||
|
bold?; italic?; super?; sub?; // Hoch-/Tiefstellung (Maß-Indizes)
|
||||||
|
}
|
||||||
|
interface TextStyle { id; name; font; heightMm; bold; italic; } // Text-Presets
|
||||||
|
```
|
||||||
|
- **Render:** `<text>` mit `<tspan>` pro Run; `super/sub` via `baseline-shift` +
|
||||||
|
kleinerer `font-size`; `mask` via weißem `<rect>` darunter; `frame` via `<rect>`.
|
||||||
|
- **Editor:** Inline-Rich-Text-Editor (contentEditable oder leichter Custom-Editor)
|
||||||
|
→ `RichRun[]`. Fonts aus einer kuratierten Web-Font-Liste + System-Fonts
|
||||||
|
(DOSSIER `_list_system_fonts` mit Preferred-Liste DM Mono/Krungthep/…); im
|
||||||
|
Browser via `document.fonts` / `queryLocalFonts()` (wo verfügbar) + gebündelte
|
||||||
|
Web-Fonts.
|
||||||
|
- **Massstabsbezug:** `heightMode:"paper"` → Texthöhe in mm, gerendert × Massstab
|
||||||
|
(plans-output.md §3) — Beschriftung bleibt bei jedem Massstab lesbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Detailgrad (Level of Detail)
|
||||||
|
|
||||||
|
Querschnittsthema (ROADMAP §2b „Modelldarstellungen"). Drei Stufen, Dokument-/
|
||||||
|
Snapshot-weiter Override mit Per-Element-Ausnahme — DOSSIER
|
||||||
|
`darstellung`/`aktive_darstellung`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DetailLevel = "coarse" | "medium" | "fine"; // einfach | standard | detail
|
||||||
|
// Auflösung (Port _resolve_oeff_darstellung):
|
||||||
|
function resolveDetail(el, doc): DetailLevel {
|
||||||
|
const v = el.detailLevel ?? "auto";
|
||||||
|
return v === "auto" ? doc.detailLevel : v; // doc-Level hat IMMER konkreten Wert
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Dokument-Ebene:** `Project.detailLevel` (Default `coarse`/einfach = 1:100,
|
||||||
|
DOSSIER `_DARSTELLUNG_DEFAULT_GLOBAL`). In der TopBar global umschaltbar; ein
|
||||||
|
ViewSnapshot kann ihn pro Ansicht überschreiben (plans-output.md §5).
|
||||||
|
- **Wirkung:** jedes Bauteil-`generatePlan`/`build3d` liest den aufgelösten LoD und
|
||||||
|
zeichnet entsprechend (elements.md: Tür coarse=Lücke, fine=Glas/Schwenkbogen/
|
||||||
|
Sims). Kritisch für Mixed-Scale-Pläne (1:50 Detail neben 1:200 Übersicht).
|
||||||
|
- **Schnitt-Schraffur** koppelt an LoD: grob ggf. nur Umriss, fein voll schraffiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Section-Style (3D-Schnittflächen)
|
||||||
|
Port DOSSIER `_apply_section_style` (`layer_builder.py`). Wo die Schnittebene ein
|
||||||
|
Bauteil durchschneidet: Schnittfläche bekommt die **Component-Schraffur**, die
|
||||||
|
Schnittkante einen dicken Rand, optional eine Silhouette.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SectionStyle { // pro Component (oder Ebene) ableitbar
|
||||||
|
hatchId?: string; hatchScale; hatchAngle;
|
||||||
|
boundaryShow: boolean; boundaryLineStyleId; boundaryWidthScale;
|
||||||
|
fillBackground: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **3D-Viewport:** Three.js hat keinen nativen „Schnittflächen-Cap". Cap-Geometrie
|
||||||
|
selbst erzeugen: Schnittpolygon der Cut-Plane mit den Breps → Fläche mit
|
||||||
|
Hatch-Material (oder Stencil-Cap-Technik). Phase 4.
|
||||||
|
- **2D-Schnitt (SVG):** `generateSection` (plans-output.md §4) liefert `cutFaces`
|
||||||
|
→ mit `SectionStyle.hatchId` füllen. Das ist der Hauptweg; der 3D-Cap ist Bonus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Was Browser hier einfacher macht (vs. DOSSIER)
|
||||||
|
|
||||||
|
| DOSSIER-Aufwand | entfällt im Browser, weil … |
|
||||||
|
|---|---|
|
||||||
|
| UserString-Backup/Restore bei Overrides (`_backup_original`/`_restore_original`) | Overrides sind reine Render-Reads — nichts wird mutiert |
|
||||||
|
| Hatch-Curve-Link über Sticky (`gestaltung_curve_hatch`, Pending-TTL) | Schraffur ist eine Eigenschaft des Polygons, kein separates Objekt |
|
||||||
|
| Plotweight-Welt↔Bildschirm-Rescaling (`write/read_plotweight`) | Strichstärke ist in mm im SVG-Paper-Space (plans-output.md §3.2) |
|
||||||
|
| `install_listeners` für Live-Override-Reapply | reaktiver Store re-rendert automatisch |
|
||||||
|
| SectionStyle-API-Reflection über Rhino-Versionen | wir definieren das Rendering selbst (SVG/Three) |
|
||||||
|
| Cross-doc Presets als Dateien im User-Home | LocalStorage + Export/Import |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Umsetzungs-Reihenfolge (verweist auf ROADMAP-Phasen)
|
||||||
|
|
||||||
|
1. **Phase 1:** Component/Hatch/Line-Manager (§1) + `resolveStyle` (§3) +
|
||||||
|
LoD-Grundgerüst (§7) + LoD-bewusste Stil-UI.
|
||||||
|
2. **Phase 2:** Stil-Kataloge (Wände/Öffnungen), Material-Seeds mit `joinPriority`.
|
||||||
|
3. **Phase 3:** Overrides-Engine + SIA-Preset (§4), Symbol-Bibliothek (§5),
|
||||||
|
Rich-Text (§6), PBR-Material-Bibliothek; massstabsabhängige Hatch-/Linetype-
|
||||||
|
Skalierung (plans-output.md §3.2).
|
||||||
|
4. **Phase 4:** Section-Style 3D-Cap (§8).
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Handoff — Rhino-artiges Befehlssystem + Modellier-Werkzeuge
|
||||||
|
|
||||||
|
> Für die Instanz, die das Befehlssystem (Tab-getriggert) und die Rhino-artigen
|
||||||
|
> Modellierfunktionen baut. Stand: 2026-06-30. Zuerst lesen: `CONVENTIONS.md`,
|
||||||
|
> `ROADMAP.md`, `HANDOVER.md`, `docs/design/drawing-tools.md`. Volle Autonomie,
|
||||||
|
> selbst bestätigen (Memory `proceed-autonomously`, `wire-dont-stub`, `prefer-agents`).
|
||||||
|
|
||||||
|
Dieses Dokument hat drei Teile:
|
||||||
|
1. **Was schon steht** (worauf du aufbaust — exakte Dateien/Typen/Actions).
|
||||||
|
2. **Rhino-Referenz** (Interaktionsmodell, das nachzubilden ist).
|
||||||
|
3. **Konkreter Bauplan für DIESE Codebase** (Architektur, Dateien, Reihenfolge).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — die Kernidee
|
||||||
|
|
||||||
|
Es gibt **kein** Befehlssystem, keine Command-Line, keinen Tab-Handler. Das baust du
|
||||||
|
greenfield. **Aber:** Das Werkzeug-System ist bereits eine saubere Pure-Function-Registry
|
||||||
|
(`src/tools/`) mit generischem Controller in `App.tsx`, und die Mutations-Schicht
|
||||||
|
(`projectSlice`) ist umfassend. Ein neues Modellier-Tool steckst du durch Hinzufügen eines
|
||||||
|
`Tool`-Objekts ein — **PlanView muss dafür nicht angefasst werden**.
|
||||||
|
|
||||||
|
Das Befehlssystem ist im Kern eine **State-Machine-Engine über prompt → pick/type →
|
||||||
|
options**, plus eine **Command-Line-UI** (Statusleiste), plus ein **Koordinaten-Parser**.
|
||||||
|
Befehle dispatchen auf bestehende Store-Actions + `setActiveTool`/`setProject`.
|
||||||
|
|
||||||
|
**Wichtigster konzeptioneller Sprung:** Die heutigen Tools haben je eine *eigene*
|
||||||
|
ad-hoc-Phasenlogik (`onClick`/`onMove`). Rhino-Feel verlangt eine **gemeinsame
|
||||||
|
Prompt/Option/Numerik-Engine**, die alle Befehle teilen. Plane das als Verallgemeinerung
|
||||||
|
des bestehenden `Tool`-Interfaces, nicht als Parallelwelt daneben (sonst zwei Eingabe-Pfade,
|
||||||
|
die divergieren).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEIL 1 — Was schon steht (Baufundament)
|
||||||
|
|
||||||
|
Stack: React 18 + TS + Vite, `three` 0.169 (nur 3D-Display). Einheiten intern **Meter**.
|
||||||
|
Eigener winziger Store (`useSyncExternalStore`, kein Redux/Zustand). Identifier englisch,
|
||||||
|
UI-Text deutsch via `t()`. Strict tsc (`noUnusedLocals` → ungenutzte Vars brechen den Build).
|
||||||
|
|
||||||
|
## 1.1 Werkzeug-System — `src/tools/`
|
||||||
|
- **`Tool`-Interface** `src/tools/types.ts:139` — reine Funktionen über internen `ToolState`
|
||||||
|
(Discriminated Union je Tool). Handler geben `[nextState, ToolResult]` zurück. Tools
|
||||||
|
schreiben NIE Plan-Primitive; sie geben `commit(project) => project` zurück.
|
||||||
|
- `ToolId` `types.ts:11`: `"select" | "wall" | "line" | "polyline" | "rect"`.
|
||||||
|
- `ToolContext` `types.ts:72`: `{ project, level, defaultCategoryCode, activeWallTypeId, activeLineStyleId }`.
|
||||||
|
- `ToolPointer` `types.ts:85`: `{ raw, snap, point (=snap?.point ?? raw), shift, ctrl, alt, button }`.
|
||||||
|
- `ToolResult` `types.ts:121`: `{ draft, commit?, done? }`. `ToolDraft` `types.ts:109`:
|
||||||
|
`{ preview: DraftShape[], vertices: Vec2[], snap?, hud?: {at,text} }`.
|
||||||
|
- **Registry** `src/tools/tools.ts:375`: `TOOLS: Record<ToolId,Tool>`, `getTool(id)` `:384`,
|
||||||
|
`TOOL_ORDER` `:389`. Implementiert: select (Platzhalter), wall, line, polyline, rect.
|
||||||
|
- `uniqueId(prefix)` `types.ts:188` — ID-Generator.
|
||||||
|
|
||||||
|
## 1.2 Controller / Verdrahtung — alles in `App.tsx` (NICHT in den Tool-Dateien)
|
||||||
|
- Aktives Tool: `const [activeTool,setActiveTool]=useState<ToolId>("select")` `App.tsx:197`.
|
||||||
|
- Laufender Zustand in **Ref** `toolStateRef` `App.tsx:205` (kein Re-Render je Mausschritt).
|
||||||
|
Live-Vorschau `const [draft,setDraft]` `App.tsx:206`.
|
||||||
|
- **Controller** `runToolStep(kind,raw,pxPerMeter,mods)` `App.tsx:350`: baut `ToolContext`
|
||||||
|
(`toolCtx` `:294`), snappt via `snapFor` `:322` (→ `computeSnap`), baut `ToolPointer`,
|
||||||
|
ruft `tool.onClick/onMove`, speichert State in Ref, `applyToolResult` `:339` wendet
|
||||||
|
draft/commit/done an. `toolHandlers` `App.tsx:419` verbindet PlanView↔Controller.
|
||||||
|
- Tool-Tasten `App.tsx:448`: Esc=Abbruch/zurück-zu-select, Enter=Commit-Geste, Backspace=Punkt zurück.
|
||||||
|
|
||||||
|
## 1.3 Semantisches Modell — `src/model/types.ts`
|
||||||
|
- `type Element = Wall | Door | Drawing2D` `:251`. `Vec2={x,y}`. **Kein Slab/Stair-Typ.**
|
||||||
|
- `Project` `:254`: `{ ..., wallTypes[], drawingLevels[], layers[], walls[], doors[], drawings2d[] }`.
|
||||||
|
- `Wall` `:150`: `{ id,type:"wall", floorId, categoryCode, start, end, wallTypeId, height, color? }`
|
||||||
|
(Mittellinie + mehrschichtiger `WallType`).
|
||||||
|
- **Plan-Primitive `Drawing2DGeom`** `:200` — die Geom-Typen existieren bereits ALLE:
|
||||||
|
`line | polyline | rect | circle | arc | text`. Aber Tools erzeugen heute nur line/polyline/rect,
|
||||||
|
und `drawingVertices` (Grips) kennt nur diese drei. **circle/arc/text sind im Typ da, aber
|
||||||
|
nicht durchgängig gerendert/editierbar** — Lücke, kein Neubau nötig.
|
||||||
|
- `Drawing2D` `:209`: `{ id,type:"drawing2d", levelId, categoryCode, geom, lineStyleId?, hatchId?, color?, fillColor?, weightMm? }`.
|
||||||
|
|
||||||
|
## 1.4 Geometrie — `src/model/geometry.ts`
|
||||||
|
`sub,add,scale,len,normalize`; `leftNormal(a)={x:-a.y,y:a.x}` `:17` (Wand-Normale-Konvention);
|
||||||
|
`cross`, `lineIntersect(a,da,b,db)`, `along`, `wallBand`, `wallCorners` `:70`, `clippedBand` `:87`.
|
||||||
|
`src/model/joins.ts`: `computeJoins(project,walls)` `:44` (nur L-Ecken gehrt; T/X eckig).
|
||||||
|
**Für Offset/Trim/Fillet (Rhino-Kern) gibt es NOCH KEIN 2D-Geometrie-Kernel** — Kurven/Kurven-
|
||||||
|
Schnitt, Polylinien-Offset usw. musst du ergänzen (siehe Bauplan §3.4).
|
||||||
|
|
||||||
|
## 1.5 Store — `src/state/`
|
||||||
|
- `createStore` `store.ts:51` über `useSyncExternalStore`. **Actions leben IM State**
|
||||||
|
(`useStore(s=>s.action)`, referenzstabil). `RootState = Project & Selection & View & Layout`
|
||||||
|
`appStore.ts:21`. Exports `useStore`, `getState`, `setState`.
|
||||||
|
- **projectSlice**: `project` + `setProject(next|(p)=>p)`. Mutationen u.a. `addFloor`,
|
||||||
|
`addCategory`, `setElementColor/Weight/Fill`, `resizeElement`, `moveGripOf`, `moveElementByOf`,
|
||||||
|
`moveEdgeOf`, `commitTransformOn`. **Es gibt keine generische „addWall/addDrawing2d"-Action** —
|
||||||
|
Tools committen via `setProject`. (Beim Befehlssystem ggf. saubere Actions ergänzen.)
|
||||||
|
- **Aktive Zeichenebene + aktive Kategorie liegen im viewSlice**, NICHT in selection:
|
||||||
|
`activeLevelId` `viewSlice.ts:46`/`setActiveLevelId`, `activeCategoryCode` `:42`/`setActiveCategoryCode`.
|
||||||
|
- selectionSlice: `selectedWallIds[]`, `selectedDrawingId` + Setter/`clearSelection`.
|
||||||
|
|
||||||
|
## 1.6 Views, Eingabe, Koordinaten — `src/plan/PlanView.tsx` (SVG-Vektor)
|
||||||
|
- Modell → `Plan`-Primitive via `generatePlan` `src/plan/generatePlan.ts:203`. `Primitive` =
|
||||||
|
`polygon|line|arc` (polygons tragen `wallId`/`drawingId` für Hit-Test).
|
||||||
|
- **Transform (entscheidend):** `PX_PER_M=90` `:20`; `toScreen(p)={x:p.x*90,y:-p.y*90}` `:31`
|
||||||
|
(fixer Welt-Ursprung 0,0; Y flippt). Invers `viewToModel` `:440`. SVG `viewBox`=State `view`;
|
||||||
|
Pan/Zoom ändern nur `view`, nie das Modell↔Screen-Mapping.
|
||||||
|
- **`rawModelAt(clientX,clientY)`** `:446` = aktuelle Mauswelt-Position in Meter (der Eine-Aufruf,
|
||||||
|
den ein Tool/Befehl braucht). `currentPxPerMeter()` `:488`.
|
||||||
|
- **Pointer-Events** alle am `<svg>` `:910`: down `:553`, move `:625`, up `:715`, wheel `:820`,
|
||||||
|
dblclick `:847`, contextmenu `:857`. Schema: Mitte=Pan, Links=Select/Marquee/Tool, Rechts=Menü.
|
||||||
|
Bei `toolActive` `:268` routen Links-Events zu `toolHandlers`. **PlanView meldet bereits
|
||||||
|
`(rawModelAt, currentPxPerMeter, toolMods)` nach oben** — neue Tools brauchen hier NICHTS.
|
||||||
|
- **Snapping** `src/tools/snapping.ts`: `computeSnap(input)` `:88` — endpoint/midpoint/intersection/
|
||||||
|
onEdge/grid/ortho mit Prioritätstabelle. Wird in App (`snapFor`) konsumiert, nicht in PlanView.
|
||||||
|
`applyAngleConstraint` für Ortho. `SnapSettings`/`DEFAULT_SNAP` in `tools/types.ts:36/55`.
|
||||||
|
- 3D `src/viewport/Viewport3D.tsx` (three.js, Raycaster): nur Anzeige+Auswahl, **keine
|
||||||
|
Zeichenwerkzeuge**. 3D-Authoring = eigene spätere Phase (Raycast auf Arbeitsebene).
|
||||||
|
|
||||||
|
## 1.7 Tastatur / globale Eingabe — **kein Dispatch-System**
|
||||||
|
- `main.tsx:38` globaler `contextmenu`→preventDefault; `:42` blockt Ctrl/Cmd+A außerhalb Inputs;
|
||||||
|
`isTextEntry(el)` `:24`.
|
||||||
|
- App-useEffects mit `window.addEventListener("keydown")`: Tool-Tasten `:448`, Delete `:604`,
|
||||||
|
Transform-Shortcuts m/s/d + u/i/o/p `:637`. **Jeder Guard wiederholt inline den
|
||||||
|
INPUT/TEXTAREA/contentEditable-Check** — es gibt keine geteilte Keymap. Dein Tab-Handler +
|
||||||
|
Command-Input kommt als neuer globaler `keydown` dazu (siehe §3.2).
|
||||||
|
|
||||||
|
## 1.8 UI-Shell + i18n
|
||||||
|
- `App.tsx` (~2200 Z., enthält noch ToolController/Grips/Transform). JSX `:1043`: TopBar → body
|
||||||
|
(Dock links, Content-View-Router, TransformBar, Dock rechts, Floating) → StatusBar →
|
||||||
|
ResourceManager → ContextMenu → InlineEditor. Panel-Daten via `PanelHostContext` (`baseHost`
|
||||||
|
`App.tsx:729`, Typ `host.ts`).
|
||||||
|
- `StatusBar.tsx` — Footer: links `hint` (Tool-Hinweis), rechts X/Y, Einheit, Massstab 1:N, Zoom,
|
||||||
|
aktives Geschoss, aktive Ebene. **Bester Ort für die Command-Line** (Rhino hat sie klassisch unten).
|
||||||
|
- **i18n** `src/i18n/`: `t(key,params?)` `index.ts:67`, `useT()` `:84`. Flaches `as const`-Dict,
|
||||||
|
Punkt-Namespaces (`tool.*`,`snap.*`,`transform.*`,`status.*`…). `de.ts` (Quelle, ~309 Keys) +
|
||||||
|
`en.ts`; `TranslationKey=keyof typeof de` erzwingt Parität. **Neue Keys IMMER in beide Dateien.**
|
||||||
|
Keine hartcodierten JSX-Strings.
|
||||||
|
|
||||||
|
## 1.9 Verifizieren
|
||||||
|
- `npx tsc -b` · `npm run build` · Dev `npm run dev` (Vite 5173, `host:true`).
|
||||||
|
- Screenshot `node scripts/probe.mjs` → `scripts/probe.png` (Puppeteer headless, `deviceScaleFactor:2`,
|
||||||
|
URL via `PROBE_URL`). Viele task-Probes existieren (`probe-tools.mjs`, `probe-line.mjs`,
|
||||||
|
`probe-transform.mjs` …) — gute Vorlagen, um Tools/Befehle programmatisch zu treiben.
|
||||||
|
**Screenshot ansehen + Geometrie prüfen**, nicht nur „kompiliert".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEIL 2 — Rhino-Referenz (das Interaktionsmodell)
|
||||||
|
|
||||||
|
## 2.1 Die Command-Line ist das Rückgrat
|
||||||
|
**Alles ist ein Befehl**, und die Command-Line **hört immer zu**: Tastenanschläge gehen an die
|
||||||
|
Command-Line, wenn sie nicht von einem Feld konsumiert werden. Kein „Tool aktiv vs. Eingabe aktiv".
|
||||||
|
Die Zeile hat gleichzeitig drei Rollen: **Eingabe** (Befehl/Wert tippen), **Prompt**
|
||||||
|
(„Start of line", „Next point"), **Optionen** (eckige, klickbare Inline-Optionen).
|
||||||
|
|
||||||
|
## 2.2 Befehl aufrufen
|
||||||
|
- Namen tippen, z. B. `Line`. **Präfix-Autocomplete** (case-insensitiv): `L`→`Li`→`Lin` zeigt
|
||||||
|
Kandidatenliste mit Best-Match. **Tab/Pfeile** akzeptieren Vorschlag, **Enter/Leertaste** führt aus.
|
||||||
|
- **Aliase**: nutzerdefinierte Kürzel → Makro (z. B. `L`→`!_Line`, `cp`→`!_Copy`). Werden VOR
|
||||||
|
Autocomplete gematcht. (Minimal: Einzelbuchstabe→Befehl.)
|
||||||
|
|
||||||
|
## 2.3 Enter / Leertaste / Rechtsklick (leicht falsch gemacht)
|
||||||
|
- **Enter = Leertaste** in der Command-Line. Beide: Befehl ausführen / Default akzeptieren /
|
||||||
|
mehrteiligen Befehl **beenden** / bei **leerer** Zeile **letzten Befehl wiederholen**.
|
||||||
|
- **Rechtsklick im Viewport = Enter.** Also: Rechtsklick beendet Polyline UND wiederholt bei
|
||||||
|
leerer Zeile den letzten Befehl. → `lastCommand` speichern, bei Leer-Enter/Rechtsklick neu starten.
|
||||||
|
|
||||||
|
## 2.4 Inline-Optionen (klickbare Klammern)
|
||||||
|
```
|
||||||
|
Start of line ( BothSides=No Chamfer Mode=Distance ):
|
||||||
|
```
|
||||||
|
- Jede Option **klickbar UND tippbar** (genug Buchstaben zur Eindeutigkeit + Enter).
|
||||||
|
- **Toggle** `Name=Value` flippt beim Klick. **Value**-Option fragt Unterwert ab. **Action**-Option
|
||||||
|
(ohne `=`) verzweigt sofort.
|
||||||
|
- Optionen sind **innerhalb des Befehls persistent**, viele **über Aufrufe hinweg** (letzte
|
||||||
|
Offset-Distanz, Array-Anzahl, Fillet-Radius merken). **Zuletzt benutzte Optionswerte je Befehl
|
||||||
|
persistieren** — Nutzer erwarten das.
|
||||||
|
|
||||||
|
## 2.5 Sub-Prompts = State-Machine
|
||||||
|
Befehle laufen Prompts ab. `Line`: „Start of line:" → Punkt → „End of line:" → Punkt → fertig.
|
||||||
|
`Polyline`: „Start" → „Next point ( Close Undo ):" → … → **Enter** beendet. Prompt-Text ist
|
||||||
|
sichtbar und lehrreich („Next point. Press Enter when done") — literal nachbilden.
|
||||||
|
|
||||||
|
## 2.6 Transparente/verschachtelbare Befehle
|
||||||
|
Manche Befehle (Zoom/Pan, Osnap-Toggle, alles mit `'`-Präfix) laufen **innerhalb** eines anderen,
|
||||||
|
ohne ihn abzubrechen, und kehren zum Original-Prompt zurück. → Command-Runner braucht einen **Stack**.
|
||||||
|
|
||||||
|
## 2.7 Koordinaten- & Numerik-Eingabe (Herz der Präzision)
|
||||||
|
| Eingabe | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `5,3` / `5,3,2` | absolut X,Y(,Z) |
|
||||||
|
| `r5,3` | **relativ** zum letzten Punkt (das `r`-Idiom) |
|
||||||
|
| `<45` | Winkel-Constraint auf 45°, dann Maus/Distanz |
|
||||||
|
| `5<45` | **polar**: Distanz 5 unter 45° vom letzten Punkt |
|
||||||
|
| Zahl tippen während Drag | **Distanz-Lock**: Richtung per Maus, Länge per Zahl+Enter (meistgenutzte Geste) |
|
||||||
|
| Zahl + **Tab** | Lock umschalten (Länge fix → Winkel folgt Maus, oder umgekehrt) |
|
||||||
|
Das Feld parst **kontextabhängig**: Befehlsname / Optionsbuchstabe / Koordinate / nackte Zahl —
|
||||||
|
je nach Befehlszustand. Das Live-Tool muss **einen primären Skalar** (Länge/Radius/Distanz)
|
||||||
|
exponieren, an den eine getippte Zahl bindet.
|
||||||
|
|
||||||
|
## 2.8 Osnaps + Ortho + Gumball
|
||||||
|
- **Osnaps** (persistente Toggles): End, Mid, Cen, Int, Perp, Near, Quad, Tan, Point. Pro Mausschritt
|
||||||
|
gegen nahe Geometrie geprüft (Pixel-Toleranz), Marker+Label am Cursor; liefert **exakte
|
||||||
|
Modellkoordinate** (nie Roh-Maus, wenn Snap aktiv). One-Shot-Osnap überschreibt für den nächsten Pick.
|
||||||
|
- **Ortho** (F8): Winkelraster (90°/konfigurierbar), **Shift** togglet temporär. **Grid Snap** (F9).
|
||||||
|
**SmartTrack**: temporäre Hilfslinien aus zuletzt gehoverten Punkten.
|
||||||
|
- **Gumball**: On-Object-Widget (Pfeile=Move, Bögen=Rotate, Handles=Scale); Handle klicken →
|
||||||
|
Zahl tippen für exakten Transform. Direkt-Manipulations-Gegenstück zu getippten Befehlen.
|
||||||
|
|
||||||
|
> Präzisionsmodell = **(Snap ODER getippte Koordinate) × (Ortho/Winkel-Constraint) ×
|
||||||
|
> (Distanz-Constraint)**, in EINEM Pick komponierbar.
|
||||||
|
|
||||||
|
## 2.9 Auswahl-Modell (links/rechts-Regel exakt)
|
||||||
|
- Klick = wählen; Shift+Klick add; Ctrl+Klick remove.
|
||||||
|
- **Links→rechts = Window** (nur voll umschlossene; **durchgezogenes** Rechteck).
|
||||||
|
- **Rechts→links = Crossing** (auch berührte; **gestricheltes** Rechteck). Richtung bestimmt
|
||||||
|
Modus — starke Konvention, exakt nachbilden.
|
||||||
|
- **SelLast** (zuletzt erzeugte/gewählte erneut wählen) ist enorm nützlich („erzeugen, dann sofort
|
||||||
|
bewegen"). Min. `SelLast`, `SelAll`, `SelNone`, `Invert`.
|
||||||
|
|
||||||
|
## 2.10 Befehls-Prompt-Sequenzen (Kurz)
|
||||||
|
2D: **Line** (2 Pkt) · **Polyline** (Close/Undo, Enter beendet) · **Rectangle** (Ecke+Ecke, oder
|
||||||
|
Breite/Höhe tippen; 3Point/Center) · **Circle** (Center+Radius; 2P/3P/Tan) · **Arc** (Center-Start-End /
|
||||||
|
3Point) · **Offset** (Kurve wählen → Seite klicken/Distanz tippen; Distanz persistent) ·
|
||||||
|
**Fillet/Chamfer** (Kurve1→Kurve2, Radius/Distances persistent) · **Trim** (Schneider wählen→Enter→
|
||||||
|
wegzuschneidendes Stück klicken) · **Split** · **Extend** · **Join** · **Explode** ·
|
||||||
|
**Move/Copy/Rotate/Scale/Mirror** (Auswahl→Basispunkt→Ziel; Copy-Option) · **ArrayRect/ArrayPolar** ·
|
||||||
|
**Group/Ungroup**.
|
||||||
|
3D (braucht CSG, später): **ExtrudeCrv** (geschlossene Kurve→Solid, Cap) · **Box** · **Boolean
|
||||||
|
Union/Difference/Intersection** · **Cap** · **Gumball-Face-Drag = PushPull** · Loft/Sweep/Revolve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# TEIL 3 — Bauplan für DIESE Codebase
|
||||||
|
|
||||||
|
> Ziel: nutzbarer 2D-Architektur-Drafter mit Rhino-Feel, dann einfaches Massing. Halte das
|
||||||
|
> ROADMAP-Prinzip: **ein semantisches Modell → Sichten abgeleitet**; Extrusionshöhe ist eine
|
||||||
|
> Eigenschaft, nie eingebackene Geometrie.
|
||||||
|
|
||||||
|
## 3.0 Kuratierungs-Prinzip (WICHTIG — Nutzer-Vorgabe)
|
||||||
|
**NICHT den ganzen Rhino-Katalog stumpf portieren.** Wir bauen ein **Wohnbau-BIM**, keinen
|
||||||
|
NURBS-Allzweck-Modeller. Nimm nur, was dem Wohnbau-Workflow dient; lass den Rest weg, bis er
|
||||||
|
konkret gebraucht wird. Faustregel: *Brauche ich das, um ein Einfamilienhaus zu zeichnen und
|
||||||
|
daraus Pläne zu ziehen?* Wenn nein → weglassen.
|
||||||
|
|
||||||
|
**Bewusst WEGLASSEN (vorerst):** Loft / Sweep1+2 / Revolve (Sonderformen, kaum Wohnbau) ·
|
||||||
|
freie NURBS-Kurven (`Curve`/`InterpCrv` Grad>1, Deformable, FromFoci) · Ellipse · Tangent/
|
||||||
|
Bisector/4Point-Linienvarianten · SmartTrack (nett, nicht kritisch) · der volle `Sel*`-Zoo
|
||||||
|
(nur SelLast/SelAll/SelNone/Invert) · Knot/Vertex/Tan-Osnaps. Alle leicht später additiv
|
||||||
|
nachrüstbar — kein Grund, sie jetzt mitzuschleppen.
|
||||||
|
|
||||||
|
**Booleans sind KEIN „nice to have später"** — sie werden gebraucht, **sobald Tür/Fenster als
|
||||||
|
echte 3D-Öffnung** kommen (heute schneidet `Door` nur eine Plan-Lücke, kein 3D-Boolean, siehe
|
||||||
|
HANDOVER). Darum: CSG/Booleans an die **Tür/Fenster-Phase koppeln** und dann reinnehmen — nicht
|
||||||
|
ans Ende schieben. ABER (das ist der „nicht stumpf"-Teil):
|
||||||
|
> Für **rechteckige** Öffnungen in extrudierten Wänden braucht es **keinen allgemeinen
|
||||||
|
> Boolean-Kernel**. Eine analytische **Wand-minus-Box-Subtraktion** (Öffnung als parametrische
|
||||||
|
> Aussparung im Wand-Solid) ist einfacher, robuster und für 95 % Wohnbau ausreichend. Den
|
||||||
|
> allgemeinen CSG-Boolean (`rhino3dm`) erst ziehen, wenn schräge/runde/verschnittene Fälle
|
||||||
|
> wirklich auftreten. Also: **Öffnungen zuerst analytisch, allgemeine Booleans erst bei Bedarf.**
|
||||||
|
|
||||||
|
## 3.1 Leitentscheidung: Engine verallgemeinern, nicht parallel bauen
|
||||||
|
Baue eine gemeinsame **Command-Engine**, die das bestehende `Tool`-Interface erweitert/ablöst,
|
||||||
|
sodass es **einen** Eingabepfad gibt (Maus + Tastatur + Command-Line speisen dieselbe Maschine).
|
||||||
|
Konkret: ein `Command`-Modell, das je Schritt einen **Prompt** (Text), erwartete **Eingabearten**
|
||||||
|
(Punkt | Zahl | Option | Auswahl) und **Optionen** beschreibt. Die heutigen Tools werden zu
|
||||||
|
Befehlen dieser Engine (wall/line/polyline/rect lassen sich 1:1 portieren — ihre Phasenlogik ist
|
||||||
|
schon eine Mini-State-Machine).
|
||||||
|
|
||||||
|
**Warum nicht das alte Tool-Interface unangetastet lassen und Command-Line nur draufsetzen?**
|
||||||
|
Weil die Command-Line getippte Koordinaten/Optionen in denselben Schritt einspeisen muss, in dem
|
||||||
|
die Maus pickt. Zwei getrennte Pfade divergieren garantiert (Snapping, Constraints, HUD doppelt).
|
||||||
|
|
||||||
|
## 3.2 Neue Dateien (Vorschlag)
|
||||||
|
- `src/commands/engine.ts` — Command-Runner: aktiver Befehl, Prompt-Stack (für transparente
|
||||||
|
Befehle §2.6), `lastCommand`-Wiederholung, Routing von Maus-Pick / getippter Eingabe / Option-Klick
|
||||||
|
in den aktuellen Schritt. Hält `CommandState`.
|
||||||
|
- `src/commands/types.ts` — `Command`-Interface (Verallgemeinerung von `Tool`): Schritte mit
|
||||||
|
`prompt: TranslationKey`, `accepts: ("point"|"number"|"option"|"selection")[]`, `options: CmdOption[]`,
|
||||||
|
`onInput(state,input,ctx): [state, CommandResult]`. `CommandResult` wie `ToolResult` (+`commit`).
|
||||||
|
- `src/commands/parseInput.ts` — Koordinaten-Parser (§2.7): `5,3` · `r5,3` · `5<45` · `<45` ·
|
||||||
|
nackte Zahl (Distanz-Lock) · Optionsbuchstabe. Liefert eine Discriminated Union, die die Engine
|
||||||
|
in einen Modellpunkt/Constraint auflöst (mit `lastPoint` für `r`/polar).
|
||||||
|
- `src/commands/registry.ts` — `COMMANDS: Record<string,Command>` + Aliase + Autocomplete (Präfix).
|
||||||
|
- `src/ui/CommandLine.tsx` — die Command-Line-UI **in/über der Statusleiste** (`StatusBar.tsx`):
|
||||||
|
zeigt Prompt + klickbare Optionen + Texteingabe; Autocomplete-Dropdown. Tab fokussiert sie.
|
||||||
|
- (später) `src/geometry/kernel2d.ts` — 2D-Kernel für Offset/Trim/Fillet/Schnitt (§3.4).
|
||||||
|
- (viel später) `src/geometry/solid3d.ts` o. `rhino3dm`-Anbindung für Massing/Booleans (§3.5).
|
||||||
|
|
||||||
|
## 3.3 Verdrahtung (minimal-invasiv)
|
||||||
|
- **Globaler Tab-Handler**: neuer `window.keydown` in App (gleicher Guard wie `App.tsx:448` —
|
||||||
|
INPUT/TEXTAREA/contentEditable überspringen). Tab → Command-Line fokussieren/öffnen. Jeder
|
||||||
|
getippte Buchstabe ohne aktives Tool startet den Befehlsmodus (Rhino „hört immer zu" — optional
|
||||||
|
in Phase 2; Phase 1 reicht Tab).
|
||||||
|
- **Command-Line → Engine → Store**: Befehle dispatchen auf `setActiveTool` (für tool-artige) bzw.
|
||||||
|
direkt auf Store-Actions / `setProject`. Nutze `getState()/setState()` (referenzstabil) aus
|
||||||
|
`appStore.ts`.
|
||||||
|
- **Pick-Eingabe**: die Engine konsumiert dieselben `(rawModelAt, currentPxPerMeter, toolMods)`,
|
||||||
|
die PlanView schon hochmeldet (`ToolHandlers`). `computeSnap` für Punktfang wiederverwenden.
|
||||||
|
→ PlanView braucht im Idealfall **keine Änderung** (höchstens: Window/Crossing-Marquee-Visual
|
||||||
|
durchgezogen vs. gestrichelt nach Drag-Richtung, §2.9 — heute evtl. nur ein Modus).
|
||||||
|
- **Prompt/HUD**: Prompt-Text in die Statusleiste (`StatusBar` `hint` existiert schon). Distanz/
|
||||||
|
Winkel-HUD am Cursor existiert in `ToolDraft.hud`.
|
||||||
|
|
||||||
|
## 3.4 Reihenfolge (Tiers — strikt 2D zuerst)
|
||||||
|
**Tier 0 — Substrat (VOR jedem Befehl; das ist der „Feel"):**
|
||||||
|
1. Command-Runner + Command-Line-UI (Prompt → pick/type → Optionen; Enter/Space/Rechtsklick =
|
||||||
|
bestätigen/beenden/wiederholen; `lastCommand`).
|
||||||
|
2. Koordinaten-Parser (`x,y` · `rdx,dy` · `dist<angle` · nackte-Zahl-Lock).
|
||||||
|
3. Osnaps (End/Mid/Cen/Int/Perp/Near) — `computeSnap` ist da, ggf. Cen/Perp/Near ergänzen.
|
||||||
|
4. Ortho (90°/45°, Shift-Toggle) + Grid-Snap — teils vorhanden (`applyAngleConstraint`).
|
||||||
|
5. Auswahl: Klick, Shift/Ctrl add/remove, **Window vs. Crossing** (durchgezogen/gestrichelt,
|
||||||
|
links/rechts-Regel).
|
||||||
|
|
||||||
|
**Tier 1 — 2D-Pflicht (reines SVG/2D), grobe Baufolge:**
|
||||||
|
6. **Line** (validiert die ganze pick/snap/constrain-Schleife) → 7. **Polyline** (Close/Undo) →
|
||||||
|
8. **Rectangle** (Ecke + Center/3Point) → 9. **Circle** (Center+Radius). Diese vier portieren die
|
||||||
|
heutigen Tools auf die Engine + numerische Eingabe.
|
||||||
|
10. **Move** → 11. **Copy** (wiederholend) → 12. **Offset** (persistente Distanz — DAS Architektur-
|
||||||
|
Primitiv) → 13. **Trim** + **Split** → 14. **Join** + **Explode**. **Undo/Redo** durchgängig
|
||||||
|
annehmen (heute? — prüfen; ggf. Command-History/Undo-Stack im Store ergänzen).
|
||||||
|
|
||||||
|
**Tier 2 — 2D stark nützlich:** Rotate/Scale/Mirror (Copy-Option) · Fillet/Chamfer · Arc ·
|
||||||
|
Extend · ArrayRect/ArrayPolar · Group/Ungroup · Gumball(2D) · Sel*-Helfer (min. SelLast).
|
||||||
|
|
||||||
|
**Tier 3 — Massing + Öffnungen (an Tür/Fenster-Phase gekoppelt):**
|
||||||
|
- **Öffnungen zuerst analytisch:** Tür/Fenster als parametrische Aussparung im Wand-Solid
|
||||||
|
(Wand-Extrude minus Öffnungs-Box) — KEIN allgemeiner Boolean-Kernel nötig (§3.0). Das ist der
|
||||||
|
kritische, roadmap-markierte 🔴-Teil (echte 3D-Öffnung statt nur Plan-Lücke) und kommt MIT
|
||||||
|
Tür/Fenster, nicht danach.
|
||||||
|
- **Massing-Befehle:** ExtrudeCrv (geschlossene Plan-Kurve → gecapptes Solid) → Box →
|
||||||
|
Gumball-Face-Drag-PushPull.
|
||||||
|
- **Allgemeine Booleans** (Union/Difference/Intersection) **erst bei Bedarf** (schräge/runde/
|
||||||
|
verschnittene Fälle): **kein eigener Kernel — `rhino3dm` (WASM-openNURBS)** als `src/io/`-Schicht
|
||||||
|
(Roadmap-Entscheid, HANDOVER). Bis dahin reicht die analytische Subtraktion.
|
||||||
|
- **Weggelassen:** Loft/Sweep/Revolve/OffsetSrf (§3.0 — Sonderformen, kaum Wohnbau).
|
||||||
|
|
||||||
|
## 3.5 Was sauber 2D ist vs. was hart ist
|
||||||
|
- **Sauber SVG/2D:** Line, Polyline, Rect, Circle, Arc, Move/Copy/Rotate/Scale/Mirror, Array, Group,
|
||||||
|
Control-Point-Edit, Gumball(2D). Affine Transforms + Kurven-Schnitt.
|
||||||
|
- **Echte Arbeit (2D-Kernel nötig):** **Offset, Trim, Fillet** brauchen kompetenten Kurven-Schnitt
|
||||||
|
und Polylinien-Offset — dafür Zeit einplanen (`src/geometry/kernel2d.ts`).
|
||||||
|
- **Braucht 3D/CSG:** Extrude, Box, Boolean*, Cap, OffsetSrf, Loft/Sweep/Revolve, Face-Drag. Booleans
|
||||||
|
sind das Korrektheits-Zentrum → `rhino3dm`.
|
||||||
|
|
||||||
|
## 3.6 Gotchas (aus Rhino-Verhalten + dieser Codebase)
|
||||||
|
- **Command-Line hört immer zu** — Tasten global routen, aber die `isTextEntry`-Disziplin
|
||||||
|
(`main.tsx:24`) + die Native-App-Regeln (kein Ctrl+A/keine Textauswahl, CONVENTIONS.md) wahren.
|
||||||
|
- **Zuletzt benutzte Optionswerte je Befehl persistieren** (Offset-Distanz, Array-Anzahl, Fillet-Radius).
|
||||||
|
- **Enter = Rechtsklick = Wiederholen/Bestätigen/Mehrteiliges-Beenden** — alle drei auf EIN Signal.
|
||||||
|
- **Distanz-Lock:** Live-Befehl muss EINEN primären Skalar exponieren, an den eine getippte Zahl bindet.
|
||||||
|
- **Window vs. Crossing** über Drag-Richtung + durchgezogen/gestrichelt — nicht global ein Modus.
|
||||||
|
- **Osnap liefert exakte Modellkoordinate** — nie Roh-Maus, wenn Snap aktiv (`ToolPointer.point`).
|
||||||
|
- **Strict tsc** (`noUnusedLocals`) — ungenutzte Vars/Parameter brechen `npm run build`.
|
||||||
|
- **i18n**: jeder sichtbare String über `t('key')`, Keys in `de.ts` UND `en.ts` (Parität erzwungen).
|
||||||
|
- **Keine generische addWall/addDrawing2d-Action** — entweder via `setProject` committen (wie heute)
|
||||||
|
oder beim Refactor saubere Actions im `projectSlice` ergänzen (besser für Undo/Redo).
|
||||||
|
- **App.tsx ist bereits ~2200 Z.** (God-Component-Kritik in HANDOVER). Lege Command-Engine in
|
||||||
|
`src/commands/`, halte App-Verdrahtung dünn (nur Tab-Handler + CommandLine-Mount + Dispatch-Brücke).
|
||||||
|
|
||||||
|
## 3.7 Verifikations-Drehbuch
|
||||||
|
Pro Tier eine Probe (Vorlage: `scripts/probe-tools.mjs`/`probe-transform.mjs`): Befehl per Command-
|
||||||
|
Line tippen → Punkte/Werte tippen → Screenshot → Geometrie visuell prüfen. Tier 0 zuerst headless
|
||||||
|
treiben (Tab → „line" → „0,0" Enter → „r3,0" Enter → Linie im PNG sichtbar). `npx tsc -b` +
|
||||||
|
`npm run build` grün halten. **Screenshot ansehen**, nicht nur Kompilat vertrauen (Memory `wire-dont-stub`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anhang — Minimaler erster Meilenstein (konkret)
|
||||||
|
1. `src/commands/types.ts` + `engine.ts` + `parseInput.ts` (Tier 0.1/0.2).
|
||||||
|
2. `src/ui/CommandLine.tsx`, in `StatusBar` gemountet; Tab-Handler in App.
|
||||||
|
3. `Line` als erster Command (portiert `lineTool`), inkl. getippter `0,0` / `r3,0` / `3<45`.
|
||||||
|
4. Probe `scripts/probe-command-line.mjs`: Tab→line→zwei getippte Koordinaten→PNG prüfen.
|
||||||
|
5. Dann Polyline/Rect/Circle, danach Move/Copy/Offset.
|
||||||
|
|
||||||
|
Damit steht der Rhino-Feel-Kern, und jeder weitere Befehl ist additiv (neues `Command`-Objekt in
|
||||||
|
`registry.ts`, keine PlanView-/App-Änderung).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Ribbon-UI + modulare Bars — Plan
|
||||||
|
|
||||||
|
Nutzer-Vision (2026-07-05, mit OCS/AutoCAD-Ribbon als Referenz). Ziel: klare,
|
||||||
|
einheitliche Werkzeug-/Eigenschaften-Darstellung; Werkzeug-Sidebar entfällt.
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
- **Ribbon-Oberleiste mit Tabs: 2D · 3D · BIM · Ansichten.** Jeder Tab zeigt
|
||||||
|
gruppierte Werkzeug-/Aktions-Icons (wie OCS Draw/Model/Insert/Annotate/View).
|
||||||
|
- **2D**: Zeichnen (Select/Linie/Polylinie/Rechteck/Kreis/Bogen/Text) · Ändern
|
||||||
|
(Move/Copy/Mirror/Offset/Trim/Join/Boolean).
|
||||||
|
- **3D**: Volumen/Boolean (vorhandene 3D-Aktionen), Kamera-Presets.
|
||||||
|
- **BIM**: Wand/Fenster/Tür/Treppe/Decke/Raum (die Bauteile).
|
||||||
|
- **Ansichten**: Geschoss/Schnitt/Ansicht-Wechsel, Zoom/Einpassen, Render-Modi.
|
||||||
|
- **Werkzeug-Sidebar entfällt** → rechtes Panel (Attribute) bekommt volle Höhe.
|
||||||
|
- **Objektinfo unter die Attribute** mergen (Wandstil-Definition etc. zusammen).
|
||||||
|
- **XYZ-Referenzpunkt-Box oben rechts bleibt** (vom Nutzer ausdrücklich gewünscht).
|
||||||
|
- **Eigenschaften-Grid im OCS-Stil** — ✅ bereits erledigt (`00733d8`): Sektion-
|
||||||
|
Balken, Zeilentrenner, füllende linksbündige Wertfelder (`.attr-*`).
|
||||||
|
|
||||||
|
## Architektur — DATENGETRIEBEN (ermöglicht modulare Custom-Bar)
|
||||||
|
|
||||||
|
Kern: EINE Registry beschreibt alle Ribbon-Elemente; Ribbon-Tabs UND eine
|
||||||
|
benutzerdefinierte Custom-Bar sind bloß zwei Ansichten über dieselbe Registry.
|
||||||
|
|
||||||
|
```
|
||||||
|
RibbonItem =
|
||||||
|
| { kind: "tool"; id: ToolId } // aktiviert ein Werkzeug (onSelectTool)
|
||||||
|
| { kind: "command"; name: string } // startet einen Engine-Befehl (engine.start)
|
||||||
|
| { kind: "action"; id: string; run: () => void } // freie App-Aktion (Zoom, Render-Modus …)
|
||||||
|
|
||||||
|
RibbonGroup = { titleKey: string; items: RibbonItem[] }
|
||||||
|
RibbonTab = { id: "2d"|"3d"|"bim"|"views"; labelKey: string; groups: RibbonGroup[] }
|
||||||
|
RIBBON: RibbonTab[]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Icons: `ToolIcon` (vorhanden) für `tool`-Items; für `command`/`action` eine
|
||||||
|
kleine Icon-Map (SVG) analog `ToolsPanel`.
|
||||||
|
- Aktivierung: `tool` → `onSelectTool(id)` · `command` → `engine.start(name)` ·
|
||||||
|
`action` → `run()`. Aktiv-Highlight über `activeTool` bzw. laufenden Befehl.
|
||||||
|
- **Custom-Bar (modular)**: der Nutzer wählt beliebige `RibbonItem`s in eine
|
||||||
|
persistierte Liste (`viewSlice`/Projekt); eine `CustomBar`-Komponente rendert
|
||||||
|
genau diese. Gleiche Item-Typen, gleiche Aktivierung — kein Sonderweg.
|
||||||
|
|
||||||
|
## Phasen
|
||||||
|
|
||||||
|
1. ✅ **Gerüst + 2D/BIM-Tab** (`9d6e86d`). `RibbonBar` unter der TopBar (additiv).
|
||||||
|
Datengetriebene Registry (`src/ui/ribbon/ribbonItems.ts`), 2D-Tab (Zeichnen +
|
||||||
|
Ändern) und BIM-Tab (Bauteile) gefüllt, Tab-State lokal, Modify-Icons ergänzt.
|
||||||
|
2. ✅ **TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll
|
||||||
|
mergen"). Die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid +
|
||||||
|
Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart)
|
||||||
|
sind als `ViewRibbonTab` (in `TopBar.tsx`) in den „Ansichten"-Tab gewandert;
|
||||||
|
App reicht sie als `viewsContent`-Node an `RibbonBar` (wie das Layout-Menü).
|
||||||
|
Die **Tab-Reiter sitzen in der TopBar-Zeile** (`RibbonTabs`, in TopBar
|
||||||
|
gerendert; Tab-State in App), der Ribbon-Inhalt (`RibbonBar`) folgt darunter →
|
||||||
|
nur ZWEI Bänder. Die TopBar ist eine **schmale Leiste** (Höhe 40px): Marke/
|
||||||
|
Ressourcen · Tabs · Datei/Export/Einstellungen · Fensterknöpfe. Die Text-/
|
||||||
|
Font-Formatierung (`TextGroup`) liegt im eigenen **„Text"-Tab** (Reihenfolge
|
||||||
|
2D·3D·BIM·Text·Ansichten; `RibbonBar.tabContent` trägt „text" + „views").
|
||||||
|
- **Offen (visuell iterieren):** ob Zoom/Massstab zusätzlich als Dauer-Anzeige
|
||||||
|
(Statusleiste) sichtbar sein soll; Default-Tab (aktuell 2D); 3D-Tab noch
|
||||||
|
leer (Kamera-Presets/3D-Aktionen füllen).
|
||||||
|
3. **Sidebar raus** (ToolsPanel aus Default-Layout) + rechtes Panel volle Höhe;
|
||||||
|
Objektinfo unter Attribute mergen.
|
||||||
|
4. **Modulare Custom-Bar**: Item-Picker + persistierte Custom-Bar-Ansicht.
|
||||||
|
|
||||||
|
## Hinweise / Randbedingungen
|
||||||
|
|
||||||
|
- Additiv bauen: bestehende Kommandozeile + Nummern-Shortcuts + `ToolsPanel`
|
||||||
|
bleiben funktionsfähig, bis Phase 3 die Sidebar bewusst entfernt.
|
||||||
|
- Werkzeug-Kopplung Tool↔Befehl existiert bereits (`TOOL_COMMAND`) — Ribbon nutzt
|
||||||
|
denselben `onSelectTool`/`engine.start`-Pfad (EIN Pfad, keine Duplikate).
|
||||||
|
- Empfehlung: Phase 1+ in fokussierter Session mit visueller Iteration im Tauri.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Zustands-Architektur & Code-Aufteilung (App.tsx entschlacken)
|
||||||
|
|
||||||
|
> Ziel: `App.tsx` von „God-Component" zu dünnem Shell. Globaler Zustand in einen
|
||||||
|
> Store, Features in eigene Module → **wartbar + parallel bearbeitbar**.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
`App.tsx` hält aktuell: Projekt-State, Auswahl, View-State (viewType/detailLevel/
|
||||||
|
renderMode/referenceLines/scale/activeLevel), Layout, ALLE Mutations-Handler
|
||||||
|
(floors/layers/components/hatches/lineStyles/walls/doors), Kontextmenü-Builder,
|
||||||
|
Inline-Editoren, Layout-Menü, View-Routing. → Risiko + Flaschenhals (jedes Feature
|
||||||
|
fasst App.tsx an → kein paralleles Arbeiten).
|
||||||
|
|
||||||
|
## Zielstruktur
|
||||||
|
```
|
||||||
|
src/state/
|
||||||
|
store.ts // Store (Zustand) — kombiniert die Slices, ein useStore-Hook
|
||||||
|
projectSlice.ts // project + alle Mutationen (floors, layers, components,
|
||||||
|
// hatches, lineStyles, walls, doors) inkl. recompute/refs-aware delete
|
||||||
|
selectionSlice.ts // selectedWallIds (+ marquee-Ergebnis)
|
||||||
|
viewSlice.ts // viewType, detailLevel, renderMode, referenceLines, activeLevelId, scale
|
||||||
|
layoutSlice.ts // wraps src/panels/layout.ts (docks + floating)
|
||||||
|
src/views/ // LevelPlanView, PerspectiveView, SectionStub, DrawingView (+ ViewRouter)
|
||||||
|
src/editors/ // FloorSettingsEditor, LayerSettingsEditor, … (heute inline in App)
|
||||||
|
src/menus/ // layerContextMenu(), levelContextMenu(), planContextMenu() — bauen ContextMenu-Items
|
||||||
|
src/ui/ // TopBar, StatusBar, ResourceManager, ContextMenu (bestehen)
|
||||||
|
src/panels/ // Docks/Panels (bestehen)
|
||||||
|
App.tsx // DÜNN: Store-Provider · TopBar · (Docks + ViewRouter) · StatusBar
|
||||||
|
// · Floating-Panels · Ressourcen-Overlay
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store-Wahl: Zustand (empfohlen)
|
||||||
|
- Winzige Lib, kein Boilerplate, **Slices** gut teilbar, Selektoren verhindern
|
||||||
|
Re-Render-Sturm, kein Prop-Drilling. Passt zu „verschiedene Features = verschiedene
|
||||||
|
Slice-Dateien" → Parallelität.
|
||||||
|
- Alternative ohne Dependency: Context + useReducer oder `useSyncExternalStore`
|
||||||
|
(wie i18n). Mehr Boilerplate; bei der State-Menge ist Zustand ergonomischer.
|
||||||
|
- Komponenten: `const walls = useStore(s => s.walls)` / `useStore(s => s.addFloor)`.
|
||||||
|
PanelHostContext entfällt (Panels lesen direkt aus dem Store).
|
||||||
|
|
||||||
|
## Vorgehen (reiner Refactor — Verhalten MUSS identisch bleiben)
|
||||||
|
1. Store + Slices anlegen, Projekt-State + Mutationen aus App.tsx hierher ziehen
|
||||||
|
(1:1, gleiche Logik inkl. recomputeFloorElevations, refs-aware delete).
|
||||||
|
2. View-/Selection-/Layout-State in ihre Slices.
|
||||||
|
3. Inline-Editoren, Kontextmenü-Builder, View-Routing in `src/editors/`,`src/menus/`,`src/views/` extrahieren; sie lesen den Store.
|
||||||
|
4. App.tsx auf den Shell reduzieren.
|
||||||
|
5. Verifizieren: tsc + build + Screenshots — **pixel-/funktionsgleich** zu vorher
|
||||||
|
(Auswahl, Massstab, Kontextmenü, Panels, 3D/Plan, i18n). Reiner Umbau, kein
|
||||||
|
Feature-Wechsel.
|
||||||
|
|
||||||
|
## Auszahlung
|
||||||
|
- Danach editiert ein Wand-Feature `projectSlice`/`views`, ein Panel-Feature `panels`,
|
||||||
|
ein Editor `editors` — **disjunkte Dateien → mehrere Code-Workflows parallel** möglich.
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Architektur-Pivot: Tauri + Rust-Backend (2026-07-01)
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
**Alte Welt:** Browser-CAD (React/Vite + WebGL/three.js)
|
||||||
|
**Neue Welt:** Desktop Tauri-App (React/Vite Frontend + Rust-Backend + **wgpu 3D-Rendering**)
|
||||||
|
|
||||||
|
**Grund:** Komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Bottleneck. **wgpu** (low-level GPU-API auf Vulkan/Metal/DX12) + Rust-Compute skaliert native.
|
||||||
|
|
||||||
|
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Migration Stack
|
||||||
|
|
||||||
|
### Frontend (React/Vite — Komponenten + State, unverändert)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
App.tsx ← Shell-Komponente
|
||||||
|
compute/index.ts ← Compute-Boundary (neu)
|
||||||
|
model/types.ts ← Semantisches Modell
|
||||||
|
commands/ ← Befehlssystem
|
||||||
|
panels/ ← UI-Panels
|
||||||
|
plan/PlanView.tsx ← 2D-SVG-Rendering
|
||||||
|
viewport/Viewport3D.tsx ← three.js 3D-Display
|
||||||
|
ui/ ← Topbar, Dialogs, etc.
|
||||||
|
state/ ← Redux-Slices (project, selection, view, layout)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rolle:** User-Input-Handling, 2D/3D-Darstellung (Display-Layer), State-Management.
|
||||||
|
|
||||||
|
### Backend (Rust/Tauri — neu)
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/
|
||||||
|
src/
|
||||||
|
main.rs ← Tauri window + invoke-handler registration
|
||||||
|
geometry.rs ← compute_joins(), kernel2d(), etc.
|
||||||
|
parsers/
|
||||||
|
dwg.rs ← DXF/DWG-Geometrie-Parsing
|
||||||
|
dxf.rs
|
||||||
|
sia/
|
||||||
|
room_detection.rs ← detectRooms() (SIA-416)
|
||||||
|
...
|
||||||
|
Cargo.toml ← Dependencies (serde, tauri, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rolle:** Rechenintensive Ops, Geometrie-Kernel, Parsing, SIA-Raumerkennung.
|
||||||
|
|
||||||
|
### IPC: Tauri invoke (async, serde)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Frontend ruft Rust auf
|
||||||
|
const result = await invoke<JoinInfo[]>('compute_joins', { walls, joints });
|
||||||
|
|
||||||
|
// Rust bearbeitet + serialisiert Ergebnis
|
||||||
|
#[tauri::command]
|
||||||
|
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
|
||||||
|
geometry::compute_joins(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compute-Boundary (Key Design)
|
||||||
|
|
||||||
|
**Neue Datei:** `src/compute/index.ts` — einziger Eingang für rechenintensive Ops.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Beispiel-Schnittstellen
|
||||||
|
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> { … }
|
||||||
|
export async function computeKernel2D(op: 'offset'|'trim', geom: Polyline, …): Promise<Polyline[]> { … }
|
||||||
|
export async function detectRooms(…): Promise<Room[]> { … }
|
||||||
|
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinter der Boundary:**
|
||||||
|
1. Versuche Tauri invoke zu Rust (`#[tauri::command]`)
|
||||||
|
2. Fallback auf lokale TS-Impl wenn Rust nicht verfügbar (während Migration)
|
||||||
|
|
||||||
|
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
|
||||||
|
|
||||||
|
**Migrationsfluss:**
|
||||||
|
```
|
||||||
|
1. TS-Impl existiert (z.B. src/model/joins.ts)
|
||||||
|
2. Neue Op in Compute-Boundary mit Invoke+Fallback
|
||||||
|
3. Parallel: Rust-Impl in src-tauri/src/geometry.rs
|
||||||
|
4. Tests: Rust-Output == TS-Output (Parität)
|
||||||
|
5. TS-Impl bleibt (Fallback, wird nicht entfernt bis Rust stable)
|
||||||
|
6. Eventuell: TS-Impl löschen wenn Rust bewährt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev-Workflow (post-Tauri)
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Vite dev-server
|
||||||
|
npm run dev # localhost:5173
|
||||||
|
|
||||||
|
# Terminal 2: Tauri dev
|
||||||
|
npm run tauri:dev # öffnet Tauri-Fenster, zeigt auf localhost:5173
|
||||||
|
# Rust hot-reload + TS hot-reload gleichzeitig
|
||||||
|
```
|
||||||
|
|
||||||
|
**Voraussetzungen:**
|
||||||
|
- Node.js + npm (wie heute)
|
||||||
|
- Rust + Cargo (neu)
|
||||||
|
- Tauri CLI: `npm install -D @tauri-apps/cli`
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single command
|
||||||
|
npm run tauri:build
|
||||||
|
|
||||||
|
# Erzeugt:
|
||||||
|
# - Windows: src-tauri/target/release/cad.exe
|
||||||
|
# - macOS: src-tauri/target/release/bundle/macos/cad.app
|
||||||
|
# - Linux: src-tauri/target/release/bundle/deb/cad_*.deb (oder Flatpak)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rust-Unit-Tests
|
||||||
|
cargo test # in src-tauri/
|
||||||
|
|
||||||
|
# TS-Tests (unverändert)
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Integration-Test: App starten + Aktion prüfen
|
||||||
|
npm run tauri:dev # manuell testen oder Puppeteer-Probe erweitern
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU-Strategy (für später)
|
||||||
|
|
||||||
|
**Milestone 1 (jetzt):** CPU-Ops in Rust (kernel2d, joins, parsing, SIA).
|
||||||
|
|
||||||
|
**Milestone 2 (später):** GPU-Compute via wgpu
|
||||||
|
- Tauri + wgpu Renderer (optional, nicht erforderlich)
|
||||||
|
- ODER drei.js bleibt, Rust handelt CPU-Ops, three.js handelt Display
|
||||||
|
- GPU-Heavy-Ops (z.B. große Boolean-Operationen) können in wgpu laufen, aber MVP braucht das nicht
|
||||||
|
|
||||||
|
**Aktueller Plan:** three.js bleibt für 3D-Display (skaliert ausreichend für Möbel-Geometrie mit Instancing + LOD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Strategy: Ops nach Priorisierung
|
||||||
|
|
||||||
|
**Phase 1 (aktuell — Tauri-Shell + Proof-of-Concept):**
|
||||||
|
- [ ] `computeJoins` (Wand-Eckverbindungen) → Rust
|
||||||
|
- Gründe: klein, häufig, zeigt invoke-Flow
|
||||||
|
|
||||||
|
**Phase 2 (nächst):**
|
||||||
|
- [ ] `kernel2d` (Offset/Trim/Extend/Fillet) → Rust
|
||||||
|
- Gründe: Rechenlast ⭐⭐, Frequenz hoch
|
||||||
|
- [ ] DXF/DWG-Parser → Rust (Geometrie-Extraktion)
|
||||||
|
- Gründe: Rechenlast ⭐⭐, Frequenz mittel (Import-Dialog)
|
||||||
|
|
||||||
|
**Phase 3 (später):**
|
||||||
|
- [ ] `detectRooms` (SIA-416 Raumerkennung) → Rust
|
||||||
|
- Gründe: Rechenlast ⭐, async-freundlich
|
||||||
|
- [ ] `booleanOps` (Union/Differenz/Schnitt) → Rust
|
||||||
|
- Gründe: Rechenlast ⭐⭐, Frequenz gering (ad-hoc)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folgen für bestehenden Code
|
||||||
|
|
||||||
|
### Was ändert sich NICHT
|
||||||
|
|
||||||
|
- `src/model/types.ts` — semantisches Modell bleibt in TS (Frontend kennt es)
|
||||||
|
- `src/state/` — Redux-Store unverändert
|
||||||
|
- `src/ui/` — Komponenten unverändert
|
||||||
|
- `src/plan/PlanView.tsx` — SVG-Rendering unverändert
|
||||||
|
- `src/viewport/Viewport3D.tsx` — three.js-Rendering unverändert
|
||||||
|
- `src/commands/` — Befehlssystem unverändert
|
||||||
|
|
||||||
|
### Was ändert sich
|
||||||
|
|
||||||
|
- **Neue `src/compute/index.ts`** — alle rechenintensiven Ops laufen durch hier
|
||||||
|
- **Neue `src-tauri/`** — Rust-Backend
|
||||||
|
- **Vite-Config:** Tauri plugin hinzufügen
|
||||||
|
- **Package.json:** tauri scripts hinzufügen
|
||||||
|
- **Build-Prozess:** `npm run tauri:build` statt `npm run build`
|
||||||
|
|
||||||
|
### Was wird migriert (schrittweise)
|
||||||
|
|
||||||
|
- `src/model/joins.ts` → `src-tauri/src/geometry.rs` (Phase 1)
|
||||||
|
- `src/geometry/kernel2d.ts` → `src-tauri/src/geometry.rs` (Phase 2)
|
||||||
|
- `src/io/{dxfParser, dwgParser}.ts` → `src-tauri/src/parsers/` (Phase 2)
|
||||||
|
- `src/geometry/{roomArea, roomBoundary}.ts` → `src-tauri/src/sia/room_detection.rs` (Phase 3)
|
||||||
|
- `src/editors/booleanOps.ts` → `src-tauri/src/geometry.rs` (Phase 3)
|
||||||
|
|
||||||
|
**Wichtig:** TS-Versionen bleiben als Fallback (nicht gelöscht).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Distribution (später)
|
||||||
|
|
||||||
|
### Desktop Binaries (post-Tauri)
|
||||||
|
|
||||||
|
- **Windows:** `.exe` (standalone executable)
|
||||||
|
- **macOS:** `.app` bundle (code-signed)
|
||||||
|
- **Linux:** `.deb` package ODER **Flatpak** (preferred)
|
||||||
|
- Flatpak = moderne WebKitGTK6 immer dabei, unabhängig von Distro-Alter
|
||||||
|
|
||||||
|
### Browser (wenn gebraucht)
|
||||||
|
|
||||||
|
- **WASM-Fallback** für `src/compute/` Ops (Rust → WASM via wasm-bindgen)
|
||||||
|
- Later-phase feature, nicht Milestone 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technische Details
|
||||||
|
|
||||||
|
### Serialisierung (TS ↔ Rust)
|
||||||
|
|
||||||
|
**serde + serde_json** für Geometrie-Typen:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Rust
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Vec2 { pub x: f64, pub y: f64 }
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Wall {
|
||||||
|
pub id: String,
|
||||||
|
pub start: Vec2,
|
||||||
|
pub end: Vec2,
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TS (type-safe invoke)
|
||||||
|
interface Vec2 { x: number; y: number }
|
||||||
|
interface Wall { id: string; start: Vec2; end: Vec2; /* … */ }
|
||||||
|
|
||||||
|
await invoke<JoinInfo[]>('compute_joins', { walls: Wall[] })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tauri Security (default)
|
||||||
|
|
||||||
|
- Invoke-Handler sind Rust-side validiert
|
||||||
|
- Whitelist-Makro (`#[tauri::command]`) registered nur explizit erlaubte Functions
|
||||||
|
- CORS/CSP Policy default secure
|
||||||
|
- Keine arbitrary-Script-Execution (native app)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abhängigkeiten (neu post-Tauri)
|
||||||
|
|
||||||
|
### Frontend (npm)
|
||||||
|
- Bestehende: react, vite, three.js, redux, etc.
|
||||||
|
- Neu: `@tauri-apps/api` (JS-SDK für invoke)
|
||||||
|
- Optional später: `@tauri-apps/cli` dev-dependency (bereits in package.json)
|
||||||
|
|
||||||
|
### Backend (Cargo)
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["webkit2gtk-6.0"] } # GTK4
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
# später: wgpu, delaunator, opencascade-sys, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### System
|
||||||
|
- Rust 1.70+
|
||||||
|
- GTK4 dev libraries (Linux only, auto-handled by Tauri)
|
||||||
|
- Xcode Command Line Tools (macOS, auto-checked)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps (Koordination)
|
||||||
|
|
||||||
|
**Aufgabe für nächste Phase:**
|
||||||
|
→ Siehe `docs/design/tauri-migration-plan.md` (Schritt-für-Schritt, vier parallele Agents)
|
||||||
|
|
||||||
|
**HANDOVER.md:** wird aktualisiert nach Tauri-Shell stabil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Läuft die App noch im Browser?**
|
||||||
|
A: Nein (Milestone 1). Desktop-only. WASM-Fallback für Browser später wenn gebraucht.
|
||||||
|
|
||||||
|
**Q: Was passiert mit dem existing TS-Code?**
|
||||||
|
A: Bleibt unverändert (außer neue Compute-Boundary). TS-Implementierungen = Fallback bis Rust stabil.
|
||||||
|
|
||||||
|
**Q: Muss ich Rust können um das Projekt zu verstehen?**
|
||||||
|
A: Nein. Frontend bleibt React/TS. Rust ist "blackbox" hinter invoke. Aber bei Rust-Bugs muss man rein.
|
||||||
|
|
||||||
|
**Q: Wann ist Tauri-Shell fertig?**
|
||||||
|
A: Nach den vier Agents (Schritt 1–4 in tauri-migration-plan.md), ~1–2 Wochen.
|
||||||
|
|
||||||
|
**Q: Kann ich lokal testen?**
|
||||||
|
A: Ja, `npm run tauri:dev` öffnet lokale App. Alles wie heute, nur Rust hinten dran.
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# Tauri-Migration + Compute-Boundary — Aufgabe für nächste Instanz
|
||||||
|
|
||||||
|
**Entscheidung (fix, 2026-07-01):** Browser-CAD → **Desktop Tauri-App mit Rust-Backend + wgpu-Rendering.**
|
||||||
|
|
||||||
|
**Grund:** komplexe Möbel mit vielen Polygonen (100k+) + mehrere parallele rechenintensive Ops → WebGL/three.js wird zum Blocker. **wgpu** (low-level GPU-API) + Rust-Compute skaliert native.
|
||||||
|
|
||||||
|
**Scope:** Desktop-only Release (Milestone 1). WASM-Fallback für Browser später wenn gebraucht.
|
||||||
|
|
||||||
|
**Rendering-Engine:** three.js → **wgpu** (Milestone 2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aufgabe: Shell aufsetzen + Compute-Boundary + erste Op migrieren
|
||||||
|
|
||||||
|
### Schritt 0 — Compute-Boundary (TS-Kontrakt)
|
||||||
|
|
||||||
|
**Neu:** `src/compute/index.ts` — die einzige Stelle, durch die alle rechenintensiven Ops laufen.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/compute/index.ts — einheitliche Schnittstelle
|
||||||
|
export async function computeKernel2D(op: 'offset'|'trim', …): Promise<Polyline[]> { … }
|
||||||
|
export async function detectRooms(…): Promise<Room[]> { … }
|
||||||
|
export async function computeJoins(…): Promise<JoinInfo[]> { … } // ← erste Op
|
||||||
|
export async function parseShapeFromDwg(…): Promise<DwgGeometry> { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinter der Boundary:**
|
||||||
|
- Erst Tauri-invoke zu Rust `#[tauri::command]`
|
||||||
|
- Fallback auf lokale TS-Impl (bleibt unberührt, bis Rust stabil)
|
||||||
|
- `catch(err) → console.warn('Rust failed, using TS fallback'); return tsImpl(…)`
|
||||||
|
|
||||||
|
**Effekt:** Aufrufort im Code bleibt stabil; Caller sieht nicht, ob Op schon in Rust ist oder noch TS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 1 — Tauri-Shell aufsetzen
|
||||||
|
|
||||||
|
**Struktur:**
|
||||||
|
```
|
||||||
|
repo/
|
||||||
|
src-tauri/ ← neue Rust-Seite (Tauri-Konvention)
|
||||||
|
src/
|
||||||
|
main.rs ← Tauri window + invoke handlers
|
||||||
|
geometry.rs ← compute_joins() + weitere Ops später
|
||||||
|
...
|
||||||
|
Cargo.toml
|
||||||
|
src/ ← React/TS (unverändert)
|
||||||
|
compute/
|
||||||
|
index.ts ← Compute-Boundary
|
||||||
|
...
|
||||||
|
vite.config.ts ← Tauri plugin integrieren
|
||||||
|
package.json ← tauri scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
1. `cargo init --name cad-tauri src-tauri` (oder `src-tauri` manuell anlegen)
|
||||||
|
2. `Cargo.toml`: Tauri v2 einbinden mit Feature `webkit2gtk-6.0` (GTK4)
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["webkit2gtk-6.0"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
```
|
||||||
|
3. `src-tauri/src/main.rs`: Minimal-Fenster, invoke-Handler registrieren
|
||||||
|
```rust
|
||||||
|
#[tauri::command]
|
||||||
|
async fn compute_joins(input: JoinInput) -> Result<JoinOutput, String> {
|
||||||
|
// Rust-Impl
|
||||||
|
geometry::compute_joins(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.invoke_handler(tauri::generate_handler![compute_joins])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. `vite.config.ts`: Tauri plugin + dev-server-Integration
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5173 } // Tauri dev zeigt hier drauf
|
||||||
|
})
|
||||||
|
```
|
||||||
|
5. `package.json`: Tauri scripts hinzufügen
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 2 — Erste Op migrieren: `computeJoins` (Wand-Eckverbindungen)
|
||||||
|
|
||||||
|
**Warum `computeJoins` zuerst?**
|
||||||
|
- Läuft häufig (bei jedem Wall-Edit)
|
||||||
|
- Klein und fokussiert (~50 Zeilen Kernlogik)
|
||||||
|
- Proof-of-Concept für Tauri-invoke-Flow
|
||||||
|
- Danach `kernel2d` parallel hochfahren
|
||||||
|
|
||||||
|
**Rust-Impl:** `src-tauri/src/geometry.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Vec2 { pub x: f64, pub y: f64 }
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct WallJoinInput {
|
||||||
|
pub walls: Vec<Wall>,
|
||||||
|
pub joints: Vec<(usize, usize)>, // wall indices
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct JoinInfo { /* … */ }
|
||||||
|
|
||||||
|
pub fn compute_joins(input: WallJoinInput) -> Result<Vec<JoinInfo>, Box<dyn std::error::Error>> {
|
||||||
|
// Port der Logik aus src/model/joins.ts
|
||||||
|
// L-Ecken, T-Stösse, +-Kreuzungen
|
||||||
|
Ok(vec![]) // Placeholder
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TS-Fallback bleibt:** `src/model/joins.ts` (LS vor Rust-Port)
|
||||||
|
|
||||||
|
**Compute-Boundary:** `src/compute/index.ts`
|
||||||
|
```typescript
|
||||||
|
export async function computeJoins(walls: Wall[], joints: Array<[number, number]>): Promise<JoinInfo[]> {
|
||||||
|
try {
|
||||||
|
return await invoke<JoinInfo[]>('compute_joins', { walls, joints });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Rust compute_joins failed, using TS fallback:', err);
|
||||||
|
return joinsTS(walls, joints); // Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 3 — Migrations-Parität testen
|
||||||
|
|
||||||
|
**Test-Fixtures:**
|
||||||
|
- Aus `src/model/sampleProject.ts` exportieren: Wand-Arrays mit bekannten L/T/±-Konfigurationen
|
||||||
|
- Rust-Unit-Tests: gleiche Fixtures → gleiche JoinInfo-Outputs
|
||||||
|
- Vergleich: `actual == expected`
|
||||||
|
|
||||||
|
**Beispiel (Rust):**
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_l_corner() {
|
||||||
|
let input = WallJoinInput { /* L-shaped walls */ };
|
||||||
|
let result = compute_joins(input).unwrap();
|
||||||
|
assert_eq!(result[0].kind, JoinKind::LCorner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 4 — Vite/Tauri-Integration
|
||||||
|
|
||||||
|
**Dev-Workflow:**
|
||||||
|
```bash
|
||||||
|
npm run tauri:dev
|
||||||
|
# → Vite dev-server (localhost:5173) lädt React-App
|
||||||
|
# → Tauri-window zeigt auf :5173
|
||||||
|
# → invoke() ruft Rust-Commands auf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build-Workflow:**
|
||||||
|
```bash
|
||||||
|
npm run build # Vite → dist/
|
||||||
|
npm run tauri:build # Tauri packt dist/ + Rust-Binary
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliverable (Proof-of-Concept)
|
||||||
|
|
||||||
|
**Git-Stand nach dieser Aufgabe:**
|
||||||
|
|
||||||
|
- [ ] `src-tauri/` Verzeichnis mit `Cargo.toml` + `src/main.rs` + `src/geometry.rs`
|
||||||
|
- [ ] `Cargo.toml` buildet sauber (`cargo check` 0 Fehler)
|
||||||
|
- [ ] `src/compute/index.ts` mit `computeJoins()` Schnittstelle (invoke + TS-fallback)
|
||||||
|
- [ ] Rust `compute_joins()` implementiert, Tests pass (`cargo test`)
|
||||||
|
- [ ] `package.json` `tauri` scripts hinzugefügt
|
||||||
|
- [ ] **App läuft:** `npm run tauri:dev` → Tauri-Fenster öffnet, Wand-Edit triggert Rust-Op, Output identisch TS-Version
|
||||||
|
- [ ] **Trace-Scan sauber** (kein TS/Rust-Code übrig, beide Impl. aktiv)
|
||||||
|
- [ ] HANDOVER.md aktualisiert: `computeJoins` migriert, nächste Ops in Queue
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
```bash
|
||||||
|
# Build-Check
|
||||||
|
cargo check # 0 Fehler
|
||||||
|
|
||||||
|
# Rust-Tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# App end-to-end
|
||||||
|
npm run tauri:dev
|
||||||
|
# → Wand zeichnen + editieren → computeJoins() über Rust aufgerufen
|
||||||
|
# → Plan + 3D aktualisiert wie vorher
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Ops (Priorisierung)
|
||||||
|
|
||||||
|
Nach `computeJoins` stabil:
|
||||||
|
|
||||||
|
1. **`kernel2d`** (Offset/Trim/Extend) — großer Hebel, läuft häufig
|
||||||
|
2. **DXF/DWG-Parser** (Geometrie) — heavy, aber niedrige Frequenz
|
||||||
|
3. **`detectRooms`** (SIA-Raumerkennung) — async, kann auf Hintergrund ziehen
|
||||||
|
4. **`booleanOps`** (Union/Differenz/Schnitt) — Kandidat für später
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte (NICHT jetzt)
|
||||||
|
|
||||||
|
- **GPU-Compute (wgpu):** Erst nach CPU-Ops stabil (kernel2d, joins, parsing)
|
||||||
|
- **WASM-Fallback:** Nur wenn Browser-Support nötig wird
|
||||||
|
- **Flatpak-Distribution:** Nach Tauri-Shell stable + erste Ops migriert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- Tauri v2 Docs: https://tauri.app/v1/guides/getting-started/prerequisites
|
||||||
|
- Serde: https://serde.rs/
|
||||||
|
- CONVENTIONS.md: Identifiers englisch, UI-Text via `t()`, …
|
||||||
|
- Commit-Regel: kein AI-Attribution im Repo
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Oberleiste – Angleichung an DOSSIER (Umsetzungs-Spezifikation)
|
||||||
|
|
||||||
|
Verbindliche Vorlage für den Umbau der Top-Bar (`src/ui/TopBar.tsx`,
|
||||||
|
`src/styles.css`, Verdrahtung in `src/App.tsx`). Quelle: das DOSSIER-Rhino-
|
||||||
|
Plugin (`ToolbarApp.jsx`, `components/BarControls.jsx`, `TextEditorApp.jsx`).
|
||||||
|
Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
## 0. Grundprimitive (neu, DOSSIER-konform)
|
||||||
|
|
||||||
|
Alle Leisten-Controls teilen dieselbe Höhe und Pillenform.
|
||||||
|
|
||||||
|
- `BAR_H = 22px` Basis-Höhe. Segmentierte Pillen `BAR_H + 2 = 24px`
|
||||||
|
(`box-sizing:border-box`, 1px Rand inbegriffen).
|
||||||
|
- Pille: `border:1px solid var(--border)`, `border-radius:999px`,
|
||||||
|
`background:var(--input)`. Hover (interaktiv): `border-color:var(--accent-border)`,
|
||||||
|
`background:var(--accent-dim)`.
|
||||||
|
- Aktiver Zustand (Toggle AN / aktive Segmentzelle): `background:var(--accent)`,
|
||||||
|
`color:#fff`.
|
||||||
|
|
||||||
|
### BarCombo (Pillen-Dropdown)
|
||||||
|
Wir haben bereits `src/ui/Dropdown.tsx`. Der Dropdown-Trigger MUSS optisch der
|
||||||
|
Pille entsprechen (Höhe 24, radius 999, obige Farben). Ein optionales Icon sitzt
|
||||||
|
LINKS **ausserhalb** der Pille (18px breit, `var(--muted)`), ein optionaler
|
||||||
|
Zahnrad-Knopf („settings") sitzt rechts **innerhalb** der Pille. Prüfen, ob
|
||||||
|
`Dropdown` bereits so aussieht; falls nicht → Trigger-CSS angleichen (Klasse
|
||||||
|
`tb-dd-trigger`). KEINE zweite Dropdown-Implementierung bauen.
|
||||||
|
|
||||||
|
### Segmentpille (3er/4er)
|
||||||
|
Aussencontainer `display:inline-flex; height:24px; border:1px solid var(--border);
|
||||||
|
border-radius:999px; overflow:hidden`. Zellen ohne eigenen Radius; interne Trenner
|
||||||
|
über `border-left:1px solid var(--border)` (erste Zelle ohne). Aktive Zelle
|
||||||
|
`var(--accent)`/#fff, inaktiv `var(--input)`/`var(--ink)`, Hover
|
||||||
|
`var(--accent-dim)`/`var(--accent)`. Genutzt für: Ansichts-Icons, Zoom (%/fit/center),
|
||||||
|
**B/I/U**, **L/C/R**.
|
||||||
|
|
||||||
|
### BarButton (quadratischer Icon-Knopf)
|
||||||
|
22×22, `border-radius:999px`, sonst wie Pille. Aktiv = Akzentfüllung, Icon #fff.
|
||||||
|
|
||||||
|
## 1. Reihenfolge der Gruppen (links → rechts)
|
||||||
|
|
||||||
|
1. Marke (bestehend, unverändert).
|
||||||
|
2. Ansichts-Gruppe (bestehend `view-grid`; Zellen auf Segmentpillen-Look bringen).
|
||||||
|
3. Sichtbarkeits-Kombinationen (bestehend, `BarCombo`-Look).
|
||||||
|
4. Detailgrad + Massstab (gestapelt, `BarCombo`-Look).
|
||||||
|
5. **Massstab/Zoom-Cluster NEU** (siehe §2) — ersetzt die heutige Gruppe mit der
|
||||||
|
DOPPELTEN Zoom-Anzeige.
|
||||||
|
6. Darstellungsart (bestehend, `BarCombo`).
|
||||||
|
7. **Text-Gruppe NEU** (siehe §3) — die zentrale neue Leiste.
|
||||||
|
8. Referenzlinien / Linien-Modus (bestehend).
|
||||||
|
9. Rechts: Layout · Ressourcen · Projektname.
|
||||||
|
|
||||||
|
## 2. Massstab/Zoom-Cluster (ersetzt Doppel-Zoom-Bug)
|
||||||
|
|
||||||
|
HEUTE FALSCH: In `TopBar.tsx` wird `tb-zoom` (Zoom %) ZWEIMAL gerendert
|
||||||
|
(einmal im `tb-zoomstack`, einmal darunter als eigener `<span>`). Die zweite,
|
||||||
|
lose `<span className="tb-zoom">…%</span>` ersatzlos ENTFERNEN.
|
||||||
|
|
||||||
|
NEUES Layout — 2×2-Raster (`display:grid; grid-template-columns:auto auto;
|
||||||
|
gap:4px 6px; align-items:center`):
|
||||||
|
|
||||||
|
- **Spalte 1, beide Zeilen** (`grid-row:1 / span 2`): EINE kombinierte Stat-Pille,
|
||||||
|
`width:70px`, Höhe `BAR_H*2+6 = 50px`, `border-radius:14px` (NICHT 999),
|
||||||
|
`border:1px solid var(--border)`, `background:var(--input)`, Innen zwei Zeilen
|
||||||
|
mittig, getrennt durch 1px-Linie (`var(--border)`):
|
||||||
|
- oben: Live-Massstab `1:N` (Akzentfarbe, `var(--font-mono)`, 11px, 700)
|
||||||
|
- unten: Zoom `NN%` (`var(--ink-2)`, mono, 11px)
|
||||||
|
- „am Massstab" (Zoom==gewählter Massstab): Pille `background:var(--accent-dim)`,
|
||||||
|
`border-color:var(--accent)`, Text `var(--accent)`.
|
||||||
|
- Nicht-Plan-Ansicht: beide Werte „—".
|
||||||
|
- **Spalte 2, Zeile 1**: Massstab-Dropdown (`BarCombo`, ~140px, mono) + Print/PDF
|
||||||
|
bleibt separat. (Massstab-Dropdown ist der bestehende `scaleOptions`-Dropdown.)
|
||||||
|
- **Spalte 2, Zeile 2**: Zoom-Segmentpille mit 3 Zellen — `%` (=`onZoom100`,
|
||||||
|
Label „1:1"/100 %), `fit_screen` (=`onFit`), `center_focus_strong`
|
||||||
|
(=`onFitSelection`). Material-Icons. Daneben ggf. Referenzlinien-BarButton.
|
||||||
|
|
||||||
|
Export-Knöpfe (PDF/DXF) wandern in eine eigene kleine BarButton-Reihe rechts vom
|
||||||
|
Cluster (Icons `picture_as_pdf` / `download`) ODER bleiben Pillen — Hauptsache
|
||||||
|
NICHT mehr Teil des Zoom-Blocks, damit der Cluster ruhig bleibt.
|
||||||
|
|
||||||
|
## 3. Text-Gruppe in der Oberleiste (NEU – Kern dieser Aufgabe)
|
||||||
|
|
||||||
|
Immer sichtbar. 3×2-Raster (`grid-template-columns:110px 130px 80px; gap:4px 6px`).
|
||||||
|
Setzt Defaults für neuen Text UND formatiert die aktuelle Auswahl live.
|
||||||
|
|
||||||
|
Zeile 1:
|
||||||
|
- **Stil-Preset** `BarCombo` (110px): Optionen aus `DEFAULT_PRESETS`
|
||||||
|
(Titel/Untertitel/Label/Notiz) + „— Stil —".
|
||||||
|
- **Font** `BarCombo` (130px): Systemfont-Liste (mind. Helvetica, Arial, Inter,
|
||||||
|
Times New Roman, Georgia, Courier New). `applyMark(doc,range,'font',v)`.
|
||||||
|
- **Grösse** `BarCombo` (80px): Presets in pt `[8,9,10,11,12,14,18,24,36,48]`
|
||||||
|
+ „Eigene…" → Zahl-Input-Pille. `applyMark(...,'sizePt',n)`.
|
||||||
|
|
||||||
|
Zeile 2:
|
||||||
|
- **B/I/U** Segmentpille (110px, Icons `format_bold`/`format_italic`/
|
||||||
|
`format_underlined`): `toggleMark(doc,range,'bold'|'italic'|'underline')`.
|
||||||
|
Aktiv-Zustand aus `isMarkActive(doc,range,mark)`.
|
||||||
|
- **L/C/R** Segmentpille (130px, Icons `format_align_left`/`_center`/`_right`):
|
||||||
|
setzt `paragraph.align` im Bereich.
|
||||||
|
- **„+"-Text-Button** (80px, BarButton/Pille, Icon `add`, Label „Text"): startet
|
||||||
|
das Text-Werkzeug (neues Textobjekt platzieren). Falls das Text-Annotation-
|
||||||
|
Werkzeug noch nicht existiert, Button vorerst `disabled` mit Tooltip
|
||||||
|
(kein stiller No-Op) — aber Verdrahtung vorbereiten.
|
||||||
|
|
||||||
|
**Auswahl-Bewusstsein (WICHTIG):** Prop `textTarget` (oder aus App-State): entweder
|
||||||
|
`null` (nichts Text-artiges selektiert → Controls setzen nur Defaults, Ränder
|
||||||
|
normal) ODER `{ doc: RichTextDoc, range: TextRange|null, apply: (doc)=>void }`
|
||||||
|
für den aktuell selektierten Raumstempel/Text. Ist `textTarget != null`, tragen
|
||||||
|
die Zeile-2-Pillen `border-color:var(--accent)` (Akzent-Glow), und alle Aktionen
|
||||||
|
wirken auf `textTarget.doc` via `textTarget.apply(newDoc)`. Ohne aktive Range
|
||||||
|
(nur Objekt selektiert, kein Editor offen) wirkt Formatierung auf das GANZE Doc.
|
||||||
|
|
||||||
|
Quelle der `textTarget`-Daten: der Raum-Agent exponiert Stempel-Doc + Setter
|
||||||
|
(`setRoomStampDoc(roomId, doc)`) im App-State (siehe Peer-Absprache). App leitet
|
||||||
|
für den selektierten Raum `{doc: room.stampDoc, range: activeStampRange,
|
||||||
|
apply: d => setRoomStampDoc(room.id, d)}` an die Text-Gruppe.
|
||||||
|
|
||||||
|
## 4. Text-Inhalt bearbeiten: kleines Fenster (kein Footer)
|
||||||
|
|
||||||
|
Doppelklick auf einen Raumstempel/ein Textobjekt öffnet ein **schwebendes
|
||||||
|
Dialog-Fenster** (nicht den Footer, kein Panel-Aufklappen). Umsetzung: neue
|
||||||
|
Komponente `src/ui/TextEditorDialog.tsx` — ein zentriertes/абgesetztes Fenster
|
||||||
|
(~560×420, `--shadow-3`, `border-radius:8px`, Titel „Text bearbeiten",
|
||||||
|
Kopf mit Schliessen-✕), Body = der bestehende `src/text/RichTextEditor.tsx`
|
||||||
|
(er bringt seine eigene Mini-Toolbar mit — das ist hier ok, weil es ein eigenes
|
||||||
|
Fenster ist), Fuss = „Abbrechen" / „Übernehmen". „Übernehmen" ruft
|
||||||
|
`setRoomStampDoc(roomId, editedDoc)`.
|
||||||
|
|
||||||
|
Der Doppelklick-Handler lebt in App (Plan-View/Viewport → onDoubleClick auf
|
||||||
|
Stempel-Hit → `openTextEditor(roomId)`), NICHT im Footer. Falls der Raum-Agent
|
||||||
|
den Footer benutzt hat: diesen Pfad entfernen und durch den Dialog ersetzen.
|
||||||
|
|
||||||
|
## 5. Farb-/Stil-Tokens
|
||||||
|
|
||||||
|
Bestehende CSS-Variablen weiterverwenden (`--panel`,`--input`,`--border`,
|
||||||
|
`--accent`,`--accent-dim`,`--accent-border`,`--ink`,`--ink-2`,`--muted`,
|
||||||
|
`--font-mono`,`--shadow-1..3`). KEINE neuen Farbwerte hart kodieren. Falls ein
|
||||||
|
Token fehlt (z. B. `--accent-border`), prüfen und ggf. aus bestehenden ableiten.
|
||||||
|
|
||||||
|
## 6. i18n
|
||||||
|
|
||||||
|
Neue Keys in de.ts UND en.ts: `text.style`, `text.font`, `text.size`,
|
||||||
|
`text.size.custom`, `text.bold/italic/underline`, `text.align.left/center/right`,
|
||||||
|
`text.add`, `text.add.hint`, `text.editTitle`, `text.apply`, `text.cancel`,
|
||||||
|
`text.selectedHint`. Presets-Namen über bestehende `rt.*`/Preset-Keys, sofern da.
|
||||||
|
|
||||||
|
## 7. Gate (Pflicht)
|
||||||
|
|
||||||
|
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
|
||||||
|
Trace-Scan (grep auf Co-Authored/Generated), Boot-Probe
|
||||||
|
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
|
||||||
|
KEIN Commit.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Top-Bar (Oberleiste) & Footer/Status-Leiste — Design
|
||||||
|
|
||||||
|
> Referenz: DOSSIER `rhino/toolbar.py` + `src/ToolbarApp.jsx`. Hier auf unseren
|
||||||
|
> Standalone-Stack (React+TS, eigenes Modell) übersetzt. DOSSIER hat KEINEN Footer
|
||||||
|
> (delegiert an Rhinos eigene Leiste) — den Footer ergänzen wir neu (Vectorworks-Stil).
|
||||||
|
|
||||||
|
## Top-Bar — Gruppen (links → rechts)
|
||||||
|
|
||||||
|
1. **Marke/Logo** + Settings-Icons (Projekt-Einstellungen, App-Einstellungen).
|
||||||
|
2. **Ansicht** — Umschalter: Grundriss · Perspektive · Schnitt · Ansicht.
|
||||||
|
Später: 3D-Views Top/Iso + Himmelsrichtungen N/O/S/W (mit Nordwinkel-Rotation).
|
||||||
|
3. **Darstellung** — Render-/Anzeigemodus (Wireframe/Shaded/…) + **Detailgrad**
|
||||||
|
(grob/mittel/fein, ≙ DOSSIER „Darstellung" Einfach/Standard/Detail).
|
||||||
|
4. **Massstab & Zoom** — Live-Anzeige „1:N" + Dropdown (1:1,1:5,…,1:1000, frei) +
|
||||||
|
**Plan-Ansicht-Toggle** (Linienstärken für Druck) + Zoom-Buttons: 100% · Einpassen ·
|
||||||
|
Auswahl. Quelle: viewBox-Skala / `dpi = 96·devicePixelRatio` (siehe docs/design/plans-output.md).
|
||||||
|
5. **Overrides** (regelbasiert, Toggle + Preset) · **Masse** (Bemaßungs-Preset) — später.
|
||||||
|
6. **Anordnen (Z-Order)** — nach vorne/hinten (für 2D-Plangrafik) — später.
|
||||||
|
7. **Snapping** — Master-Osnap + Modi (End/Mitte/Schnittpunkt/Lot/Zentrum/Nah) +
|
||||||
|
**Raster** an/aus + **Referenzlinien** (Wandachsen) an/aus — wenn Zeichenwerkzeuge da sind.
|
||||||
|
8. **Text** — Stil/Font/Größe + B/I/U + Ausrichtung + „+" — mit den 2D-Werkzeugen.
|
||||||
|
|
||||||
|
### MVP jetzt (zu vorhandenem Modell)
|
||||||
|
- Ansichts-Umschalter (haben wir, ausbauen) · Detailgrad-Dropdown · **Massstab 1:N +
|
||||||
|
Zoom: Einpassen/Auswahl/100%** · Render-Modus · Referenzlinien-Toggle · Ressourcen.
|
||||||
|
|
||||||
|
## Footer / Status-Leiste (neu, unten, ~22 px)
|
||||||
|
|
||||||
|
`[Werkzeug-Hinweis] … [Cursor X/Y/Z] · [Einheit] · [Massstab 1:N] · [Zoom %] · [Geschoss] · [Ebene] · [Snap] … [Auswahl: n]`
|
||||||
|
|
||||||
|
- **Cursor X/Y/Z** — live aus der Plan-/3D-Position (Plan: aus viewBox-Inverse der Maus).
|
||||||
|
- **Einheit** — m (aus Projekt). **Massstab** 1:N + **Zoom %** — aus der View-Transform.
|
||||||
|
- **Aktives Geschoss** + **aktive Ebene** — aus Selection/State.
|
||||||
|
- **Snap-Status** — aktive Fänge (später). **Auswahl: n** — Anzahl selektierter Objekte.
|
||||||
|
- **Werkzeug-Hinweis** links — kontextueller Text des aktiven Werkzeugs.
|
||||||
|
|
||||||
|
### MVP jetzt
|
||||||
|
- Cursor X/Y (im Grundriss), Einheit, Massstab 1:N, Zoom %, aktives Geschoss + Ebene.
|
||||||
|
Snap/Werkzeug/Auswahl kommen mit den Zeichenwerkzeugen.
|
||||||
|
|
||||||
|
## Anbindung
|
||||||
|
Beide Leisten docken an das Panel-/Dock-Layout an (Top über den Docks, Footer darunter),
|
||||||
|
Inhalte rein aus dem Modell + View-State abgeleitet (keine Sonderzustände).
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# truck-Integration — Profil-Extrusion (B-Rep → 3D-Mesh)
|
||||||
|
|
||||||
|
> Übergabe-Dokument für die Implementierung. Lies zuerst CONVENTIONS.md.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Nutzer zeichnen im Grundriss ein geschlossenes Polygon (z. B. L-Profil einer Stütze)
|
||||||
|
und können es als 3D-Körper auf eine Höhe extrudieren. Das Ergebnis erscheint sofort
|
||||||
|
im 3D-Viewport neben Wänden und Decken.
|
||||||
|
|
||||||
|
**MVP-Scope (dieser Auftrag):**
|
||||||
|
- Neue Rust/WASM-Crate `trucksolid` mit zwei Funktionen: `extrude_polygon` + `extrude_circle`
|
||||||
|
- Neue TS-Wrapper-Datei `src/engine/truckSolid.ts` (WASM laden + typisierte API)
|
||||||
|
- KEIN neues Werkzeug, KEIN UI — nur die Geometrie-Schicht. UI kommt in einem
|
||||||
|
separaten Folgeauftrag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Kontext
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/
|
||||||
|
render3d/ ← bestehend: wgpu-Renderer (wasm-pack → pkg3d/)
|
||||||
|
kernel2d/ ← bestehend: 2D-Geometrie-Kern
|
||||||
|
trucksolid/ ← NEU (dieser Auftrag)
|
||||||
|
Cargo.toml
|
||||||
|
src/
|
||||||
|
lib.rs
|
||||||
|
|
||||||
|
src/engine/
|
||||||
|
pkg3d/ ← render3d WASM-Output
|
||||||
|
pkgTruck/ ← NEU: trucksolid WASM-Output (wasm-pack Ziel)
|
||||||
|
truckSolid.ts ← NEU: TS-Wrapper
|
||||||
|
|
||||||
|
src/plan/
|
||||||
|
toWalls3d.ts ← bestehend: baut RenderScene; emitMeshes() muss erweitert werden
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Koordinatensystem in DOSSIER ist **Modell-Meter: X = rechts, Y = oben (Grundriss),
|
||||||
|
Z = Höhe**. render3d erwartet `positions` im gleichen System — `mesh.rs` macht intern
|
||||||
|
`(x, y, z) → (x, z, y)` für wgpu (Y-up world). Truck arbeitet in 3D; wir bauen
|
||||||
|
das Polygon in der XY-Ebene (z=0) und extrudieren nach +Z.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 — Rust-Crate `src-tauri/trucksolid/`
|
||||||
|
|
||||||
|
### 1.1 Cargo.toml
|
||||||
|
|
||||||
|
Exakt dasselbe Muster wie `src-tauri/kernel2d/Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[workspace] # entkoppelt vom cad-tauri-Workspace (Pflicht)
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trucksolid"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Profil-Extrusion via truck (B-Rep → tesselliertes Mesh für render3d)"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
web = [
|
||||||
|
"dep:wasm-bindgen",
|
||||||
|
"dep:serde_json",
|
||||||
|
"dep:console_error_panic_hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
wasm-bindgen = { version = "0.2", optional = true }
|
||||||
|
console_error_panic_hook = { version = "0.1", optional = true }
|
||||||
|
|
||||||
|
# truck: nur die stabilen Crates (KEIN truck-modeling — Booleans instabil)
|
||||||
|
truck-geometry = "0.3"
|
||||||
|
truck-topology = "0.3"
|
||||||
|
truck-rendimesh = "0.3"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** `truck-modeling` (die Crate mit Boolean-Operatoren) wird NICHT eingebunden.
|
||||||
|
Nur `truck-geometry`, `truck-topology` und `truck-rendimesh` — die sind stabil.
|
||||||
|
|
||||||
|
### 1.2 src/lib.rs — Kern-Logik
|
||||||
|
|
||||||
|
#### Eingabe/Ausgabe-Typen
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Input: flaches Array [x0,y0, x1,y1, ...] in Modell-Metern (Grundriss-XY).
|
||||||
|
/// Muss ≥ 3 Punkte enthalten; Wiederholung des ersten Punkts am Ende optional.
|
||||||
|
/// Reihenfolge CCW oder CW — truck normalisiert selbst.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ExtrudePolyInput {
|
||||||
|
pub points: Vec<f64>, // flat: [x0,y0, x1,y1, ...]
|
||||||
|
pub height: f64, // Extrusionshöhe in Metern (> 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output: trianguliertes Mesh, kompatibel mit render3d::types::MeshInput.
|
||||||
|
/// positions: flat [x0,y0,z0, x1,y1,z1, ...] in Modell-Metern
|
||||||
|
/// indices: Dreiecks-Indizes (je 3 = 1 Dreieck), 0-basiert
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MeshOutput {
|
||||||
|
pub positions: Vec<f32>,
|
||||||
|
pub indices: Vec<u32>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Polygon-Extrusion (truck-API)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use truck_geometry::prelude::*;
|
||||||
|
use truck_topology::*;
|
||||||
|
|
||||||
|
pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result<MeshOutput, String> {
|
||||||
|
if pts.len() < 3 { return Err("min 3 Punkte".into()); }
|
||||||
|
if height <= 0.0 { return Err("height muss > 0 sein".into()); }
|
||||||
|
|
||||||
|
// 1. Punkte in der XY-Ebene (z=0) als truck-Vertices
|
||||||
|
let verts: Vec<Vertex<Point3>> = pts.iter()
|
||||||
|
.map(|(x, y)| Vertex::new(Point3::new(*x, *y, 0.0)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 2. Kanten: je zwei aufeinanderfolgende Vertices verbinden, Ring schliessen
|
||||||
|
let edges: Vec<Edge<_, Line<Point3>>> = verts.windows(2)
|
||||||
|
.chain(std::iter::once([verts.last().unwrap(), &verts[0]].as_slice()))
|
||||||
|
.map(|w| {
|
||||||
|
let p0 = *w[0].point();
|
||||||
|
let p1 = *w[1].point();
|
||||||
|
Edge::new(&w[0], &w[1], Line(p0, p1))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 3. Wire (geschlossener Kantenzug)
|
||||||
|
let wire = Wire::from_iter(edges);
|
||||||
|
|
||||||
|
// 4. Planare Face aus dem Wire
|
||||||
|
// truck-topology::Face::new braucht die äussere Boundary + optional Holes
|
||||||
|
let face = Face::new(vec![wire]);
|
||||||
|
|
||||||
|
// 5. Lineare Extrusion: tsweep entlang +Z um `height`
|
||||||
|
let solid = face.tsweep(&Vector3::new(0.0, 0.0, height));
|
||||||
|
|
||||||
|
// 6. Tessellieren (chord-tolerance in Metern — 0.005 = 5 mm)
|
||||||
|
use truck_rendimesh::MeshedShape;
|
||||||
|
let mesh = solid.triangulation(0.005).to_polygon();
|
||||||
|
|
||||||
|
// 7. positions + indices extrahieren
|
||||||
|
let positions: Vec<f32> = mesh.positions().iter()
|
||||||
|
.flat_map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||||
|
.collect();
|
||||||
|
let indices: Vec<u32> = mesh.tri_faces().iter()
|
||||||
|
.flat_map(|tri| tri.iter().map(|idx| idx.pos as u32))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(MeshOutput { positions, indices })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Achtung:** Die exakte truck-API (Methoden-Namen, Trait-Imports, tsweep-Signatur)
|
||||||
|
> kann je nach veröffentlichter Version leicht abweichen. Prüfe die Docs von
|
||||||
|
> `truck-topology 0.3` und `truck-rendimesh 0.3` auf docs.rs. Die Struktur oben
|
||||||
|
> ist das Ziel-Pattern — passe Methoden-Signaturen an, wenn der Compiler es verlangt.
|
||||||
|
> Ändere NICHT die Eingabe/Ausgabe-JSON-Struktur.
|
||||||
|
|
||||||
|
#### Kreis-Extrusion (Zylinder)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn extrude_circle_core(cx: f64, cy: f64, r: f64, height: f64) -> Result<MeshOutput, String> {
|
||||||
|
// Kreis in XY-Ebene tessellieren (N Segmente je nach r),
|
||||||
|
// dann wie extrude_polygon_core aufrufen.
|
||||||
|
// Alternativ: truck-geometry::Ellipse/Circle direkt nutzen, falls vorhanden.
|
||||||
|
let n = (2.0 * std::f64::consts::PI * r / 0.02).ceil().max(16.0) as usize;
|
||||||
|
let pts: Vec<(f64, f64)> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
|
||||||
|
(cx + r * a.cos(), cy + r * a.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
extrude_polygon_core(&pts, height)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WASM-Bindings (Feature "web")
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
mod web {
|
||||||
|
use super::*;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen(start)]
|
||||||
|
pub fn init() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrudiert ein Polygon-Profil.
|
||||||
|
/// `input_json`: `{ "points": [x0,y0,…], "height": 2.5 }`
|
||||||
|
/// Rückgabe: `{ "positions": […], "indices": […] }` oder throws JsError.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn extrude_polygon(input_json: &str) -> Result<String, JsError> {
|
||||||
|
let input: ExtrudePolyInput = serde_json::from_str(input_json)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let pts: Vec<(f64, f64)> = input.points.chunks(2)
|
||||||
|
.map(|c| (c[0], c[1]))
|
||||||
|
.collect();
|
||||||
|
let mesh = extrude_polygon_core(&pts, input.height)
|
||||||
|
.map_err(|e| JsError::new(&e))?;
|
||||||
|
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrudiert einen Kreis-Querschnitt (Zylinder).
|
||||||
|
/// `input_json`: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0 }`
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn extrude_circle(input_json: &str) -> Result<String, JsError> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct In { cx: f64, cy: f64, r: f64, height: f64 }
|
||||||
|
let i: In = serde_json::from_str(input_json)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height)
|
||||||
|
.map_err(|e| JsError::new(&e))?;
|
||||||
|
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Tests (headless, ohne Feature "web")
|
||||||
|
|
||||||
|
Mindestens diese drei Tests müssen mit `cargo test` grün sein:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quad_extrusion_vertex_count() {
|
||||||
|
// 1m × 1m Quadrat, 2m hoch → 8 Eckpunkte minimum (6 Flächen × 2 Dreiecke)
|
||||||
|
let pts = vec![(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)];
|
||||||
|
let m = extrude_polygon_core(&pts, 2.0).unwrap();
|
||||||
|
assert!(m.positions.len() >= 8 * 3); // ≥ 8 Vertices × 3 floats
|
||||||
|
assert_eq!(m.indices.len() % 3, 0); // vollständige Dreiecke
|
||||||
|
assert!(m.indices.len() >= 12 * 3); // ≥ 12 Dreiecke (Quader)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn l_profile_extrusion() {
|
||||||
|
// L-Profil: 6 Punkte
|
||||||
|
let pts = vec![
|
||||||
|
(0.0,0.0),(0.3,0.0),(0.3,0.1),(0.1,0.1),(0.1,0.3),(0.0,0.3),
|
||||||
|
];
|
||||||
|
let m = extrude_polygon_core(&pts, 3.0).unwrap();
|
||||||
|
assert_eq!(m.indices.len() % 3, 0);
|
||||||
|
assert!(m.positions.len() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cylinder_extrusion() {
|
||||||
|
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0).unwrap();
|
||||||
|
assert_eq!(m.indices.len() % 3, 0);
|
||||||
|
assert!(m.positions.len() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_too_few_points() {
|
||||||
|
assert!(extrude_polygon_core(&[(0.0,0.0),(1.0,0.0)], 1.0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_height() {
|
||||||
|
let pts = vec![(0.0,0.0),(1.0,0.0),(0.5,1.0)];
|
||||||
|
assert!(extrude_polygon_core(&pts, 0.0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 — Build-Script (package.json)
|
||||||
|
|
||||||
|
Füge in `package.json` unter `"scripts"` hinzu:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
||||||
|
```
|
||||||
|
|
||||||
|
Und in `src-tauri/Cargo.toml` unter `exclude`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 — TS-Wrapper `src/engine/truckSolid.ts`
|
||||||
|
|
||||||
|
Gleiche Lade-Pattern wie `src/plan/useWasmPlanRenderer.ts` (render2d) und
|
||||||
|
`src/viewport/useWasm3dRenderer.ts` (render3d):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Lazy-Singleton: WASM einmalig laden, dann gecacht.
|
||||||
|
let modulePromise: Promise<typeof import("../../engine/pkgTruck/trucksolid")> | null = null;
|
||||||
|
|
||||||
|
async function getModule() {
|
||||||
|
if (!modulePromise) {
|
||||||
|
modulePromise = import("../../engine/pkgTruck/trucksolid").then(async (m) => {
|
||||||
|
await m.default(); // WASM-Binary initialisieren
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return modulePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtrudedMesh {
|
||||||
|
positions: number[];
|
||||||
|
indices: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrudiert ein geschlossenes Polygon-Profil (Modell-Meter XY) um `height` m. */
|
||||||
|
export async function extrudePolygon(
|
||||||
|
points: number[], // flat [x0,y0, x1,y1, ...]
|
||||||
|
height: number,
|
||||||
|
): Promise<ExtrudedMesh> {
|
||||||
|
const m = await getModule();
|
||||||
|
const json = m.extrude_polygon(JSON.stringify({ points, height }));
|
||||||
|
return JSON.parse(json) as ExtrudedMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extrudiert einen Kreis-Querschnitt (Zylinder). */
|
||||||
|
export async function extrudeCircle(
|
||||||
|
cx: number, cy: number, r: number, height: number,
|
||||||
|
): Promise<ExtrudedMesh> {
|
||||||
|
const m = await getModule();
|
||||||
|
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height }));
|
||||||
|
return JSON.parse(json) as ExtrudedMesh;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 — Integration in toWalls3d.ts (Vorbereitung)
|
||||||
|
|
||||||
|
In `src/plan/toWalls3d.ts` ist `RMeshKind` bereits definiert:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type RMeshKind = "terrain" | "imported";
|
||||||
|
```
|
||||||
|
|
||||||
|
Erweitere auf `"extrusion"` (damit der Renderer später eine eigene Farbe/Darstellung
|
||||||
|
wählen kann, auch wenn heute noch kein Unterschied besteht):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type RMeshKind = "terrain" | "imported" | "extrusion";
|
||||||
|
```
|
||||||
|
|
||||||
|
Die `emitMeshes()`-Funktion liest bereits `project.drawings2d` und ähnliche Arrays.
|
||||||
|
Für extrudierte Körper wird es später ein `project.extrudedSolids`-Array geben
|
||||||
|
(oder ähnlich — das ist Teil des UI-Folgeauftrags). Die `emitMeshes()`-Erweiterung
|
||||||
|
kommt dann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 — Verifikations-Checkliste
|
||||||
|
|
||||||
|
Bevor du als fertig meldest, müssen alle Punkte grün sein:
|
||||||
|
|
||||||
|
- [ ] `cd src-tauri/trucksolid && cargo test` → alle 5 Tests grün, keine Warnings
|
||||||
|
- [ ] `npm run build:truck` → `src/engine/pkgTruck/trucksolid.js` + `.wasm` erzeugt
|
||||||
|
- [ ] `npx tsc --noEmit` (aus Root) → 0 Fehler
|
||||||
|
- [ ] `npx vitest run` → alle bestehenden Tests weiterhin grün (keine Regression)
|
||||||
|
- [ ] `src-tauri/Cargo.toml` hat `"trucksolid"` im `exclude`-Array
|
||||||
|
- [ ] `package.json` hat `"build:truck"` im `"scripts"`-Block
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 — Was NICHT in diesem Auftrag
|
||||||
|
|
||||||
|
- Kein neues UI/Werkzeug (kommt später)
|
||||||
|
- Kein `truck-modeling` (Boolean-Operationen — instabil upstream)
|
||||||
|
- Kein STEP-Export (kommt in Folgeauftrag wenn Basisschicht steht)
|
||||||
|
- Kein Eintrag in `project.extrudedSolids` oder Store (UI-Auftrag)
|
||||||
|
- Keine Änderungen an render3d, generatePlan, App.tsx
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 — Koordinatensystem-Reminder
|
||||||
|
|
||||||
|
| DOSSIER-Modell | truck (im Code) | render3d (wgpu) |
|
||||||
|
|---|---|---|
|
||||||
|
| X = rechts | X = rechts | X = rechts |
|
||||||
|
| Y = oben (Grundriss) | Y = oben (Grundriss) | Z = oben (Y-up swapped) |
|
||||||
|
| Z = Höhe | Z = Höhe | Y = Höhe |
|
||||||
|
|
||||||
|
`mesh.rs` in render3d macht bereits `(model.x, model.y, model.z) → (x, z, y)` beim
|
||||||
|
Hochladen. Du musst also in truck DOSSIER-Koordinaten verwenden (XY-Ebene = Grundriss,
|
||||||
|
Z = Höhe) — das ist konsistent mit der bestehenden `MeshInput.positions`-Konvention.
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
# Design — Prioritätsbasierte mehrschichtige Wand-Verschneidung (T/X)
|
||||||
|
|
||||||
|
> Teil der Standalone-Architektur — siehe [../../ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||||
|
> Aufbauend auf der bestehenden **L-Ecken-Gehrung** in `src/model/joins.ts`
|
||||||
|
> (`computeJoins`, `miterLine`) und `src/model/geometry.ts` (`clippedBand`,
|
||||||
|
> `lineIntersect`). Plan-Verbraucher: `src/plan/generatePlan.ts` (`addWallPoche`).
|
||||||
|
> Bezeichner **englisch**, Prosa deutsch, Einheiten **Meter**.
|
||||||
|
|
||||||
|
> **Hinweis Referenz:** Die im Auftrag genannte DOSSIER-Datei
|
||||||
|
> `rhino/wand_grips.py` ist in dieser Umgebung **nicht vorhanden** (das einzige
|
||||||
|
> auffindbare `dossier` ist ein Typst-Portfolio, kein Rhino-Plugin). Das Design
|
||||||
|
> stützt sich daher auf (a) den bestehenden Code, (b) die in CONVENTIONS.md/elements.md
|
||||||
|
> festgehaltenen Geometrie-Konventionen und (c) die etablierte Bau-Semantik der
|
||||||
|
> prioritätsbasierten Schichtverschneidung (Vectorworks „Komponenten-Verbindung",
|
||||||
|
> Revit „Layer Priority", ArchiCAD „Composite Priority"). Sobald `wand_grips.py`
|
||||||
|
> verfügbar ist, sollte Abschnitt 7 (Priorität/Backbone) gegen DOSSIERs konkrete
|
||||||
|
> Rangregel abgeglichen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Problem & Leitbild
|
||||||
|
|
||||||
|
Heute (`computeJoins`) wird **nur die L-Ecke** behandelt: genau zwei Wandenden
|
||||||
|
treffen sich in einem Knoten, eine gemeinsame **Gehrungslinie** (`miterLine`)
|
||||||
|
schneidet beide Wände sauber. T-/X-Stöße (≥ 3 Enden) bleiben rechtwinklig
|
||||||
|
gekappt (`if (ends.length !== 2) continue;`) — die durchgehende Wand wird von der
|
||||||
|
ankommenden Wand nicht durchdrungen, und die Schichten überlappen sich oder
|
||||||
|
klaffen.
|
||||||
|
|
||||||
|
**Ziel:** An jedem Knoten — L (2 Enden), T (3), X/Kreuz (4+) — soll **jede
|
||||||
|
Schicht jeder Wand** genau bis zu der Fläche reichen, die ihre Verschneidungs-
|
||||||
|
Semantik vorgibt:
|
||||||
|
|
||||||
|
- Die **gemeinsame, höchstpriorisierte Schicht** (Backbone, z. B. Stahlbeton)
|
||||||
|
läuft **durch** den Stoß.
|
||||||
|
- **Niederpriorisierte Schichten** (z. B. Putz, Dämmung) **stoßen** an die nächst-
|
||||||
|
höhere Schicht des Nachbarn und **enden** dort.
|
||||||
|
- Putz **läuft auf jeder Seite** bis zur Betonfläche und endet dort; Putz
|
||||||
|
**überquert nie** den Beton (kanonisches Beispiel, siehe §7.1).
|
||||||
|
|
||||||
|
**Architektur-Prinzip (CONVENTIONS.md):** Das semantische Modell ist die einzige
|
||||||
|
Wahrheit. Die Verschneidung ist eine **reine Ableitung** (`Project + Wall[]` →
|
||||||
|
Trimm-Linien je Schicht-Band), nichts wird in die Geometrie eingebacken. Sie wird
|
||||||
|
beim Generieren des Plans angewandt und ist **deterministisch** und **idempotent**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Geometrie-Konventionen (Wiederholung, verbindlich)
|
||||||
|
|
||||||
|
Aus CONVENTIONS.md und `geometry.ts`:
|
||||||
|
|
||||||
|
- Achsrichtung `u = normalize(end - start)`.
|
||||||
|
- Wand-Normale `n = leftNormal(u) = (-u.y, u.x)`.
|
||||||
|
- Schichten werden **außen → innen** gestapelt: Offset entlang `n` läuft von
|
||||||
|
`-T/2` (linke/„außen"-Kante) nach `+T/2` (rechte/„innen"-Kante). Eine Schicht `k`
|
||||||
|
belegt das Intervall `[off_k, off_k + thickness_k]` mit `off_0 = -T/2`.
|
||||||
|
- Eine **unendliche Gerade** ist `Line { point, dir }`; Schnittpunkt zweier
|
||||||
|
Geraden via `lineIntersect(a, da, b, db)` (null bei parallel).
|
||||||
|
- Ein Schicht-**Band** zwischen Offsets `offA, offB` wird über `clippedBand(start,
|
||||||
|
end, offA, offB, startCut, endCut)` gebildet; jede Bandlängskante wird mit einer
|
||||||
|
optionalen Schnittlinie verschnitten statt rechtwinklig gekappt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Datenmodell — was schon da ist, was neu kommt
|
||||||
|
|
||||||
|
### 2.1 Vorhanden (`types.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface Component { …; joinPriority: number; } // höher = läuft am Stoß durch
|
||||||
|
interface Layer { componentId: string; thickness: number; }
|
||||||
|
interface WallType { id; name; layers: Layer[]; } // layers: außen → innen
|
||||||
|
interface Wall { start: Vec2; end: Vec2; wallTypeId; height; … }
|
||||||
|
```
|
||||||
|
|
||||||
|
`joinPriority` ist bereits **pro Bauteil (Component)** definiert — genau richtig:
|
||||||
|
Priorität ist eine **Material-Eigenschaft**, nicht eine Schicht-Eigenschaft. Damit
|
||||||
|
verschneidet sich Beton mit Beton unabhängig vom Wandtyp.
|
||||||
|
|
||||||
|
### 2.2 Neue, abgeleitete Strukturen (keine neuen persistenten Felder)
|
||||||
|
|
||||||
|
Die heutige `WallCuts`-Struktur (eine Schnittlinie je **Wandende**) reicht für L
|
||||||
|
(Gehrung) — aber **nicht** für T/X, weil dort verschiedene Schichten **verschiedene**
|
||||||
|
Trimm-Linien brauchen (Beton läuft durch, Putz stoppt früher). Wir erweitern auf
|
||||||
|
**pro Schicht, pro Ende** eine eigene Trimm-Linie:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/model/joins.ts (erweitert)
|
||||||
|
|
||||||
|
/** Eine gerichtete Halbkante eines Wandendes an einem Knoten. */
|
||||||
|
interface WallEnd {
|
||||||
|
wallId: string;
|
||||||
|
end: "start" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimm-Linie + Klassifikation für GENAU EIN Schicht-Band an EINEM Ende. */
|
||||||
|
interface LayerCut {
|
||||||
|
/** Schnittgerade in Weltkoordinaten; null = rechtwinklig kappen (Default). */
|
||||||
|
line: Line | null;
|
||||||
|
/**
|
||||||
|
* "miter" – Gehrung (L): geteilte Diagonale mit dem Nachbarn.
|
||||||
|
* "butt" – Band stößt stumpf an eine Nachbarfläche (niedrigere Priorität).
|
||||||
|
* "through" – Band läuft durch den Knoten (höchste Priorität / Backbone).
|
||||||
|
* "square" – freies Ende, rechtwinklig (line === null).
|
||||||
|
*/
|
||||||
|
kind: "miter" | "butt" | "through" | "square";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pro Wandende: eine Trimm-Linie je Schicht-Index (parallel zu WallType.layers). */
|
||||||
|
interface EndCuts {
|
||||||
|
/** layerCuts[k] gilt für layers[k]; Länge === wt.layers.length. */
|
||||||
|
layerCuts: LayerCut[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt die alte WallCuts: jetzt schichtweise an beiden Enden. */
|
||||||
|
interface WallJoin {
|
||||||
|
start: EndCuts;
|
||||||
|
end: EndCuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JoinMap = Map<string /*wallId*/, WallJoin>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Abwärtskompatibilität:** Die alte L-Gehrung ist der Spezialfall „alle
|
||||||
|
`layerCuts[k].line` an einem Ende sind dieselbe `miter`-Linie". `clippedBand`
|
||||||
|
bleibt unverändert; der Plan-Generator ruft es jetzt **je Schicht mit der
|
||||||
|
schichtspezifischen Linie** auf (siehe §8).
|
||||||
|
|
||||||
|
### 2.3 Knoten-Repräsentation
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Ein Verschneidungsknoten: alle Wandenden, die sich (gerundet) berühren. */
|
||||||
|
interface Junction {
|
||||||
|
key: string; // roundKey(p)
|
||||||
|
p: Vec2; // Knotenposition (Mittel der Enden)
|
||||||
|
arms: Arm[]; // sortiert nach Außenwinkel (CCW)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein „Arm" = ein Wandende, gesehen als Strahl, der vom Knoten WEGzeigt. */
|
||||||
|
interface Arm {
|
||||||
|
we: WallEnd;
|
||||||
|
wall: Wall;
|
||||||
|
/** Richtung VOM Knoten weg in die Wand hinein (immer normiert). */
|
||||||
|
dirOut: Vec2; // = end==="start" ? +u : -u
|
||||||
|
/** Außenwinkel atan2(dirOut.y, dirOut.x) für Sortierung. */
|
||||||
|
angle: number;
|
||||||
|
total: number; // Gesamtdicke der Wand
|
||||||
|
/** Schicht-Profil, vom Knoten aus gesehen (siehe §3.2). */
|
||||||
|
layers: ArmLayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Schicht eines Arms, mit ihren beiden Längs-Flächengeraden am Knoten. */
|
||||||
|
interface ArmLayer {
|
||||||
|
index: number; // Index in wt.layers
|
||||||
|
componentId: string;
|
||||||
|
priority: number; // Component.joinPriority
|
||||||
|
/** Offsets entlang n der Wand: [innerEdge..outerEdge] des Bandes. */
|
||||||
|
off0: number; off1: number;
|
||||||
|
/** Die zwei Längsflächen als Geraden (point am Knoten, dir = u der Wand). */
|
||||||
|
faceA: Line; faceB: Line;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Knotenerkennung (Junction Detection)
|
||||||
|
|
||||||
|
### 3.1 Knoten clustern
|
||||||
|
|
||||||
|
Identisch zur heutigen Logik, nur ohne die `length !== 2`-Abbruchbedingung:
|
||||||
|
|
||||||
|
```
|
||||||
|
function buildJunctions(walls): Junction[]
|
||||||
|
map = Map<key, WallEnd[]>
|
||||||
|
for w in walls:
|
||||||
|
map.push(roundKey(w.start), {wallId:w.id, end:"start"})
|
||||||
|
map.push(roundKey(w.end), {wallId:w.id, end:"end"})
|
||||||
|
out = []
|
||||||
|
for (key, ends) in map:
|
||||||
|
if ends.length < 2: continue // freies Ende → alle Schichten "square"
|
||||||
|
arms = ends.map(buildArm)
|
||||||
|
arms.sort(by angle) // CCW um den Knoten
|
||||||
|
out.push({ key, p: avgEndPoint(ends), arms })
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
`roundKey` (vorhanden) gruppiert Endpunkte auf ein 0.1-mm-Gitter. **Wichtig für
|
||||||
|
T-Stöße:** Bei einem echten T endet die ankommende Wand **auf der Achse** der
|
||||||
|
durchgehenden Wand, nicht an deren Endpunkt. Solche Knoten werden über die
|
||||||
|
Endpunkt-Gruppierung **nicht** gefunden, wenn die durchgehende Wand dort kein Ende
|
||||||
|
hat. Daher zusätzlich (siehe §3.3) eine **Achs-Auf-Achs-Inzidenz**.
|
||||||
|
|
||||||
|
### 3.2 Arm-Schichtprofil (kanonische Orientierung)
|
||||||
|
|
||||||
|
Damit Schichten zweier Arme vergleichbar sind, muss jeder Arm seine Schichten in
|
||||||
|
**konsistenter Welt-Orientierung** kennen. Wir speichern je Schicht ihre beiden
|
||||||
|
**Längsflächen** als Geraden mit Stützpunkt am Knoten und Richtung `u`:
|
||||||
|
|
||||||
|
```
|
||||||
|
function buildArm(we): Arm
|
||||||
|
wall = byId(we.wallId); u = dirOf(wall); n = leftNormal(u)
|
||||||
|
dirOut = we.end==="start" ? u : negate(u) // vom Knoten in die Wand
|
||||||
|
j = we.end==="start" ? wall.start : wall.end
|
||||||
|
total = wallTypeThickness(wt)
|
||||||
|
off = -total/2
|
||||||
|
layers = []
|
||||||
|
for (k, layer) in wt.layers:
|
||||||
|
off0 = off; off1 = off + layer.thickness
|
||||||
|
faceA = { point: j + n*off0, dir: u }
|
||||||
|
faceB = { point: j + n*off1, dir: u }
|
||||||
|
layers.push({ index:k, componentId, priority, off0, off1, faceA, faceB })
|
||||||
|
off = off1
|
||||||
|
return { we, wall, dirOut, angle: atan2(dirOut), total, layers }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 T-Stoß-Erkennung (Achs-auf-Achs)
|
||||||
|
|
||||||
|
Zusätzlich zu Endpunkt-Clustern: ein Wandende `e` (Punkt `P`) bildet einen
|
||||||
|
**T-Stoß** mit Wand `B`, wenn `P` (innerhalb Toleranz) auf der **Strecke** `B.start
|
||||||
|
→ B.end` liegt, aber **nicht** auf deren Endpunkten:
|
||||||
|
|
||||||
|
```
|
||||||
|
function findTeeIncidences(walls):
|
||||||
|
for endpoint P of each wall A (as WallEnd e):
|
||||||
|
for each wall B != A:
|
||||||
|
if pointOnSegment(P, B.start, B.end, tol) and not nearEndpoint(P, B):
|
||||||
|
// virtueller Knoten: A endet, B läuft durch.
|
||||||
|
registerTee(P, armOf(e), passThroughWall=B)
|
||||||
|
```
|
||||||
|
|
||||||
|
`B` wird hier als **durchgehender Strang** behandelt (kein Ende am Knoten); im
|
||||||
|
Junction-Modell taucht `B` als zwei kollineare „Arme" auf (Richtung `+u` und
|
||||||
|
`−u`), die der Prioritätsalgorithmus (§5) automatisch als „durchlaufend" erkennt
|
||||||
|
(zwei kollineare Arme gleicher Wand → ihre Schichten enden nie gegeneinander).
|
||||||
|
|
||||||
|
**MVP-Vereinfachung (Phase 1, §10):** T-Stöße zunächst NUR über koinzidente
|
||||||
|
Endpunkte (B hat dort tatsächlich einen Eckpunkt, z. B. weil die Wand dort geteilt
|
||||||
|
wurde). Echte Achs-auf-Achs-T-Stöße (B durchgehend) folgen in Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Winkel-Sektoren & Nachbar-Flächen
|
||||||
|
|
||||||
|
Für die Prioritätsauflösung muss man wissen, **welche Fläche eines Arms welcher
|
||||||
|
Fläche des Nachbarn gegenübersteht**. Die Arme sind CCW nach `angle` sortiert.
|
||||||
|
Zwischen zwei aufeinanderfolgenden Armen `arms[i]` und `arms[i+1]` (zyklisch) liegt
|
||||||
|
ein **Sektor** (Keil). Jeder Sektor wird von **einer Längsfläche jedes der beiden
|
||||||
|
Arme** begrenzt:
|
||||||
|
|
||||||
|
```
|
||||||
|
arm[i+1]
|
||||||
|
\ Sektor S_i
|
||||||
|
\ /
|
||||||
|
faceR(i+1)\ ___ faceL(i)
|
||||||
|
X (Knoten)
|
||||||
|
/
|
||||||
|
/
|
||||||
|
arm[i]
|
||||||
|
```
|
||||||
|
|
||||||
|
Konvention: pro Arm hat die **äußere** Schicht (Index 0, Offset `-T/2`) die Fläche,
|
||||||
|
die in den **CCW-vorausgehenden** Sektor zeigt; die **innere** Schicht den
|
||||||
|
**nachfolgenden**. Konkret bestimmen wir die „dem Sektor zugewandte" Fläche jeder
|
||||||
|
Schicht über das Vorzeichen von `cross(dirOut, sektorrichtung)` — analog zur
|
||||||
|
`lbCloser`-Heuristik in `miterLine`, aber pro Sektor statt global.
|
||||||
|
|
||||||
|
```
|
||||||
|
function sectorFaces(armLeft, armRight):
|
||||||
|
// armRight ist CCW vor armLeft (Sektor liegt zwischen ihnen).
|
||||||
|
// Wähle für jeden Arm die Schicht-Flächen, die in den Sektor zeigen.
|
||||||
|
faceOf(arm, towards): pick faceA or faceB of each layer by sign of
|
||||||
|
cross(arm.dirOut, towards - knoten)
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Sektor-Sicht verallgemeinert die bestehende `miterLine`: bei genau zwei
|
||||||
|
Armen (L) gibt es zwei Sektoren (innen/außen), und die Mittel-Gehrungslinie ergibt
|
||||||
|
sich wie bisher aus dem Schnitt der gegenüberliegenden Außen- bzw. Innenflächen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Prioritätsauflösung — das Kernstück
|
||||||
|
|
||||||
|
### 5.1 Idee
|
||||||
|
|
||||||
|
An einem Knoten konkurrieren Schichten verschiedener Arme um denselben Raum. Regel:
|
||||||
|
|
||||||
|
> Eine Schicht **läuft durch** (`through`), wenn sie zur **höchsten am Knoten
|
||||||
|
> präsenten Priorität** gehört **und** auf der „gegenüberliegenden" Seite eine
|
||||||
|
> Schicht **gleichen Materials** (oder ≥ gleicher Priorität) existiert, an die sie
|
||||||
|
> nahtlos anschließt. Andernfalls **stößt** sie (`butt`) an die nächsthöher-
|
||||||
|
> priorisierte Nachbarfläche und endet dort.
|
||||||
|
|
||||||
|
Praktisch lösen wir das **fläche-gegen-fläche** je Schicht: Für jede Schicht `L`
|
||||||
|
eines Arms suchen wir die **Trimm-Fläche**, die ihr Band beendet. Das ist die
|
||||||
|
**erste** (vom Knoten aus, entlang `dirOut`) der gegenüberliegenden Flächen mit
|
||||||
|
**echt höherer Priorität**; existiert keine, läuft die Schicht bis zur
|
||||||
|
Knoten-Mittelachse durch.
|
||||||
|
|
||||||
|
### 5.2 Pro Schicht: Trimm-Fläche finden
|
||||||
|
|
||||||
|
```
|
||||||
|
function resolveLayerCut(arm, layer, junction): LayerCut
|
||||||
|
// Kandidaten: alle Schichten ALLER ANDEREN Arme, deren Band den
|
||||||
|
// Halbraum dieses Layers überlappt (Offset-Überlapp im gemeinsamen
|
||||||
|
// Sektor) UND deren Priorität die Trimm-Entscheidung bestimmt.
|
||||||
|
candidates = []
|
||||||
|
for other in junction.arms where other.wall.id-end != arm.id-end:
|
||||||
|
for ol in other.layers:
|
||||||
|
if overlapsInSector(layer, ol, arm, other):
|
||||||
|
candidates.push({ other, ol, face: facingFace(ol, towards arm) })
|
||||||
|
|
||||||
|
// Höchste am Knoten präsente Priorität (global, für "through").
|
||||||
|
maxPrio = max over all candidate.ol.priority and layer.priority
|
||||||
|
|
||||||
|
if layer.priority == maxPrio and hasCollinearSamePrio(arm, layer, junction):
|
||||||
|
// Backbone: läuft durch bis zur Mittel-/Gehrungslinie.
|
||||||
|
return { line: miterMidline(arm, layer, junction), kind: "through" }
|
||||||
|
|
||||||
|
// Sonst: stoße an die NÄCHSTE Fläche höherer Priorität entlang dirOut.
|
||||||
|
blockers = candidates.filter(c => c.ol.priority > layer.priority)
|
||||||
|
if blockers.empty:
|
||||||
|
// niemand höher → Gehrung mit dem Nachbarn gleicher Stufe (L-Fall)
|
||||||
|
return { line: miterMidline(arm, layer, junction), kind: "miter" }
|
||||||
|
|
||||||
|
nearest = argmin over blockers of distanceAlong(dirOut, face)
|
||||||
|
return { line: nearest.face, kind: "butt" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Hilfsbegriffe:
|
||||||
|
|
||||||
|
- `overlapsInSector(layer, ol, …)` — projiziert beide Bänder auf die **Normale des
|
||||||
|
Sektors** und prüft Intervall-Überlapp `[off0,off1] ∩ [off0',off1'] ≠ ∅`. Nur
|
||||||
|
überlappende Bänder können sich gegenseitig trimmen.
|
||||||
|
- `facingFace(ol, towards arm)` — die der `arm`-Seite zugewandte Längsfläche von
|
||||||
|
`ol` (die `faceA`/`faceB` von §3.2), als `Line`.
|
||||||
|
- `distanceAlong(dirOut, face)` — Abstand des Schnittpunkts `faceLine ∩ layerAxis`
|
||||||
|
vom Knoten, gemessen entlang `dirOut`. Negative bzw. hinter dem Knoten liegende
|
||||||
|
Treffer werden verworfen (eine Trimm-Fläche muss **vor** dem Band liegen).
|
||||||
|
- `miterMidline(...)` — die gemeinsame Diagonale für gleichrangige Begegnung; für
|
||||||
|
zwei Arme exakt die heutige `miterLine`.
|
||||||
|
- `hasCollinearSamePrio(...)` — true, wenn auf der „anderen Seite" des Knotens eine
|
||||||
|
Schicht **gleichen Materials/Priorität** existiert, in die das Band nahtlos
|
||||||
|
übergeht (Backbone-Kontinuität, §7).
|
||||||
|
|
||||||
|
### 5.3 Resultat in `LayerCut.line` schreiben
|
||||||
|
|
||||||
|
`resolveLayerCut` liefert für `layers[k]` eine `Line | null`. Diese wird in
|
||||||
|
`WallJoin[end].layerCuts[k]` abgelegt. `clippedBand` kappt damit **dieses eine
|
||||||
|
Band** an genau dieser Linie — alle anderen Schichten derselben Wand können andere
|
||||||
|
Linien (oder `null`) haben. Das ist die ganze Verkabelung zum Renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Master-Pseudocode `computeJoins` (neu)
|
||||||
|
|
||||||
|
```
|
||||||
|
function computeJoins(project, walls): JoinMap
|
||||||
|
result = init each wall → { start:{layerCuts:[square…]}, end:{layerCuts:[square…]} }
|
||||||
|
junctions = buildJunctions(walls) // §3.1
|
||||||
|
// (Phase 3: junctions += findTeeIncidences(walls)) // §3.3
|
||||||
|
|
||||||
|
for J in junctions:
|
||||||
|
if J.arms.length == 1: continue // freies Ende: alles "square"
|
||||||
|
if J.arms.length == 2:
|
||||||
|
resolveL(J, project, result) // bestehende Gehrung, schichtweise
|
||||||
|
else:
|
||||||
|
resolveTorX(J, project, result) // §5, pro Arm pro Schicht
|
||||||
|
return result
|
||||||
|
|
||||||
|
function resolveTorX(J, project, result):
|
||||||
|
for arm in J.arms:
|
||||||
|
cuts = []
|
||||||
|
for layer in arm.layers:
|
||||||
|
cuts[layer.index] = resolveLayerCut(arm, layer, J) // §5.2
|
||||||
|
writeEnd(result, arm.we, cuts) // start oder end
|
||||||
|
|
||||||
|
function resolveL(J, project, result):
|
||||||
|
[a, b] = J.arms
|
||||||
|
// Wie heute: eine gemeinsame Gehrungslinie pro Sektor; aber wenn die
|
||||||
|
// Dicken/Schichten ungleich sind, kann pro Schicht über §5.2 verfeinert werden.
|
||||||
|
// MVP: eine miterLine für alle Schichten beider Arme (= heutiges Verhalten,
|
||||||
|
// schichtweise dupliziert).
|
||||||
|
line = miterLine(project, a.wall, a.we.end, b.wall)
|
||||||
|
for arm in [a,b]:
|
||||||
|
cuts = arm.layers.map(_ => ({ line, kind:"miter" }))
|
||||||
|
writeEnd(result, arm.we, cuts)
|
||||||
|
```
|
||||||
|
|
||||||
|
So bleibt die **L-Ecke bit-genau wie heute** (Regressionssicherheit), und die
|
||||||
|
neue Logik greift nur bei ≥ 3 Armen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Prioritäts-Semantik im Detail (das Putz/Beton-Beispiel)
|
||||||
|
|
||||||
|
### 7.1 Kanonisches Beispiel
|
||||||
|
|
||||||
|
Wandtyp „Aussenwand" (außen → innen):
|
||||||
|
|
||||||
|
| k | Component | thickness | joinPriority |
|
||||||
|
|---|------------|-----------|--------------|
|
||||||
|
| 0 | Putz aussen| 0.02 | 1 |
|
||||||
|
| 1 | Stahlbeton | 0.18 | 9 |
|
||||||
|
| 2 | Putz innen | 0.015 | 1 |
|
||||||
|
|
||||||
|
Drei solche Wände treffen in einem T-Knoten (zwei kollinear durchgehend „BB",
|
||||||
|
eine ankommend „A"):
|
||||||
|
|
||||||
|
1. **Beton (prio 9)** der durchgehenden Wände `BB` läuft durch — beide
|
||||||
|
Beton-Bänder sind kollinear gleicher Priorität → `hasCollinearSamePrio` =
|
||||||
|
true → `through`. Der Beton von `A` (prio 9) **stößt** an die **Betonfläche**
|
||||||
|
von `BB` (gegenüberliegende Fläche, gleiche höchste Priorität, aber kein
|
||||||
|
kollinearer Partner für `A`) → `butt` an Betonaußenfläche von `BB`.
|
||||||
|
2. **Putz (prio 1)** jeder Wand stößt an die **erste höherpriorisierte Fläche**
|
||||||
|
entlang `dirOut`. Für die Putzschichten von `A` ist das die **Betonfläche von
|
||||||
|
`BB`**: Putz **läuft auf jeder Seite bis zum Beton** und endet dort (`butt`).
|
||||||
|
3. Putz **überquert nie** den Beton, weil eine `butt`-Trimm-Linie immer die
|
||||||
|
**nächste** höhere Fläche ist — sie liegt vor dem Beton-Durchlauf.
|
||||||
|
|
||||||
|
Ergebnis exakt wie gefordert: *Beton durch, Putz schließt beidseitig an, Putz
|
||||||
|
kreuzt Beton nicht.*
|
||||||
|
|
||||||
|
### 7.2 Warum Priorität pro Component (nicht pro Layer)
|
||||||
|
|
||||||
|
Beton trifft Beton → gleiche Priorität → nahtlos. Würde Priorität pro Schicht
|
||||||
|
vergeben, müsste man sie pro Wandtyp neu pflegen. `joinPriority` am Component löst
|
||||||
|
das materialweise — Stahlbeton hat **immer** 9, egal in welchem Wandtyp.
|
||||||
|
|
||||||
|
### 7.3 Backbone-Erkennung für „through"
|
||||||
|
|
||||||
|
`hasCollinearSamePrio(arm, layer, junction)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
for other in junction.arms where other != arm:
|
||||||
|
if collinear(arm.dirOut, other.dirOut) (antiparallel, gleiche Achse) and
|
||||||
|
other has a layer ol with ol.priority == layer.priority and
|
||||||
|
bandsAlign(layer, ol): // gleiche Offsets relativ zur gemeinsamen Achse
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur wenn das Band auf der **gegenüberliegenden** Achse einen passenden Partner
|
||||||
|
gleicher Priorität und Lage hat, läuft es wirklich **durch**. Sonst (z. B. der
|
||||||
|
ankommende Beton von `A`) **stößt** es an — exakt das gewünschte Verhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Layer-Wrapping (Umschlagen niederpriorisierter Schichten)
|
||||||
|
|
||||||
|
Ein subtiler, aber wichtiger Fall: An einem T-Stoß endet die **innere** Putzschicht
|
||||||
|
der ankommenden Wand `A` an der Betonfläche von `BB`. Damit der Putz **um die Ecke
|
||||||
|
herum sichtbar bleibt** (auf der Innenseite der durchgehenden Wand zieht der innere
|
||||||
|
Putz von `BB` durch), ist nichts Zusätzliches nötig — `BB`s innerer Putz läuft als
|
||||||
|
eigenes durchgehendes Band weiter.
|
||||||
|
|
||||||
|
Wo Wrapping aktiv nötig ist: wenn eine **niederpriorisierte Außenschicht** an einer
|
||||||
|
Ecke **um eine höhere Schicht herumgeführt** werden soll (z. B. Dämmung, die außen
|
||||||
|
um die Stütze läuft). Modellierung:
|
||||||
|
|
||||||
|
- Standard (MVP): kein Wrapping — jede Schicht endet an ihrer Trimm-Fläche
|
||||||
|
(`butt`). Das deckt T/X korrekt ab.
|
||||||
|
- Optional (Phase 4): Ein `wrap`-Flag pro Component (`Component.wrapAtEnds?:
|
||||||
|
boolean`). Ist es gesetzt, erzeugt der Generator an der Trimm-Fläche ein
|
||||||
|
**zusätzliches kurzes Stirnband** quer (Offset-Intervall der Schicht, Länge =
|
||||||
|
Tiefe bis zur nächsten Fläche), sodass die Schicht ihre eigene Stirnseite
|
||||||
|
„umschließt". Geometrisch ein weiteres `clippedBand` mit getauschten Achsen.
|
||||||
|
|
||||||
|
Wrapping ist bewusst **nachgelagert**; es ändert die Trimm-Logik (§5) nicht,
|
||||||
|
sondern fügt nur Zusatzpolygone hinzu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Anbindung an den Plan-Generator (`generatePlan.addWallPoche`)
|
||||||
|
|
||||||
|
Heute ruft `addWallPoche` `clippedBand(p1, p2, off, off+thickness, startCut,
|
||||||
|
endCut)` mit **einer** `WallCuts` je Ende. Neu:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// joins: JoinMap (neu)
|
||||||
|
const join = joins.get(wall.id) ?? emptyJoin(wt.layers.length);
|
||||||
|
…
|
||||||
|
let off = -total / 2;
|
||||||
|
wt.layers.forEach((layer, k) => {
|
||||||
|
const startCut = (s <= 1e-6) ? join.start.layerCuts[k].line : null;
|
||||||
|
const endCut = (e >= axisLen - 1e-6) ? join.end.layerCuts[k].line : null;
|
||||||
|
out.push({ kind:"polygon",
|
||||||
|
pts: clippedBand(p1, p2, off, off + layer.thickness, startCut, endCut),
|
||||||
|
fill: comp.color, hatch: resolveHatch(...), … });
|
||||||
|
off += layer.thickness;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Die **Umrisslinie** über die volle Dicke (`-T/2..+T/2`) nutzt für ihre beiden
|
||||||
|
Kanten die Cuts der **äußersten** bzw. **innersten** Schicht — oder wird, falls
|
||||||
|
Schichten unterschiedlich getrimmt sind, durch eine **abgeleitete Outline**
|
||||||
|
(Vereinigung der Schicht-Polygone, §11) ersetzt. MVP: weiterhin volle-Dicke-Band
|
||||||
|
mit den Cuts von Schicht 0 / letzter Schicht.
|
||||||
|
- `grob`-Detailgrad nutzt nur die **Backbone-Schicht** → ihr `through`/`miter`-Cut
|
||||||
|
für die ganze Sammelfläche (entspricht der bestehenden `backboneColor`-Logik).
|
||||||
|
|
||||||
|
**Keine Signaturänderung an `clippedBand`** — es bleibt der zentrale Trimmer; wir
|
||||||
|
füttern es nur schichtweise mit verschiedenen Linien.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 2D-analytisch jetzt vs. exakte 3D-Booleans später (OCCT)
|
||||||
|
|
||||||
|
### 10.1 Jetzt — 2D-analytisch (dieses Design)
|
||||||
|
|
||||||
|
- **Plan/Grundriss** ist eine reine 2D-Ableitung (vgl. `generatePlan.ts`-Kopf:
|
||||||
|
„nicht durch Zerschneiden eines 3D-Meshes, sondern direkt aus den Parametern").
|
||||||
|
- Trimmen = **Geraden-Schnitt** (`lineIntersect`) + Polygon-Kappen (`clippedBand`).
|
||||||
|
Kein Polygon-Boolean nötig, solange Schichten als **konvexe Bänder** mit
|
||||||
|
schrägen Stirnflächen modelliert werden. Das ist exakt, schnell (O(Arme² ·
|
||||||
|
Schichten) je Knoten), und deterministisch.
|
||||||
|
- **3D-Wand** im Viewport: Extrusion der getrimmten 2D-Schichtpolygone in `z`
|
||||||
|
(Höhe). Damit stimmen Plan und 3D ohne separaten Kernel überein.
|
||||||
|
|
||||||
|
Grenzen der 2D-Analytik: nicht-konvexe Stirnprofile, echte Materialdurchdringung in
|
||||||
|
`z` (z. B. wenn Wände unterschiedlicher Höhe / Sturz / Brüstung interagieren),
|
||||||
|
Verschneidung Wand × Decke × Stütze. Das braucht echte Volumen-Booleans.
|
||||||
|
|
||||||
|
### 10.2 Später — exakte 3D-Booleans (OCCT / opencascade.js)
|
||||||
|
|
||||||
|
- **Bibliothek:** `opencascade.js` (WASM-Port von OCCT) — `BRepAlgoAPI_Cut/Common`,
|
||||||
|
`BRepPrimAPI_MakePrism` für Extrusionen. Alternativ `manifold-3d` (schneller,
|
||||||
|
robuster für reine Mesh-Booleans, aber ohne B-Rep/Fillets).
|
||||||
|
- **Modell bleibt gleich:** Priorität (`joinPriority`) bestimmt die **Cut-
|
||||||
|
Reihenfolge**. Algorithmus: baue je Schicht einen über-langen Solid (Band ×
|
||||||
|
Höhe, über den Knoten hinaus verlängert); subtrahiere von jeder niederpriori-
|
||||||
|
sierten Schicht die Solids **aller höherpriorisierten** Schichten am Knoten
|
||||||
|
(`result = layerSolid − ⋃ higherPrioritySolids`). Gleiche Priorität: kein Cut
|
||||||
|
(Beton bleibt an Beton). Das ist die **3D-Verallgemeinerung exakt derselben
|
||||||
|
Prioritätsregel** — die 2D-`butt`/`through`-Klassifikation aus §5 ist die
|
||||||
|
2D-Projektion dieser Boolean-Reihenfolge.
|
||||||
|
- Die Datenstrukturen (§2) bleiben unverändert; nur der **Trimmer** wird
|
||||||
|
ausgetauscht (`clippedBand` → OCCT-Cut). Deshalb ist das 2D-Design bereits
|
||||||
|
„OCCT-ready".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Robuste Outline (optional, Phase 4)
|
||||||
|
|
||||||
|
Wenn Schichten an einem Knoten unterschiedlich getrimmt sind, ist die „volle
|
||||||
|
Dicke"-Umrisslinie nicht mehr ein einfaches Band. Saubere Lösung: die
|
||||||
|
**Wand-Outline** als **Vereinigung aller Schicht-Polygone** berechnen
|
||||||
|
(Polygon-Boolean in 2D). Empfohlene Library: **`polygon-clipping`** (Martinez-
|
||||||
|
Rueda, robust, klein) oder **`@flatten-js/boolean-op`**. Damit wird der Umriss
|
||||||
|
exakt die Außenkontur der getrimmten Bänder — auch bei X-Knoten. MVP verzichtet
|
||||||
|
darauf und nutzt die Schicht-0/N-Cuts (visuell für die meisten Fälle ausreichend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Edge Cases
|
||||||
|
|
||||||
|
1. **Gleiche Priorität, nicht kollinear** (z. B. zwei Betonwände treffen im rechten
|
||||||
|
Winkel im T): kein „through" (kein kollinearer Partner), aber auch kein
|
||||||
|
`butt`-Blocker höherer Priorität → Fallback **`miter`** (gemeinsame Gehrung wie
|
||||||
|
im L-Fall). Bei drei gleichrangigen Armen: paarweise Gehrung pro Sektor; die
|
||||||
|
resultierenden Stirnflächen sind die Sektor-Halbierenden.
|
||||||
|
2. **Kollinear (180°)** — zwei Wände in einer Linie: `lineIntersect` liefert null
|
||||||
|
(parallel). Behandlung wie heute: **kein Schnitt**, Bänder laufen gerade durch
|
||||||
|
(bei gleichem Typ nahtlos). Bei ungleichem Typ: die schmalere Wand stößt an die
|
||||||
|
breitere; pro Schicht über §5 auflösbar.
|
||||||
|
3. **> 2 Schichten / asymmetrische Wandtypen**: Der Algorithmus ist in der Schicht-
|
||||||
|
anzahl generisch (`resolveLayerCut` läuft je Schicht-Index). Treffen Wände
|
||||||
|
verschiedener Typen (verschiedene Schichtzahl/-dicke), entscheidet **nur die
|
||||||
|
Priorität pro Fläche**, nicht der Index — deshalb arbeiten wir mit
|
||||||
|
`ArmLayer.faceA/faceB` (Welt-Flächen), nicht mit Schicht-Indizes über Wände
|
||||||
|
hinweg.
|
||||||
|
4. **Backbone fehlt** (alle Prioritäten gleich): degeneriert sauber zu reinen
|
||||||
|
Gehrungen (Fall 1).
|
||||||
|
5. **Sehr spitze Winkel**: Trimm-Schnittpunkte können weit vom Knoten wegwandern.
|
||||||
|
Begrenzung: `distanceAlong` auf `≤ maxReach` (z. B. `3 · total`) clampen; sonst
|
||||||
|
rechtwinklig kappen (`square`), um Artefakte zu vermeiden.
|
||||||
|
6. **Öffnungen am Wandende** (`addWallPoche`-Segmentierung): Cuts gelten nur für das
|
||||||
|
echte Wandende (`s≤ε` / `e≥len−ε`), wie heute. Türnahe Segmentenden bleiben
|
||||||
|
`square`.
|
||||||
|
7. **Numerische Knoten-Toleranz**: `roundKey`-Gitter (0.1 mm) und ε in
|
||||||
|
`pointOnSegment` müssen konsistent sein, sonst „flackernde" Knoten. Toleranz
|
||||||
|
zentral als Konstante (`JOIN_EPS = 1e-4` m).
|
||||||
|
8. **Mehr als 2 Wände gleicher Achse** (degenerierter X, alle kollinear): als ein
|
||||||
|
durchgehender Strang behandeln; Prioritätsregel pro Schicht greift normal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Staged Build-Plan
|
||||||
|
|
||||||
|
**Phase 1 — Single-Layer T (Fundament).**
|
||||||
|
- `JoinMap`/`WallJoin`/`EndCuts`/`LayerCut` einführen; `WallCuts` darin als
|
||||||
|
Spezialfall abbilden. `clippedBand` unverändert.
|
||||||
|
- `buildJunctions` ohne `length!==2`-Abbruch; L-Fall ruft bestehende `miterLine`
|
||||||
|
(schichtweise dupliziert) → **Regressionsgleichheit** zu heute.
|
||||||
|
- T-Knoten **nur über koinzidente Endpunkte** (§3.3 MVP). Für **einschichtige**
|
||||||
|
Wände: die durchgehende Wand läuft durch, die ankommende stößt rechtwinklig an
|
||||||
|
deren nächste Fläche. Verifizieren: `npx tsc -b`, `npm run build`,
|
||||||
|
`node scripts/probe.mjs`, Screenshot prüfen.
|
||||||
|
|
||||||
|
**Phase 2 — Prioritäts-Auflösung mehrschichtig (T).**
|
||||||
|
- `ArmLayer`-Profil (§3.2), Sektor-Flächen (§4), `resolveLayerCut` (§5) inkl.
|
||||||
|
`through`/`butt`/`miter`-Klassifikation und `hasCollinearSamePrio`.
|
||||||
|
- `addWallPoche` schichtweise verkabeln (§9). Putz/Beton-Testszene (§7.1) anlegen
|
||||||
|
und visuell prüfen.
|
||||||
|
|
||||||
|
**Phase 3 — X-Knoten & echte Achs-T-Stöße.**
|
||||||
|
- `findTeeIncidences` (Achs-auf-Achs, §3.3): durchgehende Wand wird nicht geteilt.
|
||||||
|
- 4+-Arm-Sektorlogik vollständig; Edge Cases 1/5/8 absichern.
|
||||||
|
|
||||||
|
**Phase 4 — Politur.**
|
||||||
|
- Robuste Outline via Polygon-Boolean (§11); optionales Layer-Wrapping (§8,
|
||||||
|
`Component.wrapAtEnds`).
|
||||||
|
|
||||||
|
**Phase 5 — Exakte 3D-Booleans (OCCT).**
|
||||||
|
- `opencascade.js` integrieren; Trimmer-Interface so abstrahieren, dass 2D
|
||||||
|
(`clippedBand`) und 3D (OCCT-Cut) dieselbe Prioritäts-Reihenfolge nutzen (§10.2).
|
||||||
|
Plan bleibt 2D-analytisch; nur das 3D-Volumen nutzt Booleans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Trimmer-Abstraktion (für §10.2-Migration)
|
||||||
|
|
||||||
|
Damit Phase 5 nicht den Aufrufer ändert, kapseln wir das Trimmen hinter einer
|
||||||
|
Schnittstelle. 2D nutzt `clippedBand`; 3D nutzt OCCT — beide konsumieren dieselbe
|
||||||
|
`JoinMap`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LayerTrimmer {
|
||||||
|
/** 2D: getrimmtes Bandpolygon. 3D-Variante liefert stattdessen einen Solid. */
|
||||||
|
trimBand(p1: Vec2, p2: Vec2, offA: number, offB: number,
|
||||||
|
startCut: Line | null, endCut: Line | null): Vec2[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Prioritätslogik (`computeJoins`) bleibt der **gemeinsame, kernel-unabhängige**
|
||||||
|
Kopf; nur der `LayerTrimmer` wird ausgetauscht. Das hält das Design dem
|
||||||
|
Architektur-Prinzip treu: ein semantisches Modell, viele abgeleitete Ansichten.
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
# WebGL2 GPU-Accelerated 2D Plan Renderer — Architecture
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
A WebGL2 canvas renderer for PlanView's heavy geometry (polygons, lines, hatches) with CPU fallback. Geometry tessellates once per plan and caches; pan/zoom only updates a transform-matrix uniform. Screen-space stroke width via vertex shader normal expansion. Thin SVG overlay handles text, grips, snap markers, tool preview.
|
||||||
|
|
||||||
|
**No npm dependencies** — raw WebGL2 + TypeScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (SVG Bottleneck)
|
||||||
|
|
||||||
|
**PlanView.tsx** renders `Primitive[]` (polygon/line/arc/text) → SVG DOM:
|
||||||
|
- ~2500 LOC: pan/zoom via viewBox, toScreen() scaling (1 meter = 90 viewBox units)
|
||||||
|
- `Primitive` types (generatePlan.ts:133):
|
||||||
|
- **polygon**: `pts: Vec2[]`, fill/stroke/strokeWidthMm, hatch (solid/insulation/diagonal/crosshatch)
|
||||||
|
- **line**: a/b endpoints, className, weightMm, dash[], optional color
|
||||||
|
- **arc**: center, from/to points, r, className, weightMm, dash[]
|
||||||
|
- **text**: anchor, RichTextDoc, roomStamp metadata
|
||||||
|
- Bottleneck: **pan/zoom re-renders entire SVG DOM** → Cairo rasterizes geometry at 144 Hz
|
||||||
|
|
||||||
|
**Key constants:**
|
||||||
|
- `PX_PER_M = 90` (viewBox units per meter; Modell-Y up → SVG-Y down via negation)
|
||||||
|
- `PAD = 60` (margin in viewBox units)
|
||||||
|
- `mmToPx(mm) = (mm / 25.4) * dpi()` (stroke width: constant screen-px via non-scaling-stroke)
|
||||||
|
- `ZOOM_MAX/MIN = 50/0.2` (pan/zoom bounds)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: PlanRenderer (WebGL2 + Fallback SVG)
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/plan/
|
||||||
|
├── PlanRenderer.ts (Main GPU/CPU dispatcher)
|
||||||
|
├── glPlan/
|
||||||
|
│ ├── glPlanCompile.ts (Tessellation & buffer upload)
|
||||||
|
│ ├── glPlanShaders.ts (Vertex/fragment sources + compilation)
|
||||||
|
│ ├── glPlanRender.ts (Draw loop: matrix uniform, state mgmt)
|
||||||
|
│ └── glPlanTypes.ts (TypeScript interfaces for GPU data)
|
||||||
|
└── PlanView.tsx (React wrapper, unchanged API)
|
||||||
|
```
|
||||||
|
|
||||||
|
### High-Level Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
PlanView.tsx
|
||||||
|
↓ [receives plan: Plan]
|
||||||
|
↓
|
||||||
|
PlanRenderer (new abstraction)
|
||||||
|
↓
|
||||||
|
├─→ GPU path [if WebGL2 available && flag=true]
|
||||||
|
│ ├─ glPlanCompile() → upload tessellated geometry to VRAM
|
||||||
|
│ ├─ glPlanRender() → draw with pan/zoom matrix uniform
|
||||||
|
│ └─ [fast pan/zoom via matrix only]
|
||||||
|
│
|
||||||
|
└─→ Fallback: SVG [if WebGL fails || flag=false]
|
||||||
|
└─ existing PlanView render path (toScreen + DOM)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GPU Path: Tessellation & Shaders
|
||||||
|
|
||||||
|
### 1. Tessellation Strategy
|
||||||
|
|
||||||
|
#### **Polygon** → Fans + Ear Clipping
|
||||||
|
- **Input**: Primitive.polygon = { pts: Vec2[], fill, stroke, strokeWidthMm, hatch }
|
||||||
|
- **Output**: Indexed triangle mesh
|
||||||
|
- **Algorithm**: Earcut2D (existing JS library logic, inlined to avoid npm)
|
||||||
|
- Convert polygon pts to 2D float32 array in **world space** (meters)
|
||||||
|
- Earcut → triangle indices
|
||||||
|
- Store: `{ vertices: Float32Array, indices: Uint32Array, color: vec4, hasHatch: bool }`
|
||||||
|
- **Hatch rendering**: Bake hatch as texture or re-implement in fragment shader (MVP: solid fill only; hatch deferred)
|
||||||
|
|
||||||
|
#### **Line** → Quad Expansion (Screen-Space Width)
|
||||||
|
- **Input**: Primitive.line = { a, b, weightMm, cls, dash?, color }
|
||||||
|
- **Output**: Degenerate quad (2 triangles) with screen-space normal offset
|
||||||
|
- **Strategy**:
|
||||||
|
1. Vertex shader receives `{ pos: vec2, side: float }` (side = ±1 for left/right edge)
|
||||||
|
2. Transform pos to clip space via matrix uniform
|
||||||
|
3. Compute screen-space perpendicular via `dFdx/dFdy` or pre-compute normal in CPU
|
||||||
|
4. Expand by `(weightMm / 25.4) * dpi * (screenPixelsPerClipUnit)` in clip space
|
||||||
|
5. Fragment shader: solid color (no dash MVP; dashing deferred or CPU pre-tessellation)
|
||||||
|
|
||||||
|
#### **Arc** → Line Segments (Polyline → Quads)
|
||||||
|
- **Input**: Primitive.arc = { center, from, to, r, weightMm, cls, dash }
|
||||||
|
- **Output**: Tessellate arc to ~30 line segments (adaptive based on radius/zoom), expand each as quad
|
||||||
|
- Fallback: SVG arc for MVP
|
||||||
|
|
||||||
|
#### **Text, Grips, Snap-Markers, Tool-Preview**
|
||||||
|
- **Stays in SVG overlay** (thin, non-bottleneck)
|
||||||
|
- Render above WebGL canvas at z-order 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Shader Sources (GLSL 3.00 ES)
|
||||||
|
|
||||||
|
#### **Vertex Shader: Solid Fill (polygon)**
|
||||||
|
```glsl
|
||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection; // pan/zoom as 2×3 affine (expand to mat4)
|
||||||
|
|
||||||
|
layout(location=0) in vec2 position; // world-space (meters)
|
||||||
|
layout(location=1) in vec4 color; // fill color
|
||||||
|
|
||||||
|
out VS_OUT {
|
||||||
|
flat vec4 vertexColor;
|
||||||
|
} vs_out;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
|
||||||
|
gl_Position = clipPos;
|
||||||
|
vs_out.vertexColor = color;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Vertex Shader: Screen-Space Stroked Line**
|
||||||
|
```glsl
|
||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
uniform mat4 viewProjection; // world → clip space
|
||||||
|
uniform vec2 screenSize; // canvas (width, height) in pixels
|
||||||
|
uniform float strokeWidthMm; // millimeters
|
||||||
|
uniform float dpi; // 96 * devicePixelRatio
|
||||||
|
|
||||||
|
layout(location=0) in vec2 position; // world-space endpoint
|
||||||
|
layout(location=1) in float sideFlag; // ±1.0 (left/right edge)
|
||||||
|
layout(location=2) in vec4 lineColor; // stroke color
|
||||||
|
|
||||||
|
out VS_OUT {
|
||||||
|
flat vec4 vertexColor;
|
||||||
|
} vs_out;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 clipPos = viewProjection * vec4(position, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Convert stroke width (mm) → screen pixels
|
||||||
|
float strokePx = (strokeWidthMm / 25.4) * dpi;
|
||||||
|
// Convert screen pixels → normalized device coords (NDC)
|
||||||
|
// NDC ∈ [-1,1]²; screen (0,screenSize) → NDC [-1,1]
|
||||||
|
float strokeNdc = (strokePx / screenSize.x) * 2.0;
|
||||||
|
|
||||||
|
// Expand in clip space (simple; assumes aspect ≈ 1)
|
||||||
|
vec4 expanded = clipPos + vec4(sideFlag * strokeNdc, 0.0, 0.0, 0.0);
|
||||||
|
|
||||||
|
gl_Position = expanded;
|
||||||
|
vs_out.vertexColor = lineColor;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Fragment Shader (both)**
|
||||||
|
```glsl
|
||||||
|
#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in VS_OUT {
|
||||||
|
flat vec4 vertexColor;
|
||||||
|
} fs_in;
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
fragColor = fs_in.vertexColor;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. GPU Data Structures (TypeScript)
|
||||||
|
|
||||||
|
**glPlanTypes.ts:**
|
||||||
|
```typescript
|
||||||
|
export interface GLGeometryBatch {
|
||||||
|
/** Vertex buffer: interleaved (x, y, [z if 3D], ...) in world space. */
|
||||||
|
vertexBuffer: WebGLBuffer;
|
||||||
|
vertexCount: number;
|
||||||
|
|
||||||
|
/** Index buffer (triangles for fill, degenerate quads for strokes). */
|
||||||
|
indexBuffer: WebGLBuffer;
|
||||||
|
indexCount: number;
|
||||||
|
|
||||||
|
/** Vertex Array Object (VAO) binds VBO + IBO. */
|
||||||
|
vao: WebGLVertexArrayObject;
|
||||||
|
|
||||||
|
/** Per-batch metadata. */
|
||||||
|
batches: Array<{
|
||||||
|
kind: "polygon" | "line" | "arc";
|
||||||
|
indexStart: number;
|
||||||
|
indexCount: number;
|
||||||
|
color: [r: number, g: number, b: number, a: number]; // RGBA [0,1]
|
||||||
|
hasHatch: boolean;
|
||||||
|
hatchPattern?: "solid" | "insulation" | "diagonal" | "crosshatch";
|
||||||
|
strokeWidthMm?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GLPlanRenderState {
|
||||||
|
// Pan/zoom transform: world (meters) → clip space
|
||||||
|
viewMatrix: Matrix3 | Matrix4; // 2×3 affine
|
||||||
|
projMatrix: Matrix4; // orthographic
|
||||||
|
|
||||||
|
// Viewport size & DPI for screen-space stroke width
|
||||||
|
screenWidth: number;
|
||||||
|
screenHeight: number;
|
||||||
|
dpi: number;
|
||||||
|
|
||||||
|
// Compiled shaders
|
||||||
|
solidFillProgram: WebGLProgram;
|
||||||
|
strokeProgram: WebGLProgram;
|
||||||
|
|
||||||
|
// Geometry cache (tessellated once per plan)
|
||||||
|
geometryBatch: GLGeometryBatch | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MVP API: PlanRenderer Class
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class PlanRenderer {
|
||||||
|
/**
|
||||||
|
* Create renderer with WebGL2 context + fallback config.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
options?: {
|
||||||
|
enableGpu?: boolean; // default: true
|
||||||
|
enableGpuFallback?: boolean; // SVG fallback if GL fails
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile and cache geometry from primitives.
|
||||||
|
* Call once per plan change.
|
||||||
|
*/
|
||||||
|
compilePlan(plan: Plan): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set pan/zoom transform matrix.
|
||||||
|
* Call on every view change (pan, zoom, fit).
|
||||||
|
*/
|
||||||
|
setViewMatrix(viewBox: { x, y, w, h }, canvasSize: { w, h }): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one frame: clear, draw batches, composite.
|
||||||
|
* Called from requestAnimationFrame loop.
|
||||||
|
*/
|
||||||
|
render(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release WebGL resources.
|
||||||
|
*/
|
||||||
|
dispose(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query GPU availability / fallback state.
|
||||||
|
*/
|
||||||
|
isGpuReady(): boolean;
|
||||||
|
isFallbackActive(): boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in PlanView
|
||||||
|
|
||||||
|
**Before** (SVG only):
|
||||||
|
```tsx
|
||||||
|
function PlanView({ plan, ... }) {
|
||||||
|
return (
|
||||||
|
<svg ref={svgRef}>
|
||||||
|
<defs>{hatches}</defs>
|
||||||
|
{plan.primitives.map((p, i) => <PrimitiveShape ... />)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (GPU + SVG fallback):
|
||||||
|
```tsx
|
||||||
|
function PlanView({ plan, ... }) {
|
||||||
|
const rendererRef = useRef<PlanRenderer | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
rendererRef.current = new PlanRenderer(canvas, { enableGpu: true });
|
||||||
|
rendererRef.current.compilePlan(plan);
|
||||||
|
}, [plan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rendererRef.current?.setViewMatrix(view, { w: canvasWidth, h: canvasHeight });
|
||||||
|
}, [view, canvasWidth, canvasHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = () => {
|
||||||
|
rendererRef.current?.render();
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
{/* GPU canvas (or SVG fallback if GL unavailable) */}
|
||||||
|
<canvas ref={canvasRef} style={{ position: "absolute" }} />
|
||||||
|
{/* Thin SVG overlay: text, grips, snap-markers, tool preview */}
|
||||||
|
<svg ref={svgRef} style={{ position: "absolute", zIndex: 1 }}>
|
||||||
|
{/* text, grips, snaps only; geometry stays in WebGL */}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow: From Primitives → GPU
|
||||||
|
|
||||||
|
### 1. **Compile Phase** (glPlanCompile.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function compilePlan(gl: WebGL2RenderingContext, plan: Plan): GLGeometryBatch {
|
||||||
|
const batches: BatchInfo[] = [];
|
||||||
|
const vertices: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
let indexOffset = 0;
|
||||||
|
|
||||||
|
for (const prim of plan.primitives) {
|
||||||
|
if (prim.kind === "polygon") {
|
||||||
|
const { verts, inds } = tessellatePolygon(prim.pts);
|
||||||
|
const color = parseColor(prim.fill);
|
||||||
|
batches.push({
|
||||||
|
kind: "polygon",
|
||||||
|
indexStart: indexOffset,
|
||||||
|
indexCount: inds.length,
|
||||||
|
color,
|
||||||
|
hasHatch: prim.hatch.pattern !== "none",
|
||||||
|
hatchPattern: prim.hatch.pattern,
|
||||||
|
});
|
||||||
|
vertices.push(...verts);
|
||||||
|
indices.push(...inds.map((i) => i + indexOffset));
|
||||||
|
indexOffset += verts.length / 2;
|
||||||
|
} else if (prim.kind === "line") {
|
||||||
|
const { verts, inds } = tessellateLineQuad(prim.a, prim.b);
|
||||||
|
const color = parseColor(prim.color || "black");
|
||||||
|
batches.push({
|
||||||
|
kind: "line",
|
||||||
|
indexStart: indexOffset,
|
||||||
|
indexCount: inds.length,
|
||||||
|
color,
|
||||||
|
strokeWidthMm: prim.weightMm,
|
||||||
|
});
|
||||||
|
vertices.push(...verts);
|
||||||
|
indices.push(...inds.map((i) => i + indexOffset));
|
||||||
|
indexOffset += verts.length / 2;
|
||||||
|
}
|
||||||
|
// arc → polyline → quads (deferred for MVP)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vbo = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const ibo = gl.createBuffer()!;
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
||||||
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const vao = gl.createVertexArray()!;
|
||||||
|
gl.bindVertexArray(vao);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||||
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0); // position
|
||||||
|
gl.enableVertexAttribArray(0);
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
|
||||||
|
|
||||||
|
return { vertexBuffer: vbo, indexBuffer: ibo, vao, batches, vertexCount: vertices.length, indexCount: indices.length };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **Render Phase** (glPlanRender.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function renderPlan(
|
||||||
|
gl: WebGL2RenderingContext,
|
||||||
|
state: GLPlanRenderState,
|
||||||
|
batch: GLGeometryBatch
|
||||||
|
): void {
|
||||||
|
gl.clearColor(1, 1, 1, 1); // white background
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.useProgram(state.solidFillProgram);
|
||||||
|
const mvpLoc = gl.getUniformLocation(state.solidFillProgram, "viewProjection");
|
||||||
|
const mvp = mat4.multiply(state.projMatrix, state.viewMatrix);
|
||||||
|
gl.uniformMatrix4fv(mvpLoc, false, mvp);
|
||||||
|
|
||||||
|
gl.bindVertexArray(batch.vao);
|
||||||
|
|
||||||
|
for (const b of batch.batches) {
|
||||||
|
const colorLoc = gl.getUniformLocation(state.solidFillProgram, "vertexColor");
|
||||||
|
gl.uniform4f(colorLoc, b.color[0], b.color[1], b.color[2], b.color[3]);
|
||||||
|
|
||||||
|
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.indexStart * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tessellation Details
|
||||||
|
|
||||||
|
### Earcut (Polygon Triangulation)
|
||||||
|
|
||||||
|
**Inlined earcut logic (no npm):**
|
||||||
|
```typescript
|
||||||
|
function tessellatePolygon(pts: Vec2[]): { verts: number[]; inds: number[] } {
|
||||||
|
// Convert Vec2[] → flat float array
|
||||||
|
const coords = pts.flatMap((p) => [p.x, p.y]);
|
||||||
|
|
||||||
|
// Earcut2D: robust polygon triangulation
|
||||||
|
// → Returns index array (triplets = triangles)
|
||||||
|
const triangles = earcut(coords);
|
||||||
|
|
||||||
|
// Vertex buffer: just positions (x, y) in world space (meters)
|
||||||
|
const verts = coords;
|
||||||
|
|
||||||
|
return { verts, inds: triangles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplified earcut (full version ~200 LOC; reference libtess2 or earcut.js)
|
||||||
|
function earcut(data: number[], hole?: number[], dim?: number): number[] {
|
||||||
|
// ... iterative ear clipping, complexity O(n²) worst-case
|
||||||
|
// Returns Uint32Array of triangle indices
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Line Quad Expansion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function tessellateLineQuad(
|
||||||
|
a: Vec2, b: Vec2,
|
||||||
|
widthMm: number = 0.5
|
||||||
|
): { verts: number[]; inds: number[] } {
|
||||||
|
// World-space endpoints; width (mm) will be expanded in vertex shader
|
||||||
|
|
||||||
|
// Create a degenerate quad: 2 triangles
|
||||||
|
// Vertices: [a_left, a_right, b_left, b_right]
|
||||||
|
// (normal expansion happens in VS)
|
||||||
|
|
||||||
|
const verts = [
|
||||||
|
a.x, a.y, 0.0, // vertex 0: a, left flag
|
||||||
|
a.x, a.y, 1.0, // vertex 1: a, right flag
|
||||||
|
b.x, b.y, 0.0, // vertex 2: b, left flag
|
||||||
|
b.x, b.y, 1.0, // vertex 3: b, right flag
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two triangles: (0, 1, 2) and (1, 3, 2)
|
||||||
|
const inds = [0, 1, 2, 1, 3, 2];
|
||||||
|
|
||||||
|
return { verts, inds };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pan/Zoom Matrix Transform
|
||||||
|
|
||||||
|
### View Box → Clip Space
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildViewMatrix(
|
||||||
|
viewBox: { x, y, w, h },
|
||||||
|
canvasSize: { w, h }
|
||||||
|
): Matrix4 {
|
||||||
|
// 1. World space (meters, origin at model 0,0) → viewBox units (PX_PER_M=90)
|
||||||
|
const scale = PX_PER_M; // 1 meter → 90 viewBox units
|
||||||
|
|
||||||
|
// 2. ViewBox viewport: x,y,w,h in viewBox units → NDC [-1,+1]²
|
||||||
|
// Orthographic projection (no perspective).
|
||||||
|
const ortho = mat4.ortho(
|
||||||
|
viewBox.x,
|
||||||
|
viewBox.x + viewBox.w,
|
||||||
|
viewBox.y,
|
||||||
|
viewBox.y + viewBox.h,
|
||||||
|
-1, 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Scale from viewBox units → world (invert PX_PER_M)
|
||||||
|
const scaleMatrix = mat4.scale(mat4.identity(), [1/scale, 1/scale, 1]);
|
||||||
|
|
||||||
|
return mat4.multiply(ortho, scaleMatrix);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Whenever PlanView calls `setView(viewBox)` or `onWheel()` → call `setViewMatrix()` → GPU re-renders with new matrix uniform (no tessellation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallback Strategy: SVG Renderer Flag
|
||||||
|
|
||||||
|
**Global flag** in PlanView or app state:
|
||||||
|
```typescript
|
||||||
|
const [useGpuRenderer, setUseGpuRenderer] = useState(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render path branching:**
|
||||||
|
```typescript
|
||||||
|
return useGpuRenderer && rendererRef.current?.isGpuReady()
|
||||||
|
? <canvas ref={canvasRef} />
|
||||||
|
: <svg ref={svgRef}>{/* existing SVG rendering */}</svg>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**When GL fails** (e.g., no WebGL2 support, Out-Of-Memory):
|
||||||
|
1. Renderer catches error in `compilePlan()`
|
||||||
|
2. Sets internal `fallbackActive = true`
|
||||||
|
3. Returns gracefully (app renders SVG path instead)
|
||||||
|
4. User sees same plan, slower but functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order (MVP → Iteration)
|
||||||
|
|
||||||
|
### Phase 1: Core (Week 1)
|
||||||
|
1. **glPlanTypes.ts** — TypeScript interfaces for GPU state
|
||||||
|
2. **glPlanShaders.ts** — Compile vertex/fragment shaders, handle GL errors
|
||||||
|
3. **glPlanCompile.ts** — Tessellation (earcut inlined), buffer upload
|
||||||
|
4. **glPlanRender.ts** — Draw loop, matrix uniform, clear/present
|
||||||
|
5. **PlanRenderer.ts** — Main class, dispatcher (GPU vs SVG fallback)
|
||||||
|
6. **PlanView.tsx** — Wire renderer, canvas overlay, canvas lifecycle
|
||||||
|
|
||||||
|
### Phase 2: Hatches & Lines (Week 2)
|
||||||
|
- Improve line tessellation: proper screen-space width (dFdx/dFdy or pre-computed normals)
|
||||||
|
- Hatch patterns: texture-based or procedural fragment shader (diagonal/insulation)
|
||||||
|
- Arc tessellation: polyline → quads
|
||||||
|
|
||||||
|
### Phase 3: Polish (Week 3)
|
||||||
|
- Stroke dashing via geometry or fragment shader
|
||||||
|
- Greyed opacity blending
|
||||||
|
- Hit testing integration (point-in-triangle for GPU)
|
||||||
|
- Performance profiling, batch merging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Targets
|
||||||
|
|
||||||
|
| Operation | SVG (Current) | GPU (Target) | Notes |
|
||||||
|
|-----------|---------------|--------------|-------|
|
||||||
|
| **Tessellation** | — | 10–50 ms | Once per plan |
|
||||||
|
| **Pan/Zoom 60 Hz** | 16 ms (re-render SVG) | <1 ms (matrix uniform) | Matrix upload negligible |
|
||||||
|
| **Pan/Zoom 144 Hz** | 7 ms (bottleneck) | <0.5 ms | 28× speedup expected |
|
||||||
|
| **Geometry: 1000 polygons** | 50–100 ms SVG render | 1–5 ms GPU draw | CPU tessellation pipelined |
|
||||||
|
|
||||||
|
User: AMD RX 7800 XT → easily capable of 4K+ geometry at 144 Hz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Deferred Items (Post-MVP)
|
||||||
|
|
||||||
|
- **Hatches**: Solid fill only MVP; insulation/diagonal/crosshatch in Phase 2 via texture or procedural shader
|
||||||
|
- **Dashing**: Not in MVP (complex with screen-space strokes); either CPU pre-tessellation or fragment shader alpha-discard
|
||||||
|
- **Arcs**: Fallback to SVG for MVP; GPU polyline expansion in Phase 2
|
||||||
|
- **Text, Grips, Snaps**: Stay in SVG overlay indefinitely (no GPU benefit; text rendering nontrivial)
|
||||||
|
- **Hit Testing**: Keep in CPU/SVG for MVP; GPU pick-buffer deferred
|
||||||
|
- **Color/Opacity Blending**: Basic for MVP; advanced (multiply, screen, dodge) deferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Earcut.js**: https://github.com/mapbox/earcut — polygon triangulation (logic to inline)
|
||||||
|
- **three.js line expansion**: https://github.com/mrdoob/three.js/blob/master/src/renderers/webgl/WebGLGeometries.js
|
||||||
|
- **OpenGL Perspective Division**: https://en.wikibooks.org/wiki/OpenGL_Programming/Modern_OpenGL_Tutorial_Polygon_offset
|
||||||
|
- **Screen-Space Stroke Width**: https://forum.libcinder.org/topic/smooth-line-rendering-using-geometry-shaders
|
||||||
|
- PlanView source: `/home/karim/cad/src/plan/PlanView.tsx` (2500 LOC)
|
||||||
|
- Primitive types: `/home/karim/cad/src/plan/generatePlan.ts:133`
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
|
||||||
|
|
||||||
|
> Für den Entwickler, der den nativen GPU-Renderer baut. Isoliert vom
|
||||||
|
> Web-Renderer; koordiniert über dieses Dokument.
|
||||||
|
|
||||||
|
## COMMIT-REGEL (verbindlich)
|
||||||
|
Dieses Repo darf **keinerlei Fremd-Tool-Hinweise** enthalten — nicht im Code,
|
||||||
|
in Kommentaren oder in der Git-Historie. Kommentare deutsch, Identifier englisch.
|
||||||
|
**Kein Commit ohne Absprache.**
|
||||||
|
|
||||||
|
## Warum
|
||||||
|
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
|
||||||
|
ist das 144-Hz-flüssig, aber **Tauris Linux-Webview = WebKitGTK bremst** (langsamer
|
||||||
|
Compositor, vermutlich 60-Hz-rAF-Cap). Bewiesen: dieselbe App im `chromium --app`-
|
||||||
|
Fenster = butterweich, im Tauri-Fenster = zäh — trotz imperativem Pan, CSS-transform-
|
||||||
|
Overlay, DMABUF-Tweaks. Siehe Memo `webkitgtk-bottleneck`.
|
||||||
|
|
||||||
|
**Lösung:** die schwere Grafik **nativ mit wgpu** rendern (Rust), die Webview macht
|
||||||
|
nur noch UI-Chrome (Panels/Buttons). So bleibt Tauri (natives Rust-Backend, winziges
|
||||||
|
Bundle) UND wir umgehen den Webview-Compositor komplett.
|
||||||
|
|
||||||
|
## Referenz-Implementierung (NICHT wegwerfen — 1:1 portierbar)
|
||||||
|
Der WebGL-Renderer unter `src/plan/glPlan/` ist die **arbeitende Referenz**. Die
|
||||||
|
harte Arbeit ist dort schon gelöst und portiert konzeptuell direkt nach wgpu:
|
||||||
|
- `glPlanCompile.ts` — **Earcut-Triangulierung** (konkav-fähig), Linien→Quads mit
|
||||||
|
Normale+Seiten-Flag, Batch-Merging, Farb-Parsing. Unit-Tests: `glPlanCompile.test.ts`.
|
||||||
|
- `glPlanShaders.ts` — Vertex/Fragment (GLSL 300 es). **WGSL ≈ GLSL** — direkt übersetzbar.
|
||||||
|
- Füll-VS: Bildschirm-Position → Clip via `viewProj`-Matrix.
|
||||||
|
- Linien-VS: bildschirmkonstante bzw. papier-mm-Breite via Normalen-Offset im Clip
|
||||||
|
(siehe `strokeScale`/`strokePx`-Logik — Papier-mm × Massstab N × meet-Skala).
|
||||||
|
- `glPlanRender.ts` — Ortho-Matrix (aspekt-korrekt = SVG `xMidYMid meet`), Fill-/Line-
|
||||||
|
Pass, `computeOrthoMatrix`.
|
||||||
|
- Koordinaten-Konvention: BILDSCHIRM-Raum `sx = mx·PX_PER_M, sy = -my·PX_PER_M`
|
||||||
|
(`PX_PER_M=90`), Modell-Y hoch → Bildschirm-Y runter.
|
||||||
|
- Strichbreite = **echte Papier-mm im Massstab**: `Breite_px = mm · N/1000 · PX_PER_M ·
|
||||||
|
meetSkala` (repliziert SVG `printStrokeVb`). Am Beispiel 0.35 mm @ 1:100 = 0.35 mm.
|
||||||
|
|
||||||
|
Die **Daten** kommen aus `src/plan/generatePlan.ts` → `Plan.primitives` (Union-Typ
|
||||||
|
`Primitive`: polygon/line/arc/text). Text bleibt Overlay (nicht wgpu).
|
||||||
|
|
||||||
|
## Die EINE harte Frage (zuerst spiken/recherchieren)
|
||||||
|
**Wie rendert wgpu in das Tauri-Fenster neben der Webview?** Optionen recherchieren:
|
||||||
|
1. Natives Child-Surface unter der Webview (raw-window-handle), Webview transparent
|
||||||
|
drüber für UI-Chrome. Vermutlich der Zielweg.
|
||||||
|
2. Eigenes wgpu-Fenster (entkoppelt) — nur für den Spike, um Rendering von der
|
||||||
|
Fenster-Integration zu trennen.
|
||||||
|
3. wgpu→Textur→Webview — verwirft den Zweck (Copy-Overhead), nur Notnagel.
|
||||||
|
|
||||||
|
**Empfehlung:** Rendering-Spike ZUERST entkoppelt (Option 2, standalone `winit`+wgpu-
|
||||||
|
Fenster), das die `Primitive` als gefüllte Polygone + Striche zeichnet und Pan/Zoom
|
||||||
|
per Matrix-Uniform macht. Fenster-Integration in Tauri ist der zweite, separate Schritt.
|
||||||
|
|
||||||
|
## Meilensteine
|
||||||
|
- **M0 (Research):** kurzer Bericht `docs/design/wgpu-integration-findings.md` — wie
|
||||||
|
wgpu-Surface + Tauri/webview koexistieren (raw-window-handle, Transparenz, Z-Order,
|
||||||
|
Input-Routing). Quellen verlinken.
|
||||||
|
- **M1 (Rendering-Spike, standalone):** neue Crate `src-tauri/render2d/` (serde-Input
|
||||||
|
= geflachte Primitive), wgpu + WGSL:
|
||||||
|
- Earcut-Port (oder `lyon`/`earcutr` Crate evaluieren) → Dreiecke.
|
||||||
|
- Füll-Pipeline (gefüllte Polygone, Ortho-Matrix-Uniform).
|
||||||
|
- Linien-Pipeline (Quad-Expansion, echte Papier-mm-Breite).
|
||||||
|
- `cargo test` für die Tessellierung (Muster: `src-tauri/geometry` hat 4/4 Tests —
|
||||||
|
z. B. konkaves L flächentreu, wie `glPlanCompile.test.ts`).
|
||||||
|
- `cargo build` grün. (Visuelle Fenster-Verifikation braucht Display → an Mensch/
|
||||||
|
Display-Session übergeben; im Bericht vermerken.)
|
||||||
|
- **M2 (Tauri-Integration):** Surface unter die Webview, Pan/Zoom-Input aus der
|
||||||
|
Webview an den Renderer, Parität mit dem WebGL-Pfad messen (144 Hz).
|
||||||
|
- **M3:** Schraffur, Bögen, Auswahl-Highlight; danach 3D (three.js→wgpu).
|
||||||
|
|
||||||
|
## Koordination (WICHTIG)
|
||||||
|
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
|
||||||
|
`feature/parametric-walls` committen. Isolierter Worktree.
|
||||||
|
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
|
||||||
|
`src/plan/*`, `types.ts` anfassen (dort laufen parallel Features).
|
||||||
|
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
|
||||||
|
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
|
||||||
|
|
||||||
|
## Gates
|
||||||
|
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
|
||||||
|
`npx tsc -b` + `npm run build` müssen unberührt grün bleiben (keine Web-Änderungen).
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# Briefing — Nativer wgpu-3D-Renderer (M0 Design + M1 Spike)
|
||||||
|
|
||||||
|
> Schwester-Dokument zu `wgpu-2d-renderer-briefing.md`. Beschreibt den Weg vom
|
||||||
|
> jetzigen three.js-3D-View (WebGL im WebKitGTK-Webview) zu einer nativen
|
||||||
|
> wgpu-Engine (Rust). Dieses Dokument ist der ANFANG: M0 (Bestandsaufnahme +
|
||||||
|
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
|
||||||
|
|
||||||
|
## Commit-/Spuren-Regel
|
||||||
|
Wie im ganzen Repo: keine Fremd-Tool-Hinweise im Code, in Kommentaren oder der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne Absprache.
|
||||||
|
|
||||||
|
## Warum
|
||||||
|
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
|
||||||
|
2D-Plan bremst dieser Compositor unter Linux (siehe `wgpu-2d-renderer-briefing.md`
|
||||||
|
und Memo `webkitgtk-bottleneck`). Die schwere 3D-Grafik soll daher **nativ mit wgpu**
|
||||||
|
gerendert werden (Rust), die Webview macht nur noch UI-Chrome. Das umgeht den
|
||||||
|
Webview-Compositor komplett und teilt sich die Toolchain mit dem 2D-Renderer
|
||||||
|
(`src-tauri/render2d/`, gleiche Feature-Stufung, gleiche Test-Muster).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bestandsaufnahme — was der three.js-View rendert
|
||||||
|
|
||||||
|
Referenz: `src/viewport/Viewport3D.tsx` (analysiert), plus die Geometrie-Grundlage
|
||||||
|
in `src/model/geometry.ts`, `src/model/wall.ts`, `src/model/joins.ts`,
|
||||||
|
`src/geometry/opening.ts`, `src/geometry/stair.ts`. Zeilenangaben beziehen sich auf
|
||||||
|
den Stand der Analyse.
|
||||||
|
|
||||||
|
### Koordinaten-Konvention (verbindlich)
|
||||||
|
Das Modell ist 2D in Metern (`x`, `y`) plus Hoehe `z`. Die 3D-Welt ist **Y-up**:
|
||||||
|
|
||||||
|
```
|
||||||
|
world.x = model.x
|
||||||
|
world.y = Hoehe (z)
|
||||||
|
world.z = model.y
|
||||||
|
```
|
||||||
|
|
||||||
|
D.h. **der Grundriss liegt in der XZ-Ebene, die Extrusion laeuft entlang +Y.**
|
||||||
|
Belegt u.a. in `Viewport3D.tsx`:
|
||||||
|
- Kommentar (~Z. 473): „Modell (x,y,z) → Three (x, z, y) (Z = Hoehe nach oben)".
|
||||||
|
- Kontext-Mesh (~Z. 1679–1684): `verts[i]=pos.x; verts[i+1]=pos.z (Hoehe); verts[i+2]=pos.y`.
|
||||||
|
- Wand-Griffe (~Z. 764): `new THREE.Vector3(wall.start.x, zBottom, wall.start.y)`.
|
||||||
|
- Workplane-Raycast (~Z. 627): `{ x: hit.x, y: hit.z }` (Three → Modell).
|
||||||
|
|
||||||
|
Diese Konvention ist in `render3d` 1:1 uebernommen (`types.rs`, `mesh.rs`).
|
||||||
|
|
||||||
|
### Waende (der Kern)
|
||||||
|
- Funktion `addWallMeshes()` / `addLayerPrism()` (~Z. 1939–2039, 2237–2271).
|
||||||
|
- **Mesh-Weg:** `THREE.ExtrudeGeometry` (~Z. 2248) ueber die 2D-Bandform, die
|
||||||
|
`clippedBand(p1, p2, offA, offB, startCut, endCut)` liefert (~Z. 2237; Funktion in
|
||||||
|
`src/model/geometry.ts:87`). Extrudiert wird um `depth = zTop - zBottom`.
|
||||||
|
- Die Bandform kommt aus Achse + Dicke: `wallBand`/`wallCorners`
|
||||||
|
(`geometry.ts:53`/`:70`) versetzen die Achse um `thickness/2` entlang der
|
||||||
|
**Links-Normale** `leftNormal(u) = (-u.y, u.x)` (`geometry.ts:17`), CCW-Umlauf.
|
||||||
|
- `ExtrudeGeometry` liegt in der XY-Ebene und waechst entlang +Z; three.js dreht das
|
||||||
|
Prisma daher um +90 Grad um X und setzt es auf `topY` (~Z. 2266–2271). In wgpu
|
||||||
|
extrudieren wir direkt in world (XZ-Grundriss, +Y-Hoehe) und sparen die Drehung.
|
||||||
|
- **Hoehe/Basis:** `wallVerticalExtent(project, wall)` (`wall.ts:56`) liefert
|
||||||
|
absolute `zBottom`/`zTop` (aus `wall.bottom`/`wall.top`-Ankern bzw. Geschoss-
|
||||||
|
`baseElevation + wall.height`).
|
||||||
|
- **Mehrschichtig:** je `wt.layers`-Schicht ein eigenes Prisma mit Dicken-Offset
|
||||||
|
(~Z. 1991–2015). M1 extrudiert vereinfacht EINE Schicht (Gesamtdicke).
|
||||||
|
- **Ecken/Gehrung:** `computeJoins()` (`joins.ts:45`) berechnet Schnittlinien
|
||||||
|
(`startCut`/`endCut`), die `clippedBand` an L-Ecken auf Gehrung zieht
|
||||||
|
(`miterLine`, `joins.ts:101`). M1 laesst das noch weg (stumpfe Enden).
|
||||||
|
|
||||||
|
### Oeffnungen (Fenster/Tueren)
|
||||||
|
- `addOpeningMeshes()` (~Z. 2056–2169) + Segmentierung in `addWallMeshes` (~Z.
|
||||||
|
1968–2035). **Kein CSG/Boolean:** die Wand wird entlang der Achse in Segmente
|
||||||
|
zerlegt (`openingInterval`, `geometry/opening.ts:31`), und je Oeffnung entstehen
|
||||||
|
bis zu drei Prismen: Wand DAVOR, **Bruestung** unter dem Fenster (`sillRel`),
|
||||||
|
**Sturz** ueber der Oeffnung (`headRel`). Rahmen/Fluegel als `BoxGeometry`;
|
||||||
|
Glas semitransparent, Tuerfluegel um `swingAngle` gedreht.
|
||||||
|
|
||||||
|
### Treppen
|
||||||
|
- `addStairMeshes()` (~Z. 2357–2409). `stairGeometry()` (`geometry/stair.ts`)
|
||||||
|
liefert Trittflaechen (Footprint + Steig-Hoehe) + optionalen Podest-Umriss; jede
|
||||||
|
Stufe als extrudierter Block (`ExtrudeGeometry`), Hoehe = `stairVerticalExtent`.
|
||||||
|
|
||||||
|
### Decken/Platten
|
||||||
|
- `addCeilingMesh()` (~Z. 2290–2347). `ceiling.outline` als `ExtrudeGeometry`, Tiefe
|
||||||
|
= Deckenstaerke, waechst nach unten von `zTop` (`ceilingVerticalExtent`, `wall.ts:80`).
|
||||||
|
|
||||||
|
### Raeume
|
||||||
|
- Nicht als eigenstaendige 3D-Koerper gerendert (2D-Grundriss-Repraesentation).
|
||||||
|
|
||||||
|
### Kontext/Gelaende
|
||||||
|
- `buildContext()` (~Z. 1651–1718). Terrain/importierte Meshes als rohe
|
||||||
|
`BufferGeometry` (Positions/Indices, Koordinaten-Swap wie oben); Hoehenlinien als
|
||||||
|
`LineSegments`. Dazu ein `GridHelper` (~Z. 421) auf OKFF-Hoehe.
|
||||||
|
|
||||||
|
### Materialien
|
||||||
|
- `MeshLambertMaterial` (Waende/Oeffnungen/Treppen, per Komponente eingefaerbt,
|
||||||
|
~Z. 2176–2206), `MeshStandardMaterial` (Weiss-/Textur-Modus + Terrain, PBR:
|
||||||
|
`roughness`/`metalness`/`aoMap`, ~Z. 445–491, 2001–2015), `MeshBasicMaterial`
|
||||||
|
(Hidden-Line-Flaechen + immer-oben-Marker), `LineBasicMaterial` (Kanten/2D-
|
||||||
|
Zeichnungen). Render-Modi: shaded / white / textured / wireframe / hidden-line.
|
||||||
|
|
||||||
|
### Beleuchtung
|
||||||
|
- `AmbientLight(0xffffff, 0.6)` (~Z. 416) + `DirectionalLight(0xffffff, 1.1)` bei
|
||||||
|
`(6, 12, 4)` (~Z. 417). **Keine Schatten** konfiguriert. Keine Hemisphere/Point-
|
||||||
|
Lights.
|
||||||
|
|
||||||
|
### Kamera + Presets
|
||||||
|
- Zwei Kameras: `PerspectiveCamera(fov, 1, 0.1, 1000)` (~Z. 367) und
|
||||||
|
`OrthographicCamera(-1,1,1,-1, 0.1, 5000)` (~Z. 374). `applyView3d()` (~Z.
|
||||||
|
1566–1633) setzt fuenf Presets:
|
||||||
|
- **front** — Richtung `(0,0,1)`, orthografisch.
|
||||||
|
- **side** — Richtung `(1,0,0)`, orthografisch.
|
||||||
|
- **top** — Richtung `(0,1,~0)`, orthografisch (Rotation gesperrt).
|
||||||
|
- **iso** — Richtung `(1,1,1)` normiert, orthografisch.
|
||||||
|
- **perspective** — Richtung `(0.62,0.5,0.7)` normiert, perspektivisch.
|
||||||
|
- Umschalten perspektiv/ortho ueber `active = perspective ? camera : orthoCamera`
|
||||||
|
(~Z. 1602); Ortho-Frustum aus den Modell-Bounds (`updateOrthoFrustum`, ~Z. 1522).
|
||||||
|
- **OrbitControls** (~Z. 391–413): Mitteltaste orbit, Shift+Mitte pan, Rad zoom
|
||||||
|
(linke/rechte Taste fuer Auswahl/Kontextmenue umgewidmet).
|
||||||
|
|
||||||
|
### Griffe / Gizmos
|
||||||
|
- Editier-Griffe (`SphereGeometry`, ~Z. 711–814): Endpunkt (orange), Hoehe (blau),
|
||||||
|
Verschieben (gruen); `depthTest:false` (immer sichtbar). Drag ueber Workplane-
|
||||||
|
Raycast. Fuer den nativen Renderer spaeter relevant (eigener Overlay-Pass).
|
||||||
|
|
||||||
|
### Schnittebene
|
||||||
|
- **Nicht implementiert:** keine `renderer.clippingPlanes` / `localClippingEnabled`.
|
||||||
|
Schnitte laufen aktuell 2D. Fuer wgpu ein eigenständiger spaeterer Milestone
|
||||||
|
(Clip-Distances im Shader oder Stencil-Capping).
|
||||||
|
|
||||||
|
### Tiefe / Culling
|
||||||
|
- Tiefentest three.js-Standard aktiv. Backface-Culling per Default (Ausnahme:
|
||||||
|
Terrain/Import `DoubleSide`). Diverse Overlays mit `depthTest:false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Port-Plan nach wgpu
|
||||||
|
|
||||||
|
### Datenfluss
|
||||||
|
Web-Modell → **geflachte Eingabe** (`WallInput`, spaeter Oeffnungen/Treppen/Decken)
|
||||||
|
→ `render3d`-Mesh-Erzeugung → GPU-Buffers → Draw. Analog zum 2D-Pfad (`Scene` →
|
||||||
|
Tessellierung → Buffers). Die Eingabe ist bewusst serde-only und GPU-frei, damit die
|
||||||
|
Mesh-Logik headless testbar bleibt.
|
||||||
|
|
||||||
|
### Mesh-Erzeugung (Waende extrudieren)
|
||||||
|
- Band aus Achse + Dicke ueber die Links-Normale (`(-u.y, u.x) * thickness/2`,
|
||||||
|
CCW), exakt wie `wallCorners`. Extrusion in world: XZ-Grundriss, +Y von
|
||||||
|
`base_elevation` bis `+height`.
|
||||||
|
- Ein Quader = 6 Seiten, je eigene Vertices mit Flaechen-Normale (flaches Shading,
|
||||||
|
korrektes Backface-Culling). 24 Vertices / 36 Indizes je Wand.
|
||||||
|
- Spaeter: mehrschichtige Waende (je Schicht ein Prisma), Gehrung
|
||||||
|
(`computeJoins`/`clippedBand`-Port), Oeffnungs-Segmentierung (Bruestung/Sturz).
|
||||||
|
|
||||||
|
### Kamera (View/Projektion, Presets)
|
||||||
|
- `look_at` (right-handed, Kamera blickt entlang -Z im View-Raum), `perspective`
|
||||||
|
und `orthographic` — beide auf **Clip-Z in [0,1]** (wgpu-Konvention, NICHT [-1,1]).
|
||||||
|
- Fuenf Presets (`preset_camera`): front/top/side orthografisch achsparallel,
|
||||||
|
iso/persp perspektivisch. `top` mit up=-Z, damit Modell-Y im Bild nach unten
|
||||||
|
zeigt (wie die 2D-Sicht).
|
||||||
|
- Orbit-Kamera aus Yaw/Pitch/Distanz (`orbit_eye`, Pitch geklemmt gegen Pol-Flip).
|
||||||
|
|
||||||
|
### Beleuchtung
|
||||||
|
- Zunaechst EIN Directional-Light (Richtung ZUM Licht) + ambienter Sockel im
|
||||||
|
Fragment-Shader (WGSL) — das GPU-Aequivalent zu `AmbientLight(0.6)` +
|
||||||
|
`DirectionalLight(1.1)@(6,12,4)`. Diffuses Lambert. PBR (Rauheit/Metallik/
|
||||||
|
Texturen/AO) spaeter.
|
||||||
|
|
||||||
|
### Tiefenpuffer + Culling
|
||||||
|
- `Depth32Float`-Attachment, `depth_compare = Less`, `depth_write = true`.
|
||||||
|
- `front_face = Ccw`, `cull_mode = Back` (die Extrusion liefert konsistent nach
|
||||||
|
aussen zeigende CCW-Flaechen).
|
||||||
|
|
||||||
|
### Matrix-Mathematik
|
||||||
|
- Handgerechnet (kein `glam`) in der serde-only Schicht — begruendet in `math.rs`:
|
||||||
|
die Standard-Schicht soll wie in render2d ohne Zusatz-Crates headless test-/baubar
|
||||||
|
bleiben; der Umfang (perspective/ortho/look_at + Orbit) ist klein und exakt
|
||||||
|
testbar. Ein spaeterer Wechsel zu `glam` (nur in der GPU-Schicht) bleibt moeglich,
|
||||||
|
ohne die Kamera-Tests anzufassen. Alles spalten-major, direkt als Uniform ladbar.
|
||||||
|
|
||||||
|
### Milestones M2..Mn
|
||||||
|
- **M2 — Mehrschichtige Waende + Gehrung:** Port von `computeJoins`/`miterLine` +
|
||||||
|
`clippedBand` → gehrte Bandformen je Schicht; Farben/Materialien je Komponente.
|
||||||
|
- **M3 — Oeffnungen:** Achsen-Segmentierung (Bruestung/Sturz) + Rahmen/Glas/Fluegel
|
||||||
|
als eigene Meshes; Tuerschwenk-Winkel.
|
||||||
|
- **M4 — Treppen + Decken:** Port von `stairGeometry`/`ceilingVerticalExtent`.
|
||||||
|
- **M5 — Kontext/Gelaende:** rohe Terrain-/Import-Meshes + Hoehenlinien + Grid.
|
||||||
|
- **M6 — Materialien (PBR):** `MeshStandardMaterial`-Aequivalent (roughness/
|
||||||
|
metalness/albedo/AO-Textur); Render-Modi shaded/white/textured/wireframe/hidden.
|
||||||
|
- **M7 — Schnittebene:** Clip-Distances im Shader oder Stencil-Capping (Feature, das
|
||||||
|
der three.js-View gar nicht hat — echter Mehrwert).
|
||||||
|
- **M8 — Griffe/Gizmos + Picking:** Overlay-Pass (immer-oben) + GPU-/Ray-Picking.
|
||||||
|
- **M9 — Tauri-Integration:** Surface unter der Webview (raw-window-handle,
|
||||||
|
Z-Order, Input-Routing) — siehe `wgpu-2d-renderer-briefing.md` M2 (gleiches
|
||||||
|
Integrations-Problem; einmal loesen, fuer 2D+3D nutzen). Kamera-Presets/Orbit-
|
||||||
|
Input aus der Webview an den Renderer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Was in `src-tauri/render3d/` steht (M1)
|
||||||
|
|
||||||
|
Neue, eigenstaendige Crate (eigener leerer `[workspace]`-Block, wie render2d), damit
|
||||||
|
`cargo test`/`build` unabhaengig vom Tauri-Workspace laufen. Feature-Stufung 1:1 wie
|
||||||
|
render2d:
|
||||||
|
|
||||||
|
- `Cargo.toml` — Features `default` (serde-only) / `render` (wgpu) / `window`
|
||||||
|
(winit-Spike). `[[bin]] spike3d` mit `required-features = ["window"]`.
|
||||||
|
- `src/types.rs` — serde-only Eingabe: `WallInput { start, end, thickness, height,
|
||||||
|
base_elevation, color }`, `Camera` (+ `Projection`), `CameraPreset`; Ausgabe
|
||||||
|
`Mesh` (interleaved `[pos.xyz, normal.xyz, color.rgb]` + Indizes) mit
|
||||||
|
`vertex_count`/`triangle_count`/`bounds`. Koordinaten-Konvention dokumentiert.
|
||||||
|
- `src/mesh.rs` — Wand-Extrusion: `extrude_wall`/`build_walls_mesh`. Band ueber
|
||||||
|
Links-Normale, Quader mit sechs eigenen Seiten, nach aussen zeigende Normalen.
|
||||||
|
- `src/math.rs` — `Mat4` (spalten-major), `perspective`/`orthographic` (Clip-Z
|
||||||
|
[0,1]), `look_at`, `view_projection`, `orbit_eye`, `preset_camera` (fuenf Presets).
|
||||||
|
- `src/shaders.rs` — WGSL (`MESH_WGSL`): View-Projektion-Uniform + Directional-
|
||||||
|
Light + ambienter Sockel im Fragment-Shader.
|
||||||
|
- `src/gpu.rs` (Feature `render`) — `Renderer`: eine Pipeline mit Tiefenpuffer,
|
||||||
|
View-Projektions-Uniform, Backface-Culling. `upload_walls` → GPU-Buffers,
|
||||||
|
`render(camera, viewport)`.
|
||||||
|
- `src/bin/spike3d.rs` (Feature `window`) — winit-Fenster mit Demo-Raum (5
|
||||||
|
extrudierte Waende) + **Orbit-Kamera** (linke Maustaste dreht Yaw/Pitch, Rad
|
||||||
|
zoomt Abstand). Matrix-getrieben, kein Re-Meshing beim Kamera-Wechsel.
|
||||||
|
- `src/lib.rs` — Modul-Deklarationen, Re-Exports, Tests.
|
||||||
|
|
||||||
|
### Tests (`cargo test`, default-Feature)
|
||||||
|
Muster wie render2d/`glPlanCompile.test.ts`:
|
||||||
|
- Quader-Zaehlung (eine Wand → 24 Vertices / 36 Indizes / 12 Dreiecke).
|
||||||
|
- Mehrere Waende addieren sich.
|
||||||
|
- Bounding-Box deckt Laenge/Dicke/Hoehe ab; `base_elevation` verschiebt in Y.
|
||||||
|
- Deckel-Normale = +Y; **alle Mantel-Normalen zeigen nach aussen** (Dot mit
|
||||||
|
„Vertex − Zentrum" ≥ 0 → Backface-Culling korrekt).
|
||||||
|
- Diagonale Wand; degenerierte Wand (Start==Ende) erzeugt nichts (kein Absturz).
|
||||||
|
- Kamera: `look_at` setzt Ziel auf view-z=-dist; Perspektive klemmt z in [0,1];
|
||||||
|
`orbit_eye` haelt den Abstand; Presets setzen die richtige Projektionsart.
|
||||||
|
- Mit `--features render`: WGSL headless via `naga` (Parser + Validator) validiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Build-/Test-Ergebnis
|
||||||
|
|
||||||
|
Alle Gates gruen (Toolchain: cargo 1.96, wgpu 22, winit 0.30):
|
||||||
|
- `cargo test` (default) — **12/12** gruen (Mesh + Kamera).
|
||||||
|
- `cargo test --features render` — **13/13** gruen (inkl. WGSL-naga-Validierung).
|
||||||
|
- `cargo build` (default), `--features render`, `--features window` — je gruen,
|
||||||
|
**keine Warnungen**.
|
||||||
|
- Trace-Scan sauber (keine KI-Spuren).
|
||||||
|
- Web-Gates unberuehrt (nur `src-tauri/` + `docs/` angefasst; `src/` nur gelesen).
|
||||||
|
|
||||||
|
**Visuelle Fenster-Verifikation** ist headless NICHT moeglich. Auf einer aktiven
|
||||||
|
Display-Session pruefbar mit:
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run --features window --bin spike3d
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: ein Raum aus extrudierten Waenden mit diffuser Beleuchtung; linke
|
||||||
|
Maustaste dreht die Orbit-Kamera, das Rad zoomt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Naechste Schritte
|
||||||
|
1. **M2** starten: `computeJoins`/`clippedBand`-Port für gehrte, mehrschichtige
|
||||||
|
Waende (die Bandmath ist im Web bereits verifiziert — gleiche Tests portieren).
|
||||||
|
2. Oeffnungs-Segmentierung (M3) auf demselben Extrusions-Kern.
|
||||||
|
3. Die **Tauri-Integration (M9)** gemeinsam mit dem 2D-Renderer loesen (ein Surface-
|
||||||
|
Unterbau, ein Input-Routing) — das ist der eigentliche Engpass, nicht das
|
||||||
|
Rendering. Erst standalone spiken (dieser Stand), dann unter die Webview.
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# Fenster-/Tür-Editor — Studie & Designdokument (Referenz: Vectorworks „Fenster bearbeiten")
|
||||||
|
|
||||||
|
Status: Studie/Entwurf (KEIN Code). Ziel: den heute als „mega mager" empfundenen
|
||||||
|
Fenster-/Tür-Editor zu einem eigenständigen, reichen **Einstellungs-Dialog** mit
|
||||||
|
Kategorie-Sidebar, Live-Vorschau (2D + 3D) und **„Als Stil speichern"** ausbauen.
|
||||||
|
Diese Datei ordnet die Vectorworks-Referenz dem bestehenden DOSSIER-Modell zu und
|
||||||
|
schlägt einen realistischen, phasierten Plan vor.
|
||||||
|
|
||||||
|
Konvention: Bezeichner englisch, UI-Text/Kommentare deutsch. Meter als Grundmaß.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
**Was ein guter DOSSIER-Fenster-/Tür-Editor sein sollte.** Ein eigener modaler
|
||||||
|
Dialog „Fenster-/Tür-Einstellungen" — nicht die heutige, in den Ressourcen-Manager
|
||||||
|
eingebettete Formularspalte (`WindowStylesTab`/`DoorStylesTab` in
|
||||||
|
`src/ui/ResourceManager.tsx`), die pro Feld nur eine `FieldRow` zeigt und keinerlei
|
||||||
|
Vorschau bietet. Der neue Dialog hat drei Zonen (wie Vectorworks):
|
||||||
|
|
||||||
|
1. **Kategorie-Sidebar** links (Basis, Größe/Position, Rahmen, Flügel/Sprossen,
|
||||||
|
Oberlicht/Unterlicht, Laibung/Bank, Sonnenschutz/Rollladen, Attribute/Darstellung,
|
||||||
|
Detaillierung).
|
||||||
|
2. **Parameter-Panel** in der Mitte (die Controls der gewählten Kategorie).
|
||||||
|
3. **Live-Vorschau** rechts: eine 2D-Plan-Vorschau (aus `generatePlan()`) und eine
|
||||||
|
3D-Ansicht/Elevation (aus `projectToModel3d()`), beide sofort aktualisiert.
|
||||||
|
|
||||||
|
**Die eine wichtigste strukturelle Änderung.** Der Editier-Primärort wandert vom
|
||||||
|
Ressourcen-Tab in einen **dedizierten `OpeningEditorDialog`**, der TYP-Parameter
|
||||||
|
(wiederverwendbarer Stil = `WindowType`/`DoorType`) und INSTANZ-Parameter (dieses
|
||||||
|
`Opening`) im selben Fenster editiert und oben eine Stil-Leiste trägt:
|
||||||
|
`Stil: [Dropdown]` · `Fenster speichern…` (aktuelle Konfiguration als neuen
|
||||||
|
benannten `WindowType`/`DoorType` ablegen) · `Einstellungen zurücksetzen…`
|
||||||
|
(auf den Stil zurückfallen). Das ⚙ in `ObjectInfoPanel.OpeningSection`
|
||||||
|
(`src/panels/ObjectInfoPanel.tsx:731`) öffnet künftig DIESEN Dialog statt den
|
||||||
|
Ressourcen-Manager-Tab.
|
||||||
|
|
||||||
|
**Begriffsklärung.** Der Nutzer-Ausdruck „als **Wandstil** speichern" ist ein
|
||||||
|
Versprecher — gemeint ist „als **Fensterstil/Türstil** (Bauteilstil) speichern",
|
||||||
|
also ein neuer Eintrag in `project.windowTypes` bzw. `project.doorTypes`, analog
|
||||||
|
`WallType`/`CeilingType`/`StairType`. Es entsteht KEIN neuer Wandtyp.
|
||||||
|
|
||||||
|
**Warum das der Hebel ist.** Alle geplante Tiefe (mehrflügelig, Sprossenraster,
|
||||||
|
Rollladen, Bank/Nische, Ober-/Unterlicht, Verglasungsanzahl) braucht (a) mehr
|
||||||
|
Felder auf `WindowType`/`DoorType` und (b) eine UI, die sie ohne Formularwust
|
||||||
|
zeigt und deren Wirkung sofort sichtbar macht. Ohne Live-Vorschau bleibt ein
|
||||||
|
reicher Parametersatz unbenutzbar. Erst der Dialog macht die Tiefe zugänglich; die
|
||||||
|
Modellfelder allein (Abschnitt 4) reichen nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Vollständige Mapping-Tabelle (VW-Referenz → DOSSIER)
|
||||||
|
|
||||||
|
Legende — Status: **✓** vorhanden · **~** teilweise (Feld existiert, Renderer liest
|
||||||
|
es nicht ODER nur grob) · **✗** fehlt. Ebene: **T** = Typ (Stil, wiederverwendbar,
|
||||||
|
auf `WindowType`/`DoorType`) · **I** = Instanz (auf `Opening`). Priorität P0
|
||||||
|
(erste reiche Scheibe) … P3 (VW-Ballast). Aufwand grob in Personentagen (PT).
|
||||||
|
|
||||||
|
Wichtiger Ist-Befund aus dem Code (Renderer-Konsum-Lücken):
|
||||||
|
|
||||||
|
- `WindowType.glazing` (einfach/zweifach/dreifach) ist editierbar, wird aber von
|
||||||
|
KEINEM Renderer gelesen. 3D zeichnet stets EINE Scheibe (`glassPanesForOpening`
|
||||||
|
in `src/plan/toWalls3d.ts:1965` ignoriert `glazing`); 2D leitet die Glaslinien-
|
||||||
|
Anzahl allein aus `DetailLevel` ab (`addOpeningSymbol` in
|
||||||
|
`src/plan/generatePlan.ts:2214`, `glassCount = detail==="fein" ? 2 : 1`).
|
||||||
|
- `WindowType.kind`/`DoorType.kind` (dreh/kipp/drehkipp/fest/schiebe …) schlagen
|
||||||
|
sich NICHT in 2D/3D-Geometrie nieder.
|
||||||
|
- `DoorType.leafCount` (1/2), `leafStyle`/`glazingRatio` (außer `leafStyle==="glas"`
|
||||||
|
→ 3D-Verglasung an) sind ungenutzt.
|
||||||
|
- `WindowType.sillBoard` ungenutzt; `DoorType.threshold` → `hasSill` genutzt.
|
||||||
|
- 3D-Mittelpfosten/Kämpfer und Sprossen werden nur bei `detail==="fein"` emittiert
|
||||||
|
(`frameMeshesForOpening` in `src/plan/toWalls3d.ts:1902`, ab :1938).
|
||||||
|
|
||||||
|
### 2.1 Basiseinstellungen
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld (Vorschlag) | 2D-Wirkung | 3D-Wirkung | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Fensterart („Für normale Wand") | ✗ | `WindowType.wallKind?: "normal"\|"eck"` (später) | — | — | T | P3 | 0.5 |
|
||||||
|
| Öffnungsart (Dreh/Kipp/…) | ~ | `WindowType.kind` existiert, kein Render | Öffnungssymbol/Öffnungslinien | Öffnungslinien 3D | T | P1 | 2 |
|
||||||
|
| Einfügepunkt längs (Mitte/…) | ✓ | `Opening.position` + Bezugspunkt in `ObjectInfoPanel` | Position im Plan | Position | I | — | — |
|
||||||
|
| Einfügepunkt quer (Fensterseite außen/…) | ~ | `insetFromFace`/`insetFace` (T) | Band-Lage | Rahmen-Normalenlage (`resolveFrameNormalRange` :1803) | T | P1 | 0.5 |
|
||||||
|
| Versatz im Fassadenmodul | ✗ | — (Fassadensystem fehlt in DOSSIER) | — | — | — | P3 | — |
|
||||||
|
| Laibung (wählen) | ~ | `insetFromFace` deckt Teil ab | Laibungsstriche (fein) | Laibungstiefe | T | P2 | 1 |
|
||||||
|
| Klasse | ~ | `Opening.categoryCode` (LayerCategory) | Farbe/Strich | — | I | — | — |
|
||||||
|
| Darstellung höchste Detaillierung | ✓ | `Opening.detailLevel` + Ansichts-DetailLevel | Symbolstufe | Meshstufe | I | — | — |
|
||||||
|
| Eigenes Symbol verwenden | ✗ | `WindowType.symbolId?` (Drawing2D-Ref) | Symbol-Override | — | T | P3 | 3 |
|
||||||
|
|
||||||
|
### 2.2 Fenstergröße & Bemaßung
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Fensterbreite (Wert) | ✓ | `Opening.width` (+ `defaultWidth` T) | ✓ | ✓ | I/T | — | — |
|
||||||
|
| Fensterhöhe (Wert) | ✓ | `Opening.height` (+ `defaultHeight` T) | ✓ | ✓ | I/T | — | — |
|
||||||
|
| Bezug B1..B5 / H1..H7 (Roh-/Fertigmaß) | ✗ | `WindowType.dimRef?: {...}` | Bemaßungsschema | — | T | P3 | 3+ |
|
||||||
|
| „Bemaßung automatisch" (außen/innen) | ✗ | — (DOSSIER hat noch keine parametrische Öffnungs-Bemaßung) | Maßketten | — | T | P3 | 5+ |
|
||||||
|
|
||||||
|
Der ganze VW-Maßketten-/Bezugsapparat (B1..B5, H1..H7, Roh-/Fertigmaß-Umschaltung)
|
||||||
|
ist **P3/„nicht bauen"** — siehe Abschnitt 5. DOSSIER trägt lichte Maße
|
||||||
|
(`width`/`height`), das genügt.
|
||||||
|
|
||||||
|
### 2.3 Höhe Brüstung/Sturz
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Position definieren durch (Brüstungshöhe/…) | ✓ | `Opening.sillHeight` (+ `defaultSillHeight` T) | — | vertikale Lage (`openingVerticalExtent`) | I | — | — |
|
||||||
|
| Abstand Brüstungshöhe (Wert) | ✓ | `Opening.sillHeight` | — | ✓ | I | — | — |
|
||||||
|
| „bezieht sich auf" (Wandaußenseite/…) | ✗ | — (immer Wand-UK-relativ) | — | — | — | P3 | — |
|
||||||
|
| „auf Ebenenbasishöhe" | ~ | Wand-UK ergibt sich aus Geschoss (`baseElevation`) | — | ✓ | — | — | — |
|
||||||
|
|
||||||
|
### 2.4 Rahmenwerte
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Rahmenbreiten (4 Kanten, asymmetrisch) | ~ | heute nur `frameWidth` (eine Zahl) → `WindowType.frameWidths?: {left,right,top,bottom}` | Rahmenkontur (`windowSymbol`/`addOpeningFrameBand`) | Rahmen-Boxen (`frameMeshesForOpening`) | T | P2 | 2 |
|
||||||
|
| Symmetrisch-Toggle | ✗ | `WindowType.frameSymmetric?: boolean` | — | — | T | P2 | 0.5 |
|
||||||
|
| Flügel mittig / Versatz | ✗ | `WindowType.sashOffset?: number` | Aufschlag-Linie | Flügel-Box-Lage | T | P2 | 1 |
|
||||||
|
| Rahmenstärke quer zur Wand | ✓ | `frameThickness` / `frameDepth` (→ `resolveAcrossWallDepth` :1780) | — | Rahmentiefe | T | — | — |
|
||||||
|
| Schnitt-/Außenansicht-Rahmenbreiten | ~ | von `frameWidth` mitgezeichnet | Bandbreite | — | T | P2 | 1 |
|
||||||
|
|
||||||
|
### 2.5 Flügeleinteilung (KERN-Tiefe — höchster Wert)
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Flügeltabelle (Nr/Typ/Breite/Anschlag/…) | ~ | `wingCount` (Zahl) → **neu** `WindowType.sashes: SashDef[]` (siehe §4) | Pfostenlinien (`mullionLines`) heute nur gleichmäßig | Pfosten-Boxen nur gleichmäßig | T | **P0** | 4 |
|
||||||
|
| Typ Flügel \| Pfosten | ✗ | `SashDef.kind: "fluegel"\|"pfosten"` | Pfostenlage frei | Pfosten frei | T | P0 | (inkl.) |
|
||||||
|
| Aut. Breite / Flügelbreite | ~ | `SashDef.autoWidth`/`width` (heute nur gleichmäßig) | Teilungslage | Teilungslage | T | P1 | 1 |
|
||||||
|
| Pfostenbreite / „alle gleich" | ~ | `SashDef.postWidth`, `WindowType.uniformPosts` | Pfostendicke | Pfostenbox-Breite | T | P1 | 1 |
|
||||||
|
| Anschlag (Drehbar links/Drehkipp rechts) | ✗ | `SashDef.opening: OpeningKind`, `SashDef.hingeSide: "left"\|"right"` | Öffnungslinien/Pfeil je Flügel | Öffnungslinien 3D | T | **P0** | 2 |
|
||||||
|
| Aufschlag (Abstand zu Rahmen) | ✗ | `SashDef.rebate?: number` | Aufschlag-Linie | — | T | P2 | 0.5 |
|
||||||
|
| Winkel / 3D-Öffnung | ✗ | `SashDef.openAngle?: number` | — | Flügel gekippt/offen | T | P2 | 1.5 |
|
||||||
|
| Griffart | ✗ | `SashDef.handle?: HandleKind` | — | Griff-Mesh (§2.7) | T | P2 | 1 |
|
||||||
|
| Kämpfer-Zeilen (horizontale Teilung) | ~ | `mullionRows` existiert | Querlinien | Kämpfer-Boxen (fein) | T | P1 | — |
|
||||||
|
|
||||||
|
Dies ist die **wertvollste Lücke**: VW modelliert je Flügel Typ, Breite,
|
||||||
|
Öffnungsrichtung, Anschlag, Griff. DOSSIER hat nur eine gleichmäßige `wingCount`.
|
||||||
|
Die `SashDef[]`-Tabelle (§4, Phase P0/P1) ist der zentrale Ausbau.
|
||||||
|
|
||||||
|
### 2.6 Laibungsverkleidung · Form · Ober-/Unterlicht · Nische/Bank
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Laibungsverkleidung erstellen | ✗ | `WindowType.reveal?: {create, depth, thickness}` | Laibungsband | Laibungs-Boxen | T | P2 | 1.5 |
|
||||||
|
| Form (Eckig/Schräg/Spitz/Rund) | ✗ | `WindowType.headShape?: HeadShape` + Eckmaße | Öffnungsumriss-Form | Bogen-/Schräg-Mesh | T | P2 | 4 |
|
||||||
|
| Oberlicht erstellen + Höhe/Rahmen/Sprossen | ~ | `transomHeight` existiert (feste Scheibe) → `WindowType.transom?: {height, frame, grid}` | Kämpferlinie + Feld | zweite Scheibe (`glassPanesForOpening` :1988) | T | P1 | 2 |
|
||||||
|
| Unterlicht (unteres festes Feld) | ✗ | `WindowType.underlight?: {height, frame, grid}` | Feld | Scheibe unten | T | P2 | 1.5 |
|
||||||
|
| Sprossen (horiz./vert. im Feld) | ~ | `mullionRows` grob → `WindowType.muntins?: {rows, cols}` je Feld | Sprossengitter | Sprossen-Boxen (fein) | T | P1 | 2 |
|
||||||
|
| Nische aussparen (außen/innen) | ✗ | `WindowType.niche?: {aussen, innen, ...}` | Nischenkontur | Nischen-Aussparung | T | P2 | 2 |
|
||||||
|
| Fensterbank erstellen (außen/innen) | ~ | `sillBoard` existiert, ungenutzt → `WindowType.sill?: {aussen, innen, typ, masse}` | Bankkontur | Bank-Box | T | P1 | 2 |
|
||||||
|
| Banktyp/Winkel/ΔZ/Endverkröpfung | ✗ | Unterfelder von `sill` | Detail | Detail-Mesh | T | P3 | 2 |
|
||||||
|
|
||||||
|
### 2.7 Sonnenschutz · Beschlag · Geländer · Heizkörper · Eckfenster
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Sonnenschutz/Rollladen erstellen + Typ | ✗ | `WindowType.shading?: {create, kind: "raffstore"\|"rollladen"\|…, box, projection}` | Kasten-/Kastenlinien im Grundriss | Kastenbox + Auskragung | T | **P0/P1** | 2.5 |
|
||||||
|
| Rollladenkasten-Maße | ✗ | `shading.box: {w,h,d}` | Rechteck | Box-Mesh | T | P0 | (inkl.) |
|
||||||
|
| „Nische erzeugen"/„Kasten symm."/Lamellenwinkel | ✗ | `shading`-Unterfelder | — | Detail | T | P2 | 1 |
|
||||||
|
| Beschlag (Griff/Knauf) Geometrie/Maße | ✗ | `SashDef.handle` + `WindowType.handleGeom?` | — | Griff-/Knauf-Mesh | T | P2 | 2 |
|
||||||
|
| Geländer (Position/Höhe/Bauteile) | ✗ | `WindowType.railing?: {...}` | Geländerlinien | Geländer-Mesh | T | P3 | 3 |
|
||||||
|
| Heizkörper | ✗ | — (eigenes Bauteil, nicht Fenster) | — | — | — | P3 | — |
|
||||||
|
| Eckfenster | ✗ | eigener Sonderfall (zwei Wände) | — | — | T | P3 | 5+ |
|
||||||
|
|
||||||
|
### 2.8 Attribute · Schnitte/Ansichten · Detaillierung · IFC/Energos
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Attribut-Tabelle je Bestandteil (Klasse/Material/Stift/Linie/…) | ~ | DOSSIER hat By-Layer/By-Object-Attribute (`foreground`/`background`/`hatchId`/`strokeWeight` an Wand/Decke, NICHT an `Opening`) → `Opening.foreground?` etc. + evtl. je Bestandteil | Farbe/Strich der Bestandteile | Material | I/T | P2 | 3 |
|
||||||
|
| „Klassenattribute zuweisen/entfernen" | ~ | `AttributeSource` („layer"/„object") existiert für Wand/Decke | — | — | I | P2 | 1 |
|
||||||
|
| Automatische 2D-Darstellungen (Horizontal/Ansicht/Querschnitt) | ~ | `generatePlan` erzeugt Grundriss; Schnitt/Ansicht sind Platzhalter (`DrawingLevelKind`) | — | — | — | P3 | — |
|
||||||
|
| Sichtbarkeitsmatrix 3D-Objekte × Ansichten (Oben/Unten/…/Querschnitt) | ✗ | **nicht bauen** | — | — | — | P3 | — |
|
||||||
|
| Detaillierung: Option × Kategorie × Detailstufe (Augen-Tabelle) | ~ | `DetailLevel` (grob/mittel/fein) fließt in 2D + 3D | Sichtbarkeit je Stufe | Meshstufe | I | P2 | 2 |
|
||||||
|
| „Für 3D die Tiefe/Mittlere/Hohe Detaillierung verwenden" | ✓ | `Model3dOptions.detail` (`toWalls3d.ts:2145`) | — | Meshstufe | — | — | — |
|
||||||
|
| Beschriftung | ✗ | eigenes Text/Tag-System | Label | — | — | P3 | — |
|
||||||
|
| Infos/IFC-Daten/Infopalette/Energos | ✗ | **nicht bauen** | — | — | — | P3 | — |
|
||||||
|
| „Mehrere ändern" | ~ | Multi-Selektion patcht bereits gemeinsame Felder | — | — | I | P2 | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Der dedizierte Dialog
|
||||||
|
|
||||||
|
### 3.1 Komponente & Einbettung
|
||||||
|
|
||||||
|
Neue Komponente **`src/ui/OpeningEditorDialog.tsx`** (Muster: die bestehenden
|
||||||
|
`*Dialog.tsx` in `src/ui/`, z. B. `SettingsDialog.tsx`, `TextEditorDialog.tsx`,
|
||||||
|
und das Overlay-Muster `res-overlay` + `role="dialog"` aus
|
||||||
|
`ResourceManager.tsx:371`). Props (über `usePanelHost`/App-State geliefert):
|
||||||
|
|
||||||
|
```
|
||||||
|
open: boolean
|
||||||
|
openingId: string // die editierte Instanz
|
||||||
|
kind: "window" | "door" // steuert Sidebar-Sätze + welche Type-Liste
|
||||||
|
project: Project // read (Typ-Listen, Bauteile, LayerCategories)
|
||||||
|
onPatchOpening(patch) // Instanz-Felder
|
||||||
|
onPatchType(typeId, patch) // Typ-Felder (aktueller Stil)
|
||||||
|
onSaveAsStyle(name, snapshot)// „Fenster/Tür speichern…" → neuer WindowType/DoorType
|
||||||
|
onResetToStyle() // „zurücksetzen…"
|
||||||
|
onClose()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Öffnen aus dem ⚙.** `ObjectInfoPanel.OpeningSection`
|
||||||
|
(`src/panels/ObjectInfoPanel.tsx:731`, `onClick → host.onEditOpeningType(...)`)
|
||||||
|
ruft künftig einen neuen Host-Handler `host.onOpenOpeningEditor(openingId)` statt
|
||||||
|
`setResourcesTab(...) + setResourcesOpen(true)` (heutige Verdrahtung in
|
||||||
|
`src/App.tsx:3130`). App hält `openingEditorId: string | null` als State und
|
||||||
|
rendert `<OpeningEditorDialog open={openingEditorId!=null} .../>`. Der bisherige
|
||||||
|
Ressourcen-Tab bleibt als Bibliotheks-/Massenpflege bestehen, ist aber nicht mehr
|
||||||
|
der Primärort — das ⚙ landet direkt im reichen Dialog.
|
||||||
|
|
||||||
|
### 3.2 Kategorie-Sidebar (realistischer Teilmenge)
|
||||||
|
|
||||||
|
Reihenfolge und Sichtbarkeit je `kind`:
|
||||||
|
|
||||||
|
1. **Basis** — Öffnungsart (`kind`), Einfügepunkt quer (`insetFace`/`insetFromFace`),
|
||||||
|
Klasse (`categoryCode`), Detaillierung (`detailLevel`).
|
||||||
|
2. **Größe/Position** — `width`, `height`, `sillHeight`, `position` (I),
|
||||||
|
`defaultWidth/Height/SillHeight` (T).
|
||||||
|
3. **Rahmen** — `frameThickness`, `frameDepth`, `frameWidth`/`frameWidths`,
|
||||||
|
`frameKind` (Tür), `sashOffset`.
|
||||||
|
4. **Flügel/Sprossen** — die `SashDef[]`-Tabelle (Flügel/Pfosten einfügen,
|
||||||
|
Öffnungsrichtung/Anschlag je Flügel), Kämpfer-Zeilen, Sprossengitter.
|
||||||
|
5. **Oberlicht/Unterlicht** — `transom`, `underlight` (je Feld: Höhe, Rahmen, Gitter).
|
||||||
|
6. **Laibung/Bank** — `reveal`, `sill` (außen/innen), `niche`.
|
||||||
|
7. **Sonnenschutz/Rollladen** — `shading` (Typ, Kastenmaße, Auskragung).
|
||||||
|
8. **Attribute/Darstellung** — Bestandteil-Attribute (Farbe/Strich/Material).
|
||||||
|
9. **Detaillierung** — welche Bestandteile bei grob/mittel/fein sichtbar sind.
|
||||||
|
|
||||||
|
Sidebar-Sätze: bei `kind==="door"` entfallen Oberlicht/Unterlicht-Gitter-Details,
|
||||||
|
Sonnenschutz und Bank; dafür Türblatt (`leafCount`, `leafStyle`, `threshold`,
|
||||||
|
`swing`/`hinge`/`openingDir`/`swingAngle`).
|
||||||
|
|
||||||
|
### 3.3 Live-Vorschau
|
||||||
|
|
||||||
|
**2D (der billige, sofort machbare Teil).** DOSSIER hat den kompletten
|
||||||
|
Plan→SVG-Pfad bereits: `generatePlan()` (`src/plan/generatePlan.ts:632`) →
|
||||||
|
`toRenderScene.ts` (RScene) → `sceneToPrintSvg()` (`src/export/sceneToPrintSvg.ts:109`)
|
||||||
|
bzw. `planToPrintSvg()` (`src/export/planToPrintSvg.ts:155`). Für die Vorschau ein
|
||||||
|
**Mini-Projekt** bauen (eine kurze Wand + das editierte `Opening` mit den aktuellen
|
||||||
|
Dialog-Werten), durch `generatePlan()` schicken und als SVG in ein Vorschau-`<div>`
|
||||||
|
rendern — Grundriss oben, optional eine Elevations-Skizze. Reagiert auf jeden
|
||||||
|
Patch reaktiv (React-State → neu generieren; günstig, da nur ein Element).
|
||||||
|
|
||||||
|
**3D/Elevation.** Zwei Optionen, in aufsteigendem Aufwand:
|
||||||
|
|
||||||
|
- **(A) günstig, empfohlen für den ersten Wurf:** eine reine 2D-**Ansicht/Elevation**
|
||||||
|
aus denselben Plan-Primitiven — VW's „Vorschau, schattiert" ist für uns nicht
|
||||||
|
nötig; eine saubere Frontalansicht (Rahmen/Flügel/Sprossen/Glas als SVG) zeigt
|
||||||
|
die Flügeleinteilung am aussagekräftigsten und nutzt exakt die neuen Felder.
|
||||||
|
- **(B) echte 3D-Vorschau:** `projectToModel3d()` (`src/plan/toWalls3d.ts:2149`)
|
||||||
|
auf das Mini-Projekt anwenden und im wgpu-Viewport (`Wasm3DViewport.tsx`)
|
||||||
|
darstellen. Teurer (eigener Render-Kontext im Modal, WASM-Instanz) und laut
|
||||||
|
Projekt-Memo NICHT per Browser/Puppeteer verifizierbar — der Nutzer testet 3D
|
||||||
|
selbst in der Tauri-Dev-App. Deshalb 3D-Vorschau erst NACH der 2D-Vorschau, als
|
||||||
|
eigene Phase.
|
||||||
|
|
||||||
|
Empfehlung: erste Scheibe nur **2D-Grundriss + 2D-Elevation**; echte wgpu-Vorschau
|
||||||
|
später.
|
||||||
|
|
||||||
|
### 3.4 „Als Stil speichern"-Flow
|
||||||
|
|
||||||
|
Drei Aktionen in der Stil-Leiste:
|
||||||
|
|
||||||
|
- **Fenster/Tür speichern… (`onSaveAsStyle`)** — nimmt einen Schnappschuss der
|
||||||
|
aktuellen (Typ-relevanten) Dialogwerte, fragt einen Namen ab (`PromptDialog.tsx`)
|
||||||
|
und legt einen NEUEN `WindowType`/`DoorType` in `project.windowTypes`/`doorTypes`
|
||||||
|
an (CRUD-Factories existieren: `addWindowType`/`addDoorType` in `src/App.tsx`
|
||||||
|
ab :2273/:2304). Das editierte `Opening.typeId` zeigt danach auf den neuen Stil.
|
||||||
|
- **Update dieses Stils** — `patchWindowType`/`patchDoorType` (App :2319/:2287) auf
|
||||||
|
den aktuell referenzierten `typeId` anwenden (wirkt auf alle Instanzen des Stils).
|
||||||
|
- **Einstellungen zurücksetzen… (`onResetToStyle`)** — die instanzseitigen
|
||||||
|
Overrides verwerfen und wieder die Typ-Defaults ziehen (Instanz-Felder auf
|
||||||
|
`undefined` setzen, sodass die Resolver `getWindowType`/`getDoorType`
|
||||||
|
(`src/model/types.ts:2060`/:2064) greifen).
|
||||||
|
|
||||||
|
TYP-vs-INSTANZ-Regel im Dialog sichtbar machen: Typ-Felder tragen eine dezente
|
||||||
|
„Stil"-Markierung; ein instanzseitig übersteuertes Feld zeigt einen „abweichend
|
||||||
|
vom Stil"-Indikator + „zurücksetzen". Das ist genau VW's Stil-/Override-Logik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modell-Erweiterungsplan (phasiert)
|
||||||
|
|
||||||
|
Alle Felder additiv/optional (Alt-Projekte laden unverändert — die bestehende
|
||||||
|
Konvention der Datei, vgl. `wingCount?`, `sliceTermination` Default). Neue Felder
|
||||||
|
auf `WindowType`/`DoorType` in `src/model/types.ts`.
|
||||||
|
|
||||||
|
### P0 — die erste reiche Scheibe (Kern-Tiefe)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Ein Flügel- oder Pfosten-Eintrag der Flügeleinteilung.
|
||||||
|
type OpeningKind = "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe";
|
||||||
|
interface SashDef {
|
||||||
|
kind: "fluegel" | "pfosten";
|
||||||
|
autoWidth?: boolean; // gleichmäßig aufteilen (Default true)
|
||||||
|
width?: number; // feste Flügelbreite (m), wenn !autoWidth
|
||||||
|
postWidth?: number; // Pfostenbreite (m), nur kind="pfosten"
|
||||||
|
opening?: OpeningKind; // Öffnungsart DIESES Flügels
|
||||||
|
hingeSide?: "left" | "right"; // Anschlag
|
||||||
|
}
|
||||||
|
interface WindowType {
|
||||||
|
// … bestehend …
|
||||||
|
sashes?: SashDef[]; // ersetzt/erweitert wingCount; fehlt ⇒ aus wingCount abgeleitet
|
||||||
|
shading?: { // Rollladen/Sonnenschutz-Kasten (P0-Minimalfassung)
|
||||||
|
create: boolean;
|
||||||
|
kind: "rollladen" | "raffstore" | "markise";
|
||||||
|
box: { width: number; height: number; depth: number };
|
||||||
|
};
|
||||||
|
glazingPanes?: 1 | 2 | 3; // Verglasung endlich RENDER-wirksam (heute: glazing unbenutzt)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale/Render-Freischaltung:
|
||||||
|
- `sashes` — schaltet echte, ungleichmäßige Flügel/Pfosten + Öffnungsrichtung je
|
||||||
|
Flügel frei; treibt neue Pfostenlinien in `windowSymbol`/`mullionLines`
|
||||||
|
(`src/geometry/opening.ts`) und Pfosten-Boxen in `frameMeshesForOpening`
|
||||||
|
(`src/plan/toWalls3d.ts:1938`). Höchster sichtbarer Mehrwert.
|
||||||
|
- `shading` — der Rollladenkasten ist ein einzelnes Kästchen: eine 2D-Rechteckkontur
|
||||||
|
über der Öffnung (neuer Zeichenzweig in `addOpeningSymbol`) + eine Box in
|
||||||
|
`emitOpeningFrames`/eigener Emitter. Wenig Code, große „Reichhaltigkeit".
|
||||||
|
- `glazingPanes` — `glassPanesForOpening` (:1965) liest die Scheibenzahl statt
|
||||||
|
konstant EINE Scheibe zu zeichnen; 2D-Glaslinien-Anzahl analog. Schließt die
|
||||||
|
auffälligste Konsum-Lücke.
|
||||||
|
|
||||||
|
### P1 — Tiefe der Flügel/Felder
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SashDef { /* + */ rebate?: number; openAngle?: number; handle?: "drueck"|"knauf"|"none"; }
|
||||||
|
interface WindowType {
|
||||||
|
frameWidths?: { left: number; right: number; top: number; bottom: number };
|
||||||
|
frameSymmetric?: boolean;
|
||||||
|
uniformPosts?: boolean;
|
||||||
|
transom?: { height: number; frame?: number; muntins?: { rows: number; cols: number } };
|
||||||
|
sill?: { aussen?: SillDef; innen?: SillDef };
|
||||||
|
muntins?: { rows: number; cols: number }; // Sprossen im Hauptfeld
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: asymmetrische Rahmenbreiten, richtiges Oberlicht (statt nur feste Scheibe
|
||||||
|
via `transomHeight`), Fensterbank (heute `sillBoard` ungenutzt), Sprossengitter.
|
||||||
|
Alles hängt an vorhandenen Zeichen-/Mesh-Funktionen; nur Parameterausbau.
|
||||||
|
|
||||||
|
### P2 — Detailbestandteile
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WindowType {
|
||||||
|
reveal?: { create: boolean; depth: number; thickness: number }; // Laibungsverkleidung
|
||||||
|
underlight?: { height: number; frame?: number }; // Unterlicht
|
||||||
|
niche?: { aussen?: boolean; innen?: boolean; depth: number }; // Nische
|
||||||
|
headShape?: "eckig" | "schraeg_links" | "schraeg_rechts" | "spitz" | "rund";
|
||||||
|
handleGeom?: { kind: "quader" | "rund"; masse: Record<string, number> };
|
||||||
|
}
|
||||||
|
interface Opening { foreground?: string; background?: string; strokeWeight?: number; } // Bestandteil-Attribute
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: Form (Rundbogen/Schräge → neue Öffnungsumriss-Geometrie), Laibung/Nische,
|
||||||
|
Beschlag-Geometrie, per-Instanz-Attribute (analog Wand/Decke, die es schon haben).
|
||||||
|
|
||||||
|
### P3 — VW-Ballast (nur wenn je nachgefragt)
|
||||||
|
|
||||||
|
Bemaßungs-Bezugsschemata (B1..B5/H1..H7), Sichtbarkeitsmatrix 3D×Ansichten,
|
||||||
|
IFC/Energos, Geländer, Heizkörper, Eckfenster, eigenes Symbol, Fassadenmodul-Versatz.
|
||||||
|
Siehe Abschnitt 5.
|
||||||
|
|
||||||
|
**Höchster-Wert-Reihenfolge (verdichtet):** (1) `sashes` inkl. Öffnungsrichtung je
|
||||||
|
Flügel, (2) mehrscheibige Verglasung, (3) Rollladenkasten, (4) Bank/Nische,
|
||||||
|
(5) Sprossengitter, (6) Form (Rund/Spitz/Schräg), (7) Ober-/Unterlicht als eigene
|
||||||
|
Felder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was NICHT bauen / Risiken
|
||||||
|
|
||||||
|
**Nicht bauen (VW-Komplexität ohne DOSSIER-Nutzen):**
|
||||||
|
|
||||||
|
- **Sichtbarkeitsmatrix „3D-Objekte × 9 Ansichten"** (Oben/Unten/Horizontalschnitt/
|
||||||
|
Vorne/Hinten/Frontalschnitt/Links/Rechts/Querschnitt). DOSSIER rendert heute
|
||||||
|
primär Grundriss; Schnitt/Ansicht sind `DrawingLevelKind`-Platzhalter. Eine
|
||||||
|
Matrix mit ~13×9 An/Aus-Zellen ist Pflegehölle ohne Zielrenderer. `DetailLevel`
|
||||||
|
(grob/mittel/fein) genügt als Sichtbarkeitsachse.
|
||||||
|
- **Bemaßungs-Bezugssystem (Roh-/Fertigmaß, B1..B5/H1..H7).** DOSSIER trägt lichte
|
||||||
|
Maße; eine parametrische Öffnungs-Maßkette existiert nicht. Sehr teuer, geringer
|
||||||
|
Nutzen für ein CAAD-Werkzeug dieser Reife.
|
||||||
|
- **IFC-Datenmapping-Tiefe, Energos, Infopalette.** Kein IFC-Export-Pfad vorhanden;
|
||||||
|
bewusst weglassen (kein Schein-Feature, vgl. die Modell-Doku-Konvention).
|
||||||
|
- **Heizkörper, Geländer, Eckfenster** als Fenster-Unterobjekte — das sind eigene
|
||||||
|
Bauteile bzw. topologische Sonderfälle (zwei Wände). Nicht ins Fenster packen.
|
||||||
|
- **Eigenes Symbol verwenden** (Drawing2D-Override je Stil) — erst wenn ein
|
||||||
|
Symbol-/Blocksystem existiert.
|
||||||
|
|
||||||
|
**Risiken:**
|
||||||
|
|
||||||
|
- **3D-Vorschau nicht als „korrekt" verkaufen.** Laut Projekt-Memo werden
|
||||||
|
`render3d`/`Wasm3DViewport`-Änderungen NICHT per Puppeteer/Browser verifiziert;
|
||||||
|
der Nutzer testet 3D selbst in der Tauri-Dev-App. Der Dialog darf 3D anzeigen,
|
||||||
|
aber diese Studie/Implementierung behauptet keine visuelle Korrektheit der
|
||||||
|
3D-Vorschau — nur der 2D-Pfad (`generatePlan`→SVG) ist headless prüfbar.
|
||||||
|
- **Typ-vs-Instanz-Verwirrung.** Ohne klaren „vom Stil abweichend"-Indikator wird
|
||||||
|
unklar, ob eine Änderung den Stil (alle Instanzen) oder nur dieses Fenster trifft.
|
||||||
|
Muss im UI explizit sein (§3.4).
|
||||||
|
- **`sashes` vs. `wingCount` Migration.** `wingCount` bleibt als Fallback; `sashes`
|
||||||
|
hat Vorrang, wenn gesetzt. Resolver in `resolveOpeningFrame`
|
||||||
|
(`toWalls3d.ts:1845`) und `windowSymbol` müssen beide Wege beherrschen, sonst
|
||||||
|
brechen Alt-Projekte oder die `ObjectInfoPanel`-Flügelanzahl-Eingabe.
|
||||||
|
- **Modal-Render-Kosten.** Live-Vorschau bei jedem Tastendruck neu generieren ist
|
||||||
|
ok für ein Mini-Projekt, aber Patches sollten (leicht) entprellt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empfohlene erste Scheibe (P0, konkret)
|
||||||
|
|
||||||
|
Kleinstes End-to-End-Inkrement, das sich schon „reich" anfühlt:
|
||||||
|
|
||||||
|
**Umfang:** Dialog-Shell + Live-2D-Vorschau + Flügeleinteilung (n Flügel mit
|
||||||
|
Öffnungsrichtung je Flügel) + mehrscheibige Verglasung + Rollladenkasten +
|
||||||
|
„Als Stil speichern".
|
||||||
|
|
||||||
|
**Build-Reihenfolge:**
|
||||||
|
|
||||||
|
1. **Modell (P0-Felder).** `SashDef`, `WindowType.sashes?`, `WindowType.shading?`,
|
||||||
|
`WindowType.glazingPanes?` in `src/model/types.ts` ergänzen (additiv). Resolver
|
||||||
|
`getWindowType` bleibt; `wingCount`→`sashes`-Ableitung als Helper.
|
||||||
|
2. **Renderer-Konsum (2D zuerst, headless prüfbar).**
|
||||||
|
- `windowSymbol`/`mullionLines` (`src/geometry/opening.ts`) auf `sashes` umstellen
|
||||||
|
(ungleichmäßige Pfostenlagen + Öffnungsrichtung je Flügel als Öffnungslinien).
|
||||||
|
- `glassPanesForOpening`/2D-Glaslinien auf `glazingPanes` (statt konstant 1/detail).
|
||||||
|
- neuer Zeichenzweig in `addOpeningSymbol` (`src/plan/generatePlan.ts:2214`) für
|
||||||
|
die Rollladenkasten-Kontur; 3D-Box später.
|
||||||
|
- Unit-Tests gegen `generatePlan()`-Output (Primitive zählen) — das ist der
|
||||||
|
verifizierbare Beweis, nicht nur „grüne Typecheck".
|
||||||
|
3. **Dialog-Shell.** `src/ui/OpeningEditorDialog.tsx` (Overlay `res-overlay` +
|
||||||
|
`role="dialog"`), Sidebar mit den Kategorien Basis / Größe/Position / Rahmen /
|
||||||
|
Flügel-Sprossen / Sonnenschutz. Nur diese fünf für P0.
|
||||||
|
4. **Live-2D-Vorschau.** Mini-Projekt (kurze Wand + editiertes `Opening`) →
|
||||||
|
`generatePlan()` → `sceneToPrintSvg()`/`planToPrintSvg()` → `<div>`. Grundriss +
|
||||||
|
Elevations-SVG. Reaktiv auf Patches (leicht entprellt).
|
||||||
|
5. **Flügel-Tabelle.** UI-Tabelle mit „Flügel einfügen / Pfosten einfügen / Löschen"
|
||||||
|
und je Zeile Öffnungsart + Anschlag (`SashDef`). Schreibt `WindowType.sashes`.
|
||||||
|
6. **„Als Stil speichern".** Stil-Leiste + `onSaveAsStyle` → `addWindowType`
|
||||||
|
(`src/App.tsx:2304`) mit `PromptDialog`-Namensabfrage; „zurücksetzen" verwirft
|
||||||
|
Instanz-Overrides.
|
||||||
|
7. **⚙-Verdrahtung.** `ObjectInfoPanel.OpeningSection` (:731) → neuer Host-Handler
|
||||||
|
`onOpenOpeningEditor(openingId)`; App-State `openingEditorId`. Ressourcen-Tab
|
||||||
|
bleibt als Bibliothek erhalten.
|
||||||
|
|
||||||
|
**Definition of Done (P0):** Aus dem ⚙ öffnet sich der Dialog; man setzt 3 Flügel,
|
||||||
|
davon einer Dreh-links / einer Dreh-rechts / einer fest, wählt Dreifachverglasung
|
||||||
|
und einen Rollladenkasten; die 2D-Vorschau zeigt die Pfosten/Öffnungslinien/Kasten
|
||||||
|
sofort; „Speichern…" legt einen benannten Fensterstil an, der im Typ-Dropdown der
|
||||||
|
`OpeningSection` erscheint. Verifikation über `generatePlan()`-Primitiv-Tests
|
||||||
|
(2D) — 3D-Box erst danach, vom Nutzer in der Tauri-App geprüft.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# SIA 400 — Fenster-/Türdarstellung (Grundriss, Schnitt, Ansicht)
|
||||||
|
|
||||||
|
> Quelle: SIA 400:2000 „Planbearbeitung im Hochbau", Anhang B.9 (Darstellung von
|
||||||
|
> Bauteilen), Figuren 36–42. Das PDF liegt im Projektwurzel (lokal excluded,
|
||||||
|
> NICHT committen — urheberrechtlich geschützt). Dies ist die verbindliche
|
||||||
|
> Darstellungsnorm (Schweiz) — NICHT DIN. Massstab = Detailgrad:
|
||||||
|
> **1:100 = grob, 1:50 = mittel, 1:20 = fein.**
|
||||||
|
|
||||||
|
## B.9.1.1 Fenster im Grundriss (Figuren 36/37/38)
|
||||||
|
|
||||||
|
Regel (Zitat): „Die Darstellung der Fensterkonstruktionen erfolgt nach denselben
|
||||||
|
Regeln, unabhängig davon ob es sich um Holz-, Holz-Metall-, Metall- oder
|
||||||
|
Kunststoff-Fenster handelt."
|
||||||
|
|
||||||
|
### Figur 36 — Massstab 1:100 (= grob)
|
||||||
|
- Wand **voll schwarz** (Poché massiv), keine Schraffur.
|
||||||
|
- Fenster = **eine dünne Rahmen-/Glasband-Andeutung** über die Öffnung, sehr
|
||||||
|
schematisch — im Wesentlichen EIN schmales Rechteck/Band mit wenigen Marken,
|
||||||
|
KEINE Flügel-/Anschlagdetails, KEINE Laibungsmarken.
|
||||||
|
|
||||||
|
### Figur 37 — Massstab 1:50 (= mittel)
|
||||||
|
- Wand **materialschraffiert** (Bänder: Hinterlüftung/Dämmung senkrecht
|
||||||
|
gestrichelt, Backstein/Struktur diagonal). Drei Wandaufbauten gezeigt
|
||||||
|
(Backstein+Aussendämmung hinterlüftet / Holzelementbau / Backstein verputzt).
|
||||||
|
- Fenster: **Blendrahmen als schmales Band** nahe der Aussenfläche, dünne
|
||||||
|
Kontur; **Glaslinie**; **ein kleines Quadrat mittig** (Stulp/Flügelstoss).
|
||||||
|
Laibung/Öffnungstiefe sichtbar. Einfach, aber als Fenster lesbar.
|
||||||
|
|
||||||
|
### Figur 38 — Massstab 1:20 (= fein)
|
||||||
|
- Wand voll schraffiert (Kreuzschraffur Backstein, Diagonalschraffur Dämmung).
|
||||||
|
- Fenster: **volles Rahmenprofil** — Blendrahmen + Flügelrahmen als mehrere
|
||||||
|
parallele Linien (Profiltiefe); **Glas als Doppellinie** (Isolierverglasung
|
||||||
|
IV, zwei enge Parallelen); **zwei Stulp-Quadrate mittig** (Flügelstoss der
|
||||||
|
zwei Flügel); **Anschlag/Laibung** mit Rahmen-Wand-Detail (Falz/Rebate),
|
||||||
|
gestrichelte Anschlagmarken an den Ecken; Anschlagwinkel an den Laibungen.
|
||||||
|
|
||||||
|
## B.9.1.2 Fenster im Schnitt (Figuren 39/40, M 1:50)
|
||||||
|
- Rahmen im Schnitt mit Brüstung/Sturz, Bemassung (z. B. Brüstung +0.90,
|
||||||
|
Sturzhöhe). Fenstertüren (Figur 40): Rahmen bis Boden, Schwelle.
|
||||||
|
|
||||||
|
## B.9.1.3 Sinnbilder Fenster (Öffnungsart in der ANSICHT)
|
||||||
|
Liste der Öffnungsarten (SIA-Benennung):
|
||||||
|
- Fest im Rahmen verglast (kein Symbol)
|
||||||
|
- Drehflügel einflüglig mit Verschluss, **Band rechts/links** (Dreieck, Spitze zur BANDSEITE)
|
||||||
|
- Drehflügel fest mit Band und Plattenverschraubung
|
||||||
|
- Zweiflüglig mit **Öffnungsreihenfolge** (1 = erstöffnend, 2 = zweitöffnend)
|
||||||
|
- Kippflügel mit Verschluss (Dreieck)
|
||||||
|
- Kippflügel fest, für Reinigung bedienbar
|
||||||
|
- Klappflügel mit Verschluss
|
||||||
|
- Drehkippflügel, Band rechts
|
||||||
|
- Schwingflügelfenster
|
||||||
|
- Wendeflügelfenster, Achse in der Mitte
|
||||||
|
- Vertikales Schiebefenster (nach oben schiebbar, oberer Flügel fest)
|
||||||
|
|
||||||
|
Konvention: Dreieck-/Winkelsinnbild, **Spitze zeigt zur Bandseite (Anschlag)**,
|
||||||
|
Basis zur Griffseite. (Genaues Sinnbild-Blatt S. 38 rechts.)
|
||||||
|
|
||||||
|
## B.9.1.4 Kurzzeichen Fenster/Sonnenschutz
|
||||||
|
KL Klappladen · SL Schiebeladen · ROL Rolladen · LAM Lamellenstoren ·
|
||||||
|
RAF Rafflamellenstoren · FAL Faltrolladen · K Kurbel · DV Doppelverglasung ·
|
||||||
|
IV Isolierverglasung · IV3 Dreifachisolierverglasung · BFB Beton-Fensterbank ·
|
||||||
|
MFB Metall-Fensterbank · FFB Faserzement-Fensterbank.
|
||||||
|
|
||||||
|
## B.9.2 Türen im Grundriss (Figuren 41/42) + Sinnbilder (B.9.2.2)
|
||||||
|
- **Türblatt als Linie ab Band + Viertelkreisbogen** (Schwingbahn). Bandseite =
|
||||||
|
Bogenmittelpunkt. Zweiflüglig/Doppeltüre = zwei Bögen.
|
||||||
|
- Zargenarten: Futterrahmen/Zarge, Blockrahmen/Profil, Blendrahmen — je mit
|
||||||
|
Anschlag-/Schwellenmark. Bei Niveaudifferenz Schwellenstrich.
|
||||||
|
- Weitere Sinnbilder: Pendeltüre (Bogen beidseitig gestrichelt), Falttüre
|
||||||
|
(Zickzack), Faltschiebetor, Drehtüre (Kreis mit X), Kipptor, Schiebetüre
|
||||||
|
(ausserhalb/in der Wand, Pfeil), Harmonikatüre.
|
||||||
|
|
||||||
|
## Umsetzung im Code (Soll)
|
||||||
|
- `generatePlan.ts` Fenster-Zweig + `geometry/opening.ts::windowSymbol`:
|
||||||
|
drei DEUTLICH getrennte Detailstufen statt heute (mittel≈fein):
|
||||||
|
- **grob:** 1 Rahmen-/Glasband (Rechteck), sonst nichts.
|
||||||
|
- **mittel:** Blendrahmen-Rechteck + Glaslinie + 1 Stulp-Quadrat mittig +
|
||||||
|
Flügel-Trennlinien; keine verschachtelten Flügelrahmen, kein Anschlag.
|
||||||
|
- **fein:** verschachtelte Blend-+Flügelrahmen (mehrlinig) + Glas-Doppellinie
|
||||||
|
(IV) + Stulp-Quadrate + Anschlag/Laibungsmarken.
|
||||||
|
- Öffnungssymbole (Ansicht) auf SIA umstellen (Spitze zur Bandseite), Türbögen
|
||||||
|
bleiben (schon SIA-konform).
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
# Swisstopo-Geodaten & SIA-Flächenstandards im Browser-BIM
|
||||||
|
|
||||||
|
> Stand: 2026-06-29 · Recherche für das Standalone-Browser-BIM (React + TS + Three.js),
|
||||||
|
> Port von **DOSSIER** (Rhino-Plugin). Ziel: (A) Schweizer Geodaten (Höhenmodell,
|
||||||
|
> Orthofoto, 3D-Gebäude, Parzellen) direkt im Browser laden, (B) Standort-Kontext
|
||||||
|
> (Gelände + Parzelle + Nachbargebäude) für ein Projekt importieren, (C) SIA-416-
|
||||||
|
> Flächen/Volumen + Raumschemata berechnen wie in DOSSIER.
|
||||||
|
>
|
||||||
|
> Bezug zur ROADMAP: Swisstopo/Terrain/OSM = **Phase 4** (Kontext/Daten); SIA-416-
|
||||||
|
> Räume + Bilanz-CSV = **Phase 2**. Beide sind dort bereits als ⭐-Features gelistet.
|
||||||
|
|
||||||
|
**Alle in diesem Dokument genannten geo.admin.ch-Endpunkte wurden am 2026-06-29 live
|
||||||
|
gegen die echte API getestet** (curl + CORS-Header-Check). Wo „verifiziert" steht,
|
||||||
|
liegt eine echte Antwort vor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil A — Swisstopo-APIs & Dienste aus dem Browser
|
||||||
|
|
||||||
|
### A.0 Das Wichtigste vorweg: CORS & Lizenz
|
||||||
|
|
||||||
|
Zwei Fragen entscheiden, ob ein Dienst *ohne Backend-Proxy* aus einer reinen
|
||||||
|
Browser-App nutzbar ist: CORS und Lizenz. Beide sind hier günstig.
|
||||||
|
|
||||||
|
**CORS (live verifiziert):** Alle relevanten Hosts senden `access-control-allow-origin: *`:
|
||||||
|
|
||||||
|
| Host | Dienst | CORS | Range-Requests |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api3.geo.admin.ch` | REST (height, profile, identify, find, search) | ✅ `*` | — |
|
||||||
|
| `data.geo.admin.ch` | STAC-API + Daten-Assets (COG-GeoTIFF, XYZ.zip) | ✅ `*` | ✅ `206 Partial Content`, `accept-ranges`/`content-range` vorhanden |
|
||||||
|
| `wmts.geo.admin.ch` | WMTS-Kacheln | ✅ `*` | — |
|
||||||
|
| `3d.geo.admin.ch` | 3D-Tiles (`tileset.json` + glTF) | ✅ `*` | — |
|
||||||
|
|
||||||
|
→ **Konsequenz:** Höhenabfrage, Geocoding, Parzellen-Identify, Karten-/Orthofoto-
|
||||||
|
Kacheln, **COG-GeoTIFF-Höhenmodell per Range-Request** und 3D-Tiles sind **direkt aus
|
||||||
|
dem Browser ohne eigenen Proxy** abrufbar. Das ist ein großer Vorteil gegenüber vielen
|
||||||
|
anderen nationalen Geodiensten.
|
||||||
|
|
||||||
|
**Lizenz:** swisstopo/geo.admin.ch ist **Open Government Data**: „The acquisition and
|
||||||
|
use of data or services is free of charge, subject to the provisions on fair use."
|
||||||
|
Kommerzielle Nutzung ist erlaubt, Einbindung in (auch kommerzielle) Web-Apps explizit
|
||||||
|
gedeckt. Pflicht-Attribution: **`© swisstopo`** (bzw. „© Data: swisstopo"). „Fair use"
|
||||||
|
= z.B. Web-App mit Ø 20'000 Nutzern/Tag ok; aggressives Bot-Scraping vermeiden. Haftung
|
||||||
|
ausgeschlossen, ~98% Verfügbarkeit. [Terms of use FSDI](https://www.geo.admin.ch/en/general-terms-of-use-fsdi)
|
||||||
|
|
||||||
|
> ⚠️ **Korrektur zu DOSSIER & zur Doku:** Die offizielle REST-Doku notiert beim
|
||||||
|
> *Height*-Service „This service is not freely accessible (fee required)". Das ist
|
||||||
|
> **in der Praxis falsch / veraltet**: Der Endpunkt antwortet anonym, ohne Key, mit
|
||||||
|
> `200` und CORS `*` (verifiziert, siehe A.1). DOSSIERs Aussage „alle APIs offen, ohne
|
||||||
|
> Auth, ohne Key" deckt sich mit der gemessenen Realität. Wir verlassen uns aber nicht
|
||||||
|
> blind darauf, sondern behandeln 402/429 defensiv (Retry/Backoff, Cache).
|
||||||
|
|
||||||
|
### A.1 Höhenabfrage — Height-Service (Einzelpunkt)
|
||||||
|
|
||||||
|
Punkt-Höhe (DTM) aus swissALTI3D/DTM. **Verifiziert:**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/height?easting=2600000&northing=1200000&sr=2056
|
||||||
|
→ {"height":"555.5"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter:
|
||||||
|
- `easting`, `northing` — LV95 (`sr=2056`) oder LV03 (`sr=21781`). **Pflicht.**
|
||||||
|
- `sr` — `2056` (LV95) angeben, sonst Default `21781`.
|
||||||
|
- `elevation_model` — `DTM2` (= swissALTI3D, 2 m), `DTM25` (Default), `COMB`.
|
||||||
|
(Im Tal lieferten DTM2/DTM25/COMB denselben Wert; im Steilgelände kann DTM2 genauer sein.)
|
||||||
|
- `callback` — JSONP (brauchen wir wegen CORS nicht).
|
||||||
|
|
||||||
|
Nutzung im Tool: **Projekt-Nullpunkt-Z** bzw. „Gebäude auf Gelände setzen" — eine
|
||||||
|
einzelne Höhe an der Projekt-Koordinate. Antwortzeit ~50–150 ms. Quelle:
|
||||||
|
[GeoAdmin REST – Height](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
|
||||||
|
|
||||||
|
### A.2 Höhenprofil — Profile-Service (Schnittlinie)
|
||||||
|
|
||||||
|
Höhen entlang einer Polylinie — ideal für **Geländeschnitt** unter einem
|
||||||
|
Gebäude-Schnitt. **Verifiziert** (echte Werte zurück):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/profile.json
|
||||||
|
?geom={"type":"LineString","coordinates":[[2600000,1200000],[2600200,1200000]]}
|
||||||
|
&sr=2056&nb_points=3
|
||||||
|
→ [{"alts":{"COMB":555.5,"DTM2":555.5,"DTM25":555.5},"dist":0,"easting":2600000,"northing":1200000},
|
||||||
|
{"alts":{...},"dist":100,...}, {"dist":200,...}]
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter: `geom` (GeoJSON-LineString, max 6'000 Punkte), `sr`, `nb_points` (Anzahl
|
||||||
|
Stützpunkte, Default 200), `elevation_models`, `offset` (Glättung). Auch als
|
||||||
|
`profile.csv`. → Für 2D-Geländeschnitte **ohne** Mesh-Download. Quelle:
|
||||||
|
[GeoAdmin REST – Profile](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html)
|
||||||
|
|
||||||
|
### A.3 swissALTI3D — Höhenmodell als COG-GeoTIFF (Mesh-Quelle) ⭐
|
||||||
|
|
||||||
|
Das ist der **Schlüssel für das Gelände-Mesh im Browser**. swissALTI3D ist das präzise
|
||||||
|
DTM der Schweiz (ohne Vegetation/Bebauung), Auflösung 0.5 m / 2 m, alle 6 Jahre
|
||||||
|
aktualisiert ([swissALTI3D](https://www.swisstopo.admin.ch/en/height-model-swissalti3d)).
|
||||||
|
Bezug über die **STAC-API** (verifiziert — Tile `swissalti3d_2019_2599-1198`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://data.geo.admin.ch/api/stac/v1/collections/ch.swisstopo.swissalti3d/items
|
||||||
|
?bbox=<lonMin,latMin,lonMax,latMax>&limit=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes 1×1-km-Tile liefert pro Auflösung **zwei** Asset-Typen:
|
||||||
|
|
||||||
|
| Asset | Typ | Browser-tauglich? |
|
||||||
|
|---|---|---|
|
||||||
|
| `..._0.5_2056_5728.tif` / `..._2_2056_5728.tif` | **Cloud-Optimized GeoTIFF**, **EPSG:2056** | ✅ **direkt** via `geotiff.js` + Range |
|
||||||
|
| `..._0.5_2056_5728.xyz.zip` / `..._2_..._xyz.zip` | ASCII-XYZ (E N Z) in ZIP | ✅ via `fflate` entpacken (so macht es DOSSIER) |
|
||||||
|
|
||||||
|
**Verifiziert:** `data.geo.admin.ch` liefert auf das `.tif` ein `206 Partial Content`
|
||||||
|
mit `content-range` bei `Range:`-Header und CORS `*`. Das bedeutet: **`geotiff.js`
|
||||||
|
liest nur den benötigten Ausschnitt eines COG per HTTP-Range, ohne das ganze File zu
|
||||||
|
laden** — perfekt für eine Browser-App. Bbox in WGS84 für STAC, Tile-Daten dann in
|
||||||
|
LV95-Metern (kein Reprojizieren der Z-Werte nötig). Quelle:
|
||||||
|
[STAC tech docs](https://docs.geo.admin.ch/) · COG-Tile live geprüft.
|
||||||
|
|
||||||
|
### A.4 SWISSIMAGE / Karten — WMTS-Kacheln
|
||||||
|
|
||||||
|
Orthofoto (10 cm) und Landeskarten als Kacheln. RESTful-URL-Template:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://wmts.geo.admin.ch/1.0.0/<Layer>/default/<Time>/<TileMatrixSet>/<z>/<TileCol>/<TileRow>.<ext>
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel-Layer:
|
||||||
|
- `ch.swisstopo.swissimage` — Orthofoto, `.jpeg`
|
||||||
|
- `ch.swisstopo.pixelkarte-farbe` — Landeskarte farbig, `.jpeg`
|
||||||
|
- `ch.kantone.cadastralwebmap-farbe` — **Katasterplan (AV)**, `.png`
|
||||||
|
|
||||||
|
TileMatrixSets: **`2056` (LV95)**, `21781`, `3857` (Web-Mercator), `4326`. Zoom 0–28
|
||||||
|
(4000 m → 0.1 m); Zoom 27/28 nur für wenige Layer (swissimage, Kataster). Für 3D in
|
||||||
|
Three.js am einfachsten **`3857`** (Standard-Slippy-Map-Schema, z/x/y), z.B.
|
||||||
|
|
||||||
|
```
|
||||||
|
https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Für planimetrisch exakte 2D-Arbeit besser **`2056`**. CORS `*` (verifiziert). Quellen:
|
||||||
|
[WMTS docs](https://docs.geo.admin.ch/visualize-data/wmts.html) ·
|
||||||
|
[WMTS service](https://wmts.geo.admin.ch/) ·
|
||||||
|
[WMTS EPSG:2056 CodePen](https://codepen.io/geoadmin/pen/GZKEam).
|
||||||
|
|
||||||
|
> Es gibt zusätzlich klassisches **WMS** (`https://wms.geo.admin.ch/`, GetMap mit
|
||||||
|
> beliebiger BBox/Größe, ebenfalls EPSG:2056). Für ein einzelnes georeferenziertes
|
||||||
|
> Orthofoto-Rechteck unter dem Modell ist ein WMS-GetMap manchmal praktischer als
|
||||||
|
> WMTS-Kacheln zu stitchen. [WMS docs](https://docs.geo.admin.ch/visualize-data/wms.html)
|
||||||
|
|
||||||
|
### A.5 swissBUILDINGS3D — Nachbargebäude (3D)
|
||||||
|
|
||||||
|
Zwei Wege:
|
||||||
|
|
||||||
|
**(a) 3D-Tiles (Streaming, Cesium-Format)** — `glTF`/`tileset.json`, für große Gebiete:
|
||||||
|
```
|
||||||
|
https://3d.geo.admin.ch/<Layer>/<Version>/<Time>/tileset.json
|
||||||
|
```
|
||||||
|
Layer u.a. `ch.swisstopo.swissbuildings3d.3d`, `ch.swisstopo.swisstlm3d.3d`,
|
||||||
|
`ch.swisstopo.swissnames3d.3d`, `ch.swisstopo.vegetation.3d`. `Version` = `v1`, `Time`
|
||||||
|
optional (ISO `YYYYMMDD`, weglassen = aktuellste). CORS `*` (verifiziert auf
|
||||||
|
`tileset.json`). Direkt für CesiumJS gedacht; in **reinem Three.js** über
|
||||||
|
`@loaders.gl/3d-tiles` oder den `3DTilesRendererJS` (NASA-AMMOS/`three.js`-Community)
|
||||||
|
ladbar. [3D-Tiles docs](https://docs.geo.admin.ch/visualize-data/3d-tiles.html) ·
|
||||||
|
[Switzerland in 3D](https://www.swisstopo.admin.ch/en/switzerland-in-3d)
|
||||||
|
|
||||||
|
**(b) STAC-Tiles als CAD/Mesh-Datei (Download pro Tile)** — so macht es DOSSIER:
|
||||||
|
Collections `ch.swisstopo.swissbuildings3d_3_0` (neu; in Städten z.T. >700 MB Tiles)
|
||||||
|
und `ch.swisstopo.swissbuildings3d_2` (1-km-Tiles, ~50 MB, stabil). Assets in
|
||||||
|
`.dxf/.dwg/.obj/.ifc` (+ `.zip`), Varianten `solid`/`separated`. Für den **Import als
|
||||||
|
echte, editierbare Massen** ins eigene Modell ist der OBJ/IFC-Tile-Weg besser als
|
||||||
|
3D-Tiles (die sind read-only Visualisierung). swissBUILDINGS3D: >3 Mio Gebäude,
|
||||||
|
Lage-/Höhengenauigkeit 30–50 cm.
|
||||||
|
|
||||||
|
→ **Empfehlung:** Für „Nachbarschaft als Kontext anzeigen" (Phase 4 Start) **3D-Tiles
|
||||||
|
streamen** (kein Download, kein Parsing). Wenn der Nutzer Nachbargebäude als Geometrie
|
||||||
|
*braucht* (Verschattung, Abstand), **STAC-OBJ-Tile** laden und als Mesh importieren.
|
||||||
|
|
||||||
|
### A.6 Parzelle / Kataster (AV) — Identify-Service ⭐
|
||||||
|
|
||||||
|
**Verifiziert** — Parzellen-Polygon aus einer Koordinate, in LV95:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/api/MapServer/identify
|
||||||
|
?geometry=2600423,1199521&geometryType=esriGeometryPoint
|
||||||
|
&imageDisplay=100,100,96&mapExtent=2600323,1199421,2600523,1199621
|
||||||
|
&tolerance=2&layers=all:ch.kantone.cadastralwebmap-farbe
|
||||||
|
&returnGeometry=true&geometryFormat=geojson&sr=2056
|
||||||
|
→ {"results":[{"type":"Feature","bbox":[...],
|
||||||
|
"geometry":{"type":"Polygon","coordinates":[[ [2600377.1,1199523.7], ... ]]},
|
||||||
|
"attributes":{"number":"698","egris_egrid":"CH507635214670","ak":"BE", ...}}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Parzellen-Layer: **`ch.kantone.cadastralwebmap-farbe`** (liefert `number`,
|
||||||
|
`egris_egrid` = EGRID, Kanton; mit `returnGeometry=true&geometryFormat=geojson` das
|
||||||
|
**Parzellen-Polygon in LV95-Metern** → direkt als Grundstücksgrenze importierbar).
|
||||||
|
- Gebäudeadressen: `ch.swisstopo.amtliches-gebaeudeadressverzeichnis`; Gebäude-/
|
||||||
|
Wohnungsregister `ch.bfs.gebaeude_wohnungs_register` (EGID).
|
||||||
|
- Pflichtparameter: `geometry`, `geometryType` (`esriGeometryPoint|...Polygon|...Envelope`),
|
||||||
|
`mapExtent`, `imageDisplay`, `tolerance`; `sr=2056`; max 50 Features/Request.
|
||||||
|
|
||||||
|
Quellen: [Identify features](https://docs.geo.admin.ch/access-data/identify-features.html) ·
|
||||||
|
[GeoAdmin REST – Identify/Find](https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html) ·
|
||||||
|
Live-Antwort oben.
|
||||||
|
|
||||||
|
### A.7 Geocoding — SearchServer (Adresse → LV95)
|
||||||
|
|
||||||
|
**Verifiziert** (Adresse → Koordinate, deckt sich mit DOSSIERs `geocode()`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api3.geo.admin.ch/rest/services/api/SearchServer
|
||||||
|
?searchText=Bundesplatz 3 Bern&type=locations&origins=address&sr=2056&limit=1
|
||||||
|
→ results[0].attrs: { label:"Bundesplatz 3 <b>3011 Bern</b>", lat:46.94677, lon:7.44419,
|
||||||
|
geom_st_box2d:"BOX(2600423.26 1199521.11, ...)", origin:"address", ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type=locations`, `origins` aus `{address, parcel, gg25, gazetteer, zipcode, district,
|
||||||
|
kantone}`, `sr=2056`. **Im LV95-Modus liefert die Geo-Admin-Konvention `y`=East,
|
||||||
|
`x`=North** (DOSSIER liest genau so: `e=attrs.y`, `n=attrs.x`). Labels enthalten
|
||||||
|
`<b>`-Tags (strippen).
|
||||||
|
- `type=featuresearch` + `features=<layer>` durchsucht Attribute (z.B. Parzellennummer).
|
||||||
|
|
||||||
|
Quelle: [Search](https://docs.geo.admin.ch/access-data/search.html) · Live-Antwort oben.
|
||||||
|
|
||||||
|
### A.8 Koordinatensystem LV95 / EPSG:2056 & Transformationen
|
||||||
|
|
||||||
|
Intern rechnet das BIM-Tool in **Metern** (ROADMAP-Konvention) und verschiebt den
|
||||||
|
Projekt-Ursprung nahe (0,0,0); LV95-Koordinaten sind ~2.6 Mio / 1.2 Mio Meter groß und
|
||||||
|
würden bei `float32` (Three.js) zu **Jitter** führen → **Origin-Shift Pflicht** (siehe B.3).
|
||||||
|
DOSSIER macht genau das (`origin_shift`/`shift_lv95`, typ. bbox-Center → 0/0/0).
|
||||||
|
|
||||||
|
Transformations-Optionen:
|
||||||
|
|
||||||
|
1. **`proj4` (npm `proj4@2.20.9`)** — universell, exakt. EPSG:2056-Definition:
|
||||||
|
```js
|
||||||
|
proj4.defs("EPSG:2056",
|
||||||
|
"+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 "+
|
||||||
|
"+x_0=2600000 +y_0=1200000 +ellps=bessel "+
|
||||||
|
"+towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs");
|
||||||
|
const [e,n] = proj4("EPSG:4326","EPSG:2056",[lon,lat]); // WGS84→LV95
|
||||||
|
```
|
||||||
|
Genauigkeit mit dieser 3-Parameter-`towgs84` ~1 m (für Kontext-Import völlig
|
||||||
|
ausreichend). Types: `@types/proj4`. Quellen:
|
||||||
|
[epsg.io/2056](https://epsg.io/2056) · [proj4js](https://github.com/proj4js/proj4js).
|
||||||
|
|
||||||
|
2. **Näherungsformeln (CH1903→WGS84, swisstopo)** — DOSSIERs Ansatz, ~1 m genau,
|
||||||
|
**0 Dependencies** (zwei kleine Funktionen `lv95_to_wgs84`/`wgs84_to_lv95`).
|
||||||
|
1:1 nach TS portierbar; gut, wenn man `proj4` nicht ziehen will. Reicht, weil
|
||||||
|
STAC-Queries ohnehin nur eine grobe WGS84-Bbox brauchen und alle *Daten* schon in
|
||||||
|
LV95 kommen.
|
||||||
|
|
||||||
|
3. **swisstopo REFRAME Web-API** — cm-genaue offizielle Umrechnung (LV95↔WGS84,
|
||||||
|
LN02↔Bessel). Nur nötig, wenn Vermessungs-Genauigkeit verlangt wird. REST, online.
|
||||||
|
[REFRAME Web](https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web)
|
||||||
|
|
||||||
|
**Empfehlung:** `proj4` mit fester EPSG:2056-Def (eine Abhängigkeit, exakt genug,
|
||||||
|
wartungsarm) — oder, wenn Dependency-Geiz, DOSSIERs Formeln portieren. REFRAME nur bei
|
||||||
|
Bedarf nachrüsten.
|
||||||
|
|
||||||
|
### A.9 Endpoint-Übersicht (Spickzettel)
|
||||||
|
|
||||||
|
| Zweck | Endpoint | Frei/CORS | Format |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Punkt-Höhe | `api3…/rest/services/height` | ✅ ✅ | JSON |
|
||||||
|
| Höhenprofil (Schnitt) | `api3…/rest/services/profile.json` | ✅ ✅ | JSON/CSV |
|
||||||
|
| Gelände-Mesh (COG) | STAC `…/swissalti3d/items` → `.tif` (COG, 2056) | ✅ ✅ Range | GeoTIFF |
|
||||||
|
| Gelände (ASCII) | STAC `…/swissalti3d` → `.xyz.zip` | ✅ ✅ | XYZ in ZIP |
|
||||||
|
| Orthofoto/Karte | `wmts…/1.0.0/<layer>/…/{z}/{x}/{y}.jpeg` | ✅ ✅ | Kacheln |
|
||||||
|
| 3D-Nachbargebäude (stream) | `3d…/ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` | ✅ ✅ | 3D-Tiles/glTF |
|
||||||
|
| 3D-Gebäude (Datei) | STAC `…/swissbuildings3d_2` → `.obj/.ifc` | ✅ ✅ | OBJ/IFC |
|
||||||
|
| Parzelle/Kataster | `api3…/MapServer/identify` `layers=all:ch.kantone.cadastralwebmap-farbe` | ✅ ✅ | GeoJSON |
|
||||||
|
| Geocoding | `api3…/SearchServer?type=locations` | ✅ ✅ | JSON |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil B — Standort-Kontext importieren (Terrain + Parzelle + Nachbargebäude)
|
||||||
|
|
||||||
|
So bekommt ein Projekt seinen realen Kontext „auf Knopfdruck". Der Ablauf folgt
|
||||||
|
DOSSIER (`rhino/swisstopo.py`), übersetzt auf Browser-Libs.
|
||||||
|
|
||||||
|
### B.1 Pipeline (End-to-End)
|
||||||
|
|
||||||
|
```
|
||||||
|
Adresse/Parzelle ──SearchServer──▶ Zentrum (E,N) in LV95
|
||||||
|
│
|
||||||
|
├─ radius r ──▶ bbox_LV95 (E±r, N±r) ──proj4/Formeln──▶ bbox_WGS84
|
||||||
|
│
|
||||||
|
├─[Parzelle] identify(cadastralwebmap, point) ─▶ Polygon (LV95) ─▶ Grundstücksgrenze (Ebene 01 Vermessung)
|
||||||
|
│
|
||||||
|
├─[Gelände] STAC(swissalti3d, bbox_WGS84) ─▶ COG .tif(2056)
|
||||||
|
│ └─ geotiff.js readRasters(window) ─▶ Höhen-Grid (E,N,Z, m)
|
||||||
|
│ └─ Three.js BufferGeometry (Grid→Mesh) [optional: TIN, Höhenlinien, Volumen]
|
||||||
|
│
|
||||||
|
├─[Orthofoto] WMTS swissimage ─▶ Textur auf Gelände-Mesh ODER georef. Plane
|
||||||
|
│
|
||||||
|
└─[Nachbarn] 3D-Tiles streamen (Anzeige) ODER STAC swissbuildings3d_2 .obj ─▶ Mesh-Import
|
||||||
|
(Weltweit/ausserhalb CH: OSM-Overpass als Fallback, siehe B.5)
|
||||||
|
──▶ alle Geometrien um origin_shift (bbox-Center→0/0/0) verschoben, Z aus ALTI3D
|
||||||
|
```
|
||||||
|
|
||||||
|
### B.2 Gelände-Mesh aus swissALTI3D (Kern, Phase 4)
|
||||||
|
|
||||||
|
**Empfohlener Browser-Weg (COG + geotiff.js):**
|
||||||
|
|
||||||
|
1. STAC-Query mit `bbox_WGS84` → Liste der überlappenden Tiles; pro Tile das
|
||||||
|
gewünschte COG-Asset (`_2_2056_` für 2 m, `_0.5_2056_` für 0.5 m).
|
||||||
|
2. `geotiff.js`: `const tiff = await fromUrl(href)` → COG; `image.readRasters({window})`
|
||||||
|
liest **nur den Ausschnitt** (Range-Requests, da CORS+Range bestätigt). Ergebnis ist
|
||||||
|
ein reguläres Z-Raster mit bekanntem Origin/PixelScale (LV95-Meter) aus den
|
||||||
|
GeoKeys/`image.getOrigin()`/`image.getResolution()`.
|
||||||
|
3. Raster → **`THREE.BufferGeometry`**: ein Vertex pro Rasterpunkt
|
||||||
|
`(E−shiftE, N−shiftN, Z−shiftZ)`, Faces als zwei Dreiecke pro Zelle (DOSSIER:
|
||||||
|
`mesh_from_grid`, gleiche Logik), `computeVertexNormals()`. Bei 0.5 m wird das Mesh
|
||||||
|
groß → bei Bedarf raumräumlich sub-samplen (DOSSIER macht ganzzahliges Sub-Sampling
|
||||||
|
auf dem **globalen** LV95-Raster, damit Nachbar-Tiles nahtlos zusammenpassen).
|
||||||
|
4. Mehrere Tiles: erst zu **einem** Grid mergen (gemeinsamer Origin/Step), dann meshen —
|
||||||
|
sonst entstehen Nähte (DOSSIER: `merge_grids`).
|
||||||
|
|
||||||
|
**Alternativweg (XYZ, exakt wie DOSSIER):** `.xyz.zip` laden → mit **`fflate`**
|
||||||
|
(npm, schnellster Inflate im Browser) entpacken → ASCII `E N Z` parsen → gleiches Grid.
|
||||||
|
Robust, aber überträgt mehr Bytes als der COG-Range-Weg. Für den Port empfehle ich
|
||||||
|
**COG primär, XYZ als Fallback**.
|
||||||
|
|
||||||
|
Bibliotheken: `geotiff@3.0.5`, `fflate` (XYZ-Variante), `three@0.185.0`.
|
||||||
|
[geotiff.js](https://github.com/geotiffjs/geotiff.js/) ·
|
||||||
|
[3D-Terrain aus GeoTIFF mit Three.js](https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/).
|
||||||
|
|
||||||
|
**Ableitungen wie in DOSSIER** (alle aus dem Grid, in Phase 4 portierbar):
|
||||||
|
- **Höhenlinien** (Marching-Squares auf dem Grid; npm `d3-contour` oder
|
||||||
|
`marchingsquares`) — für 2D-Plan.
|
||||||
|
- **TIN / Patch** (Delaunay aus den Punkten; npm `delaunator`) — alternatives Mesh.
|
||||||
|
- **Geschlossenes Gelände-Volumen** (Boden N m unter tiefstem Punkt) → gefüllte
|
||||||
|
Querschnitte beim Schnitt-Cut (DOSSIER `terrainVolume`/`terrainVolumeDepth`).
|
||||||
|
|
||||||
|
### B.3 Origin-Shift (Pflicht)
|
||||||
|
|
||||||
|
LV95-Koordinaten (~2.6e6) sprengen `float32`. Beim Import einmal
|
||||||
|
`shift = (eCenter, nCenter, zRef)` festlegen, **alle** Geometrien `−shift` rechnen, und
|
||||||
|
`shift` am Projekt persistieren (für Re-Import / Geo-Referenz / Norden). DOSSIER:
|
||||||
|
`origin_shift`/`shift_lv95`, plus „Auto-Zoom auf Import" (ROADMAP §11). Damit bleibt das
|
||||||
|
Modell metergenau und der Rückweg in echte LV95-Koordinaten (Export, weitere
|
||||||
|
swisstopo-Abfragen) ist `+ shift`.
|
||||||
|
|
||||||
|
### B.4 Parzelle + Orthofoto
|
||||||
|
|
||||||
|
- **Parzelle:** `identify(...cadastralwebmap..., returnGeometry=true, geometryFormat=geojson)`
|
||||||
|
→ Polygon (LV95) → `−shift` → als geschlossene Polylinie auf Ebene **`01 Vermessung`**.
|
||||||
|
Attribute `number`/`egris_egrid` am Objekt/Projekt speichern.
|
||||||
|
- **Orthofoto:** WMTS `swissimage`-Kacheln über die Modell-Bbox stitchen → eine Textur,
|
||||||
|
als Material auf das Gelände-Mesh **oder** auf eine georeferenzierte Plane (DOSSIER:
|
||||||
|
`add_ortho_plane`, mit UV-Shift gegen Tile-Nähte). Für 3D ist `3857` einfacher, für
|
||||||
|
exakte 2D-Lage `2056`.
|
||||||
|
|
||||||
|
### B.5 OSM-Overpass als weltweiter Fallback (Phase 4)
|
||||||
|
|
||||||
|
Ausserhalb der Schweiz (oder wenn nur 2D-Footprints/Straßen reichen): DOSSIER hat einen
|
||||||
|
**Overpass-Importer** (`https://overpass-api.de/api/interpreter`, POST) mit 7 Kategorien
|
||||||
|
(Straßen/Gebäude/Wasser/Wasserläufe/Grün/Wege). Liefert OSM-Ways → Polylinien. Im
|
||||||
|
Browser identisch nutzbar (`fetch` POST). Overpass koordiniert in WGS84 → mit
|
||||||
|
`proj4`→LV95→`−shift`. Hinweis: Overpass-CORS ist beim Haupt-Server meist offen, kann
|
||||||
|
aber je nach Mirror variieren; ggf. anderen Mirror wählen.
|
||||||
|
[Overpass API](https://overpass-api.de/).
|
||||||
|
|
||||||
|
### B.6 Was sich von DOSSIER **nicht** 1:1 portieren lässt
|
||||||
|
|
||||||
|
- **Rhino-`_-Import`** für DXF/DWG/OBJ und **`_-MeshPatch`/Delaunay**-Commands gibt es im
|
||||||
|
Browser nicht → ersetzen durch JS-Parser/Algorithmen (OBJ: `three`-`OBJLoader`;
|
||||||
|
Delaunay: `delaunator`; Contours: `d3-contour`).
|
||||||
|
- **Filesystem-Cache neben der `.3dm`** → Browser: **IndexedDB**-Cache (Cache-API für
|
||||||
|
Kacheln). Passt zur ROADMAP-Phase 5 (IndexedDB-Persistenz).
|
||||||
|
- IFC-Import von swissBUILDINGS3D 3.0 → über **web-ifc** (ohnehin im Stack, Phase 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil C — SIA 416 (Flächen/Volumen) & SIA 421 + DOSSIER-Logik
|
||||||
|
|
||||||
|
### C.1 SIA 416 — Flächen- und Volumenhierarchie
|
||||||
|
|
||||||
|
**SIA 416:2003** „Flächen und Volumen von Gebäuden" ist die in der CH gültige Norm und
|
||||||
|
Berechnungsbasis für Kostenplanung/Flächennachweise. Sie kennt vier Bereiche:
|
||||||
|
**GSF** (Grundstück), **GF** (Geschossflächen), **AGF** (Aussengeschossflächen), **GV**
|
||||||
|
(Volumen). Maßgeblich ist die effektive Geometrie (keine fiktiven Zuschläge mehr).
|
||||||
|
Quellen: [SIA 416 Übersicht (siworks/DBV)](https://diebauherrenvertretung.ch/sia146/) ·
|
||||||
|
[SIA-Shop 416/2003](https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product) ·
|
||||||
|
[Flächenkennzahlen SIA 416 (Ginesta, PDF)](https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf).
|
||||||
|
|
||||||
|
**Hierarchie & Formeln (verifiziert):**
|
||||||
|
|
||||||
|
```
|
||||||
|
GSF Grundstücksfläche
|
||||||
|
GF Geschossfläche = KF + NGF
|
||||||
|
├─ KF Konstruktionsfläche (Wände/Stützen; tragend KFT + nicht tragend KFN)
|
||||||
|
└─ NGF Nettogeschossfläche = NF + VF + FF
|
||||||
|
├─ NF Nutzfläche = HNF + NNF
|
||||||
|
│ ├─ HNF Hauptnutzfläche (zweckbestimmte Hauptnutzung: Wohnen, Büro …)
|
||||||
|
│ └─ NNF Nebennutzfläche (Lager, Bad/WC, Abstell-, Nebenräume)
|
||||||
|
├─ VF Verkehrsfläche (Erschließung: Flure, Treppen, Lifte)
|
||||||
|
└─ FF Funktionsfläche (Gebäudetechnik: Heizung, Lüftung, Technik)
|
||||||
|
AGF Aussengeschossfläche (Balkone, Terrassen, gedeckte Aussenflächen)
|
||||||
|
GV Gebäudevolumen [m³]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Abk. | Deutsch | Inhalt (Kurz) |
|
||||||
|
|---|---|---|
|
||||||
|
| GSF | Grundstücksfläche | Parzellenfläche (aus Kataster, Teil A.6) |
|
||||||
|
| GF | Geschossfläche | allseits umschlossene + überdeckte Grundrissflächen, geschossweise |
|
||||||
|
| KF | Konstruktionsfläche | Bauteile (Wände/Stützen), nicht begehbar; KFT tragend / KFN nicht tragend |
|
||||||
|
| NGF | Nettogeschossfläche | begehbare Fläche innerhalb der Umschließung = NF+VF+FF |
|
||||||
|
| NF | Nutzfläche | tatsächlich nutzbar = HNF+NNF |
|
||||||
|
| HNF | Hauptnutzfläche | zweckbestimmte Hauptnutzung |
|
||||||
|
| NNF | Nebennutzfläche | dienende Nebenräume (Lager, Bad, WC) |
|
||||||
|
| VF | Verkehrsfläche | horizontale/vertikale Erschließung |
|
||||||
|
| FF | Funktionsfläche | Gebäudetechnik |
|
||||||
|
| AGF | Aussengeschossfläche | Balkone/Terrassen u.ä. |
|
||||||
|
| GV | Gebäudevolumen | umbauter Raum [m³] |
|
||||||
|
|
||||||
|
> **Versionshinweis:** Es kursiert eine Revision **SIA 416:2017** mit präzisierten
|
||||||
|
> Begriffen, in der Praxis wird aber breit weiter **416:2003** referenziert. Für unser
|
||||||
|
> Tool reicht die Klassifikation **HNF/NNF/VF/FF/GF/AGF + Bilanz**; die genaue Auflage
|
||||||
|
> nur als Label dokumentieren. Verbindliche Definitionen stehen im kostenpflichtigen
|
||||||
|
> Normtext (SIA-Shop) — die hier zitierten freien Quellen stimmen in der Struktur überein.
|
||||||
|
|
||||||
|
### C.2 SIA 421 — Flächengliederung für Bewirtschaftung/Vermietung
|
||||||
|
|
||||||
|
**SIA 421:2006** „Flächengliederung und Mengenangaben" (Korrigenda C1:2014) baut auf der
|
||||||
|
SIA-416-Systematik auf und **gliedert Flächen für Immobilien-Bewirtschaftung und
|
||||||
|
Vermietung** (Mietflächen, Nutzungseinheiten, Zuordnung von VF/FF zu Mietern). Relevant,
|
||||||
|
sobald wir **Mietflächen-/Bewirtschaftungs-Auswertungen** wollen (über den reinen
|
||||||
|
Architektur-Nachweis hinaus). Für den ersten Wurf **nicht zwingend** — SIA 416 reicht
|
||||||
|
für Flächennachweis und Raumschema. SIA 421 ist die natürliche Erweiterung, wenn
|
||||||
|
Property-Management-Features kommen. Quellen:
|
||||||
|
[SIA 421:2006 (PDF Inhalt)](https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang) ·
|
||||||
|
[Korrigenda C1:2014 (PDF)](https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf).
|
||||||
|
|
||||||
|
### C.3 Wie DOSSIER SIA macht (Vorlage für den Port)
|
||||||
|
|
||||||
|
Quellcode: `rhino/elemente.py` (Räume) + `rhino/elemente_uebersicht.py` (Bilanz/CSV).
|
||||||
|
Kernpunkte, die wir 1:1 übernehmen:
|
||||||
|
|
||||||
|
**Datenmodell pro Raum.** Ein Raum ist eine **geschlossene Outline-Curve** + ein
|
||||||
|
**Text-Stempel**. Klassifikation über das Feld `dossier_raum_sia` mit Werten
|
||||||
|
`{"", hnf, nnf, vf, ff, gf, agf}` (`_RAUM_SIA_KINDS`). Weitere Felder: `name`, `nummer`,
|
||||||
|
`funktion` (`wohnen|schlafen|bad|kueche|essen|flur|…`), `personen` (für
|
||||||
|
Personenbelegung/Brandschutz), Rundung, Stempel-Layout.
|
||||||
|
|
||||||
|
**Flächen-/Umfangsberechnung** (`_raum_amp`): Fläche aus `AreaMassProperties` (≙ in JS
|
||||||
|
**Shoelace-Formel** über das Polygon), Umfang = Kurvenlänge, plus Zentroid für den
|
||||||
|
Stempel. Im Browser: Polygon-Fläche selbst rechnen (Shoelace), kein Mesh nötig — exakt
|
||||||
|
und schnell. Rundungsstufen (`_format_area`): `exakt|0.01|0.1|0.5|1` (z.B. `0.5` =
|
||||||
|
`round(a*2)/2`).
|
||||||
|
|
||||||
|
**SIA-Bilanz** (`compute_sia_bilanz`, `scope = total | geschoss:<id>`): summiert
|
||||||
|
Raumflächen je Klasse, dann:
|
||||||
|
```
|
||||||
|
NF = HNF + NNF
|
||||||
|
NGF = NF + VF + FF (GF/AGF separat aggregiert, zählen nicht in NGF)
|
||||||
|
```
|
||||||
|
(genau die Formeln aus C.1). Ergebnis je Geschoss + Total.
|
||||||
|
|
||||||
|
**Farb-/Darstellungs-Konvention** (`_SIA_COLORS_HEX`, Pastell): HNF rot `#e8a8a8`,
|
||||||
|
NNF orange `#e8c498`, VF gelb `#e8d878`, FF hellblau `#a8c8e0`, GF grau `#d0d0d0`,
|
||||||
|
AGF hellgrün `#c0d8c0`. Umgesetzt als **regelbasierte Overrides** (`_build_sia_preset_rules`,
|
||||||
|
Preset „SIA-Raeume"): Bedingung `user_string == code` → Outline-Farbe + Solid-Hatch.
|
||||||
|
→ Passt 1:1 zur geplanten **Overrides-Engine** (ROADMAP §2c/§11).
|
||||||
|
|
||||||
|
**Export** (`_cmd_export_raeume`, `_export_bilanz`): CSV, **Semikolon + UTF-8-BOM**
|
||||||
|
(CH/DE-Excel), Dezimal-Komma. Raumliste: Nummer; Name; Geschoss; Funktion; SIA; Fläche;
|
||||||
|
Fläche gerundet; Umfang. Bilanz: eine Spalte je Geschoss + Total, Zeilen je Kategorie.
|
||||||
|
→ Im Browser: Blob + Download (kein SaveFileDialog), gleiche CSV-Struktur. Optional
|
||||||
|
direkt `.xlsx` via `sheetjs`/`exceljs`.
|
||||||
|
|
||||||
|
**Layer-Routing:** GF→`61_GF`, AGF→`62_AGF`, Rest→`60_RAEUME` (`_layer_path_for_raum_sia`)
|
||||||
|
— damit Geschossflächen-Outlines getrennt schalt-/exportierbar sind. Übersetzt sich auf
|
||||||
|
unsere Ebenen-Codes (`60 Räume`).
|
||||||
|
|
||||||
|
**Was wir im Port besser/anders machen:**
|
||||||
|
- Fläche per **Shoelace** statt Rhino-Mass-Props (0 Deps).
|
||||||
|
- Bilanz **reaktiv** aus dem semantischen Modell (Zustand-Store) statt Doc-Scan.
|
||||||
|
- **Space = Slab-/Raum-Polygon mit `siaClass`** im Datenmodell (ROADMAP:
|
||||||
|
`Space { boundary, name }`), Bilanz als abgeleitete Sicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teil D — Umsetzungsplan (Endpunkte · Libs · Phase)
|
||||||
|
|
||||||
|
Reihenfolge orientiert sich an der ROADMAP (SIA = Phase 2, Geo = Phase 4) und an „größter
|
||||||
|
Nutzen zuerst, geringste Abhängigkeit zuerst".
|
||||||
|
|
||||||
|
### Phase 2 — SIA-Räume (kein Netz, reine Logik) ⭐
|
||||||
|
**Endpunkte:** keine. **Libs:** keine (Shoelace selbst), optional `exceljs`/`sheetjs` für
|
||||||
|
.xlsx.
|
||||||
|
1. `Space`-Modell: `{ boundary[], geschossId, name, nummer, funktion, siaClass∈{hnf,nnf,vf,ff,gf,agf}, personen }`.
|
||||||
|
2. `computeArea` (Shoelace) + Umfang; Rundungsstufen (`exakt|0.01|0.1|0.5|1`) wie DOSSIER `_format_area`.
|
||||||
|
3. `computeSiaBilanz(scope)` → `{hnf,nnf,nf,vf,ff,ngf,gf,agf,count,personen}` mit
|
||||||
|
`nf=hnf+nnf`, `ngf=nf+vf+ff`.
|
||||||
|
4. SIA-Farbpalette + Stil-Override (Outline-Farbe/Solid-Fill) in der Overrides-Engine.
|
||||||
|
5. Raumstempel-Renderer (Felder-Layout) + **CSV-Export** (Semikolon, UTF-8-BOM, Komma)
|
||||||
|
für Raumliste **und** Bilanz.
|
||||||
|
*Ergebnis:* SIA-416-Flächennachweis + Raumschema, Excel-kompatibel — vor jeder Geo-Arbeit nutzbar.
|
||||||
|
|
||||||
|
### Phase 4a — Geo-Grundlage: Koordinaten + Standortabfrage
|
||||||
|
**Endpunkte:** `SearchServer` (Geocoding), `height` (Punkt-Z), `identify`
|
||||||
|
(Parzelle, `cadastralwebmap-farbe`). **Libs:** `proj4@2.20.9` (+ `@types/proj4`).
|
||||||
|
1. `proj4`-EPSG:2056-Def + Helfer `lv95↔wgs84`, `bboxLv95→bboxWgs84` (DOSSIER-Logik).
|
||||||
|
2. **Origin-Shift**-Mechanik + Persistenz am Projekt (`shift = bbox-Center`), Auto-Zoom.
|
||||||
|
3. Adresssuche → Zentrum; „Gelände-Höhe holen" (height); „Parzelle holen" → Polygon auf
|
||||||
|
Ebene `01 Vermessung` (+ EGRID/Nummer am Projekt).
|
||||||
|
*Ergebnis:* Projekt ist georeferenziert; Parzelle + Adresse + Geländehöhe vorhanden.
|
||||||
|
|
||||||
|
### Phase 4b — Gelände-Mesh + Orthofoto
|
||||||
|
**Endpunkte:** STAC `swissalti3d` (COG `.tif`, 2056) + `profile.json`; WMTS `swissimage`.
|
||||||
|
**Libs:** `geotiff@3.0.5`, `three@0.185.0`, optional `fflate` (XYZ-Fallback),
|
||||||
|
`d3-contour`/`marchingsquares` (Höhenlinien), `delaunator` (TIN).
|
||||||
|
1. STAC-Query (bbox) → COG-Tiles; `geotiff.js` Range-Read → Grid; Tiles mergen.
|
||||||
|
2. Grid → `THREE.BufferGeometry` (DOSSIER `mesh_from_grid`/`merge_grids`); Sub-Sampling
|
||||||
|
auf globalem LV95-Raster; Normalen.
|
||||||
|
3. Optional: Höhenlinien (2D-Plan), TIN, geschlossenes Volumen (Schnitt-Füllung).
|
||||||
|
4. WMTS-`swissimage`-Kacheln → Textur auf Mesh/Plane (DOSSIER `add_ortho_plane`).
|
||||||
|
5. Geländeschnitt im 2D-Plan via `profile.json` entlang der Schnittlinie.
|
||||||
|
*Ergebnis:* echtes Gelände mit Orthofoto unter dem Gebäude; Geländeschnitte.
|
||||||
|
|
||||||
|
### Phase 4c — Nachbargebäude + weltweiter Fallback
|
||||||
|
**Endpunkte:** 3D-Tiles `ch.swisstopo.swissbuildings3d.3d/v1/tileset.json` (Anzeige)
|
||||||
|
**oder** STAC `swissbuildings3d_2` `.obj/.ifc` (Import); OSM `overpass-api.de`.
|
||||||
|
**Libs:** `@loaders.gl/3d-tiles@4.4.3` **oder** `3DTilesRendererJS`; `three`-`OBJLoader`;
|
||||||
|
`web-ifc` (für 3.0-IFC); `proj4`.
|
||||||
|
1. Kontext-Anzeige: 3D-Tiles in den Three-Scenegraph streamen (kein Download).
|
||||||
|
2. Bedarf an echter Geometrie (Verschattung/Abstand): STAC-OBJ-Tile → Mesh-Import → `−shift`.
|
||||||
|
3. Ausserhalb CH / nur 2D: Overpass-POST → Ways → Polylinien (Ebene `70 OSM`).
|
||||||
|
*Ergebnis:* Nachbarschaftskontext (CH 3D, weltweit OSM).
|
||||||
|
|
||||||
|
### Querschnitt (alle Geo-Phasen)
|
||||||
|
- **Caching:** IndexedDB für STAC-Antworten + COG-Bytes + Kacheln (ersetzt DOSSIERs
|
||||||
|
Disk-Cache); Cache-Schlüssel = Tile-ID/URL.
|
||||||
|
- **Defensive HTTP:** Timeouts, Retry/Backoff, 402/429 abfangen, Größen-Limit pro Tile
|
||||||
|
(DOSSIER: 200-MB-Guard).
|
||||||
|
- **Attribution:** „© swisstopo" sichtbar einblenden, „© OpenStreetMap-Mitwirkende" bei OSM.
|
||||||
|
- **Worker:** GeoTIFF-Parsing + Mesh-Bau im Web-Worker (Comlink, ROADMAP-Stack), UI bleibt flüssig.
|
||||||
|
|
||||||
|
### Empfohlener Library-Satz (npm, aktuell)
|
||||||
|
`proj4@2.20.9` · `geotiff@3.0.5` · `three@0.185.0` · `fflate` (XYZ) ·
|
||||||
|
`@loaders.gl/3d-tiles@4.4.3` *oder* `3DTilesRendererJS` · `delaunator` ·
|
||||||
|
`d3-contour` · `web-ifc` (im Stack) · `exceljs`/`sheetjs` (optional, .xlsx).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- GeoAdmin REST (height/profile/identify/find/search): https://geoadmin.readthedocs.io/en/latest/services/sdiservices.html
|
||||||
|
- GeoAdmin Tech-Docs (Hub): https://docs.geo.admin.ch/
|
||||||
|
- Identify Features: https://docs.geo.admin.ch/access-data/identify-features.html
|
||||||
|
- Search: https://docs.geo.admin.ch/access-data/search.html
|
||||||
|
- WMTS: https://docs.geo.admin.ch/visualize-data/wmts.html · https://wmts.geo.admin.ch/
|
||||||
|
- WMS: https://docs.geo.admin.ch/visualize-data/wms.html
|
||||||
|
- 3D-Tiles: https://docs.geo.admin.ch/visualize-data/3d-tiles.html
|
||||||
|
- swissALTI3D: https://www.swisstopo.admin.ch/en/height-model-swissalti3d
|
||||||
|
- Switzerland in 3D / swissBUILDINGS3D: https://www.swisstopo.admin.ch/en/switzerland-in-3d
|
||||||
|
- Terms of use (FSDI / OGD): https://www.geo.admin.ch/en/general-terms-of-use-fsdi
|
||||||
|
- REFRAME Web-API: https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web
|
||||||
|
- EPSG:2056 Definition: https://epsg.io/2056
|
||||||
|
- proj4js: https://github.com/proj4js/proj4js
|
||||||
|
- geotiff.js: https://github.com/geotiffjs/geotiff.js/
|
||||||
|
- Three.js-Terrain aus GeoTIFF: https://spatial-dev.guru/2024/11/30/creating-3d-terrain-maps-from-geotiff-files-with-three-js/
|
||||||
|
- WMTS EPSG:2056 Beispiel: https://codepen.io/geoadmin/pen/GZKEam
|
||||||
|
- Overpass API: https://overpass-api.de/
|
||||||
|
- SIA 416 (Übersicht): https://diebauherrenvertretung.ch/sia146/ · https://shop.sia.ch/normenwerk/architekt/sia%20416/dfi/D/Product
|
||||||
|
- SIA 416 Flächenkennzahlen (PDF): https://www.ginesta.ch/resources/public/lava3/media/kcfinder/files/Fl%C3%A4chenkennzahlen%20SIA%20416%20Norm.pdf
|
||||||
|
- SIA 421:2006 (PDF) + Korrigenda C1:2014: https://shop.sia.ch/d475e3f9-10c9-48aa-9be8-657784ea519b/F/DownloadAnhang · https://www.sia.ch/fileadmin/content/download/sia-norm/korrigenda_sn/421-C1_2014_d.pdf
|
||||||
|
- DOSSIER-Quellcode (Vorlage): `rhino/swisstopo.py`, `rhino/osm.py`, `rhino/elemente.py` (Räume/SIA), `rhino/elemente_uebersicht.py` (Bilanz/CSV)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Technologie-Auswahl: Browser-basiertes BIM-Tool (DOSSIER-Port)
|
||||||
|
|
||||||
|
> **Kontext:** Standalone, browser-basiertes BIM-Werkzeug (React + TypeScript + Three.js) als Port des DOSSIER Rhino-Plugins. Keine Server-Abhaengigkeit gewuenscht (alles client-side). Recherchestand: Juni 2026.
|
||||||
|
>
|
||||||
|
> **Leitprinzip:** Wo immer moeglich auf einem echten B-Rep-Geometriekernel (OCCT) aufbauen, weil ein BIM-Werkzeug exakte 2D-Ableitungen (Schnitte, verdeckte Kanten, Bemassung) braucht — das ist mit reinen Dreiecksnetzen nicht sauber loesbar. Mesh-Booleans (Manifold) als schnelle Ergaenzung fuer Importgeometrie und Vorschau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Geometriekernel im Browser (Solids + Booleans)
|
||||||
|
|
||||||
|
Das ist das schwierigste und zugleich wichtigste Problem. Drei ernsthafte Optionen, die alle im Browser (WASM) laufen.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
**A) opencascade.js (OCCT als WASM)**
|
||||||
|
Port des vollstaendigen OpenCASCADE-Kernels (OCCT) nach WebAssembly via Emscripten. Voller B-Rep-Kernel: NURBS-Flaechen, exakte boolesche Operationen, Fillets/Chamfers, STEP/IGES-Import/-Export, Meshing. TypeScript-Bindings vorhanden. Die neueren Versionen (V3-Linie) zielen explizit auf moderne Bundler.
|
||||||
|
- Repo: <https://github.com/donalffons/opencascade.js/>
|
||||||
|
- Doku: <https://opencascade-js.vercel.app/>
|
||||||
|
- npm: <https://www.npmjs.com/package/opencascade.js>
|
||||||
|
- **Lizenz:** OCCT steht unter **LGPL-2.1 mit OCCT-Exception** (seit 6.7.0). Kommerzielle Nutzung ohne Lizenzgebuehren/Royalties erlaubt, sofern man (a) sichtbar darauf hinweist, dass die Software OCCT nutzt, und (b) eine Kopie der OCCT-Lizenz mitliefert. Die Exception entschaerft das statische-Linking-Problem fuer Header/Templates. Quellen: <https://dev.opencascade.org/resources/licensing>, <https://spdx.org/licenses/OCCT-exception-1.0.html>
|
||||||
|
- **Trade-offs:** Sehr grosse WASM-Binaries (zweistelliger MB-Bereich je nach Custom-Build), steile Lern- und API-Kurve (rohe OCCT-C++-API durchgereicht), Build-Pflege aufwendig.
|
||||||
|
|
||||||
|
**B) replicad (Abstraktion ueber opencascade.js)** — *empfohlene Basis*
|
||||||
|
replicad ist eine schlanke, idiomatische TypeScript-Schicht ueber opencascade.js. Es liefert genau die High-Level-Bausteine, die ein BIM-Tool braucht: Sketches/Blueprints, Extrude/Revolve/Loft, Booleans, Fillet/Chamfer — und entscheidend: **HLR-Projektionen** (`drawProjection`) und 2D-Drawings mit SVG-Export. Laeuft per Design im **Web Worker** und gibt Dreiecksnetze an den Main-Thread fuer Three.js zurueck.
|
||||||
|
- Doku/Library-Guide: <https://replicad.xyz/docs/use-as-a-library/>
|
||||||
|
- API: <https://replicad.xyz/docs/api/>
|
||||||
|
- **Lizenz:** **MIT** (eigene Schicht) — der OCCT/LGPL-Hinweis gilt weiterhin fuer das eingebettete WASM. Repo: <https://github.com/sgenoud/replicad>
|
||||||
|
- **Trade-offs:** Erbt OCCT-WASM-Groesse und -Robustheitsgrenzen; kleineres Team/Bus-Faktor als OCCT selbst. Man kann jederzeit „unter die Haube" auf rohes opencascade.js durchgreifen, wenn die Abstraktion nicht ausreicht.
|
||||||
|
|
||||||
|
**C) Manifold (manifold-3d, WASM)** — *empfohlen als schnelle Mesh-Ergaenzung*
|
||||||
|
Geometrie-Bibliothek fuer topologisch robuste **Dreiecksnetze**. Bietet den (laut Autor) ersten garantiert mannigfaltigen Mesh-Boolean-Algorithmus — extrem schnell und robust gegen Randfaelle. Stark parallelisiert.
|
||||||
|
- Repo: <https://github.com/elalish/manifold>
|
||||||
|
- npm: <https://www.npmjs.com/package/manifold-3d> (aktuell v3.5.x, Juni 2026)
|
||||||
|
- **Lizenz:** **Apache-2.0** (sehr permissiv, ideal). Bestaetigt: <https://github.com/elalish/manifold>
|
||||||
|
- **Trade-offs:** **Nur Meshes, kein B-Rep** — keine exakten NURBS-Flaechen, keine echten Fillets auf Krümmungen, kein STEP. Fuer ein BIM-Tool, das exakte Plaene/Schnitte ableiten will, alleine nicht ausreichend, aber unschlagbar fuer schnelle Booleans auf importierter Mesh-Geometrie und Live-Vorschau. **Wichtig:** unterstuetzt `slice(z)` und `project()` (siehe Abschnitt 3).
|
||||||
|
|
||||||
|
**D) three-bvh-csg (nur erwähnt, nicht empfohlen als Kernel)**
|
||||||
|
Sehr schnelle CSG direkt auf Three.js-BufferGeometry (auf three-mesh-bvh). >100x schneller als BSP-basierte Three.js-CSG-Libs. Aber: erklaert selbst, dass Resultate „aufgrund numerischer Praezision nicht garantiert 2-mannigfaltig" sind und verweist fuer CAD-Robustheit ausdruecklich auf Manifold.
|
||||||
|
- Repo: <https://github.com/gkjohnson/three-bvh-csg>
|
||||||
|
- Forum: <https://discourse.threejs.org/t/three-bvh-csg-a-library-for-performing-fast-csg-operations/42713>
|
||||||
|
- **Trade-offs:** Gut fuer Live-Vorschau/visuelles Schneiden, ungeeignet als verlaesslicher Modellierkernel.
|
||||||
|
|
||||||
|
### Empfehlung (Kernel)
|
||||||
|
**replicad (= opencascade.js) als primaerer B-Rep-Kernel im Web Worker; Manifold als schneller Mesh-Boolean-Pfad fuer Import-/Vorschaugeometrie.** Diese Zweiteilung deckt sowohl „exakte BIM-Geometrie + 2D-Ableitung" (OCCT) als auch „schnell + robust auf beliebigen Meshes" (Manifold) ab. three-bvh-csg nur, falls man interaktives Echtzeit-Schneiden visuell braucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 2D-Ableitung aus 3D: verdeckte Kanten (HLR) + Schnittgenerierung
|
||||||
|
|
||||||
|
Kernfrage des DOSSIER-Ports: aus 3D-Solids saubere 2D-Zeichnungen (sichtbare/verdeckte Kanten, Schnitte) erzeugen — im Browser.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
**A) OCC HLRBRep via replicad `drawProjection` — empfohlen**
|
||||||
|
OCCT enthaelt zwei HLR-Algorithmen: `HLRBRep_Algo` (exakt, auf der echten B-Rep) und `HLRBRep_PolyAlgo` (auf polyederisierter Naeherung, schneller, aber polygonal). Quellen: <https://dev.opencascade.org/doc/refman/html/class_h_l_r_b_rep.html>, <https://dev.opencascade.org/doc/occt-7.7.0/refman/html/class_h_l_r_b_rep___poly_algo.html>
|
||||||
|
|
||||||
|
replicad macht genau das im Browser nutzbar: `drawProjection(shape, camera)` liefert ein Objekt mit **`{ visible, hidden }`** — getrennte sichtbare und verdeckte Kantenzuege, die man unterschiedlich stylen kann (z.B. verdeckt = gestrichelt). Konkretes Beispiel aus der Doku:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { drawProjection, ProjectionCamera } = replicad;
|
||||||
|
const camera = new ProjectionCamera(corner).lookAt(center);
|
||||||
|
const { visible, hidden } = drawProjection(shape, camera);
|
||||||
|
// visible/hidden sind Drawings -> .toSVG()
|
||||||
|
```
|
||||||
|
- Beispiel: <https://replicad.xyz/docs/examples/projections/>
|
||||||
|
- Verwandte API: `makeProjectedEdges`, `ProjectionCamera`, `Drawing.toSVG()` / `toSVGPaths()` (<https://replicad.xyz/docs/api/classes/Drawing/>)
|
||||||
|
- **Das ist der entscheidende Grund, replicad/OCCT zu nehmen:** exakte verdeckte-Kanten-Berechnung auf echtem B-Rep ist mit Mesh-Tools nicht serioes machbar.
|
||||||
|
- **Trade-offs:** HLR ist rechenintensiv (deshalb Worker + Caching pro Ansicht/Kamera); `HLRBRep_Algo` exakt aber langsam, `PolyAlgo` schneller aber genaehert.
|
||||||
|
|
||||||
|
**B) Schnitte (Sections) via OCCT**
|
||||||
|
Echte Schnitte ueber Schnitt mit einer Ebene/Halbraum (`BRepAlgoAPI_Section` bzw. Boolean mit Schnittkoerper) ergeben exakte Schnittkanten als B-Rep-Edges, die wiederum nach SVG/DXF gehen. In replicad ueber Booleans + Projektion abbildbar.
|
||||||
|
|
||||||
|
**C) Manifold `slice()` / `project()` — schnelle Mesh-Variante**
|
||||||
|
`Manifold.slice(z)` gibt den Querschnitt parallel zur X-Y-Ebene auf Hoehe `z` als `CrossSection` (2D-Polygone, intern Clipper2); `Manifold.project()` die projizierte Aussenkontur. Quellen: <https://manifoldcad.org/docs/jsapi/>, <https://manifoldcad.org/docs/html/classmanifold_1_1_cross_section.html>
|
||||||
|
- **Trade-offs:** liefert **keine** verdeckte/sichtbare-Kanten-Trennung und keine Innenkanten-Semantik wie HLR — nur die geometrische Schnitt-/Projektionskontur des Meshes. Gut fuer Plan-Schnittkonturen (siehe Abschnitt 3), ungenuegend fuer vollwertige Ansichts-Zeichnungen mit verdeckten Kanten.
|
||||||
|
|
||||||
|
### Empfehlung (2D-Ableitung)
|
||||||
|
**HLR und Ansichts-Zeichnungen ueber replicad `drawProjection` (OCC HLRBRep) im Worker, mit Caching pro Kamera/Ansicht. Echte Schnitte ueber OCCT-Section-Boolean. Manifold `slice()` als schneller Pfad nur fuer reine Schnittkonturen (z.B. Plan-Cut auf Mesh-Importen).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Plan-Ansicht: Clipping bei `cutHeight`
|
||||||
|
|
||||||
|
Verhalten: horizontaler Schnitt auf einstellbarer Hoehe (Grundriss), darüber Abschneiden, Schnittflaechen markieren.
|
||||||
|
|
||||||
|
### Optionen
|
||||||
|
|
||||||
|
**A) Three.js Clipping Planes (`clippingPlanes` / `localClippingEnabled`)**
|
||||||
|
Three.js bietet globale (`renderer.clippingPlanes`) und material-lokale (`material.clippingPlanes`) Schnittebenen; `renderer.localClippingEnabled` ist standardmaessig aus (Null-Kosten, bis aktiviert). Quellen: <https://threejs.org/docs/#api/en/materials/Material.clippingPlanes>, <https://threejs.org/examples/webgl_clipping.html>
|
||||||
|
- **Pro:** GPU-seitig, dynamisch (Slider auf `cutHeight` = `plane.constant` aendern, kein Geometrie-Rebuild), sehr fluessig.
|
||||||
|
- **Contra:** Clipping schneidet nur visuell — es entstehen **offene** Querschnitte (keine Deckflaeche). Fuer „Schnittflaeche fuellen/markieren" braucht man entweder einen Stencil-Cap-Trick oder eine echte Schnittkontur (Abschnitt 2). `clipIntersection = true` kann Material-Reinitialisierung pro Frame und FPS-Einbrueche verursachen — moeglichst vermeiden. Quelle: <https://github.com/mrdoob/three.js/issues/18675>
|
||||||
|
|
||||||
|
**B) Object Culling / Sichtbarkeit nach Hoehe**
|
||||||
|
Elemente oberhalb `cutHeight` per Bounding-Box/Etagen-Metadaten ausblenden (`object.visible = false`).
|
||||||
|
- **Pro:** trivial, keine Shader-Kosten, nutzt BIM-Etagensemantik.
|
||||||
|
- **Contra:** grobkoernig (ganze Objekte, kein praeziser Schnitt mitten durch ein Bauteil).
|
||||||
|
|
||||||
|
**C) Echte Schnittgeometrie pro Etage (OCCT/Manifold) fuer den 2D-Plan**
|
||||||
|
Fuer die exportierbare 2D-Grundriss-Zeichnung den echten Schnitt auf `cutHeight` rechnen (OCCT-Section bzw. `Manifold.slice(z)`), Schnittflaechen schraffieren (Abschnitt 6).
|
||||||
|
|
||||||
|
### Empfehlung (Plan-Clipping)
|
||||||
|
**Hybrid:** Im 3D-Viewport **Three.js Clipping Planes** fuer das interaktive Abschneiden (Slider direkt auf `plane.constant`), kombiniert mit **Object-Culling** ueber Etagen-Metadaten fuer Grob-Performance. Schnittflaechen-Caps via Stencil-Technik. Fuer die **exportierbare** 2D-Grundriss-Zeichnung die **echte** Schnittkontur ueber OCCT/Manifold berechnen statt nur GPU-Clipping. `clipIntersection` meiden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Import: DWG/DXF, IFC, STL, OBJ, XYZ-Punktwolken
|
||||||
|
|
||||||
|
### DXF / DWG
|
||||||
|
- **DXF lesen:** `dxf-parser` (gdsestimating) — robust, weit verbreitet, parst DXF-Strings zu JS-Objekten. Repo: <https://github.com/gdsestimating/dxf-parser>. Zum reinen 2D-Anzeigen: `dxf-viewer` (vagran). <https://github.com/vagran/dxf-viewer>
|
||||||
|
- **DWG lesen (binaer!):** `@mlightcad/libredwg-web` — LibreDWG nach WASM, parst **DWG** (und DXF) direkt im Browser/Node ohne Backend. Aktuell v3.x. Repo: <https://github.com/mlightcad/libredwg-web>, npm: <https://www.npmjs.com/package/@mlightcad/libredwg-web>
|
||||||
|
- **Achtung Qualitaet/Limits:** DWG-Parsing ist speicherintensiv (kann >2 GB RAM ziehen); libredwg-web erzwingt WASM-Heap-Limits, sehr grosse DWGs koennen scheitern. Im Default-Build sind DXF-Parsing und DWG-Schreiben deaktiviert, um die WASM-Groesse zu senken — DXF also lieber mit `dxf-parser` lesen. Quelle: <https://medium.com/@mlightcad/parsing-autocad-dwg-files-in-the-browser-without-relying-on-the-backend-9067c5d9abf0>
|
||||||
|
- **Lizenz:** LibreDWG ist **GPL-3.0** — das ist fuer ein proprietaeres Produkt heikel. Wenn das Produkt nicht GPL sein soll, DWG-Import entweder ueber einen separaten Out-of-Process-Konverter kapseln oder Nutzer bitten, vorab nach DXF zu exportieren. **DWG-Lizenzfrage vor Integration klaeren.**
|
||||||
|
- **Referenz-Implementierung:** `cad-viewer` (mlightcad) zeigt vollstaendigen browser-only DXF/DWG-Viewer/Editor. <https://github.com/mlightcad/cad-viewer>
|
||||||
|
|
||||||
|
### IFC (BIM-Kern)
|
||||||
|
- **web-ifc (ThatOpen/engine_web-ifc):** IFC lesen/schreiben in JS „at native speeds" via WASM. De-facto-Standard fuer Open-BIM im Browser. Repo: <https://github.com/ThatOpen/engine_web-ifc>, Doku: <https://thatopen.github.io/engine_web-ifc/docs/>. **Lizenz: MPL-2.0** (datei-basiertes Copyleft, fuer proprietaere Apps i.d.R. unkritisch). Quelle: <https://spdx.org/licenses/MPL-2.0.html>
|
||||||
|
- **ThatOpen Components + Fragments:** Hoehere Ebene — `components` (Tools für BIM-Apps), `fragments` (kompaktes Binaerformat auf Google FlatBuffers). Typisch: ~100 MB IFC -> ~10 MB Fragments, >10x schnelleres Laden; Konvertierung lauft worker-basiert. Doku: <https://docs.thatopen.com/Tutorials/Fragments/Fragments/IfcImporter/>, Repo: <https://github.com/ThatOpen/engine_fragment>. IfcImporter setzt web-ifc (>=0.0.72) voraus.
|
||||||
|
- **Empfehlung:** IFC einmal mit web-ifc parsen, in **Fragments** cachen, danach aus Fragments laden.
|
||||||
|
|
||||||
|
### STL / OBJ / XYZ-Punktwolken
|
||||||
|
- **Standard-Three.js-Loader** decken alles ab: `STLLoader` (ASCII+Binaer), `OBJLoader`, `XYZLoader` (XYZ/XYZRGB -> BufferGeometry), `PCDLoader`. Quellen: <https://threejs.org/docs/#examples/en/loaders/PCDLoader>, <https://deepwiki.com/mrdoob/three.js/4.2-model-format-loaders>. Three.js ist **MIT**.
|
||||||
|
- **Punktwolken-Performance:** Naive Darstellung skaliert nicht — ~17 Mio. Punkte ruckeln deutlich. Strategien: Downsampling, **LOD/Culling**, Hintergrund-/Streaming-Laden. Fuer sehr grosse Wolken Out-of-Core-Octree-Renderer (z.B. Potree-Ansatz) erwaegen statt eines einzelnen `Points`-Objekts. Quellen: <https://discourse.threejs.org/t/render-large-point-cloud-data-in-threejs/57331>, <https://discourse.threejs.org/t/performance-issues-rendering-large-ply-point-cloud-in-three-js-downsampling-and-background-loading/69135>
|
||||||
|
|
||||||
|
### Empfehlung (Import)
|
||||||
|
**IFC: web-ifc + Fragments (ThatOpen).** **DXF: dxf-parser.** **DWG: libredwg-web — aber GPL-Lizenz vorab klaeren / kapseln.** **STL/OBJ/XYZ/PCD: native Three.js-Loader, mit LOD/Downsampling fuer grosse Punktwolken (Potree-Pattern bei Bedarf).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vektor-Export: SVG -> PDF (Print) und DXF
|
||||||
|
|
||||||
|
### SVG -> PDF
|
||||||
|
- **svg2pdf.js (yWorks) + jsPDF — empfohlen.** Reine JS-Loesung, laeuft im Browser, erhaelt **echte Vektoren** (kein Rasterisieren via html2canvas!), was fuer druckfaehige Plaene entscheidend ist. Integriert sich ueber `doc.svg(element, ...)`. Kompatibel mit jsPDF v2/v3/v4. Repo: <https://github.com/yWorks/svg2pdf.js/>. Lizenz: svg2pdf.js **MIT**, jsPDF **MIT**.
|
||||||
|
- **Trade-off:** SVG-Feature-Abdeckung ist sehr gut, aber nicht 100% — exotische Filter/Pattern koennen abweichen; Schraffuren als explizite Linien (statt CSS-Filter) exportieren erhoeht Treffsicherheit.
|
||||||
|
- **Alternative/ergaenzend:** `pdf-lib` (MIT) fuer Seitenmontage, Mehrseitigkeit, Metadaten, Zusammenfuehren — kann mit jsPDF-Output kombiniert werden. (`html2canvas`+jsPDF bewusst **vermeiden**, da Raster statt Vektor.)
|
||||||
|
|
||||||
|
### DXF-Export
|
||||||
|
- **@tarikjabiri/dxf (dxfjs/writer) — empfohlen.** Moderner, in TypeScript geschriebener DXF-Generator fuer Node + Browser. Unterstuetzt u.a. **Blocks, Hatches, Insert, Image** — d.h. Schraffuren lassen sich als echte DXF-Hatches exportieren (wichtig fuer CAD-Weiterverarbeitung). npm: <https://www.npmjs.com/package/@tarikjabiri/dxf>, Doku: <https://dxf.vercel.app/>, Repo: <https://github.com/tarikjabiri/js-dxf>. Lizenz: MIT.
|
||||||
|
- Einfachere Alternative: `dxf-writer` (Vorlaeufer, weniger Features).
|
||||||
|
|
||||||
|
### Empfehlung (Export)
|
||||||
|
**SVG -> PDF: svg2pdf.js + jsPDF (Vektor, nicht Raster), optional pdf-lib fuer Seitenmontage. DXF-Export: @tarikjabiri/dxf mit echten Hatch-Entities.** Interner Zwischenschritt: 2D-Geometrie als SVG-Paths halten (replicad `Drawing.toSVGPaths()`), daraus sowohl PDF als auch DXF erzeugen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Schraffuren / Muster in SVG/Canvas bei Massstab
|
||||||
|
|
||||||
|
### Optionen & Erkenntnisse
|
||||||
|
- **SVG `<pattern>` mit `patternUnits="userSpaceOnUse"`** ist der richtige Weg fuer massstaebliche Schraffuren: das Muster skaliert NICHT mit der Form (im Gegensatz zu `objectBoundingBox`), sondern bleibt im Weltkoordinaten-Raster — exakt, was man fuer „mm pro Linie im Plan" braucht. Dichte/Winkel ueber `patternTransform`. Quellen: <https://www.w3.org/TR/2015/WD-SVG2-20150915/pservers.html>, <https://www.codegenes.net/blog/simple-fill-pattern-in-svg-diagonal-hatching/>
|
||||||
|
- **Performance:** Pattern-Layer werden bei jedem Layout-/Zoom-Schritt neu gerechnet; `userSpaceOnUse` vermeidet teures Rescaling und ist hier zugleich der schnellere und der korrekte Weg. Quelle: <https://oreillymedia.github.io/Using_SVG/extras/ch19-performance.html>
|
||||||
|
- **SVG vs. Canvas:** Fuer technische Zeichnungen ist SVG qualitativ klar ueberlegen (Vektor, exporttauglich nach PDF/DXF). Canvas wird erst bei sehr vielen einfachen Elementen schneller. Quellen: <https://felt.com/blog/from-svg-to-canvas-part-1-making-felt-faster>, <https://www.yworks.com/blog/svg-canvas-webgl>
|
||||||
|
- **Skalierungs-Strategie:** Bei sehr dichten Schraffuren ueber grosse Flaechen kann die Linienzahl explodieren -> entweder als Pattern-Kachel rendern (eine Definition, vielfach referenziert, statt tausende Einzellinien) **oder** beim Export Schraffuren in echte Linien/Hatch-Entities aufloesen (DXF-Hatch, Abschnitt 5).
|
||||||
|
|
||||||
|
### Empfehlung (Schraffuren)
|
||||||
|
**SVG `<pattern>` mit `patternUnits="userSpaceOnUse"` als primaerer Renderpfad** (massstabskorrekt, exportierbar, performant durch Kachel-Referenzierung). Bei extrem grossen/dichten Flaechen Canvas-Overlay nur fuer die reine Bildschirm-Vorschau erwaegen; fuer Export immer SVG -> svg2pdf.js bzw. echte DXF-Hatches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WebGPU vs. WebGL + Worker-Offloading der WASM-Geometrie
|
||||||
|
|
||||||
|
### Rendering: WebGPU vs. WebGL
|
||||||
|
- **Reifegrad:** Seit Three.js r171 (Sept. 2025) ist der **WebGPURenderer produktionsreif** mit `import * as THREE from 'three/webgpu'` und **automatischem WebGL2-Fallback** — kein eigener Fallback-Code noetig. Quelle: <https://www.utsubo.com/blog/webgpu-threejs-migration-guide>
|
||||||
|
- **Browser-Abdeckung:** Chrome/Edge 113 (Mai 2023), Safari 26.0 (Sept. 2025), Firefox 141 (Juli 2025). ~95% der Nutzer WebGPU-faehig, restliche ~5% bekommen WebGL2-Fallback. Quelle: <https://vr.org/articles/webgpu-baseline-2026-three-js-webxr-default>
|
||||||
|
- **Performance (nuanciert):** Bei draw-call-lastigen Szenen (viele Bauteile/Etagen) gewinnt WebGPU deutlich (bei ~10'000 Draw-Calls ~50 FPS WebGPU vs. ~30 FPS WebGL); bei wenigen grossen Meshes kann WebGL noch gleichauf oder schneller sein. Compute-Shader (Punktwolken, Culling) sind ein WebGPU-Alleinstellungsmerkmal. Quellen: <https://medium.com/@sudenurcevik/upgrading-performance-moving-from-webgl-to-webgpu-in-three-js-4356e84e4702>, <https://altersquare.io/three-js-vs-webgpu-2026-large-scale-construction-viewers/>
|
||||||
|
- **Vorsicht:** Es gibt weiterhin Szenarien, in denen WebGPU langsamer ist als WebGL — daher messen, nicht blind migrieren. Quelle: <https://github.com/mrdoob/three.js/issues/31055>
|
||||||
|
|
||||||
|
### Worker-Offloading der WASM-Geometrie
|
||||||
|
- **Pflicht, nicht optional:** OCCT/replicad-Berechnungen (Booleans, HLR, Section) gehoeren in einen **Web Worker**, sonst blockiert die UI. replicad ist genau dafuer gebaut (WASM im Worker, Mesh zurueck an den Main-Thread). Quelle: <https://replicad.xyz/docs/use-as-a-library/>
|
||||||
|
- **Muster:** Worker laedt das (grosse) WASM einmal; Kommunikation via Comlink o.ae.; Geometrie als Transferable (ArrayBuffer) zuruecksenden, um Kopierkosten zu sparen. Manifold (WASM) ebenso im Worker betreiben; Manifold ist intern stark parallelisiert.
|
||||||
|
|
||||||
|
### Empfehlung (Performance)
|
||||||
|
**Three.js `three/webgpu`-Renderer mit automatischem WebGL2-Fallback** (gratis Abwaertskompatibilitaet, Vorteil bei vielen Draw-Calls/Etagen). **Alle WASM-Geometrie (OCCT/replicad + Manifold) konsequent in Web Worker(n)**, Ergebnis als Transferables. WebGPU-Compute fuer Punktwolken-/Culling-Beschleunigung als spaeteres Optimierungs-Upside. Vor groesserer WebGPU-Optimierung mit der echten Szene benchmarken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empfehlung (Zusammenfassung)
|
||||||
|
|
||||||
|
| Thema | Wahl | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| **Geometriekernel (Solids/Booleans)** | **replicad** (= opencascade.js/OCCT) im Worker; **Manifold** als Mesh-Boolean-Ergaenzung | Echter B-Rep-Kernel noetig fuer exakte BIM-Geometrie + 2D-Ableitung; Manifold (Apache-2.0) schnell+robust fuer Mesh-Importe/Vorschau |
|
||||||
|
| **2D-Ableitung (HLR/Schnitt)** | **replicad `drawProjection`** (OCC HLRBRep, liefert `{visible, hidden}`); OCCT-Section fuer Schnitte | Einziger seriöser Weg fuer verdeckte/sichtbare Kanten auf echtem B-Rep im Browser; Manifold `slice()` nur fuer reine Konturen |
|
||||||
|
| **Plan-Clipping (`cutHeight`)** | **Three.js Clipping Planes** + **Object-Culling** (Etagen); echte Schnittkontur (OCCT/Manifold `slice`) fuer Export | GPU-Clipping fluessig & dynamisch fuer Viewport; Culling fuer Grob-Performance; exakte Kontur nur fuer druckbaren Plan |
|
||||||
|
| **Import IFC** | **web-ifc + Fragments** (ThatOpen) | De-facto Open-BIM-Standard, native Speed, ~10x kleineres/schnelleres Fragments-Caching; MPL-2.0 |
|
||||||
|
| **Import DXF / DWG** | **dxf-parser** (DXF) / **libredwg-web** (DWG) | Bewaehrt & browser-only; **DWG = GPL-3.0 -> Lizenz vorab klaeren/kapseln**, RAM-Limits bei grossen Dateien |
|
||||||
|
| **Import STL/OBJ/XYZ/PCD** | **Native Three.js-Loader** + LOD/Downsampling | Out of the box (MIT); grosse Punktwolken brauchen Octree/Potree-Pattern |
|
||||||
|
| **SVG -> PDF (Print)** | **svg2pdf.js + jsPDF** (+ pdf-lib optional) | Echte Vektoren statt Raster -> druckfaehig; MIT-Lizenzen |
|
||||||
|
| **DXF-Export** | **@tarikjabiri/dxf** | TS, Browser-faehig, echte Hatch-/Block-Entities fuer CAD-Weiterverarbeitung; MIT |
|
||||||
|
| **Schraffuren/Muster** | **SVG `<pattern>` mit `userSpaceOnUse`** | Massstabskorrekt (skaliert nicht mit Form), exportierbar, performant via Kachel-Referenz |
|
||||||
|
| **Rendering** | **Three.js `three/webgpu`** mit WebGL2-Fallback | Produktionsreif seit r171, ~95% Abdeckung, Vorteil bei vielen Draw-Calls; gratis Fallback |
|
||||||
|
| **WASM-Offloading** | **Web Worker** fuer OCCT/replicad + Manifold, Transferables | UI bleibt reaktiv; replicad ist dafuer gebaut; spart Kopierkosten |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Wichtigste Risiken / offene Punkte
|
||||||
|
1. **DWG-Lizenz (LibreDWG = GPL-3.0):** Vor Integration klaeren — sonst proprietaeres Produkt gefaehrdet. Option: separater Konvertierungs-Service oder DXF-Pflicht beim Import.
|
||||||
|
2. **OCCT-WASM-Groesse & Build-Pflege:** zweistellige MB; Custom-Build/Tree-Shaking und Lazy-Loading im Worker einplanen.
|
||||||
|
3. **HLR-Kosten:** `drawProjection` pro Ansicht cachen; ggf. `PolyAlgo` fuer schnelle Vorschau, `HLRBRep_Algo` fuer den finalen Plan.
|
||||||
|
4. **WebGPU nicht blind:** mit echter Szene benchmarken — es gibt Faelle, in denen WebGL noch fuehrt.
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
# UX/UI-Patterns moderner Browser-CAD/BIM-Tools — Research & Leitplanken
|
||||||
|
|
||||||
|
> Recherche für unser Browser-BIM (React + TS + Three.js, DOSSIER-Port, Wohnbau,
|
||||||
|
> CH/SIA-Kontext). Ziel: konkrete, **übernehmbare** Interaktions- und UI-Muster.
|
||||||
|
> Stand: 2026-06-29.
|
||||||
|
>
|
||||||
|
> Untersucht: **Arcol**, **Snaptrude**, **TestFit**, **Onshape** (Browser-CAD),
|
||||||
|
> **Vectorworks** (Resource/Navigation-Modell), **Figma** (Canvas-Interaktion,
|
||||||
|
> Inspector). Querschnitt: Snapping/Inferencing, Grip-Editing, perceived
|
||||||
|
> performance, Command-Palette, Onboarding.
|
||||||
|
>
|
||||||
|
> Jeder externe Claim ist mit Quelle verlinkt. Am Ende:
|
||||||
|
> **„Leitplanken für unsere UI"** — priorisierte Empfehlungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Kurzfazit (TL;DR)
|
||||||
|
|
||||||
|
Die ganze Klasse moderner Browser-CAD/BIM-Tools konvergiert auf ein **gemeinsames
|
||||||
|
Set von Mustern**, das wir fast 1:1 übernehmen sollten:
|
||||||
|
|
||||||
|
1. **Ein Modell, viele synchrone Sichten** (2D-Plan ⇄ 3D ⇄ Daten/Sheets), Änderung
|
||||||
|
in einer Sicht propagiert sofort in alle anderen. (Arcol, Snaptrude)
|
||||||
|
2. **Kontextuelle UI** statt voller Werkzeugleisten: Buttons/Felder erscheinen nur,
|
||||||
|
wenn die aktuelle Auswahl sie erlaubt. (Arcol, Onshape, Figma)
|
||||||
|
3. **Drei-Zonen-Layout**: links Navigator/Layer-Baum, Mitte Canvas + schwebende
|
||||||
|
Tool-Palette, rechts Inspector. (Figma, Vectorworks, Onshape)
|
||||||
|
4. **Aggressives Snapping/Inferencing** mit Live-Glyphen + Modifier zum
|
||||||
|
Unterdrücken. (Onshape) — für Maus-im-Browser unverzichtbar.
|
||||||
|
5. **Direkte Manipulation per Grips** statt Dialogen. (Figma, DOSSIER-Backlog)
|
||||||
|
6. **Perceived performance** über Skeletons, optimistic UI und gescopte
|
||||||
|
Ladezustände — im Browser-3D-Kontext ein Differenzierungs-Hebel.
|
||||||
|
7. **Command-Palette (Ctrl/Cmd-K)** als Discovery- und Speed-Layer.
|
||||||
|
8. **Onboarding via „learn by doing"** an einem mitgelieferten Sample-Projekt +
|
||||||
|
progressive disclosure.
|
||||||
|
|
||||||
|
Unsere bestehende Architektur (semantisches Modell als Single Source of Truth,
|
||||||
|
abgeleitete Sichten, Vectorworks-Terminologie) ist exakt der richtige Unterbau für
|
||||||
|
diese Muster — die meiste Arbeit liegt in der **UI-Schicht**, nicht im Datenmodell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gesamtlayout & Navigator-/Layer-Panels
|
||||||
|
|
||||||
|
### 1.1 Was die Tools machen
|
||||||
|
|
||||||
|
**Figma** strukturiert die Fläche in vier Zonen: eine Toolbar, **zwei Panels** und
|
||||||
|
einen scrollbaren Canvas. Links das **Navigation-Panel** mit Layern und Pages,
|
||||||
|
rechts das **Properties-Panel**; der Layer-Baum „enthält und organisiert alle
|
||||||
|
Elemente auf dem Canvas … und zeigt, wie Elemente verbunden sind"
|
||||||
|
([Figma: left sidebar](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar),
|
||||||
|
[Figma: Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)).
|
||||||
|
|
||||||
|
**Vectorworks** trennt sauber zwei Konzepte, die für uns 1:1 relevant sind:
|
||||||
|
- Die **Navigation Palette** gibt Zugriff auf *Classes, Design Layers, Sheet
|
||||||
|
Layers, Viewports, Saved Views, References* — jeweils als eigener Tab mit Liste.
|
||||||
|
Sichtbarkeit wird per Klick in einer **Visibility-Spalte** gesetzt; Doppelklick
|
||||||
|
**aktiviert** einen Layer/eine Class. Die Zeichenfläche bleibt nutzbar, während
|
||||||
|
die Palette offen ist
|
||||||
|
([VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)).
|
||||||
|
- Paletten sind **andockbar/ein-/ausblendbar pro Workspace**
|
||||||
|
([VW Palettes & Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)).
|
||||||
|
|
||||||
|
**Arcol** baut beim Modellieren einen **Model Tree** im Menü auf — pro
|
||||||
|
BIM-Komponente wächst der Baum mit
|
||||||
|
([AEC Magazine: Arcol BIM in browser](https://aecmag.com/bim/arcol-bim-cloud-browser/)).
|
||||||
|
|
||||||
|
**Onshape** zeigt links den **Feature-/Assembly-Baum** (parametrische Historie:
|
||||||
|
Sketches, Features, Mates) — die Bauhistorie ist die primäre Navigation
|
||||||
|
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
|
||||||
|
|
||||||
|
### 1.2 Übernahme für uns
|
||||||
|
|
||||||
|
Unser Dokumentmodell hat **zwei unabhängige Achsen** (siehe ROADMAP §2c):
|
||||||
|
**Zeichnungsebenen** (Geschosse + Schnitte/Ansichten) und **Ebenen**
|
||||||
|
(Grafik-Kategorien-Baum `00 Raster … 80 Plangrafik`). Das mappt fast wörtlich auf
|
||||||
|
das Vectorworks-Navigations-Modell:
|
||||||
|
|
||||||
|
- **Linke Sidebar, getabbt** wie die VW-Navigation-Palette:
|
||||||
|
- Tab **„Geschosse / Ansichten"** (= unsere Zeichnungsebenen): EG/1OG/…,
|
||||||
|
Schnitte, Ansichten. Mit **Visibility-Toggle**, **Lock**, und **Doppelklick =
|
||||||
|
aktiv setzen** (welches Geschoss editiert wird).
|
||||||
|
- Tab **„Ebenen"** (= Grafik-Kategorien-Baum, in *jedem* Geschoss gleich): Baum
|
||||||
|
mit Code, Name, Farb-Swatch, Linienstärke, Visibility, Hatch. Pro Ansicht
|
||||||
|
schaltbar (das ist genau VWs „Sichtbarkeit pro Viewport/Saved View").
|
||||||
|
- Tab **„BIM-Tree / Elemente"** (aus DOSSIER-Backlog §11: *Element-Übersicht
|
||||||
|
Geschoss→Kind→Element, Suche, Shift-Klick = Zoom*). Das ist unser Pendant zu
|
||||||
|
Arcols Model Tree + Onshapes Feature-Baum.
|
||||||
|
- **Visibility-Spalte als Erstklass-Interaktion** (VW-Muster): Auge-Icon je Zeile,
|
||||||
|
Klick togglet sofort, kein Dialog. (Wir haben bereits `EyeIcon.tsx` — das ist die
|
||||||
|
Keimzelle.)
|
||||||
|
- **Wichtig:** Geschoss wird **im 3D *oder* Plan** betrachtet, *keine* getrennten
|
||||||
|
Daten — der View-Umschalter (siehe §2) gehört in die Geschoss-Auswahl, nicht in
|
||||||
|
separate Dokumente.
|
||||||
|
|
||||||
|
> **Anti-Pattern vermeiden:** Vectorworks selbst leidet unter
|
||||||
|
> **Paletten-Wildwuchs** (viele frei schwebende Fenster). Für ein fokussiertes
|
||||||
|
> Wohnbau-Tool: **feste 3-Zonen-Shell** (links Navigator, rechts Inspector), nicht
|
||||||
|
> N frei schwebende Palettenfenster. Figmas Striktheit schlägt VWs Flexibilität für
|
||||||
|
> unsere Zielgruppe (Architekt:innen, die schnell ein EFH zeichnen wollen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 3D ⇄ Plan-View-Umschaltung (das Herzstück)
|
||||||
|
|
||||||
|
### 2.1 Was die Tools machen
|
||||||
|
|
||||||
|
- **Snaptrude:** Nutzer arbeiten **in 2D *und* 3D gleichzeitig**; Änderung in einer
|
||||||
|
Sicht spiegelt sich automatisch in der anderen. Objekte sind **nach Geschossen
|
||||||
|
klassifiziert**, was es erlaubt, „3D-Geometrie zu zeichnen, während man in einer
|
||||||
|
2D-Grundriss-Ansicht arbeitet". Push-an-einer-Fläche „fühlt sich an wie eine
|
||||||
|
Linie ziehen", berechnet aber sofort Flächen/BIM-Daten neu
|
||||||
|
([ArchDaily: Snaptrude](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work),
|
||||||
|
[Snaptrude](https://www.snaptrude.com/)).
|
||||||
|
- **Arcol:** „Every view is 3D in Arcol" — und **Boards** (Präsentations-Layouts)
|
||||||
|
sind **live-synced**: ändert sich das Gebäude, aktualisieren sich die Layouts
|
||||||
|
automatisch (kein statischer PDF-Export)
|
||||||
|
([AEC Magazine: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||||
|
- **TestFit:** explizites **Expand/Collapse von Optionen** — man klappt Varianten
|
||||||
|
auf zum Vergleichen und wieder zu, um sich aufs Detail-Editieren im Canvas zu
|
||||||
|
konzentrieren („smoother flow between setting up a site, reviewing options, and
|
||||||
|
refining a design")
|
||||||
|
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
|
||||||
|
|
||||||
|
### 2.2 Übernahme für uns
|
||||||
|
|
||||||
|
Das ist **genau unsere Kern-Architektur** (ROADMAP §2c: „Ansichtstyp = Kamera-
|
||||||
|
Projektion + optionaler Schnitt"). Konkrete UI-Muster:
|
||||||
|
|
||||||
|
- **View-Switcher als segmented control** direkt am Canvas (oben links oder
|
||||||
|
oben mittig): `3D | Grundriss | Schnitt | Ansicht`. Plan-View = orthogonale
|
||||||
|
Top-Kamera + Clipping-Ebene auf `okff + schnitthöhe` (steht bereits so im Modell).
|
||||||
|
- **Kein** Moduswechsel der *Daten*, nur der **Kamera + Schnitt** — visuell als
|
||||||
|
weicher Übergang (Kamera-Animation) kommunizieren, damit Nutzer Orientierung
|
||||||
|
behalten (Snaptrude/Arcol-Gefühl: „dieselbe Sache aus anderem Winkel").
|
||||||
|
- **Optional, stark differenzierend:** **Split-View** (3D links, Plan rechts) wie
|
||||||
|
Snaptrudes „2D + 3D simultan". Da unsere Sichten ohnehin reaktiv aus *einem*
|
||||||
|
Modell abgeleitet werden (Zustand-Store → SVG-Plan + Three-Scene), ist Split-View
|
||||||
|
technisch billig und ein Wow-Moment im Onboarding.
|
||||||
|
- **Kamera-Presets** (aus DOSSIER-Backlog): Kardinal N/O/S/W, Iso-Oktanten,
|
||||||
|
**Norden-Rotation** (CH/Swisstopo-Georeferenz) — als kleine Würfel-/Kompass-Gizmo
|
||||||
|
oben rechts im 3D (ViewCube-Muster, bekannt aus Onshape/CAD allgemein
|
||||||
|
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm))).
|
||||||
|
- **Drawing Layers (Schnitte/Ansichten) sind „Saved Views"**: Auswahl in der linken
|
||||||
|
Sidebar = Kamera + Schnitt + Maßstab + Layer-Kombination springt an (VW „Saved
|
||||||
|
Views" / DOSSIER „Ausschnitte"). Das ersetzt Ordner-Wildwuchs bei 50+ Ansichten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Zeichnen & Editieren: Snapping, Inferencing, Grips, Tool-Paletten
|
||||||
|
|
||||||
|
### 3.1 Snapping / Inferencing — das wichtigste Detail im Browser
|
||||||
|
|
||||||
|
**Onshape** ist hier die Referenz (sehr konkret dokumentiert,
|
||||||
|
[Onshape Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)):
|
||||||
|
|
||||||
|
- **Inference-Typen:** horizontal, vertikal, **midpoint**, parallel, coincident,
|
||||||
|
Ausrichtung zum Origin/zu anderen Entities, Tangente, Perpendikular.
|
||||||
|
- **Visuelles Feedback:**
|
||||||
|
- **gelbe Highlights** auf Vertices/Mittelpunkten beim Hovern,
|
||||||
|
- **orange gestrichelte Linie** zeigt eine vorgeschlagene H/V-Ausrichtung,
|
||||||
|
- **orange Highlight** verwandter Geometrie beim Ziehen (relationales Feedback).
|
||||||
|
- **Steuerung:** Linksklick **akzeptiert** die vorgeschlagene Bedingung; **Shift
|
||||||
|
gedrückt halten unterdrückt** Inferencing temporär (loslassen = wieder an).
|
||||||
|
- **„Wake-up"-Inferences:** kurzes Verweilen über einer Geometrie „weckt" deren
|
||||||
|
Bezugslinien (z. B. erst Mittelpunkt antippen, dann woanders zeichnen → bekommt
|
||||||
|
Ausrichtung zu diesem Mittelpunkt).
|
||||||
|
- **Post-hoc:** vorhandene Geometrie ziehen triggert erneut Inferencing (Center
|
||||||
|
eines Kreises vertikal zum Origin ziehen → fügt automatisch vertikale Constraint).
|
||||||
|
|
||||||
|
**Constraint-Sichtbarkeit:** Constraint-Icons sind farbcodiert (blau =
|
||||||
|
externe/Referenz, weiß = intern) und ein-/ausblendbar
|
||||||
|
([Onshape Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
|
||||||
|
|
||||||
|
### 3.2 Übernahme für uns (Snapping)
|
||||||
|
|
||||||
|
Für ein Maus-bedientes Browser-Tool ist **gutes Snapping der Unterschied zwischen
|
||||||
|
„Spielzeug" und „Werkzeug".** Konkret:
|
||||||
|
|
||||||
|
- **Snap-Targets** (Wohnbau-relevant): Wand-Endpunkte/Achsen, Wand-Mittelpunkte,
|
||||||
|
Rechtwinklig/Parallel zu bestehender Wand, **Raster** (Achsraster `00 Raster`),
|
||||||
|
Öffnungs-Achsen, vorhandene 2D-Linien-Endpunkte, Schnittpunkte.
|
||||||
|
- **Feedback exakt wie Onshape übernehmen:** Snap-Glyph am Cursor (●
|
||||||
|
Endpunkt, △ Mitte, ⟂ rechtwinklig), **gestrichelte Hilfslinie** für
|
||||||
|
Achsen-Alignment, Hover-Highlight des Snap-Ziels.
|
||||||
|
- **Modifier:** **Shift unterdrückt Snapping** (Onshape-Konvention) — Nutzer
|
||||||
|
erwarten das bereits aus anderen Tools. Zusätzlich **Ortho-Modus** (z. B. Shift
|
||||||
|
für 0/45/90° beim Linienziehen — Figma-Konvention) sauber davon trennen oder per
|
||||||
|
Toggle.
|
||||||
|
- **Live-Maßeingabe beim Zeichnen** (CAD-Standard, auch Onshape): während des
|
||||||
|
Ziehens Länge/Winkel tippbar (Tab zwischen Feldern). Das ersetzt nachträgliches
|
||||||
|
Dimensionieren und ist für Architekt:innen Pflicht.
|
||||||
|
- Wir brauchen **kein** volles Constraint-Solver-System wie Onshape (mechanisches
|
||||||
|
parametrisches CAD). BIM-Wände sind achs-basiert; **Inferencing beim Setzen**
|
||||||
|
genügt, persistente geometrische Constraints sind Overkill für Wohnbau.
|
||||||
|
|
||||||
|
### 3.3 Grip-Editing / direkte Manipulation
|
||||||
|
|
||||||
|
- **Figma:** Auswahl zeigt Bounding-Box mit **Resize-Handles**; ziehen
|
||||||
|
manipuliert direkt; Smart-Guides/Maße erscheinen relativ zu Nachbarn beim Bewegen
|
||||||
|
([Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)).
|
||||||
|
- **Snaptrude:** Push/Pull an Flächen als primäre 3D-Edit-Geste
|
||||||
|
([ArchDaily](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)).
|
||||||
|
- **DOSSIER-Backlog (§11)** listet **Grip-Editing** (Wand-Endpunkte,
|
||||||
|
Schnitt-Symbole im Plan) explizit als Aufwand L, Phase 3–4 — und das **9-Punkt-
|
||||||
|
Objekt-Info** (lesen + verschieben/skalieren/rotieren direkt).
|
||||||
|
|
||||||
|
**Übernahme:** Grips sind der wichtigste „pro feel"-Hebel.
|
||||||
|
- **Wand-Endpunkt-Grips** im Plan *und* 3D, mit Snapping (s. o.) und Live-Maß.
|
||||||
|
- **Öffnungs-Grips** (Position entlang Wand, Breite) — Host-Beziehung bleibt erhalten.
|
||||||
|
- **Schnittlinien-Grips im Plan** (Schnittlinie ziehen → Schnitt-Ansicht
|
||||||
|
re-deriviert) — das ist Grip-Editing über die Sicht-Grenze hinweg, ein starkes
|
||||||
|
Differenzierungsmerkmal.
|
||||||
|
- Selektion → **bounding handles** (Figma-Muster) für 2D-Plangrafik (Linien,
|
||||||
|
Rechtecke, Text).
|
||||||
|
|
||||||
|
### 3.4 Tool-Palette & kontextuelle Werkzeuge
|
||||||
|
|
||||||
|
**Kontextuelle UI ist das durchgehende Muster:**
|
||||||
|
- **Arcol:** „buttons appear only when they can be used" — Loft/Sweep erscheinen bei
|
||||||
|
Auswahl zweier Sketches, Boolean bei zwei Extrusions
|
||||||
|
([AEC: Arcol sneak peek](https://aecmag.com/bim/arcol-a-sneak-peek/)).
|
||||||
|
- **Onshape:** Sketch-Toolbar erscheint beim Betreten des Sketch-Modus; Tools in
|
||||||
|
Gruppen (vertikale Trennlinien), Dropdown-Pfeile für Varianten; Dialoge mit
|
||||||
|
**blau hinterlegtem Feld**, das eine Auswahl im Graphics-Bereich verlangt
|
||||||
|
([Onshape sketch toolbar](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm),
|
||||||
|
[Onshape Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)).
|
||||||
|
- **Figma:** schmale Bottom-/Top-Toolbar mit den Kern-Tools; alles Weitere
|
||||||
|
kontextuell rechts.
|
||||||
|
|
||||||
|
**Übernahme:**
|
||||||
|
- **Schlanke Tool-Palette** (Figma-artig), gruppiert nach unserer Domäne:
|
||||||
|
*Wand · Tür/Fenster · Decke · Treppe · Dach · Raum* (BIM) und *Linie · Polylinie
|
||||||
|
· Rechteck · Kreis · Bogen · Text · Bemaßung* (2D auf `80 Plangrafik`).
|
||||||
|
- **Modus-bewusste Tools:** im Grundriss andere Defaults als im 3D; im
|
||||||
|
Schnitt/Ansicht nur Annotation/2D-Tools.
|
||||||
|
- **Contextual action bar bei Auswahl** (Arcol-Muster): selektiere eine Wand →
|
||||||
|
schwebende Mini-Toolbar „Tür einsetzen / Fenster / Wandtyp / verschneiden".
|
||||||
|
Selektiere zwei Wände → „verschneiden / verlängern".
|
||||||
|
- **Aktives Tool sticky + ESC bricht ab**, Leertaste = Pan, Scroll = Zoom
|
||||||
|
(Figma/CAD-Konventionen — Nutzer bringen Muskelgedächtnis mit).
|
||||||
|
- **LoD-bewusste Tool-/Stil-UI** (DOSSIER-Backlog: *zeigt nur passende Controls je
|
||||||
|
Geometrie-Typ*) — keine Füll-Optionen bei 3D-Auswahl. Deckt sich mit Figmas
|
||||||
|
„controls appear based on layer type".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Property-/Inspector-Panel
|
||||||
|
|
||||||
|
### 4.1 Was die Tools machen
|
||||||
|
|
||||||
|
**Figma — rechte Sidebar** (für uns das Vorbild,
|
||||||
|
[Figma right sidebar](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)):
|
||||||
|
- Tabs **Design / Prototype** (bei Edit), **Inspect / Properties** (bei View-only).
|
||||||
|
- Kategorien: **Alignment/Rotation/Position → Dimensions → Constraints/Layout →
|
||||||
|
Appearance (Fill, Stroke, Effects) → Export**.
|
||||||
|
- **Controls erscheinen je Layer-Typ** (kontextuell).
|
||||||
|
- **Dev-Mode/Inspect** liefert konkrete Werte + Abstände zwischen Objekten + Code.
|
||||||
|
|
||||||
|
**Arcol — rechtes Panel** zeigt **Gebäude-Metriken** kontextuell: Geschossfläche,
|
||||||
|
Anzahl Geschosse, GFZ/FAR, Unit-Count, Standortfläche, Kostenschätzung
|
||||||
|
([Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||||
|
|
||||||
|
**TestFit — ein zentrales Parameter-Panel:** „alle Schlüsselparameter an einem
|
||||||
|
Ort" (Unit-Counts, Parkplatz-Ziele, Gebäudegrößen-Limits) → sofortige Wirkung auf
|
||||||
|
generierte Optionen
|
||||||
|
([TestFit 5.19](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)).
|
||||||
|
|
||||||
|
**Onshape — Feature-Dialoge:** Erstellen/Editieren über Dialoge mit Pflicht-
|
||||||
|
Selektionsfeldern (blau)
|
||||||
|
([Onshape UI Basics](https://cad.onshape.com/help/Content/ui-basics.htm)).
|
||||||
|
|
||||||
|
### 4.2 Übernahme für uns
|
||||||
|
|
||||||
|
- **Rechter Inspector, kontextuell nach Element-Typ** (Figma-Muster):
|
||||||
|
- **Wand** → Wandtyp (→ WallType-Bibliothek), Referenzlage mid/left/right
|
||||||
|
(DOSSIER-Backlog), Höhe, Achs-Endpunkte (9-Punkt/Maße), Stil-Overrides.
|
||||||
|
- **Tür/Fenster** → Breite/Höhe, Brüstung, Schwenkbogen/Anschlag, Detailgrad,
|
||||||
|
Symbol.
|
||||||
|
- **Decke/Slab** → SlabType, UK/OK-Override, Aussparungen.
|
||||||
|
- **Raum** → SIA-416-Kategorie (HNF/NNF/VF/FF/GF/AGF), Fläche (read-only,
|
||||||
|
berechnet), Stempel-Felder.
|
||||||
|
- **2D-Element** → Linienstil (→ Line Manager), Hatch (→ Hatch Manager), Farbe.
|
||||||
|
- **Sektionen kollabierbar** (Figma) — Wohnbau-Inspector kann lang werden;
|
||||||
|
Default-Sektionen offen, Fortgeschrittenes (Overrides) zugeklappt
|
||||||
|
(= progressive disclosure, s. §7).
|
||||||
|
- **Mixed-value-Handling bei Mehrfachauswahl** (Figma): bei Mehrfachauswahl
|
||||||
|
abweichende Werte als „Mixed/—" zeigen, gemeinsames Editieren erlauben. Wichtig
|
||||||
|
z. B. „alle EG-Wände auf Wandtyp X".
|
||||||
|
- **Live-Metriken-Block** (Arcol-Muster) — selbst im Wohnbau wertvoll:
|
||||||
|
Bruttogeschossfläche, **SIA-416-Bilanz**, Raumzahl, Volumen. Im Inspector wenn
|
||||||
|
nichts selektiert ist = „Projekt-Übersicht" (vgl. Figmas Canvas-Level-Optionen
|
||||||
|
bei leerer Auswahl).
|
||||||
|
- **Read-only-Felder klar markieren** (berechnete Flächen/Volumen) vs. editierbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Resource-Manager (Bibliotheken)
|
||||||
|
|
||||||
|
### 5.1 Was Vectorworks macht — unser direktes Vorbild
|
||||||
|
|
||||||
|
Der **Resource Manager** ist „ein zentraler Ort für Assets" (Symbole, Linientypen,
|
||||||
|
Texturen, Materialien …)
|
||||||
|
([VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)):
|
||||||
|
- **Zwei-/Drei-Pane-Modell:** **File-Browser** (offene Dateien, Favoriten,
|
||||||
|
VW-Libraries, User-/Workgroup-Libraries) → **Resource-Viewer** (Ressourcen der
|
||||||
|
gewählten Datei) → optional **Preview/Metadaten**
|
||||||
|
([VW File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm),
|
||||||
|
[VW Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)).
|
||||||
|
- **Organisation mehrdimensional:** nach Quelle, **nach Typ** (Dropdown-Filter),
|
||||||
|
nach Ordnerstruktur.
|
||||||
|
- **Ansichten:** Thumbnails / List / Thumbnails-List; **Suchfeld** mit Filtern.
|
||||||
|
- **Resource Selector**: dieselbe Bibliothek erscheint **in Dialogen** und zeigt
|
||||||
|
dort nur **kontextuell passende** Ressourcen.
|
||||||
|
|
||||||
|
### 5.2 Übernahme für uns
|
||||||
|
|
||||||
|
Unsere ROADMAP §2d definiert bereits **Line Manager / Hatch Manager / Component
|
||||||
|
Manager**, mit Verweis-per-id-Architektur (Components → Hatches → LineStyles). Das
|
||||||
|
Vectorworks-Modell passt perfekt:
|
||||||
|
|
||||||
|
- **Ein gemeinsames Resource-Browser-Pattern** für alle Bibliotheken (Linienstile,
|
||||||
|
Schraffuren, Components/Baustoffe, später Wand-/Öffnungs-Stil-Kataloge,
|
||||||
|
Material-PBR, Raumstempel-Layouts). Eine wiederverwendbare React-Komponente,
|
||||||
|
parametrisiert nach Ressourcentyp.
|
||||||
|
- **Zwei Erscheinungsformen** (wie VW):
|
||||||
|
1. **Manager-Ansicht** (großes Panel/Modal) zum Anlegen/Editieren/Duplizieren.
|
||||||
|
2. **Inline-Resource-Selector** im Inspector — beim Setzen eines Wandtyps/Hatch
|
||||||
|
öffnet sich ein kompakter Picker mit Thumbnails, gefiltert auf den passenden
|
||||||
|
Typ. (Figma macht das analog mit „Styles/Variables".)
|
||||||
|
- **Thumbnails sind im CAD-Kontext kritisch**: Hatch-Vorschau, Component-
|
||||||
|
Schichtaufbau, Linienstil-Strich als gerenderte Mini-Previews.
|
||||||
|
- **Zentrale Änderung propagiert** (unsere id-Referenz-Architektur): Component-Farbe
|
||||||
|
ändern → alle Wände mit diesem Component aktualisieren live. Das ist Arcols
|
||||||
|
„single source of truth" auf Ressourcen-Ebene.
|
||||||
|
- **Cross-Projekt-Presets/Favoriten** (DOSSIER-Backlog, LocalStorage; VW-Favoriten):
|
||||||
|
„einmal speichern, überall nutzen".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Perceived Performance (gefühlte Geschwindigkeit)
|
||||||
|
|
||||||
|
Browser-3D + WASM-Geometrie (web-ifc, OpenCascade) + HLR-Projektion = **echte
|
||||||
|
Latenz** an mehreren Stellen. Gefühlte Performance ist hier ein
|
||||||
|
Differenzierungs-Hebel gegenüber schwerfälligem Revit/ArchiCAD.
|
||||||
|
|
||||||
|
### 6.1 Belegte Muster
|
||||||
|
|
||||||
|
- **Skeleton-Screens** lassen Apps **20–30 % schneller** wirken als Spinner bei
|
||||||
|
identischer realer Ladezeit
|
||||||
|
([LogRocket: skeleton screens](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/),
|
||||||
|
[UI Deploy](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)).
|
||||||
|
- **Indikator zur Situation passen:** Spinner für kurze Waits, Skeleton für
|
||||||
|
Content, Progress-Bar für messbare Operationen, **optimistic UI** für
|
||||||
|
„instant-feeling" Aktionen
|
||||||
|
([Onething: skeleton vs spinner](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
|
||||||
|
- **Optimistic UI**: UI sofort aktualisieren, Server-Bestätigung abwarten, nur bei
|
||||||
|
Fehler zurückrollen — ideal für häufige, risikoarme Aktionen
|
||||||
|
([Smart Interface Design Patterns](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)).
|
||||||
|
- **Verzögerung vor Indikator (100–200 ms):** schließt die Operation vorher ab,
|
||||||
|
gar kein Indikator → kein Flackern
|
||||||
|
([Onething](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)).
|
||||||
|
- **Gescopte Ladezustände** (React Suspense / Next loading.tsx): nur der betroffene
|
||||||
|
Bereich lädt, der Rest bleibt interaktiv; `aria-busy`, Live-Regions,
|
||||||
|
reduced-motion respektieren
|
||||||
|
([LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)).
|
||||||
|
|
||||||
|
### 6.2 Übernahme für uns (konkret)
|
||||||
|
|
||||||
|
- **Optimistic Model-Edits:** Geometrie-Mutation (Wand ziehen, Tür setzen) **sofort**
|
||||||
|
im Zustand-Store + 3D anzeigen; **schwere Booleans/HLR im Web Worker** (Comlink,
|
||||||
|
bereits geplant) nachziehen. Wand erscheint sofort, die *exakte* verschnittene
|
||||||
|
Öffnung/Schnittlinie „schärft nach". UI bleibt flüssig.
|
||||||
|
- **Progressive Plan-Generierung:** SVG-Grundriss zuerst grob (Achsen/Linien),
|
||||||
|
Schraffuren/Symbole nachladen — Skeleton/„low-detail first" statt Spinner.
|
||||||
|
- **HLR-Schnitte (Risiko #4):** Worker + Caching (ROADMAP §6). UI: **Skeleton der
|
||||||
|
Schnitt-Ansicht** + „berechne verdeckte Kanten…" mit Progress, restliche App
|
||||||
|
bleibt nutzbar (gescopter Ladezustand).
|
||||||
|
- **Delay-then-show** für alle Worker-Tasks (150 ms-Schwelle), sonst Flicker beim
|
||||||
|
schnellen Editieren.
|
||||||
|
- **Three.js-Disziplin:** stabile 60 fps beim Orbit/Pan ist selbst „perceived
|
||||||
|
performance" — instanziertes Rendering, Frustum-Culling, LoD für ferne Geometrie
|
||||||
|
(deckt sich mit ROADMAP-Phase-7-Performance-Härtung). Lieber 60 fps bei grober
|
||||||
|
Geometrie als ruckelnde Präzision.
|
||||||
|
- **Auto-Save-Status klar, unaufdringlich** kommunizieren („Gespeichert"/„Speichern…"),
|
||||||
|
optimistic — nie blockierend (Figma-Muster).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Onboarding & Discoverability
|
||||||
|
|
||||||
|
### 7.1 Belegte Muster
|
||||||
|
|
||||||
|
- **Progressive Disclosure:** zuerst nur Essenzielles zeigen, Komplexität schrittweise
|
||||||
|
enthüllen — reduziert kognitive Last; drei Typen: step-by-step, conditional,
|
||||||
|
contextual
|
||||||
|
([IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure),
|
||||||
|
[UXPin](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)).
|
||||||
|
- **„Learn by doing" an Sample-Dokument:** Grammarly startet Nutzer mit einem
|
||||||
|
Beispiel-Dokument mit Fehlern; Hotspots/Tooltips führen durch Features
|
||||||
|
([Userpilot: onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)).
|
||||||
|
- **Stufenweises Aufdecken fortgeschrittener Features** (Asana: erst Projekt
|
||||||
|
anlegen, später Dependencies/Kanban/Gantt)
|
||||||
|
([Userpilot: progressive disclosure](https://userpilot.com/blog/progressive-disclosure-examples/)).
|
||||||
|
- **Command-Palette als Discovery-Layer:** durchsuchbare Befehlsliste hilft, Features
|
||||||
|
zu entdecken — „incredible effect on exploration and feature discoverability",
|
||||||
|
besonders für neue/seltene Nutzer
|
||||||
|
([Mobbin: command palette](https://mobbin.com/glossary/command-palette),
|
||||||
|
[Untitled UI: command menus](https://www.untitledui.com/components/command-menus)).
|
||||||
|
- **Arcol** wirbt explizit mit „low barrier to entry, gentle learning curve … clean,
|
||||||
|
intuitive, requires minimal training"
|
||||||
|
([AEC: Arcol BIM 2.0](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)).
|
||||||
|
|
||||||
|
### 7.2 Übernahme für uns
|
||||||
|
|
||||||
|
- **Mitgeliefertes Sample-Projekt** (wir haben bereits `sampleProject.ts`!) als
|
||||||
|
Onboarding-Bühne: ein kleines EFH, fertig modelliert. Nutzer **manipuliert echtes
|
||||||
|
Modell** statt leerem Canvas (Grammarly-Muster). Erste Geste: „zieh diese Wand"
|
||||||
|
→ sieht 3D + Plan live mitlaufen (unser Kern-Wow).
|
||||||
|
- **Progressive Disclosure im Inspector & Tool-Palette:** Default zeigt
|
||||||
|
Wohnbau-Essenz (Wand/Tür/Fenster/Decke/Raum). Fortgeschrittenes (Prioritäts-
|
||||||
|
Verschneidung, Overrides, Detailgrade, Section-Styles) **zugeklappt / hinter
|
||||||
|
„Erweitert"**. Das passt zu unserem radikalen Wohnbau-Fokus.
|
||||||
|
- **Contextual coachmarks** statt langem Tutorial: beim ersten Selektieren einer
|
||||||
|
Wand ein kleiner Tooltip „Endpunkt ziehen zum Verlängern, Doppelklick für Wandtyp".
|
||||||
|
- **Command-Palette (Ctrl/Cmd-K)** — siehe §8 — doppelt als Onboarding: alle
|
||||||
|
Werkzeuge/Befehle durchsuchbar = lebende Feature-Liste.
|
||||||
|
- **Tastatur-Kürzel sichtbar machen** (in Tooltips, in der Palette) — schult
|
||||||
|
beiläufig pro Workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Command-Palette & Tastatur
|
||||||
|
|
||||||
|
### 8.1 Belegte Muster
|
||||||
|
|
||||||
|
- **Ctrl/Cmd-K** ist die De-facto-Konvention (Linear, Figma [Cmd-P], Notion,
|
||||||
|
Vercel, Raycast, Slack, Superhuman); VS Code nutzt Cmd-Shift-P
|
||||||
|
([Mobbin](https://mobbin.com/glossary/command-palette),
|
||||||
|
[Superhuman: command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)).
|
||||||
|
- Zwei Haupt-Use-Cases: **Navigation/Suche** und **Shortcuts/Quick Actions**
|
||||||
|
([Outdraw Academy: command palette](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)).
|
||||||
|
- Trigger kann **sichtbar** (Button/Suchleiste) oder nur per Shortcut sein —
|
||||||
|
für Discovery besser **auch sichtbar**
|
||||||
|
([Mobbin](https://mobbin.com/glossary/command-palette)).
|
||||||
|
|
||||||
|
### 8.2 Übernahme für uns
|
||||||
|
|
||||||
|
- **Cmd/Ctrl-K-Palette** für: Werkzeug aktivieren („Wand", „Tür"), Ansicht
|
||||||
|
springen („Grundriss EG", „Schnitt A-A"), Ressource öffnen („Hatch Manager"),
|
||||||
|
globale Aktionen („Norden rotieren", „PDF exportieren", „SIA-Bilanz").
|
||||||
|
- **Sichtbarer Trigger** (Such-/Befehlsfeld in der Top-Bar) für Entdeckung +
|
||||||
|
Shortcut für Speed.
|
||||||
|
- **Konsistente, dokumentierte Shortcuts** (Figma-Disziplin): W=Wand, T=Tür,
|
||||||
|
L=Linie, Space=Pan, Scroll=Zoom, Shift=Snap aus, Esc=Abbrechen, G=Grundriss-Toggle.
|
||||||
|
In Tooltips + Palette anzeigen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Leitplanken für unsere UI (priorisierte Empfehlungen)
|
||||||
|
|
||||||
|
> Sortiert nach **Hebel × Aufwand**. „P#" = grobe Phasen-Zuordnung zur ROADMAP.
|
||||||
|
|
||||||
|
### A. Sofort / Fundament (Phase 0–1) — billig, prägt alles
|
||||||
|
|
||||||
|
1. **Feste 3-Zonen-Shell.** Links **Navigator** (Tabs: *Geschosse/Ansichten · Ebenen
|
||||||
|
· BIM-Tree*, je mit Visibility-Toggle wie VW Navigation Palette). Mitte **Canvas**
|
||||||
|
mit schwebender Tool-Palette + View-Switcher. Rechts **Inspector** (kontextuell).
|
||||||
|
*Keine* frei schwebenden Palettenfenster (Anti-VW-Wildwuchs).
|
||||||
|
2. **View-Switcher als segmented control** `3D | Grundriss | Schnitt | Ansicht`
|
||||||
|
direkt am Canvas; weiche Kamera-Übergänge; *eine* Datenquelle (kein Daten-
|
||||||
|
Moduswechsel). Nutzt unsere bestehende „abgeleitete Sichten"-Architektur.
|
||||||
|
3. **Visibility/Lock pro Zeile** als Erstklass-Interaktion (1 Klick, kein Dialog) —
|
||||||
|
`EyeIcon.tsx` ausbauen.
|
||||||
|
4. **Kontextueller Inspector**: Controls nur für den selektierten Element-Typ
|
||||||
|
(Figma/Arcol); berechnete Felder read-only markiert; Sektionen kollabierbar.
|
||||||
|
5. **Tastatur-Grundlagen + Konventionen festnageln**: Space=Pan, Scroll=Zoom,
|
||||||
|
Esc=Abbrechen, Shift=Snap aus. Früh festlegen → Muskelgedächtnis.
|
||||||
|
|
||||||
|
### B. Kern-„Pro-Feel" (Phase 1–2) — der eigentliche Wert
|
||||||
|
|
||||||
|
6. **Snapping/Inferencing nach Onshape-Vorbild**: Snap-Glyphen am Cursor,
|
||||||
|
gestrichelte Achsen-Hilfslinien, Hover-Highlight, **Shift = unterdrücken**,
|
||||||
|
Wake-up-Inferences. Targets: Wand-Enden/Achsen/Mitten, Raster, Rechtwinklig/
|
||||||
|
Parallel, Öffnungs-Achsen. **Höchste Priorität für „Werkzeug-Gefühl".**
|
||||||
|
7. **Live-Maßeingabe beim Zeichnen** (Länge/Winkel tippbar, Tab zwischen Feldern).
|
||||||
|
8. **Kontextuelle Action-Bar bei Auswahl** (Arcol): Wand selektiert → „Tür/Fenster
|
||||||
|
einsetzen / Wandtyp / verschneiden". Buttons erscheinen nur, wenn anwendbar.
|
||||||
|
9. **Schlanke domänen-gruppierte Tool-Palette** (BIM-Bauteile + 2D-Zeichnen),
|
||||||
|
modus-bewusst je Ansicht.
|
||||||
|
10. **Optimistic Edits + Worker-Nachzug**: Mutation sofort sichtbar, schwere
|
||||||
|
Booleans/HLR im Worker (Comlink); Delay-then-show (150 ms) statt Spinner-Flicker.
|
||||||
|
|
||||||
|
### C. Differenzierung (Phase 3) — hier gewinnen wir
|
||||||
|
|
||||||
|
11. **Grip-Editing** (Wand-Endpunkte, Öffnungs-Position/-Breite, **Schnittlinie im
|
||||||
|
Plan**) mit Snapping + Live-Maß, in 2D *und* 3D. (DOSSIER-Backlog, Aufwand L —
|
||||||
|
aber Kern-Differenzierer.)
|
||||||
|
12. **Saved Views / Ausschnitte** in der linken Sidebar = Kamera + Schnitt + Maßstab
|
||||||
|
+ Layer-Kombination per Klick (VW „Saved Views" / DOSSIER). Skaliert auf 50+
|
||||||
|
Ansichten.
|
||||||
|
13. **Wiederverwendbares Resource-Browser-Pattern** für Line/Hatch/Component-Manager:
|
||||||
|
Zwei-Pane (File-Browser → Viewer mit Thumbnails) als Manager *und* als inline
|
||||||
|
Picker im Inspector (VW-Modell). Zentrale Änderung propagiert (id-Referenzen).
|
||||||
|
14. **Perceived-performance-Politik festschreiben**: Skeletons für Plan-/Schnitt-
|
||||||
|
Generierung, gescopte Ladezustände (restliche App bleibt nutzbar),
|
||||||
|
reduced-motion/`aria-busy` respektieren.
|
||||||
|
15. **Split-View 3D|Plan** (Snaptrude-Muster) — billig dank reaktiver Sichten,
|
||||||
|
starker Wow-Effekt; auch fürs Onboarding.
|
||||||
|
|
||||||
|
### D. Adoption & Politur (Phase 1 fortlaufend → 7)
|
||||||
|
|
||||||
|
16. **Onboarding via Sample-Projekt** (`sampleProject.ts` als fertiges EFH);
|
||||||
|
„learn by doing", erste Geste zeigt 3D⇄Plan-Live-Sync. Progressive Disclosure:
|
||||||
|
Wohnbau-Essenz default, Fortgeschrittenes (Prioritäts-Verschneidung, Overrides,
|
||||||
|
Detailgrade) zugeklappt.
|
||||||
|
17. **Command-Palette (Cmd/Ctrl-K)** + sichtbarer Trigger: Werkzeuge/Ansichten/
|
||||||
|
Ressourcen/Aktionen durchsuchbar; doppelt als Discovery-Layer.
|
||||||
|
18. **Live-Metriken-Block** im Inspector (Arcol): BGF, **SIA-416-Bilanz**, Räume,
|
||||||
|
Volumen — bei leerer Auswahl als Projekt-Übersicht.
|
||||||
|
19. **CH-Spezifika UI-seitig vorsehen**: **Norden-Rotation**-Gizmo (ViewCube/Kompass),
|
||||||
|
SIA-Raumkategorien im Raum-Inspector, später Swisstopo-Import-Flow mit Auto-Zoom
|
||||||
|
+ Nullpunkt-Verschiebung.
|
||||||
|
|
||||||
|
### Übergreifende Designprinzipien (gelten immer)
|
||||||
|
|
||||||
|
- **Kontextualität vor Vollständigkeit** — zeige nur, was jetzt anwendbar ist
|
||||||
|
(Arcol/Onshape/Figma). Direkt verzahnt mit unserem „LoD-bewusste Stil-UI"-Backlog.
|
||||||
|
- **Direkte Manipulation vor Dialogen** — Grips/Drag/Inline-Edit schlägt
|
||||||
|
Properties-Dialog (Figma/Snaptrude/DOSSIER).
|
||||||
|
- **Eine Wahrheit, viele Sichten** — niemals Sicht-spezifische Daten; alles aus dem
|
||||||
|
semantischen Modell ableiten (deckt sich exakt mit unserem Architektur-Prinzip).
|
||||||
|
- **Konventionen respektieren** — Pan/Zoom/Snap/Esc/Cmd-K wie die etablierten Tools;
|
||||||
|
Nutzer bringen Muskelgedächtnis aus Figma/CAD mit.
|
||||||
|
- **Gefühlte > tatsächliche Geschwindigkeit** — optimistic + Skeletons + 60 fps;
|
||||||
|
im schweren Browser-3D-Geometrie-Kontext ein echter Wettbewerbsvorteil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
**Arcol**
|
||||||
|
- [Arcol unleashed – BIM 2.0 (AEC Magazine)](https://aecmag.com/bim/arcol-unleashed-bim-2-0/)
|
||||||
|
- [Arcol – BIM in a browser (AEC Magazine)](https://aecmag.com/bim/arcol-bim-cloud-browser/)
|
||||||
|
- [Arcol: a sneak peek (AEC Magazine)](https://aecmag.com/bim/arcol-a-sneak-peek/)
|
||||||
|
- [The Arcol Manifesto](https://arcol.io/blog/the-arcol-manifesto)
|
||||||
|
|
||||||
|
**Snaptrude**
|
||||||
|
- [Snaptrude – browser-based BIM tool (ArchDaily)](https://www.archdaily.com/1009121/snaptrude-the-browser-based-bim-tool-thats-changing-the-way-architects-work)
|
||||||
|
- [Snaptrude (offiziell)](https://www.snaptrude.com/)
|
||||||
|
|
||||||
|
**TestFit**
|
||||||
|
- [TestFit 5.19: A New Generative Design Workflow](https://www.testfit.io/blog/testfit-5-19-a-new-generative-design-workflow)
|
||||||
|
- [TestFit (offiziell)](https://www.testfit.io/)
|
||||||
|
|
||||||
|
**Onshape**
|
||||||
|
- [Onshape: User Interface Basics](https://cad.onshape.com/help/Content/ui-basics.htm)
|
||||||
|
- [Onshape: Automatic Inferencing](https://cad.onshape.com/help/Content/Sketch/automatic_inferencing.htm)
|
||||||
|
- [Onshape: Working with Constraints](https://cad.onshape.com/help/Content/Sketch/working_with_constraints.htm)
|
||||||
|
- [Onshape: Sketch Basics](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm)
|
||||||
|
|
||||||
|
**Vectorworks**
|
||||||
|
- [VW Resource Manager](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource%20Manager.htm)
|
||||||
|
- [VW Resource Manager: File browser pane](https://app-help.vectorworks.net/2023/eng/VW2023_Guide/ResourceManager/Resource_Manager_File_browser_pane.htm)
|
||||||
|
- [VW Resource Manager: Resource viewer pane](https://app-help.vectorworks.net/2026/eng/VW2026_Guide/ResourceManager/Resource_Manager_Resource_viewer_pane.htm)
|
||||||
|
- [VW Navigation Palette](https://app-help.vectorworks.net/2022/eng/VW2022_Guide/Structure/The_Navigation_palette.htm)
|
||||||
|
- [VW Palettes and Tool Sets](https://app-help.vectorworks.net/2018/eng/VW2018_Guide/Start/Palettes_and_Tool_Sets.htm)
|
||||||
|
|
||||||
|
**Figma**
|
||||||
|
- [Figma: right sidebar / layer properties](https://help.figma.com/hc/en-us/articles/360039832014-Design-prototype-and-explore-layer-properties-in-the-right-sidebar)
|
||||||
|
- [Figma: left sidebar (layers & pages)](https://help.figma.com/hc/en-us/articles/360039831974-View-layers-and-pages-in-the-left-sidebar)
|
||||||
|
- [Figma for Everyone: The Interface](https://www.inthepocket.design/course/figma-for-everyone/the-interface)
|
||||||
|
|
||||||
|
**Perceived Performance**
|
||||||
|
- [LogRocket: Skeleton loading screen design](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/)
|
||||||
|
- [UI Deploy: Skeleton Screens vs. Spinners](https://ui-deploy.com/blog/skeleton-screens-vs-spinners-optimizing-perceived-performance)
|
||||||
|
- [Onething: Skeleton Screens vs Loading Spinners](https://www.onething.design/post/skeleton-screens-vs-loading-spinners)
|
||||||
|
- [Smart Interface Design Patterns: Loading & Progress UX](https://smart-interface-design-patterns.com/articles/designing-better-loading-progress-ux/)
|
||||||
|
|
||||||
|
**Command Palette**
|
||||||
|
- [Mobbin: Command Palette](https://mobbin.com/glossary/command-palette)
|
||||||
|
- [Untitled UI: Command menus (Cmd-K)](https://www.untitledui.com/components/command-menus)
|
||||||
|
- [Superhuman: How to build a remarkable command palette](https://blog.superhuman.com/how-to-build-a-remarkable-command-palette/)
|
||||||
|
- [Outdraw Academy: Command Palette UX pattern](https://outdraw-academy.gitbook.io/ux-patterns/command-palette)
|
||||||
|
|
||||||
|
**Onboarding / Progressive Disclosure**
|
||||||
|
- [IxDF: Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
|
||||||
|
- [UXPin: What Is Progressive Disclosure](https://www.uxpin.com/studio/blog/what-is-progressive-disclosure/)
|
||||||
|
- [Userpilot: User onboarding examples](https://userpilot.com/blog/user-onboarding-examples/)
|
||||||
|
- [Userpilot: Progressive disclosure examples](https://userpilot.com/blog/progressive-disclosure-examples/)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Welle C — HLR-Spike: Feasibility-Report
|
||||||
|
|
||||||
|
> 2D-Vektor-Schnitte/-Ansichten aus dem 3D-Modell via Hidden-Line-Removal (HLR)
|
||||||
|
> mit opencascade.js (OCCT-WASM). Isolierter De-Risking-Spike, NICHT in die App
|
||||||
|
> verdrahtet. Einheiten: Meter.
|
||||||
|
|
||||||
|
## Ergebnis (kurz)
|
||||||
|
|
||||||
|
**HLR funktioniert in diesem Browser-Projekt.** opencascade.js initialisiert im
|
||||||
|
Vite-Build, und der HLR-Lauf liefert korrekte, getrennte sichtbare/verdeckte
|
||||||
|
2D-Kanten. Verifiziert im echten Browser (Chromium via Vite-Dev-Server) an einer
|
||||||
|
L-Wand + Bodenplatte, Front-Ansicht:
|
||||||
|
|
||||||
|
| Messwert | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Sichtbare Kanten (sharp) | **9** |
|
||||||
|
| Verdeckte Kanten | **16** |
|
||||||
|
| Reine HLR-Rechenzeit | **~21 ms** |
|
||||||
|
| WASM-Größe (unkomprimiert) | **62.8 MB** (65 864 037 Bytes) |
|
||||||
|
| WASM-Größe (gzip) | **~19.6 MB** |
|
||||||
|
| WASM-Fetch (lokal, Dev) | ~113 ms |
|
||||||
|
| Modul-Init (Emscripten instanziieren) | ~450 ms (Browser) / ~680 ms (Node) |
|
||||||
|
|
||||||
|
Beweis-Artefakte: `hlr-elevation-proof.svg` / `.png` in diesem Ordner — sichtbare
|
||||||
|
Kanten durchgezogen, verdeckte gestrichelt. Die Zeichnung liest sich als korrekte
|
||||||
|
Hidden-Line-Ansicht (Silhouette + sichtbare Front-Kanten voll, verdeckte hinten
|
||||||
|
gestrichelt).
|
||||||
|
|
||||||
|
## Der genutzte API-Pfad (wichtig!)
|
||||||
|
|
||||||
|
Der geplante klassische Pfad (`HLRBRep_Algo`/`HLRBRep_PolyAlgo` +
|
||||||
|
`HLRBRep_HLRToShape`/`HLRBRep_PolyHLRToShape`) ist im **prebuilt Vollbuild
|
||||||
|
1.1.1 NICHT verfügbar**: diese Klassen sind nur als `Handle_…`-Smart-Pointer
|
||||||
|
gebunden, ohne konstruierbare Roh-Klasse und ohne den `HLRToShape`-Extraktor.
|
||||||
|
Ein direkter Aufbau darüber ist damit nicht möglich.
|
||||||
|
|
||||||
|
**Verwendeter, funktionierender Pfad:** `HLRAppli_ReflectLines` — der High-Level-
|
||||||
|
OCCT-Wrapper, der intern GENAU den exakten HLR-Algorithmus (`HLRBRep_Algo`)
|
||||||
|
fährt. Er ist als Klasse gebunden und liefert getrennt sichtbare/verdeckte
|
||||||
|
Kanten nach Kanten-Typ:
|
||||||
|
|
||||||
|
```
|
||||||
|
const rl = new oc.HLRAppli_ReflectLines(shape);
|
||||||
|
rl.SetAxes(dirX, dirY, dirZ, atX, atY, atZ, upX, upY, upZ); // Ortho-Projektion
|
||||||
|
rl.Perform();
|
||||||
|
const T = oc.HLRBRep_TypeOfResultingEdge;
|
||||||
|
// (typ, visible, in3d) → in3d=false liefert 2D-projizierte Kanten (Z≈0)
|
||||||
|
const visSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false);
|
||||||
|
const visOutline = rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false);
|
||||||
|
const hidSharp = rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanten-Extraktion: `TopExp_Explorer_2(compound, TopAbs_EDGE, TopAbs_SHAPE)` →
|
||||||
|
`TopoDS.Edge_1(current)` → `new BRepAdaptor_Curve_2(edge)` →
|
||||||
|
`FirstParameter/LastParameter/Value(u)` (`gp_Pnt` mit `.X() .Y() .Z()`).
|
||||||
|
|
||||||
|
**embind-Überladungen sind versioniert** (`_1`, `_2`, …) und die Nummerierung im
|
||||||
|
Vollbuild folgt NICHT der Argumentzahl. Empirisch verifiziert:
|
||||||
|
`BRepPrimAPI_MakeBox_1(dx,dy,dz)`, `BRepPrimAPI_MakeBox_3(gp_Pnt, gp_Pnt)`,
|
||||||
|
`BRepAlgoAPI_Fuse_3(a, b)`, `gp_Pnt_3(x,y,z)`. Bei einem Versionswechsel des
|
||||||
|
Pakets müssen diese Suffixe neu geprüft werden (Fehlermeldung nennt die
|
||||||
|
erwartete Parameterzahl).
|
||||||
|
|
||||||
|
## Vite-/package.json-Änderungen (genau)
|
||||||
|
|
||||||
|
- `package.json`: Dependency `opencascade.js@^1.1.1` (Vollbuild).
|
||||||
|
- `vite.config.ts` (additiv, analog zum bestehenden LibreDWG-Muster):
|
||||||
|
- Zwei Aliase → virtuelle Module auf die echten Paket-Dateien:
|
||||||
|
`virtual:occt-glue` → `dist/opencascade.wasm.js`,
|
||||||
|
`virtual:occt-wasm-url` → `dist/opencascade.wasm.wasm?url`.
|
||||||
|
- `optimizeDeps.exclude: ["opencascade.js"]` (esbuild kommt mit dem
|
||||||
|
UMD-Wrapper + Node-Shims der Glue nicht klar → Vor-Bündeln ausschließen).
|
||||||
|
- `src/section/occt-wasm.d.ts`: Ambient-Stubs für die zwei virtuellen Module.
|
||||||
|
- Geladen wird per **dynamischem Import** in `src/section/occt.ts` → die 62-MB-
|
||||||
|
WASM landet NICHT im Haupt-Bundle, sondern als separater Lazy-Chunk + Asset.
|
||||||
|
|
||||||
|
Verifiziert: `vite build` der App bleibt grün und unverändert groß (kein
|
||||||
|
OCCT-Chunk, da der Spike nicht verdrahtet ist). Ein Lib-Build, der `hlr.ts`
|
||||||
|
referenziert, splittet OCCT sauber in einen eigenen Glue-Chunk + WASM-Asset ab.
|
||||||
|
`tsc` ist für die neuen Dateien grün. (`npm run build` schlägt aktuell in
|
||||||
|
`tsc -b` fehl — ausschließlich wegen fehlender i18n-Keys in den parallel
|
||||||
|
bearbeiteten Dateien `commands/cmds/stair.ts` / `state/*`, NICHT wegen dieses
|
||||||
|
Spikes.)
|
||||||
|
|
||||||
|
## Dateien dieses Spikes
|
||||||
|
|
||||||
|
- `src/section/hlr.ts` — Kernmodul: Box-Specs → Fuse → HLR → 2D-Polylinien
|
||||||
|
(`hlrFromBoxes`, `hlrShape`, `VIEWS`, `hlrToSvg`).
|
||||||
|
- `src/section/occt.ts` — Lazy-Loader + schmale Typ-Fassade + Lade-Metriken.
|
||||||
|
- `src/section/occt-wasm.d.ts` — Ambient-Deklarationen der virtuellen Module.
|
||||||
|
- `docs/welle-c-hlr-spike/` — dieser Report + Proof-SVG/-PNG.
|
||||||
|
|
||||||
|
## Bewertung für Welle C
|
||||||
|
|
||||||
|
**Viabel.** Der exakte HLR liefert saubere, normgerechte Vektor-Kanten mit
|
||||||
|
korrekter Sichtbarkeit — genau das, was Schnitt/Ansicht brauchen und was reines
|
||||||
|
three.js-Kanten-Projizieren nicht robust liefert (dort fehlt echte
|
||||||
|
Flächen-Verdeckung). Die 2D-Polylinien passen direkt in die bestehende
|
||||||
|
SVG-Plan-Pipeline (`Pt2`/`Vec2`-kompatibel).
|
||||||
|
|
||||||
|
**Der Kostenpunkt ist die WASM-Größe (62.8 MB / ~19.6 MB gzip).** Für einen
|
||||||
|
Spike akzeptabel; für Produktion zu groß, um sie eager zu laden.
|
||||||
|
|
||||||
|
### Empfohlener Integrations- + Größenreduktions-Plan
|
||||||
|
|
||||||
|
1. **Lazy laden** (bereits so gebaut): OCCT erst bei erster Schnitt-/Ansichts-
|
||||||
|
Erzeugung dynamisch importieren; Ladezustand in der UI anzeigen. Der
|
||||||
|
Grundriss läuft weiter ohne OCCT (parametrisch, wie bisher).
|
||||||
|
2. **Web-Worker**: HLR im Worker fahren, damit der UI-Thread frei bleibt (die
|
||||||
|
WASM ist groß, aber der HLR-Lauf selbst ist mit ~20 ms günstig).
|
||||||
|
3. **Größe reduzieren — lohnt sich klar**: ein **Custom-Build** von
|
||||||
|
opencascade.js (das Paket unterstützt `make.py`/Docker-Build mit einer
|
||||||
|
Symbol-Whitelist). Für Welle C reicht ein minimaler Satz:
|
||||||
|
`BRepPrimAPI_*` (bzw. der spätere Solid-Erzeuger), `BRepAlgoAPI_Fuse/Common`,
|
||||||
|
`HLRAppli_ReflectLines` + `HLRBRep_TypeOfResultingEdge`, `TopExp_Explorer`,
|
||||||
|
`TopoDS`, `BRepAdaptor_Curve`, `gp_*`. Erfahrungswerte solcher Minimal-Builds
|
||||||
|
liegen bei **~5–15 MB WASM** statt 62 MB — Aufwand: ein reproduzierbarer
|
||||||
|
Docker-Build in CI, der die `.wasm`/`.js` als Projekt-Asset eincheckt.
|
||||||
|
Alternativ die **beta 2.0** prüfen (moderneres Build-System, evtl. bereits
|
||||||
|
schlankere Module + die klassischen HLR-Klassen konstruierbar).
|
||||||
|
4. **Fallback, falls die Größe untragbar bleibt**: three.js-basierte
|
||||||
|
Kanten-Extraktion (`EdgesGeometry`) + eigene Sichtbarkeit via Depth-Peeling /
|
||||||
|
GPU-Occlusion. Deutlich mehr Eigenaufwand, weniger robust bei Verschneidungen
|
||||||
|
— daher nur zweite Wahl; OCCT-HLR bleibt der empfohlene Weg.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# M2 — nativer wgpu-2D-Renderer im Tauri-Fenster: Ansatzwahl
|
||||||
|
|
||||||
|
> Ziel: den bereits fluessig laufenden standalone `render2d`-Renderer aus dem
|
||||||
|
> ECHTEN Tauri-Prozess heraus mit nativer GPU-Performance anzeigen — nicht in der
|
||||||
|
> WebKitGTK-Webview (Perf-Flaschenhals), nicht in einem Chromium-Workaround.
|
||||||
|
> Maschine: Linux, Wayland, AMD.
|
||||||
|
|
||||||
|
## Gewaehlter Ansatz: **B — separates natives Fenster (winit) im Tauri-Prozess**
|
||||||
|
|
||||||
|
Der Tauri-Hauptthread haelt weiterhin die GTK-Hauptschleife + das Webview-Fenster
|
||||||
|
(HTML-Chrome). Aus dem Tauri-`setup`-Hook startet der Prozess auf einem
|
||||||
|
Hintergrund-Thread ein **eigenes natives winit-Fenster** mit eigener wgpu-Surface,
|
||||||
|
das die `render2d`-Demo-Szene (konkaves L + Raum + Papier-mm-Linien) rendert,
|
||||||
|
pan-/zoombar. Renderer/Szene werden NICHT reimplementiert — es ist exakt der Code
|
||||||
|
des verifizierten standalone `spike`-Bins (`render2d::gpu::Renderer` +
|
||||||
|
`render2d::demo_scene`).
|
||||||
|
|
||||||
|
### Warum B (und was gegen A/C spricht)
|
||||||
|
|
||||||
|
- **Kein geteilter Wayland-Surface → keine Contention/Flicker.** winit spricht auf
|
||||||
|
Linux DIREKT Wayland/X11 (Crates `wayland-client`/`x11rb`), es benutzt **kein
|
||||||
|
GTK**. Das native Fenster bekommt eine eigene, von WebKitGTK voellig getrennte
|
||||||
|
Wayland-Surface. Genau das umgeht das dokumentierte Problem aus der Vorrecherche
|
||||||
|
(Roh-wgpu-Surface UNTER der Webview im selben Fenster → Flicker/Schwarzbild auf
|
||||||
|
Wayland).
|
||||||
|
- **Zwei Fenster-Stacks, aber KEIN Event-Loop-Konflikt.** tao (Tauri) fuehrt eine
|
||||||
|
GTK-Hauptschleife auf dem Hauptthread; winit fuehrt eine eigene Schleife.
|
||||||
|
winit 0.30 erlaubt die Event-Loop explizit auf einem Nicht-Haupt-Thread via
|
||||||
|
`EventLoopBuilderExtWayland::with_any_thread(true)` (X11-Pendant analog). Der
|
||||||
|
native Renderer laeuft daher auf `std::thread` „cad-native2d", der Tauri-/GTK-
|
||||||
|
Hauptthread bleibt frei. Da winit nicht an GTK gebunden ist, kollidieren die
|
||||||
|
beiden Schleifen nicht (getrennte Stacks, getrennte fds).
|
||||||
|
|
||||||
|
- **Ansatz A (wgpu-Surface im SELBEN Tauri-Fenster, Unter-/Overlay):** verworfen.
|
||||||
|
taos `raw_window_handle_rwh_06()` liefert auf Wayland den `WaylandWindowHandle`
|
||||||
|
der GTK-`ApplicationWindow` — also DIESELBE Surface, in die WebKitGTK
|
||||||
|
komponiert. Eine zweite wgpu-Surface darauf ist genau die Contention, die die
|
||||||
|
Vorrecherche als flackernd/schwarz auf diesem Setup markiert hat. Hoechstes
|
||||||
|
Wayland-Risiko, fuer den Spike ungeeignet.
|
||||||
|
- **Ansatz C (wgpu als Haupt-Surface, HTML nur duennes Overlay):** verworfen fuer
|
||||||
|
den Spike. Sauberste Perf-Story, aber groesster Umbau: die ganze App muesste auf
|
||||||
|
eine winit/tao-getriebene Haupt-Schleife umziehen und die Tauri-Webview zur
|
||||||
|
Nebenrolle degradiert werden. Kein „minimaler realer Spike" mehr. Bleibt die
|
||||||
|
Ziel-Endarchitektur-Option, aber nach B.
|
||||||
|
|
||||||
|
## Crates / Versionen (verifiziert aus der Lock-Datei)
|
||||||
|
|
||||||
|
| Rolle | Crate | Version |
|
||||||
|
|---|---|---|
|
||||||
|
| Tauri | `tauri` / `tauri-runtime-wry` | 2.11.5 / 2.11.4 |
|
||||||
|
| Fenster (Webview-Seite) | `tao` | 0.35.3 |
|
||||||
|
| Webview | `wry` | 0.55.1 |
|
||||||
|
| Natives Renderfenster | `winit` | 0.30.13 |
|
||||||
|
| GPU | `wgpu` / `wgpu-hal` | 22.1.0 / 22.0.0 |
|
||||||
|
| Handle-Bruecke | `raw-window-handle` | **0.6.2 (identisch fuer tao UND wgpu/winit)** |
|
||||||
|
| Renderer | `render2d` (Pfad) | 0.1.0, Feature `window` |
|
||||||
|
|
||||||
|
Der entscheidende Kompatibilitaets-Glueckstreffer: tao 0.35 und wgpu 22/winit 0.30
|
||||||
|
teilen sich `raw-window-handle 0.6.2` — keine rwh-Versionsbruecke noetig. (Fuer
|
||||||
|
Ansatz B wird der tao-Handle gar nicht gebraucht, weil das native Fenster eine
|
||||||
|
eigene winit-Surface hat; die Versions-Parität ist aber die Voraussetzung fuer ein
|
||||||
|
spaeteres A/Compositing, falls je gewuenscht.)
|
||||||
|
|
||||||
|
## Wayland-Caveats (fuer den Start auf dieser Maschine)
|
||||||
|
|
||||||
|
- **`WEBKIT_DISABLE_DMABUF_RENDERER=1`** bleibt fuer die Webview-Sichtbarkeit auf
|
||||||
|
diesem Wayland/AMD-Setup noetig (Vorrecherche). Betrifft die WebKitGTK-Seite,
|
||||||
|
nicht das wgpu-Fenster.
|
||||||
|
- Das native winit-Fenster laeuft ueber Vulkan; GLES/EGL-Fallback wurde standalone
|
||||||
|
ebenfalls funktionierend gesehen. `WGPU_BACKEND=gl` erzwingt bei Vulkan-Zicken
|
||||||
|
den GL-Pfad.
|
||||||
|
- `with_any_thread(true)` ist zwingend — ohne die Freigabe panict winit, weil es
|
||||||
|
die Event-Loop sonst nur auf dem Hauptthread zulaesst (den haelt hier GTK).
|
||||||
|
|
||||||
|
## Bau-Isolierung
|
||||||
|
|
||||||
|
Alles hinter Cargo-Feature **`native2d`** in `cad-tauri` (opt-in). Der normale
|
||||||
|
Build (`cargo build`, `npm run tauri:dev`) zieht weder winit noch wgpu und bleibt
|
||||||
|
unveraendert. `render2d` bleibt ohne Tauri-/GTK-Abhaengigkeiten headless baubar
|
||||||
|
und testbar.
|
||||||
|
|
||||||
|
## Naechster Schritt Richtung Vollbild-App (2D-Viewport + HTML-Chrome)
|
||||||
|
|
||||||
|
1. Szene aus dem echten Modell speisen: `Plan.primitives` (TS) → serde-`Scene` →
|
||||||
|
ueber einen Tauri-`emit`/Command an den native2d-Thread (statt `demo_scene`).
|
||||||
|
2. Pan/Zoom-State zwischen Webview-Chrome und native2d-Fenster synchronisieren
|
||||||
|
(Tauri-Events beidseitig).
|
||||||
|
3. Fenster-Kopplung: das native Fenster als Kind/als angedockten Bereich neben der
|
||||||
|
Webview positionieren (Layout), spaeter ggf. Umstieg auf Ansatz C, wenn ein
|
||||||
|
einziges Fenster gefordert ist.
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "dossier",
|
"name": "cad",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dossier",
|
"name": "cad",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-http": "^2.5.9",
|
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"opencascade.js": "^1.1.1",
|
||||||
"polygon-clipping": "^0.15.7",
|
"polygon-clipping": "^0.15.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -875,271 +870,6 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -2132,42 +1862,6 @@
|
|||||||
"@tauri-apps/api": "^2.11.0"
|
"@tauri-apps/api": "^2.11.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-http": {
|
|
||||||
"version": "2.5.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
|
|
||||||
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-process": {
|
|
||||||
"version": "2.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
|
|
||||||
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-shell": {
|
|
||||||
"version": "2.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
|
||||||
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.10.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tauri-apps/plugin-updater": {
|
|
||||||
"version": "2.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
|
||||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
|
||||||
"license": "MIT OR Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.10.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tweenjs/tween.js": {
|
"node_modules/@tweenjs/tween.js": {
|
||||||
"version": "23.1.3",
|
"version": "23.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
@@ -3520,6 +3214,12 @@
|
|||||||
"node": ">=12.20.0"
|
"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": {
|
"node_modules/pako": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||||
@@ -3543,18 +3243,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/performance-now": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"electron": "scripts/electron-shell.sh",
|
"electron": "scripts/electron-shell.sh",
|
||||||
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
|
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --no-default-features --features web",
|
||||||
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
||||||
|
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
|
||||||
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
||||||
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
|
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
|
||||||
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
||||||
@@ -28,15 +29,11 @@
|
|||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-http": "^2.5.9",
|
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"opencascade.js": "^1.1.1",
|
||||||
"polygon-clipping": "^0.15.7",
|
"polygon-clipping": "^0.15.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["."]
|
members = ["."]
|
||||||
# render2d/render3d sind eigenstaendige Crates, damit sie headless ohne
|
# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne
|
||||||
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
|
# 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 —
|
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
|
||||||
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
|
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
|
||||||
# nutzen kann. kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von
|
# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut
|
||||||
# kernel2d.ts), per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des
|
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
|
||||||
# cad-tauri-Workspace.
|
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
|
||||||
exclude = ["render2d", "render3d", "kernel2d", "dwgimport", "trucksolid"]
|
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
|
||||||
|
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "cad-tauri"
|
name = "cad-tauri"
|
||||||
@@ -38,12 +39,9 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-process = "2"
|
|
||||||
tauri-plugin-http = "2"
|
|
||||||
tauri-plugin-shell = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
geometry = { path = "geometry" }
|
||||||
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
|
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
|
||||||
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
|
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
|
||||||
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
|
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"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). allow-set-size/allow-center: kompaktes Splash-/Startbildschirm-Fenster vor dem Editor (App.tsx bootPhase).",
|
"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.",
|
||||||
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
|
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -13,21 +13,10 @@
|
|||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-create",
|
"core:window:allow-create",
|
||||||
"core:window:allow-set-size",
|
|
||||||
"core:window:allow-center",
|
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
"dialog:allow-open",
|
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"fs:allow-read-text-file",
|
"fs:allow-write-text-file"
|
||||||
"fs:allow-write-text-file",
|
|
||||||
"updater:default",
|
|
||||||
"process:allow-restart",
|
|
||||||
{
|
|
||||||
"identifier": "http:default",
|
|
||||||
"allow": [{ "url": "https://git.openbureau.ch/*" }]
|
|
||||||
},
|
|
||||||
"shell:allow-open"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[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"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 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}");
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 879 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1711,23 +1711,9 @@ impl Renderer {
|
|||||||
multiview_mask: None,
|
multiview_mask: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Live-Schnitt aktiv? Dann erzwingen wir die Modell-Kanten ZUSAETZLICH
|
|
||||||
// zum aktuellen Stil (unabhaengig von `draws_edges()`) — Nutzer-Report:
|
|
||||||
// duenne, schraeg angeschnittene Bauteile (Zwischenwaende, Giebel-Enden,
|
|
||||||
// einzelne Material-Schichten) haben aus vielen Blickwinkeln eine fast
|
|
||||||
// unsichtbare (kantige) Stirnflaeche, waehrend ihre (flache) Schnitt-
|
|
||||||
// kappe voll sichtbar bleibt — das wirkt wie ein abgeloestes,
|
|
||||||
// "schwebendes" Element. Die dunklen Kanten (bereits vorhanden fuer
|
|
||||||
// wireframe/hidden/shaded-edges, `edges::build_mesh_edges`, tiefen-
|
|
||||||
// getestet + von der Schnittebene gekappt wie das Mesh selbst) zeichnen
|
|
||||||
// jede Bauteilkante nach, auch wenn ihre Flaeche praktisch auf Null
|
|
||||||
// Bildschirmbreite zusammenfaellt — das verbindet Kappe und Bauteil
|
|
||||||
// sichtbar, ohne die Kamera zu bewegen oder Geometrie zu aendern.
|
|
||||||
let force_edges_for_section = self.globals.mode[1] > 0.5;
|
|
||||||
// 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im
|
// 1) Gefuellte Flaechen — in allen Stilen ausser "wireframe". Im
|
||||||
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
||||||
// die folgenden Kanten sauber obenauf liegen. Bei aktivem Schnitt
|
// die folgenden Kanten sauber obenauf liegen.
|
||||||
// ebenso (die Kanten werden dann erzwungen, s.o.).
|
|
||||||
if self.style.draws_faces() {
|
if self.style.draws_faces() {
|
||||||
if self.style == RenderStyle::Textured {
|
if self.style == RenderStyle::Textured {
|
||||||
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
|
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
|
||||||
@@ -1744,9 +1730,7 @@ impl Renderer {
|
|||||||
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||||
}
|
}
|
||||||
} else if let Some(m) = &self.mesh {
|
} else if let Some(m) = &self.mesh {
|
||||||
let face_pipeline = if self.style.needs_biased_face_pipeline()
|
let face_pipeline = if self.style.needs_biased_face_pipeline() {
|
||||||
|| force_edges_for_section
|
|
||||||
{
|
|
||||||
&self.hidden_face_pipeline
|
&self.hidden_face_pipeline
|
||||||
} else {
|
} else {
|
||||||
&self.pipeline
|
&self.pipeline
|
||||||
@@ -1814,11 +1798,11 @@ impl Renderer {
|
|||||||
pass.draw(0..cl.vertex_count, 0..1);
|
pass.draw(0..cl.vertex_count, 0..1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Modell-Kanten (wireframe/hidden, ODER erzwungen bei aktivem
|
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
|
||||||
// Schnitt, s.o.) — LineList-Pipeline (wie Grid). Tiefengetestet
|
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
|
||||||
// gegen die (gebiasten) Flaechen -> verdeckte Kanten fallen weg.
|
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
|
||||||
// Im Wireframe ohne Flaechen -> alle Kanten sichtbar.
|
// Flaechen -> alle Kanten sichtbar.
|
||||||
if self.style.draws_edges() || force_edges_for_section {
|
if self.style.draws_edges() {
|
||||||
if let Some(e) = &self.edges {
|
if let Some(e) = &self.edges {
|
||||||
pass.set_pipeline(&self.grid_pipeline);
|
pass.set_pipeline(&self.grid_pipeline);
|
||||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// Tauri-v2-Einstieg: die Befehls-Bruecke und der App-Start.
|
// Tauri-v2-Einstieg. Die eigentliche Geometrie liegt im serde-only Crate
|
||||||
|
// `geometry`; hier nur die Befehls-Bruecke und der App-Start.
|
||||||
|
|
||||||
// M2: native wgpu-Viewports (2D und/oder 3D) im Tauri-Prozess. EIN Modul, EINE
|
// 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).
|
// winit-Event-Loop fuer beide Fenster (winit erlaubt nur eine Loop pro Prozess).
|
||||||
@@ -31,6 +32,14 @@ fn release_project_lock(path: String, state: State<lock::LockState>) {
|
|||||||
lock::release(&state, &PathBuf::from(path));
|
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)
|
// Live-Spiegelung: die Webview schiebt bei jeder Modellaenderung (debounced)
|
||||||
// die aktuelle 2D-Szene bzw. die 3D-Waende hierher; native.rs stellt sie ueber
|
// 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
|
// einen EventLoopProxy in die winit-Loop zu. Die Payload kommt als rohes JSON
|
||||||
@@ -63,18 +72,6 @@ pub fn run() {
|
|||||||
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
|
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
// Selbst-Update: prueft beim Start gegen `endpoints` aus tauri.conf.json
|
|
||||||
// (Gitea-Release-Asset `latest.json`), Installation via JS-Seite
|
|
||||||
// (@tauri-apps/plugin-updater). `tauri_plugin_process` liefert `relaunch()`
|
|
||||||
// fuer den Neustart nach der Installation.
|
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.plugin(tauri_plugin_process::init())
|
|
||||||
// HTTP-Requests aus der Webview (Versionsverlauf, versions.json vom
|
|
||||||
// selben Gitea-Release) ohne Browser-CORS-Einschraenkung; Shell: oeffnet
|
|
||||||
// den DMG-Download einer aelteren Version im System-Browser (Rollback,
|
|
||||||
// s. src/update/checkForUpdate.ts).
|
|
||||||
.plugin(tauri_plugin_http::init())
|
|
||||||
.plugin(tauri_plugin_shell::init())
|
|
||||||
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
|
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
|
||||||
.manage(lock::LockState::default())
|
.manage(lock::LockState::default())
|
||||||
.setup(|_app| {
|
.setup(|_app| {
|
||||||
@@ -87,6 +84,7 @@ pub fn run() {
|
|||||||
|
|
||||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||||
|
compute_joins,
|
||||||
push_native_scene,
|
push_native_scene,
|
||||||
push_native_walls,
|
push_native_walls,
|
||||||
acquire_project_lock,
|
acquire_project_lock,
|
||||||
@@ -94,6 +92,7 @@ pub fn run() {
|
|||||||
]);
|
]);
|
||||||
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
|
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
|
||||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||||
|
compute_joins,
|
||||||
acquire_project_lock,
|
acquire_project_lock,
|
||||||
release_project_lock
|
release_project_lock
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "dossier",
|
"productName": "Dossier",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "ch.dossier.cad",
|
"identifier": "ch.dossier.cad",
|
||||||
"build": {
|
"build": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "dossier",
|
"title": "Dossier",
|
||||||
"width": 1400,
|
"width": 1400,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false,
|
||||||
@@ -27,21 +27,6 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"createUpdaterArtifacts": true,
|
"icon": ["icons/icon.png"]
|
||||||
"icon": [
|
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"updater": {
|
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYyRDk3NkZGODQwRUJBNTQKUldSVXVnNkUvM2JaOHV2QWp0L05qZnpEUmt2TkN3ZWJBeFQwb1I0cExWNEpuZWU0UjZiazdQNU0K",
|
|
||||||
"endpoints": [
|
|
||||||
"https://git.openbureau.ch/karim/DOSSIER-STANDALONE/releases/download/latest/latest.json"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* `chamferCommand` — die Kernel-Geometrie (`chamferCorner`) ist neu (existierte
|
|
||||||
* bisher gar nicht, anders als filletCorner). Test: eine rechtwinklige
|
|
||||||
* Polylinien-Ecke wird mit einer Distanz gefast (gerader Schnitt statt Bogen).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { chamferCommand } from "./chamfer";
|
|
||||||
import type { CommandContext, Project } from "../types";
|
|
||||||
|
|
||||||
function project(): Project {
|
|
||||||
return {
|
|
||||||
id: "t",
|
|
||||||
name: "T",
|
|
||||||
lineStyles: [],
|
|
||||||
hatches: [],
|
|
||||||
components: [],
|
|
||||||
wallTypes: [],
|
|
||||||
drawingLevels: [
|
|
||||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
|
||||||
],
|
|
||||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
|
||||||
walls: [],
|
|
||||||
doors: [],
|
|
||||||
openings: [],
|
|
||||||
ceilings: [],
|
|
||||||
stairs: [],
|
|
||||||
rooms: [],
|
|
||||||
drawings2d: [
|
|
||||||
// L-förmige offene Polylinie: (0,0) → (2,0) → (2,2). Ecke bei (2,0).
|
|
||||||
{
|
|
||||||
id: "poly",
|
|
||||||
type: "drawing2d",
|
|
||||||
levelId: "eg",
|
|
||||||
categoryCode: "20",
|
|
||||||
geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }], closed: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
context: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(p: Project): CommandContext {
|
|
||||||
return {
|
|
||||||
project: p,
|
|
||||||
level: p.drawingLevels[0],
|
|
||||||
defaultCategoryCode: "20",
|
|
||||||
activeLineStyleId: "solid",
|
|
||||||
activeWallTypeId: "aw",
|
|
||||||
lastPoint: null,
|
|
||||||
selection: { wallIds: [], drawingId: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("chamferCommand", () => {
|
|
||||||
it("fast die Ecke mit getippter Distanz (Zahl-Eingabe)", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
|
|
||||||
// Schritt 1: Ecke wählen (Klick nahe (2,0)).
|
|
||||||
const [afterPick, r1] = chamferCommand.onInput(
|
|
||||||
chamferCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 2, y: 0 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(r1.done).toBeFalsy();
|
|
||||||
|
|
||||||
// Schritt 2: Distanz getippt (0.5m) → commit.
|
|
||||||
const [, r2] = chamferCommand.onInput(afterPick, { kind: "number", value: 0.5 }, ctx);
|
|
||||||
expect(r2.done).toBe(true);
|
|
||||||
expect(r2.commit).toBeDefined();
|
|
||||||
|
|
||||||
const next = r2.commit!(p);
|
|
||||||
const poly = next.drawings2d.find((d) => d.id === "poly")!;
|
|
||||||
expect(poly.geom.shape).toBe("polyline");
|
|
||||||
if (poly.geom.shape !== "polyline") return;
|
|
||||||
const pts = poly.geom.pts;
|
|
||||||
// Die scharfe Ecke (2,0) darf nicht mehr vorkommen — durch die Fasung ersetzt.
|
|
||||||
expect(pts.some((p) => p.x === 2 && p.y === 0)).toBe(false);
|
|
||||||
// Anfangs-/Endpunkt der ganzen Kette bleiben unverändert.
|
|
||||||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
|
||||||
expect(pts[pts.length - 1]).toEqual({ x: 2, y: 2 });
|
|
||||||
// Exakt zwei neue Punkte (gerade Fasung, keine Tessellierung): 0.5m vom
|
|
||||||
// Eck entlang jedes Schenkels.
|
|
||||||
expect(pts.length).toBe(4);
|
|
||||||
const close = (a: { x: number; y: number }, x: number, y: number) =>
|
|
||||||
Math.hypot(a.x - x, a.y - y) < 1e-9;
|
|
||||||
expect(close(pts[1], 1.5, 0)).toBe(true);
|
|
||||||
expect(close(pts[2], 2, 0.5)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("daneben geklickt → Befehl bleibt im pick-Zustand (kein Treffer)", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [state, result] = chamferCommand.onInput(
|
|
||||||
chamferCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 50, y: 50 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.commit).toBeUndefined();
|
|
||||||
expect(result.done).toBeFalsy();
|
|
||||||
expect((state as { phase: string }).phase).toBe("pick");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Distanz zu gross für die Schenkellänge → No-op (kein commit-Effekt)", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [afterPick] = chamferCommand.onInput(
|
|
||||||
chamferCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 2, y: 0 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
// Schenkel sind 2m lang; 10m Distanz passt auf keinen der beiden.
|
|
||||||
const [, r2] = chamferCommand.onInput(afterPick, { kind: "number", value: 10 }, ctx);
|
|
||||||
expect(r2.commit).toBeDefined();
|
|
||||||
const next = r2.commit!(p);
|
|
||||||
expect(next).toBe(p); // unverändert
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
// Chamfer — fast die Ecke einer 2D-Kurve (polyline/rect) mit einer geraden
|
|
||||||
// Abschrägung (Rhino/AutoCAD-artig, Gegenstück zu Fillet ohne Bogen).
|
|
||||||
// Gleicher Aufbau wie fillet.ts (siehe dort für die ausführlichere
|
|
||||||
// Begründung), nur mit `chamferCorner` statt `filletCorner`:
|
|
||||||
// 1) „Ecke wählen:" → Punkt (nächstgelegener Eckpunkt einer polyline/rect)
|
|
||||||
// 2) „Distanz:" → Punkt (Abstand von der Ecke) ODER getippte Zahl → commit
|
|
||||||
//
|
|
||||||
// Anders als Fillet braucht Chamfer KEINE Bogen-Tessellierung — die Fasung
|
|
||||||
// ist von Natur aus gerade, also ein exaktes (nicht approximiertes) Ergebnis.
|
|
||||||
|
|
||||||
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
|
|
||||||
import { chamferCorner, type Chamfer } from "../../geometry/kernel2d";
|
|
||||||
import type {
|
|
||||||
Command,
|
|
||||||
CommandContext,
|
|
||||||
CommandField,
|
|
||||||
CommandResult,
|
|
||||||
CommandState,
|
|
||||||
DraftShape,
|
|
||||||
Project,
|
|
||||||
ToolDraft,
|
|
||||||
Vec2,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const HIT_TOL = 0.3; // Modell-Meter: Pick-Toleranz für die Eckenwahl
|
|
||||||
const DEFAULT_DISTANCE = 0.1; // m — Startwert, bis der Nutzer per Maus/Tab einen eigenen setzt
|
|
||||||
const EPS = 1e-6;
|
|
||||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
|
||||||
|
|
||||||
interface Curve {
|
|
||||||
pts: Vec2[];
|
|
||||||
closed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wandelt eine chamfer-fähige 2D-Form (polyline/rect) in eine Punktliste oder null. */
|
|
||||||
function geomToCurve(g: Drawing2DGeom): Curve | null {
|
|
||||||
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
|
|
||||||
if (g.shape === "rect") {
|
|
||||||
return {
|
|
||||||
pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }],
|
|
||||||
closed: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null; // line (keine eigene Ecke), circle/arc/text: nicht chamfer-fähig
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Treffer: das Element + der Eckpunkt-Index (mit gültigen beiden Nachbarn). */
|
|
||||||
interface CornerHit {
|
|
||||||
drawing: Drawing2D;
|
|
||||||
curve: Curve;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nächster Eckpunkt (mit zwei Nachbarn) einer chamfer-fähigen Drawing2D zum Klickpunkt. */
|
|
||||||
function pickCornerAt(project: Project, levelId: string, pt: Vec2): CornerHit | null {
|
|
||||||
let best: CornerHit | null = null;
|
|
||||||
let bestD = HIT_TOL;
|
|
||||||
for (const d of project.drawings2d) {
|
|
||||||
if (d.levelId !== levelId) continue;
|
|
||||||
const curve = geomToCurve(d.geom);
|
|
||||||
if (!curve) continue;
|
|
||||||
const n = curve.pts.length;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
// Offene Polylinie: die beiden Endpunkte haben keinen zweiten Nachbarn.
|
|
||||||
if (!curve.closed && (i === 0 || i === n - 1)) continue;
|
|
||||||
const dd = dist(pt, curve.pts[i]);
|
|
||||||
if (dd < bestD) {
|
|
||||||
bestD = dd;
|
|
||||||
best = { drawing: d, curve, index: i };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fasungs-Geometrie für einen Treffer + Distanz (oder null, falls unmöglich). */
|
|
||||||
function chamferFor(hit: CornerHit, distance: number): Chamfer | null {
|
|
||||||
const n = hit.curve.pts.length;
|
|
||||||
const prev = hit.curve.pts[(hit.index - 1 + n) % n];
|
|
||||||
const next = hit.curve.pts[(hit.index + 1) % n];
|
|
||||||
const corner = hit.curve.pts[hit.index];
|
|
||||||
return chamferCorner(corner, prev, next, distance);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ersetzt den Eckpunkt durch die beiden Fasungs-Punkte (Schenkel↔Schenkel). */
|
|
||||||
function applyChamferPoints(hit: CornerHit, c: Chamfer): Vec2[] {
|
|
||||||
return [
|
|
||||||
...hit.curve.pts.slice(0, hit.index),
|
|
||||||
c.a,
|
|
||||||
c.b,
|
|
||||||
...hit.curve.pts.slice(hit.index + 1),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function chamferDraft(hit: CornerHit, d: number, at: Vec2 | null): ToolDraft {
|
|
||||||
const c = d > EPS ? chamferFor(hit, d) : null;
|
|
||||||
const preview: DraftShape[] = c ? [{ kind: "poly", pts: [c.a, c.b], closed: false }] : [];
|
|
||||||
const draft: ToolDraft = { preview, vertices: [hit.curve.pts[hit.index]] };
|
|
||||||
if (at) draft.hud = { at, text: `D: ${d.toFixed(3)}m${c ? "" : " ✗"}` };
|
|
||||||
return draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitChamfer(p: Project, hit: CornerHit, distance: number): Project {
|
|
||||||
const c = chamferFor(hit, distance);
|
|
||||||
if (!c) return p; // Distanz zu gross für die Schenkellänge, oder Ecke (fast) gerade → No-op
|
|
||||||
const pts = applyChamferPoints(hit, c);
|
|
||||||
const geom: Drawing2DGeom = { shape: "polyline", pts, closed: hit.curve.closed };
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
drawings2d: p.drawings2d.map((d) => (d.id === hit.drawing.id ? { ...d, geom } : d)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
|
||||||
const DISTANCE_FIELDS: CommandField[] = [{ id: "distance", labelKey: "cmd.field.distance" }];
|
|
||||||
|
|
||||||
interface ChamferPick extends CommandState {
|
|
||||||
phase: "pick";
|
|
||||||
}
|
|
||||||
interface ChamferDistance extends CommandState {
|
|
||||||
phase: "distance";
|
|
||||||
hit: CornerHit;
|
|
||||||
distance: number;
|
|
||||||
}
|
|
||||||
interface ChamferDone extends CommandState {
|
|
||||||
phase: "done";
|
|
||||||
}
|
|
||||||
type ChamferState = ChamferPick | ChamferDistance | ChamferDone;
|
|
||||||
|
|
||||||
const finish = (): [CommandState, CommandResult] => [
|
|
||||||
{ phase: "done", lastPoint: null } as ChamferDone,
|
|
||||||
{ draft: null, done: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const chamferCommand: Command = {
|
|
||||||
name: "chamfer",
|
|
||||||
labelKey: "cmd.chamfer.label",
|
|
||||||
prompt: (s) =>
|
|
||||||
(s as ChamferState).phase === "distance" ? "cmd.chamfer.distance" : "cmd.chamfer.pick",
|
|
||||||
accepts: (s) => ((s as ChamferState).phase === "distance" ? ["point", "number"] : ["point"]),
|
|
||||||
options: () => [],
|
|
||||||
fields: (s) => ((s as ChamferState).phase === "distance" ? DISTANCE_FIELDS : []),
|
|
||||||
|
|
||||||
init: (): ChamferPick => ({ phase: "pick", lastPoint: null }),
|
|
||||||
|
|
||||||
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
|
|
||||||
const s = state as ChamferState;
|
|
||||||
if (s.phase === "done") return finish();
|
|
||||||
if (s.phase === "pick") {
|
|
||||||
if (input.kind !== "point") return [s, { draft: null }];
|
|
||||||
const hit = pickCornerAt(ctx.project, ctx.level.id, input.point);
|
|
||||||
if (!hit) return [s, { draft: null }]; // daneben geklickt → aktiv bleiben
|
|
||||||
const next: ChamferDistance = {
|
|
||||||
phase: "distance",
|
|
||||||
hit,
|
|
||||||
distance: DEFAULT_DISTANCE,
|
|
||||||
lastPoint: input.point,
|
|
||||||
};
|
|
||||||
return [next, { draft: chamferDraft(hit, next.distance, input.point) }];
|
|
||||||
}
|
|
||||||
// phase === "distance"
|
|
||||||
let d = s.distance;
|
|
||||||
if (input.kind === "number") d = Math.max(0, input.value);
|
|
||||||
else if (input.kind === "point") d = Math.max(0, dist(s.hit.curve.pts[s.hit.index], input.point));
|
|
||||||
else return [s, { draft: null }];
|
|
||||||
return [
|
|
||||||
{ phase: "done", lastPoint: null } as ChamferDone,
|
|
||||||
{ draft: null, done: true, commit: (p) => commitChamfer(p, s.hit, d) },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
|
||||||
const s = state as ChamferState;
|
|
||||||
if (s.phase === "pick") {
|
|
||||||
const hit = pickCornerAt(ctx.project, ctx.level.id, point);
|
|
||||||
if (!hit) return [s, { draft: null }];
|
|
||||||
return [s, { draft: chamferDraft(hit, DEFAULT_DISTANCE, point) }];
|
|
||||||
}
|
|
||||||
if (s.phase !== "distance") return [s, { draft: null }];
|
|
||||||
const d = Math.max(0, dist(s.hit.curve.pts[s.hit.index], point));
|
|
||||||
const ns: ChamferDistance = { ...s, distance: d };
|
|
||||||
return [ns, { draft: chamferDraft(s.hit, d, point) }];
|
|
||||||
},
|
|
||||||
|
|
||||||
onConfirm: (): [CommandState, CommandResult] => finish(),
|
|
||||||
onCancel: (): [CommandState, CommandResult] => finish(),
|
|
||||||
|
|
||||||
// Tab-Feld-Zyklus nur im „distance"-Schritt, analog zu fillet.ts/circle.ts.
|
|
||||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
|
||||||
const s = state as ChamferState;
|
|
||||||
if (s.phase !== "distance" || !cursor) return {};
|
|
||||||
return { distance: dist(s.hit.curve.pts[s.hit.index], cursor) };
|
|
||||||
},
|
|
||||||
pointFromFields: (state, locks, cursor) => {
|
|
||||||
const s = state as ChamferState;
|
|
||||||
if (s.phase !== "distance") return null;
|
|
||||||
const corner = s.hit.curve.pts[s.hit.index];
|
|
||||||
const d = "distance" in locks ? Math.max(0, locks.distance) : cursor ? dist(corner, cursor) : 0;
|
|
||||||
if (cursor) {
|
|
||||||
const dd = dist(corner, cursor);
|
|
||||||
if (dd > EPS) {
|
|
||||||
return {
|
|
||||||
x: corner.x + ((cursor.x - corner.x) / dd) * d,
|
|
||||||
y: corner.y + ((cursor.y - corner.y) / dd) * d,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { x: corner.x + d, y: corner.y };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* `extendCommand` — Gegenstück zu Trim (`extendSegment` im Kernel existierte
|
|
||||||
* bereits, hatte aber keinen Aufrufer). Test: eine kurze Linie wird bis zu
|
|
||||||
* einer kreuzenden Cutter-Linie verlängert.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { extendCommand } from "./extend";
|
|
||||||
import type { CommandContext, Project } from "../types";
|
|
||||||
|
|
||||||
function project(): Project {
|
|
||||||
return {
|
|
||||||
id: "t",
|
|
||||||
name: "T",
|
|
||||||
lineStyles: [],
|
|
||||||
hatches: [],
|
|
||||||
components: [],
|
|
||||||
wallTypes: [],
|
|
||||||
drawingLevels: [
|
|
||||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
|
||||||
],
|
|
||||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
|
||||||
walls: [],
|
|
||||||
doors: [],
|
|
||||||
openings: [],
|
|
||||||
ceilings: [],
|
|
||||||
stairs: [],
|
|
||||||
rooms: [],
|
|
||||||
drawings2d: [
|
|
||||||
// Cutter: vertikale Linie bei x=5, deckt y=-10..10 ab.
|
|
||||||
{ id: "cutter", type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 5, y: -10 }, b: { x: 5, y: 10 } } },
|
|
||||||
// Ziel: kurze horizontale Linie von (0,0) bis (3,0) — reicht nicht bis zum Cutter.
|
|
||||||
{ id: "target", type: "drawing2d", levelId: "eg", categoryCode: "20", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 3, y: 0 } } },
|
|
||||||
],
|
|
||||||
context: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(p: Project): CommandContext {
|
|
||||||
return {
|
|
||||||
project: p,
|
|
||||||
level: p.drawingLevels[0],
|
|
||||||
defaultCategoryCode: "20",
|
|
||||||
activeLineStyleId: "solid",
|
|
||||||
activeWallTypeId: "aw",
|
|
||||||
lastPoint: null,
|
|
||||||
selection: { wallIds: [], drawingId: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("extendCommand", () => {
|
|
||||||
it("verlängert das nähere Ende der Linie bis zum nächsten Cutter", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [, result] = extendCommand.onInput(
|
|
||||||
extendCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 3, y: 0 } }, // nahe am Ende (3,0) der Ziellinie
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.commit).toBeDefined();
|
|
||||||
const next = result.commit!(p);
|
|
||||||
const target = next.drawings2d.find((d) => d.id === "target")!;
|
|
||||||
expect(target.geom.shape).toBe("line");
|
|
||||||
if (target.geom.shape === "line") {
|
|
||||||
expect(target.geom.a).toEqual({ x: 0, y: 0 }); // unverändertes Ende bleibt stehen
|
|
||||||
expect(target.geom.b.x).toBeCloseTo(5, 9); // bis zum Cutter (x=5) verlängert
|
|
||||||
expect(target.geom.b.y).toBeCloseTo(0, 9);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("kein Cutter getroffen → No-op (kein commit-Effekt)", () => {
|
|
||||||
const p = project();
|
|
||||||
// Cutter entfernen: nichts zum Verlängern-bis da.
|
|
||||||
p.drawings2d = p.drawings2d.filter((d) => d.id !== "cutter");
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [, result] = extendCommand.onInput(
|
|
||||||
extendCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 3, y: 0 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.commit).toBeDefined();
|
|
||||||
const next = result.commit!(p);
|
|
||||||
expect(next).toBe(p); // unverändert (No-op)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
// Extend — verlängert eine OFFENE 2D-Kurve (line/offene polyline) bis zur
|
|
||||||
// nächsten anderen Kurve (Rhino/AutoCAD-artig, Gegenstück zu Trim). Modell:
|
|
||||||
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
|
|
||||||
// (wie bei Trim — kein separater Cutter-Auswahlschritt).
|
|
||||||
// • Ein Schritt: der Nutzer klickt auf die zu verlängernde Kurve, NAHE dem
|
|
||||||
// Ende, das verlängert werden soll (das Ende, das dem Klick am nächsten
|
|
||||||
// liegt, wird verlängert — nicht zwingend das nächste per Cursor-Distanz
|
|
||||||
// zur ganzen Kurve, sondern zum jeweiligen Endpunkt).
|
|
||||||
// • Trifft die Verlängerung keinen Cutter, passiert nichts (kein Fehler,
|
|
||||||
// Befehl bleibt aktiv).
|
|
||||||
// • Wiederholend, bis Esc/Enter.
|
|
||||||
//
|
|
||||||
// Geschlossene Formen (rect, geschlossene polyline) und reine Punktformen
|
|
||||||
// (circle/arc/text) sind nicht extend-fähig — ein geschlossener Ring hat kein
|
|
||||||
// "Ende". Reine Geometrie liegt im Kernel (`extendSegment`); hier nur
|
|
||||||
// Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der Commit.
|
|
||||||
//
|
|
||||||
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
|
|
||||||
|
|
||||||
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
|
|
||||||
import {
|
|
||||||
extendSegment,
|
|
||||||
pointSegmentDistance,
|
|
||||||
polylineEdges,
|
|
||||||
} from "../../geometry/kernel2d";
|
|
||||||
import type {
|
|
||||||
Command,
|
|
||||||
CommandResult,
|
|
||||||
CommandState,
|
|
||||||
DraftShape,
|
|
||||||
Project,
|
|
||||||
Vec2,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
|
|
||||||
|
|
||||||
interface Curve {
|
|
||||||
pts: Vec2[];
|
|
||||||
closed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wandelt eine extend-fähige 2D-Form (offen) in eine Punktliste oder null. */
|
|
||||||
function geomToOpenCurve(g: Drawing2DGeom): Curve | null {
|
|
||||||
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
|
|
||||||
if (g.shape === "polyline" && !g.closed) return { pts: g.pts, closed: false };
|
|
||||||
return null; // rect/geschlossene polyline/circle/arc/text: kein Ende zum Verlängern
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alle trim/extend-fähigen Drawing2D der Ebene als Cutter-Kurven (auch geschlossene). */
|
|
||||||
function cuttersFor(project: Project, levelId: string, exceptId: string): Curve[] {
|
|
||||||
const out: Curve[] = [];
|
|
||||||
for (const d of project.drawings2d) {
|
|
||||||
if (d.id === exceptId) continue;
|
|
||||||
if (d.levelId !== levelId) continue;
|
|
||||||
const g = d.geom;
|
|
||||||
if (g.shape === "line") out.push({ pts: [g.a, g.b], closed: false });
|
|
||||||
else if (g.shape === "polyline") out.push({ pts: g.pts, closed: g.closed });
|
|
||||||
else if (g.shape === "rect") {
|
|
||||||
out.push({
|
|
||||||
pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }],
|
|
||||||
closed: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nächste extend-fähige (offene) Drawing2D der Ebene zum Klickpunkt. */
|
|
||||||
function pickOpenCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null {
|
|
||||||
let best: Drawing2D | null = null;
|
|
||||||
let bestD = HIT_TOL;
|
|
||||||
for (const d of project.drawings2d) {
|
|
||||||
if (d.levelId !== levelId) continue;
|
|
||||||
const curve = geomToOpenCurve(d.geom);
|
|
||||||
if (!curve) continue;
|
|
||||||
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
|
|
||||||
const dd = pointSegmentDistance(pt, a, b);
|
|
||||||
if (dd < bestD) {
|
|
||||||
bestD = dd;
|
|
||||||
best = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verlängert die Kurve am Ende, das dem Klickpunkt am nächsten liegt, bis zum
|
|
||||||
* nächsten Schnitt mit den Cuttern (jenseits dieses Endes). Liefert die neuen
|
|
||||||
* Punkte, oder `null`, wenn kein Cutter getroffen wird (kein Effekt).
|
|
||||||
*/
|
|
||||||
function extendCurve(curve: Curve, cutters: Curve[], pick: Vec2): Vec2[] | null {
|
|
||||||
const n = curve.pts.length;
|
|
||||||
if (n < 2) return null;
|
|
||||||
const distStart = Math.hypot(pick.x - curve.pts[0].x, pick.y - curve.pts[0].y);
|
|
||||||
const distEnd = Math.hypot(pick.x - curve.pts[n - 1].x, pick.y - curve.pts[n - 1].y);
|
|
||||||
const extendStart = distStart <= distEnd;
|
|
||||||
const idxA = extendStart ? 1 : n - 2;
|
|
||||||
const idxB = extendStart ? 0 : n - 1;
|
|
||||||
const result = extendSegment(curve.pts[idxA], curve.pts[idxB], "end", cutters);
|
|
||||||
if (!result) return null;
|
|
||||||
const next = [...curve.pts];
|
|
||||||
next[idxB] = result[1];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wendet Extend auf das Projekt an: ersetzt die getroffene Kurve durch ihre verlängerte Form. */
|
|
||||||
function applyExtend(p: Project, target: Drawing2D, pick: Vec2): Project {
|
|
||||||
const curve = geomToOpenCurve(target.geom);
|
|
||||||
if (!curve) return p;
|
|
||||||
const cutters = cuttersFor(p, target.levelId, target.id);
|
|
||||||
const next = extendCurve(curve, cutters, pick);
|
|
||||||
if (!next) return p;
|
|
||||||
const geom: Drawing2DGeom =
|
|
||||||
next.length === 2 && target.geom.shape === "line"
|
|
||||||
? { shape: "line", a: next[0], b: next[1] }
|
|
||||||
: { shape: "polyline", pts: next, closed: false };
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
drawings2d: p.drawings2d.map((d) => (d.id === target.id ? { ...d, geom } : d)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vorschau-Form der verlängerten Kurve (für den Draft). */
|
|
||||||
function extendPreview(project: Project, target: Drawing2D, pick: Vec2): DraftShape[] {
|
|
||||||
const curve = geomToOpenCurve(target.geom);
|
|
||||||
if (!curve) return [];
|
|
||||||
const cutters = cuttersFor(project, target.levelId, target.id);
|
|
||||||
const next = extendCurve(curve, cutters, pick);
|
|
||||||
if (!next) return [];
|
|
||||||
return [{ kind: "poly", pts: next, closed: false }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
|
||||||
// Einziger Schritt „pick": auf die zu verlängernde Kurve (nahe dem Ende) klicken;
|
|
||||||
// wiederholend, bis Esc/Enter.
|
|
||||||
interface ExtendPick extends CommandState {
|
|
||||||
phase: "pick";
|
|
||||||
}
|
|
||||||
interface ExtendDone extends CommandState {
|
|
||||||
phase: "done";
|
|
||||||
}
|
|
||||||
type ExtendState = ExtendPick | ExtendDone;
|
|
||||||
|
|
||||||
const finish = (): [CommandState, CommandResult] => [
|
|
||||||
{ phase: "done", lastPoint: null } as ExtendDone,
|
|
||||||
{ draft: null, done: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const extendCommand: Command = {
|
|
||||||
name: "extend",
|
|
||||||
labelKey: "cmd.extend.label",
|
|
||||||
prompt: () => "cmd.extend.pick",
|
|
||||||
accepts: () => ["point"],
|
|
||||||
options: () => [],
|
|
||||||
|
|
||||||
init: (): ExtendPick => ({ phase: "pick", lastPoint: null }),
|
|
||||||
|
|
||||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
|
||||||
const s = state as ExtendState;
|
|
||||||
if (s.phase === "done") return finish();
|
|
||||||
if (input.kind !== "point") return [s, { draft: null }];
|
|
||||||
const target = pickOpenCurveAt(ctx.project, ctx.level.id, input.point);
|
|
||||||
if (!target) return [s, { draft: null }];
|
|
||||||
const pick = input.point;
|
|
||||||
return [s, { draft: null, commit: (p) => applyExtend(p, target, pick) }];
|
|
||||||
},
|
|
||||||
|
|
||||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
|
||||||
const s = state as ExtendState;
|
|
||||||
if (s.phase !== "pick") return [s, { draft: null }];
|
|
||||||
const target = pickOpenCurveAt(ctx.project, ctx.level.id, point);
|
|
||||||
if (!target) return [s, { draft: null }];
|
|
||||||
const preview = extendPreview(ctx.project, target, point);
|
|
||||||
return [s, { draft: { preview, vertices: [], hud: { at: point, text: "↗" } } }];
|
|
||||||
},
|
|
||||||
|
|
||||||
onConfirm: (): [CommandState, CommandResult] => finish(),
|
|
||||||
onCancel: (): [CommandState, CommandResult] => finish(),
|
|
||||||
};
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* `filletCommand` — die Kernel-Geometrie (`filletCorner`) existierte bereits
|
|
||||||
* und war getestet, hatte aber KEINEN Aufrufer (kein Command, kein Tool).
|
|
||||||
* Test: eine rechtwinklige Polylinien-Ecke wird mit einem Radius verrundet.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { filletCommand } from "./fillet";
|
|
||||||
import type { CommandContext, Project } from "../types";
|
|
||||||
|
|
||||||
function project(): Project {
|
|
||||||
return {
|
|
||||||
id: "t",
|
|
||||||
name: "T",
|
|
||||||
lineStyles: [],
|
|
||||||
hatches: [],
|
|
||||||
components: [],
|
|
||||||
wallTypes: [],
|
|
||||||
drawingLevels: [
|
|
||||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
|
||||||
],
|
|
||||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
|
||||||
walls: [],
|
|
||||||
doors: [],
|
|
||||||
openings: [],
|
|
||||||
ceilings: [],
|
|
||||||
stairs: [],
|
|
||||||
rooms: [],
|
|
||||||
drawings2d: [
|
|
||||||
// L-förmige offene Polylinie: (0,0) → (2,0) → (2,2). Ecke bei (2,0).
|
|
||||||
{
|
|
||||||
id: "poly",
|
|
||||||
type: "drawing2d",
|
|
||||||
levelId: "eg",
|
|
||||||
categoryCode: "20",
|
|
||||||
geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 }], closed: false },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
context: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(p: Project): CommandContext {
|
|
||||||
return {
|
|
||||||
project: p,
|
|
||||||
level: p.drawingLevels[0],
|
|
||||||
defaultCategoryCode: "20",
|
|
||||||
activeLineStyleId: "solid",
|
|
||||||
activeWallTypeId: "aw",
|
|
||||||
lastPoint: null,
|
|
||||||
selection: { wallIds: [], drawingId: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("filletCommand", () => {
|
|
||||||
it("verrundet die Ecke mit getipptem Radius (Zahl-Eingabe)", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
|
|
||||||
// Schritt 1: Ecke wählen (Klick nahe (2,0)).
|
|
||||||
const [afterPick, r1] = filletCommand.onInput(
|
|
||||||
filletCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 2, y: 0 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(r1.done).toBeFalsy();
|
|
||||||
|
|
||||||
// Schritt 2: Radius getippt (0.5m) → commit.
|
|
||||||
const [, r2] = filletCommand.onInput(afterPick, { kind: "number", value: 0.5 }, ctx);
|
|
||||||
expect(r2.done).toBe(true);
|
|
||||||
expect(r2.commit).toBeDefined();
|
|
||||||
|
|
||||||
const next = r2.commit!(p);
|
|
||||||
const poly = next.drawings2d.find((d) => d.id === "poly")!;
|
|
||||||
expect(poly.geom.shape).toBe("polyline");
|
|
||||||
if (poly.geom.shape !== "polyline") return;
|
|
||||||
const pts = poly.geom.pts;
|
|
||||||
// Die scharfe Ecke (2,0) darf nicht mehr exakt in den Punkten vorkommen —
|
|
||||||
// sie wurde durch den Bogen ersetzt.
|
|
||||||
expect(pts.some((p) => p.x === 2 && p.y === 0)).toBe(false);
|
|
||||||
// Anfangs-/Endpunkt der ganzen Kette bleiben unverändert.
|
|
||||||
expect(pts[0]).toEqual({ x: 0, y: 0 });
|
|
||||||
expect(pts[pts.length - 1]).toEqual({ x: 2, y: 2 });
|
|
||||||
// Die Tangentenpunkte (0.5m vom Eck entlang jedes Schenkels) müssen vorkommen.
|
|
||||||
const hasNear = (x: number, y: number) =>
|
|
||||||
pts.some((p) => Math.hypot(p.x - x, p.y - y) < 1e-6);
|
|
||||||
expect(hasNear(1.5, 0)).toBe(true); // Tangente auf dem ersten Schenkel
|
|
||||||
expect(hasNear(2, 0.5)).toBe(true); // Tangente auf dem zweiten Schenkel
|
|
||||||
});
|
|
||||||
|
|
||||||
it("daneben geklickt → Befehl bleibt im pick-Zustand (kein Treffer)", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [state, result] = filletCommand.onInput(
|
|
||||||
filletCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 50, y: 50 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.commit).toBeUndefined();
|
|
||||||
expect(result.done).toBeFalsy();
|
|
||||||
expect((state as { phase: string }).phase).toBe("pick");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
// Fillet — verrundet die Ecke einer 2D-Kurve (polyline/rect) mit einem Radius
|
|
||||||
// (Rhino/AutoCAD-artig). Die reine Geometrie (`filletCorner`) existierte
|
|
||||||
// bereits im Kernel, hatte aber KEINEN Aufrufer — reine Verdrahtungsarbeit,
|
|
||||||
// kein neues Geometrie-Problem. Schritte:
|
|
||||||
// 1) „Ecke wählen:" → Punkt (nächstgelegener Eckpunkt einer polyline/rect
|
|
||||||
// wird gewählt; ein einzelnes `line`-Element hat keine eigene Ecke)
|
|
||||||
// 2) „Radius:" → Punkt (Abstand von der Ecke) ODER getippte Zahl → commit
|
|
||||||
//
|
|
||||||
// EINSCHRÄNKUNG (ehrlich, nicht verschwiegen): Polylinien kennen im Modell
|
|
||||||
// bisher keine Bogen-Segmente (kein „Bulge" — siehe PENDENZEN.md, 2D-
|
|
||||||
// Vervollständigung). Die Verrundung wird daher als kurze Punktfolge entlang
|
|
||||||
// des Bogens TESSELLIERT (approximiert), nicht als echtes Arc-Segment
|
|
||||||
// gespeichert — optisch rund, aber kein editierbares Bogen-Objekt. Ein rect
|
|
||||||
// wird beim ersten Fillet zu einer polyline (kein reines Rechteck mehr).
|
|
||||||
|
|
||||||
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
|
|
||||||
import { filletCorner, type Fillet } from "../../geometry/kernel2d";
|
|
||||||
import type {
|
|
||||||
Command,
|
|
||||||
CommandContext,
|
|
||||||
CommandField,
|
|
||||||
CommandResult,
|
|
||||||
CommandState,
|
|
||||||
DraftShape,
|
|
||||||
Project,
|
|
||||||
ToolDraft,
|
|
||||||
Vec2,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const HIT_TOL = 0.3; // Modell-Meter: Pick-Toleranz für die Eckenwahl
|
|
||||||
const DEFAULT_RADIUS = 0.1; // m — Startwert, bis der Nutzer per Maus/Tab einen eigenen setzt
|
|
||||||
const EPS = 1e-6;
|
|
||||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
|
||||||
|
|
||||||
interface Curve {
|
|
||||||
pts: Vec2[];
|
|
||||||
closed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wandelt eine fillet-fähige 2D-Form (polyline/rect) in eine Punktliste oder null. */
|
|
||||||
function geomToCurve(g: Drawing2DGeom): Curve | null {
|
|
||||||
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
|
|
||||||
if (g.shape === "rect") {
|
|
||||||
return {
|
|
||||||
pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }],
|
|
||||||
closed: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null; // line (keine eigene Ecke), circle/arc/text: nicht fillet-fähig
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Treffer: das Element + der Eckpunkt-Index (mit gültigen beiden Nachbarn). */
|
|
||||||
interface CornerHit {
|
|
||||||
drawing: Drawing2D;
|
|
||||||
curve: Curve;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nächster Eckpunkt (mit zwei Nachbarn) einer fillet-fähigen Drawing2D zum Klickpunkt. */
|
|
||||||
function pickCornerAt(project: Project, levelId: string, pt: Vec2): CornerHit | null {
|
|
||||||
let best: CornerHit | null = null;
|
|
||||||
let bestD = HIT_TOL;
|
|
||||||
for (const d of project.drawings2d) {
|
|
||||||
if (d.levelId !== levelId) continue;
|
|
||||||
const curve = geomToCurve(d.geom);
|
|
||||||
if (!curve) continue;
|
|
||||||
const n = curve.pts.length;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
// Offene Polylinie: die beiden Endpunkte haben keinen zweiten Nachbarn.
|
|
||||||
if (!curve.closed && (i === 0 || i === n - 1)) continue;
|
|
||||||
const dd = dist(pt, curve.pts[i]);
|
|
||||||
if (dd < bestD) {
|
|
||||||
bestD = dd;
|
|
||||||
best = { drawing: d, curve, index: i };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verrundungs-Geometrie für einen Treffer + Radius (oder null, falls unmöglich). */
|
|
||||||
function filletFor(hit: CornerHit, r: number): Fillet | null {
|
|
||||||
const n = hit.curve.pts.length;
|
|
||||||
const prev = hit.curve.pts[(hit.index - 1 + n) % n];
|
|
||||||
const next = hit.curve.pts[(hit.index + 1) % n];
|
|
||||||
const corner = hit.curve.pts[hit.index];
|
|
||||||
return filletCorner(corner, prev, next, r);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bogen (center/radius/start-/endAngle) als kurze Punktfolge tesselliert (kürzester Bogen). */
|
|
||||||
function tessellateFillet(f: Fillet, segments = 12): Vec2[] {
|
|
||||||
const TAU = Math.PI * 2;
|
|
||||||
let delta = f.endAngle - f.startAngle;
|
|
||||||
while (delta > Math.PI) delta -= TAU;
|
|
||||||
while (delta < -Math.PI) delta += TAU;
|
|
||||||
const pts: Vec2[] = [];
|
|
||||||
for (let i = 0; i <= segments; i++) {
|
|
||||||
const a = f.startAngle + (delta * i) / segments;
|
|
||||||
pts.push({ x: f.center.x + f.radius * Math.cos(a), y: f.center.y + f.radius * Math.sin(a) });
|
|
||||||
}
|
|
||||||
return pts;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ersetzt den Eckpunkt durch die tessellierte Bogen-Punktfolge (Tangente↔Tangente). */
|
|
||||||
function applyFilletPoints(hit: CornerHit, f: Fillet): Vec2[] {
|
|
||||||
const arc = tessellateFillet(f);
|
|
||||||
return [
|
|
||||||
...hit.curve.pts.slice(0, hit.index),
|
|
||||||
...arc,
|
|
||||||
...hit.curve.pts.slice(hit.index + 1),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function filletDraft(hit: CornerHit, r: number, at: Vec2 | null): ToolDraft {
|
|
||||||
const f = r > EPS ? filletFor(hit, r) : null;
|
|
||||||
const preview: DraftShape[] = f
|
|
||||||
? [{ kind: "poly", pts: tessellateFillet(f), closed: false }]
|
|
||||||
: [];
|
|
||||||
const draft: ToolDraft = { preview, vertices: [hit.curve.pts[hit.index]] };
|
|
||||||
if (at) draft.hud = { at, text: `R: ${r.toFixed(3)}m${f ? "" : " ✗"}` };
|
|
||||||
return draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitFillet(p: Project, hit: CornerHit, r: number): Project {
|
|
||||||
const f = filletFor(hit, r);
|
|
||||||
if (!f) return p; // Radius zu gross für die Schenkellänge, oder Ecke (fast) gerade → No-op
|
|
||||||
const pts = applyFilletPoints(hit, f);
|
|
||||||
const geom: Drawing2DGeom = { shape: "polyline", pts, closed: hit.curve.closed };
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
drawings2d: p.drawings2d.map((d) => (d.id === hit.drawing.id ? { ...d, geom } : d)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
|
||||||
const RADIUS_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }];
|
|
||||||
|
|
||||||
interface FilletPick extends CommandState {
|
|
||||||
phase: "pick";
|
|
||||||
}
|
|
||||||
interface FilletRadius extends CommandState {
|
|
||||||
phase: "radius";
|
|
||||||
hit: CornerHit;
|
|
||||||
radius: number;
|
|
||||||
}
|
|
||||||
interface FilletDone extends CommandState {
|
|
||||||
phase: "done";
|
|
||||||
}
|
|
||||||
type FilletState = FilletPick | FilletRadius | FilletDone;
|
|
||||||
|
|
||||||
const finish = (): [CommandState, CommandResult] => [
|
|
||||||
{ phase: "done", lastPoint: null } as FilletDone,
|
|
||||||
{ draft: null, done: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const filletCommand: Command = {
|
|
||||||
name: "fillet",
|
|
||||||
labelKey: "cmd.fillet.label",
|
|
||||||
prompt: (s) => ((s as FilletState).phase === "radius" ? "cmd.fillet.radius" : "cmd.fillet.pick"),
|
|
||||||
accepts: (s) => ((s as FilletState).phase === "radius" ? ["point", "number"] : ["point"]),
|
|
||||||
options: () => [],
|
|
||||||
fields: (s) => ((s as FilletState).phase === "radius" ? RADIUS_FIELDS : []),
|
|
||||||
|
|
||||||
init: (): FilletPick => ({ phase: "pick", lastPoint: null }),
|
|
||||||
|
|
||||||
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
|
|
||||||
const s = state as FilletState;
|
|
||||||
if (s.phase === "done") return finish();
|
|
||||||
if (s.phase === "pick") {
|
|
||||||
if (input.kind !== "point") return [s, { draft: null }];
|
|
||||||
const hit = pickCornerAt(ctx.project, ctx.level.id, input.point);
|
|
||||||
if (!hit) return [s, { draft: null }]; // daneben geklickt → aktiv bleiben
|
|
||||||
const next: FilletRadius = { phase: "radius", hit, radius: DEFAULT_RADIUS, lastPoint: input.point };
|
|
||||||
return [next, { draft: filletDraft(hit, next.radius, input.point) }];
|
|
||||||
}
|
|
||||||
// phase === "radius"
|
|
||||||
let r = s.radius;
|
|
||||||
if (input.kind === "number") r = Math.max(0, input.value);
|
|
||||||
else if (input.kind === "point") r = Math.max(0, dist(s.hit.curve.pts[s.hit.index], input.point));
|
|
||||||
else return [s, { draft: null }];
|
|
||||||
return [
|
|
||||||
{ phase: "done", lastPoint: null } as FilletDone,
|
|
||||||
{ draft: null, done: true, commit: (p) => commitFillet(p, s.hit, r) },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
|
||||||
const s = state as FilletState;
|
|
||||||
if (s.phase === "pick") {
|
|
||||||
const hit = pickCornerAt(ctx.project, ctx.level.id, point);
|
|
||||||
if (!hit) return [s, { draft: null }];
|
|
||||||
return [s, { draft: filletDraft(hit, DEFAULT_RADIUS, point) }];
|
|
||||||
}
|
|
||||||
if (s.phase !== "radius") return [s, { draft: null }];
|
|
||||||
const r = Math.max(0, dist(s.hit.curve.pts[s.hit.index], point));
|
|
||||||
const ns: FilletRadius = { ...s, radius: r };
|
|
||||||
return [ns, { draft: filletDraft(s.hit, r, point) }];
|
|
||||||
},
|
|
||||||
|
|
||||||
onConfirm: (): [CommandState, CommandResult] => finish(),
|
|
||||||
onCancel: (): [CommandState, CommandResult] => finish(),
|
|
||||||
|
|
||||||
// Tab-Feld-Zyklus nur im „radius"-Schritt (ein Radius-Feld), analog zu
|
|
||||||
// circle.ts: der gelockte Wert bestimmt den Punkt auf der Winkelhalbierenden
|
|
||||||
// Richtung Cursor (bzw. +X ohne Cursor), dist(Ecke, Punkt) = radius.
|
|
||||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
|
||||||
const s = state as FilletState;
|
|
||||||
if (s.phase !== "radius" || !cursor) return {};
|
|
||||||
return { radius: dist(s.hit.curve.pts[s.hit.index], cursor) };
|
|
||||||
},
|
|
||||||
pointFromFields: (state, locks, cursor) => {
|
|
||||||
const s = state as FilletState;
|
|
||||||
if (s.phase !== "radius") return null;
|
|
||||||
const corner = s.hit.curve.pts[s.hit.index];
|
|
||||||
const r = "radius" in locks ? Math.max(0, locks.radius) : cursor ? dist(corner, cursor) : 0;
|
|
||||||
if (cursor) {
|
|
||||||
const d = dist(corner, cursor);
|
|
||||||
if (d > EPS) {
|
|
||||||
return {
|
|
||||||
x: corner.x + ((cursor.x - corner.x) / d) * r,
|
|
||||||
y: corner.y + ((cursor.y - corner.y) / d) * r,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { x: corner.x + r, y: corner.y };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* `multilineCommand` — zeichnet eine Basislinie + N-1 parallele Kopien in
|
|
||||||
* einer Geste (baut auf `offsetSegment` auf, wie der bestehende `offset`-
|
|
||||||
* Befehl). Test: Start → Ende → Abstand (getippt) → commit erzeugt die
|
|
||||||
* erwartete Anzahl paralleler Linien.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { multilineCommand } from "./multiline";
|
|
||||||
import type { CommandContext, Project } from "../types";
|
|
||||||
|
|
||||||
function project(): Project {
|
|
||||||
return {
|
|
||||||
id: "t",
|
|
||||||
name: "T",
|
|
||||||
lineStyles: [],
|
|
||||||
hatches: [],
|
|
||||||
components: [],
|
|
||||||
wallTypes: [],
|
|
||||||
drawingLevels: [
|
|
||||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
|
||||||
],
|
|
||||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
|
||||||
walls: [],
|
|
||||||
doors: [],
|
|
||||||
openings: [],
|
|
||||||
ceilings: [],
|
|
||||||
stairs: [],
|
|
||||||
rooms: [],
|
|
||||||
drawings2d: [],
|
|
||||||
context: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(p: Project): CommandContext {
|
|
||||||
return {
|
|
||||||
project: p,
|
|
||||||
level: p.drawingLevels[0],
|
|
||||||
defaultCategoryCode: "20",
|
|
||||||
activeLineStyleId: "solid",
|
|
||||||
activeWallTypeId: "aw",
|
|
||||||
lastPoint: null,
|
|
||||||
selection: { wallIds: [], drawingId: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("multilineCommand", () => {
|
|
||||||
it("Basislinie + 2 Kopien (Default-Anzahl 3) bei getipptem Abstand", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
|
|
||||||
const [afterStart] = multilineCommand.onInput(
|
|
||||||
multilineCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 0, y: 0 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const [afterEnd] = multilineCommand.onInput(
|
|
||||||
afterStart,
|
|
||||||
{ kind: "point", point: { x: 10, y: 0 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const [, result] = multilineCommand.onInput(afterEnd, { kind: "number", value: 1 }, ctx);
|
|
||||||
expect(result.commit).toBeDefined();
|
|
||||||
|
|
||||||
const next = result.commit!(p);
|
|
||||||
expect(next.drawings2d.length).toBe(3); // Basislinie + 2 Kopien (Default-Count)
|
|
||||||
for (const d of next.drawings2d) {
|
|
||||||
expect(d.geom.shape).toBe("line");
|
|
||||||
if (d.geom.shape === "line") {
|
|
||||||
// Alle Linien laufen horizontal von x=0 bis x=10 (nur y verschoben).
|
|
||||||
expect(d.geom.a.x).toBeCloseTo(0, 9);
|
|
||||||
expect(d.geom.b.x).toBeCloseTo(10, 9);
|
|
||||||
expect(d.geom.a.y).toBeCloseTo(d.geom.b.y, 9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Die y-Werte sind 0, ±1, ±2 (je nach Seite) — auf jeden Fall drei
|
|
||||||
// unterschiedliche Werte im Abstand von genau 1m.
|
|
||||||
const ys = next.drawings2d
|
|
||||||
.map((d) => (d.geom.shape === "line" ? d.geom.a.y : NaN))
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
expect(ys[1] - ys[0]).toBeCloseTo(1, 9);
|
|
||||||
expect(ys[2] - ys[1]).toBeCloseTo(1, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Null-Strecke (Start=Ende) → No-op, Befehl beendet ohne Elemente", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [afterStart] = multilineCommand.onInput(
|
|
||||||
multilineCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 5, y: 5 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const [, result] = multilineCommand.onInput(
|
|
||||||
afterStart,
|
|
||||||
{ kind: "point", point: { x: 5, y: 5 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.done).toBe(true);
|
|
||||||
expect(result.commit).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
// Multiline — zeichnet in EINER Geste eine gerade Basislinie PLUS N−1 dazu
|
|
||||||
// parallel versetzte Kopien (gleicher Abstand, gleiche Seite), z. B. für
|
|
||||||
// Doppellinien/Leitungstrassen/Abstandslinien. Baut auf derselben Offset-
|
|
||||||
// Geometrie wie der bestehende `offset`-Befehl auf (`offsetSegment`), erzeugt
|
|
||||||
// aber alle Kopien auf einmal statt einzeln nacheinander offsetten zu müssen.
|
|
||||||
// Schritte:
|
|
||||||
// 1) „Startpunkt:" → Punkt
|
|
||||||
// 2) „Endpunkt:" → Punkt (Tab-Felder Länge/Winkel wie bei Line)
|
|
||||||
// 3) „Abstand/Seite:" → Distanz tippen (persistent über Aufrufe, wie bei
|
|
||||||
// Offset) ODER Seite per Klick (Vorzeichen aus der Klickseite) → commit
|
|
||||||
// ALLER `count` Linien (Basislinie + count−1 Kopien bei d, 2d, …).
|
|
||||||
// Die Anzahl `count` zyklt über eine Inline-Option (2..6, dann zurück zu 2).
|
|
||||||
//
|
|
||||||
// Reine gerade Basislinien (kein Polylinien-Multiline) — deckt den häufigsten
|
|
||||||
// Anwendungsfall ab (Grenzlinie+Abstand, Leitungspaar, Randlinien), ohne die
|
|
||||||
// Komplexität einer mehrsegmentigen Variante.
|
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
|
||||||
import { segmentHud, uniqueId } from "../../tools/types";
|
|
||||||
import { leftNormal, normalize, sub } from "../../model/geometry";
|
|
||||||
import { offsetSegment } from "../../geometry/kernel2d";
|
|
||||||
import type {
|
|
||||||
CmdOption,
|
|
||||||
Command,
|
|
||||||
CommandContext,
|
|
||||||
CommandField,
|
|
||||||
CommandResult,
|
|
||||||
CommandState,
|
|
||||||
DraftShape,
|
|
||||||
Project,
|
|
||||||
ToolDraft,
|
|
||||||
Vec2,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
const EPS = 1e-6;
|
|
||||||
const DEG = Math.PI / 180;
|
|
||||||
const MIN_COUNT = 2;
|
|
||||||
const MAX_COUNT = 6;
|
|
||||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
|
||||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
|
||||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
|
||||||
|
|
||||||
/** Persistente Einstellungen über Aufrufe hinweg (wie `lastDistance` bei Offset). */
|
|
||||||
let lastSpacing = 0.3;
|
|
||||||
let lastSign = 1; // +1 = links, −1 = rechts
|
|
||||||
let lastCount = 3;
|
|
||||||
|
|
||||||
const LINE_FIELDS: CommandField[] = [
|
|
||||||
{ id: "length", labelKey: "cmd.field.length" },
|
|
||||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function endFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
|
||||||
const ref = cursor ?? a;
|
|
||||||
const length = "length" in locks ? locks.length : segLen(a, ref);
|
|
||||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
|
|
||||||
const ang = angleDeg * DEG;
|
|
||||||
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Basislinie a→b. */
|
|
||||||
function sideSign(a: Vec2, b: Vec2, pt: Vec2): number {
|
|
||||||
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
||||||
const u = normalize(sub(b, a));
|
|
||||||
const n = leftNormal(u);
|
|
||||||
const rel = sub(pt, mid);
|
|
||||||
return rel.x * n.x + rel.y * n.y >= 0 ? 1 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Die `count` parallelen Linien (Index 0 = Basislinie) als Punktpaare. */
|
|
||||||
function multilinePairs(a: Vec2, b: Vec2, spacing: number, sign: number, count: number): [Vec2, Vec2][] {
|
|
||||||
const out: [Vec2, Vec2][] = [[a, b]];
|
|
||||||
for (let k = 1; k < count; k++) {
|
|
||||||
out.push(offsetSegment(a, b, sign * spacing * k));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendMultiline(p: Project, a: Vec2, b: Vec2, spacing: number, sign: number, count: number, ctx: CommandContext): Project {
|
|
||||||
if (segLen(a, b) < EPS) return p;
|
|
||||||
const pairs = multilinePairs(a, b, spacing, sign, count);
|
|
||||||
const copies: Drawing2D[] = pairs.map(([pa, pb]) => ({
|
|
||||||
id: uniqueId("dr2d"),
|
|
||||||
type: "drawing2d",
|
|
||||||
levelId: ctx.level.id,
|
|
||||||
categoryCode: ctx.defaultCategoryCode,
|
|
||||||
geom: { shape: "line", a: pa, b: pb },
|
|
||||||
}));
|
|
||||||
return { ...p, drawings2d: [...p.drawings2d, ...copies] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const COUNT_OPTION: CmdOption = { id: "count", labelKey: "cmd.multiline.count" };
|
|
||||||
|
|
||||||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
|
||||||
interface MlStart extends CommandState {
|
|
||||||
phase: "start";
|
|
||||||
}
|
|
||||||
interface MlEnd extends CommandState {
|
|
||||||
phase: "end";
|
|
||||||
a: Vec2;
|
|
||||||
cursor: Vec2 | null;
|
|
||||||
}
|
|
||||||
interface MlSpacing extends CommandState {
|
|
||||||
phase: "spacing";
|
|
||||||
a: Vec2;
|
|
||||||
b: Vec2;
|
|
||||||
cursor: Vec2 | null;
|
|
||||||
}
|
|
||||||
type MlState = MlStart | MlEnd | MlSpacing;
|
|
||||||
|
|
||||||
const idle = (): [CommandState, CommandResult] => [
|
|
||||||
{ phase: "start", lastPoint: null } as MlStart,
|
|
||||||
{ draft: null, done: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Vorschau aller `lastCount` Linien bei gegebenem Abstand+Seite. */
|
|
||||||
function spacingDraft(a: Vec2, b: Vec2, spacing: number, sign: number, at: Vec2 | null): ToolDraft {
|
|
||||||
const pairs = multilinePairs(a, b, spacing, sign, lastCount);
|
|
||||||
const preview: DraftShape[] = pairs.map(([pa, pb]) => ({ kind: "line", a: pa, b: pb }));
|
|
||||||
const draft: ToolDraft = { preview, vertices: [a, b] };
|
|
||||||
if (at) draft.hud = { at, text: `${spacing.toFixed(2)} m × ${lastCount}` };
|
|
||||||
return draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const multilineCommand: Command = {
|
|
||||||
name: "multiline",
|
|
||||||
labelKey: "cmd.multiline.label",
|
|
||||||
prompt: (s) => {
|
|
||||||
const ms = s as MlState;
|
|
||||||
if (ms.phase === "end") return "cmd.multiline.end";
|
|
||||||
if (ms.phase === "spacing") return "cmd.multiline.spacing";
|
|
||||||
return "cmd.multiline.start";
|
|
||||||
},
|
|
||||||
accepts: (s) => ((s as MlState).phase === "spacing" ? ["point", "number", "option"] : ["point"]),
|
|
||||||
options: (s) => ((s as MlState).phase === "spacing" ? [{ ...COUNT_OPTION, value: String(lastCount) }] : []),
|
|
||||||
|
|
||||||
init: (): MlStart => ({ phase: "start", lastPoint: null }),
|
|
||||||
|
|
||||||
onInput: (state, input, ctx: CommandContext): [CommandState, CommandResult] => {
|
|
||||||
const s = state as MlState;
|
|
||||||
|
|
||||||
if (s.phase === "start") {
|
|
||||||
if (input.kind !== "point") return [s, { draft: null }];
|
|
||||||
const next: MlEnd = { phase: "end", a: input.point, cursor: input.point, lastPoint: input.point };
|
|
||||||
return [next, { draft: { preview: [], vertices: [input.point] } }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.phase === "end") {
|
|
||||||
if (input.kind !== "point") return [s, { draft: spacingDraftForEnd(s) }];
|
|
||||||
if (segLen(s.a, input.point) < EPS) return idle();
|
|
||||||
const next: MlSpacing = { phase: "spacing", a: s.a, b: input.point, cursor: input.point, lastPoint: input.point };
|
|
||||||
return [next, { draft: spacingDraft(next.a, next.b, lastSpacing, lastSign, input.point) }];
|
|
||||||
}
|
|
||||||
|
|
||||||
// phase === "spacing"
|
|
||||||
if (input.kind === "option") {
|
|
||||||
if (input.id === "count") {
|
|
||||||
lastCount = lastCount >= MAX_COUNT ? MIN_COUNT : lastCount + 1;
|
|
||||||
const sign = s.cursor ? sideSign(s.a, s.b, s.cursor) : lastSign;
|
|
||||||
return [s, { draft: spacingDraft(s.a, s.b, lastSpacing, sign, s.cursor) }];
|
|
||||||
}
|
|
||||||
return [s, { draft: null }];
|
|
||||||
}
|
|
||||||
if (input.kind === "number") {
|
|
||||||
const mag = Math.abs(input.value);
|
|
||||||
if (mag < EPS) return [s, { draft: null }];
|
|
||||||
lastSpacing = mag;
|
|
||||||
lastSign = input.value < 0 ? -1 : lastSign;
|
|
||||||
const { a, b } = s;
|
|
||||||
return [
|
|
||||||
idle()[0],
|
|
||||||
{ draft: null, done: true, commit: (p) => appendMultiline(p, a, b, lastSpacing, lastSign, lastCount, ctx) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (input.kind === "point") {
|
|
||||||
lastSign = sideSign(s.a, s.b, input.point);
|
|
||||||
const { a, b } = s;
|
|
||||||
return [
|
|
||||||
idle()[0],
|
|
||||||
{ draft: null, done: true, commit: (p) => appendMultiline(p, a, b, lastSpacing, lastSign, lastCount, ctx) },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [s, { draft: null }];
|
|
||||||
},
|
|
||||||
|
|
||||||
onMove: (state, point): [CommandState, CommandResult] => {
|
|
||||||
const s = state as MlState;
|
|
||||||
if (s.phase === "end") {
|
|
||||||
const next: MlEnd = { ...s, cursor: point };
|
|
||||||
const preview: DraftShape[] = segLen(s.a, point) >= EPS ? [{ kind: "line", a: s.a, b: point }] : [];
|
|
||||||
const draft: ToolDraft = { preview, vertices: [s.a] };
|
|
||||||
if (segLen(s.a, point) >= EPS) draft.hud = segmentHud(s.a, point);
|
|
||||||
return [next, { draft }];
|
|
||||||
}
|
|
||||||
if (s.phase === "spacing") {
|
|
||||||
const sign = sideSign(s.a, s.b, point);
|
|
||||||
const next: MlSpacing = { ...s, cursor: point };
|
|
||||||
return [next, { draft: spacingDraft(s.a, s.b, lastSpacing, sign, point) }];
|
|
||||||
}
|
|
||||||
return [s, { draft: null }];
|
|
||||||
},
|
|
||||||
|
|
||||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
|
||||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
|
||||||
|
|
||||||
// Tab-Feld-Zyklus nur im „end"-Schritt (Basislinie): Länge + Winkel, wie line.ts.
|
|
||||||
fields: (state) => ((state as MlState).phase === "end" ? LINE_FIELDS : []),
|
|
||||||
pointFromFields: (state, locks, cursor) => {
|
|
||||||
const s = state as MlState;
|
|
||||||
if (s.phase !== "end") return null;
|
|
||||||
return endFromFields(s.a, locks, cursor);
|
|
||||||
},
|
|
||||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
|
||||||
const s = state as MlState;
|
|
||||||
if (s.phase !== "end" || !cursor) return {};
|
|
||||||
return {
|
|
||||||
length: segLen(s.a, cursor),
|
|
||||||
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Vorschau der Basislinie im „end"-Schritt (Fallback für Nicht-Punkt-Eingaben). */
|
|
||||||
function spacingDraftForEnd(s: MlEnd): ToolDraft {
|
|
||||||
if (!s.cursor || segLen(s.a, s.cursor) < EPS) return { preview: [], vertices: [s.a] };
|
|
||||||
return { preview: [{ kind: "line", a: s.a, b: s.cursor }], vertices: [s.a], hud: segmentHud(s.a, s.cursor) };
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* `textCommand` — committet seit der Umstellung auf Inline-Editing (kein
|
|
||||||
* Dialog/keine Befehlszeilen-Eingabe mehr, Nutzer-Wunsch) SOFORT ein leeres
|
|
||||||
* Text-Element nach dem Ankerpunkt und meldet `focusDrawingId`, damit die UI
|
|
||||||
* (PlanView) direkt den Inline-Rich-Text-Editor öffnet.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { textCommand } from "./text";
|
|
||||||
import type { CommandContext, Project } from "../types";
|
|
||||||
|
|
||||||
function project(): Project {
|
|
||||||
return {
|
|
||||||
id: "t",
|
|
||||||
name: "T",
|
|
||||||
lineStyles: [],
|
|
||||||
hatches: [],
|
|
||||||
components: [],
|
|
||||||
wallTypes: [],
|
|
||||||
drawingLevels: [
|
|
||||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
|
||||||
],
|
|
||||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
|
||||||
walls: [],
|
|
||||||
doors: [],
|
|
||||||
openings: [],
|
|
||||||
ceilings: [],
|
|
||||||
stairs: [],
|
|
||||||
rooms: [],
|
|
||||||
drawings2d: [],
|
|
||||||
context: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(p: Project): CommandContext {
|
|
||||||
return {
|
|
||||||
project: p,
|
|
||||||
level: p.drawingLevels[0],
|
|
||||||
defaultCategoryCode: "20",
|
|
||||||
activeLineStyleId: "solid",
|
|
||||||
activeWallTypeId: "aw",
|
|
||||||
lastPoint: null,
|
|
||||||
selection: { wallIds: [], drawingId: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("textCommand", () => {
|
|
||||||
it("committet sofort ein leeres Text-Element und meldet focusDrawingId", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [, result] = textCommand.onInput(
|
|
||||||
textCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 3, y: 4 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.done).toBe(true);
|
|
||||||
expect(result.commit).toBeDefined();
|
|
||||||
expect(result.focusDrawingId).toBeDefined();
|
|
||||||
|
|
||||||
const next = result.commit!(p);
|
|
||||||
expect(next.drawings2d.length).toBe(1);
|
|
||||||
const d = next.drawings2d[0];
|
|
||||||
expect(d.id).toBe(result.focusDrawingId); // dieselbe ID wie im Commit erzeugt
|
|
||||||
expect(d.geom.shape).toBe("text");
|
|
||||||
if (d.geom.shape === "text") {
|
|
||||||
expect(d.geom.text).toBe("");
|
|
||||||
expect(d.geom.at).toEqual({ x: 3, y: 4 });
|
|
||||||
expect(d.geom.width).toBeUndefined(); // Text-Werkzeug: KEIN Wortumbruch
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
|
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
|
||||||
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
|
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
|
||||||
// Committet SOFORT ein leeres Text-Element und meldet `focusDrawingId` —
|
// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"}
|
||||||
// die UI (PlanView) öffnet daraufhin direkt den Inline-Rich-Text-Editor AUF
|
//
|
||||||
// der Zeichenfläche (kein Dialog, keine Befehlszeilen-Eingabe). Ohne
|
// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die
|
||||||
// Spaltenbreite bricht der Text NICHT automatisch um: einzeilig, bis der
|
// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der
|
||||||
// Nutzer selbst Enter drückt (siehe `textbox` fürs InDesign-artige
|
// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import.
|
||||||
// Wortumbruch-Textfeld). Bleibt der Editor leer, verwirft `commitTextEdit`
|
|
||||||
// (App.tsx) das Element wieder.
|
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { uniqueId } from "../../tools/types";
|
||||||
import type { Command, CommandContext, CommandResult, CommandState, Project, Vec2 } from "../types";
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
|
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
|
||||||
const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
||||||
@@ -18,45 +23,50 @@ const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
|||||||
interface TextIdle extends CommandState {
|
interface TextIdle extends CommandState {
|
||||||
phase: "point";
|
phase: "point";
|
||||||
}
|
}
|
||||||
type TextState = TextIdle;
|
interface TextLabel extends CommandState {
|
||||||
|
phase: "label";
|
||||||
|
at: Vec2;
|
||||||
|
}
|
||||||
|
type TextState = TextIdle | TextLabel;
|
||||||
|
|
||||||
function appendEmptyText(p: Project, id: string, at: Vec2, ctx: CommandContext): Project {
|
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
|
||||||
|
const label = text.trim();
|
||||||
|
if (label === "") return p;
|
||||||
const d: Drawing2D = {
|
const d: Drawing2D = {
|
||||||
id,
|
id: uniqueId("dr2d"),
|
||||||
type: "drawing2d",
|
type: "drawing2d",
|
||||||
levelId: ctx.level.id,
|
levelId: ctx.level.id,
|
||||||
categoryCode: ctx.defaultCategoryCode,
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
geom: { shape: "text", at, text: "", height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
|
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
|
||||||
};
|
};
|
||||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const idle = (): [CommandState, CommandResult] => [
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
{ phase: "point", lastPoint: null } as TextIdle,
|
{ phase: "point", lastPoint: null },
|
||||||
{ draft: null, done: true },
|
{ draft: null, done: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const textCommand: Command = {
|
export const textCommand: Command = {
|
||||||
name: "text",
|
name: "text",
|
||||||
labelKey: "cmd.text.label",
|
labelKey: "cmd.text.label",
|
||||||
prompt: () => "cmd.text.point",
|
prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"),
|
||||||
accepts: () => ["point"],
|
accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]),
|
||||||
options: () => [],
|
options: () => [],
|
||||||
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
|
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
|
||||||
|
|
||||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
const s = state as TextState;
|
const s = state as TextState;
|
||||||
|
if (s.phase !== "label") {
|
||||||
if (input.kind !== "point") return [s, { draft: null }];
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
const id = uniqueId("dr2d");
|
const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point };
|
||||||
const at = input.point;
|
return [ns, { draft: null }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "text") return [s, { draft: null }];
|
||||||
|
const at = s.at;
|
||||||
return [
|
return [
|
||||||
{ phase: "point", lastPoint: null } as TextIdle,
|
{ phase: "point", lastPoint: null },
|
||||||
{
|
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
|
||||||
draft: null,
|
|
||||||
done: true,
|
|
||||||
commit: (p) => appendEmptyText(p, id, at, ctx),
|
|
||||||
focusDrawingId: id,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* `textboxCommand` — committet seit der Umstellung auf Inline-Editing (kein
|
|
||||||
* Dialog/keine Befehlszeilen-Eingabe mehr, InDesign-artiges Textrahmen-
|
|
||||||
* Verhalten, Nutzer-Wunsch) SOFORT ein leeres Text-Element MIT Spaltenbreite
|
|
||||||
* nach einem aufgezogenen RECHTECK (zwei diagonale Ecken, nicht nur eine
|
|
||||||
* Breitenlinie) und meldet `focusDrawingId` + `focusMinHeightM` (die
|
|
||||||
* aufgezogene Rahmenhöhe als Mindesthöhe des Inline-Editors).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { textboxCommand } from "./textbox";
|
|
||||||
import type { CommandContext, Project } from "../types";
|
|
||||||
|
|
||||||
function project(): Project {
|
|
||||||
return {
|
|
||||||
id: "t",
|
|
||||||
name: "T",
|
|
||||||
lineStyles: [],
|
|
||||||
hatches: [],
|
|
||||||
components: [],
|
|
||||||
wallTypes: [],
|
|
||||||
drawingLevels: [
|
|
||||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
|
||||||
],
|
|
||||||
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
|
||||||
walls: [],
|
|
||||||
doors: [],
|
|
||||||
openings: [],
|
|
||||||
ceilings: [],
|
|
||||||
stairs: [],
|
|
||||||
rooms: [],
|
|
||||||
drawings2d: [],
|
|
||||||
context: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeCtx(p: Project): CommandContext {
|
|
||||||
return {
|
|
||||||
project: p,
|
|
||||||
level: p.drawingLevels[0],
|
|
||||||
defaultCategoryCode: "20",
|
|
||||||
activeLineStyleId: "solid",
|
|
||||||
activeWallTypeId: "aw",
|
|
||||||
lastPoint: null,
|
|
||||||
selection: { wallIds: [], drawingId: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("textboxCommand", () => {
|
|
||||||
it("committet nach einem Rechteck (2 Ecken) ein leeres Text-Element mit width + meldet focusDrawingId/focusMinHeightM", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
|
|
||||||
const [afterFirst] = textboxCommand.onInput(
|
|
||||||
textboxCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 0, y: 5 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
// Gegenecke: 3m breit, 1.5m hoch (Rahmen, keine reine Breitenlinie mehr).
|
|
||||||
const [, result] = textboxCommand.onInput(
|
|
||||||
afterFirst,
|
|
||||||
{ kind: "point", point: { x: 3, y: 3.5 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
expect(result.done).toBe(true);
|
|
||||||
expect(result.commit).toBeDefined();
|
|
||||||
expect(result.focusDrawingId).toBeDefined();
|
|
||||||
expect(result.focusMinHeightM).toBeCloseTo(1.5, 9);
|
|
||||||
|
|
||||||
const next = result.commit!(p);
|
|
||||||
expect(next.drawings2d.length).toBe(1);
|
|
||||||
const d = next.drawings2d[0];
|
|
||||||
expect(d.id).toBe(result.focusDrawingId);
|
|
||||||
expect(d.geom.shape).toBe("text");
|
|
||||||
if (d.geom.shape === "text") {
|
|
||||||
expect(d.geom.text).toBe("");
|
|
||||||
expect(d.geom.width).toBeCloseTo(3, 9); // InDesign-artiger Textrahmen: Breite gesetzt
|
|
||||||
expect(d.geom.at.x).toBeCloseTo(0, 9); // oben-links des Rahmens (unabhängig von der Zugrichtung)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("beliebige Zugrichtung (Gegenecke oben-links vom Start): Rahmen wird trotzdem korrekt normalisiert", () => {
|
|
||||||
const p = project();
|
|
||||||
const ctx = makeCtx(p);
|
|
||||||
const [afterFirst] = textboxCommand.onInput(
|
|
||||||
textboxCommand.init(),
|
|
||||||
{ kind: "point", point: { x: 5, y: 2 } },
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const [, result] = textboxCommand.onInput(
|
|
||||||
afterFirst,
|
|
||||||
{ kind: "point", point: { x: 2, y: 4 } }, // Gegenecke LINKS und OBEN vom Start
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const next = result.commit!(p);
|
|
||||||
const d = next.drawings2d[0];
|
|
||||||
if (d.geom.shape === "text") {
|
|
||||||
expect(d.geom.width).toBeCloseTo(3, 9); // |5-2|
|
|
||||||
expect(d.geom.at.x).toBeCloseTo(2, 9); // min(x)
|
|
||||||
}
|
|
||||||
expect(result.focusMinHeightM).toBeCloseTo(2, 9); // |4-2|
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +1,12 @@
|
|||||||
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber als aufgezogener
|
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
|
||||||
// RAHMEN: Breite UND Höhe kommen aus einem Rechteck (zwei diagonale Punkte),
|
// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte:
|
||||||
// nicht nur einer Breitenlinie (InDesign-artiges Textrahmen-Verhalten,
|
// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte)
|
||||||
// Nutzer-Wunsch). Schritte:
|
// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite;
|
||||||
// 1) „Erste Ecke:" → Punkt
|
// Live-Vorschau der Breitenlinie)
|
||||||
// 2) „Gegenecke:" → Punkt (Live-Vorschau des Rahmens, beliebige Richtung)
|
// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width}
|
||||||
// Committet danach SOFORT ein leeres Text-Element (mit `width`) und meldet
|
|
||||||
// `focusDrawingId` + `focusMinHeightM` (die aufgezogene Rahmenhöhe als
|
|
||||||
// MINDESThöhe, s. types.ts) — die UI (PlanView) öffnet den Inline-Rich-
|
|
||||||
// Text-Editor direkt auf der Zeichenfläche, mit der Spaltenbreite als CSS-
|
|
||||||
// Breite (echter Wortumbruch beim Tippen, kein Dialog) und dem Rahmen als
|
|
||||||
// Mindesthöhe (wächst bei mehr Text darüber hinaus). Bleibt der Editor
|
|
||||||
// leer, verwirft `commitTextEdit`/`closeTextEdit` (App.tsx) das Element
|
|
||||||
// wieder.
|
|
||||||
//
|
//
|
||||||
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben
|
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion,
|
||||||
// Selektion, Verschieben und Griff automatisch vom Einzeltext; die Modell-
|
// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu.
|
||||||
// Geometrie selbst kennt aber keine feste Rahmenhöhe (nur `width` für den
|
|
||||||
// Wortumbruch) — die Höhe bleibt reine Editor-Startgrösse (PlanView-lokal,
|
|
||||||
// s. focusMinHeightM), die Textmenge bestimmt die tatsächliche Höhe.
|
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { uniqueId } from "../../tools/types";
|
||||||
@@ -26,115 +15,111 @@ import type {
|
|||||||
CommandContext,
|
CommandContext,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
CommandState,
|
CommandState,
|
||||||
DraftShape,
|
|
||||||
Project,
|
Project,
|
||||||
|
ToolDraft,
|
||||||
Vec2,
|
Vec2,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
/** Default-Schrifthöhe einer Textspalte in Modell-Metern (= Baseline-Versatz vom Rahmen-Top). */
|
/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */
|
||||||
const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
const DEFAULT_TEXT_HEIGHT_M = 0.25;
|
||||||
/** Kleinste sinnvolle Rahmen-Breite/-Höhe (Meter). */
|
/** Kleinste sinnvolle Spaltenbreite (Meter). */
|
||||||
const MIN_SIZE_M = 0.2;
|
const MIN_WIDTH_M = 0.2;
|
||||||
|
|
||||||
interface TbFirst extends CommandState {
|
interface TbPoint extends CommandState {
|
||||||
phase: "first";
|
phase: "point";
|
||||||
}
|
}
|
||||||
interface TbSecond extends CommandState {
|
interface TbWidth extends CommandState {
|
||||||
phase: "second";
|
phase: "width";
|
||||||
a: Vec2;
|
at: Vec2;
|
||||||
cursor: Vec2 | null;
|
cursor: Vec2 | null;
|
||||||
}
|
}
|
||||||
type TbState = TbFirst | TbSecond;
|
interface TbLabel extends CommandState {
|
||||||
|
phase: "label";
|
||||||
|
at: Vec2;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
type TbState = TbPoint | TbWidth | TbLabel;
|
||||||
|
|
||||||
/** Normalisiertes Rechteck aus zwei diagonalen Punkten (beliebige Zugrichtung). */
|
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
|
||||||
function rectOf(a: Vec2, b: Vec2): { minX: number; maxX: number; minY: number; maxY: number } {
|
function widthOf(at: Vec2, cursor: Vec2): number {
|
||||||
|
return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */
|
||||||
|
function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
|
||||||
|
const w = widthOf(at, cursor);
|
||||||
|
const b: Vec2 = { x: at.x + w, y: at.y };
|
||||||
return {
|
return {
|
||||||
minX: Math.min(a.x, b.x),
|
preview: [{ kind: "line", a: at, b }],
|
||||||
maxX: Math.max(a.x, b.x),
|
vertices: [at, b],
|
||||||
minY: Math.min(a.y, b.y),
|
hud: { at: b, text: `${w.toFixed(2)} m` },
|
||||||
maxY: Math.max(a.y, b.y),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Breite/Höhe (geklemmt auf MIN_SIZE_M) + die Textanker-Position (oben-links, als Baseline). */
|
function appendTextbox(
|
||||||
function frameOf(a: Vec2, b: Vec2): { at: Vec2; width: number; heightM: number } {
|
p: Project,
|
||||||
const r = rectOf(a, b);
|
at: Vec2,
|
||||||
const width = Math.max(MIN_SIZE_M, r.maxX - r.minX);
|
width: number,
|
||||||
const heightM = Math.max(MIN_SIZE_M, r.maxY - r.minY);
|
text: string,
|
||||||
// `at` ist die Baseline-links der ERSTEN Zeile (s. drawing2d.ts/primitives.tsx);
|
ctx: CommandContext,
|
||||||
// die Rahmen-Oberkante liegt eine Zeilenhöhe darüber → at.y = Rahmen-Top − Zeilenhöhe.
|
): Project {
|
||||||
return { at: { x: r.minX, y: r.maxY - DEFAULT_TEXT_HEIGHT_M }, width, heightM };
|
const label = text.trim();
|
||||||
}
|
if (label === "") return p;
|
||||||
|
|
||||||
/** Vorschau: Rahmen-Rechteck zwischen den beiden Ecken. */
|
|
||||||
function frameDraft(a: Vec2, cursor: Vec2): { preview: DraftShape[]; vertices: Vec2[]; hud: { at: Vec2; text: string } } {
|
|
||||||
const r = rectOf(a, cursor);
|
|
||||||
const pts: Vec2[] = [
|
|
||||||
{ x: r.minX, y: r.minY },
|
|
||||||
{ x: r.maxX, y: r.minY },
|
|
||||||
{ x: r.maxX, y: r.maxY },
|
|
||||||
{ x: r.minX, y: r.maxY },
|
|
||||||
];
|
|
||||||
const w = Math.max(MIN_SIZE_M, r.maxX - r.minX);
|
|
||||||
const h = Math.max(MIN_SIZE_M, r.maxY - r.minY);
|
|
||||||
return {
|
|
||||||
preview: [{ kind: "poly", pts, closed: true }],
|
|
||||||
vertices: [a, cursor],
|
|
||||||
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendEmptyTextbox(p: Project, id: string, at: Vec2, width: number, ctx: CommandContext): Project {
|
|
||||||
const d: Drawing2D = {
|
const d: Drawing2D = {
|
||||||
id,
|
id: uniqueId("dr2d"),
|
||||||
type: "drawing2d",
|
type: "drawing2d",
|
||||||
levelId: ctx.level.id,
|
levelId: ctx.level.id,
|
||||||
categoryCode: ctx.defaultCategoryCode,
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
geom: { shape: "text", at, text: "", height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
|
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
|
||||||
};
|
};
|
||||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const idle = (): [CommandState, CommandResult] => [
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
{ phase: "first", lastPoint: null } as TbFirst,
|
{ phase: "point", lastPoint: null } as TbPoint,
|
||||||
{ draft: null, done: true },
|
{ draft: null, done: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const textboxCommand: Command = {
|
export const textboxCommand: Command = {
|
||||||
name: "textbox",
|
name: "textbox",
|
||||||
labelKey: "cmd.textbox.label",
|
labelKey: "cmd.textbox.label",
|
||||||
prompt: (s) => ((s as TbState).phase === "second" ? "cmd.textbox.second" : "cmd.textbox.first"),
|
prompt: (s) => {
|
||||||
accepts: () => ["point"],
|
const ts = s as TbState;
|
||||||
|
if (ts.phase === "label") return "cmd.textbox.enter";
|
||||||
|
if (ts.phase === "width") return "cmd.textbox.width";
|
||||||
|
return "cmd.textbox.point";
|
||||||
|
},
|
||||||
|
accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]),
|
||||||
options: () => [],
|
options: () => [],
|
||||||
init: (): TbFirst => ({ phase: "first", lastPoint: null }),
|
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
|
||||||
|
|
||||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
const s = state as TbState;
|
const s = state as TbState;
|
||||||
|
if (s.phase === "point") {
|
||||||
if (input.kind !== "point") return [s, { draft: null }];
|
if (input.kind !== "point") return [s, { draft: null }];
|
||||||
if (s.phase === "first") {
|
const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point };
|
||||||
const ns: TbSecond = { phase: "second", a: input.point, cursor: input.point, lastPoint: input.point };
|
return [ns, { draft: widthDraft(input.point, input.point) }];
|
||||||
return [ns, { draft: { preview: [], vertices: [input.point] } }];
|
|
||||||
}
|
}
|
||||||
// phase === "second"
|
if (s.phase === "width") {
|
||||||
const { at, width, heightM } = frameOf(s.a, input.point);
|
if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }];
|
||||||
const id = uniqueId("dr2d");
|
const width = widthOf(s.at, input.point);
|
||||||
|
const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point };
|
||||||
|
return [ns, { draft: null }];
|
||||||
|
}
|
||||||
|
// label
|
||||||
|
if (input.kind !== "text") return [s, { draft: null }];
|
||||||
|
const { at, width } = s;
|
||||||
return [
|
return [
|
||||||
{ phase: "first", lastPoint: null } as TbFirst,
|
{ phase: "point", lastPoint: null } as TbPoint,
|
||||||
{
|
{ draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) },
|
||||||
draft: null,
|
|
||||||
done: true,
|
|
||||||
commit: (p) => appendEmptyTextbox(p, id, at, width, ctx),
|
|
||||||
focusDrawingId: id,
|
|
||||||
focusMinHeightM: heightM,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
onMove: (state, point): [CommandState, CommandResult] => {
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
const s = state as TbState;
|
const s = state as TbState;
|
||||||
if (s.phase !== "second") return [s, { draft: null }];
|
if (s.phase !== "width") return [s, { draft: null }];
|
||||||
const ns: TbSecond = { ...s, cursor: point };
|
const ns: TbWidth = { ...s, cursor: point };
|
||||||
return [ns, { draft: frameDraft(s.a, point) }];
|
return [ns, { draft: widthDraft(s.at, point) }];
|
||||||
},
|
},
|
||||||
|
|
||||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
|||||||
@@ -52,12 +52,6 @@ export interface EngineHost {
|
|||||||
commit(mutate: (p: Project) => Project): void;
|
commit(mutate: (p: Project) => Project): void;
|
||||||
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
|
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
|
||||||
isFloor(): boolean;
|
isFloor(): boolean;
|
||||||
/**
|
|
||||||
* Optional: ein frisch erzeugtes Drawing2D-Element fokussieren (Passthrough
|
|
||||||
* von `CommandResult.focusDrawingId`/`focusMinHeightM`, z. B. um sofort den
|
|
||||||
* Inline-Text-Editor zu öffnen). Fehlt der Host-Handler, ist das ein No-op.
|
|
||||||
*/
|
|
||||||
focusDrawing?(drawingId: string, minHeightM?: number): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
|
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
|
||||||
@@ -174,17 +168,6 @@ export class CommandEngine {
|
|||||||
return this.currentFields().length > 0;
|
return this.currentFields().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Erwartet der aktuelle Schritt freien Text (z. B. das Textwerkzeug-Label)?
|
|
||||||
* Dort ist JEDE Taste — auch Ziffern — literaler Inhalt, im Unterschied zu
|
|
||||||
* einem reinen Punkt-Pick-Schritt (nur „point"/„number", keine Felder), wo
|
|
||||||
* Ziffern ansonsten ungenutzt sind.
|
|
||||||
*/
|
|
||||||
acceptsFreeText(): boolean {
|
|
||||||
if (!this.command || !this.state) return false;
|
|
||||||
return this.command.accepts(this.state).includes("text");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
|
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
|
||||||
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
|
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
|
||||||
@@ -482,7 +465,6 @@ export class CommandEngine {
|
|||||||
|
|
||||||
private applyResult(res: CommandResult): void {
|
private applyResult(res: CommandResult): void {
|
||||||
if (res.commit) this.host.commit(res.commit);
|
if (res.commit) this.host.commit(res.commit);
|
||||||
if (res.focusDrawingId) this.host.focusDrawing?.(res.focusDrawingId, res.focusMinHeightM);
|
|
||||||
if (res.draft) this.host.setDraft(res.draft);
|
if (res.draft) this.host.setDraft(res.draft);
|
||||||
else this.host.setDraft(null);
|
else this.host.setDraft(null);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ import { copyCommand } from "./cmds/copy";
|
|||||||
import { offsetCommand } from "./cmds/offset";
|
import { offsetCommand } from "./cmds/offset";
|
||||||
import { extrudeCommand } from "./cmds/extrude";
|
import { extrudeCommand } from "./cmds/extrude";
|
||||||
import { trimCommand } from "./cmds/trim";
|
import { trimCommand } from "./cmds/trim";
|
||||||
import { extendCommand } from "./cmds/extend";
|
|
||||||
import { filletCommand } from "./cmds/fillet";
|
|
||||||
import { chamferCommand } from "./cmds/chamfer";
|
|
||||||
import { multilineCommand } from "./cmds/multiline";
|
|
||||||
import { importCommand } from "./cmds/import";
|
import { importCommand } from "./cmds/import";
|
||||||
import { terrainCommand } from "./cmds/terrain";
|
import { terrainCommand } from "./cmds/terrain";
|
||||||
import { measureCommand } from "./cmds/measure";
|
import { measureCommand } from "./cmds/measure";
|
||||||
@@ -62,10 +58,6 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
offset: offsetCommand,
|
offset: offsetCommand,
|
||||||
extrude: extrudeCommand,
|
extrude: extrudeCommand,
|
||||||
trim: trimCommand,
|
trim: trimCommand,
|
||||||
extend: extendCommand,
|
|
||||||
fillet: filletCommand,
|
|
||||||
chamfer: chamferCommand,
|
|
||||||
multiline: multilineCommand,
|
|
||||||
import: importCommand,
|
import: importCommand,
|
||||||
terrain: terrainCommand,
|
terrain: terrainCommand,
|
||||||
measure: measureCommand,
|
measure: measureCommand,
|
||||||
@@ -116,15 +108,6 @@ export const ALIASES: Record<string, string> = {
|
|||||||
o: "offset",
|
o: "offset",
|
||||||
ex: "extrude",
|
ex: "extrude",
|
||||||
tr: "trim",
|
tr: "trim",
|
||||||
ext: "extend",
|
|
||||||
verlaengern: "extend",
|
|
||||||
verlängern: "extend",
|
|
||||||
fi: "fillet",
|
|
||||||
verrunden: "fillet",
|
|
||||||
ch: "chamfer",
|
|
||||||
fasen: "chamfer",
|
|
||||||
ml: "multiline",
|
|
||||||
parallel: "multiline",
|
|
||||||
imp: "import",
|
imp: "import",
|
||||||
ter: "terrain",
|
ter: "terrain",
|
||||||
schnittlinie: "sectionline",
|
schnittlinie: "sectionline",
|
||||||
|
|||||||
@@ -109,20 +109,6 @@ export interface CommandResult {
|
|||||||
commit?: (p: Project) => Project;
|
commit?: (p: Project) => Project;
|
||||||
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
|
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
/**
|
|
||||||
* Bei Abschluss: die Engine soll dieses neu erzeugte Drawing2D-Element
|
|
||||||
* sofort fokussieren (z. B. Inline-Text-Editor öffnen, wie `text`/`textbox`
|
|
||||||
* nach dem Platzieren). Reiner Passthrough an `EngineHost.focusDrawing`;
|
|
||||||
* die Engine selbst wertet die ID nicht aus.
|
|
||||||
*/
|
|
||||||
focusDrawingId?: string;
|
|
||||||
/**
|
|
||||||
* Nur zusammen mit `focusDrawingId`: die vom Nutzer aufgezogene Rahmenhöhe
|
|
||||||
* (Meter) als MINDESTHöhe des Inline-Editors (InDesign-Textrahmen — wächst
|
|
||||||
* bei Bedarf über diese Höhe hinaus, schrumpft aber nicht darunter). Fehlt
|
|
||||||
* sie, bestimmt allein der Inhalt die Höhe (Text-Werkzeug ohne Rahmen).
|
|
||||||
*/
|
|
||||||
focusMinHeightM?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
|
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 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).
|
||||||
|
|
||||||
|
import type { Project, Wall } from "../model/types";
|
||||||
|
import { getWallType, wallTypeThickness } from "../model/types";
|
||||||
|
import { wallReferenceOffset } from "../model/wall";
|
||||||
|
import { computeJoins as computeJoinsTS } from "../model/joins";
|
||||||
|
import type { WallCuts } from "../model/joins";
|
||||||
|
import type { Line } from "../model/geometry";
|
||||||
|
import { detectRooms as detectRoomsTS } from "../geometry/roomBoundary";
|
||||||
|
import type { WallSegment, DetectRoomsOptions } from "../geometry/roomBoundary";
|
||||||
|
import { parseDwg as parseDwgTS } from "../io/dwgParser";
|
||||||
|
import type { DxfImportResult } from "../io/dxfParser";
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
|
||||||
|
// Tauri-invoke, wenn vorhanden — sonst null (Browser/kein Tauri). Dynamischer,
|
||||||
|
// GEGUARDETER Import, damit der Build ohne @tauri-apps/api grün bleibt.
|
||||||
|
async function tauriInvoke<T>(cmd: string, args: Record<string, unknown>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
// @ts-ignore optionales Paket
|
||||||
|
const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null);
|
||||||
|
if (!mod || typeof (mod as any).invoke !== "function") return null;
|
||||||
|
return await (mod as any).invoke(cmd, args) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialisierbare Wand-Repräsentation für den Rust-Kern. */
|
||||||
|
interface FlatWall {
|
||||||
|
id: string;
|
||||||
|
start: Vec2;
|
||||||
|
end: Vec2;
|
||||||
|
thickness: number;
|
||||||
|
referenceOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rohantwort des Rust-Befehls `compute_joins`. */
|
||||||
|
interface RustWallCut {
|
||||||
|
wallId: string;
|
||||||
|
startCut: Line | null;
|
||||||
|
endCut: Line | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gehrungs-Schnittlinien pro Wand. Unter Tauri via Rust-Kern, sonst TS-Fallback.
|
||||||
|
* Die Signatur ist synchron-kompatibel zur TS-Impl, aber async (Rust-Aufruf).
|
||||||
|
*/
|
||||||
|
export async function computeJoins(
|
||||||
|
project: Project,
|
||||||
|
walls: Wall[],
|
||||||
|
): Promise<Map<string, WallCuts>> {
|
||||||
|
try {
|
||||||
|
const flattened: FlatWall[] = walls.map((w) => {
|
||||||
|
const thickness = wallTypeThickness(getWallType(project, w));
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
start: w.start,
|
||||||
|
end: w.end,
|
||||||
|
thickness,
|
||||||
|
referenceOffset: wallReferenceOffset(w, thickness),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await tauriInvoke<RustWallCut[]>("compute_joins", {
|
||||||
|
input: { walls: flattened },
|
||||||
|
});
|
||||||
|
if (res != null) {
|
||||||
|
const map = new Map<string, WallCuts>();
|
||||||
|
for (const c of res) {
|
||||||
|
map.set(c.wallId, { startCut: c.startCut, endCut: c.endCut });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fällt unten in den TS-Pfad
|
||||||
|
}
|
||||||
|
console.warn("Rust compute_joins nicht verfügbar, TS-Fallback");
|
||||||
|
return computeJoinsTS(project, walls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass-throughs (noch reines TS; bereit für spätere Rust-Migration) ─────────
|
||||||
|
|
||||||
|
/** Raumerkennung aus Wandsegmenten. Aktuell TS; Grenze für spätere Migration. */
|
||||||
|
export async function detectRooms(
|
||||||
|
walls: WallSegment[],
|
||||||
|
options: DetectRoomsOptions = {},
|
||||||
|
): Promise<Vec2[][]> {
|
||||||
|
return detectRoomsTS(walls, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DWG-Import (LibreDWG-WASM). Aktuell TS; Grenze für spätere Migration. */
|
||||||
|
export async function parseShapeFromDwg(
|
||||||
|
data: ArrayBuffer,
|
||||||
|
): Promise<DxfImportResult> {
|
||||||
|
return parseDwgTS(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,12 +12,10 @@
|
|||||||
|
|
||||||
import { jsPDF } from "jspdf";
|
import { jsPDF } from "jspdf";
|
||||||
import "svg2pdf.js";
|
import "svg2pdf.js";
|
||||||
import type { Layout, LayoutAnnotation, MasterLayout, Project } from "../model/types";
|
import type { MasterLayout, Project } from "../model/types";
|
||||||
import { generatePlan } from "../plan/generatePlan";
|
import { generatePlan } from "../plan/generatePlan";
|
||||||
import { planToPrintSvg } from "./planToPrintSvg";
|
import { planToPrintSvg } from "./planToPrintSvg";
|
||||||
import { saveBinaryFile } from "../io/saveFile";
|
import { saveBinaryFile } from "../io/saveFile";
|
||||||
import { wrapDocToLines, type SvgLine } from "../text/renderHtml";
|
|
||||||
import { docFromText } from "../text/richText";
|
|
||||||
import {
|
import {
|
||||||
findMaster,
|
findMaster,
|
||||||
findSnapshot,
|
findSnapshot,
|
||||||
@@ -79,133 +77,6 @@ function clip(s: string, max: number): string {
|
|||||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
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. */
|
/** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */
|
||||||
async function renderPage(
|
async function renderPage(
|
||||||
doc: jsPDF,
|
doc: jsPDF,
|
||||||
@@ -257,18 +128,6 @@ async function renderPage(
|
|||||||
const tb = resolveTitleBlock(project, layout, master);
|
const tb = resolveTitleBlock(project, layout, master);
|
||||||
drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation);
|
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,8 +25,6 @@ import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
|||||||
import type { Vec2 } from "../model/types";
|
import type { Vec2 } from "../model/types";
|
||||||
import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch";
|
import { buildRandomHatchRuns, zigzagPoints, motifPoints } from "../plan/glPlan/glPlanHatch";
|
||||||
import { dashHasDot } from "../ui/lineSegments";
|
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. */
|
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
@@ -256,111 +254,7 @@ function appendPrimitive(
|
|||||||
svg.appendChild(node);
|
svg.appendChild(node);
|
||||||
break;
|
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,7 +25,6 @@
|
|||||||
import type {
|
import type {
|
||||||
RArc,
|
RArc,
|
||||||
RFill,
|
RFill,
|
||||||
RImage,
|
|
||||||
RLine,
|
RLine,
|
||||||
ROutline,
|
ROutline,
|
||||||
RPoint,
|
RPoint,
|
||||||
@@ -143,11 +142,6 @@ export function sceneToPrintSvg(scene: RScene, opts: PrintSceneOptions): PrintSc
|
|||||||
bg.setAttribute("fill", "#ffffff");
|
bg.setAttribute("fill", "#ffffff");
|
||||||
svg.appendChild(bg);
|
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
|
// ── Maler-Reihenfolge: Fuellungen/Umrisse/Polylinien/Linien gemeinsam nach
|
||||||
// `z` sortieren (stabiler Sort => Einfuegereihenfolge bricht Gleichstaende
|
// `z` sortieren (stabiler Sort => Einfuegereihenfolge bricht Gleichstaende
|
||||||
// genau wie `seq` in `compile_scene`).
|
// genau wie `seq` in `compile_scene`).
|
||||||
@@ -325,25 +319,6 @@ function appendArc(svg: SVGSVGElement, a: RArc, map: Mapper, mmPerM: number): vo
|
|||||||
svg.appendChild(el);
|
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). */
|
/** SVG-Schriftfamilie fuer Plan-Beschriftung (Vektor-Text im PDF). */
|
||||||
const TEXT_FONT_FAMILY = "Helvetica, Arial, sans-serif";
|
const TEXT_FONT_FAMILY = "Helvetica, Arial, sans-serif";
|
||||||
|
|
||||||
|
|||||||
@@ -572,44 +572,6 @@ export function filletCorner(
|
|||||||
return { center, radius: r, tangentA, tangentB, startAngle, endAngle };
|
return { center, radius: r, tangentA, tangentB, startAngle, endAngle };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis einer Eck-Fasung. */
|
|
||||||
export interface Chamfer {
|
|
||||||
/** Punkt auf dem ersten Schenkel (corner→p1), im Abstand `distance` vom Eck. */
|
|
||||||
a: Vec2;
|
|
||||||
/** Punkt auf dem zweiten Schenkel (corner→p2), im Abstand `distance` vom Eck. */
|
|
||||||
b: Vec2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fast die Ecke bei `corner` (Schenkel corner→p1, corner→p2) mit einer geraden
|
|
||||||
* Abschrägung: gleicher Abstand `distance` vom Eck auf BEIDEN Schenkeln,
|
|
||||||
* verbunden durch eine gerade Linie (klassischer Gleichabstands-Chamfer, wie
|
|
||||||
* `filletCorner`, nur ohne Bogen — die Ecke fällt weg, der Schnitt bleibt
|
|
||||||
* gerade). Liefert `null` bei (nahezu) kollinearen Schenkeln oder wenn
|
|
||||||
* `distance` länger als ein Schenkel ist.
|
|
||||||
*/
|
|
||||||
export function chamferCorner(
|
|
||||||
corner: Vec2,
|
|
||||||
p1: Vec2,
|
|
||||||
p2: Vec2,
|
|
||||||
distance: number,
|
|
||||||
): Chamfer | null {
|
|
||||||
const u1 = normalize(sub(p1, corner));
|
|
||||||
const u2 = normalize(sub(p2, corner));
|
|
||||||
const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2)));
|
|
||||||
const theta = Math.acos(cosTheta);
|
|
||||||
if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear
|
|
||||||
if (
|
|
||||||
distance > len(sub(p1, corner)) + EPS ||
|
|
||||||
distance > len(sub(p2, corner)) + EPS
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const a = add(corner, scale(u1, distance));
|
|
||||||
const b = add(corner, scale(u2, distance));
|
|
||||||
return { a, b };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ─────────────────────
|
// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ─────────────────────
|
||||||
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
|
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
|
||||||
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
|
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
|
|||||||
"AGF",
|
"AGF",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
|
||||||
|
export interface SiaLabel {
|
||||||
|
/** Kürzel, z. B. „HNF". */
|
||||||
|
code: SiaKey;
|
||||||
|
/** Ausgeschriebene deutsche Bezeichnung. */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const SIA_LABELS: Record<SiaKey, string> = {
|
const SIA_LABELS: Record<SiaKey, string> = {
|
||||||
HNF: "Hauptnutzfläche",
|
HNF: "Hauptnutzfläche",
|
||||||
NNF: "Nebennutzfläche",
|
NNF: "Nebennutzfläche",
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export const de = {
|
|||||||
"menu.native.view": "Ansicht",
|
"menu.native.view": "Ansicht",
|
||||||
"menu.native.window": "Fenster",
|
"menu.native.window": "Fenster",
|
||||||
"menu.native.panels": "Panels",
|
"menu.native.panels": "Panels",
|
||||||
"menu.native.about": "Über dossier",
|
"menu.native.about": "Über Dossier",
|
||||||
"menu.native.quit": "Beenden",
|
"menu.native.quit": "Beenden",
|
||||||
|
|
||||||
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
||||||
@@ -182,11 +182,6 @@ export const de = {
|
|||||||
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
||||||
"tool.textbox": "Textspalte",
|
"tool.textbox": "Textspalte",
|
||||||
"tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen",
|
"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-Oberleiste (Tabs + Gruppen)
|
||||||
"ribbon.tab.2d": "2D",
|
"ribbon.tab.2d": "2D",
|
||||||
"ribbon.tab.3d": "3D",
|
"ribbon.tab.3d": "3D",
|
||||||
@@ -305,7 +300,6 @@ export const de = {
|
|||||||
"attr.stroke": "Stift",
|
"attr.stroke": "Stift",
|
||||||
"attr.color": "Farbe",
|
"attr.color": "Farbe",
|
||||||
"attr.weight": "Strichstärke (mm)",
|
"attr.weight": "Strichstärke (mm)",
|
||||||
"attr.opacity": "Deckkraft",
|
|
||||||
"attr.lineStyle": "Linienstil",
|
"attr.lineStyle": "Linienstil",
|
||||||
"attr.lineStyle.none": "Standard",
|
"attr.lineStyle.none": "Standard",
|
||||||
"attr.fill": "Füllung",
|
"attr.fill": "Füllung",
|
||||||
@@ -350,8 +344,6 @@ export const de = {
|
|||||||
"objinfo.kind.column": "Stütze",
|
"objinfo.kind.column": "Stütze",
|
||||||
"objinfo.kind.roof": "Dach",
|
"objinfo.kind.roof": "Dach",
|
||||||
"objinfo.kind.contextObject": "Kontext-Objekt",
|
"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) ────────────────────────
|
// ── Kontext-Objekt-Attribute (Object-Info-Panel) ────────────────────────
|
||||||
"objinfo.contextObject.section": "Georeferenzierung",
|
"objinfo.contextObject.section": "Georeferenzierung",
|
||||||
"objinfo.contextObject.type": "Art",
|
"objinfo.contextObject.type": "Art",
|
||||||
@@ -1141,9 +1133,11 @@ export const de = {
|
|||||||
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
||||||
"cmd.text.label": "Text",
|
"cmd.text.label": "Text",
|
||||||
"cmd.text.point": "Ankerpunkt:",
|
"cmd.text.point": "Ankerpunkt:",
|
||||||
|
"cmd.text.enter": "Text eingeben:",
|
||||||
"cmd.textbox.label": "Textspalte",
|
"cmd.textbox.label": "Textspalte",
|
||||||
"cmd.textbox.first": "Erste Ecke:",
|
"cmd.textbox.point": "Ankerpunkt:",
|
||||||
"cmd.textbox.second": "Gegenecke (Breite × Höhe):",
|
"cmd.textbox.width": "Spaltenbreite (zweiter Punkt):",
|
||||||
|
"cmd.textbox.enter": "Text eingeben:",
|
||||||
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
||||||
"cmd.move.label": "Verschieben",
|
"cmd.move.label": "Verschieben",
|
||||||
"cmd.move.base": "Basispunkt:",
|
"cmd.move.base": "Basispunkt:",
|
||||||
@@ -1180,23 +1174,6 @@ export const de = {
|
|||||||
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
||||||
"cmd.trim.label": "Stutzen",
|
"cmd.trim.label": "Stutzen",
|
||||||
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
||||||
// Extend: Gegenstück zu Trim — verlängert eine offene Kurve bis zur nächsten anderen Kurve.
|
|
||||||
"cmd.extend.label": "Verlängern",
|
|
||||||
"cmd.extend.pick": "Kurve nahe dem zu verlängernden Ende anklicken (Esc beendet):",
|
|
||||||
// Fillet: Eckpunkt-Verrundung mit Radius (Maus oder getippt).
|
|
||||||
"cmd.fillet.label": "Verrunden",
|
|
||||||
"cmd.fillet.pick": "Ecke wählen:",
|
|
||||||
"cmd.fillet.radius": "Radius:",
|
|
||||||
// Chamfer: Eckpunkt-Fasung mit Distanz (Maus oder getippt) — wie Fillet, ohne Bogen.
|
|
||||||
"cmd.chamfer.label": "Fasen",
|
|
||||||
"cmd.chamfer.pick": "Ecke wählen:",
|
|
||||||
"cmd.chamfer.distance": "Distanz:",
|
|
||||||
// Multiline: Basislinie + N-1 parallele Kopien in einer Geste (baut auf Offset auf).
|
|
||||||
"cmd.multiline.label": "Parallellinien",
|
|
||||||
"cmd.multiline.start": "Startpunkt:",
|
|
||||||
"cmd.multiline.end": "Endpunkt:",
|
|
||||||
"cmd.multiline.spacing": "Abstand/Seite (Option Anzahl zyklt die Linienzahl):",
|
|
||||||
"cmd.multiline.count": "Anzahl",
|
|
||||||
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
|
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
|
||||||
// Alt+Klick). Labels für Befehlsleiste/Menüs.
|
// Alt+Klick). Labels für Befehlsleiste/Menüs.
|
||||||
"edit.split.label": "Teilen",
|
"edit.split.label": "Teilen",
|
||||||
@@ -1341,22 +1318,12 @@ export const de = {
|
|||||||
"exportSave.fmt.stl": "STL (3D-Mesh)",
|
"exportSave.fmt.stl": "STL (3D-Mesh)",
|
||||||
"about.title": "Über dossier",
|
"about.title": "Über dossier",
|
||||||
"about.version": "Version",
|
"about.version": "Version",
|
||||||
"about.desc": "Open Source Computer Aided Architecture Design — 2D-Vektordesign sowie normgerechte Pläne aus einem semantischen Gebäudemodell.",
|
"about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.",
|
||||||
"about.copyright": "© 2026 Karim Gabriele Varano",
|
"about.copyright": "© 2026 Karim Gabriele Varano",
|
||||||
"about.license": "Lizenz: AGPL-3.0-or-later",
|
"about.license": "Lizenz: AGPL-3.0-or-later",
|
||||||
"about.suite": "Teil von openbureau",
|
"about.suite": "Teil der openbureau-Suite",
|
||||||
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
|
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
|
||||||
"about.close": "Schließen",
|
"about.close": "Schließen",
|
||||||
"about.checkUpdate": "Nach Updates suchen",
|
|
||||||
"about.checkingUpdate": "Suche nach Updates …",
|
|
||||||
|
|
||||||
"update.title": "Update",
|
|
||||||
"update.upToDate": "Du hast bereits die aktuellste Version.",
|
|
||||||
"update.availableBody": "Version {version} ist verfügbar. Jetzt herunterladen und installieren? Die App startet danach neu.",
|
|
||||||
"update.installNow": "Installieren",
|
|
||||||
"update.installLater": "Später",
|
|
||||||
"update.checkFailed": "Update-Prüfung fehlgeschlagen: {error}",
|
|
||||||
"update.installFailed": "Installation fehlgeschlagen: {error}",
|
|
||||||
|
|
||||||
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
|
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
|
||||||
"topbar.settings": "Einstellungen",
|
"topbar.settings": "Einstellungen",
|
||||||
@@ -1377,19 +1344,6 @@ export const de = {
|
|||||||
"settings.referenceElevation": "Referenzhöhe EG (m ü. M.)",
|
"settings.referenceElevation": "Referenzhöhe EG (m ü. M.)",
|
||||||
"settings.referenceElevation.hint":
|
"settings.referenceElevation.hint":
|
||||||
"Nur gespeichert — wird noch nicht verwendet. Folgt für Terrain-Draping/reale Höhen relativ zum Erdgeschoss.",
|
"Nur gespeichert — wird noch nicht verwendet. Folgt für Terrain-Draping/reale Höhen relativ zum Erdgeschoss.",
|
||||||
"settings.section.update": "Update",
|
|
||||||
"settings.update.autoCheck": "Automatisch nach Updates suchen",
|
|
||||||
"settings.update.snooze.active": "Aktiv",
|
|
||||||
"settings.update.snooze.1day": "1 Tag pausieren",
|
|
||||||
"settings.update.snooze.1week": "1 Woche pausieren",
|
|
||||||
"settings.update.snooze.1month": "1 Monat pausieren",
|
|
||||||
"settings.update.snoozedUntil": "Pausiert bis {date}. Manuelles Suchen funktioniert weiterhin.",
|
|
||||||
"settings.update.versionHistory": "Frühere Versionen",
|
|
||||||
"settings.update.versionHistory.hint":
|
|
||||||
"Öffnet den Download im Browser — Installation bleibt manuell (ersetzt die aktuelle Version, wie beim ersten Installieren).",
|
|
||||||
"settings.update.loadingHistory": "Lade Versionsliste …",
|
|
||||||
"settings.update.noHistory": "Keine früheren Versionen verfügbar.",
|
|
||||||
"settings.update.download": "Herunterladen",
|
|
||||||
|
|
||||||
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
|
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
|
||||||
"drop.hint": "DXF/DWG-Datei hier ablegen",
|
"drop.hint": "DXF/DWG-Datei hier ablegen",
|
||||||
@@ -1592,7 +1546,6 @@ export const de = {
|
|||||||
// Editor
|
// Editor
|
||||||
"layouts.editorTitle": "Layout-Editor",
|
"layouts.editorTitle": "Layout-Editor",
|
||||||
"layouts.fit": "Einpassen",
|
"layouts.fit": "Einpassen",
|
||||||
"layouts.zoom100": "Echte Grösse (100 %, 1:1 auf dem Bildschirm)",
|
|
||||||
"layouts.addViewport": "Viewport hinzufügen",
|
"layouts.addViewport": "Viewport hinzufügen",
|
||||||
"layouts.pickSnapshot": "Ausschnitt wählen …",
|
"layouts.pickSnapshot": "Ausschnitt wählen …",
|
||||||
"layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.",
|
"layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.",
|
||||||
@@ -1613,21 +1566,19 @@ export const de = {
|
|||||||
"layouts.bindSnapshot": "Ausschnitt binden",
|
"layouts.bindSnapshot": "Ausschnitt binden",
|
||||||
"layouts.emptyViewports":
|
"layouts.emptyViewports":
|
||||||
"Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.",
|
"Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.",
|
||||||
// Annotationen (Linie/Rechteck/Text/Bild) direkt auf dem Blatt — gezeichnet
|
// Annotationen (Linie/Rechteck/Text) direkt auf dem Blatt
|
||||||
// mit denselben Werkzeugen wie im Grundriss (linkes Werkzeugpanel), kein
|
"layouts.annotLine": "Linie zeichnen",
|
||||||
// eigenes Werkzeug nur fürs Layout.
|
"layouts.annotRect": "Rechteck zeichnen",
|
||||||
|
"layouts.annotText": "Text setzen",
|
||||||
"layouts.annotTextTitle": "Text",
|
"layouts.annotTextTitle": "Text",
|
||||||
"layouts.annotTextLabel": "Textinhalt:",
|
"layouts.annotTextLabel": "Textinhalt:",
|
||||||
"layouts.annotEditText": "Text ändern",
|
"layouts.annotEditText": "Text ändern",
|
||||||
|
"layouts.annotColor": "Farbe",
|
||||||
|
"layouts.annotWeight": "Strichstärke (mm)",
|
||||||
"layouts.annotRemove": "Annotation entfernen",
|
"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.line": "Linie",
|
||||||
"layouts.annot.rect": "Rechteck",
|
"layouts.annot.rect": "Rechteck",
|
||||||
"layouts.annot.text": "Text",
|
"layouts.annot.text": "Text",
|
||||||
"layouts.annot.image": "Bild",
|
|
||||||
// Baum / „+"-Menü / Ordner
|
// Baum / „+"-Menü / Ordner
|
||||||
"layouts.add": "Neu …",
|
"layouts.add": "Neu …",
|
||||||
"layouts.addFolder": "Ordner erstellen",
|
"layouts.addFolder": "Ordner erstellen",
|
||||||
@@ -1662,15 +1613,6 @@ export const de = {
|
|||||||
"layouts.size.widthMm": "Breite (mm)",
|
"layouts.size.widthMm": "Breite (mm)",
|
||||||
"layouts.size.heightMm": "Höhe (mm)",
|
"layouts.size.heightMm": "Höhe (mm)",
|
||||||
"layouts.size.create": "Erstellen",
|
"layouts.size.create": "Erstellen",
|
||||||
|
|
||||||
// Startbildschirm (nach dem Splash, vor dem Editor)
|
|
||||||
"start.newProject": "Neues Projekt",
|
|
||||||
"start.newProject.hint": "Leeres Projekt anlegen",
|
|
||||||
"start.openProject": "Projekt öffnen …",
|
|
||||||
"start.openProject.hint": "Bestehende .obp-Datei auswählen",
|
|
||||||
"start.recent": "Zuletzt geöffnet",
|
|
||||||
"start.recent.remove": "Aus der Liste entfernen",
|
|
||||||
"start.recentMissing": "Datei nicht gefunden — aus der Liste entfernt.",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof de;
|
export type TranslationKey = keyof typeof de;
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"menu.native.view": "View",
|
"menu.native.view": "View",
|
||||||
"menu.native.window": "Window",
|
"menu.native.window": "Window",
|
||||||
"menu.native.panels": "Panels",
|
"menu.native.panels": "Panels",
|
||||||
"menu.native.about": "About dossier",
|
"menu.native.about": "About Dossier",
|
||||||
"menu.native.quit": "Quit",
|
"menu.native.quit": "Quit",
|
||||||
|
|
||||||
// Layout menu.
|
// Layout menu.
|
||||||
@@ -180,11 +180,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"tool.text.hint": "Text: set anchor point, then type the text",
|
"tool.text.hint": "Text: set anchor point, then type the text",
|
||||||
"tool.textbox": "Text column",
|
"tool.textbox": "Text column",
|
||||||
"tool.textbox.hint": "Text column: anchor, drag column width, then type the text",
|
"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 top bar (tabs + groups)
|
||||||
"ribbon.tab.2d": "2D",
|
"ribbon.tab.2d": "2D",
|
||||||
"ribbon.tab.3d": "3D",
|
"ribbon.tab.3d": "3D",
|
||||||
@@ -303,7 +298,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"attr.stroke": "Pen",
|
"attr.stroke": "Pen",
|
||||||
"attr.color": "Color",
|
"attr.color": "Color",
|
||||||
"attr.weight": "Line weight (mm)",
|
"attr.weight": "Line weight (mm)",
|
||||||
"attr.opacity": "Opacity",
|
|
||||||
"attr.lineStyle": "Line style",
|
"attr.lineStyle": "Line style",
|
||||||
"attr.lineStyle.none": "Default",
|
"attr.lineStyle.none": "Default",
|
||||||
"attr.fill": "Fill",
|
"attr.fill": "Fill",
|
||||||
@@ -348,8 +342,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.kind.column": "Column",
|
"objinfo.kind.column": "Column",
|
||||||
"objinfo.kind.roof": "Roof",
|
"objinfo.kind.roof": "Roof",
|
||||||
"objinfo.kind.contextObject": "Context object",
|
"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) ───────────────────────
|
// ── Context object attributes (object info panel) ───────────────────────
|
||||||
"objinfo.contextObject.section": "Georeferencing",
|
"objinfo.contextObject.section": "Georeferencing",
|
||||||
"objinfo.contextObject.type": "Type",
|
"objinfo.contextObject.type": "Type",
|
||||||
@@ -1130,9 +1122,11 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.arc.end": "End point (end angle):",
|
"cmd.arc.end": "End point (end angle):",
|
||||||
"cmd.text.label": "Text",
|
"cmd.text.label": "Text",
|
||||||
"cmd.text.point": "Anchor point:",
|
"cmd.text.point": "Anchor point:",
|
||||||
|
"cmd.text.enter": "Enter text:",
|
||||||
"cmd.textbox.label": "Text column",
|
"cmd.textbox.label": "Text column",
|
||||||
"cmd.textbox.first": "First corner:",
|
"cmd.textbox.point": "Anchor point:",
|
||||||
"cmd.textbox.second": "Opposite corner (width × height):",
|
"cmd.textbox.width": "Column width (second point):",
|
||||||
|
"cmd.textbox.enter": "Enter text:",
|
||||||
"cmd.move.label": "Move",
|
"cmd.move.label": "Move",
|
||||||
"cmd.move.base": "Base point:",
|
"cmd.move.base": "Base point:",
|
||||||
"cmd.move.target": "Target point:",
|
"cmd.move.target": "Target point:",
|
||||||
@@ -1168,23 +1162,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
||||||
"cmd.trim.label": "Trim",
|
"cmd.trim.label": "Trim",
|
||||||
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
|
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
|
||||||
// Extend: counterpart to Trim — extends an open curve to the next other curve.
|
|
||||||
"cmd.extend.label": "Extend",
|
|
||||||
"cmd.extend.pick": "Click the curve near the end to extend (Esc to finish):",
|
|
||||||
// Fillet: corner rounding with a radius (mouse or typed).
|
|
||||||
"cmd.fillet.label": "Fillet",
|
|
||||||
"cmd.fillet.pick": "Select corner:",
|
|
||||||
"cmd.fillet.radius": "Radius:",
|
|
||||||
// Chamfer: corner bevel with a distance (mouse or typed) — like Fillet, no arc.
|
|
||||||
"cmd.chamfer.label": "Chamfer",
|
|
||||||
"cmd.chamfer.pick": "Select corner:",
|
|
||||||
"cmd.chamfer.distance": "Distance:",
|
|
||||||
// Multiline: base line + N-1 parallel copies in one gesture (builds on Offset).
|
|
||||||
"cmd.multiline.label": "Parallel lines",
|
|
||||||
"cmd.multiline.start": "Start point:",
|
|
||||||
"cmd.multiline.end": "End point:",
|
|
||||||
"cmd.multiline.spacing": "Spacing/side (\"Count\" option cycles line count):",
|
|
||||||
"cmd.multiline.count": "Count",
|
|
||||||
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
|
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
|
||||||
// Alt+Click). Labels for the command line / menus.
|
// Alt+Click). Labels for the command line / menus.
|
||||||
"edit.split.label": "Split",
|
"edit.split.label": "Split",
|
||||||
@@ -1328,22 +1305,12 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"exportSave.fmt.stl": "STL (3D mesh)",
|
"exportSave.fmt.stl": "STL (3D mesh)",
|
||||||
"about.title": "About dossier",
|
"about.title": "About dossier",
|
||||||
"about.version": "Version",
|
"about.version": "Version",
|
||||||
"about.desc": "Open Source Computer Aided Architecture Design — 2D vector design plus standards-compliant drawings from a semantic building model.",
|
"about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.",
|
||||||
"about.copyright": "© 2026 Karim Gabriele Varano",
|
"about.copyright": "© 2026 Karim Gabriele Varano",
|
||||||
"about.license": "License: AGPL-3.0-or-later",
|
"about.license": "License: AGPL-3.0-or-later",
|
||||||
"about.suite": "Part of openbureau",
|
"about.suite": "Part of the openbureau suite",
|
||||||
"about.licenses": "Open-source licenses (third-party)",
|
"about.licenses": "Open-source licenses (third-party)",
|
||||||
"about.close": "Close",
|
"about.close": "Close",
|
||||||
"about.checkUpdate": "Check for updates",
|
|
||||||
"about.checkingUpdate": "Checking for updates …",
|
|
||||||
|
|
||||||
"update.title": "Update",
|
|
||||||
"update.upToDate": "You already have the latest version.",
|
|
||||||
"update.availableBody": "Version {version} is available. Download and install now? The app will restart afterwards.",
|
|
||||||
"update.installNow": "Install",
|
|
||||||
"update.installLater": "Later",
|
|
||||||
"update.checkFailed": "Update check failed: {error}",
|
|
||||||
"update.installFailed": "Installation failed: {error}",
|
|
||||||
|
|
||||||
// Settings dialog.
|
// Settings dialog.
|
||||||
"topbar.settings": "Settings",
|
"topbar.settings": "Settings",
|
||||||
@@ -1364,19 +1331,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"settings.referenceElevation": "Ground floor reference elevation (m a.s.l.)",
|
"settings.referenceElevation": "Ground floor reference elevation (m a.s.l.)",
|
||||||
"settings.referenceElevation.hint":
|
"settings.referenceElevation.hint":
|
||||||
"Stored only — not used yet. Terrain draping / real-world elevations relative to the ground floor will follow.",
|
"Stored only — not used yet. Terrain draping / real-world elevations relative to the ground floor will follow.",
|
||||||
"settings.section.update": "Update",
|
|
||||||
"settings.update.autoCheck": "Check for updates automatically",
|
|
||||||
"settings.update.snooze.active": "Active",
|
|
||||||
"settings.update.snooze.1day": "Pause for 1 day",
|
|
||||||
"settings.update.snooze.1week": "Pause for 1 week",
|
|
||||||
"settings.update.snooze.1month": "Pause for 1 month",
|
|
||||||
"settings.update.snoozedUntil": "Paused until {date}. Manual checks still work.",
|
|
||||||
"settings.update.versionHistory": "Earlier versions",
|
|
||||||
"settings.update.versionHistory.hint":
|
|
||||||
"Opens the download in your browser — installation stays manual (replaces the current version, same as the first install).",
|
|
||||||
"settings.update.loadingHistory": "Loading version list …",
|
|
||||||
"settings.update.noHistory": "No earlier versions available.",
|
|
||||||
"settings.update.download": "Download",
|
|
||||||
|
|
||||||
// Drag & drop (app-wide file drop).
|
// Drag & drop (app-wide file drop).
|
||||||
"drop.hint": "Drop a DXF/DWG file here",
|
"drop.hint": "Drop a DXF/DWG file here",
|
||||||
@@ -1579,7 +1533,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
// Editor
|
// Editor
|
||||||
"layouts.editorTitle": "Layout editor",
|
"layouts.editorTitle": "Layout editor",
|
||||||
"layouts.fit": "Fit",
|
"layouts.fit": "Fit",
|
||||||
"layouts.zoom100": "True size (100%, 1:1 on screen)",
|
|
||||||
"layouts.addViewport": "Add viewport",
|
"layouts.addViewport": "Add viewport",
|
||||||
"layouts.pickSnapshot": "Pick a view …",
|
"layouts.pickSnapshot": "Pick a view …",
|
||||||
"layouts.noSnapshots": "No views available. Save a view first.",
|
"layouts.noSnapshots": "No views available. Save a view first.",
|
||||||
@@ -1600,20 +1553,19 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"layouts.bindSnapshot": "Bind snapshot",
|
"layouts.bindSnapshot": "Bind snapshot",
|
||||||
"layouts.emptyViewports":
|
"layouts.emptyViewports":
|
||||||
"No viewports yet. Choose “Draw viewport” and drag a rectangle on the sheet.",
|
"No viewports yet. Choose “Draw viewport” and drag a rectangle on the sheet.",
|
||||||
// Annotations (line/rect/text/image) drawn directly on the sheet — with the
|
// Annotations (line/rect/text) drawn directly on the sheet
|
||||||
// same tools as the plan (left tools panel), no layout-only tool.
|
"layouts.annotLine": "Draw line",
|
||||||
|
"layouts.annotRect": "Draw rectangle",
|
||||||
|
"layouts.annotText": "Place text",
|
||||||
"layouts.annotTextTitle": "Text",
|
"layouts.annotTextTitle": "Text",
|
||||||
"layouts.annotTextLabel": "Text content:",
|
"layouts.annotTextLabel": "Text content:",
|
||||||
"layouts.annotEditText": "Edit text",
|
"layouts.annotEditText": "Edit text",
|
||||||
|
"layouts.annotColor": "Color",
|
||||||
|
"layouts.annotWeight": "Line weight (mm)",
|
||||||
"layouts.annotRemove": "Remove annotation",
|
"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.line": "Line",
|
||||||
"layouts.annot.rect": "Rectangle",
|
"layouts.annot.rect": "Rectangle",
|
||||||
"layouts.annot.text": "Text",
|
"layouts.annot.text": "Text",
|
||||||
"layouts.annot.image": "Image",
|
|
||||||
// Tree / “+” menu / folders
|
// Tree / “+” menu / folders
|
||||||
"layouts.add": "New …",
|
"layouts.add": "New …",
|
||||||
"layouts.addFolder": "Create folder",
|
"layouts.addFolder": "Create folder",
|
||||||
@@ -1648,13 +1600,4 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"layouts.size.widthMm": "Width (mm)",
|
"layouts.size.widthMm": "Width (mm)",
|
||||||
"layouts.size.heightMm": "Height (mm)",
|
"layouts.size.heightMm": "Height (mm)",
|
||||||
"layouts.size.create": "Create",
|
"layouts.size.create": "Create",
|
||||||
|
|
||||||
// Start screen (after the splash, before the editor)
|
|
||||||
"start.newProject": "New project",
|
|
||||||
"start.newProject.hint": "Create a blank project",
|
|
||||||
"start.openProject": "Open project …",
|
|
||||||
"start.openProject.hint": "Choose an existing .obp file",
|
|
||||||
"start.recent": "Recently opened",
|
|
||||||
"start.recent.remove": "Remove from list",
|
|
||||||
"start.recentMissing": "File not found — removed from the list.",
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import Delaunator from "delaunator";
|
import Delaunator from "delaunator";
|
||||||
|
import type { GeoOrigin } from "./lv95";
|
||||||
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
|
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
|
||||||
|
|
||||||
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
||||||
@@ -38,6 +39,12 @@ export interface GeoFeature {
|
|||||||
/** Default-Gebäudehöhe (m), wenn keine Höhe aus der Quelle vorliegt. */
|
/** Default-Gebäudehöhe (m), wenn keine Höhe aus der Quelle vorliegt. */
|
||||||
export const DEFAULT_BUILDING_HEIGHT = 9;
|
export const DEFAULT_BUILDING_HEIGHT = 9;
|
||||||
|
|
||||||
|
/** Ergebnis eines Import-Laufs: Features + verwendete Origin. */
|
||||||
|
export interface GeoImportResult {
|
||||||
|
origin: GeoOrigin;
|
||||||
|
features: GeoFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Menschlicher Layer-/Objektname je Kategorie (deutsch, für die Kontext-Liste). */
|
/** Menschlicher Layer-/Objektname je Kategorie (deutsch, für die Kontext-Liste). */
|
||||||
const CATEGORY_LABEL: Record<GeoCategory, string> = {
|
const CATEGORY_LABEL: Record<GeoCategory, string> = {
|
||||||
building: "Gebäude",
|
building: "Gebäude",
|
||||||
|
|||||||