Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9133c0961d | |||
| b640bbe606 | |||
| 0b56d777bc | |||
| 1c09b6e7c2 | |||
| 4b77047916 | |||
| 670823e98d | |||
| 71e75b99f0 | |||
| 3a2cef3886 | |||
| d01cb82a89 | |||
| 8d688b982e | |||
| 5b60136a19 | |||
| 39ddd9b501 | |||
| 9c911e6d43 | |||
| cdc71621c4 | |||
| b9b75f7ad1 | |||
| e82d4b084c | |||
| 12441d36fb | |||
| 2c8ad8fe34 | |||
| 3697b7e2d8 | |||
| a750e87517 | |||
| 456bf29984 | |||
| c40764a25f | |||
| ff68ec3a5c | |||
| 72a40ccaaf | |||
| fe22cbf0df | |||
| 4e3b074f5c | |||
| 033cabe80f | |||
| 456ebc8098 | |||
| 13c3294f4d | |||
| 85011cb8ef | |||
| 3a206060e3 | |||
| 9d6e86d4c0 | |||
| ace0dc62a8 | |||
| fe9f396750 | |||
| 00733d8180 | |||
| 077e774303 | |||
| bd2b12bfb7 | |||
| 5130a050ff | |||
| e454eab1a8 | |||
| 67195d7dd5 | |||
| dd76ec89fd | |||
| 376aa67566 | |||
| 4ac99d37cb | |||
| 45e19b7293 | |||
| c794feef9e | |||
| c5b5ca600c | |||
| 4ef73c9198 | |||
| 4b93ac9cbb |
+44
-6
@@ -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: ~4–6 Wochen. Mit Nutzer Scope/MVP klären vor Start.
|
||||
|
||||
## ⛔ 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.**
|
||||
@@ -39,15 +43,21 @@
|
||||
|
||||
## ⏭️ 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)
|
||||
|
||||
- [ ] **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 1–3 fallen mit „Schnitt- vs. Ansichts-Darstellung" zusammen** (Sturz = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion).
|
||||
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. **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] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
|
||||
- [x] ~~**Kreis-Toolbar** (trivial)~~ — **erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
|
||||
- [x] ~~**Bogen-Werkzeug** (mittel)~~ — **erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
|
||||
|
||||
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 1–6) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, 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 R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT** — `parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings` → `{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen** — `Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
|
||||
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
|
||||
|
||||
- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
|
||||
- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** ✅ **Phase 1 (Wand unter Schnittebene) erledigt (2026-07-05):** `generatePlan.addWallPoche` hat einen `viewOnly`-Zweig — erreicht eine Wand die Grundriss-Schnitthöhe nicht (`wall.height < floor.cutHeight`, z. B. 0.3-m-Brüstung bei 1 m), wird sie nur als Ansichts-Umriss (Haarlinie, `fill:"none"`, keine Schraffur) gezeichnet statt als Schnitt-Poché; normale Wände (≥ Schnitthöhe) unberührt. Segment-/Gehrungs-/Öffnungs-Logik geteilt. +2 Tests (`generatePlan.viewwall.test.ts`, Suite 336). ✅ **Phase 2 (Decke über Ebene = gestrichelte Überkopf-Linie) erledigt (2026-07-05, `39ddd9b`):** der freie Decken-Umriss (Überstände/Balkone, wo keine Wand verdeckt) ist jetzt gestrichelte Haarlinie (`OVERHEAD_DASH`) statt kräftiger Volllinie — BIM-Konvention „Aufsicht auf Bauteil über einem". Decken-FLÄCHE war schon Ansicht (viewHatchId, weiß). +1 Test. **Offen (Phase 3):** per-Component View-/Cut-Weight + eigene View-Schraffur (Slot-Trennung); cut/Aufsicht/Untersicht (Deckenspiegel/Reflected Ceiling Plan); Unterzüge; Feinheiten. Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
|
||||
- Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
|
||||
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
|
||||
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
|
||||
@@ -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.
|
||||
- [ ] **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).
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
|
||||
- **Auth + Projekt-Liste:** Supabase Auth (Email/Magic-Link), Projektübersicht, Öffnen/Schliessen.
|
||||
- **Lock-Service:** Tabelle `object_locks (project_id, object_id, user_id, locked_at)` mit Row-Level Security; `reserveObjects(ids[])` / `releaseObjects(ids[])` als Supabase-RPC; Optimistisches Check-in via DB-Constraint (doppelte Reserve → Fehler → UI-Feedback).
|
||||
- **Realtime-Sync:** Supabase Realtime-Channel pro Projekt; bei Freigabe werden die veränderten Objekte (JSON-Patch oder ganzes Objekt-Payload, TBD) gepusht; lokaler Store merged incoming changes sofort.
|
||||
- **Presence:** Wer ist online, wer hat welche Objekte reserviert (farbige User-Badges an reservierten Elementen im Plan).
|
||||
- **Offline-Guard:** Beim Verbindungsabbruch Locks automatisch nach Timeout freigeben (DB-seitig: `locked_at + interval` prüfen).
|
||||
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
|
||||
- **Aufwand:** gross (2–4 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
|
||||
|
||||
### 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 ~18–29 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~1–2 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~3–5 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~5–8 · Mipmaps (Blit-Pass) + anisotropes Filtern ~1–2 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~3–4 · UI zum Zuweisen + Persistenz im Dokumentmodell ~5–8.
|
||||
- [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).
|
||||
- [ ] **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._
|
||||
|
||||
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
|
||||
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
|
||||
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
|
||||
- [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus** — `DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen.
|
||||
- [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar").
|
||||
- [x] 2026-07-05 **Textur-Spike verifiziert erledigt** (`0ca3b1d`) — bei der Backlog-Abarbeitung festgestellt, dass die render3d-Textur-Spike (`RenderStyle::Textured`, Schachbrett, planare Meter-UVs, `MESH_TEXTURED_WGSL` group 1, `spike3d`-`T`-Toggle) bereits vollständig umgesetzt + committet war; `cargo test` 58/59 grün, keine neuen Deps, Alt-Pfad bitgleich. „Als Nächstes"-Eintrag war veraltet → abgehakt; PBR-Restlückenliste (~18–29 PT) in 3D-REST übernommen.
|
||||
- [x] 2026-07-05 **Ribbon-Feinschliff (OCS-Zeile)** — Tabs in die TopBar-Zeile verlegt (`456ebc8`, eine Leiste Chrome+Tabs, Inhalt darunter; Tab-State in App); OCS-Chrome: kleine Wortmarke „dossier." + Quick-Access-Icons für ALLE Datei-/Export-Aktionen statt Burger-Menü, Zeile auf 26px (`4e3b074`); Text-/Font-Formatierung mit der Ansichts-/Zoom-Steuerung auf EINE Leiste gelegt, „Ansichten" als Standard-Tab zuerst + initial aktiv (`fe22cbf`). Alle tsc + 331 Tests grün. Visuelle Abnahme (26px, Icon-Sitz) noch offen.
|
||||
- [x] 2026-07-05 **Ribbon Phase 2: TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll mergen") — die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid+Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart) als `ViewRibbonTab` (in `TopBar.tsx`) in den Ansichten-Tab verschoben; App reicht sie als `viewsContent`-Node an `RibbonBar` (analog Layout-Menü). TopBar ist jetzt schmale globale Leiste (Marke/Ressourcen · Text · Datei/Export/Einstellungen · Fensterknöpfe). `TopBarProps` entsprechend verschlankt. Visuelle Feinabstimmung offen.
|
||||
- [x] 2026-07-05 **Ribbon-UI Phase 1** (`9d6e86d`) — datengetriebene Tab-Leiste (`src/ui/ribbon/`: `ribbonItems.ts` Registry, `RibbonBar.tsx`, `CommandIcon.tsx`) unter der TopBar, additiv (Sidebar bleibt). Tabs 2D·3D·BIM·Ansichten; 2D=Zeichnen(select/line/polyline/rect/circle/arc)+Ändern(move/copy/mirror/offset/trim/join), BIM=Bauteile(wall/window/door/stair/ceiling/room); 3D/Ansichten noch leer. Ein Aktivierungs-Pfad: tool→`onSelectTool`, command→`onRunCommand`(engine.start); Aktiv-Highlight über `activeTool`/`engineView.commandName`. `ToolIcon` aus ToolsPanel exportiert (wiederverwendet).
|
||||
- [x] 2026-07-05 **Attribut-Panel: ein Grid, volle Breite** (`ace0dc6`) — drei getrennte Grids zu EINEM durchgehenden Zwei-Spalten-Grid gemerged (Wertspalte über alle Sektionen bündig), Dropdown-Pills füllen die Wertspalte (fixe Breiten raus), doppeltes Eigen-Padding + zweiter „Attribute"-Titel entfernt. **Nutzer-Report.**
|
||||
- [x] 2026-07-05 **Eigenschaften-Grid OCS-Stil** (`00733d8`) — `.attr-*` als klares Grid: Sektion-Balken, Zeilentrenner, füllende linksbündige Wertfelder (Texte/Zahlen/Dropdowns einheitlich). Erste Stufe der Ribbon-UI-Vision.
|
||||
- [x] 2026-07-05 **Zoom-Scroll-Fix** (`077e774`) — Dokument-Overscroll/Rubberband gesperrt (`html,body,#root { overflow:hidden; overscroll-behavior:none }`); Viewport-Zoom zieht die UI nicht mehr mit (macOS). **Nutzer-Report.**
|
||||
- [x] 2026-07-05 **Kreis- + Bogen-Werkzeug** (`e454eab`, `bd2b12b`) — Kreis-Toolbar (circleCommand gekoppelt) + neues `arcCommand` (3-Klick CCW), beide mit Toolbar-Icon/i18n; rendern glatt via `drawingCircle`/`drawingArc`.
|
||||
|
||||
- [x] 2026-07-05 **DXF-Import: Platzierungsoption** (`dd76ec8`) — ImportDialog fragt „relativ zum Nullpunkt" ODER „in die Mitte der aktuellen Ansicht"; `PlanViewHandle.viewCenterModel()` neu; `runImport` verschiebt den Gesamt-Umriss (bbox-Mitte → Ansichtsmitte). tsc + Suite 331 grün.
|
||||
- [x] 2026-07-05 **Zeichnungen Copy/Paste über Geschosse** (`376aa67`) — Ctrl/Cmd+C kopiert gewählte Drawing2D tief; Ctrl/Cmd+V fügt Klone (neue IDs) auf dem AKTIVEN Geschoss ein und wählt sie. Für z. B. importiertes Mobiliar. Textfeld-Fokus bleibt natives Copy/Paste.
|
||||
- [x] 2026-07-05 **Tauri Drag&Drop + Import-Dialog-Fix** (`c5b5ca6`, `45e19b7`) — `import`-Befehl als `autoRun` (Datei-Dialog öffnet synchron in der Geste, HMR-Falle via Config-Relaunch behoben); `dragDropEnabled:false` (Tauris natives Drag-Drop fing HTML-Drops ab). **Nutzer-bestätigt: Dialog + Text gehen.**
|
||||
- [x] 2026-07-05 **Layout: Befehlszeile in Mitte-Spalte** (`c794fee`) — nur Viewport-breit, Docks gewinnen Höhe. **Nutzer-bestätigt „sieht clean aus".**
|
||||
|
||||
- [x] 2026-07-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
|
||||
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
|
||||
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Ribbon-UI + modulare Bars — Plan
|
||||
|
||||
Nutzer-Vision (2026-07-05, mit OCS/AutoCAD-Ribbon als Referenz). Ziel: klare,
|
||||
einheitliche Werkzeug-/Eigenschaften-Darstellung; Werkzeug-Sidebar entfällt.
|
||||
|
||||
## Zielbild
|
||||
|
||||
- **Ribbon-Oberleiste mit Tabs: 2D · 3D · BIM · Ansichten.** Jeder Tab zeigt
|
||||
gruppierte Werkzeug-/Aktions-Icons (wie OCS Draw/Model/Insert/Annotate/View).
|
||||
- **2D**: Zeichnen (Select/Linie/Polylinie/Rechteck/Kreis/Bogen/Text) · Ändern
|
||||
(Move/Copy/Mirror/Offset/Trim/Join/Boolean).
|
||||
- **3D**: Volumen/Boolean (vorhandene 3D-Aktionen), Kamera-Presets.
|
||||
- **BIM**: Wand/Fenster/Tür/Treppe/Decke/Raum (die Bauteile).
|
||||
- **Ansichten**: Geschoss/Schnitt/Ansicht-Wechsel, Zoom/Einpassen, Render-Modi.
|
||||
- **Werkzeug-Sidebar entfällt** → rechtes Panel (Attribute) bekommt volle Höhe.
|
||||
- **Objektinfo unter die Attribute** mergen (Wandstil-Definition etc. zusammen).
|
||||
- **XYZ-Referenzpunkt-Box oben rechts bleibt** (vom Nutzer ausdrücklich gewünscht).
|
||||
- **Eigenschaften-Grid im OCS-Stil** — ✅ bereits erledigt (`00733d8`): Sektion-
|
||||
Balken, Zeilentrenner, füllende linksbündige Wertfelder (`.attr-*`).
|
||||
|
||||
## Architektur — DATENGETRIEBEN (ermöglicht modulare Custom-Bar)
|
||||
|
||||
Kern: EINE Registry beschreibt alle Ribbon-Elemente; Ribbon-Tabs UND eine
|
||||
benutzerdefinierte Custom-Bar sind bloß zwei Ansichten über dieselbe Registry.
|
||||
|
||||
```
|
||||
RibbonItem =
|
||||
| { kind: "tool"; id: ToolId } // aktiviert ein Werkzeug (onSelectTool)
|
||||
| { kind: "command"; name: string } // startet einen Engine-Befehl (engine.start)
|
||||
| { kind: "action"; id: string; run: () => void } // freie App-Aktion (Zoom, Render-Modus …)
|
||||
|
||||
RibbonGroup = { titleKey: string; items: RibbonItem[] }
|
||||
RibbonTab = { id: "2d"|"3d"|"bim"|"views"; labelKey: string; groups: RibbonGroup[] }
|
||||
RIBBON: RibbonTab[]
|
||||
```
|
||||
|
||||
- Icons: `ToolIcon` (vorhanden) für `tool`-Items; für `command`/`action` eine
|
||||
kleine Icon-Map (SVG) analog `ToolsPanel`.
|
||||
- Aktivierung: `tool` → `onSelectTool(id)` · `command` → `engine.start(name)` ·
|
||||
`action` → `run()`. Aktiv-Highlight über `activeTool` bzw. laufenden Befehl.
|
||||
- **Custom-Bar (modular)**: der Nutzer wählt beliebige `RibbonItem`s in eine
|
||||
persistierte Liste (`viewSlice`/Projekt); eine `CustomBar`-Komponente rendert
|
||||
genau diese. Gleiche Item-Typen, gleiche Aktivierung — kein Sonderweg.
|
||||
|
||||
## Phasen
|
||||
|
||||
1. ✅ **Gerüst + 2D/BIM-Tab** (`9d6e86d`). `RibbonBar` unter der TopBar (additiv).
|
||||
Datengetriebene Registry (`src/ui/ribbon/ribbonItems.ts`), 2D-Tab (Zeichnen +
|
||||
Ändern) und BIM-Tab (Bauteile) gefüllt, Tab-State lokal, Modify-Icons ergänzt.
|
||||
2. ✅ **TopBar → „Ansichten"-Tab gemergt** (`85011cb`, Nutzer-Entscheid „voll
|
||||
mergen"). Die Ansichts-/Zoom-/Darstellungs-Cluster der TopBar (View-Grid +
|
||||
Kamera, Ebenen-/Zeichnungs-Kombis, Detailgrad, Massstab/Zoom, Darstellungsart)
|
||||
sind als `ViewRibbonTab` (in `TopBar.tsx`) in den „Ansichten"-Tab gewandert;
|
||||
App reicht sie als `viewsContent`-Node an `RibbonBar` (wie das Layout-Menü).
|
||||
Die **Tab-Reiter sitzen in der TopBar-Zeile** (`RibbonTabs`, in TopBar
|
||||
gerendert; Tab-State in App), der Ribbon-Inhalt (`RibbonBar`) folgt darunter →
|
||||
nur ZWEI Bänder. Die TopBar ist eine **schmale Leiste** (Höhe 40px): Marke/
|
||||
Ressourcen · Tabs · Datei/Export/Einstellungen · Fensterknöpfe. Die Text-/
|
||||
Font-Formatierung (`TextGroup`) liegt im eigenen **„Text"-Tab** (Reihenfolge
|
||||
2D·3D·BIM·Text·Ansichten; `RibbonBar.tabContent` trägt „text" + „views").
|
||||
- **Offen (visuell iterieren):** ob Zoom/Massstab zusätzlich als Dauer-Anzeige
|
||||
(Statusleiste) sichtbar sein soll; Default-Tab (aktuell 2D); 3D-Tab noch
|
||||
leer (Kamera-Presets/3D-Aktionen füllen).
|
||||
3. **Sidebar raus** (ToolsPanel aus Default-Layout) + rechtes Panel volle Höhe;
|
||||
Objektinfo unter Attribute mergen.
|
||||
4. **Modulare Custom-Bar**: Item-Picker + persistierte Custom-Bar-Ansicht.
|
||||
|
||||
## Hinweise / Randbedingungen
|
||||
|
||||
- Additiv bauen: bestehende Kommandozeile + Nummern-Shortcuts + `ToolsPanel`
|
||||
bleiben funktionsfähig, bis Phase 3 die Sidebar bewusst entfernt.
|
||||
- Werkzeug-Kopplung Tool↔Befehl existiert bereits (`TOOL_COMMAND`) — Ribbon nutzt
|
||||
denselben `onSelectTool`/`engine.start`-Pfad (EIN Pfad, keine Duplikate).
|
||||
- Empfehlung: Phase 1+ in fokussierter Session mit visueller Iteration im Tauri.
|
||||
@@ -0,0 +1,403 @@
|
||||
# truck-Integration — Profil-Extrusion (B-Rep → 3D-Mesh)
|
||||
|
||||
> Übergabe-Dokument für die Implementierung. Lies zuerst CONVENTIONS.md.
|
||||
|
||||
## Ziel
|
||||
|
||||
Nutzer zeichnen im Grundriss ein geschlossenes Polygon (z. B. L-Profil einer Stütze)
|
||||
und können es als 3D-Körper auf eine Höhe extrudieren. Das Ergebnis erscheint sofort
|
||||
im 3D-Viewport neben Wänden und Decken.
|
||||
|
||||
**MVP-Scope (dieser Auftrag):**
|
||||
- Neue Rust/WASM-Crate `trucksolid` mit zwei Funktionen: `extrude_polygon` + `extrude_circle`
|
||||
- Neue TS-Wrapper-Datei `src/engine/truckSolid.ts` (WASM laden + typisierte API)
|
||||
- KEIN neues Werkzeug, KEIN UI — nur die Geometrie-Schicht. UI kommt in einem
|
||||
separaten Folgeauftrag.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Kontext
|
||||
|
||||
```
|
||||
src-tauri/
|
||||
render3d/ ← bestehend: wgpu-Renderer (wasm-pack → pkg3d/)
|
||||
kernel2d/ ← bestehend: 2D-Geometrie-Kern
|
||||
trucksolid/ ← NEU (dieser Auftrag)
|
||||
Cargo.toml
|
||||
src/
|
||||
lib.rs
|
||||
|
||||
src/engine/
|
||||
pkg3d/ ← render3d WASM-Output
|
||||
pkgTruck/ ← NEU: trucksolid WASM-Output (wasm-pack Ziel)
|
||||
truckSolid.ts ← NEU: TS-Wrapper
|
||||
|
||||
src/plan/
|
||||
toWalls3d.ts ← bestehend: baut RenderScene; emitMeshes() muss erweitert werden
|
||||
```
|
||||
|
||||
Das Koordinatensystem in DOSSIER ist **Modell-Meter: X = rechts, Y = oben (Grundriss),
|
||||
Z = Höhe**. render3d erwartet `positions` im gleichen System — `mesh.rs` macht intern
|
||||
`(x, y, z) → (x, z, y)` für wgpu (Y-up world). Truck arbeitet in 3D; wir bauen
|
||||
das Polygon in der XY-Ebene (z=0) und extrudieren nach +Z.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Rust-Crate `src-tauri/trucksolid/`
|
||||
|
||||
### 1.1 Cargo.toml
|
||||
|
||||
Exakt dasselbe Muster wie `src-tauri/kernel2d/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace] # entkoppelt vom cad-tauri-Workspace (Pflicht)
|
||||
|
||||
[package]
|
||||
name = "trucksolid"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Profil-Extrusion via truck (B-Rep → tesselliertes Mesh für render3d)"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
web = [
|
||||
"dep:wasm-bindgen",
|
||||
"dep:serde_json",
|
||||
"dep:console_error_panic_hook",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# truck: nur die stabilen Crates (KEIN truck-modeling — Booleans instabil)
|
||||
truck-geometry = "0.3"
|
||||
truck-topology = "0.3"
|
||||
truck-rendimesh = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
**Wichtig:** `truck-modeling` (die Crate mit Boolean-Operatoren) wird NICHT eingebunden.
|
||||
Nur `truck-geometry`, `truck-topology` und `truck-rendimesh` — die sind stabil.
|
||||
|
||||
### 1.2 src/lib.rs — Kern-Logik
|
||||
|
||||
#### Eingabe/Ausgabe-Typen
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input: flaches Array [x0,y0, x1,y1, ...] in Modell-Metern (Grundriss-XY).
|
||||
/// Muss ≥ 3 Punkte enthalten; Wiederholung des ersten Punkts am Ende optional.
|
||||
/// Reihenfolge CCW oder CW — truck normalisiert selbst.
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtrudePolyInput {
|
||||
pub points: Vec<f64>, // flat: [x0,y0, x1,y1, ...]
|
||||
pub height: f64, // Extrusionshöhe in Metern (> 0)
|
||||
}
|
||||
|
||||
/// Output: trianguliertes Mesh, kompatibel mit render3d::types::MeshInput.
|
||||
/// positions: flat [x0,y0,z0, x1,y1,z1, ...] in Modell-Metern
|
||||
/// indices: Dreiecks-Indizes (je 3 = 1 Dreieck), 0-basiert
|
||||
#[derive(Serialize)]
|
||||
pub struct MeshOutput {
|
||||
pub positions: Vec<f32>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Polygon-Extrusion (truck-API)
|
||||
|
||||
```rust
|
||||
use truck_geometry::prelude::*;
|
||||
use truck_topology::*;
|
||||
|
||||
pub fn extrude_polygon_core(pts: &[(f64, f64)], height: f64) -> Result<MeshOutput, String> {
|
||||
if pts.len() < 3 { return Err("min 3 Punkte".into()); }
|
||||
if height <= 0.0 { return Err("height muss > 0 sein".into()); }
|
||||
|
||||
// 1. Punkte in der XY-Ebene (z=0) als truck-Vertices
|
||||
let verts: Vec<Vertex<Point3>> = pts.iter()
|
||||
.map(|(x, y)| Vertex::new(Point3::new(*x, *y, 0.0)))
|
||||
.collect();
|
||||
|
||||
// 2. Kanten: je zwei aufeinanderfolgende Vertices verbinden, Ring schliessen
|
||||
let edges: Vec<Edge<_, Line<Point3>>> = verts.windows(2)
|
||||
.chain(std::iter::once([verts.last().unwrap(), &verts[0]].as_slice()))
|
||||
.map(|w| {
|
||||
let p0 = *w[0].point();
|
||||
let p1 = *w[1].point();
|
||||
Edge::new(&w[0], &w[1], Line(p0, p1))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3. Wire (geschlossener Kantenzug)
|
||||
let wire = Wire::from_iter(edges);
|
||||
|
||||
// 4. Planare Face aus dem Wire
|
||||
// truck-topology::Face::new braucht die äussere Boundary + optional Holes
|
||||
let face = Face::new(vec![wire]);
|
||||
|
||||
// 5. Lineare Extrusion: tsweep entlang +Z um `height`
|
||||
let solid = face.tsweep(&Vector3::new(0.0, 0.0, height));
|
||||
|
||||
// 6. Tessellieren (chord-tolerance in Metern — 0.005 = 5 mm)
|
||||
use truck_rendimesh::MeshedShape;
|
||||
let mesh = solid.triangulation(0.005).to_polygon();
|
||||
|
||||
// 7. positions + indices extrahieren
|
||||
let positions: Vec<f32> = mesh.positions().iter()
|
||||
.flat_map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||
.collect();
|
||||
let indices: Vec<u32> = mesh.tri_faces().iter()
|
||||
.flat_map(|tri| tri.iter().map(|idx| idx.pos as u32))
|
||||
.collect();
|
||||
|
||||
Ok(MeshOutput { positions, indices })
|
||||
}
|
||||
```
|
||||
|
||||
> **Achtung:** Die exakte truck-API (Methoden-Namen, Trait-Imports, tsweep-Signatur)
|
||||
> kann je nach veröffentlichter Version leicht abweichen. Prüfe die Docs von
|
||||
> `truck-topology 0.3` und `truck-rendimesh 0.3` auf docs.rs. Die Struktur oben
|
||||
> ist das Ziel-Pattern — passe Methoden-Signaturen an, wenn der Compiler es verlangt.
|
||||
> Ändere NICHT die Eingabe/Ausgabe-JSON-Struktur.
|
||||
|
||||
#### Kreis-Extrusion (Zylinder)
|
||||
|
||||
```rust
|
||||
pub fn extrude_circle_core(cx: f64, cy: f64, r: f64, height: f64) -> Result<MeshOutput, String> {
|
||||
// Kreis in XY-Ebene tessellieren (N Segmente je nach r),
|
||||
// dann wie extrude_polygon_core aufrufen.
|
||||
// Alternativ: truck-geometry::Ellipse/Circle direkt nutzen, falls vorhanden.
|
||||
let n = (2.0 * std::f64::consts::PI * r / 0.02).ceil().max(16.0) as usize;
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
|
||||
(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
extrude_polygon_core(&pts, height)
|
||||
}
|
||||
```
|
||||
|
||||
#### WASM-Bindings (Feature "web")
|
||||
|
||||
```rust
|
||||
#[cfg(feature = "web")]
|
||||
mod web {
|
||||
use super::*;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// Extrudiert ein Polygon-Profil.
|
||||
/// `input_json`: `{ "points": [x0,y0,…], "height": 2.5 }`
|
||||
/// Rückgabe: `{ "positions": […], "indices": […] }` oder throws JsError.
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_polygon(input_json: &str) -> Result<String, JsError> {
|
||||
let input: ExtrudePolyInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let pts: Vec<(f64, f64)> = input.points.chunks(2)
|
||||
.map(|c| (c[0], c[1]))
|
||||
.collect();
|
||||
let mesh = extrude_polygon_core(&pts, input.height)
|
||||
.map_err(|e| JsError::new(&e))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Extrudiert einen Kreis-Querschnitt (Zylinder).
|
||||
/// `input_json`: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0 }`
|
||||
#[wasm_bindgen]
|
||||
pub fn extrude_circle(input_json: &str) -> Result<String, JsError> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct In { cx: f64, cy: f64, r: f64, height: f64 }
|
||||
let i: In = serde_json::from_str(input_json)
|
||||
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height)
|
||||
.map_err(|e| JsError::new(&e))?;
|
||||
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Tests (headless, ohne Feature "web")
|
||||
|
||||
Mindestens diese drei Tests müssen mit `cargo test` grün sein:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quad_extrusion_vertex_count() {
|
||||
// 1m × 1m Quadrat, 2m hoch → 8 Eckpunkte minimum (6 Flächen × 2 Dreiecke)
|
||||
let pts = vec![(0.0,0.0),(1.0,0.0),(1.0,1.0),(0.0,1.0)];
|
||||
let m = extrude_polygon_core(&pts, 2.0).unwrap();
|
||||
assert!(m.positions.len() >= 8 * 3); // ≥ 8 Vertices × 3 floats
|
||||
assert_eq!(m.indices.len() % 3, 0); // vollständige Dreiecke
|
||||
assert!(m.indices.len() >= 12 * 3); // ≥ 12 Dreiecke (Quader)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn l_profile_extrusion() {
|
||||
// L-Profil: 6 Punkte
|
||||
let pts = vec![
|
||||
(0.0,0.0),(0.3,0.0),(0.3,0.1),(0.1,0.1),(0.1,0.3),(0.0,0.3),
|
||||
];
|
||||
let m = extrude_polygon_core(&pts, 3.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(m.positions.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_extrusion() {
|
||||
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0).unwrap();
|
||||
assert_eq!(m.indices.len() % 3, 0);
|
||||
assert!(m.positions.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_few_points() {
|
||||
assert!(extrude_polygon_core(&[(0.0,0.0),(1.0,0.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_height() {
|
||||
let pts = vec![(0.0,0.0),(1.0,0.0),(0.5,1.0)];
|
||||
assert!(extrude_polygon_core(&pts, 0.0).is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2 — Build-Script (package.json)
|
||||
|
||||
Füge in `package.json` unter `"scripts"` hinzu:
|
||||
|
||||
```json
|
||||
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
||||
```
|
||||
|
||||
Und in `src-tauri/Cargo.toml` unter `exclude`:
|
||||
|
||||
```toml
|
||||
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3 — TS-Wrapper `src/engine/truckSolid.ts`
|
||||
|
||||
Gleiche Lade-Pattern wie `src/plan/useWasmPlanRenderer.ts` (render2d) und
|
||||
`src/viewport/useWasm3dRenderer.ts` (render3d):
|
||||
|
||||
```typescript
|
||||
// Lazy-Singleton: WASM einmalig laden, dann gecacht.
|
||||
let modulePromise: Promise<typeof import("../../engine/pkgTruck/trucksolid")> | null = null;
|
||||
|
||||
async function getModule() {
|
||||
if (!modulePromise) {
|
||||
modulePromise = import("../../engine/pkgTruck/trucksolid").then(async (m) => {
|
||||
await m.default(); // WASM-Binary initialisieren
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
export interface ExtrudedMesh {
|
||||
positions: number[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/** Extrudiert ein geschlossenes Polygon-Profil (Modell-Meter XY) um `height` m. */
|
||||
export async function extrudePolygon(
|
||||
points: number[], // flat [x0,y0, x1,y1, ...]
|
||||
height: number,
|
||||
): Promise<ExtrudedMesh> {
|
||||
const m = await getModule();
|
||||
const json = m.extrude_polygon(JSON.stringify({ points, height }));
|
||||
return JSON.parse(json) as ExtrudedMesh;
|
||||
}
|
||||
|
||||
/** Extrudiert einen Kreis-Querschnitt (Zylinder). */
|
||||
export async function extrudeCircle(
|
||||
cx: number, cy: number, r: number, height: number,
|
||||
): Promise<ExtrudedMesh> {
|
||||
const m = await getModule();
|
||||
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height }));
|
||||
return JSON.parse(json) as ExtrudedMesh;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4 — Integration in toWalls3d.ts (Vorbereitung)
|
||||
|
||||
In `src/plan/toWalls3d.ts` ist `RMeshKind` bereits definiert:
|
||||
|
||||
```typescript
|
||||
export type RMeshKind = "terrain" | "imported";
|
||||
```
|
||||
|
||||
Erweitere auf `"extrusion"` (damit der Renderer später eine eigene Farbe/Darstellung
|
||||
wählen kann, auch wenn heute noch kein Unterschied besteht):
|
||||
|
||||
```typescript
|
||||
export type RMeshKind = "terrain" | "imported" | "extrusion";
|
||||
```
|
||||
|
||||
Die `emitMeshes()`-Funktion liest bereits `project.drawings2d` und ähnliche Arrays.
|
||||
Für extrudierte Körper wird es später ein `project.extrudedSolids`-Array geben
|
||||
(oder ähnlich — das ist Teil des UI-Folgeauftrags). Die `emitMeshes()`-Erweiterung
|
||||
kommt dann.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Verifikations-Checkliste
|
||||
|
||||
Bevor du als fertig meldest, müssen alle Punkte grün sein:
|
||||
|
||||
- [ ] `cd src-tauri/trucksolid && cargo test` → alle 5 Tests grün, keine Warnings
|
||||
- [ ] `npm run build:truck` → `src/engine/pkgTruck/trucksolid.js` + `.wasm` erzeugt
|
||||
- [ ] `npx tsc --noEmit` (aus Root) → 0 Fehler
|
||||
- [ ] `npx vitest run` → alle bestehenden Tests weiterhin grün (keine Regression)
|
||||
- [ ] `src-tauri/Cargo.toml` hat `"trucksolid"` im `exclude`-Array
|
||||
- [ ] `package.json` hat `"build:truck"` im `"scripts"`-Block
|
||||
|
||||
---
|
||||
|
||||
## 6 — Was NICHT in diesem Auftrag
|
||||
|
||||
- Kein neues UI/Werkzeug (kommt später)
|
||||
- Kein `truck-modeling` (Boolean-Operationen — instabil upstream)
|
||||
- Kein STEP-Export (kommt in Folgeauftrag wenn Basisschicht steht)
|
||||
- Kein Eintrag in `project.extrudedSolids` oder Store (UI-Auftrag)
|
||||
- Keine Änderungen an render3d, generatePlan, App.tsx
|
||||
|
||||
---
|
||||
|
||||
## 7 — Koordinatensystem-Reminder
|
||||
|
||||
| DOSSIER-Modell | truck (im Code) | render3d (wgpu) |
|
||||
|---|---|---|
|
||||
| X = rechts | X = rechts | X = rechts |
|
||||
| Y = oben (Grundriss) | Y = oben (Grundriss) | Z = oben (Y-up swapped) |
|
||||
| Z = Höhe | Z = Höhe | Y = Höhe |
|
||||
|
||||
`mesh.rs` in render3d macht bereits `(model.x, model.y, model.z) → (x, z, y)` beim
|
||||
Hochladen. Du musst also in truck DOSSIER-Koordinaten verwenden (XY-Ebene = Grundriss,
|
||||
Z = Höhe) — das ist konsistent mit der bestehenden `MeshInput.positions`-Konvention.
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
@@ -10,9 +10,13 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "cad",
|
||||
"width": 1400,
|
||||
"height": 900
|
||||
"height": 900,
|
||||
"dragDropEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+258
-65
@@ -69,7 +69,9 @@ import type { Pick3dHit } from "./viewport/Wasm3DViewport";
|
||||
import { ResourceManager } from "./ui/ResourceManager";
|
||||
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
|
||||
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 type { DropdownItem } from "./ui/Dropdown";
|
||||
import { TextEditorDialog } from "./ui/TextEditorDialog";
|
||||
@@ -212,6 +214,8 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
|
||||
line: "line",
|
||||
polyline: "polyline",
|
||||
rect: "rect",
|
||||
circle: "circle",
|
||||
arc: "arc",
|
||||
};
|
||||
/** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */
|
||||
function coupledCommandFor(id: ToolId): string | null {
|
||||
@@ -226,6 +230,68 @@ function promptCount(): number | 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() {
|
||||
// Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache
|
||||
// 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
|
||||
// Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView).
|
||||
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>(
|
||||
project.wallTypes[0].id,
|
||||
);
|
||||
@@ -349,6 +431,11 @@ export default function App() {
|
||||
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
|
||||
const setSelectedDrawingId = (id: string | null) =>
|
||||
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).
|
||||
const selectedWallId = selectedWallIds[0] ?? null;
|
||||
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
|
||||
@@ -766,6 +853,16 @@ export default function App() {
|
||||
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
|
||||
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
|
||||
// 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
|
||||
}, [selectedDrawingIds, engine]);
|
||||
|
||||
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt
|
||||
// die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit
|
||||
// neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so
|
||||
// wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im
|
||||
// Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== "c" && k !== "v") return;
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
if (k === "c") {
|
||||
const ids = new Set(selectedDrawingIds);
|
||||
if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy)
|
||||
const picked = project.drawings2d.filter((d) => ids.has(d.id));
|
||||
if (picked.length === 0) return;
|
||||
e.preventDefault();
|
||||
setDrawingClipboard(picked.map((d) => structuredClone(d)));
|
||||
return;
|
||||
}
|
||||
// Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt.
|
||||
if (drawingClipboard.length === 0) return;
|
||||
e.preventDefault();
|
||||
const stamp = Date.now();
|
||||
const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({
|
||||
...structuredClone(d),
|
||||
id: `drawing-${stamp}-${i}`,
|
||||
levelId: activeLevelId,
|
||||
}));
|
||||
importDrawings(pasted);
|
||||
setSelectedDrawingIds(pasted.map((p) => p.id));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDrawingIds, drawingClipboard, activeLevelId, project.drawings2d]);
|
||||
|
||||
// Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
|
||||
// geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
|
||||
// geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.)
|
||||
@@ -2091,12 +2228,24 @@ export default function App() {
|
||||
decision.target.kind === "new"
|
||||
? addDrawingLevel(decision.target.name)
|
||||
: decision.target.levelId;
|
||||
const drawings = buildDrawings(
|
||||
let drawings = buildDrawings(
|
||||
decision,
|
||||
levelId,
|
||||
project,
|
||||
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 (decision.importMeshes && decision.parsed.meshes.length) {
|
||||
addContextObjects(decision.parsed.meshes);
|
||||
@@ -3084,8 +3233,46 @@ export default function App() {
|
||||
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
|
||||
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"}
|
||||
viewType={viewType}
|
||||
onViewType={setViewType}
|
||||
@@ -3104,9 +3291,6 @@ export default function App() {
|
||||
onFit={fitPlan}
|
||||
onFitSelection={fitSelection}
|
||||
onZoom100={zoomToApplied}
|
||||
onExportPdf={() => setExportOpen(true)}
|
||||
onExportDxf={() => setExportDxfOpen(true)}
|
||||
onExportSchedule={onExportSchedule}
|
||||
renderMode={renderMode}
|
||||
onRenderMode={setRenderMode}
|
||||
renderModeEnabled={viewType === "perspektive"}
|
||||
@@ -3133,20 +3317,25 @@ export default function App() {
|
||||
},
|
||||
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}
|
||||
/>
|
||||
),
|
||||
"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">
|
||||
{/* 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-
|
||||
Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt
|
||||
das Zoom-Handle entgegen. */}
|
||||
@@ -3232,6 +3425,54 @@ export default function App() {
|
||||
onEdit3dWallTop={onEdit3dWallTop}
|
||||
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
|
||||
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. */}
|
||||
<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.
|
||||
Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */}
|
||||
<StatusBar
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Arc — Mittelpunkt + Startpunkt + Endpunkt. Schritte:
|
||||
// 1) „Mittelpunkt:" → Punkt
|
||||
// 2) „Startpunkt:" → Punkt (setzt Radius r = |P1−Center| 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(),
|
||||
};
|
||||
@@ -26,6 +26,16 @@ export const importCommand: Command = {
|
||||
options: () => [],
|
||||
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.
|
||||
onInput: () => open(),
|
||||
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||
|
||||
@@ -13,6 +13,7 @@ import { stairCommand } from "./cmds/stair";
|
||||
import { roomCommand } from "./cmds/room";
|
||||
import { rectCommand } from "./cmds/rect";
|
||||
import { circleCommand } from "./cmds/circle";
|
||||
import { arcCommand } from "./cmds/arc";
|
||||
import { moveCommand } from "./cmds/move";
|
||||
import { mirrorCommand } from "./cmds/mirror";
|
||||
import { joinCommand } from "./cmds/join";
|
||||
@@ -35,6 +36,7 @@ export const COMMANDS: Record<string, Command> = {
|
||||
polyline: polylineCommand,
|
||||
rect: rectCommand,
|
||||
circle: circleCommand,
|
||||
arc: arcCommand,
|
||||
move: moveCommand,
|
||||
mirror: mirrorCommand,
|
||||
join: joinCommand,
|
||||
@@ -67,6 +69,8 @@ export const ALIASES: Record<string, string> = {
|
||||
pl: "polyline",
|
||||
rec: "rect",
|
||||
c: "circle",
|
||||
a: "arc",
|
||||
bogen: "arc",
|
||||
m: "move",
|
||||
s: "mirror",
|
||||
spiegeln: "mirror",
|
||||
|
||||
+31
-5
@@ -116,6 +116,23 @@ export const de = {
|
||||
"tool.line": "Linie",
|
||||
"tool.polyline": "Polylinie",
|
||||
"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.window.hint": "Fenster: 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)
|
||||
"attr.title": "Attribute",
|
||||
"attr.empty": "Keine Auswahl",
|
||||
"attr.newComponent": "Neues Bauteil",
|
||||
"attr.stroke": "Stift",
|
||||
"attr.color": "Farbe",
|
||||
"attr.weight": "Strichstärke (mm)",
|
||||
@@ -255,7 +273,7 @@ export const de = {
|
||||
"objinfo.wall.refLine.center": "Mitte",
|
||||
"objinfo.wall.refLine.right": "Innen (rechts)",
|
||||
"objinfo.wall.buildup": "Aufbau",
|
||||
"objinfo.wall.single": "Einschichtig",
|
||||
"objinfo.wall.single": "Solid",
|
||||
"objinfo.wall.multi": "Mehrschichtig",
|
||||
"objinfo.wall.thickness": "Dicke",
|
||||
"objinfo.wall.preset": "Wandaufbau",
|
||||
@@ -264,8 +282,8 @@ export const de = {
|
||||
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
|
||||
"objinfo.wall.bottom": "Unterkante (UK)",
|
||||
"objinfo.wall.top": "Oberkante (OK)",
|
||||
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
|
||||
"objinfo.wall.anchorCustom": "Eigene Höhe",
|
||||
"objinfo.wall.anchorFloor": "Gebunden",
|
||||
"objinfo.wall.anchorCustom": "Eigene",
|
||||
"objinfo.wall.height": "Höhe",
|
||||
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
||||
"objinfo.ceiling.section": "Decke",
|
||||
@@ -277,8 +295,8 @@ export const de = {
|
||||
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
||||
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
||||
"objinfo.ceiling.area": "Fläche",
|
||||
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden",
|
||||
"objinfo.ceiling.anchorCustom": "Eigene Höhe",
|
||||
"objinfo.ceiling.anchorFloor": "Gebunden",
|
||||
"objinfo.ceiling.anchorCustom": "Eigene",
|
||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Öffnung",
|
||||
"objinfo.opening.kind": "Art",
|
||||
@@ -707,6 +725,10 @@ export const de = {
|
||||
"cmd.circle.label": "Kreis",
|
||||
"cmd.circle.center": "Mittelpunkt:",
|
||||
"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.
|
||||
"cmd.move.label": "Verschieben",
|
||||
"cmd.move.base": "Basispunkt:",
|
||||
@@ -779,6 +801,7 @@ export const de = {
|
||||
"import.file": "Datei",
|
||||
"import.summary.heading": "Inhalt",
|
||||
"import.summary.contours": "{n} Konturen",
|
||||
"import.summary.texts": "{n} Texte",
|
||||
"import.summary.meshes": "{n} Meshes",
|
||||
"import.summary.layers": "Layer: {layers}",
|
||||
"import.summary.noLayers": "keine Layer-Namen",
|
||||
@@ -790,6 +813,9 @@ export const de = {
|
||||
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
|
||||
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
|
||||
"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.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
|
||||
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
|
||||
|
||||
+31
-5
@@ -115,6 +115,23 @@ export const en: Record<TranslationKey, string> = {
|
||||
"tool.line": "Line",
|
||||
"tool.polyline": "Polyline",
|
||||
"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.window.hint": "Window: 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)
|
||||
"attr.title": "Attributes",
|
||||
"attr.empty": "No selection",
|
||||
"attr.newComponent": "New component",
|
||||
"attr.stroke": "Pen",
|
||||
"attr.color": "Color",
|
||||
"attr.weight": "Line weight (mm)",
|
||||
@@ -254,7 +272,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.wall.refLine.center": "Center",
|
||||
"objinfo.wall.refLine.right": "Interior (right)",
|
||||
"objinfo.wall.buildup": "Build-up",
|
||||
"objinfo.wall.single": "Single layer",
|
||||
"objinfo.wall.single": "Solid",
|
||||
"objinfo.wall.multi": "Multi layer",
|
||||
"objinfo.wall.thickness": "Thickness",
|
||||
"objinfo.wall.preset": "Wall style",
|
||||
@@ -263,8 +281,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.wall.linkAbove": "Bind to floor above",
|
||||
"objinfo.wall.bottom": "Bottom (UK)",
|
||||
"objinfo.wall.top": "Top (OK)",
|
||||
"objinfo.wall.anchorFloor": "Bound to floor",
|
||||
"objinfo.wall.anchorCustom": "Custom height",
|
||||
"objinfo.wall.anchorFloor": "Bound",
|
||||
"objinfo.wall.anchorCustom": "Custom",
|
||||
"objinfo.wall.height": "Height",
|
||||
"objinfo.wall.floorAboveNone": "No floor above",
|
||||
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
||||
@@ -277,8 +295,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.ceiling.bottom": "Bottom (UK)",
|
||||
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
||||
"objinfo.ceiling.area": "Area",
|
||||
"objinfo.ceiling.anchorFloor": "Bound to floor",
|
||||
"objinfo.ceiling.anchorCustom": "Custom height",
|
||||
"objinfo.ceiling.anchorFloor": "Bound",
|
||||
"objinfo.ceiling.anchorCustom": "Custom",
|
||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Opening",
|
||||
"objinfo.opening.kind": "Type",
|
||||
@@ -697,6 +715,10 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.circle.label": "Circle",
|
||||
"cmd.circle.center": "Center:",
|
||||
"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.base": "Base point:",
|
||||
"cmd.move.target": "Target point:",
|
||||
@@ -768,6 +790,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"import.file": "File",
|
||||
"import.summary.heading": "Contents",
|
||||
"import.summary.contours": "{n} contours",
|
||||
"import.summary.texts": "{n} texts",
|
||||
"import.summary.meshes": "{n} meshes",
|
||||
"import.summary.layers": "Layers: {layers}",
|
||||
"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.asCategories": "Keep DXF layers as categories",
|
||||
"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.asContext": "Import meshes as context geometry (3D)",
|
||||
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
|
||||
|
||||
+107
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseDxf } from "./dxfParser";
|
||||
import { contoursToDrawings } from "./dxfToDrawings";
|
||||
import { contoursToDrawings, textsToDrawings } from "./dxfToDrawings";
|
||||
import type { Contour, ContourSet, Vec2 } from "../model/types";
|
||||
|
||||
/** 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", () => {
|
||||
it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => {
|
||||
const set: ContourSet = {
|
||||
|
||||
+68
-5
@@ -24,9 +24,10 @@
|
||||
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
|
||||
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
|
||||
// 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 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)
|
||||
@@ -41,10 +42,12 @@ export interface ImportDiagnostics {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
|
||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze + Texte. */
|
||||
export interface DxfImportResult {
|
||||
meshes: ImportedMesh[];
|
||||
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. */
|
||||
diagnostics?: ImportDiagnostics;
|
||||
}
|
||||
@@ -97,6 +100,12 @@ interface DxfEntity {
|
||||
rowSpacing?: number;
|
||||
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
|
||||
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;
|
||||
}
|
||||
/** 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 meshIndices: number[] = [];
|
||||
const contours: Contour[] = [];
|
||||
const texts: ImportedText[] = [];
|
||||
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
@@ -156,6 +166,11 @@ export function parseDxf(text: string): DxfImportResult {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
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
|
||||
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -393,7 +444,13 @@ function arcContour(e: DxfEntity): Contour | null {
|
||||
const t = start + (sweep * i) / segs;
|
||||
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). */
|
||||
@@ -420,7 +477,13 @@ function circleContour(e: DxfEntity): Contour | null {
|
||||
const t = (Math.PI * 2 * i) / segs;
|
||||
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
@@ -15,7 +15,7 @@
|
||||
//
|
||||
// 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. */
|
||||
export type CategoryMode = "active" | "byLayer";
|
||||
@@ -66,8 +66,50 @@ export function contoursToDrawings(
|
||||
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). */
|
||||
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;
|
||||
if (pts.length < 2) return null;
|
||||
if (pts.length === 2 && !contour.closed) {
|
||||
|
||||
+14
-18
@@ -16,22 +16,18 @@ export const sampleProject: Project = {
|
||||
id: "demo",
|
||||
name: "Demo-Haus",
|
||||
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
|
||||
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
|
||||
// ist der dünne Strich für Schraffur-Muster.
|
||||
// Linienstile: aufgeräumt auf kanonische VOLLLINIEN je Strichstärke (dash=null)
|
||||
// + 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: [
|
||||
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
||||
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
||||
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
|
||||
{ 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 },
|
||||
{ id: "hatch-hair", name: "Volllinie 0.02", weight: 0.02, color: "#1a1a1a", dash: null },
|
||||
{ id: "thin", name: "Volllinie 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
{ id: "medium", name: "Volllinie 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
||||
{ id: "thick", name: "Volllinie 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
||||
// 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] },
|
||||
// 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.
|
||||
// "none" = keine Schraffur (nur Component-Füllung).
|
||||
@@ -46,7 +42,7 @@ export const sampleProject: Project = {
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
lineStyleId: "thin",
|
||||
},
|
||||
{
|
||||
id: "diagonal",
|
||||
@@ -55,7 +51,7 @@ export const sampleProject: Project = {
|
||||
scale: 1,
|
||||
angle: 45,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
lineStyleId: "thin",
|
||||
},
|
||||
{
|
||||
id: "crosshatch",
|
||||
@@ -64,7 +60,7 @@ export const sampleProject: Project = {
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
lineStyleId: "thin",
|
||||
},
|
||||
{
|
||||
id: "solid-concrete",
|
||||
@@ -133,7 +129,7 @@ export const sampleProject: Project = {
|
||||
layers: [
|
||||
{ componentId: "render-ext", thickness: 0.02 },
|
||||
// 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: "render-int", thickness: 0.015 },
|
||||
],
|
||||
@@ -160,7 +156,7 @@ export const sampleProject: Project = {
|
||||
// Estrich (schwimmend) → Trittschalldämmung.
|
||||
{ componentId: "screed", thickness: 0.06 },
|
||||
// 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.
|
||||
{ componentId: "concrete", thickness: 0.2 },
|
||||
],
|
||||
|
||||
@@ -1098,6 +1098,33 @@ export interface Contour {
|
||||
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
|
||||
*/
|
||||
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). */
|
||||
|
||||
@@ -30,6 +30,16 @@ import type { AttributeSource } from "../model/types";
|
||||
import { usePanelHost } from "./host";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
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:
|
||||
* „custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht
|
||||
@@ -47,11 +57,38 @@ export function AttributesPanel() {
|
||||
const sel = host.selection;
|
||||
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) {
|
||||
return (
|
||||
<div className="attr-panel">
|
||||
<div className="attr-empty">{t("attr.empty")}</div>
|
||||
{typeSection}
|
||||
{!typeSection && <div className="attr-empty">{t("attr.empty")}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +117,6 @@ export function AttributesPanel() {
|
||||
onChange: (next: UiSource) => void,
|
||||
) => (
|
||||
<Dropdown
|
||||
width={104}
|
||||
value={ui}
|
||||
disabled={!editable}
|
||||
onChange={(v) => onChange(v as UiSource)}
|
||||
@@ -110,7 +146,7 @@ export function AttributesPanel() {
|
||||
) => (
|
||||
<>
|
||||
<span className="attr-key">{t(labelKey)}</span>
|
||||
<span className="attr-val" style={{ gap: 6 }}>
|
||||
<span className="attr-val">
|
||||
{ui === "custom" && valueEditor}
|
||||
{sourceSelect(editable, ui, onSource)}
|
||||
</span>
|
||||
@@ -159,11 +195,15 @@ export function AttributesPanel() {
|
||||
|
||||
return (
|
||||
<div className="attr-panel">
|
||||
<div className="attr-title">{t("attr.title")}</div>
|
||||
|
||||
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
|
||||
<div className="attr-section-label">{t("attr.stroke")}</div>
|
||||
{/* Typ-Picker für das neue Bauteil (nur bei aktivem Wand-/Decken-Werkzeug). */}
|
||||
{typeSection}
|
||||
{/* EIN durchgehendes Grid über alle Sektionen — dieselben zwei Spalten
|
||||
(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">
|
||||
{/* ── Stift/Strich ──────────────────────────────────────────── */}
|
||||
<div className="attr-section-label">{t("attr.stroke")}</div>
|
||||
|
||||
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
|
||||
<span className="attr-key">{t("attr.color")}</span>
|
||||
<span className="attr-val">
|
||||
@@ -208,7 +248,6 @@ export function AttributesPanel() {
|
||||
onChange={(v) =>
|
||||
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
|
||||
}
|
||||
width={120}
|
||||
options={[
|
||||
{ value: "__none__", label: t("attr.lineStyle.none") },
|
||||
...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>
|
||||
</div>
|
||||
|
||||
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────────── */}
|
||||
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/Poché.
|
||||
Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) / Nach Bauteil
|
||||
(erbt vom Component-Manager, Default) / eigener Wert (Override). */}
|
||||
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */}
|
||||
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/
|
||||
Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) /
|
||||
Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */}
|
||||
<div className="attr-section-label">{t("attr.fill")}</div>
|
||||
<div className="attr-grid">
|
||||
|
||||
{sourceRow(
|
||||
"attr.foreground",
|
||||
pocheEditable,
|
||||
@@ -248,7 +286,6 @@ export function AttributesPanel() {
|
||||
hatchUi,
|
||||
onHatchSourceChange,
|
||||
<Dropdown
|
||||
width={104}
|
||||
value={sel.fillHatchId ?? ""}
|
||||
onChange={(v) => host.onSetSelectionFill(v || null)}
|
||||
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-grid">
|
||||
<span className="attr-key">{t("attr.width")}</span>
|
||||
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
|
||||
<span className="attr-key">{t("attr.height")}</span>
|
||||
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
|
||||
</div>
|
||||
|
||||
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) —
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,40 +82,14 @@ export function ObjectInfoPanel() {
|
||||
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 (
|
||||
<div className="objinfo-panel">
|
||||
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
|
||||
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
|
||||
{/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte
|
||||
(Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel;
|
||||
ObjectInfo trägt nur noch Bezugspunkt + Masse. */}
|
||||
<div className="objinfo-head">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
@@ -174,21 +148,9 @@ export function ObjectInfoPanel() {
|
||||
value={height}
|
||||
onCommit={(h) => commitSize(width, h)}
|
||||
/>
|
||||
|
||||
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
|
||||
{wall && <WallSection wall={wall} host={host} />}
|
||||
|
||||
{/* ── 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} />}
|
||||
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
|
||||
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||
die exportierten *Section-Komponenten). */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -197,7 +159,7 @@ export function ObjectInfoPanel() {
|
||||
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
|
||||
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
|
||||
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
|
||||
function RoomSection({
|
||||
export function RoomSection({
|
||||
room,
|
||||
roomId,
|
||||
host,
|
||||
@@ -269,7 +231,7 @@ function RoomSection({
|
||||
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
|
||||
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
||||
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
||||
function StairSection({
|
||||
export function StairSection({
|
||||
stair,
|
||||
host,
|
||||
}: {
|
||||
@@ -385,7 +347,7 @@ function StairSection({
|
||||
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
|
||||
// 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.
|
||||
function OpeningSection({
|
||||
export function OpeningSection({
|
||||
opening,
|
||||
host,
|
||||
}: {
|
||||
@@ -557,7 +519,7 @@ function OpeningSection({
|
||||
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
|
||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
|
||||
function CeilingSection({
|
||||
export function CeilingSection({
|
||||
ceiling,
|
||||
host,
|
||||
}: {
|
||||
@@ -670,7 +632,7 @@ function CeilingSection({
|
||||
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
|
||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
|
||||
function WallSection({
|
||||
export function WallSection({
|
||||
wall,
|
||||
host,
|
||||
}: {
|
||||
@@ -701,6 +663,23 @@ function WallSection({
|
||||
<div className="objinfo-sep" />
|
||||
<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. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
|
||||
|
||||
+18
-40
@@ -13,13 +13,12 @@ import { t } from "../i18n";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { ToolId } from "./host";
|
||||
import { TOOL_ORDER, getTool } from "../tools/tools";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
||||
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
|
||||
// Zeile übernimmt.
|
||||
function ToolIcon({ id }: { id: ToolId }) {
|
||||
export function ToolIcon({ id }: { id: ToolId }) {
|
||||
const common = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
@@ -111,6 +110,20 @@ function ToolIcon({ id }: { id: ToolId }) {
|
||||
<rect x="2.5" y="3.5" width="11" height="9" />
|
||||
</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:
|
||||
return null;
|
||||
}
|
||||
@@ -148,44 +161,9 @@ export function ToolsPanel() {
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="tools-sep" />
|
||||
|
||||
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */}
|
||||
{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" />}
|
||||
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
|
||||
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
|
||||
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-6
@@ -31,7 +31,10 @@ import type {
|
||||
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
|
||||
* 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. */
|
||||
const CURRENT_KEY = "cad.layout";
|
||||
@@ -55,9 +58,12 @@ const NAMED_PREFIX = "cad.layouts.";
|
||||
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
|
||||
* 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[] = [
|
||||
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
|
||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
|
||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
|
||||
];
|
||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
||||
@@ -86,9 +92,10 @@ function cloneGroups(groups: DockGroup[]): DockGroup[] {
|
||||
// ── Standard ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben,
|
||||
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische
|
||||
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
|
||||
* Liefert den eingebauten Startzustand: links das Attribute-Panel (volle Höhe),
|
||||
* rechts Objektinfo/Raumbilanz oben + Zeichnungsebenen/Ebenen/Umgebung unten.
|
||||
* 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 {
|
||||
return {
|
||||
|
||||
+85
-1
@@ -131,6 +131,8 @@ export interface PlanViewHandle {
|
||||
applyScale: (n: number) => void;
|
||||
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
|
||||
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. */
|
||||
@@ -631,6 +633,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
if (n > 0) setPaperScale(n);
|
||||
},
|
||||
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
|
||||
[],
|
||||
@@ -1640,7 +1647,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
|
||||
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
|
||||
// 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
|
||||
key={i}
|
||||
p={p}
|
||||
@@ -3065,5 +3080,74 @@ function renderPrimitive(
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,3 +147,20 @@ describe("generatePlan — Tür-Typ wandoeffnung (Item 6)", () => {
|
||||
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
@@ -98,6 +98,10 @@ const MONO_INK = "#111111";
|
||||
const LAYER_LINE_MM = 0.02;
|
||||
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
|
||||
const SYMBOL_HAIRLINE_MM = 0.02;
|
||||
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
|
||||
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
|
||||
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
|
||||
const OVERHEAD_DASH: number[] = [0.4, 0.25];
|
||||
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
||||
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
|
||||
grob: 1.6, // dickere Sammellinie
|
||||
@@ -294,6 +298,52 @@ export type Primitive =
|
||||
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
|
||||
roomId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
// Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
|
||||
// z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
|
||||
// er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
|
||||
kind: "drawingText";
|
||||
/** Ankerpunkt im Modell (Meter). */
|
||||
at: Vec2;
|
||||
text: string;
|
||||
/** Schrifthöhe in Modell-Metern. */
|
||||
heightM: number;
|
||||
/** Drehung in RADIANT (CCW im Modell). */
|
||||
angle: number;
|
||||
color: string;
|
||||
/** 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) {
|
||||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||||
const category = catByCode.get(ceiling.categoryCode);
|
||||
const lwMm = resolveStrokeWeight(
|
||||
ceiling.strokeWeight,
|
||||
ceiling.strokeWeightSource,
|
||||
category,
|
||||
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
|
||||
);
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category, wallFootprints);
|
||||
// Kein lwMm mehr: der Decken-Umriss ist eine feste Überkopf-Haarlinie
|
||||
// (gestrichelt), unabhängig von der Kategorie-Strichstärke.
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
|
||||
}
|
||||
|
||||
// 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 gaps = wallGaps(wall, doors, openings);
|
||||
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
|
||||
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
|
||||
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
|
||||
@@ -1000,20 +1053,48 @@ function addDrawing2D(
|
||||
seg(c4, c1);
|
||||
break;
|
||||
}
|
||||
case "circle": {
|
||||
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die
|
||||
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv.
|
||||
const N = 64;
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = (i / N) * Math.PI * 2;
|
||||
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) });
|
||||
}
|
||||
fillPoly(pts);
|
||||
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]);
|
||||
case "circle":
|
||||
// Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
|
||||
// optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
|
||||
out.push({
|
||||
kind: "drawingCircle",
|
||||
center: g.center,
|
||||
r: g.r,
|
||||
fill: background ?? d.fillColor ?? "none",
|
||||
stroke: color,
|
||||
weightMm,
|
||||
dash,
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
break;
|
||||
case "arc":
|
||||
// Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
|
||||
out.push({
|
||||
kind: "drawingArc",
|
||||
center: g.center,
|
||||
r: g.r,
|
||||
a0: g.a0,
|
||||
a1: g.a1,
|
||||
stroke: color,
|
||||
weightMm,
|
||||
dash,
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
break;
|
||||
case "text":
|
||||
out.push({
|
||||
kind: "drawingText",
|
||||
at: g.at,
|
||||
text: g.text,
|
||||
heightM: g.height,
|
||||
angle: g.angle,
|
||||
color,
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// arc/text: Primitive-Erweiterungen folgen später.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1184,6 +1265,10 @@ function addWallPoche(
|
||||
detail: DetailLevel,
|
||||
wallLwMm: number,
|
||||
category: LayerCategory | undefined,
|
||||
// Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
|
||||
// 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
|
||||
// Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
|
||||
viewOnly: boolean,
|
||||
): void {
|
||||
const wt = getWallType(project, wall);
|
||||
const total = wallTypeThickness(wt);
|
||||
@@ -1252,6 +1337,24 @@ function addWallPoche(
|
||||
const startCut = s <= 1e-6 ? cuts.startCut : null;
|
||||
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
|
||||
|
||||
if (viewOnly) {
|
||||
// Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
|
||||
// als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
|
||||
// bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: layerLineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
noStrokeEdges: joinEdges(startCut, endCut),
|
||||
wallId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (detail === "grob") {
|
||||
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
|
||||
out.push({
|
||||
@@ -1730,6 +1833,30 @@ function addOpeningSymbol(
|
||||
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.
|
||||
// Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
|
||||
// `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
|
||||
@@ -1801,7 +1928,6 @@ function addCeilingPoche(
|
||||
ceiling: Ceiling,
|
||||
greyed: boolean,
|
||||
detail: DetailLevel,
|
||||
lwMm: number,
|
||||
category: LayerCategory | undefined,
|
||||
wallFootprints: WallFootprint[],
|
||||
): void {
|
||||
@@ -1810,7 +1936,6 @@ function addCeilingPoche(
|
||||
const wt = getCeilingType(project, ceiling);
|
||||
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
|
||||
const stroke = ceiling.color ?? POCHE_STROKE;
|
||||
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
|
||||
const ceilingId = ceiling.id;
|
||||
|
||||
// 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)),
|
||||
b: add(a, scale(d, t1)),
|
||||
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,
|
||||
greyed,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
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).
|
||||
}
|
||||
|
||||
@@ -486,7 +486,22 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
||||
widthMm: p.weightMm,
|
||||
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-
|
||||
// Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie
|
||||
// wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case
|
||||
|
||||
+330
-25
@@ -109,6 +109,11 @@ body,
|
||||
#root {
|
||||
height: 100%;
|
||||
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.
|
||||
@@ -152,9 +157,9 @@ body {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 14px;
|
||||
height: 60px;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
height: 26px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
|
||||
nach unten → trennt sich klar von der Zeichenfläche (Tiefe). */
|
||||
@@ -169,6 +174,12 @@ body {
|
||||
scrollbar-width: thin;
|
||||
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
|
||||
(`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */
|
||||
.topbar::-webkit-scrollbar {
|
||||
@@ -550,10 +561,12 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
.brand-word {
|
||||
font-weight: 800;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: lowercase;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand-dot {
|
||||
color: var(--accent);
|
||||
@@ -1463,6 +1476,18 @@ body {
|
||||
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.
|
||||
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
|
||||
@@ -3892,6 +3917,64 @@ body {
|
||||
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) ─────────────────────────────────────── */
|
||||
/* Referenzlinie-Dropdown rechts oben im Kopf. */
|
||||
.objinfo-head-ref {
|
||||
@@ -3962,10 +4045,6 @@ body {
|
||||
|
||||
/* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */
|
||||
.attr-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -3976,44 +4055,66 @@ body {
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.attr-title {
|
||||
font-weight: 600;
|
||||
color: var(--label);
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
/* OCS-artiges Eigenschaften-Grid: EIN durchgehendes Zwei-Spalten-Grid über alle
|
||||
Sektionen (Label | Wert), damit die Wertfelder über Stift/Füllung/Maße hinweg
|
||||
in EINER Spalte bündig stehen. Sektion-Balken spannen beide Spalten. Zeilen mit
|
||||
dezenten Trennlinien; Wertfelder füllen ihre Zelle linksbündig über die volle
|
||||
Panel-Breite — einheitlicher Look für Texte/Zahlen/Dropdowns. */
|
||||
.attr-section-label {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 6px;
|
||||
padding: 0 2px;
|
||||
margin: 8px 0 2px;
|
||||
padding: 3px 6px;
|
||||
background: var(--panel-2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.attr-section-label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.attr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
padding: 2px;
|
||||
grid-template-columns: minmax(80px, max-content) 1fr;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.attr-key {
|
||||
color: var(--label);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px 3px 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.attr-val {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
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 {
|
||||
color: var(--label);
|
||||
opacity: 0.85;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.attr-num,
|
||||
.attr-select {
|
||||
width: 110px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--panel-2);
|
||||
color: var(--label);
|
||||
border: 1px solid var(--line);
|
||||
@@ -5280,15 +5381,16 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
/* Ganz links in der randlosen Titelleiste (macOS-Konvention). */
|
||||
margin-right: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.win-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 26px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -5305,3 +5407,206 @@ body {
|
||||
background: #e5484d;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
|
||||
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). */
|
||||
function wallProject(wall: Wall): Project {
|
||||
@@ -134,3 +134,95 @@ describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,28 @@ function collectSegments(input: SnapInput): Seg[] {
|
||||
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. */
|
||||
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||
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");
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
|
||||
|
||||
@@ -526,6 +526,34 @@ const rectTool: Tool = {
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TOOLS: Record<ToolId, Tool> = {
|
||||
@@ -539,6 +567,8 @@ const TOOLS: Record<ToolId, Tool> = {
|
||||
line: lineTool,
|
||||
polyline: polylineTool,
|
||||
rect: rectTool,
|
||||
circle: circleTool,
|
||||
arc: arcTool,
|
||||
};
|
||||
|
||||
/** Liefert das Werkzeug zur ID. */
|
||||
@@ -558,4 +588,6 @@ export const TOOL_ORDER: ToolId[] = [
|
||||
"line",
|
||||
"polyline",
|
||||
"rect",
|
||||
"circle",
|
||||
"arc",
|
||||
];
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ export type ToolId =
|
||||
| "room"
|
||||
| "line"
|
||||
| "polyline"
|
||||
| "rect";
|
||||
| "rect"
|
||||
| "circle"
|
||||
| "arc";
|
||||
|
||||
// ── Snapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+52
-3
@@ -21,6 +21,7 @@ import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
|
||||
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
|
||||
import {
|
||||
contoursToDrawings,
|
||||
textsToDrawings,
|
||||
countContours,
|
||||
distinctLayers,
|
||||
} from "../io/dxfToDrawings";
|
||||
@@ -35,6 +36,12 @@ export interface ImportDecision {
|
||||
categoryMode: CategoryMode;
|
||||
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
|
||||
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). */
|
||||
parsed: DxfImportResult;
|
||||
}
|
||||
@@ -107,9 +114,11 @@ export function ImportDialog({
|
||||
|
||||
const contourSets = parsed?.contours ?? [];
|
||||
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
|
||||
const texts = parsed?.texts ?? [];
|
||||
const contourCount = countContours(contourSets);
|
||||
const textCount = texts.length;
|
||||
const layers = distinctLayers(contourSets);
|
||||
const hasDrawings = contourCount > 0;
|
||||
const hasDrawings = contourCount > 0 || textCount > 0;
|
||||
const hasMeshes = meshes.length > 0;
|
||||
|
||||
// ── Formular-Zustand ──────────────────────────────────────────────────────
|
||||
@@ -124,6 +133,7 @@ export function ImportDialog({
|
||||
layers.length > 0 ? "byLayer" : "active",
|
||||
);
|
||||
const [importMeshes, setImportMeshes] = useState<boolean>(true);
|
||||
const [placement, setPlacement] = useState<"origin" | "viewCenter">("origin");
|
||||
|
||||
const canImport =
|
||||
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
|
||||
@@ -134,7 +144,7 @@ export function ImportDialog({
|
||||
targetKind === "existing"
|
||||
? ({ kind: "existing", levelId: existingLevelId } as const)
|
||||
: ({ kind: "new", name: newName } as const);
|
||||
onImport({ target, categoryMode, importMeshes, parsed });
|
||||
onImport({ target, categoryMode, importMeshes, placement, parsed });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -187,6 +197,9 @@ export function ImportDialog({
|
||||
</div>
|
||||
<ul className="imp-summary">
|
||||
<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>
|
||||
{layers.length > 0
|
||||
@@ -289,6 +302,34 @@ export function ImportDialog({
|
||||
</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. */}
|
||||
{hasMeshes && (
|
||||
<section className="imp-section">
|
||||
@@ -348,11 +389,19 @@ export function buildDrawings(
|
||||
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
|
||||
const layerToCode = (layerName: string): string =>
|
||||
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
|
||||
return contoursToDrawings(
|
||||
const fromContours = contoursToDrawings(
|
||||
decision.parsed.contours,
|
||||
levelId,
|
||||
decision.categoryMode,
|
||||
activeCategoryCode,
|
||||
layerToCode,
|
||||
);
|
||||
const fromTexts = textsToDrawings(
|
||||
decision.parsed.texts ?? [],
|
||||
levelId,
|
||||
decision.categoryMode,
|
||||
activeCategoryCode,
|
||||
layerToCode,
|
||||
);
|
||||
return [...fromContours, ...fromTexts];
|
||||
}
|
||||
|
||||
@@ -1847,7 +1847,8 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
|
||||
scale: 1,
|
||||
angle: p.families[0]?.angle ?? 45,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
// Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin").
|
||||
lineStyleId: "thin",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+324
-206
@@ -15,6 +15,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||
import { WindowControls } from "./WindowControls";
|
||||
import { RibbonTabs } from "./ribbon/RibbonBar";
|
||||
import type { RibbonTabId } from "./ribbon/ribbonItems";
|
||||
import {
|
||||
applyMark,
|
||||
applyPreset,
|
||||
@@ -84,49 +86,17 @@ const ZOOM_CLUSTER_WIDTH = 140;
|
||||
export interface TopBarProps {
|
||||
project: Project;
|
||||
|
||||
// Ansicht.
|
||||
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */
|
||||
viewToggleEnabled: boolean;
|
||||
viewType: ViewType;
|
||||
onViewType: (v: ViewType) => void;
|
||||
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
|
||||
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
|
||||
ribbonTab: RibbonTabId;
|
||||
onRibbonTab: (id: RibbonTabId) => void;
|
||||
|
||||
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in
|
||||
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin.
|
||||
view3d: View3d;
|
||||
onView3d: (v: View3d) => void;
|
||||
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
|
||||
fov: number;
|
||||
onFov: (n: number) => void;
|
||||
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
|
||||
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
|
||||
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
|
||||
|
||||
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben
|
||||
// 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. */
|
||||
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
||||
zoomEnabled: boolean;
|
||||
onFit: () => void;
|
||||
onFitSelection: () => void;
|
||||
onZoom100: () => void;
|
||||
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
onExportPdf: () => void;
|
||||
/** Ö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. */
|
||||
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.
|
||||
resourcesOpen: boolean;
|
||||
onToggleResources: () => void;
|
||||
@@ -167,14 +122,8 @@ export interface TopBarProps {
|
||||
/** Projekt aus einer Datei laden. */
|
||||
onOpenProject: () => void;
|
||||
|
||||
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
|
||||
/**
|
||||
* 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;
|
||||
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
|
||||
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
|
||||
*/
|
||||
function TextGroup({
|
||||
export function TextGroup({
|
||||
textTarget,
|
||||
}: {
|
||||
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
|
||||
* 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,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
onExportSchedule,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
onToggleResources,
|
||||
onOpenSettings,
|
||||
resourcesOpen,
|
||||
exportEnabled,
|
||||
}: {
|
||||
onOpenProject: () => void;
|
||||
onSaveProject: () => void;
|
||||
onImport: () => void;
|
||||
onExportPdf: () => void;
|
||||
onExportDxf: () => void;
|
||||
onExportSchedule: () => void;
|
||||
onSaveProject: () => void;
|
||||
onOpenProject: () => void;
|
||||
onToggleResources: () => void;
|
||||
onOpenSettings: () => void;
|
||||
resourcesOpen: 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 (
|
||||
<Dropdown
|
||||
items={items}
|
||||
placeholder=""
|
||||
title={t("file.menu")}
|
||||
triggerClassName="tb-burger"
|
||||
triggerSuffix={<TbIcon name="menu" />}
|
||||
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
|
||||
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
|
||||
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
|
||||
<QaSep />
|
||||
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
|
||||
<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,
|
||||
viewType,
|
||||
onViewType,
|
||||
@@ -978,44 +1022,14 @@ export function TopBar({
|
||||
onFit,
|
||||
onFitSelection,
|
||||
onZoom100,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
onExportSchedule,
|
||||
renderMode,
|
||||
onRenderMode,
|
||||
renderModeEnabled,
|
||||
planColorMode,
|
||||
onPlanColorMode,
|
||||
combos,
|
||||
resourcesOpen,
|
||||
onToggleResources,
|
||||
onOpenSettings,
|
||||
layoutMenu,
|
||||
onImport,
|
||||
onSaveProject,
|
||||
onOpenProject,
|
||||
textTarget,
|
||||
}: 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
}: ViewRibbonTabProps) {
|
||||
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
|
||||
const onScaleChange = (value: string) => {
|
||||
if (value === "__custom__") {
|
||||
@@ -1030,14 +1044,7 @@ export function TopBar({
|
||||
const n = Number(value);
|
||||
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);
|
||||
|
||||
// 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[] = [
|
||||
...(inPresets
|
||||
? []
|
||||
@@ -1047,41 +1054,8 @@ export function TopBar({
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="topbar" ref={headerRef}>
|
||||
{/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben,
|
||||
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="ribbon-views">
|
||||
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
||||
<div className="view-grid view-grid-4">
|
||||
<button
|
||||
@@ -1139,10 +1113,7 @@ export function TopBar({
|
||||
</div>
|
||||
<Sep />
|
||||
|
||||
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs-
|
||||
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. */}
|
||||
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
||||
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("combo.layers")}</span>
|
||||
@@ -1167,27 +1138,9 @@ export function TopBar({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
||||
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). */}
|
||||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("topbar.detail")}</span>
|
||||
@@ -1208,14 +1161,9 @@ export function TopBar({
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
|
||||
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. */}
|
||||
{/* Massstab/Zoom-Cluster (2×2). */}
|
||||
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||
<div
|
||||
className={`tb-stat${
|
||||
@@ -1271,13 +1219,9 @@ export function TopBar({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
|
||||
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
|
||||
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
|
||||
Linien-Modus (Display/Print) bleibt separat. */}
|
||||
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
|
||||
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
|
||||
<label className="tb-field">
|
||||
<span className="tb-label">{t("display.style")}</span>
|
||||
@@ -1308,25 +1252,199 @@ export function TopBar({
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts,
|
||||
GTK-Stil) — Ressourcen sitzt jetzt bei der Marke (s. o.). */}
|
||||
<div className="topbar-right">
|
||||
{layoutMenu}
|
||||
<span className="project-name">{project.name}</span>
|
||||
<AppMenu
|
||||
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
|
||||
Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
|
||||
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
||||
<TextGroup textTarget={textTarget} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
onExportPdf={onExportPdf}
|
||||
onExportDxf={onExportDxf}
|
||||
onExportSchedule={onExportSchedule}
|
||||
onSaveProject={onSaveProject}
|
||||
onOpenProject={onOpenProject}
|
||||
onToggleResources={onToggleResources}
|
||||
onOpenSettings={onOpenSettings}
|
||||
resourcesOpen={resourcesOpen}
|
||||
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>
|
||||
</header>
|
||||
);
|
||||
|
||||
+23
-22
@@ -1,11 +1,11 @@
|
||||
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
|
||||
// Electron-Fenster (frame:false, s. scripts/electron-main.cjs) — ohne native
|
||||
// Titelleiste gibt es sonst keine Möglichkeit, das Fenster zu steuern.
|
||||
// ELECTRON-Fenster (frame:false, s. scripts/electron-main.cjs) — dort gibt es
|
||||
// ohne native Titelleiste sonst keine Fenstersteuerung.
|
||||
//
|
||||
// Rendert NUR, wenn `window.dossierWindow` existiert (vom Preload-Skript
|
||||
// injiziert) — also nur unter Electron. In Tauri (native Fensterdekoration)
|
||||
// und im Browser-Dev-Modus (?vite) bleibt die Komponente unsichtbar (`null`),
|
||||
// dort steuert das Betriebssystem bzw. der Browser das Fenster.
|
||||
// Unter TAURI werden die NATIVEN macOS-Ampeln verwendet (tauri.conf.json:
|
||||
// titleBarStyle "Overlay" + hiddenTitle, native Dekoration mit gerundeten Ecken),
|
||||
// daher rendert diese Komponente dort NICHT (kein `window.dossierWindow`) — sonst
|
||||
// gäbe es doppelte Knöpfe. Im Browser-Dev-Modus ebenfalls unsichtbar (`null`).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -43,8 +43,25 @@ export function WindowControls() {
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
// Reihenfolge (macOS-Titelleiste links): Schliessen · Minimieren · Maximieren.
|
||||
return (
|
||||
<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
|
||||
type="button"
|
||||
className="win-btn"
|
||||
@@ -86,22 +103,6 @@ export function WindowControls() {
|
||||
</svg>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
Reference in New Issue
Block a user