Compare commits
48 Commits
05bc5aa6e1
...
9133c0961d
| 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)
|
## ⛔ Blocker (zuerst klären)
|
||||||
|
|
||||||
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
||||||
@@ -39,15 +43,21 @@
|
|||||||
|
|
||||||
## ⏭️ Als Nächstes
|
## ⏭️ Als Nächstes
|
||||||
|
|
||||||
- [ ] **SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen).** Kleinster ehrlicher Durchstich: beweisen, dass der wgpu-3D-Renderer echte Bild-Texturen auf Wandflächen zeigt, sichtbar im `spike3d`-Fenster. **Voller Auftrag/Übergabe: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md)** (Ausgangslage verifiziert, Symbol-Karte, exakter Umfang, harte Randbedingungen, Akzeptanz, Abschlussbericht). Kurz: UVs auf Wandflächen (planar in Metern, Alt-Pfad `[pos,normal,color]` bitgleich unangetastet) · prozedurales 256×256-Schachbrett (kein Asset, kein `image`-Crate) · Textur-Bind-Group group 1 (Globals bleibt group 0) · `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`) · Verdrahtung bei `RenderStyle::Textured` · `spike3d` umschaltbar (`T`). **Nur `render3d`-Crate, keine neuen Deps, Default-Build unverändert.** Akzeptanz: `cargo test` grün (Mesh-Regression + naga-Test für `MESH_TEXTURED_WGSL`), `spike3d` zeigt weltmassstäbliche Textur. Ist der **erste Durchstich der Textur-/PBR-Pipeline** (§3D-REST unten) → am Ende ehrliche Lückenliste + Aufwandsschätzung für „richtig gutes" Texturing.
|
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + +-Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
|
||||||
|
|
||||||
|
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~ — **erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
|
||||||
|
|
||||||
## 📋 Backlog (Priorität grob absteigend)
|
## 📋 Backlog (Priorität grob absteigend)
|
||||||
|
|
||||||
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. Gruppe A (2D, sofort, kernel2d): (1) **Treppe Aussenlinie/Outline** (gerade/L/Wendel), (2) **Fenster Brüstungslinie** im Plan, (3) **Tür Sturzlinien** (gestrichelt, SIA), (4) Treppe Referenz links/mitte/rechts, (5) Fenster Flügel-Mittelpfosten, (6) Tür `wandoeffnung`-Typ. Gruppe B (2D mittel), Gruppe C (3D, nach Mesh-Pipeline/truck). **A-Items 1–3 fallen mit „Schnitt- vs. Ansichts-Darstellung" zusammen** (Sturz = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion).
|
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
|
||||||
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber 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] ~~**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.**
|
- [ ] **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 wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
|
||||||
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
|
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
|
||||||
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
|
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
|
||||||
@@ -68,13 +78,22 @@
|
|||||||
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
|
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
|
||||||
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||||
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||||
- [ ] **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
|
- [x] ~~**Linienstile aufräumen**~~ — **erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line`→`thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive`→`thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
|
||||||
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||||
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||||
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||||
|
|
||||||
|
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
|
||||||
|
- **Auth + Projekt-Liste:** Supabase Auth (Email/Magic-Link), Projektübersicht, Öffnen/Schliessen.
|
||||||
|
- **Lock-Service:** Tabelle `object_locks (project_id, object_id, user_id, locked_at)` mit Row-Level Security; `reserveObjects(ids[])` / `releaseObjects(ids[])` als Supabase-RPC; Optimistisches Check-in via DB-Constraint (doppelte Reserve → Fehler → UI-Feedback).
|
||||||
|
- **Realtime-Sync:** Supabase Realtime-Channel pro Projekt; bei Freigabe werden die veränderten Objekte (JSON-Patch oder ganzes Objekt-Payload, TBD) gepusht; lokaler Store merged incoming changes sofort.
|
||||||
|
- **Presence:** Wer ist online, wer hat welche Objekte reserviert (farbige User-Badges an reservierten Elementen im Plan).
|
||||||
|
- **Offline-Guard:** Beim Verbindungsabbruch Locks automatisch nach Timeout freigeben (DB-seitig: `locked_at + interval` prüfen).
|
||||||
|
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
|
||||||
|
- **Aufwand:** gross (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)
|
### 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.
|
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~ — **bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
|
||||||
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
||||||
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||||
@@ -92,6 +111,25 @@
|
|||||||
|
|
||||||
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
||||||
|
|
||||||
|
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
|
||||||
|
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
|
||||||
|
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
|
||||||
|
- [x] 2026-07-05 **Ribbon Phase 3 (Teil): Werkzeug-Sidebar raus** — `DEFAULT_LEFT_GROUPS` nur noch `attributes` (volle linke Höhe), `LAYOUT_VERSION` 7→8 (gespeicherte Layouts fallen auf neuen Default zurück → sichtbar). Wandtyp-/Deckentyp-Picker von `ToolsPanel` ins `AttributesPanel` verschoben (Nutzer-Entscheid „ins Attribute-Panel") — erscheint bei aktivem Wand-/Decken-Werkzeug (Sektion „Neues Bauteil", auch ohne Auswahl), Host-State unverändert. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), `Dropdown`-Import dort entfernt. tsc + Suite 334 grün. **Offen:** Objektinfo unter Attribute mergen.
|
||||||
|
- [x] 2026-07-05 **Fix: gezeichnete/importierte Kreise+Bögen im WebGL unsichtbar** — der WebGL-Compiler (`glPlan/glPlanCompile.ts`, Default-Renderer) dispatchte `polygon`/`line`/`arc`, aber NICHT `drawingCircle`/`drawingArc` → seit `4ac99d3` (Kreise als echtes `drawingCircle`-Primitiv statt 64-Eck-Polygon) fielen sie im GL still raus (SVG-/WASM-Pfad hatten sie, GL nicht). Zweig ergänzt: bildschirm-adaptive Tessellierung wie beim `arc`-Primitiv (Kreis geschlossen + optionale Vollton-Füllung, Bogen a0..a1 offen). tsc + Suite 334 grün. **Nutzer-Report** („zeichne Kreis, bleibt nicht sichtbar").
|
||||||
|
- [x] 2026-07-05 **Textur-Spike verifiziert erledigt** (`0ca3b1d`) — bei der Backlog-Abarbeitung festgestellt, dass die render3d-Textur-Spike (`RenderStyle::Textured`, Schachbrett, planare Meter-UVs, `MESH_TEXTURED_WGSL` group 1, `spike3d`-`T`-Toggle) bereits vollständig umgesetzt + committet war; `cargo test` 58/59 grün, keine neuen Deps, Alt-Pfad bitgleich. „Als Nächstes"-Eintrag war veraltet → abgehakt; PBR-Restlückenliste (~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-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
|
||||||
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
|
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
|
||||||
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
|
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
|
"label": "main",
|
||||||
"title": "cad",
|
"title": "cad",
|
||||||
"width": 1400,
|
"width": 1400,
|
||||||
"height": 900
|
"height": 900,
|
||||||
|
"dragDropEnabled": false,
|
||||||
|
"titleBarStyle": "Overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+287
-94
@@ -69,7 +69,9 @@ import type { Pick3dHit } from "./viewport/Wasm3DViewport";
|
|||||||
import { ResourceManager } from "./ui/ResourceManager";
|
import { ResourceManager } from "./ui/ResourceManager";
|
||||||
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
|
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
|
||||||
import { HatchSwatch } from "./ui/hatchPreview";
|
import { HatchSwatch } from "./ui/hatchPreview";
|
||||||
import { TopBar } from "./ui/TopBar";
|
import { TopBar, ViewRibbonTab, Ribbon3dTab } from "./ui/TopBar";
|
||||||
|
import { RibbonBar } from "./ui/ribbon/RibbonBar";
|
||||||
|
import type { RibbonTabId } from "./ui/ribbon/ribbonItems";
|
||||||
import { Dropdown } from "./ui/Dropdown";
|
import { Dropdown } from "./ui/Dropdown";
|
||||||
import type { DropdownItem } from "./ui/Dropdown";
|
import type { DropdownItem } from "./ui/Dropdown";
|
||||||
import { TextEditorDialog } from "./ui/TextEditorDialog";
|
import { TextEditorDialog } from "./ui/TextEditorDialog";
|
||||||
@@ -212,6 +214,8 @@ const TOOL_COMMAND: Partial<Record<ToolId, string>> = {
|
|||||||
line: "line",
|
line: "line",
|
||||||
polyline: "polyline",
|
polyline: "polyline",
|
||||||
rect: "rect",
|
rect: "rect",
|
||||||
|
circle: "circle",
|
||||||
|
arc: "arc",
|
||||||
};
|
};
|
||||||
/** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */
|
/** Befehlsname für ein gekoppeltes Werkzeug (oder null, wenn keiner). */
|
||||||
function coupledCommandFor(id: ToolId): string | null {
|
function coupledCommandFor(id: ToolId): string | null {
|
||||||
@@ -226,6 +230,68 @@ function promptCount(): number | null {
|
|||||||
return Number.isFinite(n) && n >= 1 ? n : null;
|
return Number.isFinite(n) && n >= 1 ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** localStorage-Schlüssel der modularen Custom-Bar (Ribbon-Tab „Eigene"). */
|
||||||
|
const RIBBON_CUSTOM_KEY = "dossier.ribbon.customItems";
|
||||||
|
|
||||||
|
/** Persistierte Custom-Bar-Item-Schlüssel lesen (leer bei Fehler/Erststart). */
|
||||||
|
function loadCustomItems(): string[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(RIBBON_CUSTOM_KEY);
|
||||||
|
const arr = raw ? JSON.parse(raw) : null;
|
||||||
|
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom-Bar-Item-Schlüssel persistieren (Fehler still schlucken). */
|
||||||
|
function saveCustomItems(items: string[]): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(RIBBON_CUSTOM_KEY, JSON.stringify(items));
|
||||||
|
} catch {
|
||||||
|
// localStorage nicht verfügbar (privater Modus o. ä.) → ignorieren.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */
|
||||||
|
function drawingsBBox(
|
||||||
|
drawings: Drawing2D[],
|
||||||
|
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
const acc = (x: number, y: number) => {
|
||||||
|
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
|
||||||
|
};
|
||||||
|
for (const d of drawings) {
|
||||||
|
const g = d.geom;
|
||||||
|
if (g.shape === "line") { acc(g.a.x, g.a.y); acc(g.b.x, g.b.y); }
|
||||||
|
else if (g.shape === "polyline") for (const p of g.pts) acc(p.x, p.y);
|
||||||
|
else if (g.shape === "rect") { acc(g.min.x, g.min.y); acc(g.max.x, g.max.y); }
|
||||||
|
else if (g.shape === "circle" || g.shape === "arc") {
|
||||||
|
acc(g.center.x - g.r, g.center.y - g.r);
|
||||||
|
acc(g.center.x + g.r, g.center.y + g.r);
|
||||||
|
} else if (g.shape === "text") acc(g.at.x, g.at.y);
|
||||||
|
}
|
||||||
|
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Drawing2D um (dx,dy) in Modell-Metern verschieben (alle Formen). */
|
||||||
|
function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
|
||||||
|
const mv = (p: Vec2): Vec2 => ({ x: p.x + dx, y: p.y + dy });
|
||||||
|
const g = d.geom;
|
||||||
|
switch (g.shape) {
|
||||||
|
case "line": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
|
||||||
|
case "polyline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
|
||||||
|
case "rect": return { ...d, geom: { ...g, min: mv(g.min), max: mv(g.max) } };
|
||||||
|
case "circle": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
|
case "arc": return { ...d, geom: { ...g, center: mv(g.center) } };
|
||||||
|
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
|
||||||
|
default: return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache
|
// Sprachwechsel abonnieren: re-rendert den gesamten Baum, wenn die Sprache
|
||||||
// umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t).
|
// umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t).
|
||||||
@@ -315,6 +381,22 @@ export default function App() {
|
|||||||
// Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je
|
// Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je
|
||||||
// Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView).
|
// Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView).
|
||||||
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
const [activeTool, setActiveTool] = useState<ToolId>("select");
|
||||||
|
// Aktiver Ribbon-Tab (2D/3D/BIM/Ansichten). Liegt hier (nicht in der RibbonBar),
|
||||||
|
// weil die Tab-Reiter in der TopBar-Zeile sitzen und der Ribbon-Inhalt darunter
|
||||||
|
// — beide teilen sich denselben aktiven Tab.
|
||||||
|
const [ribbonTab, setRibbonTab] = useState<RibbonTabId>("views");
|
||||||
|
// Modulare Custom-Bar (Ribbon-Tab „Eigene"): persistierte Item-Schlüssel. Der
|
||||||
|
// Picker im Tab wählt Werkzeuge/Befehle an/ab (localStorage-persistiert).
|
||||||
|
const [customItems, setCustomItems] = useState<string[]>(loadCustomItems);
|
||||||
|
const onToggleCustomItem = (key: string) => {
|
||||||
|
setCustomItems((prev) => {
|
||||||
|
const next = prev.includes(key)
|
||||||
|
? prev.filter((k) => k !== key)
|
||||||
|
: [...prev, key];
|
||||||
|
saveCustomItems(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(
|
const [activeWallTypeId, setActiveWallTypeId] = useState<string>(
|
||||||
project.wallTypes[0].id,
|
project.wallTypes[0].id,
|
||||||
);
|
);
|
||||||
@@ -349,6 +431,11 @@ export default function App() {
|
|||||||
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
|
selectedDrawingIds.length === 1 ? selectedDrawingIds[0] : null;
|
||||||
const setSelectedDrawingId = (id: string | null) =>
|
const setSelectedDrawingId = (id: string | null) =>
|
||||||
setSelectedDrawingIds(id ? [id] : []);
|
setSelectedDrawingIds(id ? [id] : []);
|
||||||
|
// Zwischenablage für 2D-Zeichenelemente (Ctrl/Cmd+C/V). Kopiert die GEWÄHLTEN
|
||||||
|
// Drawing2D tief; Einfügen legt Klone mit neuen IDs auf dem AKTIVEN Geschoss
|
||||||
|
// (Zeichnungsebene) an — so lässt sich z. B. importiertes Mobiliar über
|
||||||
|
// Geschosse hinweg wiederverwenden. Kategorie/Stil bleiben erhalten.
|
||||||
|
const [drawingClipboard, setDrawingClipboard] = useState<Drawing2D[]>([]);
|
||||||
// Bequemer Einzel-Selektor (erste gewählte Wand).
|
// Bequemer Einzel-Selektor (erste gewählte Wand).
|
||||||
const selectedWallId = selectedWallIds[0] ?? null;
|
const selectedWallId = selectedWallIds[0] ?? null;
|
||||||
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
|
// Decken-Auswahl (MENGE); für die Einzel-Element-Maschinerie der „eine"
|
||||||
@@ -766,6 +853,16 @@ export default function App() {
|
|||||||
setActiveTool(id);
|
setActiveTool(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ribbon-Befehl (Ändern-Gruppe u. a.): startet einen Engine-Befehl OHNE
|
||||||
|
// eigenes Werkzeug (kein ToolId). Läuft über DIESELBE Engine wie das Tippen des
|
||||||
|
// Befehls; das Werkzeug-Highlight fällt auf „Auswahl" zurück, das Aktiv-
|
||||||
|
// Highlight des Ribbon-Befehls spiegelt der laufende Befehl (engineView).
|
||||||
|
const onRunCommand = (name: string) => {
|
||||||
|
engine.start(name);
|
||||||
|
setActiveTool("select");
|
||||||
|
requestAnimationFrame(() => commandLineRef.current?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
// 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf
|
// 3D-Viewport als Erstellungs-Fläche: der Viewport projiziert den Cursor auf
|
||||||
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
|
// die Geschoss-Arbeitsebene und liefert einen rohen Modell-`Vec2`; wir füttern
|
||||||
// ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick).
|
// ihn in DIESELBE ansichts-agnostische CommandEngine wie PlanView (move/pick).
|
||||||
@@ -1581,6 +1678,46 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDrawingIds, engine]);
|
}, [selectedDrawingIds, engine]);
|
||||||
|
|
||||||
|
// Ctrl/Cmd+C / Ctrl/Cmd+V = 2D-Zeichenelemente kopieren/einfügen. Kopieren legt
|
||||||
|
// die GEWÄHLTEN Drawing2D tief in die Zwischenablage; Einfügen erzeugt Klone mit
|
||||||
|
// neuen IDs auf dem AKTIVEN Geschoss (Zeichnungsebene) und wählt sie aus — so
|
||||||
|
// wandert z. B. importiertes Mobiliar von einem Geschoss auf ein anderes. Im
|
||||||
|
// Textfeld bleibt das native Copy/Paste; ohne 2D-Auswahl kein preventDefault.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k !== "c" && k !== "v") return;
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k === "c") {
|
||||||
|
const ids = new Set(selectedDrawingIds);
|
||||||
|
if (ids.size === 0) return; // nur bei 2D-Auswahl (sonst Browser-Copy)
|
||||||
|
const picked = project.drawings2d.filter((d) => ids.has(d.id));
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDrawingClipboard(picked.map((d) => structuredClone(d)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Einfügen: Klone auf dem aktiven Geschoss, neue IDs, danach ausgewählt.
|
||||||
|
if (drawingClipboard.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const stamp = Date.now();
|
||||||
|
const pasted: Drawing2D[] = drawingClipboard.map((d, i) => ({
|
||||||
|
...structuredClone(d),
|
||||||
|
id: `drawing-${stamp}-${i}`,
|
||||||
|
levelId: activeLevelId,
|
||||||
|
}));
|
||||||
|
importDrawings(pasted);
|
||||||
|
setSelectedDrawingIds(pasted.map((p) => p.id));
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedDrawingIds, drawingClipboard, activeLevelId, project.drawings2d]);
|
||||||
|
|
||||||
// Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
|
// Ctrl/Cmd+Shift+U/D/I = Boolean Vereinigung/Subtraktion/Schnitt der gewählten
|
||||||
// geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
|
// geschlossenen Drawing2D. preventDefault (sonst Browser-Aktionen). Braucht ≥2
|
||||||
// geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.)
|
// geschlossene Flächen; sonst No-op. (Menü spiegelt diese Aktionen.)
|
||||||
@@ -2091,12 +2228,24 @@ export default function App() {
|
|||||||
decision.target.kind === "new"
|
decision.target.kind === "new"
|
||||||
? addDrawingLevel(decision.target.name)
|
? addDrawingLevel(decision.target.name)
|
||||||
: decision.target.levelId;
|
: decision.target.levelId;
|
||||||
const drawings = buildDrawings(
|
let drawings = buildDrawings(
|
||||||
decision,
|
decision,
|
||||||
levelId,
|
levelId,
|
||||||
project,
|
project,
|
||||||
activeCategoryCode,
|
activeCategoryCode,
|
||||||
);
|
);
|
||||||
|
// Platzierung „viewCenter": den Gesamt-Umriss so verschieben, dass seine
|
||||||
|
// Mitte auf die Mitte des aktuellen Ausschnitts fällt (sonst bleibt alles an
|
||||||
|
// den DXF-Koordinaten relativ zum Nullpunkt).
|
||||||
|
if (decision.placement === "viewCenter" && drawings.length) {
|
||||||
|
const center = planViewRef.current?.viewCenterModel();
|
||||||
|
const bb = drawingsBBox(drawings);
|
||||||
|
if (center && bb) {
|
||||||
|
const dx = center.x - (bb.minX + bb.maxX) / 2;
|
||||||
|
const dy = center.y - (bb.minY + bb.maxY) / 2;
|
||||||
|
drawings = drawings.map((d) => translateDrawing(d, dx, dy));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (drawings.length) importDrawings(drawings);
|
if (drawings.length) importDrawings(drawings);
|
||||||
if (decision.importMeshes && decision.parsed.meshes.length) {
|
if (decision.importMeshes && decision.parsed.meshes.length) {
|
||||||
addContextObjects(decision.parsed.meshes);
|
addContextObjects(decision.parsed.meshes);
|
||||||
@@ -3084,55 +3233,17 @@ export default function App() {
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* Schmale globale Leiste: Marke/Ressourcen · Text-Formatierung ·
|
||||||
|
Datei/Export/Einstellungen. Ansichts-/Zoom-/Darstellungs-Steuerung ist
|
||||||
|
in den „Ansichten"-Ribbon-Tab gewandert (ViewRibbonTab, unten). */}
|
||||||
<TopBar
|
<TopBar
|
||||||
project={project}
|
project={project}
|
||||||
viewToggleEnabled={activeLevel.kind === "floor"}
|
ribbonTab={ribbonTab}
|
||||||
viewType={viewType}
|
onRibbonTab={setRibbonTab}
|
||||||
onViewType={setViewType}
|
|
||||||
view3d={view3d}
|
|
||||||
onView3d={setView3d}
|
|
||||||
fov={fov}
|
|
||||||
onFov={setFov}
|
|
||||||
detail={detail}
|
|
||||||
onDetail={setDetail}
|
|
||||||
detailEnabled={planActive}
|
|
||||||
scaleDenominator={scaleDenominator}
|
|
||||||
onApplyScale={applyScale}
|
|
||||||
liveScale={planActive ? liveScale : null}
|
|
||||||
zoomPercent={zoomPercent}
|
|
||||||
zoomEnabled={planActive}
|
zoomEnabled={planActive}
|
||||||
onFit={fitPlan}
|
|
||||||
onFitSelection={fitSelection}
|
|
||||||
onZoom100={zoomToApplied}
|
|
||||||
onExportPdf={() => setExportOpen(true)}
|
onExportPdf={() => setExportOpen(true)}
|
||||||
onExportDxf={() => setExportDxfOpen(true)}
|
onExportDxf={() => setExportDxfOpen(true)}
|
||||||
onExportSchedule={onExportSchedule}
|
onExportSchedule={onExportSchedule}
|
||||||
renderMode={renderMode}
|
|
||||||
onRenderMode={setRenderMode}
|
|
||||||
renderModeEnabled={viewType === "perspektive"}
|
|
||||||
planColorMode={planColorMode}
|
|
||||||
onPlanColorMode={setPlanColorMode}
|
|
||||||
combos={{
|
|
||||||
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
|
|
||||||
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
|
|
||||||
listLayerCombos,
|
|
||||||
saveLayerCombo: (name) =>
|
|
||||||
saveLayerCombo(name, snapshotLayerVisibility()),
|
|
||||||
loadLayerCombo: (name) => {
|
|
||||||
const codes = loadLayerCombo(name);
|
|
||||||
if (codes) applyLayerVisibility(codes);
|
|
||||||
},
|
|
||||||
deleteLayerCombo,
|
|
||||||
// Zeichnungs-Kombinationen: analog über drawingLevels[].visible.
|
|
||||||
listDrawingCombos,
|
|
||||||
saveDrawingCombo: (name) =>
|
|
||||||
saveDrawingCombo(name, snapshotDrawingVisibility()),
|
|
||||||
loadDrawingCombo: (name) => {
|
|
||||||
const ids = loadDrawingCombo(name);
|
|
||||||
if (ids) applyDrawingVisibility(ids);
|
|
||||||
},
|
|
||||||
deleteDrawingCombo,
|
|
||||||
}}
|
|
||||||
resourcesOpen={resourcesOpen}
|
resourcesOpen={resourcesOpen}
|
||||||
onToggleResources={() => setResourcesOpen((v) => !v)}
|
onToggleResources={() => setResourcesOpen((v) => !v)}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
@@ -3145,7 +3256,85 @@ export default function App() {
|
|||||||
onApply={(l) => setLayout(reconcileLayout(l))}
|
onApply={(l) => setLayout(reconcileLayout(l))}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
textTarget={textTarget}
|
/>
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
view3d={view3d}
|
||||||
|
onView3d={setView3d}
|
||||||
|
fov={fov}
|
||||||
|
onFov={setFov}
|
||||||
|
detail={detail}
|
||||||
|
onDetail={setDetail}
|
||||||
|
detailEnabled={planActive}
|
||||||
|
scaleDenominator={scaleDenominator}
|
||||||
|
onApplyScale={applyScale}
|
||||||
|
liveScale={planActive ? liveScale : null}
|
||||||
|
zoomPercent={zoomPercent}
|
||||||
|
zoomEnabled={planActive}
|
||||||
|
onFit={fitPlan}
|
||||||
|
onFitSelection={fitSelection}
|
||||||
|
onZoom100={zoomToApplied}
|
||||||
|
renderMode={renderMode}
|
||||||
|
onRenderMode={setRenderMode}
|
||||||
|
renderModeEnabled={viewType === "perspektive"}
|
||||||
|
planColorMode={planColorMode}
|
||||||
|
onPlanColorMode={setPlanColorMode}
|
||||||
|
combos={{
|
||||||
|
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
|
||||||
|
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
|
||||||
|
listLayerCombos,
|
||||||
|
saveLayerCombo: (name) =>
|
||||||
|
saveLayerCombo(name, snapshotLayerVisibility()),
|
||||||
|
loadLayerCombo: (name) => {
|
||||||
|
const codes = loadLayerCombo(name);
|
||||||
|
if (codes) applyLayerVisibility(codes);
|
||||||
|
},
|
||||||
|
deleteLayerCombo,
|
||||||
|
// Zeichnungs-Kombinationen: analog über drawingLevels[].visible.
|
||||||
|
listDrawingCombos,
|
||||||
|
saveDrawingCombo: (name) =>
|
||||||
|
saveDrawingCombo(name, snapshotDrawingVisibility()),
|
||||||
|
loadDrawingCombo: (name) => {
|
||||||
|
const ids = loadDrawingCombo(name);
|
||||||
|
if (ids) applyDrawingVisibility(ids);
|
||||||
|
},
|
||||||
|
deleteDrawingCombo,
|
||||||
|
}}
|
||||||
|
textTarget={textTarget}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
"3d": (
|
||||||
|
<Ribbon3dTab
|
||||||
|
viewToggleEnabled={activeLevel.kind === "floor"}
|
||||||
|
viewType={viewType}
|
||||||
|
onViewType={setViewType}
|
||||||
|
view3d={view3d}
|
||||||
|
onView3d={setView3d}
|
||||||
|
fov={fov}
|
||||||
|
onFov={setFov}
|
||||||
|
renderMode={renderMode}
|
||||||
|
onRenderMode={setRenderMode}
|
||||||
|
renderModeEnabled={viewType === "perspektive"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
@@ -3168,6 +3357,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mitte-Spalte: Hauptansicht + Befehlszeile DARUNTER. So ist die
|
||||||
|
Befehlszeile nur so breit wie der Viewport (nicht die ganze App),
|
||||||
|
und die Docks nehmen die volle Body-Höhe ein (inkl. der Zeile). */}
|
||||||
|
<div className="center-col">
|
||||||
{/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan-
|
{/* Hauptansicht (Mitte) — füllt den verbleibenden Platz. Die Plan-
|
||||||
Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt
|
Ansicht meldet Cursor + Zoom an die Status-/Oberleiste und nimmt
|
||||||
das Zoom-Handle entgegen. */}
|
das Zoom-Handle entgegen. */}
|
||||||
@@ -3232,6 +3425,54 @@ export default function App() {
|
|||||||
onEdit3dWallTop={onEdit3dWallTop}
|
onEdit3dWallTop={onEdit3dWallTop}
|
||||||
onEdit3dEnd={onEdit3dEnd}
|
onEdit3dEnd={onEdit3dEnd}
|
||||||
/>
|
/>
|
||||||
|
{/* Command-Line (Rhino-artig) UNTER dem Viewport — nur Viewport-breit.
|
||||||
|
Prompt + klickbare Inline-Optionen + Texteingabe + Autocomplete. */}
|
||||||
|
<CommandLine
|
||||||
|
ref={commandLineRef}
|
||||||
|
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
|
||||||
|
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
|
||||||
|
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
|
||||||
|
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
|
||||||
|
active={gripEdit ? true : activeTransform ? true : engineView.active}
|
||||||
|
promptKey={
|
||||||
|
gripEdit
|
||||||
|
? "cmd.edit.point"
|
||||||
|
: activeTransform
|
||||||
|
? `transform.hint.${activeTransform.op}.${Math.min(
|
||||||
|
activeTransform.points.length,
|
||||||
|
activeTransform.op === "rotate" ? 2 : 1,
|
||||||
|
)}`
|
||||||
|
: engineView.promptKey
|
||||||
|
}
|
||||||
|
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
|
||||||
|
fields={
|
||||||
|
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
|
||||||
|
}
|
||||||
|
onSubmit={(text) => {
|
||||||
|
if (gripEdit) {
|
||||||
|
const v = parseFloat(text.replace(",", "."));
|
||||||
|
if (isFinite(v)) submitGripEditValue(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeTransform) {
|
||||||
|
const v = parseFloat(text.replace(",", "."));
|
||||||
|
if (isFinite(v)) submitTransformValue(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engine.submitText(text);
|
||||||
|
}}
|
||||||
|
onOption={(id) =>
|
||||||
|
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
|
||||||
|
}
|
||||||
|
onCycleField={() =>
|
||||||
|
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
|
||||||
|
}
|
||||||
|
onCancel={() =>
|
||||||
|
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
|
||||||
|
}
|
||||||
|
suggest={(prefix) => engine.suggest(prefix)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes
|
{/* Rechtes Dock: standardmäßig leer (Ressourcen ist ein schwebendes
|
||||||
Fenster). Nur gerendert, wenn es Gruppen hat — sonst nutzt die Mitte
|
Fenster). Nur gerendert, wenn es Gruppen hat — sonst nutzt die Mitte
|
||||||
@@ -3300,54 +3541,6 @@ export default function App() {
|
|||||||
{/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */}
|
{/* Drag-Overlay (Geist + Tab-Einfüge-Caret) während eines Tab-Ziehens. */}
|
||||||
<DragOverlay drag={drag} />
|
<DragOverlay drag={drag} />
|
||||||
|
|
||||||
{/* Command-Line (Rhino-artig) ÜBER der Statusleiste: Prompt + klickbare
|
|
||||||
Inline-Optionen + Texteingabe + Autocomplete. Tab fokussiert sie. */}
|
|
||||||
<CommandLine
|
|
||||||
ref={commandLineRef}
|
|
||||||
// (c) Während eines Vertex-Griff-Drags belegt der Feld-Controller das
|
|
||||||
// Befehlsfeld (Länge/Winkel). Läuft eine Auswahl-Transformation (Drehen
|
|
||||||
// etc.), belegt sie Prompt + U/I/O/P + Winkel-/Distanzfeld. Sonst gilt die
|
|
||||||
// Engine (Zeichnen/Ruhe). Griff-Drag hat Vorrang (schließen sich sonst aus).
|
|
||||||
active={gripEdit ? true : activeTransform ? true : engineView.active}
|
|
||||||
promptKey={
|
|
||||||
gripEdit
|
|
||||||
? "cmd.edit.point"
|
|
||||||
: activeTransform
|
|
||||||
? `transform.hint.${activeTransform.op}.${Math.min(
|
|
||||||
activeTransform.points.length,
|
|
||||||
activeTransform.op === "rotate" ? 2 : 1,
|
|
||||||
)}`
|
|
||||||
: engineView.promptKey
|
|
||||||
}
|
|
||||||
options={gripEdit ? [] : activeTransform ? transformOptions() : engineView.options}
|
|
||||||
fields={
|
|
||||||
gripEdit ? gripEditFields() : activeTransform ? transformFields() : engineView.fields
|
|
||||||
}
|
|
||||||
onSubmit={(text) => {
|
|
||||||
if (gripEdit) {
|
|
||||||
const v = parseFloat(text.replace(",", "."));
|
|
||||||
if (isFinite(v)) submitGripEditValue(v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeTransform) {
|
|
||||||
const v = parseFloat(text.replace(",", "."));
|
|
||||||
if (isFinite(v)) submitTransformValue(v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
engine.submitText(text);
|
|
||||||
}}
|
|
||||||
onOption={(id) =>
|
|
||||||
activeTransform ? onTransformMode(id as CopyMode) : engine.chooseOption(id)
|
|
||||||
}
|
|
||||||
onCycleField={() =>
|
|
||||||
gripEdit ? cycleGripEditField() : activeTransform ? undefined : engine.cycleField()
|
|
||||||
}
|
|
||||||
onCancel={() =>
|
|
||||||
gripEdit ? setGripEdit(null) : activeTransform ? cancelTransform() : engine.cancel()
|
|
||||||
}
|
|
||||||
suggest={(prefix) => engine.suggest(prefix)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State.
|
{/* Footer / Status-Leiste (unten). Werte rein aus Modell + View-State.
|
||||||
Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */}
|
Cursor/Zoom nur, wenn eine Plan-Ansicht aktiv ist (sonst „—"). */}
|
||||||
<StatusBar
|
<StatusBar
|
||||||
|
|||||||
@@ -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: () => [],
|
options: () => [],
|
||||||
init: () => ({ ...IDLE }),
|
init: () => ({ ...IDLE }),
|
||||||
|
|
||||||
|
// Sofort-Befehl (wie Join/Split): `startCommand` ruft `autoRun` SYNCHRON im
|
||||||
|
// selben Aufruf-Stack wie die Enter-/Klick-Geste auf. Genau das braucht der
|
||||||
|
// Datei-Dialog — ein `<input type=file>.click()` öffnet den OS-Dialog nur
|
||||||
|
// innerhalb einer echten Nutzer-Geste; ein State-Update/async-Hop dazwischen
|
||||||
|
// (wie beim reinen onConfirm/onInput-Weg) lässt ihn verzögern oder ausbleiben.
|
||||||
|
autoRun: (): CommandResult => {
|
||||||
|
openDxfImport();
|
||||||
|
return { draft: null, done: true };
|
||||||
|
},
|
||||||
|
|
||||||
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
|
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
|
||||||
onInput: () => open(),
|
onInput: () => open(),
|
||||||
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { stairCommand } from "./cmds/stair";
|
|||||||
import { roomCommand } from "./cmds/room";
|
import { roomCommand } from "./cmds/room";
|
||||||
import { rectCommand } from "./cmds/rect";
|
import { rectCommand } from "./cmds/rect";
|
||||||
import { circleCommand } from "./cmds/circle";
|
import { circleCommand } from "./cmds/circle";
|
||||||
|
import { arcCommand } from "./cmds/arc";
|
||||||
import { moveCommand } from "./cmds/move";
|
import { moveCommand } from "./cmds/move";
|
||||||
import { mirrorCommand } from "./cmds/mirror";
|
import { mirrorCommand } from "./cmds/mirror";
|
||||||
import { joinCommand } from "./cmds/join";
|
import { joinCommand } from "./cmds/join";
|
||||||
@@ -35,6 +36,7 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
polyline: polylineCommand,
|
polyline: polylineCommand,
|
||||||
rect: rectCommand,
|
rect: rectCommand,
|
||||||
circle: circleCommand,
|
circle: circleCommand,
|
||||||
|
arc: arcCommand,
|
||||||
move: moveCommand,
|
move: moveCommand,
|
||||||
mirror: mirrorCommand,
|
mirror: mirrorCommand,
|
||||||
join: joinCommand,
|
join: joinCommand,
|
||||||
@@ -67,6 +69,8 @@ export const ALIASES: Record<string, string> = {
|
|||||||
pl: "polyline",
|
pl: "polyline",
|
||||||
rec: "rect",
|
rec: "rect",
|
||||||
c: "circle",
|
c: "circle",
|
||||||
|
a: "arc",
|
||||||
|
bogen: "arc",
|
||||||
m: "move",
|
m: "move",
|
||||||
s: "mirror",
|
s: "mirror",
|
||||||
spiegeln: "mirror",
|
spiegeln: "mirror",
|
||||||
|
|||||||
+31
-5
@@ -116,6 +116,23 @@ export const de = {
|
|||||||
"tool.line": "Linie",
|
"tool.line": "Linie",
|
||||||
"tool.polyline": "Polylinie",
|
"tool.polyline": "Polylinie",
|
||||||
"tool.rect": "Rechteck",
|
"tool.rect": "Rechteck",
|
||||||
|
"tool.circle": "Kreis",
|
||||||
|
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
||||||
|
"tool.arc": "Bogen",
|
||||||
|
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
||||||
|
// Ribbon-Oberleiste (Tabs + Gruppen)
|
||||||
|
"ribbon.tab.2d": "2D",
|
||||||
|
"ribbon.tab.3d": "3D",
|
||||||
|
"ribbon.tab.bim": "BIM",
|
||||||
|
"ribbon.tab.views": "Ansichten",
|
||||||
|
"ribbon.tab.custom": "Eigene",
|
||||||
|
"ribbon.group.draw": "Zeichnen",
|
||||||
|
"ribbon.group.modify": "Ändern",
|
||||||
|
"ribbon.group.components": "Bauteile",
|
||||||
|
"ribbon.group.custom": "Eigene Leiste",
|
||||||
|
"ribbon.custom.add": "+ Hinzufügen",
|
||||||
|
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
||||||
|
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
|
||||||
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
|
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
|
||||||
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
|
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
|
||||||
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
|
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
|
||||||
@@ -208,6 +225,7 @@ export const de = {
|
|||||||
// Attribute-Palette (AttributesPanel)
|
// Attribute-Palette (AttributesPanel)
|
||||||
"attr.title": "Attribute",
|
"attr.title": "Attribute",
|
||||||
"attr.empty": "Keine Auswahl",
|
"attr.empty": "Keine Auswahl",
|
||||||
|
"attr.newComponent": "Neues Bauteil",
|
||||||
"attr.stroke": "Stift",
|
"attr.stroke": "Stift",
|
||||||
"attr.color": "Farbe",
|
"attr.color": "Farbe",
|
||||||
"attr.weight": "Strichstärke (mm)",
|
"attr.weight": "Strichstärke (mm)",
|
||||||
@@ -255,7 +273,7 @@ export const de = {
|
|||||||
"objinfo.wall.refLine.center": "Mitte",
|
"objinfo.wall.refLine.center": "Mitte",
|
||||||
"objinfo.wall.refLine.right": "Innen (rechts)",
|
"objinfo.wall.refLine.right": "Innen (rechts)",
|
||||||
"objinfo.wall.buildup": "Aufbau",
|
"objinfo.wall.buildup": "Aufbau",
|
||||||
"objinfo.wall.single": "Einschichtig",
|
"objinfo.wall.single": "Solid",
|
||||||
"objinfo.wall.multi": "Mehrschichtig",
|
"objinfo.wall.multi": "Mehrschichtig",
|
||||||
"objinfo.wall.thickness": "Dicke",
|
"objinfo.wall.thickness": "Dicke",
|
||||||
"objinfo.wall.preset": "Wandaufbau",
|
"objinfo.wall.preset": "Wandaufbau",
|
||||||
@@ -264,8 +282,8 @@ export const de = {
|
|||||||
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
|
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
|
||||||
"objinfo.wall.bottom": "Unterkante (UK)",
|
"objinfo.wall.bottom": "Unterkante (UK)",
|
||||||
"objinfo.wall.top": "Oberkante (OK)",
|
"objinfo.wall.top": "Oberkante (OK)",
|
||||||
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
|
"objinfo.wall.anchorFloor": "Gebunden",
|
||||||
"objinfo.wall.anchorCustom": "Eigene Höhe",
|
"objinfo.wall.anchorCustom": "Eigene",
|
||||||
"objinfo.wall.height": "Höhe",
|
"objinfo.wall.height": "Höhe",
|
||||||
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
||||||
"objinfo.ceiling.section": "Decke",
|
"objinfo.ceiling.section": "Decke",
|
||||||
@@ -277,8 +295,8 @@ export const de = {
|
|||||||
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
||||||
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
||||||
"objinfo.ceiling.area": "Fläche",
|
"objinfo.ceiling.area": "Fläche",
|
||||||
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden",
|
"objinfo.ceiling.anchorFloor": "Gebunden",
|
||||||
"objinfo.ceiling.anchorCustom": "Eigene Höhe",
|
"objinfo.ceiling.anchorCustom": "Eigene",
|
||||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||||
"objinfo.opening.section": "Öffnung",
|
"objinfo.opening.section": "Öffnung",
|
||||||
"objinfo.opening.kind": "Art",
|
"objinfo.opening.kind": "Art",
|
||||||
@@ -707,6 +725,10 @@ export const de = {
|
|||||||
"cmd.circle.label": "Kreis",
|
"cmd.circle.label": "Kreis",
|
||||||
"cmd.circle.center": "Mittelpunkt:",
|
"cmd.circle.center": "Mittelpunkt:",
|
||||||
"cmd.circle.radius": "Radius:",
|
"cmd.circle.radius": "Radius:",
|
||||||
|
"cmd.arc.label": "Bogen",
|
||||||
|
"cmd.arc.center": "Mittelpunkt:",
|
||||||
|
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
||||||
|
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
||||||
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
||||||
"cmd.move.label": "Verschieben",
|
"cmd.move.label": "Verschieben",
|
||||||
"cmd.move.base": "Basispunkt:",
|
"cmd.move.base": "Basispunkt:",
|
||||||
@@ -779,6 +801,7 @@ export const de = {
|
|||||||
"import.file": "Datei",
|
"import.file": "Datei",
|
||||||
"import.summary.heading": "Inhalt",
|
"import.summary.heading": "Inhalt",
|
||||||
"import.summary.contours": "{n} Konturen",
|
"import.summary.contours": "{n} Konturen",
|
||||||
|
"import.summary.texts": "{n} Texte",
|
||||||
"import.summary.meshes": "{n} Meshes",
|
"import.summary.meshes": "{n} Meshes",
|
||||||
"import.summary.layers": "Layer: {layers}",
|
"import.summary.layers": "Layer: {layers}",
|
||||||
"import.summary.noLayers": "keine Layer-Namen",
|
"import.summary.noLayers": "keine Layer-Namen",
|
||||||
@@ -790,6 +813,9 @@ export const de = {
|
|||||||
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
|
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
|
||||||
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
|
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
|
||||||
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
|
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
|
||||||
|
"import.placement.heading": "Platzierung",
|
||||||
|
"import.placement.origin": "An DXF-Koordinaten (relativ zum Nullpunkt)",
|
||||||
|
"import.placement.viewCenter": "In die Mitte der aktuellen Ansicht",
|
||||||
"import.mesh.heading": "Meshes",
|
"import.mesh.heading": "Meshes",
|
||||||
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
|
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
|
||||||
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
|
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
|
||||||
|
|||||||
+31
-5
@@ -115,6 +115,23 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"tool.line": "Line",
|
"tool.line": "Line",
|
||||||
"tool.polyline": "Polyline",
|
"tool.polyline": "Polyline",
|
||||||
"tool.rect": "Rectangle",
|
"tool.rect": "Rectangle",
|
||||||
|
"tool.circle": "Circle",
|
||||||
|
"tool.circle.hint": "Circle: set center, then radius",
|
||||||
|
"tool.arc": "Arc",
|
||||||
|
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
||||||
|
// Ribbon top bar (tabs + groups)
|
||||||
|
"ribbon.tab.2d": "2D",
|
||||||
|
"ribbon.tab.3d": "3D",
|
||||||
|
"ribbon.tab.bim": "BIM",
|
||||||
|
"ribbon.tab.views": "Views",
|
||||||
|
"ribbon.tab.custom": "Custom",
|
||||||
|
"ribbon.group.draw": "Draw",
|
||||||
|
"ribbon.group.modify": "Modify",
|
||||||
|
"ribbon.group.components": "Components",
|
||||||
|
"ribbon.group.custom": "Custom bar",
|
||||||
|
"ribbon.custom.add": "+ Add",
|
||||||
|
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
||||||
|
"ribbon.empty": "No tools in this tab yet",
|
||||||
"tool.select.hint": "Select — click/drag elements",
|
"tool.select.hint": "Select — click/drag elements",
|
||||||
"tool.window.hint": "Window: pick a wall, then set the position",
|
"tool.window.hint": "Window: pick a wall, then set the position",
|
||||||
"tool.door.hint": "Door: pick a wall, then set the position",
|
"tool.door.hint": "Door: pick a wall, then set the position",
|
||||||
@@ -207,6 +224,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
// Attribute-Palette (AttributesPanel)
|
// Attribute-Palette (AttributesPanel)
|
||||||
"attr.title": "Attributes",
|
"attr.title": "Attributes",
|
||||||
"attr.empty": "No selection",
|
"attr.empty": "No selection",
|
||||||
|
"attr.newComponent": "New component",
|
||||||
"attr.stroke": "Pen",
|
"attr.stroke": "Pen",
|
||||||
"attr.color": "Color",
|
"attr.color": "Color",
|
||||||
"attr.weight": "Line weight (mm)",
|
"attr.weight": "Line weight (mm)",
|
||||||
@@ -254,7 +272,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.wall.refLine.center": "Center",
|
"objinfo.wall.refLine.center": "Center",
|
||||||
"objinfo.wall.refLine.right": "Interior (right)",
|
"objinfo.wall.refLine.right": "Interior (right)",
|
||||||
"objinfo.wall.buildup": "Build-up",
|
"objinfo.wall.buildup": "Build-up",
|
||||||
"objinfo.wall.single": "Single layer",
|
"objinfo.wall.single": "Solid",
|
||||||
"objinfo.wall.multi": "Multi layer",
|
"objinfo.wall.multi": "Multi layer",
|
||||||
"objinfo.wall.thickness": "Thickness",
|
"objinfo.wall.thickness": "Thickness",
|
||||||
"objinfo.wall.preset": "Wall style",
|
"objinfo.wall.preset": "Wall style",
|
||||||
@@ -263,8 +281,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.wall.linkAbove": "Bind to floor above",
|
"objinfo.wall.linkAbove": "Bind to floor above",
|
||||||
"objinfo.wall.bottom": "Bottom (UK)",
|
"objinfo.wall.bottom": "Bottom (UK)",
|
||||||
"objinfo.wall.top": "Top (OK)",
|
"objinfo.wall.top": "Top (OK)",
|
||||||
"objinfo.wall.anchorFloor": "Bound to floor",
|
"objinfo.wall.anchorFloor": "Bound",
|
||||||
"objinfo.wall.anchorCustom": "Custom height",
|
"objinfo.wall.anchorCustom": "Custom",
|
||||||
"objinfo.wall.height": "Height",
|
"objinfo.wall.height": "Height",
|
||||||
"objinfo.wall.floorAboveNone": "No floor above",
|
"objinfo.wall.floorAboveNone": "No floor above",
|
||||||
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
||||||
@@ -277,8 +295,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.ceiling.bottom": "Bottom (UK)",
|
"objinfo.ceiling.bottom": "Bottom (UK)",
|
||||||
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
||||||
"objinfo.ceiling.area": "Area",
|
"objinfo.ceiling.area": "Area",
|
||||||
"objinfo.ceiling.anchorFloor": "Bound to floor",
|
"objinfo.ceiling.anchorFloor": "Bound",
|
||||||
"objinfo.ceiling.anchorCustom": "Custom height",
|
"objinfo.ceiling.anchorCustom": "Custom",
|
||||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||||
"objinfo.opening.section": "Opening",
|
"objinfo.opening.section": "Opening",
|
||||||
"objinfo.opening.kind": "Type",
|
"objinfo.opening.kind": "Type",
|
||||||
@@ -697,6 +715,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.circle.label": "Circle",
|
"cmd.circle.label": "Circle",
|
||||||
"cmd.circle.center": "Center:",
|
"cmd.circle.center": "Center:",
|
||||||
"cmd.circle.radius": "Radius:",
|
"cmd.circle.radius": "Radius:",
|
||||||
|
"cmd.arc.label": "Arc",
|
||||||
|
"cmd.arc.center": "Center:",
|
||||||
|
"cmd.arc.start": "Start point (radius + start angle):",
|
||||||
|
"cmd.arc.end": "End point (end angle):",
|
||||||
"cmd.move.label": "Move",
|
"cmd.move.label": "Move",
|
||||||
"cmd.move.base": "Base point:",
|
"cmd.move.base": "Base point:",
|
||||||
"cmd.move.target": "Target point:",
|
"cmd.move.target": "Target point:",
|
||||||
@@ -768,6 +790,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"import.file": "File",
|
"import.file": "File",
|
||||||
"import.summary.heading": "Contents",
|
"import.summary.heading": "Contents",
|
||||||
"import.summary.contours": "{n} contours",
|
"import.summary.contours": "{n} contours",
|
||||||
|
"import.summary.texts": "{n} texts",
|
||||||
"import.summary.meshes": "{n} meshes",
|
"import.summary.meshes": "{n} meshes",
|
||||||
"import.summary.layers": "Layers: {layers}",
|
"import.summary.layers": "Layers: {layers}",
|
||||||
"import.summary.noLayers": "no layer names",
|
"import.summary.noLayers": "no layer names",
|
||||||
@@ -779,6 +802,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"import.layers.heading": "Layer handling (DXF layers)",
|
"import.layers.heading": "Layer handling (DXF layers)",
|
||||||
"import.layers.asCategories": "Keep DXF layers as categories",
|
"import.layers.asCategories": "Keep DXF layers as categories",
|
||||||
"import.layers.toActive": "Everything onto the active category ({code})",
|
"import.layers.toActive": "Everything onto the active category ({code})",
|
||||||
|
"import.placement.heading": "Placement",
|
||||||
|
"import.placement.origin": "At DXF coordinates (relative to origin)",
|
||||||
|
"import.placement.viewCenter": "Centered in the current view",
|
||||||
"import.mesh.heading": "Meshes",
|
"import.mesh.heading": "Meshes",
|
||||||
"import.mesh.asContext": "Import meshes as context geometry (3D)",
|
"import.mesh.asContext": "Import meshes as context geometry (3D)",
|
||||||
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
|
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
|
||||||
|
|||||||
+107
-1
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { parseDxf } from "./dxfParser";
|
import { parseDxf } from "./dxfParser";
|
||||||
import { contoursToDrawings } from "./dxfToDrawings";
|
import { contoursToDrawings, textsToDrawings } from "./dxfToDrawings";
|
||||||
import type { Contour, ContourSet, Vec2 } from "../model/types";
|
import type { Contour, ContourSet, Vec2 } from "../model/types";
|
||||||
|
|
||||||
/** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */
|
/** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */
|
||||||
@@ -411,6 +411,112 @@ describe("parseDxf — HATCH", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── TEXT / MTEXT ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** TEXT-Entity: Position, Höhe, Rotation (Grad), String. */
|
||||||
|
function textEnt(x: number, y: number, h: number, rotDeg: number, s: string): string[] {
|
||||||
|
return ["0", "TEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", `${rotDeg}`, "1", s];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MTEXT-Entity: Position, Höhe, String (Rotation 0). */
|
||||||
|
function mtextEnt(x: number, y: number, h: number, s: string): string[] {
|
||||||
|
return ["0", "MTEXT", "8", "0", "10", `${x}`, "20", `${y}`, "30", "0", "40", `${h}`, "50", "0", "1", s];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseDxf — TEXT/MTEXT", () => {
|
||||||
|
it("TEXT → ImportedText mit Position/Höhe/Winkel (Grad→Radiant)", () => {
|
||||||
|
const res = parseDxf(dxf(textEnt(5, 3, 0.5, 90, "Hallo")));
|
||||||
|
expect(res.texts).toHaveLength(1);
|
||||||
|
const t0 = res.texts![0];
|
||||||
|
expect(t0.at.x).toBeCloseTo(5, 9);
|
||||||
|
expect(t0.at.y).toBeCloseTo(3, 9);
|
||||||
|
expect(t0.height).toBeCloseTo(0.5, 9);
|
||||||
|
expect(t0.angle).toBeCloseTo(Math.PI / 2, 9);
|
||||||
|
expect(t0.text).toBe("Hallo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MTEXT → Formatcodes gesäubert (\\P → Leerzeichen)", () => {
|
||||||
|
const res = parseDxf(dxf(mtextEnt(1, 2, 0.3, "Welt\\Pzeile2")));
|
||||||
|
expect(res.texts![0].text).toBe("Welt zeile2");
|
||||||
|
expect(res.texts![0].at.x).toBeCloseTo(1, 9);
|
||||||
|
expect(res.texts![0].height).toBeCloseTo(0.3, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leerer Text wird übersprungen", () => {
|
||||||
|
const res = parseDxf(dxf(textEnt(0, 0, 0.2, 0, "")));
|
||||||
|
expect(res.texts ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fehlende Höhe → positiver Default", () => {
|
||||||
|
const g = ["0", "TEXT", "8", "0", "10", "0", "20", "0", "30", "0", "1", "X"];
|
||||||
|
const res = parseDxf(dxf(g));
|
||||||
|
expect(res.texts![0].height).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("textsToDrawings", () => {
|
||||||
|
it("ImportedText → Drawing2D {shape:\"text\"}", () => {
|
||||||
|
const drawings = textsToDrawings(
|
||||||
|
[{ at: { x: 1, y: 2 }, text: "Hi", height: 0.4, angle: 0, layer: "L" }],
|
||||||
|
"lvl", "active", "CODE", (s) => s,
|
||||||
|
);
|
||||||
|
expect(drawings).toHaveLength(1);
|
||||||
|
const g = drawings[0].geom;
|
||||||
|
expect(g.shape).toBe("text");
|
||||||
|
if (g.shape === "text") {
|
||||||
|
expect(g.text).toBe("Hi");
|
||||||
|
expect(g.height).toBeCloseTo(0.4, 9);
|
||||||
|
expect(g.at.x).toBeCloseTo(1, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDxf — Kurven behalten echte Geometrie (curve)", () => {
|
||||||
|
it("CIRCLE trägt curve {kind:circle}", () => {
|
||||||
|
const c = onlyContour(dxf(circle(10, 20, 5, 4)));
|
||||||
|
expect(c.curve?.kind).toBe("circle");
|
||||||
|
expect(c.curve?.cx).toBeCloseTo(10, 9);
|
||||||
|
expect(c.curve?.cy).toBeCloseTo(20, 9);
|
||||||
|
expect(c.curve?.r).toBeCloseTo(4, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ARC trägt curve {kind:arc} mit Winkeln (Radiant)", () => {
|
||||||
|
const c = onlyContour(dxf(arc(0, 0, 10, 0, 90)));
|
||||||
|
expect(c.curve?.kind).toBe("arc");
|
||||||
|
expect(c.curve?.r).toBeCloseTo(10, 9);
|
||||||
|
expect(c.curve?.a0).toBeCloseTo(0, 9);
|
||||||
|
expect(c.curve?.a1).toBeCloseTo(Math.PI / 2, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contoursToDrawings — Kurven → glatte Formen", () => {
|
||||||
|
it("curve circle → {shape:\"circle\"}", () => {
|
||||||
|
const set: ContourSet = {
|
||||||
|
id: "s", type: "contourSet", name: "c",
|
||||||
|
contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }], curve: { kind: "circle", cx: 1, cy: 2, r: 3, a0: 0, a1: Math.PI * 2 } }],
|
||||||
|
};
|
||||||
|
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
|
||||||
|
expect(d.geom.shape).toBe("circle");
|
||||||
|
if (d.geom.shape === "circle") {
|
||||||
|
expect(d.geom.r).toBeCloseTo(3, 9);
|
||||||
|
expect(d.geom.center.x).toBeCloseTo(1, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("curve arc → {shape:\"arc\"}", () => {
|
||||||
|
const set: ContourSet = {
|
||||||
|
id: "s", type: "contourSet", name: "c",
|
||||||
|
contours: [{ z: 0, closed: false, pts: [{ x: 0, y: 0 }], curve: { kind: "arc", cx: 0, cy: 0, r: 5, a0: 0, a1: 1 } }],
|
||||||
|
};
|
||||||
|
const [d] = contoursToDrawings([set], "lvl", "active", "C", (s) => s);
|
||||||
|
expect(d.geom.shape).toBe("arc");
|
||||||
|
if (d.geom.shape === "arc") {
|
||||||
|
expect(d.geom.r).toBeCloseTo(5, 9);
|
||||||
|
expect(d.geom.a1).toBeCloseTo(1, 9);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("contoursToDrawings — HATCH-Füllung", () => {
|
describe("contoursToDrawings — HATCH-Füllung", () => {
|
||||||
it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => {
|
it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => {
|
||||||
const set: ContourSet = {
|
const set: ContourSet = {
|
||||||
|
|||||||
+68
-5
@@ -24,9 +24,10 @@
|
|||||||
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
|
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
|
||||||
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
|
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
|
||||||
// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`).
|
// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`).
|
||||||
|
// • TEXT / MTEXT → ImportedText (Position/Höhe/Winkel) → Drawing2D `{shape:"text"}`.
|
||||||
|
|
||||||
import DxfParser from "dxf-parser";
|
import DxfParser from "dxf-parser";
|
||||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
import type { Contour, ContourSet, ImportedMesh, ImportedText, Vec2 } from "../model/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
|
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
|
||||||
@@ -41,10 +42,12 @@ export interface ImportDiagnostics {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
|
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze + Texte. */
|
||||||
export interface DxfImportResult {
|
export interface DxfImportResult {
|
||||||
meshes: ImportedMesh[];
|
meshes: ImportedMesh[];
|
||||||
contours: ContourSet[];
|
contours: ContourSet[];
|
||||||
|
/** Importierte TEXT/MTEXT-Elemente (optional; der DWG-Parser füllt sie nicht). */
|
||||||
|
texts?: ImportedText[];
|
||||||
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
|
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
|
||||||
diagnostics?: ImportDiagnostics;
|
diagnostics?: ImportDiagnostics;
|
||||||
}
|
}
|
||||||
@@ -97,6 +100,12 @@ interface DxfEntity {
|
|||||||
rowSpacing?: number;
|
rowSpacing?: number;
|
||||||
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
|
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
|
||||||
rawCodes?: RawCode[];
|
rawCodes?: RawCode[];
|
||||||
|
// TEXT/MTEXT. `position` (MTEXT-Einfügepunkt) wird tolerant gelesen (oben als
|
||||||
|
// MESH-Vertexliste getippt). `rotation` (Grad) ist bereits oben deklariert.
|
||||||
|
startPoint?: DxfVertex; // TEXT-Einfügepunkt
|
||||||
|
textHeight?: number; // TEXT-Höhe (Code 40)
|
||||||
|
height?: number; // MTEXT-Höhe (Code 40)
|
||||||
|
text?: string; // Textinhalt (Code 1, MTEXT auch 3)
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
|
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
|
||||||
@@ -140,6 +149,7 @@ export function parseDxf(text: string): DxfImportResult {
|
|||||||
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
||||||
const meshIndices: number[] = [];
|
const meshIndices: number[] = [];
|
||||||
const contours: Contour[] = [];
|
const contours: Contour[] = [];
|
||||||
|
const texts: ImportedText[] = [];
|
||||||
|
|
||||||
for (const e of entities) {
|
for (const e of entities) {
|
||||||
const type = (e.type ?? "").toUpperCase();
|
const type = (e.type ?? "").toUpperCase();
|
||||||
@@ -156,6 +166,11 @@ export function parseDxf(text: string): DxfImportResult {
|
|||||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (type === "TEXT" || type === "MTEXT") {
|
||||||
|
const it = textEntity(e, type);
|
||||||
|
if (it) texts.push(it);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über
|
// Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über
|
||||||
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
|
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
|
||||||
collectContours(e, blocks, contours, 0);
|
collectContours(e, blocks, contours, 0);
|
||||||
@@ -182,7 +197,43 @@ export function parseDxf(text: string): DxfImportResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { meshes, contours: contourSets };
|
return { meshes, contours: contourSets, texts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TEXT / MTEXT ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fallback-Schrifthöhe (Meter), wenn die DXF-Höhe fehlt/0 ist. */
|
||||||
|
const DEFAULT_TEXT_HEIGHT = 0.25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MTEXT-Inhalt von Formatierungscodes säubern (grober erster Wurf): \P → Leer-
|
||||||
|
* zeichen, \~ → Leerzeichen, `\f…;`/`\H…;`/`\C…;`/`\A…;`-Sequenzen und die {}-
|
||||||
|
* Gruppenklammern entfernen, `\{`/`\}`/`\\` entschärfen. Komplexe Verschachte-
|
||||||
|
* lungen werden flachgeklopft (dokumentierte Grenze).
|
||||||
|
*/
|
||||||
|
function sanitizeMText(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/\\P/gi, " ")
|
||||||
|
.replace(/\\~/g, " ")
|
||||||
|
.replace(/\\[A-Za-z][^;\\]*;/g, "")
|
||||||
|
.replace(/[{}]/g, "")
|
||||||
|
.replace(/\\([{}\\])/g, "$1")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TEXT/MTEXT-Entity → ImportedText (Position/Höhe/Winkel), oder null bei leer. */
|
||||||
|
function textEntity(e: DxfEntity, type: string): ImportedText | null {
|
||||||
|
const rawText = typeof e.text === "string" ? e.text : "";
|
||||||
|
const content = type === "MTEXT" ? sanitizeMText(rawText) : rawText;
|
||||||
|
if (!content) return null;
|
||||||
|
const at: Vec2 =
|
||||||
|
type === "MTEXT"
|
||||||
|
? { x: coord(e.position, "x"), y: coord(e.position, "y") }
|
||||||
|
: { x: e.startPoint?.x ?? 0, y: e.startPoint?.y ?? 0 };
|
||||||
|
const rawHeight = type === "MTEXT" ? numOr(e.height, 0) : numOr(e.textHeight, 0);
|
||||||
|
const height = rawHeight > 0 ? rawHeight : DEFAULT_TEXT_HEIGHT;
|
||||||
|
const angle = (numOr(e.rotation, 0) * Math.PI) / 180; // DXF: Grad CCW → Radiant
|
||||||
|
return { at, text: content, height, angle, layer: e.layer };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
||||||
@@ -393,7 +444,13 @@ function arcContour(e: DxfEntity): Contour | null {
|
|||||||
const t = start + (sweep * i) / segs;
|
const t = start + (sweep * i) / segs;
|
||||||
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
||||||
}
|
}
|
||||||
return { z, pts, closed: false, layer: e.layer };
|
return {
|
||||||
|
z,
|
||||||
|
pts,
|
||||||
|
closed: false,
|
||||||
|
layer: e.layer,
|
||||||
|
curve: { kind: "arc", cx, cy, r, a0: start, a1: start + sweep },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */
|
/** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */
|
||||||
@@ -420,7 +477,13 @@ function circleContour(e: DxfEntity): Contour | null {
|
|||||||
const t = (Math.PI * 2 * i) / segs;
|
const t = (Math.PI * 2 * i) / segs;
|
||||||
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
||||||
}
|
}
|
||||||
return { z, pts, closed: true, layer: e.layer };
|
return {
|
||||||
|
z,
|
||||||
|
pts,
|
||||||
|
closed: true,
|
||||||
|
layer: e.layer,
|
||||||
|
curve: { kind: "circle", cx, cy, r, a0: 0, a1: Math.PI * 2 },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+43
-1
@@ -15,7 +15,7 @@
|
|||||||
//
|
//
|
||||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
|
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom, ImportedText } from "../model/types";
|
||||||
|
|
||||||
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
|
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
|
||||||
export type CategoryMode = "active" | "byLayer";
|
export type CategoryMode = "active" | "byLayer";
|
||||||
@@ -66,8 +66,50 @@ export function contoursToDrawings(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt importierte Texte in Drawing2D-`text`-Elemente um — analog
|
||||||
|
* `contoursToDrawings` (gleiche Layer→Kategorie-Zuordnung). Winkel/Höhe kommen
|
||||||
|
* 1:1 aus `ImportedText` (Höhe in Modell-Metern, Winkel in Radiant).
|
||||||
|
*/
|
||||||
|
export function textsToDrawings(
|
||||||
|
texts: ImportedText[],
|
||||||
|
levelId: string,
|
||||||
|
categoryMode: CategoryMode,
|
||||||
|
fallbackCode: string,
|
||||||
|
layerToCode: (layerName: string) => string,
|
||||||
|
): Drawing2D[] {
|
||||||
|
const out: Drawing2D[] = [];
|
||||||
|
for (const it of texts) {
|
||||||
|
const layerName = it.layer ?? "";
|
||||||
|
const categoryCode =
|
||||||
|
categoryMode === "byLayer" && layerName ? layerToCode(layerName) : fallbackCode;
|
||||||
|
out.push({
|
||||||
|
id: nextId(),
|
||||||
|
type: "drawing2d",
|
||||||
|
levelId,
|
||||||
|
categoryCode,
|
||||||
|
geom: {
|
||||||
|
shape: "text",
|
||||||
|
at: { ...it.at },
|
||||||
|
text: it.text,
|
||||||
|
height: it.height,
|
||||||
|
angle: it.angle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
|
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
|
||||||
function contourGeom(contour: Contour): Drawing2DGeom | null {
|
function contourGeom(contour: Contour): Drawing2DGeom | null {
|
||||||
|
// Echte Kurve (CIRCLE/ARC) → GLATTE Kreis-/Bogen-Form statt tesselliertem Vieleck.
|
||||||
|
const c = contour.curve;
|
||||||
|
if (c) {
|
||||||
|
if (c.kind === "circle") {
|
||||||
|
return { shape: "circle", center: { x: c.cx, y: c.cy }, r: c.r };
|
||||||
|
}
|
||||||
|
return { shape: "arc", center: { x: c.cx, y: c.cy }, r: c.r, a0: c.a0, a1: c.a1 };
|
||||||
|
}
|
||||||
const pts = contour.pts;
|
const pts = contour.pts;
|
||||||
if (pts.length < 2) return null;
|
if (pts.length < 2) return null;
|
||||||
if (pts.length === 2 && !contour.closed) {
|
if (pts.length === 2 && !contour.closed) {
|
||||||
|
|||||||
+14
-18
@@ -16,22 +16,18 @@ export const sampleProject: Project = {
|
|||||||
id: "demo",
|
id: "demo",
|
||||||
name: "Demo-Haus",
|
name: "Demo-Haus",
|
||||||
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
|
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
|
||||||
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
|
// Linienstile: aufgeräumt auf kanonische VOLLLINIEN je Strichstärke (dash=null)
|
||||||
// ist der dünne Strich für Schraffur-Muster.
|
// + eine gestrichelte Linie. Früher gab es drei funktional identische 0.13-
|
||||||
|
// Volllinien ("thin"/"hatch-line"/"joint-massive") — zusammengeführt auf "thin"
|
||||||
|
// ("Volllinie 0.13"); Schraffur-/Schichtfugen-Referenzen zeigen jetzt darauf.
|
||||||
|
// (Die ids bleiben stabil, damit geladene Projekte + interne Referenzen halten.)
|
||||||
lineStyles: [
|
lineStyles: [
|
||||||
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
{ id: "hatch-hair", name: "Volllinie 0.02", weight: 0.02, color: "#1a1a1a", dash: null },
|
||||||
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
{ id: "thin", name: "Volllinie 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||||
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
{ id: "medium", name: "Volllinie 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
||||||
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
|
{ id: "thick", name: "Volllinie 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
||||||
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
|
|
||||||
// Feinste Haarlinie für die SIA-Schnittschraffuren (Beton/Backstein/Dämmung):
|
|
||||||
// Gewicht unter der Bildschirm-Untergrenze, damit die Musterlinien als dünnste
|
|
||||||
// Haarlinie (Stroke-Minimum) rendern.
|
|
||||||
{ id: "hatch-hair", name: "Schraffur-Haarlinie", weight: 0.02, color: "#1a1a1a", dash: null },
|
|
||||||
// Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke).
|
// Gestrichelte Haarlinie für die Dämmung (Striche quer durch die Wanddicke).
|
||||||
{ id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] },
|
{ id: "hatch-dash", name: "Dämmungsstrich", weight: 0.02, color: "#1a1a1a", dash: [0.35, 0.3] },
|
||||||
// Kräftige Schichtfuge, wo zwei massive Materialien aneinanderstoßen (Wandstile).
|
|
||||||
{ id: "joint-massive", name: "Schichtfuge 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
|
||||||
],
|
],
|
||||||
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
|
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
|
||||||
// "none" = keine Schraffur (nur Component-Füllung).
|
// "none" = keine Schraffur (nur Component-Füllung).
|
||||||
@@ -46,7 +42,7 @@ export const sampleProject: Project = {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
lineStyleId: "hatch-line",
|
lineStyleId: "thin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "diagonal",
|
id: "diagonal",
|
||||||
@@ -55,7 +51,7 @@ export const sampleProject: Project = {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
angle: 45,
|
angle: 45,
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
lineStyleId: "hatch-line",
|
lineStyleId: "thin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "crosshatch",
|
id: "crosshatch",
|
||||||
@@ -64,7 +60,7 @@ export const sampleProject: Project = {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
angle: 0,
|
angle: 0,
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
lineStyleId: "hatch-line",
|
lineStyleId: "thin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "solid-concrete",
|
id: "solid-concrete",
|
||||||
@@ -133,7 +129,7 @@ export const sampleProject: Project = {
|
|||||||
layers: [
|
layers: [
|
||||||
{ componentId: "render-ext", thickness: 0.02 },
|
{ componentId: "render-ext", thickness: 0.02 },
|
||||||
// Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm.
|
// Innere Fuge der Dämmung trifft auf den Backstein (massiv↔massiv) → 0.13 mm.
|
||||||
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "joint-massive" },
|
{ componentId: "insulation", thickness: 0.16, jointLineStyleId: "thin" },
|
||||||
{ componentId: "brick", thickness: 0.15 },
|
{ componentId: "brick", thickness: 0.15 },
|
||||||
{ componentId: "render-int", thickness: 0.015 },
|
{ componentId: "render-int", thickness: 0.015 },
|
||||||
],
|
],
|
||||||
@@ -160,7 +156,7 @@ export const sampleProject: Project = {
|
|||||||
// Estrich (schwimmend) → Trittschalldämmung.
|
// Estrich (schwimmend) → Trittschalldämmung.
|
||||||
{ componentId: "screed", thickness: 0.06 },
|
{ componentId: "screed", thickness: 0.06 },
|
||||||
// Innere Fuge der Dämmung trifft auf die Betondecke (massiv) → 0.13 mm.
|
// Innere Fuge der Dämmung trifft auf die Betondecke (massiv) → 0.13 mm.
|
||||||
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "joint-massive" },
|
{ componentId: "insulation", thickness: 0.04, jointLineStyleId: "thin" },
|
||||||
// Betondecke (Rohdecke, tragend) — innerste/unterste Schicht.
|
// Betondecke (Rohdecke, tragend) — innerste/unterste Schicht.
|
||||||
{ componentId: "concrete", thickness: 0.2 },
|
{ componentId: "concrete", thickness: 0.2 },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1098,6 +1098,33 @@ export interface Contour {
|
|||||||
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
|
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
|
||||||
*/
|
*/
|
||||||
filled?: boolean;
|
filled?: boolean;
|
||||||
|
/**
|
||||||
|
* Wahre Kurvengeometrie (aus CIRCLE/ARC). `pts` bleibt tesselliert (Kontext/
|
||||||
|
* 3D), aber der Drawing-Import baut daraus eine GLATTE `{shape:"circle"|"arc"}`-
|
||||||
|
* Form statt eines Vielecks. Winkel in RADIANT (CCW); Kreis = a0..a1 über 2π.
|
||||||
|
*/
|
||||||
|
curve?: {
|
||||||
|
kind: "circle" | "arc";
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
r: number;
|
||||||
|
a0: number;
|
||||||
|
a1: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein importiertes Text-Element (DXF TEXT/MTEXT). `at` = Einfügepunkt in Metern,
|
||||||
|
* `height` = Schrifthöhe in Modell-Metern, `angle` = Drehung in RADIANT (CCW).
|
||||||
|
* Wird beim Drawing-Import zu einem `{shape:"text"}`-Drawing2D.
|
||||||
|
*/
|
||||||
|
export interface ImportedText {
|
||||||
|
at: Vec2;
|
||||||
|
text: string;
|
||||||
|
height: number;
|
||||||
|
angle: number;
|
||||||
|
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
||||||
|
layer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
|
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ import type { AttributeSource } from "../model/types";
|
|||||||
import { usePanelHost } from "./host";
|
import { usePanelHost } from "./host";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { ColorHexField } from "../ui/ColorHexField";
|
import { ColorHexField } from "../ui/ColorHexField";
|
||||||
|
// Element-spezifische Attribut-Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) —
|
||||||
|
// aus ObjectInfoPanel hierher verschoben (Ribbon Phase 3: ObjectInfo trägt nur
|
||||||
|
// noch Bezugspunkt + Masse).
|
||||||
|
import {
|
||||||
|
WallSection,
|
||||||
|
CeilingSection,
|
||||||
|
OpeningSection,
|
||||||
|
StairSection,
|
||||||
|
RoomSection,
|
||||||
|
} from "./ObjectInfoPanel";
|
||||||
|
|
||||||
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
|
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
|
||||||
* „custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht
|
* „custom" ist abgeleitet aus „ist ein expliziter Wert gesetzt?", nicht
|
||||||
@@ -47,11 +57,38 @@ export function AttributesPanel() {
|
|||||||
const sel = host.selection;
|
const sel = host.selection;
|
||||||
const { project } = host;
|
const { project } = host;
|
||||||
|
|
||||||
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist.
|
// Wandtyp-/Deckentyp-Wahl für das NEUE Bauteil (was gezeichnet wird) — früher
|
||||||
|
// in der Werkzeug-Sidebar (ToolsPanel), seit deren Entfernung hier. Erscheint
|
||||||
|
// nur bei aktivem Wand-/Decken-Werkzeug (unabhängig von einer Auswahl); Wand und
|
||||||
|
// Decke teilen sich denselben Aufbau-Typ (WallType) + Host-State.
|
||||||
|
const drawingComponent =
|
||||||
|
host.activeTool === "wall" || host.activeTool === "ceiling";
|
||||||
|
const typeSection = drawingComponent ? (
|
||||||
|
<>
|
||||||
|
<div className="attr-section-label">{t("attr.newComponent")}</div>
|
||||||
|
<div className="attr-grid">
|
||||||
|
<span className="attr-key">
|
||||||
|
{t(host.activeTool === "wall" ? "tool.wallType" : "tool.ceilingType")}
|
||||||
|
</span>
|
||||||
|
<span className="attr-val">
|
||||||
|
<Dropdown
|
||||||
|
value={host.activeWallTypeId}
|
||||||
|
disabled={!host.toolsEnabled}
|
||||||
|
onChange={(v) => host.onActiveWallTypeId(v)}
|
||||||
|
options={project.wallTypes.map((wt) => ({ value: wt.id, label: wt.name }))}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Leerzustand: der Typ-Picker (falls ein Bauteil-Werkzeug aktiv ist), sonst ein
|
||||||
|
// kompakter Hinweis.
|
||||||
if (sel === null) {
|
if (sel === null) {
|
||||||
return (
|
return (
|
||||||
<div className="attr-panel">
|
<div className="attr-panel">
|
||||||
<div className="attr-empty">{t("attr.empty")}</div>
|
{typeSection}
|
||||||
|
{!typeSection && <div className="attr-empty">{t("attr.empty")}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +117,6 @@ export function AttributesPanel() {
|
|||||||
onChange: (next: UiSource) => void,
|
onChange: (next: UiSource) => void,
|
||||||
) => (
|
) => (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
width={104}
|
|
||||||
value={ui}
|
value={ui}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
onChange={(v) => onChange(v as UiSource)}
|
onChange={(v) => onChange(v as UiSource)}
|
||||||
@@ -110,7 +146,7 @@ export function AttributesPanel() {
|
|||||||
) => (
|
) => (
|
||||||
<>
|
<>
|
||||||
<span className="attr-key">{t(labelKey)}</span>
|
<span className="attr-key">{t(labelKey)}</span>
|
||||||
<span className="attr-val" style={{ gap: 6 }}>
|
<span className="attr-val">
|
||||||
{ui === "custom" && valueEditor}
|
{ui === "custom" && valueEditor}
|
||||||
{sourceSelect(editable, ui, onSource)}
|
{sourceSelect(editable, ui, onSource)}
|
||||||
</span>
|
</span>
|
||||||
@@ -159,11 +195,15 @@ export function AttributesPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attr-panel">
|
<div className="attr-panel">
|
||||||
<div className="attr-title">{t("attr.title")}</div>
|
{/* Typ-Picker für das neue Bauteil (nur bei aktivem Wand-/Decken-Werkzeug). */}
|
||||||
|
{typeSection}
|
||||||
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
|
{/* EIN durchgehendes Grid über alle Sektionen — dieselben zwei Spalten
|
||||||
<div className="attr-section-label">{t("attr.stroke")}</div>
|
(Label | Wert) für Stift/Füllung/Maße, damit die Wertfelder überall
|
||||||
|
bündig in EINER Spalte stehen. Sektion-Balken spannen beide Spalten. */}
|
||||||
<div className="attr-grid">
|
<div className="attr-grid">
|
||||||
|
{/* ── Stift/Strich ──────────────────────────────────────────── */}
|
||||||
|
<div className="attr-section-label">{t("attr.stroke")}</div>
|
||||||
|
|
||||||
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
|
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
|
||||||
<span className="attr-key">{t("attr.color")}</span>
|
<span className="attr-key">{t("attr.color")}</span>
|
||||||
<span className="attr-val">
|
<span className="attr-val">
|
||||||
@@ -208,7 +248,6 @@ export function AttributesPanel() {
|
|||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
|
host.onSetSelectionLineStyle(v === "__none__" ? undefined : v)
|
||||||
}
|
}
|
||||||
width={120}
|
|
||||||
options={[
|
options={[
|
||||||
{ value: "__none__", label: t("attr.lineStyle.none") },
|
{ value: "__none__", label: t("attr.lineStyle.none") },
|
||||||
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
|
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
|
||||||
@@ -218,14 +257,13 @@ export function AttributesPanel() {
|
|||||||
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
|
<span className="attr-readonly">{lineStyle ? lineStyle.name : "—"}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────────── */}
|
{/* ── Füllung (Vordergrund/Hintergrund + Schraffur) ─────────── */}
|
||||||
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/Poché.
|
{/* Vordergrund = Muster-/Schraffurfarbe, Hintergrund = Füllfarbe/
|
||||||
Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) / Nach Bauteil
|
Poché. Jedes Feld: Nach Ebene (LayerCategory erzwingt den Wert) /
|
||||||
(erbt vom Component-Manager, Default) / eigener Wert (Override). */}
|
Nach Bauteil (erbt vom Component-Manager, Default) / eigener Wert. */}
|
||||||
<div className="attr-section-label">{t("attr.fill")}</div>
|
<div className="attr-section-label">{t("attr.fill")}</div>
|
||||||
<div className="attr-grid">
|
|
||||||
{sourceRow(
|
{sourceRow(
|
||||||
"attr.foreground",
|
"attr.foreground",
|
||||||
pocheEditable,
|
pocheEditable,
|
||||||
@@ -248,7 +286,6 @@ export function AttributesPanel() {
|
|||||||
hatchUi,
|
hatchUi,
|
||||||
onHatchSourceChange,
|
onHatchSourceChange,
|
||||||
<Dropdown
|
<Dropdown
|
||||||
width={104}
|
|
||||||
value={sel.fillHatchId ?? ""}
|
value={sel.fillHatchId ?? ""}
|
||||||
onChange={(v) => host.onSetSelectionFill(v || null)}
|
onChange={(v) => host.onSetSelectionFill(v || null)}
|
||||||
options={[
|
options={[
|
||||||
@@ -257,16 +294,23 @@ export function AttributesPanel() {
|
|||||||
]}
|
]}
|
||||||
/>,
|
/>,
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */}
|
{/* ── Maße (informativ, aus der bbox) ───────────────────────── */}
|
||||||
<div className="attr-section-label">{t("attr.size")}</div>
|
<div className="attr-section-label">{t("attr.size")}</div>
|
||||||
<div className="attr-grid">
|
|
||||||
<span className="attr-key">{t("attr.width")}</span>
|
<span className="attr-key">{t("attr.width")}</span>
|
||||||
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
|
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
|
||||||
<span className="attr-key">{t("attr.height")}</span>
|
<span className="attr-key">{t("attr.height")}</span>
|
||||||
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
|
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Element-spezifische Abschnitte (aus ObjectInfo hierher verschoben) —
|
||||||
|
nur der jeweils passende erscheint. Nutzen weiter die `objinfo-*`-
|
||||||
|
Darstellung + die Host-Handler. */}
|
||||||
|
{sel.wall && <WallSection wall={sel.wall} host={host} />}
|
||||||
|
{sel.ceiling && <CeilingSection ceiling={sel.ceiling} host={host} />}
|
||||||
|
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
|
||||||
|
{sel.stair && <StairSection stair={sel.stair} host={host} />}
|
||||||
|
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,40 +82,14 @@ export function ObjectInfoPanel() {
|
|||||||
host.onResizeSelection(nextWidth, nextHeight, anchor);
|
host.onResizeSelection(nextWidth, nextHeight, anchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wall = sel.wall;
|
|
||||||
const ceiling = sel.ceiling;
|
|
||||||
const opening = sel.opening;
|
|
||||||
const stair = sel.stair;
|
|
||||||
const room = sel.room;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="objinfo-panel">
|
<div className="objinfo-panel">
|
||||||
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
|
{/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte
|
||||||
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
|
(Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel;
|
||||||
|
ObjectInfo trägt nur noch Bezugspunkt + Masse. */}
|
||||||
<div className="objinfo-head">
|
<div className="objinfo-head">
|
||||||
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
|
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
|
||||||
{wall ? (
|
<span className="objinfo-cat">{sel.categoryCode}</span>
|
||||||
<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>
|
||||||
|
|
||||||
<div className="objinfo-sep" />
|
<div className="objinfo-sep" />
|
||||||
@@ -174,21 +148,9 @@ export function ObjectInfoPanel() {
|
|||||||
value={height}
|
value={height}
|
||||||
onCommit={(h) => commitSize(width, h)}
|
onCommit={(h) => commitSize(width, h)}
|
||||||
/>
|
/>
|
||||||
|
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
|
||||||
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
|
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||||
{wall && <WallSection wall={wall} host={host} />}
|
die exportierten *Section-Komponenten). */}
|
||||||
|
|
||||||
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
|
|
||||||
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
|
|
||||||
|
|
||||||
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
|
|
||||||
{opening && <OpeningSection opening={opening} host={host} />}
|
|
||||||
|
|
||||||
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
|
|
||||||
{stair && <StairSection stair={stair} host={host} />}
|
|
||||||
|
|
||||||
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
|
|
||||||
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,7 +159,7 @@ export function ObjectInfoPanel() {
|
|||||||
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
|
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
|
||||||
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
|
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
|
||||||
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
|
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
|
||||||
function RoomSection({
|
export function RoomSection({
|
||||||
room,
|
room,
|
||||||
roomId,
|
roomId,
|
||||||
host,
|
host,
|
||||||
@@ -269,7 +231,7 @@ function RoomSection({
|
|||||||
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
|
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
|
||||||
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
||||||
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
||||||
function StairSection({
|
export function StairSection({
|
||||||
stair,
|
stair,
|
||||||
host,
|
host,
|
||||||
}: {
|
}: {
|
||||||
@@ -385,7 +347,7 @@ function StairSection({
|
|||||||
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
|
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
|
||||||
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
|
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
|
||||||
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
|
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
|
||||||
function OpeningSection({
|
export function OpeningSection({
|
||||||
opening,
|
opening,
|
||||||
host,
|
host,
|
||||||
}: {
|
}: {
|
||||||
@@ -557,7 +519,7 @@ function OpeningSection({
|
|||||||
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
|
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
|
||||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||||
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
|
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
|
||||||
function CeilingSection({
|
export function CeilingSection({
|
||||||
ceiling,
|
ceiling,
|
||||||
host,
|
host,
|
||||||
}: {
|
}: {
|
||||||
@@ -670,7 +632,7 @@ function CeilingSection({
|
|||||||
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
|
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
|
||||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||||
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
|
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
|
||||||
function WallSection({
|
export function WallSection({
|
||||||
wall,
|
wall,
|
||||||
host,
|
host,
|
||||||
}: {
|
}: {
|
||||||
@@ -701,6 +663,23 @@ function WallSection({
|
|||||||
<div className="objinfo-sep" />
|
<div className="objinfo-sep" />
|
||||||
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
|
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
|
||||||
|
|
||||||
|
{/* Referenzlinie (wo die Achse über die Dicke liegt) — früher im ObjectInfo-
|
||||||
|
Kopf, jetzt hier bei den Wand-Attributen. */}
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.wall.refLine")}</span>
|
||||||
|
<Dropdown
|
||||||
|
value={wall.referenceLine}
|
||||||
|
onChange={(v) => host.onSetWallReferenceLine(v as WallReferenceLine)}
|
||||||
|
options={[
|
||||||
|
{ value: "left", label: t("objinfo.wall.refLine.left") },
|
||||||
|
{ value: "center", label: t("objinfo.wall.refLine.center") },
|
||||||
|
{ value: "right", label: t("objinfo.wall.refLine.right") },
|
||||||
|
]}
|
||||||
|
title={t("objinfo.wall.refLine")}
|
||||||
|
width={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Aufbau-Typ-Segment. */}
|
{/* Aufbau-Typ-Segment. */}
|
||||||
<div className="objinfo-field">
|
<div className="objinfo-field">
|
||||||
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
|
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
|
||||||
|
|||||||
+18
-40
@@ -13,13 +13,12 @@ import { t } from "../i18n";
|
|||||||
import { usePanelHost } from "./host";
|
import { usePanelHost } from "./host";
|
||||||
import type { ToolId } from "./host";
|
import type { ToolId } from "./host";
|
||||||
import { TOOL_ORDER, getTool } from "../tools/tools";
|
import { TOOL_ORDER, getTool } from "../tools/tools";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
|
||||||
|
|
||||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||||
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
||||||
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
|
// (~16×16, stroke=currentColor, fill=none), damit das Icon die Textfarbe der
|
||||||
// Zeile übernimmt.
|
// Zeile übernimmt.
|
||||||
function ToolIcon({ id }: { id: ToolId }) {
|
export function ToolIcon({ id }: { id: ToolId }) {
|
||||||
const common = {
|
const common = {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
@@ -111,6 +110,20 @@ function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
<rect x="2.5" y="3.5" width="11" height="9" />
|
<rect x="2.5" y="3.5" width="11" height="9" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "circle":
|
||||||
|
// Kreis-Umriss.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="8" cy="8" r="5.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "arc":
|
||||||
|
// Offener Halbbogen.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -148,44 +161,9 @@ export function ToolsPanel() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="tools-sep" />
|
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
|
||||||
|
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
|
||||||
{/* Wandtyp-Auswahl — nur sichtbar, wenn das Wand-Werkzeug aktiv ist. */}
|
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
|
||||||
{host.activeTool === "wall" && (
|
|
||||||
<label className="tools-field">
|
|
||||||
<span>{t("tool.wallType")}</span>
|
|
||||||
<Dropdown
|
|
||||||
value={host.activeWallTypeId}
|
|
||||||
disabled={!host.toolsEnabled}
|
|
||||||
onChange={(v) => host.onActiveWallTypeId(v)}
|
|
||||||
options={host.project.wallTypes.map((wt) => ({
|
|
||||||
value: wt.id,
|
|
||||||
label: wt.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{host.activeTool === "wall" && <div className="tools-sep" />}
|
|
||||||
|
|
||||||
{/* Deckentyp-Auswahl — nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
|
|
||||||
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
|
|
||||||
{host.activeTool === "ceiling" && (
|
|
||||||
<label className="tools-field">
|
|
||||||
<span>{t("tool.ceilingType")}</span>
|
|
||||||
<Dropdown
|
|
||||||
value={host.activeWallTypeId}
|
|
||||||
disabled={!host.toolsEnabled}
|
|
||||||
onChange={(v) => host.onActiveWallTypeId(v)}
|
|
||||||
options={host.project.wallTypes.map((wt) => ({
|
|
||||||
value: wt.id,
|
|
||||||
label: wt.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{host.activeTool === "ceiling" && <div className="tools-sep" />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -31,7 +31,10 @@ import type {
|
|||||||
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
|
* der rechten unteren Gruppe. Das Hochzählen verwirft alt gespeicherte Layouts
|
||||||
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
|
* sauber auf den neuen Standard (kein Migrationspfad über die Strukturgrenze).
|
||||||
*/
|
*/
|
||||||
export const LAYOUT_VERSION = 7;
|
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
|
||||||
|
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
|
||||||
|
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
|
||||||
|
export const LAYOUT_VERSION = 8;
|
||||||
|
|
||||||
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
||||||
const CURRENT_KEY = "cad.layout";
|
const CURRENT_KEY = "cad.layout";
|
||||||
@@ -55,9 +58,12 @@ const NAMED_PREFIX = "cad.layouts.";
|
|||||||
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
|
* OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein
|
||||||
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
|
* schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht.
|
||||||
*/
|
*/
|
||||||
|
// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default-
|
||||||
|
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
|
||||||
|
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
|
||||||
|
// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe.
|
||||||
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
||||||
{ tabs: ["tools"], activeTab: "tools", weight: 1 },
|
{ tabs: ["attributes"], activeTab: "attributes", weight: 1 },
|
||||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
|
|
||||||
];
|
];
|
||||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||||
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
||||||
@@ -86,9 +92,10 @@ function cloneGroups(groups: DockGroup[]): DockGroup[] {
|
|||||||
// ── Standard ────────────────────────────────────────────────────────────────
|
// ── Standard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den eingebauten Startzustand: links zwei Gruppen (Werkzeuge oben,
|
* Liefert den eingebauten Startzustand: links das Attribute-Panel (volle Höhe),
|
||||||
* Zeichnungsebenen/Ebenen unten), rechts LEER. Jeder Aufruf erzeugt frische
|
* rechts Objektinfo/Raumbilanz oben + Zeichnungsebenen/Ebenen/Umgebung unten.
|
||||||
* Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
|
* Die Werkzeuge liegen im Ribbon (keine Sidebar mehr). Jeder Aufruf erzeugt
|
||||||
|
* frische Objekte/Arrays, damit Aufrufer das Ergebnis gefahrlos mutieren können.
|
||||||
*/
|
*/
|
||||||
export function defaultLayout(): LayoutState {
|
export function defaultLayout(): LayoutState {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+85
-1
@@ -131,6 +131,8 @@ export interface PlanViewHandle {
|
|||||||
applyScale: (n: number) => void;
|
applyScale: (n: number) => void;
|
||||||
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
|
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz der Command-Engine). */
|
||||||
pxPerMeter: () => number;
|
pxPerMeter: () => number;
|
||||||
|
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
|
||||||
|
viewCenterModel: () => Vec2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
|
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
|
||||||
@@ -631,6 +633,11 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
if (n > 0) setPaperScale(n);
|
if (n > 0) setPaperScale(n);
|
||||||
},
|
},
|
||||||
pxPerMeter: () => currentPxPerMeter(),
|
pxPerMeter: () => currentPxPerMeter(),
|
||||||
|
viewCenterModel: () => {
|
||||||
|
// Mitte der frischen viewBox (aus Ref) → Modell (inverse toScreen).
|
||||||
|
const v = viewRef.current;
|
||||||
|
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[],
|
[],
|
||||||
@@ -1640,7 +1647,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
|
// Raum-Stempel selbst (glyphon) — das SVG darf ihn NICHT zusätzlich
|
||||||
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
|
// zeichnen (sonst doppelter Text), also entfällt dort auch das Textoverlay.
|
||||||
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
|
// Sonst (reines SVG): alles außer den gebündelten 2D-Zeichenlinien.
|
||||||
(useGpuRenderer ? wantWasm || p.kind !== "text" : p.kind === "line" && p.drawingId) ? null : (
|
// `drawingText` (importierter Einzeltext) rendert IMMER im SVG — der GL-
|
||||||
|
// wie der WASM-Renderer zeichnet ihn nicht. Der Raum-Stempel (`text`)
|
||||||
|
// bleibt: unter WASM rastert ihn render2d selbst (sonst doppelt).
|
||||||
|
(useGpuRenderer
|
||||||
|
? wantWasm
|
||||||
|
? p.kind !== "drawingText"
|
||||||
|
: p.kind !== "text" && p.kind !== "drawingText"
|
||||||
|
: p.kind === "line" && p.drawingId)
|
||||||
|
? null : (
|
||||||
<PrimitiveShape
|
<PrimitiveShape
|
||||||
key={i}
|
key={i}
|
||||||
p={p}
|
p={p}
|
||||||
@@ -3065,5 +3080,74 @@ function renderPrimitive(
|
|||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case "drawingText": {
|
||||||
|
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
|
||||||
|
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
|
||||||
|
const c = toScreen(p.at);
|
||||||
|
const fontSize = p.heightM * PX_PER_M;
|
||||||
|
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
|
||||||
|
const deg = -(p.angle * 180) / Math.PI;
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={c.x}
|
||||||
|
y={c.y}
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontFamily="var(--font-ui, sans-serif)"
|
||||||
|
fill={p.color}
|
||||||
|
opacity={p.greyed ? 0.45 : undefined}
|
||||||
|
textAnchor="start"
|
||||||
|
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
|
||||||
|
style={{ pointerEvents: "none", userSelect: "none" }}
|
||||||
|
>
|
||||||
|
{p.text}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "drawingCircle": {
|
||||||
|
// Echtes `<circle>` — rund bei jedem Zoom (kein Vieleck). Füllung optional.
|
||||||
|
const c = toScreen(p.center);
|
||||||
|
const r = p.r * PX_PER_M;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
cx={c.x}
|
||||||
|
cy={c.y}
|
||||||
|
r={r}
|
||||||
|
fill={p.fill}
|
||||||
|
stroke={p.stroke}
|
||||||
|
strokeWidth={weight(p.weightMm)}
|
||||||
|
strokeDasharray={dashOf(p.dash)}
|
||||||
|
vectorEffect={vfx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "drawingArc": {
|
||||||
|
// Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist
|
||||||
|
// gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn
|
||||||
|
// (SVG sweep-flag 1). large-arc aus der Spannweite (>π).
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const rPx = p.r * PX_PER_M;
|
||||||
|
const from = toScreen({
|
||||||
|
x: p.center.x + p.r * Math.cos(p.a0),
|
||||||
|
y: p.center.y + p.r * Math.sin(p.a0),
|
||||||
|
});
|
||||||
|
const to = toScreen({
|
||||||
|
x: p.center.x + p.r * Math.cos(p.a1),
|
||||||
|
y: p.center.y + p.r * Math.sin(p.a1),
|
||||||
|
});
|
||||||
|
const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU;
|
||||||
|
const largeArc = sweep > Math.PI ? 1 : 0;
|
||||||
|
const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={p.stroke}
|
||||||
|
strokeWidth={weight(p.weightMm)}
|
||||||
|
strokeDasharray={dashOf(p.dash)}
|
||||||
|
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
|
||||||
|
vectorEffect={vfx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,3 +147,20 @@ describe("generatePlan — Tür-Typ wandoeffnung (Item 6)", () => {
|
|||||||
expect(arcCount(def)).toBe(1);
|
expect(arcCount(def)).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Zählt Fenster-Anschlag-/Laibungsstriche (cls "window-frame") im Plan. */
|
||||||
|
function windowRevealCount(p: Project, detail: "grob" | "mittel" | "fein") {
|
||||||
|
return generatePlan(p, "eg", visible, undefined, detail).primitives.filter(
|
||||||
|
(pr) => pr.kind === "line" && pr.cls === "window-frame",
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generatePlan — Fenster Anschlag-Striche (Item 8)", () => {
|
||||||
|
it("erzeugt bei 'fein' zwei Laibungsstriche (je Pfosten einen)", () => {
|
||||||
|
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "fein")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt bei 'mittel' KEINE (nur im Detailgrad fein)", () => {
|
||||||
|
expect(windowRevealCount(projectWithOpening({ ...baseWindow }), "mittel")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+152
-24
@@ -98,6 +98,10 @@ const MONO_INK = "#111111";
|
|||||||
const LAYER_LINE_MM = 0.02;
|
const LAYER_LINE_MM = 0.02;
|
||||||
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
|
/** Haarlinie für Tür-/Fenstersymbole (Flügel, Bogen, Rahmen, Glas) in mm. */
|
||||||
const SYMBOL_HAIRLINE_MM = 0.02;
|
const SYMBOL_HAIRLINE_MM = 0.02;
|
||||||
|
/** Strichmuster (mm Papier) für ÜBERKOPF-Ansichtslinien: Bauteile über der
|
||||||
|
* Grundriss-Schnittebene (Decken-/Slab-Überstände, Unterzüge) werden nach
|
||||||
|
* BIM-Konvention gestrichelt gezeichnet (Aufsicht auf ein Bauteil über einem). */
|
||||||
|
const OVERHEAD_DASH: number[] = [0.4, 0.25];
|
||||||
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
/** Stärke der Wand-Umrisslinie je Detailgrad, als Faktor auf die Ebenen-lw. */
|
||||||
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
|
const OUTLINE_DETAIL_FACTOR: Record<DetailLevel, number> = {
|
||||||
grob: 1.6, // dickere Sammellinie
|
grob: 1.6, // dickere Sammellinie
|
||||||
@@ -294,6 +298,52 @@ export type Primitive =
|
|||||||
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
|
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
greyed?: boolean;
|
greyed?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Schlichter, modellverankerter Einzeltext (aus Drawing2D `{shape:"text"}`,
|
||||||
|
// z. B. DXF-Import). Anders als `kind:"text"` (Raum-Stempel, RichText) trägt
|
||||||
|
// er nur einen String mit Höhe in Metern + Drehung — rein SVG-gerendert.
|
||||||
|
kind: "drawingText";
|
||||||
|
/** Ankerpunkt im Modell (Meter). */
|
||||||
|
at: Vec2;
|
||||||
|
text: string;
|
||||||
|
/** Schrifthöhe in Modell-Metern. */
|
||||||
|
heightM: number;
|
||||||
|
/** Drehung in RADIANT (CCW im Modell). */
|
||||||
|
angle: number;
|
||||||
|
color: string;
|
||||||
|
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
|
||||||
|
drawingId?: string;
|
||||||
|
greyed?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Glatter Kreis (aus Drawing2D `{shape:"circle"}`, z. B. DXF-CIRCLE) — als
|
||||||
|
// echtes `<circle>` gerendert (rund bei jedem Zoom), nicht als Vieleck.
|
||||||
|
kind: "drawingCircle";
|
||||||
|
center: Vec2;
|
||||||
|
/** Radius in Modell-Metern. */
|
||||||
|
r: number;
|
||||||
|
/** Vollton-Füllfarbe oder "none". */
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
weightMm: number;
|
||||||
|
dash?: number[] | null;
|
||||||
|
drawingId?: string;
|
||||||
|
greyed?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Glatter Bogen (aus Drawing2D `{shape:"arc"}`, z. B. DXF-ARC) — als
|
||||||
|
// SVG-Bogenpfad gerendert; Winkel in RADIANT (CCW im Modell).
|
||||||
|
kind: "drawingArc";
|
||||||
|
center: Vec2;
|
||||||
|
r: number;
|
||||||
|
a0: number;
|
||||||
|
a1: number;
|
||||||
|
stroke: string;
|
||||||
|
weightMm: number;
|
||||||
|
dash?: number[] | null;
|
||||||
|
drawingId?: string;
|
||||||
|
greyed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -592,13 +642,9 @@ export function generatePlan(
|
|||||||
for (const ceiling of ceilings) {
|
for (const ceiling of ceilings) {
|
||||||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||||||
const category = catByCode.get(ceiling.categoryCode);
|
const category = catByCode.get(ceiling.categoryCode);
|
||||||
const lwMm = resolveStrokeWeight(
|
// Kein lwMm mehr: der Decken-Umriss ist eine feste Überkopf-Haarlinie
|
||||||
ceiling.strokeWeight,
|
// (gestrichelt), unabhängig von der Kategorie-Strichstärke.
|
||||||
ceiling.strokeWeightSource,
|
addCeilingPoche(primitives, project, ceiling, greyed, detail, category, wallFootprints);
|
||||||
category,
|
|
||||||
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
|
|
||||||
);
|
|
||||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category, wallFootprints);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||||||
@@ -631,7 +677,14 @@ export function generatePlan(
|
|||||||
const openings = openingsOfWall(project, wall.id);
|
const openings = openingsOfWall(project, wall.id);
|
||||||
const gaps = wallGaps(wall, doors, openings);
|
const gaps = wallGaps(wall, doors, openings);
|
||||||
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
|
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
|
||||||
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory);
|
// Schnitt vs. Ansicht nach Schnitthöhe (BIM-Standard): erreicht die Wand die
|
||||||
|
// Grundriss-Schnittebene nicht (Höhe < Schnitthöhe des Geschosses, z. B. eine
|
||||||
|
// Brüstung/ein Podestrand), wird sie nur als Ansichts-Umriss gezeichnet —
|
||||||
|
// keine Schnitt-Poché. Normale Wände (Höhe ≥ Schnitthöhe) sind unberührt.
|
||||||
|
const wallViewOnly = wall.height < (floor?.cutHeight ?? 1.0);
|
||||||
|
addWallPoche(
|
||||||
|
primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm, wallCategory, wallViewOnly,
|
||||||
|
);
|
||||||
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
|
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
|
||||||
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
|
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
|
||||||
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
|
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
|
||||||
@@ -1000,20 +1053,48 @@ function addDrawing2D(
|
|||||||
seg(c4, c1);
|
seg(c4, c1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "circle": {
|
case "circle":
|
||||||
// Kreis als geschlossene Polygon-Approximation (N Segmente) — nutzt die
|
// Echtes Kreis-Primitiv → glattes `<circle>` (rund bei jedem Zoom) mit
|
||||||
// vorhandene Füll-/Strich-Pipeline, ohne ein eigenes Kreis-Primitiv.
|
// optionaler Vollton-Füllung. Ersetzt die frühere 64-Ecken-Approximation.
|
||||||
const N = 64;
|
out.push({
|
||||||
const pts: Vec2[] = [];
|
kind: "drawingCircle",
|
||||||
for (let i = 0; i < N; i++) {
|
center: g.center,
|
||||||
const t = (i / N) * Math.PI * 2;
|
r: g.r,
|
||||||
pts.push({ x: g.center.x + g.r * Math.cos(t), y: g.center.y + g.r * Math.sin(t) });
|
fill: background ?? d.fillColor ?? "none",
|
||||||
}
|
stroke: color,
|
||||||
fillPoly(pts);
|
weightMm,
|
||||||
for (let i = 0; i < N; i++) seg(pts[i], pts[(i + 1) % N]);
|
dash,
|
||||||
|
greyed,
|
||||||
|
drawingId: d.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "arc":
|
||||||
|
// Echtes Bogen-Primitiv → glatter SVG-Bogenpfad (Winkel in Radiant).
|
||||||
|
out.push({
|
||||||
|
kind: "drawingArc",
|
||||||
|
center: g.center,
|
||||||
|
r: g.r,
|
||||||
|
a0: g.a0,
|
||||||
|
a1: g.a1,
|
||||||
|
stroke: color,
|
||||||
|
weightMm,
|
||||||
|
dash,
|
||||||
|
greyed,
|
||||||
|
drawingId: d.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "text":
|
||||||
|
out.push({
|
||||||
|
kind: "drawingText",
|
||||||
|
at: g.at,
|
||||||
|
text: g.text,
|
||||||
|
heightM: g.height,
|
||||||
|
angle: g.angle,
|
||||||
|
color,
|
||||||
|
greyed,
|
||||||
|
drawingId: d.id,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
// arc/text: Primitive-Erweiterungen folgen später.
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1184,6 +1265,10 @@ function addWallPoche(
|
|||||||
detail: DetailLevel,
|
detail: DetailLevel,
|
||||||
wallLwMm: number,
|
wallLwMm: number,
|
||||||
category: LayerCategory | undefined,
|
category: LayerCategory | undefined,
|
||||||
|
// Wand liegt GANZ unter der Grundriss-Schnittebene (z. B. 30-cm-Brüstung bei
|
||||||
|
// 1 m Schnitthöhe) → „von oben gesehen": nur Ansichts-Umriss (Haarlinie), KEINE
|
||||||
|
// Schnitt-Poché/Schraffur/Schichten. Der reguläre Schnittfall bleibt `false`.
|
||||||
|
viewOnly: boolean,
|
||||||
): void {
|
): void {
|
||||||
const wt = getWallType(project, wall);
|
const wt = getWallType(project, wall);
|
||||||
const total = wallTypeThickness(wt);
|
const total = wallTypeThickness(wt);
|
||||||
@@ -1252,6 +1337,24 @@ function addWallPoche(
|
|||||||
const startCut = s <= 1e-6 ? cuts.startCut : null;
|
const startCut = s <= 1e-6 ? cuts.startCut : null;
|
||||||
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
|
const endCut = e >= axisLen - 1e-6 ? cuts.endCut : null;
|
||||||
|
|
||||||
|
if (viewOnly) {
|
||||||
|
// Ansichts-Wand (unter der Schnittebene): ein Umriss über die volle Dicke
|
||||||
|
// als Haarlinie, ohne Füllung/Schraffur. Segment-/Gehrungs-/Öffnungs-Logik
|
||||||
|
// bleibt dieselbe wie im Schnittfall (nur eben ohne Poché).
|
||||||
|
out.push({
|
||||||
|
kind: "polygon",
|
||||||
|
pts: clippedBand(p1, p2, refOff - total / 2, refOff + total / 2, startCut, endCut),
|
||||||
|
fill: "none",
|
||||||
|
stroke,
|
||||||
|
strokeWidthMm: layerLineMm,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
greyed,
|
||||||
|
noStrokeEdges: joinEdges(startCut, endCut),
|
||||||
|
wallId,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (detail === "grob") {
|
if (detail === "grob") {
|
||||||
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
|
// Eine Sammelfläche über die volle Dicke, ohne Schraffur, dicker Umriss.
|
||||||
out.push({
|
out.push({
|
||||||
@@ -1730,6 +1833,30 @@ function addOpeningSymbol(
|
|||||||
openingId: o.id,
|
openingId: o.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// fein: Anschlag-/Laibungsstriche an beiden Pfosten (kurz quer zur Wand) —
|
||||||
|
// analog zur Tür, damit Fenster im Detailplan gleich „fertig" wirken (bisher
|
||||||
|
// hatten nur Türen sie). Haarlinie, 6 cm Andeutung je Seite.
|
||||||
|
if (detail === "fein") {
|
||||||
|
const jambs = openingJambs(wall, o);
|
||||||
|
const dir = sub(wall.end, wall.start);
|
||||||
|
const len = Math.hypot(dir.x, dir.y);
|
||||||
|
if (jambs && len > 1e-9) {
|
||||||
|
const n = { x: -dir.y / len, y: dir.x / len };
|
||||||
|
const reveal = 0.06;
|
||||||
|
for (const jamb of [jambs.jambStart, jambs.jambEnd]) {
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: add(jamb, scale(n, reveal)),
|
||||||
|
b: add(jamb, scale(n, -reveal)),
|
||||||
|
cls: "window-frame",
|
||||||
|
weightMm: SYMBOL_HAIRLINE_MM,
|
||||||
|
color: o.color ?? POCHE_STROKE,
|
||||||
|
greyed,
|
||||||
|
openingId: o.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse.
|
// Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse.
|
||||||
// Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
|
// Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
|
||||||
// `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
|
// `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
|
||||||
@@ -1801,7 +1928,6 @@ function addCeilingPoche(
|
|||||||
ceiling: Ceiling,
|
ceiling: Ceiling,
|
||||||
greyed: boolean,
|
greyed: boolean,
|
||||||
detail: DetailLevel,
|
detail: DetailLevel,
|
||||||
lwMm: number,
|
|
||||||
category: LayerCategory | undefined,
|
category: LayerCategory | undefined,
|
||||||
wallFootprints: WallFootprint[],
|
wallFootprints: WallFootprint[],
|
||||||
): void {
|
): void {
|
||||||
@@ -1810,7 +1936,6 @@ function addCeilingPoche(
|
|||||||
const wt = getCeilingType(project, ceiling);
|
const wt = getCeilingType(project, ceiling);
|
||||||
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
|
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
|
||||||
const stroke = ceiling.color ?? POCHE_STROKE;
|
const stroke = ceiling.color ?? POCHE_STROKE;
|
||||||
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
|
|
||||||
const ceilingId = ceiling.id;
|
const ceilingId = ceiling.id;
|
||||||
|
|
||||||
// Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
|
// Gefüllte Fläche mit der Ansichts-Schraffur: die Decke wird im Grundriss
|
||||||
@@ -1871,7 +1996,10 @@ function addCeilingPoche(
|
|||||||
a: add(a, scale(d, t0)),
|
a: add(a, scale(d, t0)),
|
||||||
b: add(a, scale(d, t1)),
|
b: add(a, scale(d, t1)),
|
||||||
cls: "ceiling-outline",
|
cls: "ceiling-outline",
|
||||||
weightMm: outlineMm,
|
// Überkopf-Ansichtslinie (Decke liegt als Slab über der Schnittebene) →
|
||||||
|
// gestrichelte Haarlinie statt kräftiger Volllinie (BIM-Konvention).
|
||||||
|
weightMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||||
|
dash: OVERHEAD_DASH,
|
||||||
color: stroke,
|
color: stroke,
|
||||||
greyed,
|
greyed,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) });
|
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
|
||||||
}
|
}
|
||||||
strokePolyline(arcPts, false, color, strokeMm);
|
strokePolyline(arcPts, false, color, strokeMm);
|
||||||
|
} else if (prim.kind === 'drawingCircle' || prim.kind === 'drawingArc') {
|
||||||
|
// 2D-Zeichnungs-Kreis/-Bogen (Kreis-/Bogen-Werkzeug ODER DXF-Import). Der
|
||||||
|
// SVG-Pfad zeichnet sie als glattes <circle>/<path>; die WebGL-Ebene kennt
|
||||||
|
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
|
||||||
|
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
|
||||||
|
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
|
||||||
|
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1];
|
||||||
|
const strokeMm = prim.weightMm || 0.18;
|
||||||
|
const c = prim.center;
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const a0 = prim.kind === 'drawingArc' ? prim.a0 : 0;
|
||||||
|
const sweep =
|
||||||
|
prim.kind === 'drawingArc'
|
||||||
|
? ((prim.a1 - prim.a0) % TAU + TAU) % TAU || TAU
|
||||||
|
: TAU;
|
||||||
|
// Segmentzahl wie im 'arc'-Zweig: Sagitta < ~0.5 px (LOD über pxPerMeter).
|
||||||
|
let segs: number;
|
||||||
|
if (pxPerMeter > 0) {
|
||||||
|
const rPx = prim.r * pxPerMeter;
|
||||||
|
const theta = rPx > 0.5 ? 2 * Math.acos(1 - 0.5 / rPx) : Math.PI;
|
||||||
|
segs = Math.min(2048, Math.max(8, Math.ceil(sweep / theta)));
|
||||||
|
} else {
|
||||||
|
segs = Math.max(8, Math.ceil((sweep / (Math.PI / 2)) * 16));
|
||||||
|
}
|
||||||
|
const pts: Vec2[] = [];
|
||||||
|
for (let i = 0; i <= segs; i++) {
|
||||||
|
const t = a0 + (sweep * i) / segs;
|
||||||
|
pts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
|
||||||
|
}
|
||||||
|
// Vollton-Füllung nur beim geschlossenen Kreis (Bogen ist offen).
|
||||||
|
if (prim.kind === 'drawingCircle') {
|
||||||
|
const fill = parseColor(prim.fill);
|
||||||
|
if (fill) {
|
||||||
|
const ring = pts.slice(0, segs); // ohne den doppelten Schlusspunkt
|
||||||
|
const tris = triangulate(ring);
|
||||||
|
if (tris.length) {
|
||||||
|
const base = fillPos.length / 2;
|
||||||
|
for (const pt of ring) {
|
||||||
|
const s = toScreen(pt);
|
||||||
|
fillPos.push(s.x, s.y);
|
||||||
|
track(pt.x, pt.y);
|
||||||
|
}
|
||||||
|
for (const t of tris) fillIdx.push(base + t);
|
||||||
|
addFillBatch(tris.length, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strokePolyline(pts, prim.kind === 'drawingCircle', stroke, strokeMm);
|
||||||
}
|
}
|
||||||
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
|
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,7 +486,22 @@ export function planToRenderScene(plan: Plan, paperScaleN: number = STAMP_DEFAUL
|
|||||||
widthMm: p.weightMm,
|
widthMm: p.weightMm,
|
||||||
dash: p.dash ?? null,
|
dash: p.dash ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else if (p.kind === "drawingCircle" || p.kind === "drawingArc") {
|
||||||
|
// Der native Renderer hat kein Kreis-/Bogen-Primitiv → hier tessellieren
|
||||||
|
// (der SVG-Pfad zeichnet sie glatt). Kreis = voller Umlauf, Bogen = a0..a1.
|
||||||
|
flushRun();
|
||||||
|
const col = withOpacity(toRgba(p.stroke, 1) ?? [0.1, 0.1, 0.1, 1], undefined, p.greyed);
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const a0 = p.kind === "drawingArc" ? p.a0 : 0;
|
||||||
|
const sweep = p.kind === "drawingArc" ? (((p.a1 - p.a0) % TAU) + TAU) % TAU : TAU;
|
||||||
|
const N = Math.max(8, Math.ceil(sweep / (Math.PI / 32)));
|
||||||
|
const pts: RPoint[] = [];
|
||||||
|
for (let i = 0; i <= N; i++) {
|
||||||
|
const t = a0 + (sweep * i) / N;
|
||||||
|
pts.push([p.center.x + p.r * Math.cos(t), p.center.y + p.r * Math.sin(t)]);
|
||||||
|
}
|
||||||
|
polylines.push({ pts, color: col, widthMm: p.weightMm, dash: p.dash ?? null, z: zc++ });
|
||||||
|
} else if (p.kind === "text") {
|
||||||
// "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust-
|
// "text": zeilenweise flachen — die ECHTEN Glyphen rastert der Rust-
|
||||||
// Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie
|
// Renderer über einen GPU-Glyphen-Atlas (glyphon, dieselbe Font-Familie
|
||||||
// wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case
|
// wie im Browser). Layout identisch zum SVG-Pfad in PlanView (case
|
||||||
|
|||||||
+330
-25
@@ -109,6 +109,11 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
/* Dokument selbst NIE scrollen/bouncen: das Zoom-Wheel im Viewport darf die
|
||||||
|
App-Chrome nicht mitziehen (macOS-Overscroll/Rubberband). Interne Panels
|
||||||
|
scrollen weiterhin über ihre eigenen overflow-Container. */
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Native-App-Verhalten: KEINE Textauswahl / „Alles markieren" in der App-Chrome.
|
/* Native-App-Verhalten: KEINE Textauswahl / „Alles markieren" in der App-Chrome.
|
||||||
@@ -152,9 +157,9 @@ body {
|
|||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 6px 14px;
|
padding: 0 10px;
|
||||||
height: 60px;
|
height: 26px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
|
/* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten
|
||||||
nach unten → trennt sich klar von der Zeichenfläche (Tiefe). */
|
nach unten → trennt sich klar von der Zeichenfläche (Tiefe). */
|
||||||
@@ -169,6 +174,12 @@ body {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: transparent transparent;
|
scrollbar-color: transparent transparent;
|
||||||
}
|
}
|
||||||
|
/* Tauri/macOS: native Ampeln (titleBarStyle "Overlay") liegen oben links über
|
||||||
|
dem Inhalt → Zeile links einrücken, damit `dossier.` frei bleibt (~3 Ampeln +
|
||||||
|
Rand). */
|
||||||
|
.topbar.topbar-native-inset {
|
||||||
|
padding-left: 78px;
|
||||||
|
}
|
||||||
/* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen
|
/* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen
|
||||||
(`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */
|
(`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */
|
||||||
.topbar::-webkit-scrollbar {
|
.topbar::-webkit-scrollbar {
|
||||||
@@ -550,10 +561,12 @@ body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.brand-word {
|
.brand-word {
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
font-size: 15px;
|
font-size: 12px;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
text-transform: lowercase;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.brand-dot {
|
.brand-dot {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -1463,6 +1476,18 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mitte-Spalte: Viewport (.content) + Befehlszeile (.cmdline) darunter. Nimmt in
|
||||||
|
der horizontalen .body den Platz ZWISCHEN den Docks ein und stapelt vertikal —
|
||||||
|
so ist die Befehlszeile nur so breit wie der Viewport, und die Docks reichen
|
||||||
|
über die volle Body-Höhe (inkl. der Befehlszeilen-Zeile). */
|
||||||
|
.center-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dokument-Tabs „Zeichnungen/Blätter" — Kopfzeile über der Zeichenfläche.
|
/* Dokument-Tabs „Zeichnungen/Blätter" — Kopfzeile über der Zeichenfläche.
|
||||||
Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und
|
Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und
|
||||||
verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei
|
verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei
|
||||||
@@ -3892,6 +3917,64 @@ body {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Element-Abschnitte IM Attribute-Panel dem .attr-Grid-Look angleichen ──────
|
||||||
|
Die aus ObjectInfo verschobenen Sektionen (Wand/Decke/Öffnung/Treppe/Raum)
|
||||||
|
nutzen weiter die `objinfo-*`-Klassen; hier bekommen sie — NUR innerhalb
|
||||||
|
`.attr-panel` — denselben sauberen Look wie das obere Attribut-Grid: Sektion-
|
||||||
|
Balken, Zeilentrenner, einheitliche Label-Spalte, linksbündig füllende Felder.
|
||||||
|
Das eigenständige ObjectInfo-Panel (Bezugspunkt/Masse) bleibt unberührt. */
|
||||||
|
.attr-panel .objinfo-section-label {
|
||||||
|
color: var(--label);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 8px 0 2px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* Sektion-Balken trennen bereits → die dünnen Extra-Striche entfallen. */
|
||||||
|
.attr-panel .objinfo-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* Zeile: Label-Spalte | Wert/Control, mit dezentem Zeilentrenner (wie .attr-*). */
|
||||||
|
.attr-panel .objinfo-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 2px 2px 2px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.attr-panel .objinfo-flabel {
|
||||||
|
flex: 0 0 104px;
|
||||||
|
width: 104px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-right: 8px;
|
||||||
|
color: var(--label);
|
||||||
|
}
|
||||||
|
/* Werte/Controls füllen die Zelle linksbündig (statt rechtsbündig). */
|
||||||
|
.attr-panel .objinfo-fval {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.attr-panel .objinfo-input {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.attr-panel .objinfo-field .tb-dd-trigger {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
/* Segment-Umschalter + verschachtelte Felder füllen ebenfalls die Wert-Spalte. */
|
||||||
|
.attr-panel .objinfo-segment,
|
||||||
|
.attr-panel .objinfo-subfield {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Wand-Abschnitt (Object-Info) ─────────────────────────────────────── */
|
/* ── Wand-Abschnitt (Object-Info) ─────────────────────────────────────── */
|
||||||
/* Referenzlinie-Dropdown rechts oben im Kopf. */
|
/* Referenzlinie-Dropdown rechts oben im Kopf. */
|
||||||
.objinfo-head-ref {
|
.objinfo-head-ref {
|
||||||
@@ -3962,10 +4045,6 @@ body {
|
|||||||
|
|
||||||
/* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */
|
/* ── Attribute-Palette (AttributesPanel) ─────────────────────────────── */
|
||||||
.attr-panel {
|
.attr-panel {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -3976,44 +4055,66 @@ body {
|
|||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.attr-title {
|
/* OCS-artiges Eigenschaften-Grid: EIN durchgehendes Zwei-Spalten-Grid über alle
|
||||||
font-weight: 600;
|
Sektionen (Label | Wert), damit die Wertfelder über Stift/Füllung/Maße hinweg
|
||||||
color: var(--label);
|
in EINER Spalte bündig stehen. Sektion-Balken spannen beide Spalten. Zeilen mit
|
||||||
padding: 0 2px 4px;
|
dezenten Trennlinien; Wertfelder füllen ihre Zelle linksbündig über die volle
|
||||||
}
|
Panel-Breite — einheitlicher Look für Texte/Zahlen/Dropdowns. */
|
||||||
.attr-section-label {
|
.attr-section-label {
|
||||||
|
grid-column: 1 / -1;
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
margin-top: 6px;
|
margin: 8px 0 2px;
|
||||||
padding: 0 2px;
|
padding: 3px 6px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.attr-section-label:first-child {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.attr-grid {
|
.attr-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: minmax(80px, max-content) 1fr;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 4px 10px;
|
gap: 0;
|
||||||
padding: 2px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.attr-key {
|
.attr-key {
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px 3px 2px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.attr-val {
|
.attr-val {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
/* Wertfelder (Dropdown-Trigger, Zahlenfeld) füllen die Wert-Spalte — der
|
||||||
|
Dropdown wächst in die verfügbare Breite (kein fixes width mehr im TSX). */
|
||||||
|
.attr-val .tb-dd-trigger {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.attr-readonly {
|
.attr-readonly {
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
.attr-num,
|
.attr-num,
|
||||||
.attr-select {
|
.attr-select {
|
||||||
width: 110px;
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
color: var(--label);
|
color: var(--label);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -5280,15 +5381,16 @@ body {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-left: 6px;
|
/* Ganz links in der randlosen Titelleiste (macOS-Konvention). */
|
||||||
|
margin-right: 4px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.win-btn {
|
.win-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 22px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -5305,3 +5407,206 @@ body {
|
|||||||
background: #e5484d;
|
background: #e5484d;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Ribbon (2D · 3D · BIM · Ansichten) ────────────────────────────────────
|
||||||
|
Die Tab-REITER (`.ribbon-tabs`) sitzen in der TopBar-Zeile; hier folgt nur der
|
||||||
|
INHALT (`.ribbon`/`.ribbon-body`) des aktiven Tabs darunter. So gibt es genau
|
||||||
|
zwei Bänder: TopBar-Zeile (Chrome + Tabs) oben, Ribbon-Inhalt darunter. */
|
||||||
|
.ribbon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
/* Tab-Reiter — leben in der TopBar-Zeile: volle Zeilenhöhe, Aktiv-Unterstrich
|
||||||
|
sitzt an der Unterkante (verbindet optisch mit dem Inhalt darunter). */
|
||||||
|
.ribbon-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ribbon-tab {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick-Access-Leiste (OCS-Stil) — kleine Icon-Knöpfe in der TopBar-Zeile. */
|
||||||
|
.tb-qa {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.tb-qa-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.tb-qa-btn:hover:not(:disabled) {
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--label);
|
||||||
|
}
|
||||||
|
.tb-qa-btn.active {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.tb-qa-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.tb-qa-btn .tb-ico {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.tb-qa-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 15px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 0 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.ribbon-tab:hover {
|
||||||
|
color: var(--label);
|
||||||
|
}
|
||||||
|
.ribbon-tab.active {
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-color: var(--accent-border, var(--accent));
|
||||||
|
}
|
||||||
|
.ribbon-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ribbon-body::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
.ribbon-body::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.ribbon-body:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
/* Eine Gruppe: Buttons oben, kleiner Gruppentitel darunter; rechts ein dezenter
|
||||||
|
Trenner (wie OCS-Ribbon-Gruppen). */
|
||||||
|
.ribbon-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.ribbon-group:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.ribbon-items {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ribbon-group-title {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
/* Ein Ribbon-Button: Icon oben, Label darunter (kompakt, wie eine Palette). */
|
||||||
|
.ribbon-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--label);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
width: 52px;
|
||||||
|
padding: 4px 2px 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.15;
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.ribbon-btn span {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ribbon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
.ribbon-btn.active {
|
||||||
|
background: var(--accent-dim, var(--panel-2));
|
||||||
|
border-color: var(--accent-border, var(--accent));
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.ribbon-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ribbon-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
/* Custom-Bar-Picker („+ Hinzufügen") — kompakter, unauffälliger Trigger, der
|
||||||
|
sich neben die Custom-Buttons stellt. */
|
||||||
|
.ribbon-add.tb-dd-trigger {
|
||||||
|
align-self: center;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.ribbon-add.tb-dd-trigger:hover {
|
||||||
|
color: var(--label);
|
||||||
|
}
|
||||||
|
/* „Ansichten"-Tab: die aus der TopBar gemergten Cluster (View-Grid, Kombis,
|
||||||
|
Detail, Zoom, Darstellung) in EINER Zeile — nutzt die bestehenden `.tb-*`-
|
||||||
|
Stile unverändert weiter. */
|
||||||
|
.ribbon-views {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
|
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
|
||||||
import { DEFAULT_SNAP } from "./types";
|
import { DEFAULT_SNAP } from "./types";
|
||||||
import type { Project, Vec2, Wall } from "../model/types";
|
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||||
|
|
||||||
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
|
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
|
||||||
function wallProject(wall: Wall): Project {
|
function wallProject(wall: Wall): Project {
|
||||||
@@ -134,3 +134,95 @@ describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
|
|||||||
expect(result!.point.y).toBeCloseTo(0.025, 6);
|
expect(result!.point.y).toBeCloseTo(0.025, 6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Kreis-/Bogen-Snaps (Center/Quadrant/Bogen-Endpunkt) ──────────────────────
|
||||||
|
|
||||||
|
/** Minimalprojekt mit gegebenen 2D-Geometrien auf dem Geschoss „eg". */
|
||||||
|
function drawProject(geoms: Drawing2D["geom"][]): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{
|
||||||
|
id: "eg",
|
||||||
|
name: "EG",
|
||||||
|
kind: "floor",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
floorHeight: 2.6,
|
||||||
|
cutHeight: 1.0,
|
||||||
|
baseElevation: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: geoms.map((geom, i) => ({
|
||||||
|
id: `d${i}`,
|
||||||
|
type: "drawing2d" as const,
|
||||||
|
levelId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
geom,
|
||||||
|
})),
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapAt(project: Project, raw: Vec2, settings = DEFAULT_SNAP) {
|
||||||
|
return computeSnap({
|
||||||
|
raw,
|
||||||
|
project,
|
||||||
|
levelId: "eg",
|
||||||
|
visibleCodes: new Set(["20"]),
|
||||||
|
settings,
|
||||||
|
draftPoints: [],
|
||||||
|
lastPoint: null,
|
||||||
|
pxPerMeter: 100,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeSnap — Kreis/Bogen", () => {
|
||||||
|
const withCenter = { ...DEFAULT_SNAP, center: true };
|
||||||
|
|
||||||
|
it("fängt Mittelpunkt + Quadranten eines Kreises (center aktiv)", () => {
|
||||||
|
const p = drawProject([{ shape: "circle", center: { x: 0, y: 0 }, r: 1 }]);
|
||||||
|
const c = snapAt(p, { x: 0, y: 0 }, withCenter);
|
||||||
|
expect(c?.kind).toBe("center");
|
||||||
|
expect(c!.point.x).toBeCloseTo(0, 6);
|
||||||
|
expect(c!.point.y).toBeCloseTo(0, 6);
|
||||||
|
const q = snapAt(p, { x: 1, y: 0 }, withCenter);
|
||||||
|
expect(q?.kind).toBe("quadrant");
|
||||||
|
expect(q!.point.x).toBeCloseTo(1, 6);
|
||||||
|
expect(q!.point.y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fängt Bogen-Endpunkte (endpoint per Default aktiv)", () => {
|
||||||
|
// Viertelbogen um (5,0), r=1, 0…90° → Endpunkte (6,0) und (5,1).
|
||||||
|
const p = drawProject([
|
||||||
|
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
|
||||||
|
]);
|
||||||
|
const e = snapAt(p, { x: 6, y: 0 });
|
||||||
|
expect(e?.kind).toBe("endpoint");
|
||||||
|
expect(e!.point.x).toBeCloseTo(6, 6);
|
||||||
|
expect(e!.point.y).toBeCloseTo(0, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bietet Quadranten AUSSERHALB der Bogen-Spanne NICHT an", () => {
|
||||||
|
// Viertelbogen 0…90°: der West-Quadrant (4,0) liegt NICHT im Bogen.
|
||||||
|
const p = drawProject([
|
||||||
|
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
|
||||||
|
]);
|
||||||
|
const r = snapAt(p, { x: 4, y: 0 }, withCenter);
|
||||||
|
expect(r?.kind).not.toBe("quadrant");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -108,6 +108,28 @@ function collectSegments(input: SnapInput): Seg[] {
|
|||||||
return segs;
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kreis-/Bogen-Geometrie für Center-/Quadrant-/Endpunkt-Snaps. `arc=null` =
|
||||||
|
* Vollkreis (keine Endpunkte, alle vier Quadranten). */
|
||||||
|
interface CircleGeom {
|
||||||
|
center: Vec2;
|
||||||
|
r: number;
|
||||||
|
arc: { a0: number; a1: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sammelt sichtbare Kreise/Bögen des aktiven Geschosses (aus `drawings2d`). */
|
||||||
|
function collectCircles(input: SnapInput): CircleGeom[] {
|
||||||
|
const out: CircleGeom[] = [];
|
||||||
|
for (const d of input.project.drawings2d) {
|
||||||
|
if (d.levelId !== input.levelId) continue;
|
||||||
|
if (!input.visibleCodes.has(d.categoryCode)) continue;
|
||||||
|
const g = d.geom;
|
||||||
|
if (g.shape === "circle") out.push({ center: g.center, r: g.r, arc: null });
|
||||||
|
else if (g.shape === "arc")
|
||||||
|
out.push({ center: g.center, r: g.r, arc: { a0: g.a0, a1: g.a1 } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
|
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
|
||||||
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||||
const dx = b.x - a.x, dy = b.y - a.y;
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
@@ -184,6 +206,34 @@ export function computeSnap(input: SnapInput): SnapResult | null {
|
|||||||
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
|
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kreis-/Bogen-Snaps: Mittelpunkt + Quadranten (Ost/Nord/West/Süd); bei Bögen
|
||||||
|
// nur die Quadranten IM Bogen-Bereich, plus die beiden Bogen-Endpunkte. Center/
|
||||||
|
// Quadrant hängen an der `center`-Einstellung, die Endpunkte an `endpoint`.
|
||||||
|
if (settings.center || settings.endpoint) {
|
||||||
|
const TAU = Math.PI * 2;
|
||||||
|
const norm = (x: number) => ((x % TAU) + TAU) % TAU;
|
||||||
|
const onCircle = (c: CircleGeom, ang: number): Vec2 => ({
|
||||||
|
x: c.center.x + c.r * Math.cos(ang),
|
||||||
|
y: c.center.y + c.r * Math.sin(ang),
|
||||||
|
});
|
||||||
|
for (const c of collectCircles(input)) {
|
||||||
|
if (settings.center) {
|
||||||
|
consider(c.center, "center");
|
||||||
|
// Volle CCW-Spanne (0 ⇒ Vollkreis); Quadrant nur, wenn im Bogen liegt.
|
||||||
|
const sweep = c.arc ? norm(c.arc.a1 - c.arc.a0) || TAU : TAU;
|
||||||
|
for (let q = 0; q < 4; q++) {
|
||||||
|
const ang = q * (Math.PI / 2);
|
||||||
|
if (c.arc && norm(ang - c.arc.a0) > sweep + 1e-9) continue;
|
||||||
|
consider(onCircle(c, ang), "quadrant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.endpoint && c.arc) {
|
||||||
|
consider(onCircle(c, c.arc.a0), "endpoint");
|
||||||
|
consider(onCircle(c, c.arc.a1), "endpoint");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (best) return best;
|
if (best) return best;
|
||||||
|
|
||||||
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
|
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
|
||||||
|
|||||||
@@ -526,6 +526,34 @@ const rectTool: Tool = {
|
|||||||
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
|
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kreis: Platzhalter-Werkzeug (analog window/door), gekoppelt an den
|
||||||
|
// `circle`-Befehl (TOOL_COMMAND in App.tsx). Der eigentliche Ablauf
|
||||||
|
// (Mittelpunkt → Radius) läuft über die Command-Engine — EIN Pfad für
|
||||||
|
// Toolbar-Klick und getipptes „circle". Nicht floorOnly (freie 2D-Zeichnung).
|
||||||
|
const circleTool: Tool = {
|
||||||
|
id: "circle",
|
||||||
|
labelKey: "tool.circle",
|
||||||
|
hintKey: () => "tool.circle.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bogen: Platzhalter-Werkzeug, gekoppelt an den `arc`-Befehl (3 Klicks:
|
||||||
|
// Mitte → Start/Radius → Ende). Wie Kreis nicht floorOnly.
|
||||||
|
const arcTool: Tool = {
|
||||||
|
id: "arc",
|
||||||
|
labelKey: "tool.arc",
|
||||||
|
hintKey: () => "tool.arc.hint",
|
||||||
|
init: () => ({ phase: "idle" }),
|
||||||
|
onClick: (s) => [s, { draft: null }],
|
||||||
|
onMove: (s) => [s, { draft: null }],
|
||||||
|
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||||
|
onCancel: (s) => [s, { draft: null, done: true }],
|
||||||
|
};
|
||||||
|
|
||||||
// ── Registry ─────────────────────────────────────────────────────────────────
|
// ── Registry ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TOOLS: Record<ToolId, Tool> = {
|
const TOOLS: Record<ToolId, Tool> = {
|
||||||
@@ -539,6 +567,8 @@ const TOOLS: Record<ToolId, Tool> = {
|
|||||||
line: lineTool,
|
line: lineTool,
|
||||||
polyline: polylineTool,
|
polyline: polylineTool,
|
||||||
rect: rectTool,
|
rect: rectTool,
|
||||||
|
circle: circleTool,
|
||||||
|
arc: arcTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Liefert das Werkzeug zur ID. */
|
/** Liefert das Werkzeug zur ID. */
|
||||||
@@ -558,4 +588,6 @@ export const TOOL_ORDER: ToolId[] = [
|
|||||||
"line",
|
"line",
|
||||||
"polyline",
|
"polyline",
|
||||||
"rect",
|
"rect",
|
||||||
|
"circle",
|
||||||
|
"arc",
|
||||||
];
|
];
|
||||||
|
|||||||
+3
-1
@@ -18,7 +18,9 @@ export type ToolId =
|
|||||||
| "room"
|
| "room"
|
||||||
| "line"
|
| "line"
|
||||||
| "polyline"
|
| "polyline"
|
||||||
| "rect";
|
| "rect"
|
||||||
|
| "circle"
|
||||||
|
| "arc";
|
||||||
|
|
||||||
// ── Snapping ───────────────────────────────────────────────────────────────
|
// ── Snapping ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+52
-3
@@ -21,6 +21,7 @@ import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
|
|||||||
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
|
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
|
||||||
import {
|
import {
|
||||||
contoursToDrawings,
|
contoursToDrawings,
|
||||||
|
textsToDrawings,
|
||||||
countContours,
|
countContours,
|
||||||
distinctLayers,
|
distinctLayers,
|
||||||
} from "../io/dxfToDrawings";
|
} from "../io/dxfToDrawings";
|
||||||
@@ -35,6 +36,12 @@ export interface ImportDecision {
|
|||||||
categoryMode: CategoryMode;
|
categoryMode: CategoryMode;
|
||||||
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
|
/** Ob die Meshes als Kontext-Geometrie übernommen werden. */
|
||||||
importMeshes: boolean;
|
importMeshes: boolean;
|
||||||
|
/**
|
||||||
|
* Platzierung der importierten Geometrie: `origin` = 1:1 an den DXF-Koordinaten
|
||||||
|
* (relativ zum Nullpunkt); `viewCenter` = der Gesamt-Umriss wird in die Mitte
|
||||||
|
* des aktuellen Ausschnitts geschoben.
|
||||||
|
*/
|
||||||
|
placement: "origin" | "viewCenter";
|
||||||
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
|
/** Die geparsten Konturen-Sätze (für die spätere 2D-Konvertierung). */
|
||||||
parsed: DxfImportResult;
|
parsed: DxfImportResult;
|
||||||
}
|
}
|
||||||
@@ -107,9 +114,11 @@ export function ImportDialog({
|
|||||||
|
|
||||||
const contourSets = parsed?.contours ?? [];
|
const contourSets = parsed?.contours ?? [];
|
||||||
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
|
const meshes: ImportedMesh[] = parsed?.meshes ?? [];
|
||||||
|
const texts = parsed?.texts ?? [];
|
||||||
const contourCount = countContours(contourSets);
|
const contourCount = countContours(contourSets);
|
||||||
|
const textCount = texts.length;
|
||||||
const layers = distinctLayers(contourSets);
|
const layers = distinctLayers(contourSets);
|
||||||
const hasDrawings = contourCount > 0;
|
const hasDrawings = contourCount > 0 || textCount > 0;
|
||||||
const hasMeshes = meshes.length > 0;
|
const hasMeshes = meshes.length > 0;
|
||||||
|
|
||||||
// ── Formular-Zustand ──────────────────────────────────────────────────────
|
// ── Formular-Zustand ──────────────────────────────────────────────────────
|
||||||
@@ -124,6 +133,7 @@ export function ImportDialog({
|
|||||||
layers.length > 0 ? "byLayer" : "active",
|
layers.length > 0 ? "byLayer" : "active",
|
||||||
);
|
);
|
||||||
const [importMeshes, setImportMeshes] = useState<boolean>(true);
|
const [importMeshes, setImportMeshes] = useState<boolean>(true);
|
||||||
|
const [placement, setPlacement] = useState<"origin" | "viewCenter">("origin");
|
||||||
|
|
||||||
const canImport =
|
const canImport =
|
||||||
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
|
!isDwg && !loading && (hasDrawings || (hasMeshes && importMeshes));
|
||||||
@@ -134,7 +144,7 @@ export function ImportDialog({
|
|||||||
targetKind === "existing"
|
targetKind === "existing"
|
||||||
? ({ kind: "existing", levelId: existingLevelId } as const)
|
? ({ kind: "existing", levelId: existingLevelId } as const)
|
||||||
: ({ kind: "new", name: newName } as const);
|
: ({ kind: "new", name: newName } as const);
|
||||||
onImport({ target, categoryMode, importMeshes, parsed });
|
onImport({ target, categoryMode, importMeshes, placement, parsed });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -187,6 +197,9 @@ export function ImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
<ul className="imp-summary">
|
<ul className="imp-summary">
|
||||||
<li>{t("import.summary.contours", { n: contourCount })}</li>
|
<li>{t("import.summary.contours", { n: contourCount })}</li>
|
||||||
|
{textCount > 0 && (
|
||||||
|
<li>{t("import.summary.texts", { n: textCount })}</li>
|
||||||
|
)}
|
||||||
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
|
<li>{t("import.summary.meshes", { n: meshes.length })}</li>
|
||||||
<li>
|
<li>
|
||||||
{layers.length > 0
|
{layers.length > 0
|
||||||
@@ -289,6 +302,34 @@ export function ImportDialog({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Platzierung: an den DXF-Koordinaten (relativ zu 0) ODER in die
|
||||||
|
Mitte des aktuellen Ausschnitts geschoben. */}
|
||||||
|
{hasDrawings && (
|
||||||
|
<section className="imp-section">
|
||||||
|
<div className="imp-section-title">
|
||||||
|
{t("import.placement.heading")}
|
||||||
|
</div>
|
||||||
|
<label className="imp-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imp-place"
|
||||||
|
checked={placement === "origin"}
|
||||||
|
onChange={() => setPlacement("origin")}
|
||||||
|
/>
|
||||||
|
<span>{t("import.placement.origin")}</span>
|
||||||
|
</label>
|
||||||
|
<label className="imp-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="imp-place"
|
||||||
|
checked={placement === "viewCenter"}
|
||||||
|
onChange={() => setPlacement("viewCenter")}
|
||||||
|
/>
|
||||||
|
<span>{t("import.placement.viewCenter")}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mesh-Behandlung. */}
|
{/* Mesh-Behandlung. */}
|
||||||
{hasMeshes && (
|
{hasMeshes && (
|
||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
@@ -348,11 +389,19 @@ export function buildDrawings(
|
|||||||
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
|
for (const c of project.layers) byName.set(c.name.toLowerCase(), c.code);
|
||||||
const layerToCode = (layerName: string): string =>
|
const layerToCode = (layerName: string): string =>
|
||||||
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
|
byName.get(layerName.toLowerCase()) ?? activeCategoryCode;
|
||||||
return contoursToDrawings(
|
const fromContours = contoursToDrawings(
|
||||||
decision.parsed.contours,
|
decision.parsed.contours,
|
||||||
levelId,
|
levelId,
|
||||||
decision.categoryMode,
|
decision.categoryMode,
|
||||||
activeCategoryCode,
|
activeCategoryCode,
|
||||||
layerToCode,
|
layerToCode,
|
||||||
);
|
);
|
||||||
|
const fromTexts = textsToDrawings(
|
||||||
|
decision.parsed.texts ?? [],
|
||||||
|
levelId,
|
||||||
|
decision.categoryMode,
|
||||||
|
activeCategoryCode,
|
||||||
|
layerToCode,
|
||||||
|
);
|
||||||
|
return [...fromContours, ...fromTexts];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1847,7 +1847,8 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
angle: p.families[0]?.angle ?? 45,
|
angle: p.families[0]?.angle ?? 45,
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
lineStyleId: "hatch-line",
|
// Kanonische Volllinie 0.13 (früher "hatch-line", zusammengeführt auf "thin").
|
||||||
|
lineStyleId: "thin",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+328
-210
@@ -15,6 +15,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Dropdown } from "./Dropdown";
|
import { Dropdown } from "./Dropdown";
|
||||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||||
import { WindowControls } from "./WindowControls";
|
import { WindowControls } from "./WindowControls";
|
||||||
|
import { RibbonTabs } from "./ribbon/RibbonBar";
|
||||||
|
import type { RibbonTabId } from "./ribbon/ribbonItems";
|
||||||
import {
|
import {
|
||||||
applyMark,
|
applyMark,
|
||||||
applyPreset,
|
applyPreset,
|
||||||
@@ -84,49 +86,17 @@ const ZOOM_CLUSTER_WIDTH = 140;
|
|||||||
export interface TopBarProps {
|
export interface TopBarProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
||||||
// Ansicht.
|
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
|
||||||
/** Ob der Ansichts-Umschalter sinnvoll ist (nur für Geschosse). */
|
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
|
||||||
viewToggleEnabled: boolean;
|
ribbonTab: RibbonTabId;
|
||||||
viewType: ViewType;
|
onRibbonTab: (id: RibbonTabId) => void;
|
||||||
onViewType: (v: ViewType) => void;
|
|
||||||
|
|
||||||
// 3D-Ansicht: kanonischer Blickwinkel + Kamera-Sichtwinkel (FOV). Wirkt nur in
|
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
|
||||||
// der Perspektive; ein Preset-Klick wechselt nötigenfalls auch dorthin.
|
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
|
||||||
view3d: View3d;
|
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
|
||||||
onView3d: (v: View3d) => void;
|
|
||||||
/** Sichtwinkel der Perspektivkamera in Grad (Kamera-Einstellungen). */
|
|
||||||
fov: number;
|
|
||||||
onFov: (n: number) => void;
|
|
||||||
|
|
||||||
// Zeichenwerkzeuge (Auswahl/Wand/Linie …), Wandtyp-Wahl und Fang-Menü leben
|
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
||||||
// jetzt in der Werkzeug-Palette (ToolsPanel), nicht mehr in der Oberleiste.
|
|
||||||
|
|
||||||
// Detailgrad.
|
|
||||||
detail: DetailLevel;
|
|
||||||
onDetail: (d: DetailLevel) => void;
|
|
||||||
/** Detailgrad wirkt nur, wenn ein Plan gezeichnet wird (sonst deaktiviert). */
|
|
||||||
detailEnabled: boolean;
|
|
||||||
|
|
||||||
// Massstab + Zoom.
|
|
||||||
/**
|
|
||||||
* Gewählter (angewandter) Papier-Massstab-Nenner (1:N) — steuert das Dropdown
|
|
||||||
* und „100 %". Setzt beim Wählen den Plan-Zoom auf exakt 1:N.
|
|
||||||
*/
|
|
||||||
scaleDenominator: number;
|
|
||||||
/** Einen Papier-Massstab anwenden (Preset/frei): setzt N + Plan-Zoom. */
|
|
||||||
onApplyScale: (n: number) => void;
|
|
||||||
/**
|
|
||||||
* LIVE Papier-Massstab-Nenner aus der aktuellen View-Transform (ändert sich
|
|
||||||
* beim Wheel-Zoom) oder `null`, wenn keine Plan-Ansicht aktiv ist.
|
|
||||||
*/
|
|
||||||
liveScale: number | null;
|
|
||||||
/** Live-Zoom in Prozent (aus der Plan-View-Transform). */
|
|
||||||
zoomPercent: number;
|
|
||||||
/** Zoom-Aktionen — nur wirksam, wenn eine Plan-Ansicht aktiv ist. */
|
|
||||||
zoomEnabled: boolean;
|
zoomEnabled: boolean;
|
||||||
onFit: () => void;
|
|
||||||
onFitSelection: () => void;
|
|
||||||
onZoom100: () => void;
|
|
||||||
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||||
@@ -134,21 +104,6 @@ export interface TopBarProps {
|
|||||||
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
|
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
|
||||||
onExportSchedule: () => void;
|
onExportSchedule: () => void;
|
||||||
|
|
||||||
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
|
|
||||||
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
|
|
||||||
renderMode: RenderMode;
|
|
||||||
onRenderMode: (m: RenderMode) => void;
|
|
||||||
/** Render-Modus nur in der Perspektive sinnvoll (sonst deaktiviert). */
|
|
||||||
renderModeEnabled: boolean;
|
|
||||||
|
|
||||||
// Farb-Modus des Grundrisses (Farbig ↔ Schwarz-Weiss). Nur im Grundriss.
|
|
||||||
planColorMode: PlanColorMode;
|
|
||||||
onPlanColorMode: (m: PlanColorMode) => void;
|
|
||||||
|
|
||||||
// Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen).
|
|
||||||
/** Handler-Satz für die beiden Kombinations-Dropdowns. */
|
|
||||||
combos: ComboMenuHandlers;
|
|
||||||
|
|
||||||
// Ressourcen-Fenster.
|
// Ressourcen-Fenster.
|
||||||
resourcesOpen: boolean;
|
resourcesOpen: boolean;
|
||||||
onToggleResources: () => void;
|
onToggleResources: () => void;
|
||||||
@@ -167,14 +122,8 @@ export interface TopBarProps {
|
|||||||
/** Projekt aus einer Datei laden. */
|
/** Projekt aus einer Datei laden. */
|
||||||
onOpenProject: () => void;
|
onOpenProject: () => void;
|
||||||
|
|
||||||
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
|
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
|
||||||
/**
|
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
|
||||||
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
|
|
||||||
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
|
|
||||||
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
|
|
||||||
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
|
|
||||||
*/
|
|
||||||
textTarget: TextTarget | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -552,7 +501,7 @@ const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
|
|||||||
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
|
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
|
||||||
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
|
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
|
||||||
*/
|
*/
|
||||||
function TextGroup({
|
export function TextGroup({
|
||||||
textTarget,
|
textTarget,
|
||||||
}: {
|
}: {
|
||||||
textTarget: TextTarget | null;
|
textTarget: TextTarget | null;
|
||||||
@@ -916,50 +865,145 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
|
|||||||
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
|
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
|
||||||
* Leiste ruhiger bleibt (GTK-Burger-Stil).
|
* Leiste ruhiger bleibt (GTK-Burger-Stil).
|
||||||
*/
|
*/
|
||||||
function AppMenu({
|
/** Ein kleiner Icon-Knopf der Quick-Access-Leiste (OCS-Stil). */
|
||||||
|
function QaButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tb-qa-btn${active ? " active" : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<TbIcon name={icon} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleiner Trenner in der Quick-Access-Leiste. */
|
||||||
|
function QaSep() {
|
||||||
|
return <span className="tb-qa-sep" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-Access-Leiste (OCS-Stil) — alle früheren Datei-Burger-Aktionen plus
|
||||||
|
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
|
||||||
|
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
|
||||||
|
*/
|
||||||
|
function QuickAccess({
|
||||||
|
onOpenProject,
|
||||||
|
onSaveProject,
|
||||||
onImport,
|
onImport,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
onExportDxf,
|
onExportDxf,
|
||||||
onExportSchedule,
|
onExportSchedule,
|
||||||
onSaveProject,
|
onToggleResources,
|
||||||
onOpenProject,
|
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
resourcesOpen,
|
||||||
exportEnabled,
|
exportEnabled,
|
||||||
}: {
|
}: {
|
||||||
|
onOpenProject: () => void;
|
||||||
|
onSaveProject: () => void;
|
||||||
onImport: () => void;
|
onImport: () => void;
|
||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
onExportDxf: () => void;
|
onExportDxf: () => void;
|
||||||
onExportSchedule: () => void;
|
onExportSchedule: () => void;
|
||||||
onSaveProject: () => void;
|
onToggleResources: () => void;
|
||||||
onOpenProject: () => void;
|
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
|
resourcesOpen: boolean;
|
||||||
exportEnabled: boolean;
|
exportEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const items: DropdownItem[] = [
|
|
||||||
{ label: t("file.save"), onSelect: onSaveProject },
|
|
||||||
{ label: t("file.open"), onSelect: onOpenProject },
|
|
||||||
{ divider: true },
|
|
||||||
{ label: t("file.import"), onSelect: onImport },
|
|
||||||
{ divider: true },
|
|
||||||
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
|
|
||||||
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
|
|
||||||
{ label: t("file.exportSchedule"), onSelect: onExportSchedule },
|
|
||||||
{ divider: true },
|
|
||||||
{ label: t("topbar.settings"), onSelect: onOpenSettings },
|
|
||||||
];
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
|
||||||
items={items}
|
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
|
||||||
placeholder=""
|
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
|
||||||
title={t("file.menu")}
|
<QaSep />
|
||||||
triggerClassName="tb-burger"
|
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
|
||||||
triggerSuffix={<TbIcon name="menu" />}
|
<QaButton
|
||||||
/>
|
icon="picture_as_pdf"
|
||||||
|
label={t("file.exportPdf")}
|
||||||
|
onClick={onExportPdf}
|
||||||
|
disabled={!exportEnabled}
|
||||||
|
/>
|
||||||
|
<QaButton
|
||||||
|
icon="output"
|
||||||
|
label={t("file.exportDxf")}
|
||||||
|
onClick={onExportDxf}
|
||||||
|
disabled={!exportEnabled}
|
||||||
|
/>
|
||||||
|
<QaButton
|
||||||
|
icon="table_view"
|
||||||
|
label={t("file.exportSchedule")}
|
||||||
|
onClick={onExportSchedule}
|
||||||
|
/>
|
||||||
|
<QaSep />
|
||||||
|
<QaButton
|
||||||
|
icon="widgets"
|
||||||
|
label={t("topbar.resources")}
|
||||||
|
onClick={onToggleResources}
|
||||||
|
active={resourcesOpen}
|
||||||
|
/>
|
||||||
|
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopBar({
|
/**
|
||||||
project,
|
* Props des „Ansichten"-Tabs — die Ansichts-/Zoom-/Darstellungs-Steuerung, die
|
||||||
|
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
|
||||||
|
*/
|
||||||
|
export interface ViewRibbonTabProps {
|
||||||
|
viewToggleEnabled: boolean;
|
||||||
|
viewType: ViewType;
|
||||||
|
onViewType: (v: ViewType) => void;
|
||||||
|
view3d: View3d;
|
||||||
|
onView3d: (v: View3d) => void;
|
||||||
|
fov: number;
|
||||||
|
onFov: (n: number) => void;
|
||||||
|
detail: DetailLevel;
|
||||||
|
onDetail: (d: DetailLevel) => void;
|
||||||
|
detailEnabled: boolean;
|
||||||
|
scaleDenominator: number;
|
||||||
|
onApplyScale: (n: number) => void;
|
||||||
|
liveScale: number | null;
|
||||||
|
zoomPercent: number;
|
||||||
|
zoomEnabled: boolean;
|
||||||
|
onFit: () => void;
|
||||||
|
onFitSelection: () => void;
|
||||||
|
onZoom100: () => void;
|
||||||
|
renderMode: RenderMode;
|
||||||
|
onRenderMode: (m: RenderMode) => void;
|
||||||
|
renderModeEnabled: boolean;
|
||||||
|
planColorMode: PlanColorMode;
|
||||||
|
onPlanColorMode: (m: PlanColorMode) => void;
|
||||||
|
combos: ComboMenuHandlers;
|
||||||
|
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
|
||||||
|
* nur Defaults). Liegt bewusst im „Ansichten"-Tab (Nutzer-Wunsch). */
|
||||||
|
textTarget: TextTarget | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Ansichten"-Tab des Ribbons — trägt die aus der TopBar gemergten Mittelcluster:
|
||||||
|
* 2D/3D-Ansichts-Raster (+ Kamera-FOV), Sichtbarkeits-Kombinationen, Detailgrad,
|
||||||
|
* Massstab/Zoom-Cluster und Darstellungsart. EIN Ribbon für alles; Handler/Setter
|
||||||
|
* kommen unverändert von App (gleiche Props wie zuvor an die TopBar). Wird von
|
||||||
|
* `RibbonBar` als Inhalt des „Ansichten"-Tabs gerendert.
|
||||||
|
*/
|
||||||
|
export function ViewRibbonTab({
|
||||||
viewToggleEnabled,
|
viewToggleEnabled,
|
||||||
viewType,
|
viewType,
|
||||||
onViewType,
|
onViewType,
|
||||||
@@ -978,44 +1022,14 @@ export function TopBar({
|
|||||||
onFit,
|
onFit,
|
||||||
onFitSelection,
|
onFitSelection,
|
||||||
onZoom100,
|
onZoom100,
|
||||||
onExportPdf,
|
|
||||||
onExportDxf,
|
|
||||||
onExportSchedule,
|
|
||||||
renderMode,
|
renderMode,
|
||||||
onRenderMode,
|
onRenderMode,
|
||||||
renderModeEnabled,
|
renderModeEnabled,
|
||||||
planColorMode,
|
planColorMode,
|
||||||
onPlanColorMode,
|
onPlanColorMode,
|
||||||
combos,
|
combos,
|
||||||
resourcesOpen,
|
|
||||||
onToggleResources,
|
|
||||||
onOpenSettings,
|
|
||||||
layoutMenu,
|
|
||||||
onImport,
|
|
||||||
onSaveProject,
|
|
||||||
onOpenProject,
|
|
||||||
textTarget,
|
textTarget,
|
||||||
}: TopBarProps) {
|
}: ViewRibbonTabProps) {
|
||||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
|
||||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
|
||||||
// NUR während des Scrollens ein (Klasse `is-scrolling`, ~900 ms nachlaufend).
|
|
||||||
const headerRef = useRef<HTMLElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = headerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const onScroll = () => {
|
|
||||||
el.classList.add("is-scrolling");
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => el.classList.remove("is-scrolling"), 900);
|
|
||||||
};
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
el.removeEventListener("scroll", onScroll);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
|
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
|
||||||
const onScaleChange = (value: string) => {
|
const onScaleChange = (value: string) => {
|
||||||
if (value === "__custom__") {
|
if (value === "__custom__") {
|
||||||
@@ -1030,14 +1044,7 @@ export function TopBar({
|
|||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
if (Number.isFinite(n) && n > 0) onApplyScale(n);
|
if (Number.isFinite(n) && n > 0) onApplyScale(n);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aktueller Nenner steht evtl. nicht in den Presets (frei gesetzt) → dann
|
|
||||||
// einen Eintrag „1:N" voranstellen, damit das Select den Wert anzeigt.
|
|
||||||
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
|
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
|
||||||
|
|
||||||
// Optionsliste des Massstab-Dropdowns: ggf. der frei gesetzte aktuelle Wert
|
|
||||||
// (damit er sichtbar bleibt), dann die Presets, zuletzt „frei …" als
|
|
||||||
// Sonder-Wert, der über onScaleChange den Prompt öffnet (Funktion unverändert).
|
|
||||||
const scaleOptions: DropdownOption[] = [
|
const scaleOptions: DropdownOption[] = [
|
||||||
...(inPresets
|
...(inPresets
|
||||||
? []
|
? []
|
||||||
@@ -1047,41 +1054,8 @@ export function TopBar({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="topbar" ref={headerRef}>
|
<div className="ribbon-views">
|
||||||
{/* Marke (DOSSIER-Wortmarke) + Settings/Ressourcen-Icons daneben,
|
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
|
||||||
gestapelt wie im Rhino-Plugin (ToolbarApp.jsx: Wortmarke links,
|
|
||||||
rechts daneben ein schmaler Icon-Stapel). */}
|
|
||||||
<div className="tb-brandgroup">
|
|
||||||
<div className="brand">
|
|
||||||
<span className="brand-word">
|
|
||||||
DOSSIER<span className="brand-dot">.</span>
|
|
||||||
</span>
|
|
||||||
<span className="tag">{t("brand.tag")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="tb-brandicons">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`tb-brandicon${resourcesOpen ? " active" : ""}`}
|
|
||||||
onClick={onToggleResources}
|
|
||||||
aria-pressed={resourcesOpen}
|
|
||||||
aria-label={t("topbar.resources")}
|
|
||||||
title={`${t("topbar.resources")} — ${t("topbar.resources.hint")}`}
|
|
||||||
>
|
|
||||||
<TbIcon name="widgets" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Sep />
|
|
||||||
|
|
||||||
{/* Ansichten als EIN kompaktes Icon-Grid (Vectorworks/DOSSIER-Stil): der
|
|
||||||
2D-Grundriss UND die 3D-Blicke sind eine durchgängige Icon-Serie —
|
|
||||||
„Grundriss" schaltet auf den 2D-Plan, die übrigen auf die jeweilige
|
|
||||||
3D-Ansicht (Perspektive wird dabei automatisch aktiviert). 4 oben /
|
|
||||||
3 unten. Tooltip nennt jede Ansicht im Klartext. */}
|
|
||||||
{/* Ansichts-Icons bleiben immer sichtbar; auf Nicht-Geschoss-Ebenen
|
|
||||||
(Schnitt/Ansicht/2D-Zeichnung) sind sie deaktiviert, weil dort keine
|
|
||||||
2D/3D-Umschaltung greift — die Serie verschwindet nicht mehr ganz. */}
|
|
||||||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
||||||
<div className="view-grid view-grid-4">
|
<div className="view-grid view-grid-4">
|
||||||
<button
|
<button
|
||||||
@@ -1139,10 +1113,7 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Sichtbarkeits-Kombinationen (Vectorworks: Ebenen-/Zeichnungs-
|
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
||||||
kombinationen). Sitzt direkt nach dem Ansichts-Umschalter, weil beides
|
|
||||||
„Navigations-/Sicht"-Steuerung ist — ruhige Stelle vor dem Detailgrad.
|
|
||||||
Nur die Dropdowns; eine grafische Überschreibung kommt später. */}
|
|
||||||
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
|
<div className="tb-group tb-stack" role="group" aria-label={t("combo.layers.title")}>
|
||||||
<label className="tb-field tb-field-row">
|
<label className="tb-field tb-field-row">
|
||||||
<span className="tb-label">{t("combo.layers")}</span>
|
<span className="tb-label">{t("combo.layers")}</span>
|
||||||
@@ -1167,27 +1138,9 @@ export function TopBar({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
|
||||||
Zeilenhöhe. Formatiert das aktuelle Text-Ziel (Raumstempel) live.
|
|
||||||
Sitzt bewusst zwischen den Ebenen-Kombis und Detailgrad/Massstab.
|
|
||||||
„+ Text" ist raus — Text wird ein eigenständiges Zeichenwerkzeug
|
|
||||||
(HANDOVER-Backlog AUDIT B1), kein TopBar-Knopf mehr. */}
|
|
||||||
<TextGroup textTarget={textTarget} />
|
|
||||||
|
|
||||||
<Sep />
|
|
||||||
|
|
||||||
{/* Zeichenwerkzeuge liegen jetzt in der Werkzeug-Palette (ToolsPanel,
|
|
||||||
dockbar) — nicht mehr in der Oberleiste (Vectorworks-Bild: Werkzeuge
|
|
||||||
in der Palette). Auch Wandtyp-Wahl + Fang-Menü sind dort. */}
|
|
||||||
|
|
||||||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
|
|
||||||
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
|
|
||||||
deaktiviert mit Tooltip statt stiller No-Op. */}
|
|
||||||
{/* Detailgrad — Massstab lebt nur noch EINMAL im Zoom-Cluster (die
|
|
||||||
Dropdown hier war ein redundantes Relikt der TopBar-Reorg). */}
|
|
||||||
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
|
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
|
||||||
<label className="tb-field tb-field-row">
|
<label className="tb-field tb-field-row">
|
||||||
<span className="tb-label">{t("topbar.detail")}</span>
|
<span className="tb-label">{t("topbar.detail")}</span>
|
||||||
@@ -1208,14 +1161,9 @@ export function TopBar({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
|
{/* Massstab/Zoom-Cluster (2×2). */}
|
||||||
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
|
|
||||||
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. „am
|
|
||||||
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
|
|
||||||
Akzent hervor. */}
|
|
||||||
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
|
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||||
<div
|
<div
|
||||||
className={`tb-stat${
|
className={`tb-stat${
|
||||||
@@ -1271,13 +1219,9 @@ export function TopBar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
|
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
|
||||||
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
|
|
||||||
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
|
|
||||||
Linien-Modus (Display/Print) bleibt separat. */}
|
|
||||||
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
|
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
|
||||||
<label className="tb-field">
|
<label className="tb-field">
|
||||||
<span className="tb-label">{t("display.style")}</span>
|
<span className="tb-label">{t("display.style")}</span>
|
||||||
@@ -1308,25 +1252,199 @@ export function TopBar({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
onToggleResources={onToggleResources}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
resourcesOpen={resourcesOpen}
|
||||||
|
exportEnabled={zoomEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Rechte Gruppe: Layout-Menü · Projektname · Datei-Burger (ganz rechts,
|
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) — sitzen in dieser Zeile,
|
||||||
GTK-Stil) — Ressourcen sitzt jetzt bei der Marke (s. o.). */}
|
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">
|
<div className="topbar-right">
|
||||||
{layoutMenu}
|
{layoutMenu}
|
||||||
<span className="project-name">{project.name}</span>
|
<span className="project-name">{project.name}</span>
|
||||||
<AppMenu
|
|
||||||
onImport={onImport}
|
|
||||||
onExportPdf={onExportPdf}
|
|
||||||
onExportDxf={onExportDxf}
|
|
||||||
onExportSchedule={onExportSchedule}
|
|
||||||
onSaveProject={onSaveProject}
|
|
||||||
onOpenProject={onOpenProject}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
exportEnabled={zoomEnabled}
|
|
||||||
/>
|
|
||||||
<WindowControls />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-22
@@ -1,11 +1,11 @@
|
|||||||
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
|
// Eigene Fenstersteuerung (Minimieren/Maximieren/Schliessen) für das randlose
|
||||||
// Electron-Fenster (frame:false, s. scripts/electron-main.cjs) — ohne native
|
// ELECTRON-Fenster (frame:false, s. scripts/electron-main.cjs) — dort gibt es
|
||||||
// Titelleiste gibt es sonst keine Möglichkeit, das Fenster zu steuern.
|
// ohne native Titelleiste sonst keine Fenstersteuerung.
|
||||||
//
|
//
|
||||||
// Rendert NUR, wenn `window.dossierWindow` existiert (vom Preload-Skript
|
// Unter TAURI werden die NATIVEN macOS-Ampeln verwendet (tauri.conf.json:
|
||||||
// injiziert) — also nur unter Electron. In Tauri (native Fensterdekoration)
|
// titleBarStyle "Overlay" + hiddenTitle, native Dekoration mit gerundeten Ecken),
|
||||||
// und im Browser-Dev-Modus (?vite) bleibt die Komponente unsichtbar (`null`),
|
// daher rendert diese Komponente dort NICHT (kein `window.dossierWindow`) — sonst
|
||||||
// dort steuert das Betriebssystem bzw. der Browser das Fenster.
|
// gäbe es doppelte Knöpfe. Im Browser-Dev-Modus ebenfalls unsichtbar (`null`).
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -43,8 +43,25 @@ export function WindowControls() {
|
|||||||
|
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
|
|
||||||
|
// Reihenfolge (macOS-Titelleiste links): Schliessen · Minimieren · Maximieren.
|
||||||
return (
|
return (
|
||||||
<div className="win-controls" role="group" aria-label="Fenster">
|
<div className="win-controls" role="group" aria-label="Fenster">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="win-btn win-btn-close"
|
||||||
|
onClick={() => api.close()}
|
||||||
|
title="Schliessen"
|
||||||
|
aria-label="Schliessen"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 1 L9 9 M9 1 L1 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="win-btn"
|
className="win-btn"
|
||||||
@@ -86,22 +103,6 @@ export function WindowControls() {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="win-btn win-btn-close"
|
|
||||||
onClick={() => api.close()}
|
|
||||||
title="Schliessen"
|
|
||||||
aria-label="Schliessen"
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M1 1 L9 9 M9 1 L1 9"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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