Compare commits

...

48 Commits

Author SHA1 Message Date
karim 9133c0961d Übergabe-Dokument: truck-Integration Profil-Extrusion (docs/design/truck-plan.md) 2026-07-05 22:54:17 +02:00
karim b640bbe606 Ribbon: feste Höhe 60px statt min-height — kein Springen beim Tab-Wechsel 2026-07-05 22:50:26 +02:00
karim 0b56d777bc Ribbon 3D-Tab: Kamera-Presets + Darstellungsart; truck-Integration geplant 2026-07-07 2026-07-05 22:43:38 +02:00
karim 1c09b6e7c2 Attribute: Aufbau-Umschalter Einschichtig→Solid (kürzer), Mehrschichtig bleibt 2026-07-05 22:27:10 +02:00
karim 4b77047916 Attribute: Höhen-Bindung kürzer benannt (Gebunden/Eigene statt An Geschoss gebunden/Eigene Höhe), Wand+Decke 2026-07-05 22:18:30 +02:00
karim 670823e98d Attribute-Panel: verschobene Element-Abschnitte dem sauberen .attr-Grid-Look angeglichen (Sektion-Balken, Zeilentrenner, Label-Spalte, füllende Controls) 2026-07-05 22:13:56 +02:00
karim 71e75b99f0 Pendenzen: Objektinfo→Attribute-Merge erledigt → Ribbon-UI komplett 2026-07-05 21:50:22 +02:00
karim 3a2cef3886 ObjectInfo→Attribute: element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) ins Attribute-Panel; ObjectInfo nur noch Bezugspunkt + Masse 2026-07-05 21:50:03 +02:00
karim d01cb82a89 Pendenzen: Fenster-Anschlag-Striche vermerkt 2026-07-05 21:43:00 +02:00
karim 8d688b982e Bauteile: Fenster-Anschlag-Striche (Laibung, fein) analog zur Tür, mit Tests 2026-07-05 21:42:40 +02:00
karim 5b60136a19 Pendenzen: Schnitt/Ansicht Phase 2 (Decken-Überkopf gestrichelt) vermerkt 2026-07-05 21:32:29 +02:00
karim 39ddd9b501 Schnitt/Ansicht Phase 2: Decken-Überkopf-Umriss als gestrichelte Haarlinie (über Schnittebene), mit Test 2026-07-05 21:31:48 +02:00
karim 9c911e6d43 Schnitt/Ansicht Phase 1: Wand unter Schnittebene als Ansichts-Umriss (viewOnly, keine Poché), mit Tests 2026-07-05 21:22:51 +02:00
karim cdc71621c4 Pendenzen: Bauteile Gruppe A (2D) als komplett verifiziert vermerkt; Rest = Gruppe B/C + Schnitt/Ansicht 2026-07-05 21:17:45 +02:00
karim b9b75f7ad1 Pendenzen: Kreis+Bogen-Werkzeuge als komplett erledigt vermerkt 2026-07-05 21:11:25 +02:00
karim e82d4b084c Ribbon Phase 4: modulare Custom-Bar als eigener Tab (Eigene) mit +-Picker, localStorage-persistiert 2026-07-05 20:57:13 +02:00
karim 12441d36fb Ribbon Phase 3: Werkzeug-Sidebar aus Default-Layout, Wandtyp-Picker ins Attribute-Panel, Attribute volle Höhe (LAYOUT_VERSION 8) 2026-07-05 20:50:05 +02:00
karim 2c8ad8fe34 Fix: Kreise/Bögen im WebGL-Renderer sichtbar (drawingCircle/drawingArc tesselliert; Regression aus 4ac99d3) 2026-07-05 20:31:04 +02:00
karim 3697b7e2d8 Snap: Center-/Quadrant-Snaps für Kreise + Bögen (Bogen nur im Spannbereich) + Bogen-Endpunkte, mit Tests 2026-07-05 20:19:34 +02:00
karim a750e87517 Native macOS-Titelleiste: titleBarStyle Overlay (echte Ampeln + gerundete Ecken), Zeile links eingerückt; WindowControls Electron-only 2026-07-05 20:14:10 +02:00
karim 456bf29984 Headless-Titelleiste (Tauri decorations:false): Fensterknöpfe links + Drag-Region, Tauri-Fenstersteuerung + Window-Capabilities 2026-07-05 19:42:48 +02:00
karim c40764a25f Linienstile aufgeräumt: redundante 0.13-Volllinien zusammengeführt, weight-only als Volllinie benannt, Referenzen remappt 2026-07-05 19:32:38 +02:00
karim ff68ec3a5c Pendenzen: Textur-Spike als erledigt verifiziert (0ca3b1d), PBR-Restlücken dokumentiert 2026-07-05 19:23:00 +02:00
karim 72a40ccaaf Pendenzen: Ribbon-Feinschliff + Bogen-Werkzeug als erledigt vermerkt 2026-07-05 19:20:51 +02:00
karim fe22cbf0df Ribbon: Text + Ansichten auf eine Leiste (Ansichten-Tab), als Standard-Tab zuerst 2026-07-05 19:18:23 +02:00
karim 4e3b074f5c TopBar OCS-Stil: kleine Wortmarke + Quick-Access-Icons (alle Datei-/Export-Aktionen), Zeile auf 26px, Burger-Menu raus 2026-07-05 18:37:18 +02:00
karim 033cabe80f Ribbon: Text-Formatierung in eigenen Text-Tab; TopBar-Zeile auf 40px verschlankt 2026-07-05 18:10:14 +02:00
karim 456ebc8098 Ribbon-Tabs in die TopBar-Zeile verlegt: eine Leiste (Chrome + Tabs), Inhalt darunter 2026-07-05 18:04:48 +02:00
karim 13c3294f4d Doku: TopBar→Ansichten-Tab-Merge in Ribbon-Plan + Pendenzen vermerkt 2026-07-05 17:58:23 +02:00
karim 85011cb8ef Ribbon: TopBar-Ansichtssteuerung in Ansichten-Tab gemergt; TopBar auf schmale globale Leiste reduziert 2026-07-05 17:57:17 +02:00
karim 3a206060e3 Pendenzen: Ribbon Phase 1 + Attribut-Grid als erledigt vermerkt 2026-07-05 17:01:06 +02:00
karim 9d6e86d4c0 Ribbon-UI Phase 1: datengetriebene Tab-Leiste (2D/3D/BIM/Ansichten), additiv unter TopBar 2026-07-05 17:00:09 +02:00
karim ace0dc62a8 Attribute-Panel: ein durchgehendes Grid, volle Breite, doppelten Titel raus 2026-07-05 16:54:04 +02:00
karim fe9f396750 Doku: Ribbon-UI-Plan (2D/3D/BIM/Ansichten, datengetrieben, modulare Custom-Bar) + Pendenzen 2026-07-05 15:55:55 +02:00
karim 00733d8180 UI: Eigenschaften-Panel als OCS-artiges Grid (Sektion-Balken, Zeilentrenner, fuellende Wertfelder) 2026-07-05 15:48:42 +02:00
karim 077e774303 Fix: Dokument-Overscroll gesperrt (Viewport-Zoom-Wheel zog die UI leicht mit, macOS) 2026-07-05 15:45:32 +02:00
karim bd2b12bfb7 Werkzeuge: Bogen-Werkzeug (arcCommand, 3-Klick CCW) + Toolbar/Icon/i18n verdrahtet 2026-07-05 15:44:05 +02:00
karim 5130a050ff Pendenzen: Kreis-Toolbar erledigt vermerkt 2026-07-05 15:25:10 +02:00
karim e454eab1a8 Werkzeuge: Kreis-Toolbar verdrahtet (circleCommand gekoppelt, Icon, i18n) 2026-07-05 15:24:45 +02:00
karim 67195d7dd5 Pendenzen: DXF-Kurven/Copy-Paste/Platzierung/UX-Fixes vermerkt 2026-07-05 15:18:10 +02:00
karim dd76ec89fd DXF-Import: Platzierungsoption (relativ zu 0 ODER in Ansichtsmitte) 2026-07-05 15:17:09 +02:00
karim 376aa67566 Zeichnungen: Ctrl/Cmd+C/V — 2D-Elemente ueber Geschosse kopieren/einfuegen (aktives Geschoss) 2026-07-05 15:10:58 +02:00
karim 4ac99d37cb DXF-Import: CIRCLE/ARC als echte glatte Kreis-/Bogen-Formen (statt tesselliertem Vieleck) 2026-07-05 15:06:25 +02:00
karim 45e19b7293 Tauri: dragDropEnabled=false — HTML-Drag&Drop (Datei-Import) im Fenster wieder aktiv 2026-07-05 14:56:15 +02:00
karim c794feef9e Layout: Befehlszeile in die Mitte-Spalte (nur Viewport-breit, Docks gewinnen Hoehe) 2026-07-05 14:48:37 +02:00
karim c5b5ca600c Import-Befehl: autoRun statt onConfirm — Datei-Dialog oeffnet synchron in der Geste 2026-07-05 14:48:37 +02:00
karim 4ef73c9198 Pendenzen: HATCH-Ellipse/Spline-Kanten + TEXT/MTEXT-Import erledigt 2026-07-05 14:29:55 +02:00
karim 4b93ac9cbb DXF-Import: TEXT/MTEXT -> editierbarer Plan-Text (Drawing2D text-Primitiv, SVG-Render) 2026-07-05 14:29:05 +02:00
38 changed files with 3266 additions and 542 deletions
+44 -6
View File
@@ -18,6 +18,10 @@
--- ---
## 📅 Geplant
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)****Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~46 Wochen. Mit Nutzer Scope/MVP klären vor Start.
## ⛔ Blocker (zuerst klären) ## ⛔ 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.** - [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.**
@@ -39,15 +43,21 @@
## ⏭️ Als Nächstes ## ⏭️ Als Nächstes
- [ ] **SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen).** Kleinster ehrlicher Durchstich: beweisen, dass der wgpu-3D-Renderer echte Bild-Texturen auf Wandflächen zeigt, sichtbar im `spike3d`-Fenster. **Voller Auftrag/Übergabe: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md)** (Ausgangslage verifiziert, Symbol-Karte, exakter Umfang, harte Randbedingungen, Akzeptanz, Abschlussbericht). Kurz: UVs auf Wandflächen (planar in Metern, Alt-Pfad `[pos,normal,color]` bitgleich unangetastet) · prozedurales 256×256-Schachbrett (kein Asset, kein `image`-Crate) · Textur-Bind-Group group 1 (Globals bleibt group 0) · `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`) · Verdrahtung bei `RenderStyle::Textured` · `spike3d` umschaltbar (`T`). **Nur `render3d`-Crate, keine neuen Deps, Default-Build unverändert.** Akzeptanz: `cargo test` grün (Mesh-Regression + naga-Test für `MESH_TEXTURED_WGSL`), `spike3d` zeigt weltmassstäbliche Textur. Ist der **erste Durchstich der Textur-/PBR-Pipeline** (§3D-REST unten) → am Ende ehrliche Lückenliste + Aufwandsschätzung für „richtig gutes" Texturing. - [ ] **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) ## 📋 Backlog (Priorität grob absteigend)
- [ ] **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, sofort, kernel2d): (1) **Treppe Aussenlinie/Outline** (gerade/L/Wendel), (2) **Fenster Brüstungslinie** im Plan, (3) **Tür Sturzlinien** (gestrichelt, SIA), (4) Treppe Referenz links/mitte/rechts, (5) Fenster Flügel-Mittelpfosten, (6) Tür `wandoeffnung`-Typ. Gruppe B (2D mittel), Gruppe C (3D, nach Mesh-Pipeline/truck). **A-Items 13 fallen mit „Schnitt- vs. Ansichts-Darstellung" zusammen** (Sturz = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion). - [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~**komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
- [ ] **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 R13R2018-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. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Ellipse-/Spline-Kanten übersprungen; Insel-Loops = eigene Ringe (keine echten Löcher). **Weiter offen (grob absteigend):** TEXT/MTEXT (→ editierbarer Plan-Text via Drawings-Import, Nutzer-bestätigt); dann Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke). - [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 16) 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, Treppen-Pfeil-Style `filled`, Fenster/Tür-Presets, `swing_invert`) — Feinpolish. ✅ **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 R13R2018-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.** - [ ] **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).** Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.250.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): - [ ] **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.250.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 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 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. - Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
@@ -68,13 +78,22 @@
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers. - [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`). - [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden). - [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
- [ ] **Linienstile aufräumen** (Weight-only-Stile „Volllinie", lineStyleId-Referenzen remappen). - [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). - [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; 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.) Belege in `/tmp/dossier-ref/rhino/*.py`. - [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; 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.) Belege in `/tmp/dossier-ref/rhino/*.py`.
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen. - [ ] 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 (24 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen) ### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). **Erster Durchstich als Spike terminiert → [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md) (steht unter ⏭️ Als Nächstes).** - [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). **Erster Durchstich erledigt** (`0ca3b1d`, Schachbrett-Textur auf Wänden, group-1-Bind-Group, `MESH_TEXTURED_WGSL`, planare Meter-UVs). **Verbleibende Lücken bis „richtig gut" (Grobschätzung ~1829 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~12 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~35 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~58 · Mipmaps (Blit-Pass) + anisotropes Filtern ~12 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~34 · UI zum Zuweisen + Persistenz im Dokumentmodell ~58.
- [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] ~~**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.
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau). - [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit). - [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
@@ -92,6 +111,25 @@
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._ _Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [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 (~1829 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-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 **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 **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`
+74
View File
@@ -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.
+403
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-start-dragging"
]
}
+5 -1
View File
@@ -10,9 +10,13 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"label": "main",
"title": "cad", "title": "cad",
"width": 1400, "width": 1400,
"height": 900 "height": 900,
"dragDropEnabled": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true
} }
], ],
"security": { "security": {
+258 -65
View File
@@ -69,7 +69,9 @@ import type { Pick3dHit } from "./viewport/Wasm3DViewport";
import { ResourceManager } from "./ui/ResourceManager"; import { ResourceManager } from "./ui/ResourceManager";
import type { ResourceManagerHandlers } from "./ui/ResourceManager"; import type { ResourceManagerHandlers } from "./ui/ResourceManager";
import { HatchSwatch } from "./ui/hatchPreview"; import { HatchSwatch } from "./ui/hatchPreview";
import { TopBar } from "./ui/TopBar"; import { TopBar, ViewRibbonTab, Ribbon3dTab } from "./ui/TopBar";
import { RibbonBar } from "./ui/ribbon/RibbonBar";
import type { RibbonTabId } from "./ui/ribbon/ribbonItems";
import { Dropdown } from "./ui/Dropdown"; import { Dropdown } from "./ui/Dropdown";
import type { DropdownItem } from "./ui/Dropdown"; import type { DropdownItem } from "./ui/Dropdown";
import { TextEditorDialog } from "./ui/TextEditorDialog"; import { TextEditorDialog } from "./ui/TextEditorDialog";
@@ -212,6 +214,8 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
line: "line", line: "line",
polyline: "polyline", polyline: "polyline",
rect: "rect", rect: "rect",
circle: "circle",
arc: "arc",
}; };
/** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */ /** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */
function coupledCommandFor(id: ToolId): string | null { function coupledCommandFor(id: ToolId): string | null {
@@ -226,6 +230,68 @@ function promptCount(): number | null {
return Number.isFinite(n) && n >= 1 ? n : null; return Number.isFinite(n) && n >= 1 ? n : null;
} }
/** localStorage-Schlüssel der modularen Custom-Bar (Ribbon-Tab „Eigene"). */
const RIBBON_CUSTOM_KEY = "dossier.ribbon.customItems";
/** Persistierte Custom-Bar-Item-Schlüssel lesen (leer bei Fehler/Erststart). */
function loadCustomItems(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(RIBBON_CUSTOM_KEY);
const arr = raw ? JSON.parse(raw) : null;
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
/** Custom-Bar-Item-Schlüssel persistieren (Fehler still schlucken). */
function saveCustomItems(items: string[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RIBBON_CUSTOM_KEY, JSON.stringify(items));
} catch {
// localStorage nicht verfügbar (privater Modus o. ä.) → ignorieren.
}
}
/** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */
function drawingsBBox(
drawings: Drawing2D[],
): { minX: number; minY: number; maxX: number; maxY: number } | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (x: number, y: number) => {
minX = Math.min(minX, x); minY = Math.min(minY, y);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
};
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a.x, g.a.y); acc(g.b.x, g.b.y); }
else if (g.shape === "polyline") for (const p of g.pts) acc(p.x, p.y);
else if (g.shape === "rect") { acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); }
else if (g.shape === "circle" || g.shape === "arc") {
acc(g.center.x - g.r, g.center.y - g.r);
acc(g.center.x + g.r, g.center.y + g.r);
} else if (g.shape === "text") acc(g.at.x, g.at.y);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
/** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */
function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy });
const g = d.geom;
switch (g.shape) {
case "line": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
case "polyline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
}
}
export default function App() { export default function App() {
// Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache // Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache
// umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t). // umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t).
@@ -315,6 +381,22 @@ export default function App() {
// Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je // Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je
// Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView). // Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView).
const [activeTool, setActiveTool] = useState<ToolId>("select"); const [activeTool, setActiveTool] = useState<ToolId>("select");
// Aktiver Ribbon-Tab (2D/3D/BIM/Ansichten). Liegt hier (nicht in der RibbonBar),
// weil die Tab-Reiter in der TopBar-Zeile sitzen und der Ribbon-Inhalt darunter
// — beide teilen sich denselben aktiven Tab.
const [ribbonTab, setRibbonTab] = useState<RibbonTabId>("views");
// Modulare Custom-Bar (Ribbon-Tab „Eigene"): persistierte Item-Schlüssel. Der
// Picker im Tab wählt Werkzeuge/Befehle an/ab (localStorage-persistiert).
const [customItems, setCustomItems] = useState<string[]>(loadCustomItems);
const onToggleCustomItem = (key: string) => {
setCustomItems((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
saveCustomItems(next);
return next;
});
};
const [activeWallTypeId, setActiveWallTypeId] = useState<string>( const [activeWallTypeId, setActiveWallTypeId] = useState<string>(
project.wallTypes[0].id, project.wallTypes[0].id,
); );
@@ -349,6 +431,11 @@ export default function App() {
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null; selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
const setSelectedDrawingId = (id: string | null) => const setSelectedDrawingId = (id: string | null) =>
setSelectedDrawingIds(id ? [id] : []); setSelectedDrawingIds(id ? [id] : []);
// Zwischenablage für 2D-Zeichenelemente (Ctrl/Cmd+C/V). Kopiert die GEWÄHLTEN
// Drawing2D tief; Einfügen legt Klone mit neuen IDs auf dem AKTIVEN Geschoss
// (Zeichnungsebene) an — so lässt sich z. B. importiertes Mobiliar über
// Geschosse hinweg wiederverwenden. Kategorie/Stil bleiben erhalten.
const [drawingClipboard, setDrawingClipboard] = useState<Drawing2D[]>([]);
// Bequemer Einzel-Selektor (erste gewählte Wand). // Bequemer Einzel-Selektor (erste gewählte Wand).
const selectedWallId = selectedWallIds[0] ?? null; const selectedWallId = selectedWallIds[0] ?? null;
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine" // Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
@@ -766,6 +853,16 @@ export default function App() {
setActiveTool(id); setActiveTool(id);
}; };
// Ribbon-Befehl (Ändern-Gruppe u. a.): startet einen Engine-Befehl OHNE
// eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des
// Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv-
// Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView).
const onRunCommand = (name: string) => {
engine.start(name);
setActiveTool("select");
requestAnimationFrame(() => commandLineRef.current?.focus());
};
// 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf // 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern // die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
// ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick). // ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick).
@@ -1581,6 +1678,46 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDrawingIds, engine]); }, [selectedDrawingIds, engine]);
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt
// die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit
// neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so
// wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im
// Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
const k = e.key.toLowerCase();
if (k !== "c" && k !== "v") return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
return;
}
if (k === "c") {
const ids = new Set(selectedDrawingIds);
if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy)
const picked = project.drawings2d.filter((d) => ids.has(d.id));
if (picked.length === 0) return;
e.preventDefault();
setDrawingClipboard(picked.map((d) => structuredClone(d)));
return;
}
// Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt.
if (drawingClipboard.length === 0) return;
e.preventDefault();
const stamp = Date.now();
const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({
...structuredClone(d),
id: `drawing-${stamp}-${i}`,
levelId: activeLevelId,
}));
importDrawings(pasted);
setSelectedDrawingIds(pasted.map((p) => p.id));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDrawingIds, drawingClipboard, activeLevelId, project.drawings2d]);
// Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten // Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
// geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2 // geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
// geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.) // geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.)
@@ -2091,12 +2228,24 @@ export default function App() {
decision.target.kind === "new" decision.target.kind === "new"
? addDrawingLevel(decision.target.name) ? addDrawingLevel(decision.target.name)
: decision.target.levelId; : decision.target.levelId;
const drawings = buildDrawings( let drawings = buildDrawings(
decision, decision,
levelId, levelId,
project, project,
activeCategoryCode, activeCategoryCode,
); );
// Platzierung „viewCenter": den Gesamt-Umriss so verschieben, dass seine
// Mitte auf die Mitte des aktuellen Ausschnitts fällt (sonst bleibt alles an
// den DXF-Koordinaten relativ zum Nullpunkt).
if (decision.placement === "viewCenter" && drawings.length) {
const center = planViewRef.current?.viewCenterModel();
const bb = drawingsBBox(drawings);
if (center && bb) {
const dx = center.x - (bb.minX + bb.maxX) / 2;
const dy = center.y - (bb.minY + bb.maxY) / 2;
drawings = drawings.map((d) => translateDrawing(d, dx, dy));
}
}
if (drawings.length) importDrawings(drawings); if (drawings.length) importDrawings(drawings);
if (decision.importMeshes && decision.parsed.meshes.length) { if (decision.importMeshes && decision.parsed.meshes.length) {
addContextObjects(decision.parsed.meshes); addContextObjects(decision.parsed.meshes);
@@ -3084,8 +3233,46 @@ export default function App() {
e.target.value = ""; e.target.value = "";
}} }}
/> />
{/* Schmale globale Leiste: Marke/Ressourcen · Text-Formatierung ·
Datei/Export/Einstellungen. Ansichts-/Zoom-/Darstellungs-Steuerung ist
in den „Ansichten"-Ribbon-Tab gewandert (ViewRibbonTab, unten). */}
<TopBar <TopBar
project={project} project={project}
ribbonTab={ribbonTab}
onRibbonTab={setRibbonTab}
zoomEnabled={planActive}
onExportPdf={() => setExportOpen(true)}
onExportDxf={() => setExportDxfOpen(true)}
onExportSchedule={onExportSchedule}
resourcesOpen={resourcesOpen}
onToggleResources={() => setResourcesOpen((v) => !v)}
onOpenSettings={() => setSettingsOpen(true)}
onImport={() => dxfInputRef.current?.click()}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
layoutMenu={
<LayoutMenu
layout={layout}
onApply={(l) => setLayout(reconcileLayout(l))}
/>
}
/>
{/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten). Der „Ansichten"-Tab trägt
die aus der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung
(ViewRibbonTab); die Werkzeug-Sidebar bleibt vorerst erhalten. */}
<RibbonBar
tab={ribbonTab}
activeTool={activeTool}
activeCommand={engineView.commandName}
toolsEnabled={activeLevel.kind === "floor"}
onSelectTool={onSelectTool}
onRunCommand={onRunCommand}
customItems={customItems}
onToggleCustomItem={onToggleCustomItem}
tabContent={{
views: (
<ViewRibbonTab
viewToggleEnabled={activeLevel.kind === "floor"} viewToggleEnabled={activeLevel.kind === "floor"}
viewType={viewType} viewType={viewType}
onViewType={setViewType} onViewType={setViewType}
@@ -3104,9 +3291,6 @@ export default function App() {
onFit={fitPlan} onFit={fitPlan}
onFitSelection={fitSelection} onFitSelection={fitSelection}
onZoom100={zoomToApplied} onZoom100={zoomToApplied}
onExportPdf={() => setExportOpen(true)}
onExportDxf={() => setExportDxfOpen(true)}
onExportSchedule={onExportSchedule}
renderMode={renderMode} renderMode={renderMode}
onRenderMode={setRenderMode} onRenderMode={setRenderMode}
renderModeEnabled={viewType === "perspektive"} renderModeEnabled={viewType === "perspektive"}
@@ -3133,20 +3317,25 @@ export default function App() {
}, },
deleteDrawingCombo, deleteDrawingCombo,
}} }}
resourcesOpen={resourcesOpen}
onToggleResources={() => setResourcesOpen((v) => !v)}
onOpenSettings={() => setSettingsOpen(true)}
onImport={() => dxfInputRef.current?.click()}
onSaveProject={onSaveProject}
onOpenProject={onOpenProject}
layoutMenu={
<LayoutMenu
layout={layout}
onApply={(l) => setLayout(reconcileLayout(l))}
/>
}
textTarget={textTarget} textTarget={textTarget}
/> />
),
"3d": (
<Ribbon3dTab
viewToggleEnabled={activeLevel.kind === "floor"}
viewType={viewType}
onViewType={setViewType}
view3d={view3d}
onView3d={setView3d}
fov={fov}
onFov={setFov}
renderMode={renderMode}
onRenderMode={setRenderMode}
renderModeEnabled={viewType === "perspektive"}
/>
),
}}
/>
<div className="body"> <div className="body">
{/* Linkes Dock: gestapelte Gruppen (Werkzeuge oben, Zeichnungsebenen/ {/* Linkes Dock: gestapelte Gruppen (Werkzeuge oben, Zeichnungsebenen/
@@ -3168,6 +3357,10 @@ export default function App() {
/> />
)} )}
{/* Mitte-Spalte: Hauptansicht + Befehlszeile DARUNTER. So ist die
Befehlszeile nur so breit wie der Viewport (nicht die ganze App),
und die Docks nehmen die volle Body-Höhe ein (inkl. der Zeile). */}
<div className="center-col">
{/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan- {/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan-
Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt
das Zoom-Handle entgegen. */} das Zoom-Handle entgegen. */}
@@ -3232,6 +3425,54 @@ export default function App() {
onEdit3dWallTop={onEdit3dWallTop} onEdit3dWallTop={onEdit3dWallTop}
onEdit3dEnd={onEdit3dEnd} onEdit3dEnd={onEdit3dEnd}
/> />
{/* Command-Line (Rhino-artig) UNTER dem Viewport — nur Viewport-breit.
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
<CommandLine
ref={commandLineRef}
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
active={gripEdit ? true : activeTransform ? true : engineView.active}
promptKey={
gripEdit
? "cmd.edit.point"
: activeTransform
? `transform.hint.${activeTransform.op}.${Math.min(
activeTransform.points.length,
activeTransform.op === "rotate" ? 2 : 1,
)}`
: engineView.promptKey
}
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
fields={
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
}
onSubmit={(text) => {
if (gripEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitGripEditValue(v);
return;
}
if (activeTransform) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitTransformValue(v);
return;
}
engine.submitText(text);
}}
onOption={(id) =>
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
}
onCycleField={() =>
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
}
onCancel={() =>
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
}
suggest={(prefix) => engine.suggest(prefix)}
/>
</div>
{/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes {/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes
Fenster). Nur gerendert, wenn es Gruppen hat — sonst nutzt die Mitte Fenster). Nur gerendert, wenn es Gruppen hat — sonst nutzt die Mitte
@@ -3300,54 +3541,6 @@ export default function App() {
{/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */} {/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */}
<DragOverlay drag={drag} /> <DragOverlay drag={drag} />
{/* Command-Line (Rhino-artig) ÜBER der Statusleiste: Prompt + klickbare
Inline-Optionen + Texteingabe + Autocomplete. Tab fokussiert sie. */}
<CommandLine
ref={commandLineRef}
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
active={gripEdit ? true : activeTransform ? true : engineView.active}
promptKey={
gripEdit
? "cmd.edit.point"
: activeTransform
? `transform.hint.${activeTransform.op}.${Math.min(
activeTransform.points.length,
activeTransform.op === "rotate" ? 2 : 1,
)}`
: engineView.promptKey
}
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
fields={
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
}
onSubmit={(text) => {
if (gripEdit) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitGripEditValue(v);
return;
}
if (activeTransform) {
const v = parseFloat(text.replace(",", "."));
if (isFinite(v)) submitTransformValue(v);
return;
}
engine.submitText(text);
}}
onOption={(id) =>
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
}
onCycleField={() =>
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
}
onCancel={() =>
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
}
suggest={(prefix) => engine.suggest(prefix)}
/>
{/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State. {/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State.
Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */} Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */}
<StatusBar <StatusBar
+166
View File
@@ -0,0 +1,166 @@
// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte:
// 1) „Mittelpunkt:" → Punkt
// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1Center| UND Startwinkel a0)
// 3) „Endpunkt:" → Punkt (setzt Endwinkel a1; Radius bleibt) → commit
//
// Der Bogen läuft CCW von a0 nach a1 (wie DXF-ARC und das `{shape:"arc"}`-
// Rendering in PlanView). Der Endpunkt wird nur für seinen WINKEL genutzt (auf
// den Kreis mit Radius r projiziert). In generatePlan/PlanView als glatter
// SVG-Bogen (`drawingArc`) gerendert.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
interface ArcCenter extends CommandState {
phase: "center";
}
interface ArcStart extends CommandState {
phase: "start";
center: Vec2;
cursor: Vec2 | null;
}
interface ArcEnd extends CommandState {
phase: "end";
center: Vec2;
r: number;
a0: number;
cursor: Vec2 | null;
}
type ArcState = ArcCenter | ArcStart | ArcEnd;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const angle = (c: Vec2, p: Vec2): number => Math.atan2(p.y - c.y, p.x - c.x);
/** CCW-Spanne von a0 nach a1, normiert auf (0, 2π]. */
function sweepOf(a0: number, a1: number): number {
const TAU = Math.PI * 2;
const s = ((a1 - a0) % TAU + TAU) % TAU;
return s < EPS ? TAU : s;
}
/** Punkte eines Bogens (CCW a0→a1) für die Vorschau. */
function arcPts(center: Vec2, r: number, a0: number, a1: number): Vec2[] {
const sweep = sweepOf(a0, a1);
const N = Math.max(2, Math.ceil(sweep / (Math.PI / 32)));
const pts: Vec2[] = [];
for (let i = 0; i <= N; i++) {
const t = a0 + (sweep * i) / N;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Punkte einer vollen Kreis-Approximation (Radius-Vorschau im „start"-Schritt). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) {
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
draft.hud = { at, text: `${deg.toFixed(0)}°` };
}
return draft;
}
function appendArc(p: Project, center: Vec2, r: number, a0: number, a1: number, ctx: CommandContext): Project {
if (r < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "arc", center, r, a0, a1 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "center", lastPoint: null },
{ draft: null, done: true },
];
export const arcCommand: Command = {
name: "arc",
labelKey: "cmd.arc.label",
prompt: (s) => {
const phase = (s as ArcState).phase;
return phase === "start" ? "cmd.arc.start" : phase === "end" ? "cmd.arc.end" : "cmd.arc.center";
},
accepts: () => ["point"],
options: () => [],
init: (): ArcCenter => ({ phase: "center", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ArcState;
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase === "center") {
const ns: ArcStart = { phase: "start", center: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: { preview: [], vertices: [pt] } }];
}
if (s.phase === "start") {
const r = dist(s.center, pt);
if (r < EPS) return [s, { draft: null }];
const a0 = angle(s.center, pt);
const ns: ArcEnd = { phase: "end", center: s.center, r, a0, cursor: pt, lastPoint: pt };
return [ns, { draft: arcDraft(s.center, r, a0, a0, pt) }];
}
// Endpunkt: Endwinkel setzen, Bogen committen.
const a1 = angle(s.center, pt);
const { center, r, a0 } = s;
return [
{ phase: "center", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendArc(p, center, r, a0, a1, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ArcState;
if (s.phase === "start") {
const r = dist(s.center, point);
const ns: ArcStart = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
vertices: [s.center],
hud: { at: point, text: `r ${r.toFixed(2)} m` },
},
},
];
}
if (s.phase === "end") {
const a1 = angle(s.center, point);
const ns: ArcEnd = { ...s, cursor: point };
return [ns, { draft: arcDraft(s.center, s.r, s.a0, a1, point) }];
}
return [s, { draft: null }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+10
View File
@@ -26,6 +26,16 @@ export const importCommand: Command = {
options: () => [], options: () => [],
init: () => ({ ...IDLE }), init: () => ({ ...IDLE }),
// Sofort-Befehl (wie Join/Split): `startCommand` ruft `autoRun` SYNCHRON im
// selben Aufruf-Stack wie die Enter-/Klick-Geste auf. Genau das braucht der
// Datei-Dialog — ein `<input type=file>.click()` öffnet den OS-Dialog nur
// innerhalb einer echten Nutzer-Geste; ein State-Update/async-Hop dazwischen
// (wie beim reinen onConfirm/onInput-Weg) lässt ihn verzögern oder ausbleiben.
autoRun: (): CommandResult => {
openDxfImport();
return { draft: null, done: true };
},
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet. // Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
onInput: () => open(), onInput: () => open(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }], onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
+4
View File
@@ -13,6 +13,7 @@ import { stairCommand } from "./cmds/stair";
import { roomCommand } from "./cmds/room"; import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect"; import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle"; import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { moveCommand } from "./cmds/move"; import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror"; import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join"; import { joinCommand } from "./cmds/join";
@@ -35,6 +36,7 @@ export const COMMANDS: Record<string, Command> = {
polyline: polylineCommand, polyline: polylineCommand,
rect: rectCommand, rect: rectCommand,
circle: circleCommand, circle: circleCommand,
arc: arcCommand,
move: moveCommand, move: moveCommand,
mirror: mirrorCommand, mirror: mirrorCommand,
join: joinCommand, join: joinCommand,
@@ -67,6 +69,8 @@ export const ALIASES: Record<string, string> = {
pl: "polyline", pl: "polyline",
rec: "rect", rec: "rect",
c: "circle", c: "circle",
a: "arc",
bogen: "arc",
m: "move", m: "move",
s: "mirror", s: "mirror",
spiegeln: "mirror", spiegeln: "mirror",
+31 -5
View File
@@ -116,6 +116,23 @@ export const de = {
"tool.line": "Linie", "tool.line": "Linie",
"tool.polyline": "Polylinie", "tool.polyline": "Polylinie",
"tool.rect": "Rechteck", "tool.rect": "Rechteck",
"tool.circle": "Kreis",
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
// Ribbon-Oberleiste (Tabs + Gruppen)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
"ribbon.tab.bim": "BIM",
"ribbon.tab.views": "Ansichten",
"ribbon.tab.custom": "Eigene",
"ribbon.group.draw": "Zeichnen",
"ribbon.group.modify": "Ändern",
"ribbon.group.components": "Bauteile",
"ribbon.group.custom": "Eigene Leiste",
"ribbon.custom.add": " Hinzufügen",
"ribbon.custom.empty": "Elemente über Hinzufügen wählen",
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen", "tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen", "tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
"tool.door.hint": "Tür: Wand wählen, dann Position setzen", "tool.door.hint": "Tür: Wand wählen, dann Position setzen",
@@ -208,6 +225,7 @@ export const de = {
// Attribute-Palette (AttributesPanel) // Attribute-Palette (AttributesPanel)
"attr.title": "Attribute", "attr.title": "Attribute",
"attr.empty": "Keine Auswahl", "attr.empty": "Keine Auswahl",
"attr.newComponent": "Neues Bauteil",
"attr.stroke": "Stift", "attr.stroke": "Stift",
"attr.color": "Farbe", "attr.color": "Farbe",
"attr.weight": "Strichstärke (mm)", "attr.weight": "Strichstärke (mm)",
@@ -255,7 +273,7 @@ export const de = {
"objinfo.wall.refLine.center": "Mitte", "objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)", "objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.buildup": "Aufbau", "objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Einschichtig", "objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Mehrschichtig", "objinfo.wall.multi": "Mehrschichtig",
"objinfo.wall.thickness": "Dicke", "objinfo.wall.thickness": "Dicke",
"objinfo.wall.preset": "Wandaufbau", "objinfo.wall.preset": "Wandaufbau",
@@ -264,8 +282,8 @@ export const de = {
"objinfo.wall.linkAbove": "An oberes Geschoss binden", "objinfo.wall.linkAbove": "An oberes Geschoss binden",
"objinfo.wall.bottom": "Unterkante (UK)", "objinfo.wall.bottom": "Unterkante (UK)",
"objinfo.wall.top": "Oberkante (OK)", "objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "An Geschoss gebunden", "objinfo.wall.anchorFloor": "Gebunden",
"objinfo.wall.anchorCustom": "Eigene Höhe", "objinfo.wall.anchorCustom": "Eigene",
"objinfo.wall.height": "Höhe", "objinfo.wall.height": "Höhe",
// ── Decken-Attribute (Object-Info-Panel) ──────────────────────────────── // ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
"objinfo.ceiling.section": "Decke", "objinfo.ceiling.section": "Decke",
@@ -277,8 +295,8 @@ export const de = {
"objinfo.ceiling.bottom": "Unterkante (UK)", "objinfo.ceiling.bottom": "Unterkante (UK)",
"objinfo.ceiling.height": "Dicke (OKUK)", "objinfo.ceiling.height": "Dicke (OKUK)",
"objinfo.ceiling.area": "Fläche", "objinfo.ceiling.area": "Fläche",
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden", "objinfo.ceiling.anchorFloor": "Gebunden",
"objinfo.ceiling.anchorCustom": "Eigene Höhe", "objinfo.ceiling.anchorCustom": "Eigene",
// ── Öffnungs-Attribute (Object-Info-Panel) ────────────────────────────── // ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
"objinfo.opening.section": "Öffnung", "objinfo.opening.section": "Öffnung",
"objinfo.opening.kind": "Art", "objinfo.opening.kind": "Art",
@@ -707,6 +725,10 @@ export const de = {
"cmd.circle.label": "Kreis", "cmd.circle.label": "Kreis",
"cmd.circle.center": "Mittelpunkt:", "cmd.circle.center": "Mittelpunkt:",
"cmd.circle.radius": "Radius:", "cmd.circle.radius": "Radius:",
"cmd.arc.label": "Bogen",
"cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):",
// 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:",
@@ -779,6 +801,7 @@ export const de = {
"import.file": "Datei", "import.file": "Datei",
"import.summary.heading": "Inhalt", "import.summary.heading": "Inhalt",
"import.summary.contours": "{n} Konturen", "import.summary.contours": "{n} Konturen",
"import.summary.texts": "{n} Texte",
"import.summary.meshes": "{n} Meshes", "import.summary.meshes": "{n} Meshes",
"import.summary.layers": "Layer: {layers}", "import.summary.layers": "Layer: {layers}",
"import.summary.noLayers": "keine Layer-Namen", "import.summary.noLayers": "keine Layer-Namen",
@@ -790,6 +813,9 @@ export const de = {
"import.layers.heading": "Ebenen-Handling (DXF-Layer)", "import.layers.heading": "Ebenen-Handling (DXF-Layer)",
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen", "import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})", "import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
"import.placement.heading": "Platzierung",
"import.placement.origin": "An DXF-Koordinaten (relativ zum Nullpunkt)",
"import.placement.viewCenter": "In die Mitte der aktuellen Ansicht",
"import.mesh.heading": "Meshes", "import.mesh.heading": "Meshes",
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren", "import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.", "import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
+31 -5
View File
@@ -115,6 +115,23 @@ export const en: Record<TranslationKey, string> = {
"tool.line": "Line", "tool.line": "Line",
"tool.polyline": "Polyline", "tool.polyline": "Polyline",
"tool.rect": "Rectangle", "tool.rect": "Rectangle",
"tool.circle": "Circle",
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
// Ribbon top bar (tabs + groups)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
"ribbon.tab.bim": "BIM",
"ribbon.tab.views": "Views",
"ribbon.tab.custom": "Custom",
"ribbon.group.draw": "Draw",
"ribbon.group.modify": "Modify",
"ribbon.group.components": "Components",
"ribbon.group.custom": "Custom bar",
"ribbon.custom.add": " Add",
"ribbon.custom.empty": "Pick elements via “+ Add”",
"ribbon.empty": "No tools in this tab yet",
"tool.select.hint": "Select — click/drag elements", "tool.select.hint": "Select — click/drag elements",
"tool.window.hint": "Window: pick a wall, then set the position", "tool.window.hint": "Window: pick a wall, then set the position",
"tool.door.hint": "Door: pick a wall, then set the position", "tool.door.hint": "Door: pick a wall, then set the position",
@@ -207,6 +224,7 @@ export const en: Record<TranslationKey, string> = {
// Attribute-Palette (AttributesPanel) // Attribute-Palette (AttributesPanel)
"attr.title": "Attributes", "attr.title": "Attributes",
"attr.empty": "No selection", "attr.empty": "No selection",
"attr.newComponent": "New component",
"attr.stroke": "Pen", "attr.stroke": "Pen",
"attr.color": "Color", "attr.color": "Color",
"attr.weight": "Line weight (mm)", "attr.weight": "Line weight (mm)",
@@ -254,7 +272,7 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.refLine.center": "Center", "objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)", "objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.buildup": "Build-up", "objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Single layer", "objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Multi layer", "objinfo.wall.multi": "Multi layer",
"objinfo.wall.thickness": "Thickness", "objinfo.wall.thickness": "Thickness",
"objinfo.wall.preset": "Wall style", "objinfo.wall.preset": "Wall style",
@@ -263,8 +281,8 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.linkAbove": "Bind to floor above", "objinfo.wall.linkAbove": "Bind to floor above",
"objinfo.wall.bottom": "Bottom (UK)", "objinfo.wall.bottom": "Bottom (UK)",
"objinfo.wall.top": "Top (OK)", "objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound to floor", "objinfo.wall.anchorFloor": "Bound",
"objinfo.wall.anchorCustom": "Custom height", "objinfo.wall.anchorCustom": "Custom",
"objinfo.wall.height": "Height", "objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above", "objinfo.wall.floorAboveNone": "No floor above",
// ── Ceiling attributes (object info panel) ─────────────────────────────── // ── Ceiling attributes (object info panel) ───────────────────────────────
@@ -277,8 +295,8 @@ export const en: Record<TranslationKey, string> = {
"objinfo.ceiling.bottom": "Bottom (UK)", "objinfo.ceiling.bottom": "Bottom (UK)",
"objinfo.ceiling.height": "Thickness (OKUK)", "objinfo.ceiling.height": "Thickness (OKUK)",
"objinfo.ceiling.area": "Area", "objinfo.ceiling.area": "Area",
"objinfo.ceiling.anchorFloor": "Bound to floor", "objinfo.ceiling.anchorFloor": "Bound",
"objinfo.ceiling.anchorCustom": "Custom height", "objinfo.ceiling.anchorCustom": "Custom",
// ── Opening attributes (object info panel) ────────────────────────────── // ── Opening attributes (object info panel) ──────────────────────────────
"objinfo.opening.section": "Opening", "objinfo.opening.section": "Opening",
"objinfo.opening.kind": "Type", "objinfo.opening.kind": "Type",
@@ -697,6 +715,10 @@ export const en: Record<TranslationKey, string> = {
"cmd.circle.label": "Circle", "cmd.circle.label": "Circle",
"cmd.circle.center": "Center:", "cmd.circle.center": "Center:",
"cmd.circle.radius": "Radius:", "cmd.circle.radius": "Radius:",
"cmd.arc.label": "Arc",
"cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):",
"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:",
@@ -768,6 +790,7 @@ export const en: Record<TranslationKey, string> = {
"import.file": "File", "import.file": "File",
"import.summary.heading": "Contents", "import.summary.heading": "Contents",
"import.summary.contours": "{n} contours", "import.summary.contours": "{n} contours",
"import.summary.texts": "{n} texts",
"import.summary.meshes": "{n} meshes", "import.summary.meshes": "{n} meshes",
"import.summary.layers": "Layers: {layers}", "import.summary.layers": "Layers: {layers}",
"import.summary.noLayers": "no layer names", "import.summary.noLayers": "no layer names",
@@ -779,6 +802,9 @@ export const en: Record<TranslationKey, string> = {
"import.layers.heading": "Layer handling (DXF layers)", "import.layers.heading": "Layer handling (DXF layers)",
"import.layers.asCategories": "Keep DXF layers as categories", "import.layers.asCategories": "Keep DXF layers as categories",
"import.layers.toActive": "Everything onto the active category ({code})", "import.layers.toActive": "Everything onto the active category ({code})",
"import.placement.heading": "Placement",
"import.placement.origin": "At DXF coordinates (relative to origin)",
"import.placement.viewCenter": "Centered in the current view",
"import.mesh.heading": "Meshes", "import.mesh.heading": "Meshes",
"import.mesh.asContext": "Import meshes as context geometry (3D)", "import.mesh.asContext": "Import meshes as context geometry (3D)",
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.", "import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
+107 -1
View File
@@ -3,7 +3,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseDxf } from "./dxfParser"; import { parseDxf } from "./dxfParser";
import { contoursToDrawings } from "./dxfToDrawings"; import { contoursToDrawings, textsToDrawings } from "./dxfToDrawings";
import type { Contour, ContourSet, Vec2 } from "../model/types"; import type { Contour, ContourSet, Vec2 } from "../model/types";
/** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */ /** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */
@@ -411,6 +411,112 @@ describe("parseDxf — HATCH", () => {
}); });
}); });
// ── TEXT / MTEXT ──────────────────────────────────────────────────────────────
/** TEXT-Entity: Position, Höhe, Rotation (Grad), String. */
function textEnt(x: number, y: number, h: number, rotDeg: number, s: string): string[] {
return ["0", "TEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", `${rotDeg}`, "1", s];
}
/** MTEXT-Entity: Position, Höhe, String (Rotation 0). */
function mtextEnt(x: number, y: number, h: number, s: string): string[] {
return ["0", "MTEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", "0", "1", s];
}
describe("parseDxf — TEXT/MTEXT", () => {
it("TEXT → ImportedText mit Position/Höhe/Winkel (Grad→Radiant)", () => {
const res = parseDxf(dxf(textEnt(5, 3, 0.5, 90, "Hallo")));
expect(res.texts).toHaveLength(1);
const t0 = res.texts![0];
expect(t0.at.x).toBeCloseTo(5, 9);
expect(t0.at.y).toBeCloseTo(3, 9);
expect(t0.height).toBeCloseTo(0.5, 9);
expect(t0.angle).toBeCloseTo(Math.PI / 2, 9);
expect(t0.text).toBe("Hallo");
});
it("MTEXT → Formatcodes gesäubert (\\P → Leerzeichen)", () => {
const res = parseDxf(dxf(mtextEnt(1, 2, 0.3, "Welt\\Pzeile2")));
expect(res.texts![0].text).toBe("Welt zeile2");
expect(res.texts![0].at.x).toBeCloseTo(1, 9);
expect(res.texts![0].height).toBeCloseTo(0.3, 9);
});
it("leerer Text wird übersprungen", () => {
const res = parseDxf(dxf(textEnt(0, 0, 0.2, 0, "")));
expect(res.texts ?? []).toHaveLength(0);
});
it("fehlende Höhe → positiver Default", () => {
const g = ["0", "TEXT", "8", "0", "10", "0", "20", "0", "30", "0", "1", "X"];
const res = parseDxf(dxf(g));
expect(res.texts![0].height).toBeGreaterThan(0);
});
});
describe("textsToDrawings", () => {
it("ImportedText → Drawing2D {shape:\"text\"}", () => {
const drawings = textsToDrawings(
[{ at: { x: 1, y: 2 }, text: "Hi", height: 0.4, angle: 0, layer: "L" }],
"lvl", "active", "CODE", (s) => s,
);
expect(drawings).toHaveLength(1);
const g = drawings[0].geom;
expect(g.shape).toBe("text");
if (g.shape === "text") {
expect(g.text).toBe("Hi");
expect(g.height).toBeCloseTo(0.4, 9);
expect(g.at.x).toBeCloseTo(1, 9);
}
});
});
describe("parseDxf — Kurven behalten echte Geometrie (curve)", () => {
it("CIRCLE trägt curve {kind:circle}", () => {
const c = onlyContour(dxf(circle(10, 20, 5, 4)));
expect(c.curve?.kind).toBe("circle");
expect(c.curve?.cx).toBeCloseTo(10, 9);
expect(c.curve?.cy).toBeCloseTo(20, 9);
expect(c.curve?.r).toBeCloseTo(4, 9);
});
it("ARC trägt curve {kind:arc} mit Winkeln (Radiant)", () => {
const c = onlyContour(dxf(arc(0, 0, 10, 0, 90)));
expect(c.curve?.kind).toBe("arc");
expect(c.curve?.r).toBeCloseTo(10, 9);
expect(c.curve?.a0).toBeCloseTo(0, 9);
expect(c.curve?.a1).toBeCloseTo(Math.PI / 2, 9);
});
});
describe("contoursToDrawings — Kurven → glatte Formen", () => {
it("curve circle → {shape:\"circle\"}", () => {
const set: ContourSet = {
id: "s", type: "contourSet", name: "c",
contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }], curve: { kind: "circle", cx: 1, cy: 2, r: 3, a0: 0, a1: Math.PI * 2 } }],
};
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
expect(d.geom.shape).toBe("circle");
if (d.geom.shape === "circle") {
expect(d.geom.r).toBeCloseTo(3, 9);
expect(d.geom.center.x).toBeCloseTo(1, 9);
}
});
it("curve arc → {shape:\"arc\"}", () => {
const set: ContourSet = {
id: "s", type: "contourSet", name: "c",
contours: [{ z: 0, closed: false, pts: [{ x: 0, y: 0 }], curve: { kind: "arc", cx: 0, cy: 0, r: 5, a0: 0, a1: 1 } }],
};
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
expect(d.geom.shape).toBe("arc");
if (d.geom.shape === "arc") {
expect(d.geom.r).toBeCloseTo(5, 9);
expect(d.geom.a1).toBeCloseTo(1, 9);
}
});
});
describe("contoursToDrawings — HATCH-Füllung", () => { describe("contoursToDrawings — HATCH-Füllung", () => {
it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => { it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => {
const set: ContourSet = { const set: ContourSet = {
+68 -5
View File
@@ -24,9 +24,10 @@
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt). // • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler, // • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`). // Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`).
// • TEXT / MTEXT → ImportedText (Position/Höhe/Winkel) → Drawing2D `{shape:"text"}`.
import DxfParser from "dxf-parser"; import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types"; import type { Contour, ContourSet, ImportedMesh, ImportedText, Vec2 } from "../model/types";
/** /**
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl) * Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
@@ -41,10 +42,12 @@ export interface ImportDiagnostics {
total: number; total: number;
} }
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */ /** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze + Texte. */
export interface DxfImportResult { export interface DxfImportResult {
meshes: ImportedMesh[]; meshes: ImportedMesh[];
contours: ContourSet[]; contours: ContourSet[];
/** Importierte TEXT/MTEXT-Elemente (optional; der DWG-Parser füllt sie nicht). */
texts?: ImportedText[];
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */ /** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
diagnostics?: ImportDiagnostics; diagnostics?: ImportDiagnostics;
} }
@@ -97,6 +100,12 @@ interface DxfEntity {
rowSpacing?: number; rowSpacing?: number;
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler). // HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
rawCodes?: RawCode[]; rawCodes?: RawCode[];
// TEXT/MTEXT. `position` (MTEXT-Einfügepunkt) wird tolerant gelesen (oben als
// MESH-Vertexliste getippt). `rotation` (Grad) ist bereits oben deklariert.
startPoint?: DxfVertex; // TEXT-Einfügepunkt
textHeight?: number; // TEXT-Höhe (Code 40)
height?: number; // MTEXT-Höhe (Code 40)
text?: string; // Textinhalt (Code 1, MTEXT auch 3)
[k: string]: unknown; [k: string]: unknown;
} }
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */ /** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
@@ -140,6 +149,7 @@ export function parseDxf(text: string): DxfImportResult {
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…) const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
const meshIndices: number[] = []; const meshIndices: number[] = [];
const contours: Contour[] = []; const contours: Contour[] = [];
const texts: ImportedText[] = [];
for (const e of entities) { for (const e of entities) {
const type = (e.type ?? "").toUpperCase(); const type = (e.type ?? "").toUpperCase();
@@ -156,6 +166,11 @@ export function parseDxf(text: string): DxfImportResult {
addPolyfaceMesh(meshTriangles, meshIndices, e); addPolyfaceMesh(meshTriangles, meshIndices, e);
continue; continue;
} }
if (type === "TEXT" || type === "MTEXT") {
const it = textEntity(e, type);
if (it) texts.push(it);
continue;
}
// Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über // Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion. // den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
collectContours(e, blocks, contours, 0); collectContours(e, blocks, contours, 0);
@@ -182,7 +197,43 @@ export function parseDxf(text: string): DxfImportResult {
}); });
} }
return { meshes, contours: contourSets }; return { meshes, contours: contourSets, texts };
}
// ── TEXT / MTEXT ─────────────────────────────────────────────────────────────
/** Fallback-Schrifthöhe (Meter), wenn die DXF-Höhe fehlt/0 ist. */
const DEFAULT_TEXT_HEIGHT = 0.25;
/**
* MTEXT-Inhalt von Formatierungscodes säubern (grober erster Wurf): \P → Leer-
* zeichen, \~ → Leerzeichen, `\f…;`/`\H…;`/`\C…;`/`\A…;`-Sequenzen und die {}-
* Gruppenklammern entfernen, `\{`/`\}`/`\\` entschärfen. Komplexe Verschachte-
* lungen werden flachgeklopft (dokumentierte Grenze).
*/
function sanitizeMText(s: string): string {
return s
.replace(/\\P/gi, " ")
.replace(/\\~/g, " ")
.replace(/\\[A-Za-z][^;\\]*;/g, "")
.replace(/[{}]/g, "")
.replace(/\\([{}\\])/g, "$1")
.trim();
}
/** TEXT/MTEXT-Entity → ImportedText (Position/Höhe/Winkel), oder null bei leer. */
function textEntity(e: DxfEntity, type: string): ImportedText | null {
const rawText = typeof e.text === "string" ? e.text : "";
const content = type === "MTEXT" ? sanitizeMText(rawText) : rawText;
if (!content) return null;
const at: Vec2 =
type === "MTEXT"
? { x: coord(e.position, "x"), y: coord(e.position, "y") }
: { x: e.startPoint?.x ?? 0, y: e.startPoint?.y ?? 0 };
const rawHeight = type === "MTEXT" ? numOr(e.height, 0) : numOr(e.textHeight, 0);
const height = rawHeight > 0 ? rawHeight : DEFAULT_TEXT_HEIGHT;
const angle = (numOr(e.rotation, 0) * Math.PI) / 180; // DXF: Grad CCW → Radiant
return { at, text: content, height, angle, layer: e.layer };
} }
// ── Mesh-Entities ──────────────────────────────────────────────────────────── // ── Mesh-Entities ────────────────────────────────────────────────────────────
@@ -393,7 +444,13 @@ function arcContour(e: DxfEntity): Contour | null {
const t = start + (sweep * i) / segs; const t = start + (sweep * i) / segs;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
} }
return { z, pts, closed: false, layer: e.layer }; return {
z,
pts,
closed: false,
layer: e.layer,
curve: { kind: "arc", cx, cy, r, a0: start, a1: start + sweep },
};
} }
/** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */ /** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */
@@ -420,7 +477,13 @@ function circleContour(e: DxfEntity): Contour | null {
const t = (Math.PI * 2 * i) / segs; const t = (Math.PI * 2 * i) / segs;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) }); pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
} }
return { z, pts, closed: true, layer: e.layer }; return {
z,
pts,
closed: true,
layer: e.layer,
curve: { kind: "circle", cx, cy, r, a0: 0, a1: Math.PI * 2 },
};
} }
/** /**
+43 -1
View File
@@ -15,7 +15,7 @@
// //
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types"; import type { Contour, ContourSet, Drawing2D, Drawing2DGeom, ImportedText } from "../model/types";
/** Wie DXF-Layer auf Kategorien abgebildet werden. */ /** Wie DXF-Layer auf Kategorien abgebildet werden. */
export type CategoryMode = "active" | "byLayer"; export type CategoryMode = "active" | "byLayer";
@@ -66,8 +66,50 @@ export function contoursToDrawings(
return out; return out;
} }
/**
* Wandelt importierte Texte in Drawing2D-`text`-Elemente um — analog
* `contoursToDrawings` (gleiche Layer→Kategorie-Zuordnung). Winkel/Höhe kommen
* 1:1 aus `ImportedText` (Höhe in Modell-Metern, Winkel in Radiant).
*/
export function textsToDrawings(
texts: ImportedText[],
levelId: string,
categoryMode: CategoryMode,
fallbackCode: string,
layerToCode: (layerName: string) => string,
): Drawing2D[] {
const out: Drawing2D[] = [];
for (const it of texts) {
const layerName = it.layer ?? "";
const categoryCode =
categoryMode === "byLayer" && layerName ? layerToCode(layerName) : fallbackCode;
out.push({
id: nextId(),
type: "drawing2d",
levelId,
categoryCode,
geom: {
shape: "text",
at: { ...it.at },
text: it.text,
height: it.height,
angle: it.angle,
},
});
}
return out;
}
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */ /** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
function contourGeom(contour: Contour): Drawing2DGeom | null { function contourGeom(contour: Contour): Drawing2DGeom | null {
// Echte Kurve (CIRCLE/ARC) → GLATTE Kreis-/Bogen-Form statt tesselliertem Vieleck.
const c = contour.curve;
if (c) {
if (c.kind === "circle") {
return { shape: "circle", center: { x: c.cx, y: c.cy }, r: c.r };
}
return { shape: "arc", center: { x: c.cx, y: c.cy }, r: c.r, a0: c.a0, a1: c.a1 };
}
const pts = contour.pts; const pts = contour.pts;
if (pts.length < 2) return null; if (pts.length < 2) return null;
if (pts.length === 2 && !contour.closed) { if (pts.length === 2 && !contour.closed) {
+14 -18
View File
@@ -16,22 +16,18 @@ export const sampleProject: Project = {
id: "demo", id: "demo",
name: "Demo-Haus", name: "Demo-Haus",
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ────────────────── // ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line" // Linienstile: aufgeräumt auf kanonische VOLLLINIEN je Strichstärke (dash=null)
// ist der dünne Strich für Schraffur-Muster. // + eine gestrichelte Linie. Früher gab es drei funktional identische 0.13-
// Volllinien ("thin"/"hatch-line"/"joint-massive") — zusammengeführt auf "thin"
// ("Volllinie 0.13"); Schraffur-/Schichtfugen-Referenzen zeigen jetzt darauf.
// (Die ids bleiben stabil, damit geladene Projekte + interne Referenzen halten.)
lineStyles: [ lineStyles: [
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null }, { id: "hatch-hair", name: "Volllinie 0.02", weight: 0.02, color: "#1a1a1a", dash: null },
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null }, { id: "thin", name: "Volllinie 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null }, { id: "medium", name: "Volllinie 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren). { id: "thick", name: "Volllinie 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
// Feinste Haarlinie für die SIA-Schnittschraffuren (Beton/Backstein/Dämmung):
// Gewicht unter der Bildschirm-Untergrenze, damit die Musterlinien als dünnste
// Haarlinie (Stroke-Minimum) rendern.
{ id: "hatch-hair", name: "Schraffur-Haarlinie", weight: 0.02, color: "#1a1a1a", dash: null },
// Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke). // Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke).
{ id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] }, { id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] },
// Kräftige Schichtfuge, wo zwei massive Materialien aneinanderstoßen (Wandstile).
{ id: "joint-massive", name: "Schichtfuge 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
], ],
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad. // Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
// "none" = keine Schraffur (nur Component-Füllung). // "none" = keine Schraffur (nur Component-Füllung).
@@ -46,7 +42,7 @@ export const sampleProject: Project = {
scale: 1, scale: 1,
angle: 0, angle: 0,
color: "#1a1a1a", color: "#1a1a1a",
lineStyleId: "hatch-line", lineStyleId: "thin",
}, },
{ {
id: "diagonal", id: "diagonal",
@@ -55,7 +51,7 @@ export const sampleProject: Project = {
scale: 1, scale: 1,
angle: 45, angle: 45,
color: "#1a1a1a", color: "#1a1a1a",
lineStyleId: "hatch-line", lineStyleId: "thin",
}, },
{ {
id: "crosshatch", id: "crosshatch",
@@ -64,7 +60,7 @@ export const sampleProject: Project = {
scale: 1, scale: 1,
angle: 0, angle: 0,
color: "#1a1a1a", color: "#1a1a1a",
lineStyleId: "hatch-line", lineStyleId: "thin",
}, },
{ {
id: "solid-concrete", id: "solid-concrete",
@@ -133,7 +129,7 @@ export const sampleProject: Project = {
layers: [ layers: [
{ componentId: "render-ext", thickness: 0.02 }, { componentId: "render-ext", thickness: 0.02 },
// Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm. // Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm.
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "joint-massive" }, { componentId: "insulation", thickness: 0.16, jointLineStyleId: "thin" },
{ componentId: "brick", thickness: 0.15 }, { componentId: "brick", thickness: 0.15 },
{ componentId: "render-int", thickness: 0.015 }, { componentId: "render-int", thickness: 0.015 },
], ],
@@ -160,7 +156,7 @@ export const sampleProject: Project = {
// Estrich (schwimmend) → Trittschalldämmung. // Estrich (schwimmend) → Trittschalldämmung.
{ componentId: "screed", thickness: 0.06 }, { componentId: "screed", thickness: 0.06 },
// Innere Fuge der Dämmung trifft auf die Betondecke (massiv) → 0.13 mm. // Innere Fuge der Dämmung trifft auf die Betondecke (massiv) → 0.13 mm.
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "joint-massive" }, { componentId: "insulation", thickness: 0.04, jointLineStyleId: "thin" },
// Betondecke (Rohdecke, tragend) — innerste/unterste Schicht. // Betondecke (Rohdecke, tragend) — innerste/unterste Schicht.
{ componentId: "concrete", thickness: 0.2 }, { componentId: "concrete", thickness: 0.2 },
], ],
+27
View File
@@ -1098,6 +1098,33 @@ export interface Contour {
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss. * geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
*/ */
filled?: boolean; filled?: boolean;
/**
* Wahre Kurvengeometrie (aus CIRCLE/ARC). `pts` bleibt tesselliert (Kontext/
* 3D), aber der Drawing-Import baut daraus eine GLATTE `{shape:"circle"|"arc"}`-
* Form statt eines Vielecks. Winkel in RADIANT (CCW); Kreis = a0..a1 über 2π.
*/
curve?: {
kind: "circle" | "arc";
cx: number;
cy: number;
r: number;
a0: number;
a1: number;
};
}
/**
* Ein importiertes Text-Element (DXF TEXT/MTEXT). `at` = Einfügepunkt in Metern,
* `height` = Schrifthöhe in Modell-Metern, `angle` = Drehung in RADIANT (CCW).
* Wird beim Drawing-Import zu einem `{shape:"text"}`-Drawing2D.
*/
export interface ImportedText {
at: Vec2;
text: string;
height: number;
angle: number;
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
layer?: string;
} }
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */ /** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
+63 -19
View File
@@ -30,6 +30,16 @@ import type { AttributeSource } from "../model/types";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import { Dropdown } from "../ui/Dropdown"; import { Dropdown } from "../ui/Dropdown";
import { ColorHexField } from "../ui/ColorHexField"; import { ColorHexField } from "../ui/ColorHexField";
// Element-spezifische Attribut-Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) —
// aus ObjectInfoPanel hierher verschoben (Ribbon Phase 3: ObjectInfo trägt nur
// noch Bezugspunkt + Masse).
import {
WallSection,
CeilingSection,
OpeningSection,
StairSection,
RoomSection,
} from "./ObjectInfoPanel";
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld: /** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
* „custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht * „custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht
@@ -47,11 +57,38 @@ export function AttributesPanel() {
const sel = host.selection; const sel = host.selection;
const { project } = host; const { project } = host;
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist. // Wandtyp-/Deckentyp-Wahl für das NEUE Bauteil (was gezeichnet wird) — früher
// in der Werkzeug-Sidebar (ToolsPanel), seit deren Entfernung hier. Erscheint
// nur bei aktivem Wand-/Decken-Werkzeug (unabhängig von einer Auswahl); Wand und
// Decke teilen sich denselben Aufbau-Typ (WallType) + Host-State.
const drawingComponent =
host.activeTool === "wall" || host.activeTool === "ceiling";
const typeSection = drawingComponent ? (
<>
<div className="attr-section-label">{t("attr.newComponent")}</div>
<div className="attr-grid">
<span className="attr-key">
{t(host.activeTool === "wall" ? "tool.wallType" : "tool.ceilingType")}
</span>
<span className="attr-val">
<Dropdown
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(v) => host.onActiveWallTypeId(v)}
options={project.wallTypes.map((wt) => ({ value: wt.id, label: wt.name }))}
/>
</span>
</div>
</>
) : null;
// Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein
// kompakter Hinweis.
if (sel === null) { if (sel === null) {
return ( return (
<div className="attr-panel"> <div className="attr-panel">
<div className="attr-empty">{t("attr.empty")}</div> {typeSection}
{!typeSection && <div className="attr-empty">{t("attr.empty")}</div>}
</div> </div>
); );
} }
@@ -80,7 +117,6 @@ export function AttributesPanel() {
onChange: (next: UiSource) => void, onChange: (next: UiSource) => void,
) => ( ) => (
<Dropdown <Dropdown
width={104}
value={ui} value={ui}
disabled={!editable} disabled={!editable}
onChange={(v) => onChange(v as UiSource)} onChange={(v) => onChange(v as UiSource)}
@@ -110,7 +146,7 @@ export function AttributesPanel() {
) => ( ) => (
<> <>
<span className="attr-key">{t(labelKey)}</span> <span className="attr-key">{t(labelKey)}</span>
<span className="attr-val" style={{ gap: 6 }}> <span className="attr-val">
{ui === "custom" && valueEditor} {ui === "custom" && valueEditor}
{sourceSelect(editable, ui, onSource)} {sourceSelect(editable, ui, onSource)}
</span> </span>
@@ -159,11 +195,15 @@ export function AttributesPanel() {
return ( return (
<div className="attr-panel"> <div className="attr-panel">
<div className="attr-title">{t("attr.title")}</div> {/* Typ-Picker für das neue Bauteil (nur bei aktivem Wand-/Decken-Werkzeug). */}
{typeSection}
{/* ── Stift/Strich ──────────────────────────────────────────────── */} {/* EIN durchgehendes Grid über alle Sektionen — dieselben zwei Spalten
<div className="attr-section-label">{t("attr.stroke")}</div> (Label | Wert) für Stift/Füllung/Maße, damit die Wertfelder überall
bündig in EINER Spalte stehen. Sektion-Balken spannen beide Spalten. */}
<div className="attr-grid"> <div className="attr-grid">
{/* ── Stift/Strich ──────────────────────────────────────────── */}
<div className="attr-section-label">{t("attr.stroke")}</div>
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */} {/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
<span className="attr-key">{t("attr.color")}</span> <span className="attr-key">{t("attr.color")}</span>
<span className="attr-val"> <span className="attr-val">
@@ -208,7 +248,6 @@ export function AttributesPanel() {
onChange={(v) => onChange={(v) =>
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v) host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
} }
width={120}
options={[ options={[
{ value: "__none__", label: t("attr.lineStyle.none") }, { value: "__none__", label: t("attr.lineStyle.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })), ...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
@@ -218,14 +257,13 @@ export function AttributesPanel() {
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span> <span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
)} )}
</span> </span>
</div>
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────────── */} {/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */}
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/Poché. {/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/
Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) / Nach Bauteil Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) /
(erbt vom Component-Manager, Default) / eigener Wert (Override). */} Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */}
<div className="attr-section-label">{t("attr.fill")}</div> <div className="attr-section-label">{t("attr.fill")}</div>
<div className="attr-grid">
{sourceRow( {sourceRow(
"attr.foreground", "attr.foreground",
pocheEditable, pocheEditable,
@@ -248,7 +286,6 @@ export function AttributesPanel() {
hatchUi, hatchUi,
onHatchSourceChange, onHatchSourceChange,
<Dropdown <Dropdown
width={104}
value={sel.fillHatchId ?? ""} value={sel.fillHatchId ?? ""}
onChange={(v) => host.onSetSelectionFill(v || null)} onChange={(v) => host.onSetSelectionFill(v || null)}
options={[ options={[
@@ -257,16 +294,23 @@ export function AttributesPanel() {
]} ]}
/>, />,
)} )}
</div>
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */} {/* ── Maße (informativ, aus der bbox) ───────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div> <div className="attr-section-label">{t("attr.size")}</div>
<div className="attr-grid">
<span className="attr-key">{t("attr.width")}</span> <span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span> <span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span> <span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span> <span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</div> </div>
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) —
nur der jeweils passende erscheint. Nutzen weiter die `objinfo-*`-
Darstellung + die Host-Handler. */}
{sel.wall && <WallSection wall={sel.wall} host={host} />}
{sel.ceiling && <CeilingSection ceiling={sel.ceiling} host={host} />}
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
</div> </div>
); );
} }
+28 -49
View File
@@ -82,40 +82,14 @@ export function ObjectInfoPanel() {
host.onResizeSelection(nextWidth, nextHeight, anchor); host.onResizeSelection(nextWidth, nextHeight, anchor);
} }
const wall = sel.wall;
const ceiling = sel.ceiling;
const opening = sel.opening;
const stair = sel.stair;
const room = sel.room;
return ( return (
<div className="objinfo-panel"> <div className="objinfo-panel">
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das {/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */} (Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel;
ObjectInfo trägt nur noch Bezugspunkt + Masse. */}
<div className="objinfo-head"> <div className="objinfo-head">
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span> <span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
{wall ? (
<div className="objinfo-head-ref" title={t("objinfo.wall.refLine")}>
<span className="objinfo-head-ref-label">
{t("objinfo.wall.refLine")}
</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) =>
host.onSetWallReferenceLine(v as WallReferenceLine)
}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
) : (
<span className="objinfo-cat">{sel.categoryCode}</span> <span className="objinfo-cat">{sel.categoryCode}</span>
)}
</div> </div>
<div className="objinfo-sep" /> <div className="objinfo-sep" />
@@ -174,21 +148,9 @@ export function ObjectInfoPanel() {
value={height} value={height}
onCommit={(h) => commitSize(width, h)} onCommit={(h) => commitSize(width, h)}
/> />
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */} werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
{wall && <WallSection wall={wall} host={host} />} die exportierten *Section-Komponenten). */}
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
{opening && <OpeningSection opening={opening} host={host} />}
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
{stair && <StairSection stair={stair} host={host} />}
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
</div> </div>
); );
} }
@@ -197,7 +159,7 @@ export function ObjectInfoPanel() {
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche // Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text- // + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
// Stempel-Editor öffnet. Alle Werte/Setter über den Host. // Stempel-Editor öffnet. Alle Werte/Setter über den Host.
function RoomSection({ export function RoomSection({
room, room,
roomId, roomId,
host, host,
@@ -269,7 +231,7 @@ function RoomSection({
// ── Treppen-Attribut-Abschnitt ─────────────────────────────────────────────── // ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete // Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only. // Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
function StairSection({ export function StairSection({
stair, stair,
host, host,
}: { }: {
@@ -385,7 +347,7 @@ function StairSection({
// ── Öffnungs-Attribut-Abschnitt ────────────────────────────────────────────── // ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen // Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only. // zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
function OpeningSection({ export function OpeningSection({
opening, opening,
host, host,
}: { }: {
@@ -557,7 +519,7 @@ function OpeningSection({
// ── Decken-Attribut-Abschnitt ──────────────────────────────────────────────── // ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss + // Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// OK-Bindung, Fläche. Alle Werte/Setter über den Host. // OK-Bindung, Fläche. Alle Werte/Setter über den Host.
function CeilingSection({ export function CeilingSection({
ceiling, ceiling,
host, host,
}: { }: {
@@ -670,7 +632,7 @@ function CeilingSection({
// ── Wand-Attribut-Abschnitt ────────────────────────────────────────────────── // ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss + // Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host. // Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
function WallSection({ export function WallSection({
wall, wall,
host, host,
}: { }: {
@@ -701,6 +663,23 @@ function WallSection({
<div className="objinfo-sep" /> <div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div> <div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
{/* Referenzlinie (wo die Achse über die Dicke liegt) — früher im ObjectInfo-
Kopf, jetzt hier bei den Wand-Attributen. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.refLine")}</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) => host.onSetWallReferenceLine(v as WallReferenceLine)}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
{/* Aufbau-Typ-Segment. */} {/* Aufbau-Typ-Segment. */}
<div className="objinfo-field"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span> <span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
+18 -40
View File
@@ -13,13 +13,12 @@ import { t } from "../i18n";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import type { ToolId } from "./host"; import type { ToolId } from "./host";
import { TOOL_ORDER, getTool } from "../tools/tools"; import { TOOL_ORDER, getTool } from "../tools/tools";
import { Dropdown } from "../ui/Dropdown";
// ── Icons ────────────────────────────────────────────────────────────────── // ── Icons ──────────────────────────────────────────────────────────────────
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug // Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der // (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
// Zeile übernimmt. // Zeile übernimmt.
function ToolIcon({ id }: { id: ToolId }) { export function ToolIcon({ id }: { id: ToolId }) {
const common = { const common = {
width: 16, width: 16,
height: 16, height: 16,
@@ -111,6 +110,20 @@ function ToolIcon({ id }: { id: ToolId }) {
<rect x="2.5" y="3.5" width="11" height="9" /> <rect x="2.5" y="3.5" width="11" height="9" />
</svg> </svg>
); );
case "circle":
// Kreis-Umriss.
return (
<svg {...common}>
<circle cx="8" cy="8" r="5.5" />
</svg>
);
case "arc":
// Offener Halbbogen.
return (
<svg {...common}>
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
</svg>
);
default: default:
return null; return null;
} }
@@ -148,44 +161,9 @@ export function ToolsPanel() {
); );
})} })}
<div className="tools-sep" /> {/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */} erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
{host.activeTool === "wall" && (
<label className="tools-field">
<span>{t("tool.wallType")}</span>
<Dropdown
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(v) => host.onActiveWallTypeId(v)}
options={host.project.wallTypes.map((wt) => ({
value: wt.id,
label: wt.name,
}))}
/>
</label>
)}
{host.activeTool === "wall" && <div className="tools-sep" />}
{/* Deckentyp-Auswahl — nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
{host.activeTool === "ceiling" && (
<label className="tools-field">
<span>{t("tool.ceilingType")}</span>
<Dropdown
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(v) => host.onActiveWallTypeId(v)}
options={host.project.wallTypes.map((wt) => ({
value: wt.id,
label: wt.name,
}))}
/>
</label>
)}
{host.activeTool === "ceiling" && <div className="tools-sep" />}
</div> </div>
); );
} }
+13 -6
View File
@@ -31,7 +31,10 @@ import type {
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts * der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze). * sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
*/ */
export const LAYOUT_VERSION = 7; // v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
export const LAYOUT_VERSION = 8;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */ /** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout"; const CURRENT_KEY = "cad.layout";
@@ -55,9 +58,12 @@ const NAMED_PREFIX = "cad.layouts.";
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein * OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht. * schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
*/ */
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default-
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
const DEFAULT_LEFT_GROUPS: DockGroup[] = [ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["tools"], activeTab: "tools", weight: 1 }, { tabs: ["attributes"], activeTab: "attributes", weight: 1 },
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
]; ];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [ const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 }, { tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
@@ -86,9 +92,10 @@ function cloneGroups(groups: DockGroup[]): DockGroup[] {
// ── Standard ──────────────────────────────────────────────────────────────── // ── Standard ────────────────────────────────────────────────────────────────
/** /**
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben, * Liefert den eingebauten Startzustand: links das Attribute-Panel (volle Höhe),
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische * rechts Objektinfo/Raumbilanz oben + Zeichnungsebenen/Ebenen/Umgebung unten.
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können. * Die Werkzeuge liegen im Ribbon (keine Sidebar mehr). Jeder Aufruf erzeugt
* frische Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
*/ */
export function defaultLayout(): LayoutState { export function defaultLayout(): LayoutState {
return { return {
+85 -1
View File
@@ -131,6 +131,8 @@ export interface PlanViewHandle {
applyScale: (n: number) => void; applyScale: (n: number) => void;
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */ /** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
pxPerMeter: () => number; pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2;
} }
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */ /** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -631,6 +633,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (n > 0) setPaperScale(n); if (n > 0) setPaperScale(n);
}, },
pxPerMeter: () => currentPxPerMeter(), pxPerMeter: () => currentPxPerMeter(),
viewCenterModel: () => {
// Mitte der frischen viewBox (aus Ref) → Modell (inverse toScreen).
const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
},
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[], [],
@@ -1640,7 +1647,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich // Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay. // zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien. // Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
(useGpuRenderer ? wantWasm || p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : ( // `drawingText` (importierter Einzeltext) rendert IMMER im SVG — der GL-
// wie der WASM-Renderer zeichnet ihn nicht. Der Raum-Stempel (`text`)
// bleibt: unter WASM rastert ihn render2d selbst (sonst doppelt).
(useGpuRenderer
? wantWasm
? p.kind !== "drawingText"
: p.kind !== "text" && p.kind !== "drawingText"
: p.kind === "line" && p.drawingId)
? null : (
<PrimitiveShape <PrimitiveShape
key={i} key={i}
p={p} p={p}
@@ -3065,5 +3080,74 @@ function renderPrimitive(
</text> </text>
); );
} }
case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
const c = toScreen(p.at);
const fontSize = p.heightM * PX_PER_M;
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
const deg = -(p.angle * 180) / Math.PI;
return (
<text
x={c.x}
y={c.y}
fontSize={fontSize}
fontFamily="var(--font-ui, sans-serif)"
fill={p.color}
opacity={p.greyed ? 0.45 : undefined}
textAnchor="start"
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{p.text}
</text>
);
}
case "drawingCircle": {
// Echtes `<circle>` — rund bei jedem Zoom (kein Vieleck). Füllung optional.
const c = toScreen(p.center);
const r = p.r * PX_PER_M;
return (
<circle
cx={c.x}
cy={c.y}
r={r}
fill={p.fill}
stroke={p.stroke}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
vectorEffect={vfx}
/>
);
}
case "drawingArc": {
// Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist
// gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn
// (SVG sweep-flag 1). large-arc aus der Spannweite (>π).
const TAU = Math.PI * 2;
const rPx = p.r * PX_PER_M;
const from = toScreen({
x: p.center.x + p.r * Math.cos(p.a0),
y: p.center.y + p.r * Math.sin(p.a0),
});
const to = toScreen({
x: p.center.x + p.r * Math.cos(p.a1),
y: p.center.y + p.r * Math.sin(p.a1),
});
const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU;
const largeArc = sweep > Math.PI ? 1 : 0;
const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`;
return (
<path
d={d}
fill="none"
stroke={p.stroke}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
vectorEffect={vfx}
/>
);
}
} }
} }
+17
View File
@@ -147,3 +147,20 @@ describe("generatePlan — Tür-Typ wandoeffnung (Item 6)", () => {
expect(arcCount(def)).toBe(1); expect(arcCount(def)).toBe(1);
}); });
}); });
/** Zählt Fenster-Anschlag-/Laibungsstriche (cls "window-frame") im Plan. */
function windowRevealCount(p: Project, detail: "grob" | "mittel" | "fein") {
return generatePlan(p, "eg", visible, undefined, detail).primitives.filter(
(pr) => pr.kind === "line" && pr.cls === "window-frame",
).length;
}
describe("generatePlan — Fenster Anschlag-Striche (Item 8)", () => {
it("erzeugt bei 'fein' zwei Laibungsstriche (je Pfosten einen)", () => {
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "fein")).toBe(2);
});
it("erzeugt bei 'mittel' KEINE (nur im Detailgrad fein)", () => {
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "mittel")).toBe(0);
});
});
+152 -24
View File
@@ -98,6 +98,10 @@ const MONO_INK = "#111111";
const LAYER_LINE_MM = 0.02; const LAYER_LINE_MM = 0.02;
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */ /** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
const SYMBOL_HAIRLINE_MM = 0.02; const SYMBOL_HAIRLINE_MM = 0.02;
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
const OVERHEAD_DASH: number[] = [0.4, 0.25];
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */ /** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = { const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
grob: 1.6, // dickere Sammellinie grob: 1.6, // dickere Sammellinie
@@ -294,6 +298,52 @@ export type Primitive =
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */ /** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
roomId?: string; roomId?: string;
greyed?: boolean; greyed?: boolean;
}
| {
// Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
// z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
// er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
kind: "drawingText";
/** Ankerpunkt im Modell (Meter). */
at: Vec2;
text: string;
/** Schrifthöhe in Modell-Metern. */
heightM: number;
/** Drehung in RADIANT (CCW im Modell). */
angle: number;
color: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
drawingId?: string;
greyed?: boolean;
}
| {
// Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als
// echtes `<circle>` gerendert (rund bei jedem Zoom), nicht als Vieleck.
kind: "drawingCircle";
center: Vec2;
/** Radius in Modell-Metern. */
r: number;
/** Vollton-Füllfarbe oder "none". */
fill: string;
stroke: string;
weightMm: number;
dash?: number[] | null;
drawingId?: string;
greyed?: boolean;
}
| {
// Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als
// SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell).
kind: "drawingArc";
center: Vec2;
r: number;
a0: number;
a1: number;
stroke: string;
weightMm: number;
dash?: number[] | null;
drawingId?: string;
greyed?: boolean;
}; };
/** /**
@@ -592,13 +642,9 @@ export function generatePlan(
for (const ceiling of ceilings) { for (const ceiling of ceilings) {
const greyed = categoryDisplay(ceiling.categoryCode).greyed; const greyed = categoryDisplay(ceiling.categoryCode).greyed;
const category = catByCode.get(ceiling.categoryCode); const category = catByCode.get(ceiling.categoryCode);
const lwMm = resolveStrokeWeight( // Kein lwMm mehr: der Decken-Umriss ist eine feste Überkopf-Haarlinie
ceiling.strokeWeight, // (gestrichelt), unabhängig von der Kategorie-Strichstärke.
ceiling.strokeWeightSource, addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
category,
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
);
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category, wallFootprints);
} }
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel // Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
@@ -631,7 +677,14 @@ export function generatePlan(
const openings = openingsOfWall(project, wall.id); const openings = openingsOfWall(project, wall.id);
const gaps = wallGaps(wall, doors, openings); const gaps = wallGaps(wall, doors, openings);
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null }; const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory); // Schnitt vs. Ansicht nach Schnitthöhe (BIM-Standard): erreicht die Wand die
// Grundriss-Schnittebene nicht (Höhe < Schnitthöhe des Geschosses, z. B. eine
// Brüstung/ein Podestrand), wird sie nur als Ansichts-Umriss gezeichnet —
// keine Schnitt-Poché. Normale Wände (Höhe ≥ Schnitthöhe) sind unberührt.
const wallViewOnly = wall.height < (floor?.cutHeight ?? 1.0);
addWallPoche(
primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory, wallViewOnly,
);
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste // Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt. // umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed); if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
@@ -1000,20 +1053,48 @@ function addDrawing2D(
seg(c4, c1); seg(c4, c1);
break; break;
} }
case "circle": { case "circle":
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die // Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv. // optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
const N = 64; out.push({
const pts: Vec2[] = []; kind: "drawingCircle",
for (let i = 0; i < N; i++) { center: g.center,
const t = (i / N) * Math.PI * 2; r: g.r,
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) }); fill: background ?? d.fillColor ?? "none",
} stroke: color,
fillPoly(pts); weightMm,
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]); dash,
greyed,
drawingId: d.id,
});
break;
case "arc":
// Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
out.push({
kind: "drawingArc",
center: g.center,
r: g.r,
a0: g.a0,
a1: g.a1,
stroke: color,
weightMm,
dash,
greyed,
drawingId: d.id,
});
break;
case "text":
out.push({
kind: "drawingText",
at: g.at,
text: g.text,
heightM: g.height,
angle: g.angle,
color,
greyed,
drawingId: d.id,
});
break; break;
}
// arc/text: Primitive-Erweiterungen folgen später.
default: default:
break; break;
} }
@@ -1184,6 +1265,10 @@ function addWallPoche(
detail: DetailLevel, detail: DetailLevel,
wallLwMm: number, wallLwMm: number,
category: LayerCategory | undefined, category: LayerCategory | undefined,
// Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
// 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
// Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
viewOnly: boolean,
): void { ): void {
const wt = getWallType(project, wall); const wt = getWallType(project, wall);
const total = wallTypeThickness(wt); const total = wallTypeThickness(wt);
@@ -1252,6 +1337,24 @@ function addWallPoche(
const startCut = s <= 1e-6 ? cuts.startCut : null; const startCut = s <= 1e-6 ? cuts.startCut : null;
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null; const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
if (viewOnly) {
// Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
// als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
// bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
out.push({
kind: "polygon",
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
fill: "none",
stroke,
strokeWidthMm: layerLineMm,
hatch: NO_HATCH,
greyed,
noStrokeEdges: joinEdges(startCut, endCut),
wallId,
});
continue;
}
if (detail === "grob") { if (detail === "grob") {
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss. // Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
out.push({ out.push({
@@ -1730,6 +1833,30 @@ function addOpeningSymbol(
openingId: o.id, openingId: o.id,
}); });
} }
// fein: Anschlag-/Laibungsstriche an beiden Pfosten (kurz quer zur Wand) —
// analog zur Tür, damit Fenster im Detailplan gleich „fertig" wirken (bisher
// hatten nur Türen sie). Haarlinie, 6 cm Andeutung je Seite.
if (detail === "fein") {
const jambs = openingJambs(wall, o);
const dir = sub(wall.end, wall.start);
const len = Math.hypot(dir.x, dir.y);
if (jambs && len > 1e-9) {
const n = { x: -dir.y / len, y: dir.x / len };
const reveal = 0.06;
for (const jamb of [jambs.jambStart, jambs.jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(n, reveal)),
b: add(jamb, scale(n, -reveal)),
cls: "window-frame",
weightMm: SYMBOL_HAIRLINE_MM,
color: o.color ?? POCHE_STROKE,
greyed,
openingId: o.id,
});
}
}
}
// Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse. // Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse.
// Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in // Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
// `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin. // `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
@@ -1801,7 +1928,6 @@ function addCeilingPoche(
ceiling: Ceiling, ceiling: Ceiling,
greyed: boolean, greyed: boolean,
detail: DetailLevel, detail: DetailLevel,
lwMm: number,
category: LayerCategory | undefined, category: LayerCategory | undefined,
wallFootprints: WallFootprint[], wallFootprints: WallFootprint[],
): void { ): void {
@@ -1810,7 +1936,6 @@ function addCeilingPoche(
const wt = getCeilingType(project, ceiling); const wt = getCeilingType(project, ceiling);
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null; const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
const stroke = ceiling.color ?? POCHE_STROKE; const stroke = ceiling.color ?? POCHE_STROKE;
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
const ceilingId = ceiling.id; const ceilingId = ceiling.id;
// Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss // Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
@@ -1871,7 +1996,10 @@ function addCeilingPoche(
a: add(a, scale(d, t0)), a: add(a, scale(d, t0)),
b: add(a, scale(d, t1)), b: add(a, scale(d, t1)),
cls: "ceiling-outline", cls: "ceiling-outline",
weightMm: outlineMm, // Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) →
// gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention).
weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
dash: OVERHEAD_DASH,
color: stroke, color: stroke,
greyed, greyed,
}); });
+135
View File
@@ -0,0 +1,135 @@
/**
* Unit-Tests für Schnitt vs. Ansicht nach Schnitthöhe" (generatePlan):
* Eine Wand, die die Grundriss-Schnittebene NICHT erreicht (Höhe < `cutHeight`,
* z. B. eine Brüstung), wird nur als Ansichts-Umriss gezeichnet Poché-Polygone
* OHNE Füllung (`fill:"none"`), keine Schraffur. Eine normale Wand (Höhe
* Schnitthöhe) behält die geschnittene Poché (gefüllte Bänder).
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project, Wall } from "../model/types";
/** Minimalprojekt: eine normale Wand (2.6 m) + eine niedrige Wand (0.3 m). */
function project(): Project {
const wall = (id: string, start: Wall["start"], end: Wall["end"], height: number): Wall => ({
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
start,
end,
wallTypeId: "aw",
height,
});
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [
wall("HI", { x: 0, y: 0 }, { x: 4, y: 0 }, 2.6), // erreicht Schnitt → Poché
wall("LOW", { x: 0, y: 2 }, { x: 4, y: 2 }, 0.3), // Brüstung → nur Ansicht
],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20"]);
describe("generatePlan — Schnitt vs. Ansicht nach Schnitthöhe", () => {
it("zeichnet die niedrige Wand nur als Ansichts-Umriss (fill none), die normale mit Poché", () => {
const plan = generatePlan(project(), "eg", visible, undefined, "mittel");
const wallPolys = (id: string) =>
plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> =>
p.kind === "polygon" && p.wallId === id,
);
const hi = wallPolys("HI");
const low = wallPolys("LOW");
expect(hi.length).toBeGreaterThan(0);
expect(low.length).toBeGreaterThan(0);
// Normale Wand: mindestens ein gefülltes Poché-Band (Bauteil-Farbe).
expect(hi.some((p) => p.fill !== "none")).toBe(true);
// Niedrige Wand: ALLE Polygone ohne Füllung (reiner Ansichts-Umriss).
expect(low.every((p) => p.fill === "none")).toBe(true);
});
it("behandelt die niedrige Wand als geschnitten, wenn die Schnitthöhe darunter liegt", () => {
const p = project();
p.drawingLevels[0].cutHeight = 0.2; // Schnittebene unter 0.3 m → auch LOW geschnitten
const plan = generatePlan(p, "eg", visible, undefined, "mittel");
const low = plan.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "polygon" }> =>
pr.kind === "polygon" && pr.wallId === "LOW",
);
expect(low.some((pr) => pr.fill !== "none")).toBe(true);
});
});
// ── Decke über der Schnittebene → gestrichelte Überkopf-Umrisslinie ──────────
/** Minimalprojekt mit EINER Decke (Slab) ohne Wände → voller Umriss sichtbar. */
function ceilingProject(): Project {
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [
{
id: "C1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 2 },
{ x: 0, y: 2 },
],
wallTypeId: "aw",
},
],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
describe("generatePlan — Decke = gestrichelte Überkopf-Ansicht", () => {
it("zeichnet den freien Decken-Umriss gestrichelt (Überkopf-Konvention)", () => {
const plan = generatePlan(ceilingProject(), "eg", new Set(["30"]), undefined, "mittel");
const outlines = plan.primitives.filter(
(p): p is Extract<typeof p, { kind: "line" }> =>
p.kind === "line" && p.cls === "ceiling-outline",
);
expect(outlines.length).toBeGreaterThan(0);
// JEDE Decken-Umrisslinie ist gestrichelt (dash gesetzt, nicht null).
expect(outlines.every((p) => Array.isArray(p.dash) && p.dash.length >= 2)).toBe(true);
});
});
+48
View File
@@ -476,6 +476,54 @@ export function compilePrimitivesToGpu(
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) }); arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
} }
strokePolyline(arcPts, false, color, strokeMm); strokePolyline(arcPts, false, color, strokeMm);
} else if (prim.kind === 'drawingCircle' || prim.kind === 'drawingArc') {
// 2D-Zeichnungs-Kreis/-Bogen (Kreis-/Bogen-Werkzeug ODER DXF-Import). Der
// SVG-Pfad zeichnet sie als glattes <circle>/<path>; die WebGL-Ebene kennt
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1];
const strokeMm = prim.weightMm || 0.18;
const c = prim.center;
const TAU = Math.PI * 2;
const a0 = prim.kind === 'drawingArc' ? prim.a0 : 0;
const sweep =
prim.kind === 'drawingArc'
? ((prim.a1 - prim.a0) % TAU + TAU) % TAU || TAU
: TAU;
// Segmentzahl wie im 'arc'-Zweig: Sagitta < ~0.5 px (LOD über pxPerMeter).
let segs: number;
if (pxPerMeter > 0) {
const rPx = prim.r * pxPerMeter;
const theta = rPx > 0.5 ? 2 * Math.acos(1 - 0.5 / rPx) : Math.PI;
segs = Math.min(2048, Math.max(8, Math.ceil(sweep / theta)));
} else {
segs = Math.max(8, Math.ceil((sweep / (Math.PI / 2)) * 16));
}
const pts: Vec2[] = [];
for (let i = 0; i <= segs; i++) {
const t = a0 + (sweep * i) / segs;
pts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
}
// Vollton-Füllung nur beim geschlossenen Kreis (Bogen ist offen).
if (prim.kind === 'drawingCircle') {
const fill = parseColor(prim.fill);
if (fill) {
const ring = pts.slice(0, segs); // ohne den doppelten Schlusspunkt
const tris = triangulate(ring);
if (tris.length) {
const base = fillPos.length / 2;
for (const pt of ring) {
const s = toScreen(pt);
fillPos.push(s.x, s.y);
track(pt.x, pt.y);
}
for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill);
}
}
}
strokePolyline(pts, prim.kind === 'drawingCircle', stroke, strokeMm);
} }
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test). // Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
} }
+16 -1
View File
@@ -486,7 +486,22 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
widthMm: p.weightMm, widthMm: p.weightMm,
dash: p.dash ?? null, dash: p.dash ?? null,
}); });
} else { } else if (p.kind === "drawingCircle" || p.kind === "drawingArc") {
// Der native Renderer hat kein Kreis-/Bogen-Primitiv → hier tessellieren
// (der SVG-Pfad zeichnet sie glatt). Kreis = voller Umlauf, Bogen = a0..a1.
flushRun();
const col = withOpacity(toRgba(p.stroke, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed);
const TAU = Math.PI * 2;
const a0 = p.kind === "drawingArc" ? p.a0 : 0;
const sweep = p.kind === "drawingArc" ? (((p.a1 - p.a0) % TAU) + TAU) % TAU : TAU;
const N = Math.max(8, Math.ceil(sweep / (Math.PI / 32)));
const pts: RPoint[] = [];
for (let i = 0; i <= N; i++) {
const t = a0 + (sweep * i) / N;
pts.push([p.center.x + p.r * Math.cos(t), p.center.y + p.r * Math.sin(t)]);
}
polylines.push({ pts, color: col, widthMm: p.weightMm, dash: p.dash ?? null, z: zc++ });
} else if (p.kind === "text") {
// "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust- // "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust-
// Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie // Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie
// wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case // wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case
+330 -25
View File
@@ -109,6 +109,11 @@ body,
#root { #root {
height: 100%; height: 100%;
margin: 0; margin: 0;
/* Dokument selbst NIE scrollen/bouncen: das Zoom-Wheel im Viewport darf die
App-Chrome nicht mitziehen (macOS-Overscroll/Rubberband). Interne Panels
scrollen weiterhin über ihre eigenen overflow-Container. */
overflow: hidden;
overscroll-behavior: none;
} }
/* Native-App-Verhalten: KEINE Textauswahl / Alles markieren" in der App-Chrome. /* Native-App-Verhalten: KEINE Textauswahl / Alles markieren" in der App-Chrome.
@@ -152,9 +157,9 @@ body {
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 6px 14px; padding: 0 10px;
height: 60px; height: 26px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten /* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
nach unten trennt sich klar von der Zeichenfläche (Tiefe). */ nach unten trennt sich klar von der Zeichenfläche (Tiefe). */
@@ -169,6 +174,12 @@ body {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: transparent transparent; scrollbar-color: transparent transparent;
} }
/* Tauri/macOS: native Ampeln (titleBarStyle "Overlay") liegen oben links über
dem Inhalt Zeile links einrücken, damit `dossier.` frei bleibt (~3 Ampeln +
Rand). */
.topbar.topbar-native-inset {
padding-left: 78px;
}
/* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen /* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen
(`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */ (`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */
.topbar::-webkit-scrollbar { .topbar::-webkit-scrollbar {
@@ -550,10 +561,12 @@ body {
line-height: 1; line-height: 1;
} }
.brand-word { .brand-word {
font-weight: 800; font-weight: 700;
font-size: 15px; font-size: 12px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
text-transform: lowercase;
color: var(--ink); color: var(--ink);
white-space: nowrap;
} }
.brand-dot { .brand-dot {
color: var(--accent); color: var(--accent);
@@ -1463,6 +1476,18 @@ body {
min-width: 0; min-width: 0;
} }
/* Mitte-Spalte: Viewport (.content) + Befehlszeile (.cmdline) darunter. Nimmt in
der horizontalen .body den Platz ZWISCHEN den Docks ein und stapelt vertikal
so ist die Befehlszeile nur so breit wie der Viewport, und die Docks reichen
über die volle Body-Höhe (inkl. der Befehlszeilen-Zeile). */
.center-col {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
/* Dokument-Tabs Zeichnungen/Blätter" Kopfzeile über der Zeichenfläche. /* Dokument-Tabs Zeichnungen/Blätter" Kopfzeile über der Zeichenfläche.
Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und
verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei
@@ -3892,6 +3917,64 @@ body {
font-size: 11px; font-size: 11px;
} }
/* Element-Abschnitte IM Attribute-Panel dem .attr-Grid-Look angleichen
Die aus ObjectInfo verschobenen Sektionen (Wand/Decke/Öffnung/Treppe/Raum)
nutzen weiter die `objinfo-*`-Klassen; hier bekommen sie NUR innerhalb
`.attr-panel` denselben sauberen Look wie das obere Attribut-Grid: Sektion-
Balken, Zeilentrenner, einheitliche Label-Spalte, linksbündig füllende Felder.
Das eigenständige ObjectInfo-Panel (Bezugspunkt/Masse) bleibt unberührt. */
.attr-panel .objinfo-section-label {
color: var(--label);
font-weight: 700;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.05em;
margin: 8px 0 2px;
padding: 3px 6px;
background: var(--panel-2);
border-radius: 3px;
}
/* Sektion-Balken trennen bereits → die dünnen Extra-Striche entfallen. */
.attr-panel .objinfo-sep {
display: none;
}
/* Zeile: Label-Spalte | Wert/Control, mit dezentem Zeilentrenner (wie .attr-*). */
.attr-panel .objinfo-field {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 2px 2px 2px;
border-bottom: 1px solid var(--line);
}
.attr-panel .objinfo-flabel {
flex: 0 0 104px;
width: 104px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 8px;
color: var(--label);
}
/* Werte/Controls füllen die Zelle linksbündig (statt rechtsbündig). */
.attr-panel .objinfo-fval {
flex: 1 1 auto;
text-align: left;
}
.attr-panel .objinfo-input {
text-align: left;
}
.attr-panel .objinfo-field .tb-dd-trigger {
flex: 1 1 auto;
min-width: 0;
justify-content: space-between;
}
/* Segment-Umschalter + verschachtelte Felder füllen ebenfalls die Wert-Spalte. */
.attr-panel .objinfo-segment,
.attr-panel .objinfo-subfield {
flex: 1 1 auto;
min-width: 0;
}
/* ── Wand-Abschnitt (Object-Info) ─────────────────────────────────────── */ /* ── Wand-Abschnitt (Object-Info) ─────────────────────────────────────── */
/* Referenzlinie-Dropdown rechts oben im Kopf. */ /* Referenzlinie-Dropdown rechts oben im Kopf. */
.objinfo-head-ref { .objinfo-head-ref {
@@ -3962,10 +4045,6 @@ body {
/* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */ /* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */
.attr-panel { .attr-panel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
@@ -3976,44 +4055,66 @@ body {
padding: 8px 4px; padding: 8px 4px;
text-align: center; text-align: center;
} }
.attr-title { /* OCS-artiges Eigenschaften-Grid: EIN durchgehendes Zwei-Spalten-Grid über alle
font-weight: 600; Sektionen (Label | Wert), damit die Wertfelder über Stift/Füllung/Maße hinweg
color: var(--label); in EINER Spalte bündig stehen. Sektion-Balken spannen beide Spalten. Zeilen mit
padding: 0 2px 4px; dezenten Trennlinien; Wertfelder füllen ihre Zelle linksbündig über die volle
} Panel-Breite einheitlicher Look für Texte/Zahlen/Dropdowns. */
.attr-section-label { .attr-section-label {
grid-column: 1 / -1;
color: var(--label); color: var(--label);
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
font-size: 10px; font-size: 10px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin-top: 6px; margin: 8px 0 2px;
padding: 0 2px; padding: 3px 6px;
background: var(--panel-2);
border-radius: 3px;
}
.attr-section-label:first-child {
margin-top: 0;
} }
.attr-grid { .attr-grid {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: minmax(80px, max-content) 1fr;
align-items: center; align-items: stretch;
gap: 4px 10px; gap: 0;
padding: 2px; padding: 0;
} }
.attr-key { .attr-key {
color: var(--label); color: var(--label);
white-space: nowrap; white-space: nowrap;
display: flex;
align-items: center;
padding: 3px 10px 3px 2px;
border-bottom: 1px solid var(--line);
} }
.attr-val { .attr-val {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-start;
gap: 6px;
min-width: 0; min-width: 0;
padding: 2px 0;
border-bottom: 1px solid var(--line);
}
/* Wertfelder (Dropdown-Trigger, Zahlenfeld) füllen die Wert-Spalte der
Dropdown wächst in die verfügbare Breite (kein fixes width mehr im TSX). */
.attr-val .tb-dd-trigger {
flex: 1 1 auto;
min-width: 0;
justify-content: space-between;
} }
.attr-readonly { .attr-readonly {
color: var(--label); color: var(--label);
opacity: 0.85; opacity: 0.85;
padding-left: 6px;
} }
.attr-num, .attr-num,
.attr-select { .attr-select {
width: 110px; width: 100%;
text-align: left;
background: var(--panel-2); background: var(--panel-2);
color: var(--label); color: var(--label);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -5280,15 +5381,16 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
margin-left: 6px; /* Ganz links in der randlosen Titelleiste (macOS-Konvention). */
margin-right: 4px;
flex: 0 0 auto; flex: 0 0 auto;
} }
.win-btn { .win-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 28px; width: 26px;
height: 28px; height: 22px;
padding: 0; padding: 0;
border: none; border: none;
background: transparent; background: transparent;
@@ -5305,3 +5407,206 @@ body {
background: #e5484d; background: #e5484d;
color: #fff; color: #fff;
} }
/* Ribbon (2D · 3D · BIM · Ansichten)
Die Tab-REITER (`.ribbon-tabs`) sitzen in der TopBar-Zeile; hier folgt nur der
INHALT (`.ribbon`/`.ribbon-body`) des aktiven Tabs darunter. So gibt es genau
zwei Bänder: TopBar-Zeile (Chrome + Tabs) oben, Ribbon-Inhalt darunter. */
.ribbon {
display: flex;
flex-direction: column;
background: var(--panel);
border-bottom: 1px solid var(--line);
position: relative;
z-index: 1;
flex: 0 0 auto;
}
/* Tab-Reiter leben in der TopBar-Zeile: volle Zeilenhöhe, Aktiv-Unterstrich
sitzt an der Unterkante (verbindet optisch mit dem Inhalt darunter). */
.ribbon-tabs {
display: flex;
align-self: stretch;
align-items: stretch;
gap: 2px;
}
.ribbon-tab {
appearance: none;
border: none;
background: transparent;
color: var(--muted);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0 10px;
display: flex;
align-items: center;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.12s, border-color 0.12s;
}
/* Quick-Access-Leiste (OCS-Stil) — kleine Icon-Knöpfe in der TopBar-Zeile. */
.tb-qa {
display: inline-flex;
align-items: center;
gap: 1px;
flex: 0 0 auto;
}
.tb-qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: var(--muted);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.tb-qa-btn:hover:not(:disabled) {
background: var(--panel-2);
color: var(--label);
}
.tb-qa-btn.active {
background: var(--accent-dim);
color: var(--accent);
}
.tb-qa-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tb-qa-btn .tb-ico {
font-size: 16px;
}
.tb-qa-sep {
width: 1px;
height: 15px;
background: var(--line);
margin: 0 3px;
flex: 0 0 auto;
}
.ribbon-tab:hover {
color: var(--label);
}
.ribbon-tab.active {
color: var(--ink);
border-bottom-color: var(--accent-border, var(--accent));
}
.ribbon-body {
display: flex;
align-items: stretch;
gap: 0;
padding: 4px 8px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
height: 60px;
flex-shrink: 0;
}
.ribbon-body::-webkit-scrollbar {
height: 6px;
}
.ribbon-body::-webkit-scrollbar-thumb {
background: transparent;
}
.ribbon-body:hover::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
/* Eine Gruppe: Buttons oben, kleiner Gruppentitel darunter; rechts ein dezenter
Trenner (wie OCS-Ribbon-Gruppen). */
.ribbon-group {
display: flex;
flex-direction: column;
padding: 0 8px;
border-right: 1px solid var(--line);
}
.ribbon-group:last-child {
border-right: none;
}
.ribbon-items {
display: flex;
align-items: flex-start;
gap: 2px;
flex: 1;
}
.ribbon-group-title {
text-align: center;
color: var(--muted);
font-size: 9px;
letter-spacing: 0.05em;
text-transform: uppercase;
padding-top: 2px;
}
/* Ein Ribbon-Button: Icon oben, Label darunter (kompakt, wie eine Palette). */
.ribbon-btn {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--label);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 52px;
padding: 4px 2px 3px;
border-radius: 5px;
cursor: pointer;
font-size: 10px;
line-height: 1.15;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.ribbon-btn span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ribbon-btn:hover:not(:disabled) {
background: var(--panel-2);
border-color: var(--line);
}
.ribbon-btn.active {
background: var(--accent-dim, var(--panel-2));
border-color: var(--accent-border, var(--accent));
color: var(--ink);
}
.ribbon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ribbon-empty {
display: flex;
align-items: center;
color: var(--muted);
font-size: 11px;
font-style: italic;
padding: 0 8px;
}
/* Custom-Bar-Picker ( Hinzufügen") kompakter, unauffälliger Trigger, der
sich neben die Custom-Buttons stellt. */
.ribbon-add.tb-dd-trigger {
align-self: center;
height: 24px;
font-size: 11px;
color: var(--muted);
border-style: dashed;
}
.ribbon-add.tb-dd-trigger:hover {
color: var(--label);
}
/* Ansichten"-Tab: die aus der TopBar gemergten Cluster (View-Grid, Kombis,
Detail, Zoom, Darstellung) in EINER Zeile nutzt die bestehenden `.tb-*`-
Stile unverändert weiter. */
.ribbon-views {
display: flex;
align-items: center;
gap: 10px;
height: 100%;
overflow: hidden;
}
+93 -1
View File
@@ -12,7 +12,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computeSnap, wallLayerBoundarySegments } from "./snapping"; import { computeSnap, wallLayerBoundarySegments } from "./snapping";
import { DEFAULT_SNAP } from "./types"; import { DEFAULT_SNAP } from "./types";
import type { Project, Vec2, Wall } from "../model/types"; import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */ /** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
function wallProject(wall: Wall): Project { function wallProject(wall: Wall): Project {
@@ -134,3 +134,95 @@ describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
expect(result!.point.y).toBeCloseTo(0.025, 6); expect(result!.point.y).toBeCloseTo(0.025, 6);
}); });
}); });
// ── Kreis-/Bogen-Snaps (Center/Quadrant/Bogen-Endpunkt) ──────────────────────
/** Minimalprojekt mit gegebenen 2D-Geometrien auf dem Geschoss „eg". */
function drawProject(geoms: Drawing2D["geom"][]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
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: geoms.map((geom, i) => ({
id: `d${i}`,
type: "drawing2d" as const,
levelId: "eg",
categoryCode: "20",
geom,
})),
context: [],
};
}
function snapAt(project: Project, raw: Vec2, settings = DEFAULT_SNAP) {
return computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings,
draftPoints: [],
lastPoint: null,
pxPerMeter: 100,
shift: false,
ctrl: false,
});
}
describe("computeSnap — Kreis/Bogen", () => {
const withCenter = { ...DEFAULT_SNAP, center: true };
it("fängt Mittelpunkt + Quadranten eines Kreises (center aktiv)", () => {
const p = drawProject([{ shape: "circle", center: { x: 0, y: 0 }, r: 1 }]);
const c = snapAt(p, { x: 0, y: 0 }, withCenter);
expect(c?.kind).toBe("center");
expect(c!.point.x).toBeCloseTo(0, 6);
expect(c!.point.y).toBeCloseTo(0, 6);
const q = snapAt(p, { x: 1, y: 0 }, withCenter);
expect(q?.kind).toBe("quadrant");
expect(q!.point.x).toBeCloseTo(1, 6);
expect(q!.point.y).toBeCloseTo(0, 6);
});
it("fängt Bogen-Endpunkte (endpoint per Default aktiv)", () => {
// Viertelbogen um (5,0), r=1, 0…90° → Endpunkte (6,0) und (5,1).
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const e = snapAt(p, { x: 6, y: 0 });
expect(e?.kind).toBe("endpoint");
expect(e!.point.x).toBeCloseTo(6, 6);
expect(e!.point.y).toBeCloseTo(0, 6);
});
it("bietet Quadranten AUSSERHALB der Bogen-Spanne NICHT an", () => {
// Viertelbogen 0…90°: der West-Quadrant (4,0) liegt NICHT im Bogen.
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const r = snapAt(p, { x: 4, y: 0 }, withCenter);
expect(r?.kind).not.toBe("quadrant");
});
});
+50
View File
@@ -108,6 +108,28 @@ function collectSegments(input: SnapInput): Seg[] {
return segs; return segs;
} }
/** Kreis-/Bogen-Geometrie für Center-/Quadrant-/Endpunkt-Snaps. `arc=null` =
* Vollkreis (keine Endpunkte, alle vier Quadranten). */
interface CircleGeom {
center: Vec2;
r: number;
arc: { a0: number; a1: number } | null;
}
/** Sammelt sichtbare Kreise/Bögen des aktiven Geschosses (aus `drawings2d`). */
function collectCircles(input: SnapInput): CircleGeom[] {
const out: CircleGeom[] = [];
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
const g = d.geom;
if (g.shape === "circle") out.push({ center: g.center, r: g.r, arc: null });
else if (g.shape === "arc")
out.push({ center: g.center, r: g.r, arc: { a0: g.a0, a1: g.a1 } });
}
return out;
}
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */ /** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 { function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
@@ -184,6 +206,34 @@ export function computeSnap(input: SnapInput): SnapResult | null {
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge"); for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
} }
// Kreis-/Bogen-Snaps: Mittelpunkt + Quadranten (Ost/Nord/West/Süd); bei Bögen
// nur die Quadranten IM Bogen-Bereich, plus die beiden Bogen-Endpunkte. Center/
// Quadrant hängen an der `center`-Einstellung, die Endpunkte an `endpoint`.
if (settings.center || settings.endpoint) {
const TAU = Math.PI * 2;
const norm = (x: number) => ((x % TAU) + TAU) % TAU;
const onCircle = (c: CircleGeom, ang: number): Vec2 => ({
x: c.center.x + c.r * Math.cos(ang),
y: c.center.y + c.r * Math.sin(ang),
});
for (const c of collectCircles(input)) {
if (settings.center) {
consider(c.center, "center");
// Volle CCW-Spanne (0 ⇒ Vollkreis); Quadrant nur, wenn im Bogen liegt.
const sweep = c.arc ? norm(c.arc.a1 - c.arc.a0) || TAU : TAU;
for (let q = 0; q < 4; q++) {
const ang = q * (Math.PI / 2);
if (c.arc && norm(ang - c.arc.a0) > sweep + 1e-9) continue;
consider(onCircle(c, ang), "quadrant");
}
}
if (settings.endpoint && c.arc) {
consider(onCircle(c, c.arc.a0), "endpoint");
consider(onCircle(c, c.arc.a1), "endpoint");
}
}
}
if (best) return best; if (best) return best;
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt). // Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
+32
View File
@@ -526,6 +526,34 @@ const rectTool: Tool = {
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }], onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
}; };
// Kreis: Platzhalter-Werkzeug (analog window/door), gekoppelt an den
// `circle`-Befehl (TOOL_COMMAND in App.tsx). Der eigentliche Ablauf
// (Mittelpunkt → Radius) läuft über die Command-Engine — EIN Pfad für
// Toolbar-Klick und getipptes „circle". Nicht floorOnly (freie 2D-Zeichnung).
const circleTool: Tool = {
id: "circle",
labelKey: "tool.circle",
hintKey: () => "tool.circle.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// Bogen: Platzhalter-Werkzeug, gekoppelt an den `arc`-Befehl (3 Klicks:
// Mitte → Start/Radius → Ende). Wie Kreis nicht floorOnly.
const arcTool: Tool = {
id: "arc",
labelKey: "tool.arc",
hintKey: () => "tool.arc.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// ── Registry ───────────────────────────────────────────────────────────────── // ── Registry ─────────────────────────────────────────────────────────────────
const TOOLS: Record<ToolId, Tool> = { const TOOLS: Record<ToolId, Tool> = {
@@ -539,6 +567,8 @@ const TOOLS: Record<ToolId, Tool> = {
line: lineTool, line: lineTool,
polyline: polylineTool, polyline: polylineTool,
rect: rectTool, rect: rectTool,
circle: circleTool,
arc: arcTool,
}; };
/** Liefert das Werkzeug zur ID. */ /** Liefert das Werkzeug zur ID. */
@@ -558,4 +588,6 @@ export const TOOL_ORDER: ToolId[] = [
"line", "line",
"polyline", "polyline",
"rect", "rect",
"circle",
"arc",
]; ];
+3 -1
View File
@@ -18,7 +18,9 @@ export type ToolId =
| "room" | "room"
| "line" | "line"
| "polyline" | "polyline"
| "rect"; | "rect"
| "circle"
| "arc";
// ── Snapping ─────────────────────────────────────────────────────────────── // ── Snapping ───────────────────────────────────────────────────────────────
+52 -3
View File
@@ -21,6 +21,7 @@ import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser"; import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
import { import {
contoursToDrawings, contoursToDrawings,
textsToDrawings,
countContours, countContours,
distinctLayers, distinctLayers,
} from "../io/dxfToDrawings"; } from "../io/dxfToDrawings";
@@ -35,6 +36,12 @@ export interface ImportDecision {
categoryMode: CategoryMode; categoryMode: CategoryMode;
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */ /** Ob die Meshes als Kontext-Geometrie übernommen werden. */
importMeshes: boolean; importMeshes: boolean;
/**
* Platzierung der importierten Geometrie: `origin` = 1:1 an den DXF-Koordinaten
* (relativ zum Nullpunkt); `viewCenter` = der Gesamt-Umriss wird in die Mitte
* des aktuellen Ausschnitts geschoben.
*/
placement: "origin" | "viewCenter";
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */ /** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
parsed: DxfImportResult; parsed: DxfImportResult;
} }
@@ -107,9 +114,11 @@ export function ImportDialog({
const contourSets = parsed?.contours ?? []; const contourSets = parsed?.contours ?? [];
const meshes: ImportedMesh[] = parsed?.meshes ?? []; const meshes: ImportedMesh[] = parsed?.meshes ?? [];
const texts = parsed?.texts ?? [];
const contourCount = countContours(contourSets); const contourCount = countContours(contourSets);
const textCount = texts.length;
const layers = distinctLayers(contourSets); const layers = distinctLayers(contourSets);
const hasDrawings = contourCount > 0; const hasDrawings = contourCount > 0 || textCount > 0;
const hasMeshes = meshes.length > 0; const hasMeshes = meshes.length > 0;
// ── Formular-Zustand ────────────────────────────────────────────────────── // ── Formular-Zustand ──────────────────────────────────────────────────────
@@ -124,6 +133,7 @@ export function ImportDialog({
layers.length > 0 ? "byLayer" : "active", layers.length > 0 ? "byLayer" : "active",
); );
const [importMeshes, setImportMeshes] = useState<boolean>(true); const [importMeshes, setImportMeshes] = useState<boolean>(true);
const [placement, setPlacement] = useState<"origin" | "viewCenter">("origin");
const canImport = const canImport =
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes)); !isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
@@ -134,7 +144,7 @@ export function ImportDialog({
targetKind === "existing" targetKind === "existing"
? ({ kind: "existing", levelId: existingLevelId } as const) ? ({ kind: "existing", levelId: existingLevelId } as const)
: ({ kind: "new", name: newName } as const); : ({ kind: "new", name: newName } as const);
onImport({ target, categoryMode, importMeshes, parsed }); onImport({ target, categoryMode, importMeshes, placement, parsed });
}; };
return ( return (
@@ -187,6 +197,9 @@ export function ImportDialog({
</div> </div>
<ul className="imp-summary"> <ul className="imp-summary">
<li>{t("import.summary.contours", { n: contourCount })}</li> <li>{t("import.summary.contours", { n: contourCount })}</li>
{textCount > 0 && (
<li>{t("import.summary.texts", { n: textCount })}</li>
)}
<li>{t("import.summary.meshes", { n: meshes.length })}</li> <li>{t("import.summary.meshes", { n: meshes.length })}</li>
<li> <li>
{layers.length > 0 {layers.length > 0
@@ -289,6 +302,34 @@ export function ImportDialog({
</section> </section>
)} )}
{/* Platzierung: an den DXF-Koordinaten (relativ zu 0) ODER in die
Mitte des aktuellen Ausschnitts geschoben. */}
{hasDrawings && (
<section className="imp-section">
<div className="imp-section-title">
{t("import.placement.heading")}
</div>
<label className="imp-radio">
<input
type="radio"
name="imp-place"
checked={placement === "origin"}
onChange={() => setPlacement("origin")}
/>
<span>{t("import.placement.origin")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
name="imp-place"
checked={placement === "viewCenter"}
onChange={() => setPlacement("viewCenter")}
/>
<span>{t("import.placement.viewCenter")}</span>
</label>
</section>
)}
{/* Mesh-Behandlung. */} {/* Mesh-Behandlung. */}
{hasMeshes && ( {hasMeshes && (
<section className="imp-section"> <section className="imp-section">
@@ -348,11 +389,19 @@ export function buildDrawings(
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code); for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
const layerToCode = (layerName: string): string => const layerToCode = (layerName: string): string =>
byName.get(layerName.toLowerCase()) ?? activeCategoryCode; byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
return contoursToDrawings( const fromContours = contoursToDrawings(
decision.parsed.contours, decision.parsed.contours,
levelId, levelId,
decision.categoryMode, decision.categoryMode,
activeCategoryCode, activeCategoryCode,
layerToCode, layerToCode,
); );
const fromTexts = textsToDrawings(
decision.parsed.texts ?? [],
levelId,
decision.categoryMode,
activeCategoryCode,
layerToCode,
);
return [...fromContours, ...fromTexts];
} }
+2 -1
View File
@@ -1847,7 +1847,8 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
scale: 1, scale: 1,
angle: p.families[0]?.angle ?? 45, angle: p.families[0]?.angle ?? 45,
color: "#1a1a1a", color: "#1a1a1a",
lineStyleId: "hatch-line", // Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin").
lineStyleId: "thin",
})); }));
} }
+324 -206
View File
@@ -15,6 +15,8 @@ import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown"; import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown"; import type { DropdownItem, DropdownOption } from "./Dropdown";
import { WindowControls } from "./WindowControls"; import { WindowControls } from "./WindowControls";
import { RibbonTabs } from "./ribbon/RibbonBar";
import type { RibbonTabId } from "./ribbon/ribbonItems";
import { import {
applyMark, applyMark,
applyPreset, applyPreset,
@@ -84,49 +86,17 @@ const ZOOM_CLUSTER_WIDTH = 140;
export interface TopBarProps { export interface TopBarProps {
project: Project; project: Project;
// Ansicht. // Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */ // Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
viewToggleEnabled: boolean; ribbonTab: RibbonTabId;
viewType: ViewType; onRibbonTab: (id: RibbonTabId) => void;
onViewType: (v: ViewType) => void;
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in // Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin. // gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
view3d: View3d; // Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
onView3d: (v: View3d) => void;
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
fov: number;
onFov: (n: number) => void;
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben /** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
// jetzt in der Werkzeug-Palette (ToolsPanel), nicht mehr in der Oberleiste.
// Detailgrad.
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
/** Detailgrad wirkt nur, wenn ein Plan gezeichnet wird (sonst deaktiviert). */
detailEnabled: boolean;
// Massstab + Zoom.
/**
* Gewählter (angewandter) Papier-Massstab-Nenner (1:N) steuert das Dropdown
* und 100 %". Setzt beim Wählen den Plan-Zoom auf exakt 1:N.
*/
scaleDenominator: number;
/** Einen Papier-Massstab anwenden (Preset/frei): setzt N + Plan-Zoom. */
onApplyScale: (n: number) => void;
/**
* LIVE Papier-Massstab-Nenner aus der aktuellen View-Transform (ändert sich
* beim Wheel-Zoom) oder `null`, wenn keine Plan-Ansicht aktiv ist.
*/
liveScale: number | null;
/** Live-Zoom in Prozent (aus der Plan-View-Transform). */
zoomPercent: number;
/** Zoom-Aktionen — nur wirksam, wenn eine Plan-Ansicht aktiv ist. */
zoomEnabled: boolean; zoomEnabled: boolean;
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */ /** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportPdf: () => void; onExportPdf: () => void;
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */ /** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
@@ -134,21 +104,6 @@ export interface TopBarProps {
/** Lädt die Bauteilliste des Projekts als CSV herunter. */ /** Lädt die Bauteilliste des Projekts als CSV herunter. */
onExportSchedule: () => void; onExportSchedule: () => void;
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
/** Render-Modus nur in der Perspektive sinnvoll (sonst deaktiviert). */
renderModeEnabled: boolean;
// Farb-Modus des Grundrisses (Farbig ↔ Schwarz-Weiss). Nur im Grundriss.
planColorMode: PlanColorMode;
onPlanColorMode: (m: PlanColorMode) => void;
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
combos: ComboMenuHandlers;
// Ressourcen-Fenster. // Ressourcen-Fenster.
resourcesOpen: boolean; resourcesOpen: boolean;
onToggleResources: () => void; onToggleResources: () => void;
@@ -167,14 +122,8 @@ export interface TopBarProps {
/** Projekt aus einer Datei laden. */ /** Projekt aus einer Datei laden. */
onOpenProject: () => void; onOpenProject: () => void;
// ── Text-Gruppe (Stempel-/Text-Formatierung) ─────────────────────────────── // Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
/** // (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
*/
textTarget: TextTarget | null;
} }
/** /**
@@ -552,7 +501,7 @@ const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle * `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`. * Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
*/ */
function TextGroup({ export function TextGroup({
textTarget, textTarget,
}: { }: {
textTarget: TextTarget | null; textTarget: TextTarget | null;
@@ -916,50 +865,145 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die * Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
* Leiste ruhiger bleibt (GTK-Burger-Stil). * Leiste ruhiger bleibt (GTK-Burger-Stil).
*/ */
function AppMenu({ /** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
function QaButton({
icon,
label,
onClick,
disabled,
active,
}: {
icon: string;
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
}) {
return (
<button
type="button"
className={`tb-qa-btn${active ? " active" : ""}`}
onClick={onClick}
disabled={disabled}
aria-pressed={active}
aria-label={label}
title={label}
>
<TbIcon name={icon} />
</button>
);
}
/** Kleiner Trenner in der Quick-Access-Leiste. */
function QaSep() {
return <span className="tb-qa-sep" aria-hidden="true" />;
}
/**
* Quick-Access-Leiste (OCS-Stil) alle früheren Datei-Burger-Aktionen plus
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
*/
function QuickAccess({
onOpenProject,
onSaveProject,
onImport, onImport,
onExportPdf, onExportPdf,
onExportDxf, onExportDxf,
onExportSchedule, onExportSchedule,
onSaveProject, onToggleResources,
onOpenProject,
onOpenSettings, onOpenSettings,
resourcesOpen,
exportEnabled, exportEnabled,
}: { }: {
onOpenProject: () => void;
onSaveProject: () => void;
onImport: () => void; onImport: () => void;
onExportPdf: () => void; onExportPdf: () => void;
onExportDxf: () => void; onExportDxf: () => void;
onExportSchedule: () => void; onExportSchedule: () => void;
onSaveProject: () => void; onToggleResources: () => void;
onOpenProject: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
resourcesOpen: boolean;
exportEnabled: boolean; exportEnabled: boolean;
}) { }) {
const items: DropdownItem[] = [
{ label: t("file.save"), onSelect: onSaveProject },
{ label: t("file.open"), onSelect: onOpenProject },
{ divider: true },
{ label: t("file.import"), onSelect: onImport },
{ divider: true },
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
{ label: t("file.exportSchedule"), onSelect: onExportSchedule },
{ divider: true },
{ label: t("topbar.settings"), onSelect: onOpenSettings },
];
return ( return (
<Dropdown <div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
items={items} <QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
placeholder="" <QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
title={t("file.menu")} <QaSep />
triggerClassName="tb-burger" <QaButton icon="download" label={t("file.import")} onClick={onImport} />
triggerSuffix={<TbIcon name="menu" />} <QaButton
icon="picture_as_pdf"
label={t("file.exportPdf")}
onClick={onExportPdf}
disabled={!exportEnabled}
/> />
<QaButton
icon="output"
label={t("file.exportDxf")}
onClick={onExportDxf}
disabled={!exportEnabled}
/>
<QaButton
icon="table_view"
label={t("file.exportSchedule")}
onClick={onExportSchedule}
/>
<QaSep />
<QaButton
icon="widgets"
label={t("topbar.resources")}
onClick={onToggleResources}
active={resourcesOpen}
/>
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
</div>
); );
} }
export function TopBar({ /**
project, * Props des Ansichten"-Tabs die Ansichts-/Zoom-/Darstellungs-Steuerung, die
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
*/
export interface ViewRibbonTabProps {
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
detailEnabled: boolean;
scaleDenominator: number;
onApplyScale: (n: number) => void;
liveScale: number | null;
zoomPercent: number;
zoomEnabled: boolean;
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
planColorMode: PlanColorMode;
onPlanColorMode: (m: PlanColorMode) => void;
combos: ComboMenuHandlers;
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
* nur Defaults). Liegt bewusst im Ansichten"-Tab (Nutzer-Wunsch). */
textTarget: TextTarget | null;
}
/**
* Ansichten"-Tab des Ribbons trägt die aus der TopBar gemergten Mittelcluster:
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar). Wird von
* `RibbonBar` als Inhalt des Ansichten"-Tabs gerendert.
*/
export function ViewRibbonTab({
viewToggleEnabled, viewToggleEnabled,
viewType, viewType,
onViewType, onViewType,
@@ -978,44 +1022,14 @@ export function TopBar({
onFit, onFit,
onFitSelection, onFitSelection,
onZoom100, onZoom100,
onExportPdf,
onExportDxf,
onExportSchedule,
renderMode, renderMode,
onRenderMode, onRenderMode,
renderModeEnabled, renderModeEnabled,
planColorMode, planColorMode,
onPlanColorMode, onPlanColorMode,
combos, combos,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
textTarget, textTarget,
}: TopBarProps) { }: ViewRibbonTabProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const onScroll = () => {
el.classList.add("is-scrolling");
if (timer) clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timer) clearTimeout(timer);
};
}, []);
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35". // „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
const onScaleChange = (value: string) => { const onScaleChange = (value: string) => {
if (value === "__custom__") { if (value === "__custom__") {
@@ -1030,14 +1044,7 @@ export function TopBar({
const n = Number(value); const n = Number(value);
if (Number.isFinite(n) && n > 0) onApplyScale(n); if (Number.isFinite(n) && n > 0) onApplyScale(n);
}; };
// Aktueller Nenner steht evtl. nicht in den Presets (frei gesetzt) → dann
// einen Eintrag „1:N" voranstellen, damit das Select den Wert anzeigt.
const inPresets = SCALE_PRESETS.includes(scaleDenominator); const inPresets = SCALE_PRESETS.includes(scaleDenominator);
// Optionsliste des Massstab-Dropdowns: ggf. der frei gesetzte aktuelle Wert
// (damit er sichtbar bleibt), dann die Presets, zuletzt „frei …" als
// Sonder-Wert, der über onScaleChange den Prompt öffnet (Funktion unverändert).
const scaleOptions: DropdownOption[] = [ const scaleOptions: DropdownOption[] = [
...(inPresets ...(inPresets
? [] ? []
@@ -1047,41 +1054,8 @@ export function TopBar({
]; ];
return ( return (
<header className="topbar" ref={headerRef}> <div className="ribbon-views">
{/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben, {/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links,
rechts daneben ein schmaler Icon-Stapel). */}
<div className="tb-brandgroup">
<div className="brand">
<span className="brand-word">
DOSSIER<span className="brand-dot">.</span>
</span>
<span className="tag">{t("brand.tag")}</span>
</div>
<div className="tb-brandicons">
<button
type="button"
className={`tb-brandicon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
<TbIcon name="widgets" />
</button>
</div>
</div>
<Sep />
{/* Ansichten als EIN kompaktes Icon-Grid (Vectorworks/DOSSIER-Stil): der
2D-Grundriss UND die 3D-Blicke sind eine durchgängige Icon-Serie
Grundriss" schaltet auf den 2D-Plan, die übrigen auf die jeweilige
3D-Ansicht (Perspektive wird dabei automatisch aktiviert). 4 oben /
3 unten. Tooltip nennt jede Ansicht im Klartext. */}
{/* Ansichts-Icons bleiben immer sichtbar; auf Nicht-Geschoss-Ebenen
(Schnitt/Ansicht/2D-Zeichnung) sind sie deaktiviert, weil dort keine
2D/3D-Umschaltung greift die Serie verschwindet nicht mehr ganz. */}
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}> <div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4"> <div className="view-grid view-grid-4">
<button <button
@@ -1139,10 +1113,7 @@ export function TopBar({
</div> </div>
<Sep /> <Sep />
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs- {/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
kombinationen). Sitzt direkt nach dem Ansichts-Umschalter, weil beides
Navigations-/Sicht"-Steuerung ist ruhige Stelle vor dem Detailgrad.
Nur die Dropdowns; eine grafische Überschreibung kommt später. */}
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}> <div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
<label className="tb-field tb-field-row"> <label className="tb-field tb-field-row">
<span className="tb-label">{t("combo.layers")}</span> <span className="tb-label">{t("combo.layers")}</span>
@@ -1167,27 +1138,9 @@ export function TopBar({
/> />
</label> </label>
</div> </div>
<Sep /> <Sep />
{/* Text-Gruppe (immer sichtbar) Stil/Schrift/Grösse + B/I/U · L/C/R + {/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
+ Text" ist raus Text wird ein eigenständiges Zeichenwerkzeug
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
<TextGroup textTarget={textTarget} />
<Sep />
{/* Zeichenwerkzeuge liegen jetzt in der Werkzeug-Palette (ToolsPanel,
dockbar) nicht mehr in der Oberleiste (Vectorworks-Bild: Werkzeuge
in der Palette). Auch Wandtyp-Wahl + Fang-Menü sind dort. */}
{/* Detailgrad (grob/mittel/fein) wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung dort
deaktiviert mit Tooltip statt stiller No-Op. */}
{/* Detailgrad Massstab lebt nur noch EINMAL im Zoom-Cluster (die
Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}> <div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row"> <label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span> <span className="tb-label">{t("topbar.detail")}</span>
@@ -1208,14 +1161,9 @@ export function TopBar({
/> />
</label> </label>
</div> </div>
<Sep /> <Sep />
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die {/* Massstab/Zoom-Cluster (2×2). */}
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. am
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
Akzent hervor. */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}> <div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div <div
className={`tb-stat${ className={`tb-stat${
@@ -1271,13 +1219,9 @@ export function TopBar({
/> />
</div> </div>
</div> </div>
<Sep /> <Sep />
{/* Darstellungsart (Vectorworks-Stil) EIN Dropdown, dessen Optionen vom {/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
Ansichtstyp abhängen: Grundriss Farbig/Schwarz-Weiss (planColorMode);
Perspektive Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
Linien-Modus (Display/Print) bleibt separat. */}
<div className="tb-group" role="group" aria-label={t("display.style.group")}> <div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field"> <label className="tb-field">
<span className="tb-label">{t("display.style")}</span> <span className="tb-label">{t("display.style")}</span>
@@ -1308,25 +1252,199 @@ export function TopBar({
)} )}
</label> </label>
</div> </div>
<Sep /> <Sep />
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts, {/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
GTK-Stil) Ressourcen sitzt jetzt bei der Marke (s. o.). */} Ansichten zusammen) Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
<div className="topbar-right"> formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
{layoutMenu} <TextGroup textTarget={textTarget} />
<span className="project-name">{project.name}</span> </div>
<AppMenu );
}
export interface Ribbon3dTabProps {
viewToggleEnabled: boolean;
viewType: ViewType;
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
}
/** 3D-Tab des Ribbons Kamera-Presets + Darstellungsart. Kein Duplikat des
* Ansichten-Tabs: hier nur 3D-relevante Steuerung (Blickwinkel, Rendermodus). */
export function Ribbon3dTab({
viewToggleEnabled,
viewType,
onViewType,
view3d,
onView3d,
fov,
onFov,
renderMode,
onRenderMode,
renderModeEnabled,
}: Ribbon3dTabProps) {
return (
<div className="ribbon-views">
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
<div className="view-grid view-grid-4">
<button
className={`view-icon${viewToggleEnabled && viewType === "grundriss" ? " active" : ""}`}
onClick={() => onViewType("grundriss")}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
aria-label={t("topbar.grundriss")}
>
<ViewIcon name="grundriss" />
</button>
{(
[
["top", t("view3d.top")],
["iso", t("view3d.iso")],
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label}${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!viewToggleEnabled || viewType !== "perspektive"}
/>
</div>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
/>
</label>
</div>
</div>
);
}
export function TopBar({
project,
ribbonTab,
onRibbonTab,
zoomEnabled,
onExportPdf,
onExportDxf,
onExportSchedule,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
const headerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const onScroll = () => {
el.classList.add("is-scrolling");
if (timer) clearTimeout(timer);
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (timer) clearTimeout(timer);
};
}, []);
// Unter Tauri (native macOS-Ampeln oben links via titleBarStyle "Overlay")
// braucht die Zeile links Platz, damit `dossier.` nicht darunter liegt.
const tauriNativeTitlebar =
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
return (
// `data-tauri-drag-region`: die Titelleiste ist selbst der Fenster-Ziehbereich
// (macOS titleBarStyle "Overlay"). Kind-Buttons/Dropdowns bleiben klickbar;
// nur die leeren Zonen ziehen das Fenster. Unter Tauri bekommt die Zeile links
// einen Inset, damit `dossier.` nicht unter den nativen macOS-Ampeln liegt.
<header
className={`topbar${tauriNativeTitlebar ? " topbar-native-inset" : ""}`}
ref={headerRef}
data-tauri-drag-region
>
{/* Fensterknöpfe NUR unter Electron (randloses frame:false-Fenster) unter
Tauri liefern die NATIVEN macOS-Ampeln Schliessen/Minimieren/Maximieren,
daher rendert WindowControls dort null. */}
<WindowControls />
{/* Kleine Wortmarke dossier" (OCS-Stil, klein/kleingeschrieben) + Quick-
Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen
als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
<div className="tb-brandgroup">
<span className="brand-word">
dossier<span className="brand-dot">.</span>
</span>
</div>
<QuickAccess
onOpenProject={onOpenProject}
onSaveProject={onSaveProject}
onImport={onImport} onImport={onImport}
onExportPdf={onExportPdf} onExportPdf={onExportPdf}
onExportDxf={onExportDxf} onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule} onExportSchedule={onExportSchedule}
onSaveProject={onSaveProject} onToggleResources={onToggleResources}
onOpenProject={onOpenProject}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
resourcesOpen={resourcesOpen}
exportEnabled={zoomEnabled} exportEnabled={zoomEnabled}
/> />
<WindowControls />
<Sep />
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) sitzen in dieser Zeile,
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
{/* Rechte Gruppe: Layout-Menü · Projektname. Die Fensterknöpfe sitzen jetzt
ganz links (macOS-Titelleisten-Konvention); Datei-/Export-Aktionen in
der Quick-Access-Leiste (kein Burger). */}
<div className="topbar-right">
{layoutMenu}
<span className="project-name">{project.name}</span>
</div> </div>
</header> </header>
); );
+23 -22
View File
@@ -1,11 +1,11 @@
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose // Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
// Electron-Fenster (frame:false, s. scripts/electron-main.cjs) — ohne native // ELECTRON-Fenster (frame:false, s. scripts/electron-main.cjs) — dort gibt es
// Titelleiste gibt es sonst keine Möglichkeit, das Fenster zu steuern. // ohne native Titelleiste sonst keine Fenstersteuerung.
// //
// Rendert NUR, wenn `window.dossierWindow` existiert (vom Preload-Skript // Unter TAURI werden die NATIVEN macOS-Ampeln verwendet (tauri.conf.json:
// injiziert) — also nur unter Electron. In Tauri (native Fensterdekoration) // titleBarStyle "Overlay" + hiddenTitle, native Dekoration mit gerundeten Ecken),
// und im Browser-Dev-Modus (?vite) bleibt die Komponente unsichtbar (`null`), // daher rendert diese Komponente dort NICHT (kein `window.dossierWindow`) — sonst
// dort steuert das Betriebssystem bzw. der Browser das Fenster. // gäbe es doppelte Knöpfe. Im Browser-Dev-Modus ebenfalls unsichtbar (`null`).
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -43,8 +43,25 @@ export function WindowControls() {
if (!api) return null; if (!api) return null;
// Reihenfolge (macOS-Titelleiste links): Schliessen · Minimieren · Maximieren.
return ( return (
<div className="win-controls" role="group" aria-label="Fenster"> <div className="win-controls" role="group" aria-label="Fenster">
<button
type="button"
className="win-btn win-btn-close"
onClick={() => api.close()}
title="Schliessen"
aria-label="Schliessen"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path
d="M1 1 L9 9 M9 1 L1 9"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</button>
<button <button
type="button" type="button"
className="win-btn" className="win-btn"
@@ -86,22 +103,6 @@ export function WindowControls() {
</svg> </svg>
)} )}
</button> </button>
<button
type="button"
className="win-btn win-btn-close"
onClick={() => api.close()}
title="Schliessen"
aria-label="Schliessen"
>
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path
d="M1 1 L9 9 M9 1 L1 9"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</button>
</div> </div>
); );
} }
+77
View File
@@ -0,0 +1,77 @@
// Kleine Inline-SVG-Glyphen für Befehls-Ribbon-Elemente (Ändern-Gruppe u. a.),
// die keine ToolId haben und daher nicht über `ToolIcon` abgedeckt sind. Gleicher
// Stil wie `ToolIcon` (~16×16, stroke=currentColor, fill=none), damit das Icon
// die Textfarbe der Zeile übernimmt. Unbekannte Namen → kein Icon (null).
const common = {
width: 16,
height: 16,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.4,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
export function CommandIcon({ name }: { name: string }) {
switch (name) {
case "move":
// Vier-Wege-Verschiebepfeil.
return (
<svg {...common}>
<line x1="8" y1="2.5" x2="8" y2="13.5" />
<line x1="2.5" y1="8" x2="13.5" y2="8" />
<polyline points="6,4.5 8,2.5 10,4.5" />
<polyline points="6,11.5 8,13.5 10,11.5" />
<polyline points="4.5,6 2.5,8 4.5,10" />
<polyline points="11.5,6 13.5,8 11.5,10" />
</svg>
);
case "copy":
// Zwei überlappende Rechtecke (Duplikat).
return (
<svg {...common}>
<rect x="2.5" y="2.5" width="8" height="8" />
<rect x="5.5" y="5.5" width="8" height="8" />
</svg>
);
case "mirror":
// Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken.
return (
<svg {...common}>
<line x1="8" y1="2" x2="8" y2="14" strokeDasharray="1.6 1.6" />
<path d="M6.5 4 L2.5 8 L6.5 12 Z" />
<path d="M9.5 4 L13.5 8 L9.5 12 Z" />
</svg>
);
case "offset":
// Zwei parallele Winkel (Ursprung + Versatz).
return (
<svg {...common}>
<polyline points="3,12 3,4 11,4" />
<polyline points="6,12 6,7 14,7" />
</svg>
);
case "trim":
// Schere (Stutzen).
return (
<svg {...common}>
<line x1="13.5" y1="3" x2="6.5" y2="9" />
<circle cx="4" cy="11" r="2" />
<line x1="13.5" y1="13" x2="6.5" y2="7" />
<circle cx="4" cy="5" r="2" />
</svg>
);
case "join":
// Zwei Segmente, die sich an einer Ecke treffen (Verbinden).
return (
<svg {...common}>
<polyline points="2.5,13 8,13 8,3" />
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
</svg>
);
default:
return null;
}
}
+229
View File
@@ -0,0 +1,229 @@
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop
// hereingereicht (State liegt in App, geteilt mit der TopBar).
//
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
// gemergt) statt datengetriebener Item-Gruppen.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import { t } from "../../i18n";
import { ToolIcon } from "../../panels/ToolsPanel";
import { getTool } from "../../tools/tools";
import type { ToolId } from "../../tools/types";
import { getCommand } from "../../commands/registry";
import { Dropdown } from "../Dropdown";
import type { DropdownItem } from "../Dropdown";
import { RIBBON_TABS, ALL_RIBBON_ITEMS, itemKey, itemFromKey } from "./ribbonItems";
import type { RibbonItem, RibbonTabId } from "./ribbonItems";
import { CommandIcon } from "./CommandIcon";
/** Aufgelöste Beschriftung eines Items (Werkzeug bzw. Befehl). */
function itemLabel(item: RibbonItem): string {
if (item.kind === "tool") return t(getTool(item.id).labelKey);
const cmd = getCommand(item.name);
return cmd ? t(cmd.labelKey) : item.name;
}
export interface RibbonBarProps {
/** Aktiver Tab (State liegt in App, geteilt mit den Reitern in der TopBar). */
tab: RibbonTabId;
/** Aktives Werkzeug (Highlight der `tool`-Elemente). */
activeTool: ToolId;
/** Name des laufenden Engine-Befehls (Highlight der `command`-Elemente). */
activeCommand: string | null;
/** Ob Boden-gebundene (`floorOnly`) Werkzeuge nutzbar sind (aktives Geschoss). */
toolsEnabled: boolean;
/** Werkzeug aktivieren (gleicher Pfad wie die Sidebar). */
onSelectTool: (id: ToolId) => void;
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
onRunCommand: (name: string) => void;
/**
* Reicher Custom-Inhalt je Tab (statt datengetriebener Item-Gruppen) z. B.
* Ansichten" (`ViewRibbonTab`) und „Text" (`TextGroup`). Von App geliefert,
* damit deren Setter/State dort bleiben. Ein Tab ohne Custom-Inhalt und ohne
* Gruppen zeigt den Leer-Hinweis.
*/
tabContent?: Partial<Record<RibbonTabId, ReactNode>>;
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
customItems: string[];
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
onToggleCustomItem: (key: string) => void;
}
/** Aktivierungs-/Highlight-Kontext, den jeder Button-Typ braucht. */
interface ButtonCtx {
activeTool: ToolId;
activeCommand: string | null;
toolsEnabled: boolean;
onSelectTool: (id: ToolId) => void;
onRunCommand: (name: string) => void;
}
/** Ein einzelner Ribbon-Button (Werkzeug ODER Befehl). */
function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
if (item.kind === "tool") {
const tool = getTool(item.id);
// Nur Boden-gebundene Werkzeuge sind ohne aktives Geschoss gesperrt; freie
// 2D-Werkzeuge (Linie/Kreis/…) bleiben auch auf Zeichnungsebenen nutzbar.
const disabled = !!tool.floorOnly && !ctx.toolsEnabled;
const active = ctx.activeTool === item.id;
const label = t(tool.labelKey);
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
disabled={disabled}
title={disabled ? t("tool.floorOnlyDisabled") : label}
onClick={() => ctx.onSelectTool(item.id)}
>
<ToolIcon id={item.id} />
<span>{label}</span>
</button>
);
}
// Befehls-Element: Label aus der Befehls-Registry ableiten.
const cmd = getCommand(item.name);
const label = cmd ? t(cmd.labelKey) : item.name;
const active = ctx.activeCommand === item.name;
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
title={label}
onClick={() => ctx.onRunCommand(item.name)}
>
<CommandIcon name={item.name} />
<span>{label}</span>
</button>
);
}
/**
* Die Tab-REITER als eigene Komponente exportiert, weil sie in der TopBar-Zeile
* gerendert werden (nicht hier). Rein gesteuert über `tab`/`onTab`.
*/
export function RibbonTabs({
tab,
onTab,
}: {
tab: RibbonTabId;
onTab: (id: RibbonTabId) => void;
}) {
return (
<div className="ribbon-tabs" role="tablist">
{RIBBON_TABS.map((tt) => (
<button
key={tt.id}
type="button"
role="tab"
aria-selected={tt.id === tab}
className={`ribbon-tab${tt.id === tab ? " active" : ""}`}
onClick={() => onTab(tt.id)}
>
{t(tt.labelKey)}
</button>
))}
</div>
);
}
/**
* Modulare Custom-Bar (Tab Eigene") rendert die vom Nutzer gewählten Items
* plus einen "-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
* die bereits enthaltenen listet (Klick = an-/abwählen). Gleiche Item-Typen +
* gleiche Aktivierung wie die normalen Tabs kein Sonderweg.
*/
function CustomBar({
customItems,
onToggle,
ctx,
}: {
customItems: string[];
onToggle: (key: string) => void;
ctx: ButtonCtx;
}) {
const pickerItems: DropdownItem[] = ALL_RIBBON_ITEMS.map((it) => {
const key = itemKey(it);
return {
label: itemLabel(it),
active: customItems.includes(key),
onSelect: () => onToggle(key),
};
});
const items = customItems
.map(itemFromKey)
.filter((it): it is RibbonItem => it !== null);
return (
<div className="ribbon-group">
<div className="ribbon-items">
{items.length === 0 && (
<div className="ribbon-empty">{t("ribbon.custom.empty")}</div>
)}
{items.map((item, i) => (
<RibbonButton key={`${itemKey(item)}-${i}`} item={item} ctx={ctx} />
))}
<Dropdown
items={pickerItems}
placeholder={t("ribbon.custom.add")}
title={t("ribbon.custom.add")}
triggerClassName="ribbon-add"
/>
</div>
<div className="ribbon-group-title">{t("ribbon.group.custom")}</div>
</div>
);
}
export function RibbonBar(props: RibbonBarProps) {
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
const custom = props.tabContent?.[current.id];
const ctx: ButtonCtx = {
activeTool: props.activeTool,
activeCommand: props.activeCommand,
toolsEnabled: props.toolsEnabled,
onSelectTool: props.onSelectTool,
onRunCommand: props.onRunCommand,
};
return (
<div className="ribbon">
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt (Ansichten",
Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst
die datengetriebenen Item-Gruppen. */}
<div className="ribbon-body">
{current.id === "custom" ? (
<CustomBar
customItems={props.customItems}
onToggle={props.onToggleCustomItem}
ctx={ctx}
/>
) : custom ? (
custom
) : current.groups.length === 0 ? (
<div className="ribbon-empty">{t("ribbon.empty")}</div>
) : (
current.groups.map((g) => (
<div key={g.titleKey} className="ribbon-group">
<div className="ribbon-items">
{g.items.map((item) => (
<RibbonButton
key={item.kind === "tool" ? `t:${item.id}` : `c:${item.name}`}
item={item}
ctx={ctx}
/>
))}
</div>
<div className="ribbon-group-title">{t(g.titleKey)}</div>
</div>
))
)}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
// Ribbon-Registry (docs/design/ribbon-ui-plan.md §Architektur) — EINE
// datengetriebene Beschreibung aller Ribbon-Elemente. Die Ribbon-Tabs UND eine
// spätere modulare Custom-Bar (Phase 4) sind bloß zwei Ansichten über dieselbe
// Registry, ohne Sonderweg pro Element.
//
// Ein `RibbonItem` ist entweder ein Werkzeug (`tool` → onSelectTool, wie die
// Werkzeug-Sidebar) oder ein Engine-Befehl (`command` → engine.start). Beide
// münden in exakt DENSELBEN Pfad wie Klick in der alten Sidebar bzw. das Tippen
// des Befehlsnamens — keine Duplikate.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Sichtbare
// Texte kommen als i18n-Schlüssel und werden erst in der Komponente über t(...)
// aufgelöst.
import type { ToolId } from "../../tools/types";
/** Ein aktivierbares Ribbon-Element. Bei `command` wird die Beschriftung aus der
* Befehls-Registry (`getCommand(name).labelKey`) abgeleitet kein doppeltes
* Pflegen von Labels. */
export type RibbonItem =
| { kind: "tool"; id: ToolId }
| { kind: "command"; name: string };
/** Eine benannte Gruppe von Elementen innerhalb eines Tabs. */
export interface RibbonGroup {
titleKey: string;
items: RibbonItem[];
}
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
export type RibbonTabId = "views" | "2d" | "3d" | "bim" | "custom";
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
export interface RibbonTab {
id: RibbonTabId;
labelKey: string;
groups: RibbonGroup[];
}
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
const c = (name: string): RibbonItem => ({ kind: "command", name });
/**
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). Ansichten" ist der
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font-
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` `groups` bleibt leer). 2D
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/
* Befehlen; 3D ist vorerst leer (spätere Phase).
*/
export const RIBBON_TABS: RibbonTab[] = [
{
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
id: "views",
labelKey: "ribbon.tab.views",
groups: [],
},
{
id: "2d",
labelKey: "ribbon.tab.2d",
groups: [
{
titleKey: "ribbon.group.draw",
items: [
t("select"),
t("line"),
t("polyline"),
t("rect"),
t("circle"),
t("arc"),
],
},
{
titleKey: "ribbon.group.modify",
items: [
c("move"),
c("copy"),
c("mirror"),
c("offset"),
c("trim"),
c("join"),
],
},
],
},
{
id: "3d",
labelKey: "ribbon.tab.3d",
groups: [],
},
{
id: "bim",
labelKey: "ribbon.tab.bim",
groups: [
{
titleKey: "ribbon.group.components",
items: [
t("wall"),
t("window"),
t("door"),
t("stair"),
t("ceiling"),
t("room"),
],
},
],
},
{
// Modulare Custom-Bar (Phase 4): der Nutzer stellt sich die Elemente selbst
// zusammen (persistiert). Gerendert aus `customItems`, nicht aus `groups`.
id: "custom",
labelKey: "ribbon.tab.custom",
groups: [],
},
];
/** Stabiler Schlüssel eines Items für die Persistenz der Custom-Bar. */
export function itemKey(item: RibbonItem): string {
return item.kind === "tool" ? `tool:${item.id}` : `command:${item.name}`;
}
/** Parst einen Item-Schlüssel zurück (oder null bei unbekanntem Präfix). */
export function itemFromKey(key: string): RibbonItem | null {
const i = key.indexOf(":");
if (i < 0) return null;
const kind = key.slice(0, i);
const rest = key.slice(i + 1);
if (kind === "tool") return { kind: "tool", id: rest as ToolId };
if (kind === "command") return { kind: "command", name: rest };
return null;
}
/**
* Alle datengetriebenen Items (aus den Werkzeug-/Befehls-Tabs) die wählbare
* Menge für den Custom-Bar-Picker. views"/„custom" tragen keine `groups`.
*/
export const ALL_RIBBON_ITEMS: RibbonItem[] = RIBBON_TABS.flatMap((tab) =>
tab.groups.flatMap((g) => g.items),
);