Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import
- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht, unsichtbare Ebenen blenden ihre Führungslinie aus - Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/ Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste (AppMenuBar) neben der Wortmarke - Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert, flackerfrei vor erstem Render gesetzt - SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene - UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access- Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/ Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt - Neues Dachziegel-Material (RoofingTiles013A)
@@ -103,7 +103,7 @@
|
|||||||
- [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] ~~**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).
|
- [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).**
|
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. ✅ **Gruppe A (2D, Items 1–6) komplett — verifiziert 2026-07-05:** (1) Treppe-Outline (`stairOutline`, gerade/L/Wendel, `generatePlan.ts:2186`), (2) Fenster-Brüstungslinie (`window-sill`, gepunktet, `sillHeight>0`, `:1810`), (3) Tür-Sturzlinien (`door-lintel` + `lintelLines` keine/innen/aussen/beide, gestrichelt, `:1711`), (4) Treppe-Referenz links/mitte/rechts, (5) Fenster-Flügel-Mittelpfosten, (6) Tür `wandoeffnung`. **Offen:** Gruppe B (2D mittel: Tür-Schwung am Rahmen, Fenster/Tür-Presets) — Feinpolish. ✅ **`swing_invert` gilt als abgedeckt** (2026-07-17): die Aufschlagseite ist über `Opening.swing` (links/rechts) UND der Scharnier-Pfosten über `Opening.hinge` (start/ende) bereits voll im Objekt-Info wählbar (4 Kombinationen = jede Schwenklage), ein separater Invert-Flag wäre redundant. ✅ **Treppen-Pfeil-Style `filled` erledigt 2026-07-17** (uncommittet): `Stair.arrowStyle?:"line"|"filled"` (additiv), gefülltes Dreieck-Polygon statt zwei offener Linien in `addStairSymbol` (`generatePlan.ts`), Segment-Umschalter im Objekt-Info (`onSetStairArrowStyle`), `StairInfo.arrowStyle`, +2 Tests (`generatePlan.stairArrow.test.ts`). tsc/vitest 869 grün. ✅ **Fenster-Anschlag-Striche erledigt** (`8d688b9`, Laibungsstriche quer zur Wand bei „fein", analog Tür; +2 Tests). Gruppe C (3D: Rahmen/Blatt/Glas/Sims als Mesh — wartet auf Mesh-/B-Rep-Pipeline). **Der große Rest ist „Schnitt- vs. Ansichts-Darstellung" (eigenes Item unten).**
|
||||||
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. ✅ **05bc5aa (2026-07-05): HATCH-Ellipse/Spline-Kanten** ergänzt (Kantentyp 3/4 tesselliert; B-Spline-Sampling in `sampleBSpline` extrahiert). ✅ **4b93ac9 (2026-07-05): TEXT/MTEXT** — `parseDxf` liefert `DxfImportResult.texts` (`ImportedText`: Position/Höhe-in-Metern/Winkel-Radiant; MTEXT-Formatcodes grob gesäubert); `textsToDrawings` → `{shape:"text"}`-Drawing2D; **Darstellung neu**: `addDrawing2D` emittiert ein schlankes `kind:"drawingText"`-Primitiv, PlanView rendert es rein per SVG (modellverankert, Rotation; GPU-Guard so, dass es in ALLEN Renderer-Modi im SVG bleibt); `toRenderScene` überspringt es; ImportDialog zählt/importiert Texte. +5 Tests, Suite 327 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Insel-Loops = eigene Ringe (keine echten Löcher). **Bekannte Grenzen TEXT:** importierte Texte (pointerEvents:none) noch nicht per Canvas-Klick selektierbar; MTEXT-Feinformatierung flachgeklopft; Block-interne TEXT/MTEXT nicht expandiert. **Text im Tauri visuell abgenommen (Nutzer 2026-07-05)** — auch gedreht korrekt. ✅ **4ac99d3: CIRCLE/ARC als echte glatte Formen** — `Contour.curve` trägt die wahre Kreis-/Bogen-Geometrie (pts bleiben für Kontext/3D); `contoursToDrawings` baut `{shape:"circle"|"arc"}`; neue Primitive `drawingCircle` (SVG `<circle>`) + `drawingArc` (SVG-Bogenpfad), `toRenderScene` tesselliert sie für den nativen Pfad; +4 Tests, Suite 331. Ellipse bleibt tesselliert (kein Ellipsen-Primitiv). **Weiter offen:** Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
|
- [ ] **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.**
|
||||||
|
|
||||||
@@ -167,6 +167,11 @@
|
|||||||
- **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen.
|
- **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen.
|
||||||
- **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen.
|
- **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen.
|
||||||
- [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten):
|
- [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten):
|
||||||
|
- ✅ **Phase 1 erledigt 2026-07-17 (Kopplung + Navigation, KEIN Rust — plan: `.claude/plans/cozy-bubbling-goose.md`):** der 3D-Live-Schnitt (`Wasm3DViewport`) folgt jetzt der gewählten Schnittlinie statt einer fest verdrahteten horizontalen Ebene — die Engine kann beliebige Ebenen längst (`set_section_plane(point,normal)`), es fehlte nur die Bindung. App-State `section3dCutId` + abgeleitete `section3dPlane` via `sectionPlaneFromLevel` (world point+normal aus `linePoints`/`directionSign`, `plan/toSection.ts`) → durch `Viewport3D`→`Wasm3DViewport` (neue Props `section3d`/`onClearSection3d`). Schalter „Im 3D schneiden" in der Schnittlinien-Sektion des Objekt-Info-Panels (`host.section3dCutActive`/`onToggleSection3dCut`); der Viewport-Overlay-Knopf ist an die aktive Linie gebunden und hebt sie auf. **Doppelklick auf die Schnittlinie im Grundriss → springt in die 2D-Schnittansicht** (`PlanView.onOpenSectionLevel` → `onSelectLevel`). 2D-Editieren (Endpunkte/Blickrichtung/Tiefe) existierte schon im Objekt-Info. `tsc` + `vitest` 866 grün. **Nutzer prüft visuell in Tauri** (render3d-Pfad).
|
||||||
|
- ✅ **Phase 2 Teil „eigene Ebene / Sichtbarkeit" erledigt 2026-07-17:** eine unsichtbare Schnitt-/Ansichtsebene (`visible===false`) zeigt ihre Führungslinie im Grundriss nicht mehr (`addSectionLines` in `generatePlan.ts`, +1 Test). Ein-/ausblendbar wie andere Ebenen (Zeichnungsebenen-Panel).
|
||||||
|
- **Offen Phase 2 (engine-schwer, mit Nutzer im Tauri-Loop):** Farbe der 3D-Schnittebene + **Cut-away-Schleier/Schraffur auf der weggeschnittenen Seite** (wgpu-Shader/`section_fill.rs`/`section.rs`) — blind über Nacht bewusst NICHT gebaut (unverifizierbar ohne Tauri-Sicht, s. Arbeitsstil „Nutzer prüft render3d visuell").
|
||||||
|
- **Offen Phase 3 (engine-schwer):** 3D-POV je Schnitt (Augen-/Zielhöhe/Winkel getrennt, pro Projektionsart) im Datenmodell + eigenes 3D-Schnittfenster (Doppelklick 3D-Symbol → Clip+Kamera); koppelt an das Ausschnitte-/Kamera-System. Kamera-Kopplung an `Wasm3DViewport`-Orbit → Tauri-Verifikation nötig.
|
||||||
|
- Restliche Vision-Bausteine (unverändert offen):
|
||||||
- **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln).
|
- **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln).
|
||||||
- **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D.
|
- **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D.
|
||||||
- **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion.
|
- **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion.
|
||||||
@@ -209,6 +214,7 @@
|
|||||||
|
|
||||||
_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-17 **Schnittebenen 2D↔3D — Phase 1 (Kopplung + Navigation) + Phase-2-Sichtbarkeit** (uncommittet) — der 3D-Live-Schnitt folgt jetzt der gewählten Grundriss-Schnittlinie statt einer fest verdrahteten horizontalen Ebene (`Wasm3DViewport`/`Viewport3D` neue Props `section3d`/`onClearSection3d`; App-State `section3dCutId` → `sectionPlaneFromLevel`). „Im 3D schneiden"-Schalter im Objekt-Info (`host.section3dCutActive`/`onToggleSection3dCut`), Viewport-Knopf an die Linie gebunden. Doppelklick auf die Schnittlinie → 2D-Schnittansicht (`PlanView.onOpenSectionLevel`). Unsichtbare Schnitt-/Ansichtsebene blendet ihre Grundriss-Linie aus (`addSectionLines`). `tsc` + `vitest` 867 grün (+1). Cut-away-Schleier/Farbe (Phase 2) + POV/3D-Schnittfenster (Phase 3) bewusst offen (engine-schwer, Tauri-Verifikation nötig — s. Backlog-Eintrag). Nutzer prüft Phase 1 visuell in Tauri. UI-Nebenarbeit derselben Session (uncommittet): Werkzeug-Panel (Symbole/Liste-Umschalter, neue SIA-Icons, helle Auswahl), Topbar-Quick-Access-Icons entfernt (dupliziert das native Menü), Zahnrad→Einstellungen in Zeichnungsebenen/Ebenen-Köpfen, Footerbar + Snap-Marker + Maß-HUD auf die helle Pillen-Sprache umgestellt.
|
||||||
- [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün.
|
- [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün.
|
||||||
- [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün.
|
- [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün.
|
||||||
- [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen.
|
- [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E"
|
||||||
/>
|
/>
|
||||||
<title>cad — Phase 0 Spike</title>
|
<title>Dossier</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "cad",
|
"name": "dossier",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 551 KiB |
@@ -61,6 +61,18 @@
|
|||||||
"ao": "/assets/materials/Bricks104/ao.jpg"
|
"ao": "/assets/materials/Bricks104/ao.jpg"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "RoofingTiles013A",
|
||||||
|
"name": "Dachziegel",
|
||||||
|
"category": "Roofing Tiles",
|
||||||
|
"maps": {
|
||||||
|
"color": "/assets/materials/RoofingTiles013A/color.jpg",
|
||||||
|
"normal": "/assets/materials/RoofingTiles013A/normal.jpg",
|
||||||
|
"roughness": "/assets/materials/RoofingTiles013A/roughness.jpg",
|
||||||
|
"displacement": "/assets/materials/RoofingTiles013A/displacement.jpg",
|
||||||
|
"ao": "/assets/materials/RoofingTiles013A/ao.jpg"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "Tiles141",
|
"id": "Tiles141",
|
||||||
"name": "Bodenfliesen",
|
"name": "Bodenfliesen",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).",
|
"description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss.",
|
||||||
"windows": ["main"],
|
"windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-create",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:menu:default",
|
||||||
|
"core:event:default",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"fs:allow-write-text-file"
|
"fs:allow-write-text-file"
|
||||||
]
|
]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 103 B After Width: | Height: | Size: 12 KiB |
@@ -91,11 +91,21 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
|||||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
|
/// Eine Kante samt ihrer deduplizierten Repraesentanten-Normalen (Rueckseiten
|
||||||
///
|
/// bereits gepaart, s. `representatives`). Grundlage BEIDER Kanten-Stile: die
|
||||||
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
|
/// statischen Feature-Edges (`build_mesh_edges`) UND die blickabhaengigen
|
||||||
/// Leerer Vektor bei leerem/degeneriertem Mesh.
|
/// Silhouetten-Konturen (`build_silhouette_edges`).
|
||||||
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
pub struct EdgeAdj {
|
||||||
|
pub p0: [f32; 3],
|
||||||
|
pub p1: [f32; 3],
|
||||||
|
/// Flaechennormalen der (max. 2) angrenzenden Original-Dreiecke.
|
||||||
|
pub reps: Vec<[f32; 3]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut die Kanten-Adjazenz aus dem Mesh: je eindeutiger Kante die Endpunkte +
|
||||||
|
/// die deduplizierten Repraesentanten-Normalen. GPU-frei; einmalig bei `set_model`
|
||||||
|
/// berechnet und gecacht (die Silhouette wird daraus je Blickwinkel neu abgeleitet).
|
||||||
|
pub fn build_edge_adjacency(mesh: &Mesh) -> Vec<EdgeAdj> {
|
||||||
if mesh.indices.len() < 3 {
|
if mesh.indices.len() < 3 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -137,20 +147,83 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut verts: Vec<f32> = Vec::new();
|
edges
|
||||||
let mut push = |p: [f32; 3]| {
|
.into_values()
|
||||||
verts.push(p[0]);
|
.map(|rec| EdgeAdj {
|
||||||
verts.push(p[1]);
|
p0: rec.p0,
|
||||||
verts.push(p[2]);
|
p1: rec.p1,
|
||||||
verts.push(EDGE_COLOR[0]);
|
reps: representatives(&rec.normals),
|
||||||
verts.push(EDGE_COLOR[1]);
|
})
|
||||||
verts.push(EDGE_COLOR[2]);
|
.collect()
|
||||||
};
|
}
|
||||||
|
|
||||||
for rec in edges.values() {
|
fn push_edge(verts: &mut Vec<f32>, p0: [f32; 3], p1: [f32; 3]) {
|
||||||
if should_draw(&rec.normals) {
|
for p in [p0, p1] {
|
||||||
push(rec.p0);
|
verts.extend_from_slice(&[p[0], p[1], p[2], EDGE_COLOR[0], EDGE_COLOR[1], EDGE_COLOR[2]]);
|
||||||
push(rec.p1);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut die Feature-Edge-LineList aus dem Mesh (siehe Moduldoc).
|
||||||
|
///
|
||||||
|
/// Ausgabe: interleaved `[px,py,pz, r,g,b, ...]`, je 2 Vertices = 1 Segment.
|
||||||
|
/// Leerer Vektor bei leerem/degeneriertem Mesh.
|
||||||
|
pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
||||||
|
let adj = build_edge_adjacency(mesh);
|
||||||
|
let mut verts: Vec<f32> = Vec::new();
|
||||||
|
for a in &adj {
|
||||||
|
if draws_crease(&a.reps) {
|
||||||
|
push_edge(&mut verts, a.p0, a.p1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blickabhaengige Silhouetten-Konturen aus der gecachten Kanten-Adjazenz: gezeichnet
|
||||||
|
/// werden Randkanten (nur ein Dreieck) UND echte Silhouetten-Kanten — solche, deren
|
||||||
|
/// zwei angrenzende Flaechen aus Sicht der Kamera GEGENSAETZLICH orientiert sind
|
||||||
|
/// (eine vorder-, eine rueckseitig). Ergebnis ist der reine Umriss (Gipsmodell-/
|
||||||
|
/// Konturen-Look) ohne die inneren Knickkanten (Dachgrate). `eye` = Kameraposition
|
||||||
|
/// (world), `forward` = Blickrichtung (world, von der Kamera ins Bild); `perspective`
|
||||||
|
/// waehlt die pro-Kante-Blickrichtung (Perspektive) vs. die konstante (ortho).
|
||||||
|
pub fn build_silhouette_edges(
|
||||||
|
adj: &[EdgeAdj],
|
||||||
|
eye: [f32; 3],
|
||||||
|
forward: [f32; 3],
|
||||||
|
perspective: bool,
|
||||||
|
) -> Vec<f32> {
|
||||||
|
let mut verts: Vec<f32> = Vec::new();
|
||||||
|
for a in adj {
|
||||||
|
let draw = match a.reps.len() {
|
||||||
|
0 => false,
|
||||||
|
1 => true, // offene Randkante -> immer Teil der Kontur
|
||||||
|
_ => {
|
||||||
|
let mid = [
|
||||||
|
(a.p0[0] + a.p1[0]) * 0.5,
|
||||||
|
(a.p0[1] + a.p1[1]) * 0.5,
|
||||||
|
(a.p0[2] + a.p1[2]) * 0.5,
|
||||||
|
];
|
||||||
|
// Blickvektor von der Flaeche ZUR Kamera. Perspektive: eye - mid;
|
||||||
|
// ortho: entgegen der Blickrichtung (konstante Kamera).
|
||||||
|
let view = if perspective {
|
||||||
|
sub(eye, mid)
|
||||||
|
} else {
|
||||||
|
[-forward[0], -forward[1], -forward[2]]
|
||||||
|
};
|
||||||
|
let mut front = false;
|
||||||
|
let mut back = false;
|
||||||
|
for n in &a.reps {
|
||||||
|
let d = dot(*n, view);
|
||||||
|
if d > 0.0 {
|
||||||
|
front = true;
|
||||||
|
} else if d < 0.0 {
|
||||||
|
back = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
front && back // Silhouette: eine Flaeche vorder-, eine rueckseitig
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if draw {
|
||||||
|
push_edge(&mut verts, a.p0, a.p1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
verts
|
verts
|
||||||
@@ -171,15 +244,18 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
|||||||
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
|
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
|
||||||
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
|
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
|
||||||
/// ob doppelseitig gerendert wurde oder nicht.
|
/// ob doppelseitig gerendert wurde oder nicht.
|
||||||
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
/// Dedupliziert die angrenzenden Normalen zu Repraesentanten: paart die von der
|
||||||
let mut representatives: Vec<[f32; 3]> = Vec::new();
|
/// doppelseitigen Emission stammenden Rueckseiten (dot ≈ -1) und behaelt je
|
||||||
|
/// Original-Dreieck EINEN Vertreter (s. Moduldoc `should_draw`-Historie).
|
||||||
|
fn representatives(normals: &[[f32; 3]]) -> Vec<[f32; 3]> {
|
||||||
|
let mut reps: Vec<[f32; 3]> = Vec::new();
|
||||||
let mut used = vec![false; normals.len()];
|
let mut used = vec![false; normals.len()];
|
||||||
for i in 0..normals.len() {
|
for i in 0..normals.len() {
|
||||||
if used[i] {
|
if used[i] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
used[i] = true;
|
used[i] = true;
|
||||||
representatives.push(normals[i]);
|
reps.push(normals[i]);
|
||||||
for j in (i + 1)..normals.len() {
|
for j in (i + 1)..normals.len() {
|
||||||
if used[j] {
|
if used[j] {
|
||||||
continue;
|
continue;
|
||||||
@@ -190,14 +266,20 @@ fn should_draw(normals: &[[f32; 3]]) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match representatives.len() {
|
reps
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entscheidet fuer die STATISCHEN Feature-Edges, ob eine Kante gezeichnet wird:
|
||||||
|
/// Randkante (ein Repraesentant) oder Knickkante (zwei stehen ueber dem Crease-
|
||||||
|
/// Winkel zueinander). Koplanare geteilte Kanten (Flaechendiagonalen) fallen weg.
|
||||||
|
fn draws_crease(reps: &[[f32; 3]]) -> bool {
|
||||||
|
match reps.len() {
|
||||||
0 => false,
|
0 => false,
|
||||||
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
|
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
|
||||||
_ => {
|
_ => {
|
||||||
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
|
for i in 0..reps.len() {
|
||||||
for i in 0..representatives.len() {
|
for j in (i + 1)..reps.len() {
|
||||||
for j in (i + 1)..representatives.len() {
|
if dot(reps[i], reps[j]) < CREASE_COS {
|
||||||
if dot(representatives[i], representatives[j]) < CREASE_COS {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +359,57 @@ mod tests {
|
|||||||
let m = Mesh::default();
|
let m = Mesh::default();
|
||||||
assert!(build_mesh_edges(&m).is_empty());
|
assert!(build_mesh_edges(&m).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein geschlossener Wuerfel: 8 Ecken, 12 Dreiecke (6 Flaechen, aussen).
|
||||||
|
fn unit_cube() -> Mesh {
|
||||||
|
let c = [
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[1.0, 1.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
[1.0, 0.0, 1.0],
|
||||||
|
[1.0, 1.0, 1.0],
|
||||||
|
[0.0, 1.0, 1.0],
|
||||||
|
];
|
||||||
|
#[rustfmt::skip]
|
||||||
|
let idx: Vec<u32> = vec![
|
||||||
|
0,3,2, 0,2,1, // -Z
|
||||||
|
4,5,6, 4,6,7, // +Z
|
||||||
|
0,1,5, 0,5,4, // -Y
|
||||||
|
3,7,6, 3,6,2, // +Y
|
||||||
|
0,4,7, 0,7,3, // -X
|
||||||
|
1,2,6, 1,6,5, // +X
|
||||||
|
];
|
||||||
|
mesh(&c, &idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cube_feature_edges_are_twelve() {
|
||||||
|
// Statische Feature-Edges: die 12 Wuerfelkanten (Flaechendiagonalen weg).
|
||||||
|
let e = build_mesh_edges(&unit_cube());
|
||||||
|
assert_eq!(segment_count(&e), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cube_silhouette_from_generic_view_is_hexagon() {
|
||||||
|
// Aus einer generischen Ecken-Sicht ist die Silhouette eines Wuerfels ein
|
||||||
|
// Sechseck = 6 Kanten (nur der Umriss, KEINE inneren Knickkanten).
|
||||||
|
let adj = build_edge_adjacency(&unit_cube());
|
||||||
|
let eye = [5.0, 6.0, 7.0];
|
||||||
|
let fwd = normalize(sub([0.5, 0.5, 0.5], eye)); // Blick auf die Mitte
|
||||||
|
let sil = build_silhouette_edges(&adj, eye, fwd, true);
|
||||||
|
assert_eq!(segment_count(&sil), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cube_silhouette_ortho_is_hexagon_too() {
|
||||||
|
// Auch orthografisch (konstante Blickrichtung) ergibt sich das Sechseck.
|
||||||
|
let adj = build_edge_adjacency(&unit_cube());
|
||||||
|
let fwd = normalize([-1.0, -1.2, -1.4]);
|
||||||
|
let sil = build_silhouette_edges(&adj, [0.0, 0.0, 0.0], fwd, false);
|
||||||
|
assert_eq!(segment_count(&sil), 6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,17 +12,20 @@
|
|||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
use crate::edges::{
|
||||||
|
build_edge_adjacency, build_mesh_edges, build_silhouette_edges, EdgeAdj, EDGE_FLOATS_PER_VERTEX,
|
||||||
|
};
|
||||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||||
use crate::math::{view_projection, Mat4};
|
use crate::math::{view_projection, Mat4};
|
||||||
use crate::mesh::{
|
use crate::mesh::{
|
||||||
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured,
|
build_free_wall_end_caps, build_scene_mesh, build_scene_mesh_textured, build_terrain_mesh,
|
||||||
|
build_walls_mesh, build_walls_mesh_textured,
|
||||||
};
|
};
|
||||||
use crate::section::SectionPlane;
|
use crate::section::SectionPlane;
|
||||||
use crate::section_fill::{
|
use crate::section_fill::{
|
||||||
build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX,
|
build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX,
|
||||||
};
|
};
|
||||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_AERIAL_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
|
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
|
||||||
TEXTURED_FLOATS_PER_VERTEX,
|
TEXTURED_FLOATS_PER_VERTEX,
|
||||||
@@ -51,6 +54,11 @@ pub enum RenderStyle {
|
|||||||
/// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt
|
/// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt
|
||||||
/// Weiss-Flaechen).
|
/// Weiss-Flaechen).
|
||||||
ShadedEdges,
|
ShadedEdges,
|
||||||
|
/// „Nur Konturen" (Gipsmodell-Look): flach-weisse Flaechen wie `Hidden`, aber
|
||||||
|
/// obenauf NUR die blickabhaengige Silhouette (Umriss je Volumen), nicht die
|
||||||
|
/// inneren Knickkanten (Dachgrate). Die Silhouette wird je Kamera aus der
|
||||||
|
/// gecachten Kanten-Adjazenz neu abgeleitet (siehe `edges::build_silhouette_edges`).
|
||||||
|
Contour,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderStyle {
|
impl RenderStyle {
|
||||||
@@ -62,6 +70,7 @@ impl RenderStyle {
|
|||||||
"wireframe" => RenderStyle::Wireframe,
|
"wireframe" => RenderStyle::Wireframe,
|
||||||
"hidden" => RenderStyle::Hidden,
|
"hidden" => RenderStyle::Hidden,
|
||||||
"shaded-edges" => RenderStyle::ShadedEdges,
|
"shaded-edges" => RenderStyle::ShadedEdges,
|
||||||
|
"contour" => RenderStyle::Contour,
|
||||||
_ => RenderStyle::Shaded,
|
_ => RenderStyle::Shaded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +81,8 @@ impl RenderStyle {
|
|||||||
fn mesh_mode(self) -> f32 {
|
fn mesh_mode(self) -> f32 {
|
||||||
match self {
|
match self {
|
||||||
RenderStyle::White => 1.0,
|
RenderStyle::White => 1.0,
|
||||||
RenderStyle::Hidden => 2.0,
|
// Contour teilt den flach-weissen Flaechen-Look von Hidden (mode 2).
|
||||||
|
RenderStyle::Hidden | RenderStyle::Contour => 2.0,
|
||||||
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
|
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
|
||||||
// gezeichnet), ShadedEdges -> Material.
|
// gezeichnet), ShadedEdges -> Material.
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
@@ -98,7 +108,10 @@ impl RenderStyle {
|
|||||||
/// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch
|
/// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch
|
||||||
/// `ShadedEdges`.
|
/// `ShadedEdges`.
|
||||||
fn needs_biased_face_pipeline(self) -> bool {
|
fn needs_biased_face_pipeline(self) -> bool {
|
||||||
matches!(self, RenderStyle::Hidden | RenderStyle::ShadedEdges)
|
matches!(
|
||||||
|
self,
|
||||||
|
RenderStyle::Hidden | RenderStyle::ShadedEdges | RenderStyle::Contour
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +187,19 @@ struct TexturedMeshBuffers {
|
|||||||
index_count: u32,
|
index_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GPU-Ressourcen EINES georeferenzierten Luftbilds (SWISSIMAGE), das aufs Gelaende
|
||||||
|
/// drapiert wird (`gpu::aerial_pipeline`). Haelt Textur + View am Leben und die
|
||||||
|
/// fertige Bind-Group (group 1: Textur + Sampler + Bbox-Uniform).
|
||||||
|
struct AerialBuffers {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
texture: wgpu::Texture,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
view: wgpu::TextureView,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
bbox: wgpu::Buffer,
|
||||||
|
bind_group: wgpu::BindGroup,
|
||||||
|
}
|
||||||
|
|
||||||
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
|
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
|
||||||
const TEXTURE_SIZE: u32 = 256;
|
const TEXTURE_SIZE: u32 = 256;
|
||||||
|
|
||||||
@@ -268,6 +294,30 @@ pub struct Renderer {
|
|||||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||||
/// Shader/Layout unterscheiden sich.
|
/// Shader/Layout unterscheiden sich.
|
||||||
textured_pipeline: wgpu::RenderPipeline,
|
textured_pipeline: wgpu::RenderPipeline,
|
||||||
|
/// Luftbild-Drape-Pipeline (`MESH_AERIAL_WGSL`): zeichnet das Terrain-Overlay
|
||||||
|
/// (`terrain_overlay`) ein zweites Mal mit einer Bild-Textur (group 1). Vertex-
|
||||||
|
/// Layout = Haupt-Mesh-Layout `[pos, normal, color]`. Leichter Depth-Bias zur
|
||||||
|
/// Kamera hin (wie `cap_pipeline`), damit das Overlay das deckungsgleiche
|
||||||
|
/// Terrain im Hauptpuffer ueberlagert statt zu flimmern.
|
||||||
|
aerial_pipeline: wgpu::RenderPipeline,
|
||||||
|
/// Bind-Group-Layout (group 1) des Luftbild-Pfades: Textur + Sampler + Bbox-
|
||||||
|
/// Uniform. Festgehalten fuer den Bind-Group-Neubau in `set_aerial_texture`.
|
||||||
|
aerial_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
/// Sampler des Luftbilds (ClampToEdge/Linear — kein Kacheln, weiche Filterung).
|
||||||
|
aerial_sampler: wgpu::Sampler,
|
||||||
|
/// Das aktuell drapierte Luftbild (Textur + Bind-Group). None = kein Luftbild.
|
||||||
|
aerial: Option<AerialBuffers>,
|
||||||
|
/// Nur die Gelaende-Dreiecke (aus `build_terrain_mesh`) als eigener Puffer —
|
||||||
|
/// Grundlage des Luftbild-Overlays. Bei `upload_model` erzeugt; None = kein
|
||||||
|
/// Terrain. Wird nur gezeichnet, wenn zusaetzlich `aerial` gesetzt ist.
|
||||||
|
terrain_overlay: Option<MeshBuffers>,
|
||||||
|
/// Gecachte Kanten-Adjazenz des Modells (bei `upload_*` erzeugt) — Grundlage
|
||||||
|
/// der blickabhaengigen Silhouette (Stil `Contour`). Leer = kein Modell.
|
||||||
|
edge_adj: Vec<EdgeAdj>,
|
||||||
|
/// Zuletzt gebaute Silhouetten-LineList (Stil `Contour`) + die Kamera-Signatur,
|
||||||
|
/// fuer die sie gilt — bei Blickwechsel neu abgeleitet (nicht je Frame blind).
|
||||||
|
silhouette: Option<LineBuffers>,
|
||||||
|
silhouette_key: Option<[i64; 6]>,
|
||||||
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
|
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
|
||||||
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
|
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
|
||||||
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
|
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
|
||||||
@@ -310,6 +360,12 @@ pub struct Renderer {
|
|||||||
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
||||||
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
||||||
caps: Option<CapBuffers>,
|
caps: Option<CapBuffers>,
|
||||||
|
/// STATISCHE Schraffur-Kappen an freien Wandenden (`mesh::build_free_wall_end_caps`,
|
||||||
|
/// kein Live-Schnitt noetig — unabhaengig von `caps`/`cut_lines`, die nur bei
|
||||||
|
/// aktivem Live-Schnitt existieren). Bei `upload_model` neu gebaut; None = keine
|
||||||
|
/// Wand hat eine offene, schraffierte Kante. Immer gezeichnet, wenn Flaechen
|
||||||
|
/// gezeichnet werden (nicht an den Live-Schnitt-Zustand gebunden).
|
||||||
|
wall_end_caps: Option<CapBuffers>,
|
||||||
/// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList
|
/// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList
|
||||||
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
|
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
|
||||||
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
|
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
|
||||||
@@ -733,6 +789,115 @@ impl Renderer {
|
|||||||
cache: None,
|
cache: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Luftbild-Drape-Pfad (aerial_pipeline) ───────────────────────────────
|
||||||
|
// group 1: EINE 2D-Bild-Textur + Sampler + Bbox-Uniform (16 Byte, vec4).
|
||||||
|
// group 0 (Globals) bleibt geteilt. Vertex-Layout = Haupt-Mesh-Layout
|
||||||
|
// [pos, normal, color], damit der Terrain-Overlay-Puffer direkt passt.
|
||||||
|
let aerial_bind_group_layout =
|
||||||
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("aerial.layout"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: wgpu::BufferSize::new(16),
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let aerial_pipeline_layout =
|
||||||
|
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("3d.aerial.layout"),
|
||||||
|
bind_group_layouts: &[Some(&bind_group_layout), Some(&aerial_bind_group_layout)],
|
||||||
|
immediate_size: 0,
|
||||||
|
});
|
||||||
|
let aerial_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("mesh_aerial.wgsl"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(MESH_AERIAL_WGSL.into()),
|
||||||
|
});
|
||||||
|
let aerial_vertex_layout = wgpu::VertexBufferLayout {
|
||||||
|
array_stride: (FLOATS_PER_VERTEX * 4) as u64,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3],
|
||||||
|
};
|
||||||
|
let aerial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("mesh.aerial.pipeline"),
|
||||||
|
layout: Some(&aerial_pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &aerial_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[aerial_vertex_layout],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &aerial_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: color_format,
|
||||||
|
blend: Some(wgpu::BlendState::REPLACE),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
front_face: wgpu::FrontFace::Ccw,
|
||||||
|
cull_mode: Some(wgpu::Face::Back),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: Some(wgpu::DepthStencilState {
|
||||||
|
format: DEPTH_FORMAT,
|
||||||
|
depth_write_enabled: Some(true),
|
||||||
|
// Das Overlay nutzt EXAKT dieselbe Terrain-Geometrie wie der
|
||||||
|
// Hauptpuffer -> bit-identische Tiefe. Mit LessEqual gewinnt das
|
||||||
|
// NACH dem Terrain gezeichnete Overlay bei gleicher Tiefe
|
||||||
|
// deterministisch (kein Bias noetig). Ein Slope-Bias wuerde in
|
||||||
|
// ORTHO-Praesets (iso/axo) je nach Dreiecks-Neigung ungleich
|
||||||
|
// schieben und fleckige „Abschnitte" erzeugen — daher KEIN Bias.
|
||||||
|
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||||
|
stencil: wgpu::StencilState::default(),
|
||||||
|
bias: wgpu::DepthBiasState::default(),
|
||||||
|
}),
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: SAMPLE_COUNT,
|
||||||
|
mask: !0,
|
||||||
|
alpha_to_coverage_enabled: false,
|
||||||
|
},
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
// ClampToEdge (Randpixel ausserhalb der Bbox) + Linear (weiche Filterung).
|
||||||
|
let aerial_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("aerial.sampler"),
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
|
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
|
||||||
// damit die im Shader gesampelten Werte im linearen Raum landen (die
|
// damit die im Shader gesampelten Werte im linearen Raum landen (die
|
||||||
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
|
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
|
||||||
@@ -809,6 +974,14 @@ impl Renderer {
|
|||||||
cap_pipeline,
|
cap_pipeline,
|
||||||
cut_line_pipeline,
|
cut_line_pipeline,
|
||||||
textured_pipeline,
|
textured_pipeline,
|
||||||
|
aerial_pipeline,
|
||||||
|
aerial_bind_group_layout,
|
||||||
|
aerial_sampler,
|
||||||
|
aerial: None,
|
||||||
|
terrain_overlay: None,
|
||||||
|
edge_adj: Vec::new(),
|
||||||
|
silhouette: None,
|
||||||
|
silhouette_key: None,
|
||||||
texture_bind_group,
|
texture_bind_group,
|
||||||
texture,
|
texture,
|
||||||
texture_bind_group_layout,
|
texture_bind_group_layout,
|
||||||
@@ -823,6 +996,7 @@ impl Renderer {
|
|||||||
grid: None,
|
grid: None,
|
||||||
highlight: None,
|
highlight: None,
|
||||||
caps: None,
|
caps: None,
|
||||||
|
wall_end_caps: None,
|
||||||
cut_lines: None,
|
cut_lines: None,
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
style: RenderStyle::Shaded,
|
style: RenderStyle::Shaded,
|
||||||
@@ -859,19 +1033,63 @@ impl Renderer {
|
|||||||
build_scene_mesh(walls, slabs, meshes),
|
build_scene_mesh(walls, slabs, meshes),
|
||||||
build_scene_mesh_textured(walls, slabs, meshes),
|
build_scene_mesh_textured(walls, slabs, meshes),
|
||||||
);
|
);
|
||||||
|
// Terrain-Overlay-Puffer (nur Gelaende-Dreiecke) fuer das Luftbild-Drapieren.
|
||||||
|
let terrain = build_terrain_mesh(meshes);
|
||||||
|
self.terrain_overlay = if terrain.indices.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("terrain.overlay.vbo"),
|
||||||
|
contents: bytemuck::cast_slice(&terrain.verts),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
|
});
|
||||||
|
let ibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("terrain.overlay.ibo"),
|
||||||
|
contents: bytemuck::cast_slice(&terrain.indices),
|
||||||
|
usage: wgpu::BufferUsages::INDEX,
|
||||||
|
});
|
||||||
|
Some(MeshBuffers {
|
||||||
|
vbo,
|
||||||
|
ibo,
|
||||||
|
index_count: terrain.indices.len() as u32,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Schraffur-Kappen an freien (unangeschlossenen) Wandenden — statisch aus
|
||||||
|
// der Wandkonnektivitaet, unabhaengig vom Live-Schnitt-Zustand.
|
||||||
|
let end_caps = build_free_wall_end_caps(walls);
|
||||||
|
self.wall_end_caps = if end_caps.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("wall_end_caps.vbo"),
|
||||||
|
contents: bytemuck::cast_slice(&end_caps),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
|
});
|
||||||
|
Some(CapBuffers {
|
||||||
|
vbo,
|
||||||
|
vertex_count: (end_caps.len() / CAP_FLOATS_PER_VERTEX) as u32,
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
|
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
|
||||||
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
||||||
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
||||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
|
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
|
||||||
|
// Silhouette-Cache verwerfen (neues Modell) — wird bei Bedarf neu gebaut.
|
||||||
|
self.silhouette = None;
|
||||||
|
self.silhouette_key = None;
|
||||||
if mesh.indices.is_empty() {
|
if mesh.indices.is_empty() {
|
||||||
self.mesh = None;
|
self.mesh = None;
|
||||||
self.textured_mesh = None;
|
self.textured_mesh = None;
|
||||||
self.edges = None;
|
self.edges = None;
|
||||||
|
self.edge_adj = Vec::new();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kanten-Adjazenz einmalig ableiten: Grundlage der statischen Feature-Edges
|
||||||
|
// UND der blickabhaengigen Silhouette (Stil `Contour`).
|
||||||
|
self.edge_adj = build_edge_adjacency(&mesh);
|
||||||
// Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen.
|
// Kanten aus der Mesh-Geometrie ableiten und (falls vorhanden) hochladen.
|
||||||
let edge_verts = build_mesh_edges(&mesh);
|
let edge_verts = build_mesh_edges(&mesh);
|
||||||
self.edges = if edge_verts.is_empty() {
|
self.edges = if edge_verts.is_empty() {
|
||||||
@@ -1111,6 +1329,111 @@ impl Renderer {
|
|||||||
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
|
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
|
||||||
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
|
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
|
||||||
/// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck.
|
/// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck.
|
||||||
|
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
|
||||||
|
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord);
|
||||||
|
/// `bbox = [min_x, min_y, max_x, max_y]` die Modell-Meter-Bounding-Box, die das
|
||||||
|
/// Bild ausfuellt (x = Ost/world.x, y = Nord/world.z). Erzeugt Textur + Bind-
|
||||||
|
/// Group (group 1) fuer die `aerial_pipeline`. Ungueltige/leere Eingabe loescht
|
||||||
|
/// das Luftbild. Gezeichnet wird nur, wenn zusaetzlich Terrain vorliegt.
|
||||||
|
pub fn set_aerial_texture(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
bbox: [f32; 4],
|
||||||
|
) {
|
||||||
|
let needed = (width as usize) * (height as usize) * 4;
|
||||||
|
if width == 0 || height == 0 || rgba.len() < needed {
|
||||||
|
self.aerial = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("aerial.texture"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
// srgb: die gesampelten Werte landen linear (Beleuchtung rechnet linear).
|
||||||
|
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
// write_texture verlangt bytes_per_row als Vielfaches von 256. Bei Bedarf
|
||||||
|
// Zeilen in einen gepolsterten Puffer kopieren (sonst 1:1 hochladen).
|
||||||
|
let unpadded = (width * 4) as usize;
|
||||||
|
let padded = unpadded.div_ceil(256) * 256;
|
||||||
|
let upload: std::borrow::Cow<[u8]> = if padded == unpadded {
|
||||||
|
std::borrow::Cow::Borrowed(&rgba[..needed])
|
||||||
|
} else {
|
||||||
|
let mut buf = vec![0u8; padded * height as usize];
|
||||||
|
for row in 0..height as usize {
|
||||||
|
let src = &rgba[row * unpadded..row * unpadded + unpadded];
|
||||||
|
buf[row * padded..row * padded + unpadded].copy_from_slice(src);
|
||||||
|
}
|
||||||
|
std::borrow::Cow::Owned(buf)
|
||||||
|
};
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&upload,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(padded as u32),
|
||||||
|
rows_per_image: Some(height),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||||
|
let bbox_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("aerial.bbox"),
|
||||||
|
contents: bytemuck::cast_slice(&bbox),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
|
});
|
||||||
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("aerial.bind"),
|
||||||
|
layout: &self.aerial_bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.aerial_sampler),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: bbox_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
self.aerial = Some(AerialBuffers {
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
bbox: bbox_buf,
|
||||||
|
bind_group,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loescht das drapierte Luftbild (das Gelaende erscheint wieder einfarbig/shaded).
|
||||||
|
pub fn clear_aerial(&mut self) {
|
||||||
|
self.aerial = None;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_material_textures(
|
pub fn set_material_textures(
|
||||||
&mut self,
|
&mut self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
@@ -1307,6 +1630,51 @@ impl Renderer {
|
|||||||
|
|
||||||
self.ensure_depth(device, w, h);
|
self.ensure_depth(device, w, h);
|
||||||
self.ensure_msaa(device, w, h);
|
self.ensure_msaa(device, w, h);
|
||||||
|
|
||||||
|
// Silhouette (Stil `Contour`) je Blickwinkel neu ableiten — aber nur, wenn
|
||||||
|
// sich die Kamera merklich bewegt hat (Signatur-Cache), nicht blind je Frame.
|
||||||
|
if self.style == RenderStyle::Contour && !self.edge_adj.is_empty() {
|
||||||
|
let eye = camera.eye;
|
||||||
|
let mut fwd = [
|
||||||
|
camera.target[0] - eye[0],
|
||||||
|
camera.target[1] - eye[1],
|
||||||
|
camera.target[2] - eye[2],
|
||||||
|
];
|
||||||
|
let fl = (fwd[0] * fwd[0] + fwd[1] * fwd[1] + fwd[2] * fwd[2]).sqrt();
|
||||||
|
if fl > 1e-6 {
|
||||||
|
fwd = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
|
||||||
|
}
|
||||||
|
let perspective = matches!(camera.projection, crate::types::Projection::Perspective);
|
||||||
|
// Signatur: Auge (0.01 m) + Blickrichtung (0.001). Perspektive haengt
|
||||||
|
// von der Auge-Position ab, Ortho nur von der Richtung — beide erfasst.
|
||||||
|
let q = |v: f32, s: f32| (v * s).round() as i64;
|
||||||
|
let key = [
|
||||||
|
q(eye[0], 100.0),
|
||||||
|
q(eye[1], 100.0),
|
||||||
|
q(eye[2], 100.0),
|
||||||
|
q(fwd[0], 1000.0),
|
||||||
|
q(fwd[1], 1000.0),
|
||||||
|
q(fwd[2], 1000.0),
|
||||||
|
];
|
||||||
|
if self.silhouette.is_none() || self.silhouette_key != Some(key) {
|
||||||
|
let verts = build_silhouette_edges(&self.edge_adj, eye, fwd, perspective);
|
||||||
|
self.silhouette = if verts.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("silhouette.vbo"),
|
||||||
|
contents: bytemuck::cast_slice(&verts),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
|
});
|
||||||
|
Some(LineBuffers {
|
||||||
|
vbo,
|
||||||
|
vertex_count: (verts.len() / EDGE_FLOATS_PER_VERTEX) as u32,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
self.silhouette_key = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let depth_view = &self.depth.as_ref().unwrap().view;
|
let depth_view = &self.depth.as_ref().unwrap().view;
|
||||||
let msaa_view = &self.msaa.as_ref().unwrap().view;
|
let msaa_view = &self.msaa.as_ref().unwrap().view;
|
||||||
|
|
||||||
@@ -1375,6 +1743,38 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1a) Luftbild-Drape: das Terrain-Overlay ein zweites Mal texturiert
|
||||||
|
// ueber das (deckungsgleiche) Terrain im Hauptpuffer zeichnen. Nur
|
||||||
|
// wenn Flaechen gezeichnet werden, ein Luftbild gesetzt ist UND ein
|
||||||
|
// Terrain-Overlay vorliegt. Der Depth-Bias der Pipeline haelt es vor
|
||||||
|
// dem Terrain (kein Z-Fighting). group 1 = Bild-Textur + Bbox.
|
||||||
|
if self.style.draws_faces() {
|
||||||
|
if let (Some(a), Some(t)) = (&self.aerial, &self.terrain_overlay) {
|
||||||
|
pass.set_pipeline(&self.aerial_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
pass.set_bind_group(1, &a.bind_group, &[]);
|
||||||
|
pass.set_vertex_buffer(0, t.vbo.slice(..));
|
||||||
|
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
|
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1a2) Schraffur-Kappen an freien Wandenden (STATISCH, unabhaengig vom
|
||||||
|
// Live-Schnitt-Zustand — `mesh::build_free_wall_end_caps`): ein
|
||||||
|
// unangeschlossenes Wandende zeigt die Bauteil-Schraffur statt der
|
||||||
|
// Flaechenfarbe, wie ein echter Schnitt. Dieselbe `cap_pipeline`
|
||||||
|
// (Bias zur Kamera) wie die Live-Schnitt-Kappen — sitzt exakt auf
|
||||||
|
// der (weiterhin vorhandenen) flach eingefaerbten End-Kappe im
|
||||||
|
// Hauptpuffer und gewinnt den Tiefentest.
|
||||||
|
if self.style.draws_faces() {
|
||||||
|
if let Some(wc) = &self.wall_end_caps {
|
||||||
|
pass.set_pipeline(&self.cap_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
pass.set_vertex_buffer(0, wc.vbo.slice(..));
|
||||||
|
pass.draw(0..wc.vertex_count, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den
|
// 1b) Schnittflaechen-Kappen (Cut-Caps) NACH den Flaechen, VOR den
|
||||||
// Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde
|
// Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde
|
||||||
// mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt.
|
// mit der Diagonalschraffur (CAP_WGSL). Werden selbst NICHT gekappt.
|
||||||
@@ -1411,6 +1811,19 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2b) „Nur Konturen" (Stil Contour): die blickabhaengige Silhouette
|
||||||
|
// obenauf (tiefengetestet gegen die gebiasten Flaechen -> nur
|
||||||
|
// sichtbare Umrisslinien). Gleiche LineList-Pipeline wie die
|
||||||
|
// Feature-Edges, aber der reduzierte Kantensatz.
|
||||||
|
if self.style == RenderStyle::Contour {
|
||||||
|
if let Some(s) = &self.silhouette {
|
||||||
|
pass.set_pipeline(&self.grid_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
pass.set_vertex_buffer(0, s.vbo.slice(..));
|
||||||
|
pass.draw(0..s.vertex_count, 0..1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer ->
|
// 3) Bodengitter NACH dem Modell zeichnen (gemeinsamer Tiefenpuffer ->
|
||||||
// das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur
|
// das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur
|
||||||
// wenn sichtbar + vorhanden.
|
// wenn sichtbar + vorhanden.
|
||||||
|
|||||||
@@ -55,10 +55,13 @@ pub use types::{
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::mesh::{
|
use super::mesh::{
|
||||||
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
|
build_free_wall_end_caps, build_scene_mesh_textured, build_walls_mesh,
|
||||||
|
build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
|
||||||
};
|
};
|
||||||
|
use super::section_fill::CAP_FLOATS_PER_VERTEX;
|
||||||
use super::types::{
|
use super::types::{
|
||||||
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
Hatch, Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX,
|
||||||
|
TEXTURED_FLOATS_PER_VERTEX,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
||||||
@@ -161,6 +164,65 @@ mod tests {
|
|||||||
assert!((max_idx as usize) < mesh.vertex_count());
|
assert!((max_idx as usize) < mesh.vertex_count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `wall_x` mit gesetzter Bauteil-Schraffur (Voraussetzung fuer die Kappen).
|
||||||
|
fn wall_x_hatched(len: f32, thickness: f32, height: f32) -> WallInput {
|
||||||
|
WallInput {
|
||||||
|
hatch: Some(Hatch { pattern: 3, angle: 45.0, scale: 1.0, line_weight: 0.13 }),
|
||||||
|
..wall_x(len, thickness, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn isolierte_wand_ohne_hatch_bekommt_keine_kappen() {
|
||||||
|
// Bestehende Test-Fixtures (kein `hatch`) bleiben unangetastet: keine
|
||||||
|
// Kappen-Geometrie, unabhaengig davon, dass beide Enden frei sind.
|
||||||
|
let caps = build_free_wall_end_caps(&[wall_x(3.0, 0.2, 2.5)]);
|
||||||
|
assert!(caps.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn isolierte_wand_mit_hatch_bekommt_kappen_an_beiden_freien_enden() {
|
||||||
|
// Eine einzelne Wand hat KEINE Nachbarn -> beide Achsenenden sind frei ->
|
||||||
|
// je Ende eine Schraffur-Kappe (1 Schicht x 2 Dreiecke x 3 Vertices = 6
|
||||||
|
// Vertices je Ende, 12 insgesamt).
|
||||||
|
let caps = build_free_wall_end_caps(&[wall_x_hatched(3.0, 0.2, 2.5)]);
|
||||||
|
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12);
|
||||||
|
// Jeder Kappen-Vertex traegt die Schraffur-Parameter (Muster 3 = crosshatch).
|
||||||
|
for v in caps.chunks_exact(CAP_FLOATS_PER_VERTEX) {
|
||||||
|
assert_eq!(v[5], 3.0, "pattern");
|
||||||
|
assert!((v[6] - 45.0_f32.to_radians()).abs() < 1e-5, "angle_rad");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verbundene_wandenden_bekommen_keine_kappe_dort() {
|
||||||
|
// Zwei Waende treffen sich rechtwinklig bei (3,0) -> DIESES Ende ist bei
|
||||||
|
// beiden Waenden NICHT frei (Gehrung/Anschluss), die beiden anderen
|
||||||
|
// (freien) Enden bekommen weiterhin je eine Kappe -> 2 Kappen gesamt
|
||||||
|
// (nicht 4), macht 12 Vertices statt 24.
|
||||||
|
let a = wall_x_hatched(3.0, 0.2, 2.5); // (0,0) frei .. (3,0) verbunden
|
||||||
|
let b = WallInput {
|
||||||
|
start: [3.0, 0.0],
|
||||||
|
end: [3.0, 4.0],
|
||||||
|
..wall_x_hatched(4.0, 0.2, 2.5) // (3,0) verbunden .. (3,4) frei
|
||||||
|
};
|
||||||
|
let caps = build_free_wall_end_caps(&[a, b]);
|
||||||
|
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 12, "nur die 2 freien Enden -> 2 Kappen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_stoss_bekommt_ebenfalls_keine_kappe_am_knoten() {
|
||||||
|
// Drei Waende treffen sich am selben Punkt (T-Stoss, Gehrung bleibt
|
||||||
|
// rechtwinklig, s. compute_wall_miters) -> trotzdem gilt der Knoten als
|
||||||
|
// "beruehrt", KEINE der drei dortigen Enden bekommt eine Kappe.
|
||||||
|
let a = wall_x_hatched(3.0, 0.2, 2.5); // endet bei (3,0)
|
||||||
|
let b = WallInput { start: [3.0, 0.0], end: [3.0, 4.0], ..wall_x_hatched(4.0, 0.2, 2.5) };
|
||||||
|
let c = WallInput { start: [3.0, 0.0], end: [6.0, 0.0], ..wall_x_hatched(3.0, 0.2, 2.5) };
|
||||||
|
let caps = build_free_wall_end_caps(&[a, b, c]);
|
||||||
|
// Freie Enden: a.start(0,0), b.end(3,4), c.end(6,0) -> 3 Kappen (nicht 6).
|
||||||
|
assert_eq!(caps.len() / CAP_FLOATS_PER_VERTEX, 18);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mehrere_waende_addieren_sich() {
|
fn mehrere_waende_addieren_sich() {
|
||||||
let mesh = build_walls_mesh(&[
|
let mesh = build_walls_mesh(&[
|
||||||
@@ -840,6 +902,8 @@ mod tests {
|
|||||||
indices: vec![0, 1, 2],
|
indices: vec![0, 1, 2],
|
||||||
kind,
|
kind,
|
||||||
color,
|
color,
|
||||||
|
material_index: None,
|
||||||
|
vertex_colors: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -881,6 +945,73 @@ mod tests {
|
|||||||
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
|
assert_eq!([mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]], [0.1, 0.2, 0.3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_mesh_pro_vertex_farbe_wird_uebernommen() {
|
||||||
|
// Luftbild-Drape: jeder Vertex traegt eine eigene Farbe. Wird 1:1 an die
|
||||||
|
// Ausgabe-Vertices durchgereicht (statt der Einzelfarbe).
|
||||||
|
let mut mesh = Mesh::default();
|
||||||
|
append_context_mesh(
|
||||||
|
&mut mesh,
|
||||||
|
&MeshInput {
|
||||||
|
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||||
|
indices: vec![0, 1, 2],
|
||||||
|
kind: MeshKind::Terrain,
|
||||||
|
color: None,
|
||||||
|
material_index: None,
|
||||||
|
vertex_colors: vec![
|
||||||
|
1.0, 0.0, 0.0, // Vertex 0 rot
|
||||||
|
0.0, 1.0, 0.0, // Vertex 1 gruen
|
||||||
|
0.0, 0.0, 1.0, // Vertex 2 blau
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Vorderseite = erste 3 Vertices (Winding a,b,c): Farben 0,1,2.
|
||||||
|
assert_eq!([mesh.verts[6], mesh.verts[7], mesh.verts[8]], [1.0, 0.0, 0.0]);
|
||||||
|
assert_eq!([mesh.verts[15], mesh.verts[16], mesh.verts[17]], [0.0, 1.0, 0.0]);
|
||||||
|
assert_eq!([mesh.verts[24], mesh.verts[25], mesh.verts[26]], [0.0, 0.0, 1.0]);
|
||||||
|
// Zu kurzes vertex_colors-Array -> Einzelfarbe (kein Panic, kein Teilbezug).
|
||||||
|
let mut mesh2 = Mesh::default();
|
||||||
|
append_context_mesh(
|
||||||
|
&mut mesh2,
|
||||||
|
&MeshInput {
|
||||||
|
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||||
|
indices: vec![0, 1, 2],
|
||||||
|
kind: MeshKind::Terrain,
|
||||||
|
color: None,
|
||||||
|
material_index: None,
|
||||||
|
vertex_colors: vec![1.0, 0.0, 0.0], // nur 1 Vertex -> ignoriert
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
[mesh2.verts[6], mesh2.verts[7], mesh2.verts[8]],
|
||||||
|
MeshKind::Terrain.default_color()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kontext_mesh_materialindex_wandert_in_die_textured_layer() {
|
||||||
|
// Daecher (und potenziell andere Kontext-Meshes) koennen jetzt einen
|
||||||
|
// Material-Textur-Index tragen (`MeshInput::material_index`) — vorher
|
||||||
|
// war er fuer ALLE Kontext-Meshes hart auf -1 (Schachbrett) gesetzt, der
|
||||||
|
// Stil „Textured" konnte also nie eine echte Dachziegel-Textur zeigen.
|
||||||
|
let m = MeshInput {
|
||||||
|
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
|
||||||
|
indices: vec![0, 1, 2],
|
||||||
|
kind: MeshKind::Extrusion,
|
||||||
|
color: None,
|
||||||
|
material_index: Some(2),
|
||||||
|
vertex_colors: Vec::new(),
|
||||||
|
};
|
||||||
|
let tex = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m));
|
||||||
|
assert!(!tex.layers.is_empty());
|
||||||
|
assert!(tex.layers.iter().all(|&l| l == 2.0), "alle Vertices Ebene 2");
|
||||||
|
|
||||||
|
// Ohne material_index bleibt der Fallback (-1 -> Schachbrett).
|
||||||
|
let m2 = MeshInput { material_index: None, ..m };
|
||||||
|
let tex2 = build_scene_mesh_textured(&[], &[], std::slice::from_ref(&m2));
|
||||||
|
assert!(tex2.layers.iter().all(|&l| l == -1.0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
|
fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() {
|
||||||
let mut mesh = Mesh::default();
|
let mut mesh = Mesh::default();
|
||||||
@@ -892,6 +1023,8 @@ mod tests {
|
|||||||
indices: vec![0, 1, 9],
|
indices: vec![0, 1, 9],
|
||||||
kind: MeshKind::Imported,
|
kind: MeshKind::Imported,
|
||||||
color: None,
|
color: None,
|
||||||
|
material_index: None,
|
||||||
|
vertex_colors: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(mesh.vertex_count(), 0);
|
assert_eq!(mesh.vertex_count(), 0);
|
||||||
@@ -903,6 +1036,8 @@ mod tests {
|
|||||||
indices: vec![0, 1, 2],
|
indices: vec![0, 1, 2],
|
||||||
kind: MeshKind::Imported,
|
kind: MeshKind::Imported,
|
||||||
color: None,
|
color: None,
|
||||||
|
material_index: None,
|
||||||
|
vertex_colors: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(mesh.vertex_count(), 0);
|
assert_eq!(mesh.vertex_count(), 0);
|
||||||
@@ -1078,5 +1213,14 @@ mod tests {
|
|||||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||||
.validate(&tex_module)
|
.validate(&tex_module)
|
||||||
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||||
|
|
||||||
|
// Luftbild-Drape-Shader (Terrain-Overlay, Bild-Textur + Bbox-Uniform in
|
||||||
|
// group 1) ebenso headless validieren.
|
||||||
|
let aerial_src = super::shaders::MESH_AERIAL_WGSL;
|
||||||
|
let aerial_module = naga::front::wgsl::parse_str(aerial_src)
|
||||||
|
.unwrap_or_else(|e| panic!("aerial: WGSL-Parse-Fehler: {e:?}"));
|
||||||
|
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||||
|
.validate(&aerial_module)
|
||||||
|
.unwrap_or_else(|e| panic!("aerial: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -835,6 +835,160 @@ fn compute_wall_miters(walls: &[WallInput]) -> Vec<WallMiters> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ermittelt je Wand, ob ihr Start-/Endpunkt ein FREIES Ende ist — d. h. KEIN
|
||||||
|
/// anderes Wandende (auch kein T-/X-Stoss) trifft sich dort im selben
|
||||||
|
/// Hoehenbereich. Dieselbe Punkt-Rundung + Hoehen-Ueberlappungs-Clustering wie
|
||||||
|
/// `compute_wall_miters`, aber OHNE dessen Zwei-Wandenden-Einschraenkung: jeder
|
||||||
|
/// Cluster der Groesse ≥ 2 (Gehrung ODER T-/X-Stoss) gilt als "beruehrt", nur
|
||||||
|
/// ein isolierter Cluster der Groesse 1 (nur die Wand selbst) bleibt frei.
|
||||||
|
/// Grundlage der Schraffur-Kappen an unverbundenen Wandenden
|
||||||
|
/// (`build_free_wall_end_caps`) — ein Wandende "ohne Anschluss" (Nutzer-Begriff)
|
||||||
|
/// zeigt dort die Bauteil-Schraffur statt der Flaechenfarbe, wie ein echter
|
||||||
|
/// Schnitt.
|
||||||
|
fn compute_free_wall_ends(walls: &[WallInput]) -> Vec<(bool, bool)> {
|
||||||
|
let mut out = vec![(true, true); walls.len()];
|
||||||
|
|
||||||
|
let mut ends: Vec<EndRef> = Vec::with_capacity(walls.len() * 2);
|
||||||
|
for (i, w) in walls.iter().enumerate() {
|
||||||
|
let n = left_normal(w.start, w.end);
|
||||||
|
if n == [0.0, 0.0] {
|
||||||
|
continue; // entartete Wand: weder Ende gilt als "frei" (irrelevant)
|
||||||
|
}
|
||||||
|
let dir: Point2 = [n[1], -n[0]];
|
||||||
|
let z0 = w.base_elevation;
|
||||||
|
let z1 = w.base_elevation + w.height;
|
||||||
|
ends.push(EndRef { wall: i, is_start: true, point: w.start, dir, z0, z1 });
|
||||||
|
ends.push(EndRef { wall: i, is_start: false, point: w.end, dir, z0, z1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut groups: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||||
|
for (idx, e) in ends.iter().enumerate() {
|
||||||
|
groups.entry(round_key(e.point)).or_default().push(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
for members in groups.into_values() {
|
||||||
|
if members.len() < 2 {
|
||||||
|
continue; // einziges Ende an diesem Punkt -> bleibt frei (Default)
|
||||||
|
}
|
||||||
|
for cluster in cluster_by_height_overlap(&ends, &members) {
|
||||||
|
if cluster.len() < 2 {
|
||||||
|
continue; // eigener, hoehenmaessig isolierter Cluster -> frei
|
||||||
|
}
|
||||||
|
for &idx in &cluster {
|
||||||
|
let e = &ends[idx];
|
||||||
|
if e.is_start {
|
||||||
|
out[e.wall].0 = false;
|
||||||
|
} else {
|
||||||
|
out[e.wall].1 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Haengt EINE Schraffur-Kappe (alle Schicht-Baender gestapelt) an einem
|
||||||
|
/// Wand-Achsenende an `verts` an — Vertex-Format identisch zu
|
||||||
|
/// `section_fill::build_cut_caps` (`CAP_FLOATS_PER_VERTEX`), damit dieselbe
|
||||||
|
/// `cap_pipeline`/`CAP_WGSL` sie zeichnen kann. `(u,v)` = (Quer-Versatz `s` zur
|
||||||
|
/// Wandachse, Welt-Hoehe) — metrisch, an den Schicht-Grenzen stetig.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn push_wall_end_cap(
|
||||||
|
verts: &mut Vec<f32>,
|
||||||
|
point: Point2,
|
||||||
|
n: Point2,
|
||||||
|
layers: &[WallLayer],
|
||||||
|
total: f32,
|
||||||
|
y0: f32,
|
||||||
|
y1: f32,
|
||||||
|
pattern: f32,
|
||||||
|
angle_rad: f32,
|
||||||
|
scale: f32,
|
||||||
|
line_weight: f32,
|
||||||
|
) {
|
||||||
|
let corner = |s: f32, y: f32| -> [f32; 9] {
|
||||||
|
let p = [point[0] + n[0] * s, point[1] + n[1] * s];
|
||||||
|
[p[0], y, p[1], s, y, pattern, angle_rad, scale, line_weight]
|
||||||
|
};
|
||||||
|
let mut off_a = total * 0.5;
|
||||||
|
for layer in layers {
|
||||||
|
let off_b = off_a - layer.thickness;
|
||||||
|
let c0 = corner(off_a, y0);
|
||||||
|
let c1 = corner(off_b, y0);
|
||||||
|
let c2 = corner(off_b, y1);
|
||||||
|
let c3 = corner(off_a, y1);
|
||||||
|
// Fan-Triangulierung des Quads (Winding egal, cap_pipeline kennt kein
|
||||||
|
// Backface-Culling — siehe section_fill.rs-Moduldoc).
|
||||||
|
verts.extend_from_slice(&c0);
|
||||||
|
verts.extend_from_slice(&c1);
|
||||||
|
verts.extend_from_slice(&c2);
|
||||||
|
verts.extend_from_slice(&c0);
|
||||||
|
verts.extend_from_slice(&c2);
|
||||||
|
verts.extend_from_slice(&c3);
|
||||||
|
off_a = off_b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schraffur-Kappen an FREIEN Wandenden (kein Anschluss an eine andere Wand,
|
||||||
|
/// s. `compute_free_wall_ends`): additive, ZWEITE Geometrie neben der
|
||||||
|
/// (unveraendert weiterhin flach eingefaerbten) End-Kappe der normalen
|
||||||
|
/// Extrusion — gezeichnet mit Tiefen-Bias VOR der Flaeche (analog dem
|
||||||
|
/// Luftbild-Overlay `gpu::aerial_pipeline`), sodass sie diese optisch
|
||||||
|
/// ueberdeckt, OHNE `extrude_layer_segment` selbst anzufassen (kein
|
||||||
|
/// Regressionsrisiko fuer bestehende Vollkoerper-Tests). Nur Waende MIT
|
||||||
|
/// explizit gesetzter `hatch` (Bauteil-Schraffur, `toWalls3d.ts` loest sie je
|
||||||
|
/// Materialschicht auf) bekommen eine Kappe — ohne `hatch` (z. B. alle
|
||||||
|
/// bestehenden Test-Fixtures) bleibt das Alt-Verhalten unangetastet. Bildet NUR
|
||||||
|
/// die echten Achsen-Enden ab (Wandanfang/-ende), keine Oeffnungs-Laibungen —
|
||||||
|
/// "Wand endet an Decke" (vertikale Terminierung) ist bewusst NICHT Teil dieser
|
||||||
|
/// Kappen (separates, noch offenes Thema).
|
||||||
|
pub fn build_free_wall_end_caps(walls: &[WallInput]) -> Vec<f32> {
|
||||||
|
let free_ends = compute_free_wall_ends(walls);
|
||||||
|
let mut verts: Vec<f32> = Vec::new();
|
||||||
|
|
||||||
|
for (wi, wall) in walls.iter().enumerate() {
|
||||||
|
let Some(hatch) = wall.hatch else { continue };
|
||||||
|
let (free_start, free_end) = free_ends[wi];
|
||||||
|
if !free_start && !free_end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = left_normal(wall.start, wall.end);
|
||||||
|
if n == [0.0, 0.0] || wall.height <= 1e-6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let y0 = wall.base_elevation;
|
||||||
|
let y1 = wall.base_elevation + wall.height;
|
||||||
|
|
||||||
|
let synthetic;
|
||||||
|
let layers: &[WallLayer] = match &wall.layers {
|
||||||
|
Some(ls) if !ls.is_empty() => ls.as_slice(),
|
||||||
|
_ => {
|
||||||
|
synthetic = [WallLayer { thickness: wall.thickness, color: wall.color }];
|
||||||
|
&synthetic
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total: f32 = layers.iter().map(|l| l.thickness).sum();
|
||||||
|
let pattern = hatch.pattern as f32;
|
||||||
|
let angle_rad = hatch.angle.to_radians();
|
||||||
|
let scale = hatch.scale.max(0.05);
|
||||||
|
let line_weight = hatch.line_weight.max(0.0);
|
||||||
|
|
||||||
|
if free_start {
|
||||||
|
push_wall_end_cap(
|
||||||
|
&mut verts, wall.start, n, layers, total, y0, y1, pattern, angle_rad, scale,
|
||||||
|
line_weight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if free_end {
|
||||||
|
push_wall_end_cap(
|
||||||
|
&mut verts, wall.end, n, layers, total, y0, y1, pattern, angle_rad, scale,
|
||||||
|
line_weight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verts
|
||||||
|
}
|
||||||
|
|
||||||
/// Grundriss-Fussabdruck EINER Wand (die vier Band-Eckpunkte g0..g3 in CCW),
|
/// Grundriss-Fussabdruck EINER Wand (die vier Band-Eckpunkte g0..g3 in CCW),
|
||||||
/// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion
|
/// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion
|
||||||
/// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine
|
/// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine
|
||||||
@@ -1026,7 +1180,10 @@ pub fn build_scene_mesh_textured(
|
|||||||
for m in meshes {
|
for m in meshes {
|
||||||
let before = mesh.vertex_count();
|
let before = mesh.vertex_count();
|
||||||
append_context_mesh(&mut mesh, m);
|
append_context_mesh(&mut mesh, m);
|
||||||
fill(&mesh, &mut layers, before, -1.0);
|
// Material-Ebene des Kontext-Meshes (aktuell nur Daecher setzen sie,
|
||||||
|
// s. `MeshInput::material_index`); ohne -> Fallback-Schachbrett.
|
||||||
|
let layer = m.material_index.map(|i| i as f32).unwrap_or(-1.0);
|
||||||
|
fill(&mesh, &mut layers, before, layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut tex = textured_from_mesh(&mesh);
|
let mut tex = textured_from_mesh(&mesh);
|
||||||
@@ -1063,6 +1220,20 @@ pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[Mesh
|
|||||||
mesh
|
mesh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Baut NUR die Gelaende-Dreiecke (`MeshKind::Terrain`) zu EINEM Mesh — Grundlage
|
||||||
|
/// des Luftbild-Overlays (`gpu::aerial_pipeline`): dieselbe Geometrie wie im
|
||||||
|
/// Hauptpuffer, aber separat, damit sie ein zweites Mal texturiert obenauf
|
||||||
|
/// gezeichnet werden kann. Leer, wenn kein Terrain vorliegt.
|
||||||
|
pub fn build_terrain_mesh(meshes: &[MeshInput]) -> Mesh {
|
||||||
|
let mut mesh = Mesh::default();
|
||||||
|
for m in meshes {
|
||||||
|
if matches!(m.kind, crate::types::MeshKind::Terrain) {
|
||||||
|
append_context_mesh(&mut mesh, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mesh
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
|
// ── Rohe Kontext-Meshes (Terrain / importierte Volumen) ──────────────────────
|
||||||
|
|
||||||
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
|
/// Haengt EIN rohes Dreiecks-Mesh (Terrain-TIN oder importiertes Volumen, siehe
|
||||||
@@ -1085,6 +1256,21 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
|||||||
let pos = &input.positions;
|
let pos = &input.positions;
|
||||||
let vcount = pos.len() / 3;
|
let vcount = pos.len() / 3;
|
||||||
let color = input.effective_color();
|
let color = input.effective_color();
|
||||||
|
// Pro-Vertex-Farben nur nutzen, wenn genau ein RGB je Vertex vorliegt
|
||||||
|
// (sonst greift die Einzelfarbe fuers ganze Mesh). Beispiel: Luftbild-Drape.
|
||||||
|
let per_vertex = input.vertex_colors.len() >= vcount * 3 && vcount > 0;
|
||||||
|
let vcol = |i: usize| -> Rgb {
|
||||||
|
if per_vertex {
|
||||||
|
let b = i * 3;
|
||||||
|
[
|
||||||
|
input.vertex_colors[b],
|
||||||
|
input.vertex_colors[b + 1],
|
||||||
|
input.vertex_colors[b + 2],
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
color
|
||||||
|
}
|
||||||
|
};
|
||||||
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
|
// world-Position aus Modell (x, y, z=Hoehe): (x, hoehe, y).
|
||||||
let vworld = |i: usize| -> [f32; 3] {
|
let vworld = |i: usize| -> [f32; 3] {
|
||||||
let b = i * 3;
|
let b = i * 3;
|
||||||
@@ -1099,6 +1285,7 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
|||||||
let a = vworld(ia);
|
let a = vworld(ia);
|
||||||
let b = vworld(ib);
|
let b = vworld(ib);
|
||||||
let c = vworld(ic);
|
let c = vworld(ic);
|
||||||
|
let (ca, cb, cc) = (vcol(ia), vcol(ib), vcol(ic));
|
||||||
// Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen.
|
// Flaechen-Normale (b-a) x (c-a); entartete Dreiecke ueberspringen.
|
||||||
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||||
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||||||
@@ -1113,8 +1300,8 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) {
|
|||||||
}
|
}
|
||||||
let n = [gn[0] / len, gn[1] / len, gn[2] / len];
|
let n = [gn[0] / len, gn[1] / len, gn[2] / len];
|
||||||
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
|
// Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig).
|
||||||
push_ctx_tri(mesh, a, b, c, n, color);
|
push_ctx_tri(mesh, a, b, c, n, [ca, cb, cc]);
|
||||||
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], color);
|
push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], [ca, cc, cb]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,10 +1314,10 @@ fn push_ctx_tri(
|
|||||||
b: [f32; 3],
|
b: [f32; 3],
|
||||||
c: [f32; 3],
|
c: [f32; 3],
|
||||||
normal: [f32; 3],
|
normal: [f32; 3],
|
||||||
color: Rgb,
|
colors: [Rgb; 3],
|
||||||
) {
|
) {
|
||||||
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
|
let base = (mesh.verts.len() / FLOATS_PER_VERTEX) as u32;
|
||||||
for p in [a, b, c] {
|
for (p, color) in [a, b, c].iter().zip(colors.iter()) {
|
||||||
mesh.verts.extend_from_slice(&[
|
mesh.verts.extend_from_slice(&[
|
||||||
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
|
p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -220,6 +220,90 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
|||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// WGSL des LUFTBILD-Drapierens aufs Gelände (`gpu::aerial_pipeline`). Zeichnet die
|
||||||
|
/// TERRAIN-Dreiecke ein zweites Mal, texturiert mit EINEM georeferenzierten Luftbild
|
||||||
|
/// (SWISSIMAGE). Die UV kommen NICHT aus einem Vertex-Attribut, sondern werden im
|
||||||
|
/// Fragment aus der WELT-Lage (world.x = Ost, world.z = Nord) und der Bild-Bounding-
|
||||||
|
/// Box (group 1, Uniform `bbox = (min_x, min_y, max_x, max_y)` in Modell-Metern)
|
||||||
|
/// berechnet — dadurch ist die Auflösung PIXELSCHARF (unabhängig von der TIN-Dichte,
|
||||||
|
/// anders als die Pro-Vertex-Färbung). Beleuchtung identisch zu `MESH_WGSL`. Vertex-
|
||||||
|
/// Layout = das Haupt-Mesh-Layout `[pos, normal, color]` (color wird ignoriert), damit
|
||||||
|
/// derselbe Vertex-Puffer (Terrain-Overlay) ohne Re-Meshing genutzt werden kann.
|
||||||
|
/// Bild-Zeile 0 liegt im NORDEN (+Y) -> die Textur-v-Achse wird gespiegelt (1 - v).
|
||||||
|
pub const MESH_AERIAL_WGSL: &str = r#"
|
||||||
|
struct Globals {
|
||||||
|
view_proj : mat4x4<f32>,
|
||||||
|
light_dir : vec4<f32>,
|
||||||
|
sky_color : vec4<f32>,
|
||||||
|
ground_color : vec4<f32>,
|
||||||
|
sun_color : vec4<f32>,
|
||||||
|
mode : vec4<f32>,
|
||||||
|
section_plane : vec4<f32>,
|
||||||
|
};
|
||||||
|
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||||
|
|
||||||
|
// Luftbild-Textur + Sampler + Bounding-Box (group 1). `bbox = (min_x, min_y,
|
||||||
|
// max_x, max_y)` in Modell-Metern (x = Ost/world.x, y = Nord/world.z).
|
||||||
|
@group(1) @binding(0) var tex : texture_2d<f32>;
|
||||||
|
@group(1) @binding(1) var samp : sampler;
|
||||||
|
struct Aerial { bbox : vec4<f32>, };
|
||||||
|
@group(1) @binding(2) var<uniform> aerial : Aerial;
|
||||||
|
|
||||||
|
struct VsIn {
|
||||||
|
@location(0) position : vec3<f32>,
|
||||||
|
@location(1) normal : vec3<f32>,
|
||||||
|
@location(2) color : vec3<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct VsOut {
|
||||||
|
@builtin(position) clip_pos : vec4<f32>,
|
||||||
|
@location(0) world_normal : vec3<f32>,
|
||||||
|
@location(1) world_pos : vec3<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(in : VsIn) -> VsOut {
|
||||||
|
var out : VsOut;
|
||||||
|
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||||
|
out.world_normal = in.normal;
|
||||||
|
out.world_pos = in.position;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||||
|
// Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv).
|
||||||
|
if (globals.mode.y > 0.5) {
|
||||||
|
if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n = normalize(in.world_normal);
|
||||||
|
let l = normalize(globals.light_dir.xyz);
|
||||||
|
|
||||||
|
// Hemisphaerisches Ambient (identisch zu MESH_WGSL).
|
||||||
|
let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||||
|
let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t);
|
||||||
|
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
|
||||||
|
let fill_l = normalize(vec3<f32>(-l.x, abs(l.y) * 0.35 + 0.15, -l.z));
|
||||||
|
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
|
||||||
|
* vec3<f32>(0.24, 0.27, 0.30);
|
||||||
|
|
||||||
|
// UV aus der Welt-Lage + Bild-Bbox. world.x = Ost, world.z = Nord.
|
||||||
|
let span = vec2<f32>(aerial.bbox.z - aerial.bbox.x, aerial.bbox.w - aerial.bbox.y);
|
||||||
|
let u = (in.world_pos.x - aerial.bbox.x) / span.x;
|
||||||
|
let v = (in.world_pos.z - aerial.bbox.y) / span.y;
|
||||||
|
// Bild-Zeile 0 = Nord (+Y) -> v spiegeln. Ausserhalb [0,1] -> Randpixel (Clamp
|
||||||
|
// uebernimmt der Sampler mit ClampToEdge).
|
||||||
|
let albedo = textureSample(tex, samp, vec2<f32>(u, 1.0 - v)).rgb;
|
||||||
|
|
||||||
|
var shaded = albedo * (ambient + diffuse + fill);
|
||||||
|
let edge = 0.90 + 0.10 * abs(n.y);
|
||||||
|
shaded = shaded * edge;
|
||||||
|
return vec4<f32>(shaded, 1.0);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
|
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
|
||||||
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
||||||
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
||||||
|
|||||||
@@ -336,6 +336,20 @@ pub struct MeshInput {
|
|||||||
/// Default-Farbe je `kind` (siehe `effective_color`).
|
/// Default-Farbe je `kind` (siehe `effective_color`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color: Option<Rgb>,
|
pub color: Option<Rgb>,
|
||||||
|
/// Optionaler Material-Textur-Index fuer den Stil `Textured` (1-basiert,
|
||||||
|
/// Ebene 0 = Fallback-Schachbrett) — analog `WallInput::material_index`.
|
||||||
|
/// Bislang nutzen das nur Daecher (`toWalls3d.ts::emitRoofs`); andere
|
||||||
|
/// Kontext-Meshes (Terrain/Import) bleiben ohne (`None` -> Schachbrett).
|
||||||
|
#[serde(default, rename = "materialIndex")]
|
||||||
|
pub material_index: Option<u32>,
|
||||||
|
/// Optionale PRO-VERTEX-Albedo (flaches `[r,g,b, r,g,b, ...]`, 0..1) — ein
|
||||||
|
/// Wert je Positions-Vertex. Wird u. a. genutzt, um ein SWISSIMAGE-Luftbild
|
||||||
|
/// auf das Gelaende-TIN zu „drapieren" (jeder Vertex traegt den Bildpunkt an
|
||||||
|
/// seiner Lage). Ist das Array leer oder zu kurz, greift die Einzelfarbe
|
||||||
|
/// (`effective_color`) fuer das ganze Mesh. Die Farben werden im Shaded-Pfad
|
||||||
|
/// pro Vertex interpoliert (kein Textur-Sampler noetig).
|
||||||
|
#[serde(default, rename = "vertexColors")]
|
||||||
|
pub vertex_colors: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MeshInput {
|
impl MeshInput {
|
||||||
@@ -398,8 +412,15 @@ fn default_ortho_half_height() -> f32 {
|
|||||||
fn default_near() -> f32 {
|
fn default_near() -> f32 {
|
||||||
0.05
|
0.05
|
||||||
}
|
}
|
||||||
fn default_far() -> f32 {
|
// `pub(crate)`, damit `web::set_camera` denselben Basiswert als Untergrenze fuer
|
||||||
1000.0
|
// die modellgroessen-abhaengige Far-Ebene nutzen kann (s. Moduldoc dort).
|
||||||
|
// War 1000.0 — Nutzer-Report: Teile des Modells (und das Boden-Referenzgitter)
|
||||||
|
// verschwanden abhaengig von Kamera-Distanz/-Winkel. Grosszuegig angehoben,
|
||||||
|
// damit im ueblichen Gebrauch (auch grosse/georeferenzierte Situationen) nie
|
||||||
|
// etwas am Fern-Clipping haengenbleibt; `set_camera` skaliert ohnehin
|
||||||
|
// zusaetzlich mit der tatsaechlichen Modell-Bounding-Box nach oben.
|
||||||
|
pub(crate) fn default_far() -> f32 {
|
||||||
|
50_000.0
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Camera {
|
impl Default for Camera {
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ pub struct WebModelRenderer {
|
|||||||
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
|
/// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx):
|
||||||
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
|
/// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante.
|
||||||
/// `None` bei leerem Modell.
|
/// `None` bei leerem Modell.
|
||||||
fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [f32; 3])> {
|
fn model_bounds(
|
||||||
|
walls: &[WallInput],
|
||||||
|
slabs: &[SlabInput],
|
||||||
|
meshes: &[MeshInput],
|
||||||
|
) -> Option<([f32; 3], [f32; 3])> {
|
||||||
let mut min = [f32::INFINITY; 3];
|
let mut min = [f32::INFINITY; 3];
|
||||||
let mut max = [f32::NEG_INFINITY; 3];
|
let mut max = [f32::NEG_INFINITY; 3];
|
||||||
let mut grow = |x: f32, y: f32, z: f32| {
|
let mut grow = |x: f32, y: f32, z: f32| {
|
||||||
@@ -147,6 +151,17 @@ fn model_bounds(walls: &[WallInput], slabs: &[SlabInput]) -> Option<([f32; 3], [
|
|||||||
grow(p[0], s.z_top, p[1]);
|
grow(p[0], s.z_top, p[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Meshes (Daecher, Extrusionen, Stuetzen UND importierte Gebaeude/Terrain):
|
||||||
|
// model (x,y,z=Hoehe) -> world (x, z, y). Ohne sie kann ein Praeset/Fit den
|
||||||
|
// (evtl. weit entfernten) Import nicht rahmen.
|
||||||
|
for m in meshes {
|
||||||
|
let p = &m.positions;
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 2 < p.len() {
|
||||||
|
grow(p[i], p[i + 2], p[i + 1]);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
if !min[0].is_finite() {
|
if !min[0].is_finite() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -247,7 +262,7 @@ impl WebModelRenderer {
|
|||||||
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
|
pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> {
|
||||||
let model: ModelInput = serde_json::from_str(json)
|
let model: ModelInput = serde_json::from_str(json)
|
||||||
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
|
.map_err(|e| JsValue::from_str(&format!("Modell parsen: {e}")))?;
|
||||||
self.bounds = model_bounds(&model.walls, &model.slabs);
|
self.bounds = model_bounds(&model.walls, &model.slabs, &model.meshes);
|
||||||
self.renderer
|
self.renderer
|
||||||
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
|
.upload_model(&self.device, &model.walls, &model.slabs, &model.meshes);
|
||||||
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
|
// Wand-/Decken-Eingabe fuer den Live-Schnitt cachen (Klon, da der Renderer
|
||||||
@@ -300,6 +315,15 @@ impl WebModelRenderer {
|
|||||||
/// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet
|
/// (`ortho_half_height` = halbe Sichthoehe in Metern). Die TS-Seite berechnet
|
||||||
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
|
/// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp)
|
||||||
/// — siehe `useWasm3dRenderer`/`Wasm3DViewport`.
|
/// — siehe `useWasm3dRenderer`/`Wasm3DViewport`.
|
||||||
|
///
|
||||||
|
/// FAR-EBENE: die feste `Camera::default()`-Far-Ebene (1000 m) reicht bei
|
||||||
|
/// grossen georeferenzierten Importen (Suchradius bis 2000 m, Bounding-Kugel
|
||||||
|
/// entsprechend gross) nicht mehr — Teile des Modells wurden je nach
|
||||||
|
/// Blickwinkel/Distanz VOM FERN-CLIPPING abgeschnitten (Nutzer-Report: "Teile
|
||||||
|
/// im 3D nicht sichtbar in einem Winkel"). Die Far-Ebene wird deshalb hier je
|
||||||
|
/// Aufruf aus der zuletzt hochgeladenen Modell-Bounding-Box abgeleitet: der
|
||||||
|
/// weiteste Bbox-Eckpunkt vom Auge aus + Sicherheitsmarge, mindestens der
|
||||||
|
/// bisherige Default (kleine Modelle bleiben unveraendert).
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_camera(
|
pub fn set_camera(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -316,6 +340,32 @@ impl WebModelRenderer {
|
|||||||
fov_y: f32,
|
fov_y: f32,
|
||||||
ortho_half_height: f32,
|
ortho_half_height: f32,
|
||||||
) {
|
) {
|
||||||
|
let far = match self.bounds {
|
||||||
|
Some((min, max)) => {
|
||||||
|
let corners = [
|
||||||
|
[min[0], min[1], min[2]],
|
||||||
|
[max[0], min[1], min[2]],
|
||||||
|
[min[0], max[1], min[2]],
|
||||||
|
[max[0], max[1], min[2]],
|
||||||
|
[min[0], min[1], max[2]],
|
||||||
|
[max[0], min[1], max[2]],
|
||||||
|
[min[0], max[1], max[2]],
|
||||||
|
[max[0], max[1], max[2]],
|
||||||
|
];
|
||||||
|
let mut max_dist = 0.0f32;
|
||||||
|
for c in corners {
|
||||||
|
let dx = c[0] - eye_x;
|
||||||
|
let dy = c[1] - eye_y;
|
||||||
|
let dz = c[2] - eye_z;
|
||||||
|
let d = (dx * dx + dy * dy + dz * dz).sqrt();
|
||||||
|
if d > max_dist {
|
||||||
|
max_dist = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(max_dist * 1.5).max(crate::types::default_far())
|
||||||
|
}
|
||||||
|
None => crate::types::default_far(),
|
||||||
|
};
|
||||||
self.camera = Camera {
|
self.camera = Camera {
|
||||||
eye: [eye_x, eye_y, eye_z],
|
eye: [eye_x, eye_y, eye_z],
|
||||||
target: [target_x, target_y, target_z],
|
target: [target_x, target_y, target_z],
|
||||||
@@ -327,6 +377,7 @@ impl WebModelRenderer {
|
|||||||
},
|
},
|
||||||
fov_y,
|
fov_y,
|
||||||
ortho_half_height,
|
ortho_half_height,
|
||||||
|
far,
|
||||||
..Camera::default()
|
..Camera::default()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -455,6 +506,39 @@ impl WebModelRenderer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt/ersetzt das aufs Gelaende drapierte Luftbild (SWISSIMAGE). `rgba` =
|
||||||
|
/// dicht gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von OBEN = Nord),
|
||||||
|
/// die der JS-Aufrufer aus der Bild-Data-URL dekodiert (siehe
|
||||||
|
/// `viewport/aerialTexture.ts`). `min_x/min_y/max_x/max_y` = Modell-Meter-
|
||||||
|
/// Bounding-Box (x = Ost, y = Nord). Ungueltige/leere Eingabe loescht das
|
||||||
|
/// Luftbild. Wirkt erst beim naechsten `render`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn set_aerial_texture(
|
||||||
|
&mut self,
|
||||||
|
rgba: &[u8],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
min_x: f32,
|
||||||
|
min_y: f32,
|
||||||
|
max_x: f32,
|
||||||
|
max_y: f32,
|
||||||
|
) -> Result<(), JsValue> {
|
||||||
|
self.renderer.set_aerial_texture(
|
||||||
|
&self.device,
|
||||||
|
&self.queue,
|
||||||
|
rgba,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
[min_x, min_y, max_x, max_y],
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loescht das drapierte Luftbild (Gelaende erscheint wieder einfarbig/shaded).
|
||||||
|
pub fn clear_aerial(&mut self) {
|
||||||
|
self.renderer.clear_aerial();
|
||||||
|
}
|
||||||
|
|
||||||
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||||
pub fn resize(&mut self, width: u32, height: u32) {
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
let (w, h) = (width.max(1), height.max(1));
|
let (w, h) = (width.max(1), height.max(1));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "cad",
|
"productName": "Dossier",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "ch.dossier.cad",
|
"identifier": "ch.dossier.cad",
|
||||||
"build": {
|
"build": {
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "cad",
|
"title": "Dossier",
|
||||||
"width": 1400,
|
"width": 1400,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
"dragDropEnabled": false,
|
"dragDropEnabled": false,
|
||||||
"titleBarStyle": "Overlay",
|
"titleBarStyle": "Overlay",
|
||||||
"hiddenTitle": true
|
"hiddenTitle": true,
|
||||||
|
"trafficLightPosition": { "x": 20, "y": 22 }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -308,6 +308,49 @@ describe("exportIfcSpf — IFC4-Export", () => {
|
|||||||
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
|
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("georeferenziert: mit geoAnchor werden die ECHTEN LV95-Koordinaten geschrieben (lokal bearbeitet, weltweit lagerichtig)", () => {
|
||||||
|
// Modell lokal nahe (0,0), aber geoAnchor sagt: Modell (0,0) = LV95
|
||||||
|
// (2600000,1200000). Der Export muss die Profil-/Punkt-Koordinaten auf die
|
||||||
|
// echten Landeskoordinaten (Millionenbereich) verschoben schreiben.
|
||||||
|
const local = fixtureProject();
|
||||||
|
const withoutAnchor = exportIfcSpf(local);
|
||||||
|
// Ohne Bezug: lokale Koordinaten, KEINE Millionenwerte im Text.
|
||||||
|
expect(withoutAnchor).not.toMatch(/26000\d\d\./);
|
||||||
|
|
||||||
|
const geo: Project = {
|
||||||
|
...local,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
|
||||||
|
};
|
||||||
|
const spf = exportIfcSpf(geo);
|
||||||
|
// W1.start (0,0) -> LV95 (2600000,1200000); W2.end (5,4) -> (2600005,1200004).
|
||||||
|
expect(spf).toContain("2600000.");
|
||||||
|
expect(spf).toContain("1200000.");
|
||||||
|
expect(spf).toContain("2600005.");
|
||||||
|
// Struktur bleibt valide (keine dangling refs / Duplikate).
|
||||||
|
const { defined, referenced, duplicates } = collectIds(spf);
|
||||||
|
expect(duplicates).toEqual([]);
|
||||||
|
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geoAnchor.height wird als IfcSite.RefElevation geschrieben (Vermessungspunkt-Höhe)", () => {
|
||||||
|
const local = fixtureProject();
|
||||||
|
const geo: Project = {
|
||||||
|
...local,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, height: 683.5, model: { x: 0, y: 0 } },
|
||||||
|
};
|
||||||
|
const spf = exportIfcSpf(geo);
|
||||||
|
// Die IFCSITE-Zeile trägt die reale Datumshöhe (RefElevation, 12. Attribut).
|
||||||
|
const siteLine = spf.split("\n").find((l) => l.includes("=IFCSITE("));
|
||||||
|
expect(siteLine).toBeDefined();
|
||||||
|
expect(siteLine).toContain("683.5");
|
||||||
|
// Ohne Höhe bleibt RefElevation leer ($) — kein 683.5 irgendwo.
|
||||||
|
const noH = exportIfcSpf({
|
||||||
|
...local,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
|
||||||
|
});
|
||||||
|
expect(noH).not.toContain("683.5");
|
||||||
|
});
|
||||||
|
|
||||||
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
|
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
|
||||||
const proj = fixtureProject();
|
const proj = fixtureProject();
|
||||||
proj.walls = [];
|
proj.walls = [];
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
openingLabel,
|
openingLabel,
|
||||||
wallTypeThickness,
|
wallTypeThickness,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
|
import { projectToWorldCoords } from "../model/geoRebase";
|
||||||
import {
|
import {
|
||||||
ceilingVerticalExtent,
|
ceilingVerticalExtent,
|
||||||
stairVerticalExtent,
|
stairVerticalExtent,
|
||||||
@@ -283,7 +284,13 @@ interface Structure {
|
|||||||
* File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal-
|
* File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal-
|
||||||
* IFC (Project/Site/Building, kein Crash).
|
* IFC (Project/Site/Building, kein Crash).
|
||||||
*/
|
*/
|
||||||
export function exportIfcSpf(project: Project): string {
|
export function exportIfcSpf(projectIn: Project): string {
|
||||||
|
// Georeferenziert exportieren: ist ein Standort-Bezug gesetzt (geoAnchor),
|
||||||
|
// wird die gesamte Geometrie auf die ECHTEN LV95-Weltkoordinaten verschoben,
|
||||||
|
// damit die IFC-Datei in anderer Software lagerichtig sitzt — auch wenn das
|
||||||
|
// Modell lokal nahe (0,0) bearbeitet wurde (s. projectToWorldCoords / der
|
||||||
|
// „Neuer Bezugspunkt"-Workflow). Ohne Bezug bleibt alles lokal (Identität).
|
||||||
|
const project = projectToWorldCoords(projectIn);
|
||||||
const w = new StepWriter();
|
const w = new StepWriter();
|
||||||
|
|
||||||
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
|
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
|
||||||
@@ -341,9 +348,14 @@ export function exportIfcSpf(project: Project): string {
|
|||||||
"IFCPROJECT",
|
"IFCPROJECT",
|
||||||
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
|
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
|
||||||
);
|
);
|
||||||
|
// RefElevation (m ü. M.): reale Datumshöhe des Standorts aus dem
|
||||||
|
// Georeferenzierungs-Bezug (wie ArchiCADs Vermessungspunkt). Rein informativ —
|
||||||
|
// die Geometrie wird NICHT verschoben (das Terrain trägt bereits reale Höhen).
|
||||||
|
const refElevation = project.geoAnchor?.height;
|
||||||
|
const siteRefElev = typeof refElevation === "number" ? R(refElevation) : "$";
|
||||||
const siteId = w.add(
|
const siteId = w.add(
|
||||||
"IFCSITE",
|
"IFCSITE",
|
||||||
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,$,$,$`,
|
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,${siteRefElev},$,$`,
|
||||||
);
|
);
|
||||||
const buildingId = w.add(
|
const buildingId = w.add(
|
||||||
"IFCBUILDING",
|
"IFCBUILDING",
|
||||||
|
|||||||
@@ -199,3 +199,108 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
|||||||
expect(scheduleRows(proj)).toHaveLength(0);
|
expect(scheduleRows(proj)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scheduleRows — Dächer und Stützen (bisher fehlten sie in Liste/Element-Baum)", () => {
|
||||||
|
it("wertet ein Dach mit Dachtyp aus (Typname aus roofTypes, Dicke aus Schichtsumme via roof.thickness)", () => {
|
||||||
|
const proj = fixtureProject();
|
||||||
|
proj.roofTypes = [{ id: "rt", name: "Warmdach", layers: [{ componentId: "c", thickness: 0.35 }] }];
|
||||||
|
proj.roofs = [
|
||||||
|
{
|
||||||
|
id: "R1",
|
||||||
|
type: "roof",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "31",
|
||||||
|
outline: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4, y: 0 },
|
||||||
|
{ x: 4, y: 3 },
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
], // 4×3 = 12 m²
|
||||||
|
shape: "flach",
|
||||||
|
pitchDeg: 0,
|
||||||
|
overhang: 0,
|
||||||
|
ridgeAxis: "x",
|
||||||
|
thickness: 0.35,
|
||||||
|
roofTypeId: "rt",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = scheduleRows(proj).filter((r) => r.kind === "Dach");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
kind: "Dach",
|
||||||
|
id: "R1",
|
||||||
|
typeName: "Warmdach",
|
||||||
|
floor: "EG",
|
||||||
|
length: null,
|
||||||
|
heightM: null,
|
||||||
|
thickness: 0.35,
|
||||||
|
area: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wertet eine rechteckige und eine runde Stütze aus (Profil-Masse als Länge/Dicke)", () => {
|
||||||
|
const proj = fixtureProject();
|
||||||
|
proj.columns = [
|
||||||
|
{
|
||||||
|
id: "C1",
|
||||||
|
type: "column",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "50",
|
||||||
|
position: { x: 1, y: 1 },
|
||||||
|
profile: { kind: "rect", width: 0.3, depth: 0.4 },
|
||||||
|
rotation: 0,
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "C2",
|
||||||
|
type: "column",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "50",
|
||||||
|
position: { x: 2, y: 2 },
|
||||||
|
profile: { kind: "round", radius: 0.15 },
|
||||||
|
rotation: 0,
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rows = scheduleRows(proj).filter((r) => r.kind === "Stütze");
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
id: "C1",
|
||||||
|
typeName: "Rechteck",
|
||||||
|
length: 0.3,
|
||||||
|
heightM: 2.6,
|
||||||
|
thickness: 0.4,
|
||||||
|
area: null,
|
||||||
|
});
|
||||||
|
expect(rows[1]).toMatchObject({
|
||||||
|
id: "C2",
|
||||||
|
typeName: "Rund",
|
||||||
|
length: null,
|
||||||
|
heightM: 2.6,
|
||||||
|
thickness: 0.3, // 2 × Radius
|
||||||
|
area: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Räume bleiben bewusst ausgeschlossen (eigener CSV-Export, siehe Dateikopf)", () => {
|
||||||
|
const proj = fixtureProject();
|
||||||
|
proj.rooms = [
|
||||||
|
{
|
||||||
|
id: "RM1",
|
||||||
|
type: "room",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "45",
|
||||||
|
siaCategory: "HNF",
|
||||||
|
name: "Wohnen",
|
||||||
|
boundary: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 3, y: 0 },
|
||||||
|
{ x: 3, y: 3 },
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
],
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(scheduleRows(proj).some((r) => r.id === "RM1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type {
|
|||||||
Opening,
|
Opening,
|
||||||
Stair,
|
Stair,
|
||||||
ExtrudedSolid,
|
ExtrudedSolid,
|
||||||
|
Roof,
|
||||||
|
Column,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
import {
|
import {
|
||||||
getWallType,
|
getWallType,
|
||||||
@@ -63,7 +65,9 @@ export type ScheduleKind =
|
|||||||
| "Fenster"
|
| "Fenster"
|
||||||
| "Öffnung"
|
| "Öffnung"
|
||||||
| "Treppe"
|
| "Treppe"
|
||||||
| "Extrusion";
|
| "Extrusion"
|
||||||
|
| "Dach"
|
||||||
|
| "Stütze";
|
||||||
|
|
||||||
/** Eine ausgewertete Zeile der Bauteilliste (je Bauteil-Vorkommen ein Datensatz). */
|
/** Eine ausgewertete Zeile der Bauteilliste (je Bauteil-Vorkommen ein Datensatz). */
|
||||||
export interface ScheduleRow {
|
export interface ScheduleRow {
|
||||||
@@ -215,12 +219,48 @@ function evalExtrudedSolid(project: Project, solid: ExtrudedSolid): ScheduleRow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wertet ein Dach für die Bauteilliste aus (defensiv gegen fehlenden Dachtyp). */
|
||||||
|
function evalRoof(project: Project, roof: Roof): ScheduleRow {
|
||||||
|
let typeName = roof.roofTypeId ?? roof.shape;
|
||||||
|
if (roof.roofTypeId) {
|
||||||
|
const rt = (project.roofTypes ?? []).find((t) => t.id === roof.roofTypeId);
|
||||||
|
if (rt) typeName = rt.name;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "Dach",
|
||||||
|
id: roof.id,
|
||||||
|
typeName,
|
||||||
|
floor: floorNameOf(project, roof.floorId),
|
||||||
|
floorId: roof.floorId,
|
||||||
|
length: null,
|
||||||
|
heightM: null,
|
||||||
|
thickness: roof.thickness,
|
||||||
|
area: polygonArea(roof.outline),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wertet eine Stütze für die Bauteilliste aus (Querschnitt als „Dicke", Rechteck-Breite als „Länge"). */
|
||||||
|
function evalColumn(project: Project, column: Column): ScheduleRow {
|
||||||
|
return {
|
||||||
|
kind: "Stütze",
|
||||||
|
id: column.id,
|
||||||
|
typeName: column.profile.kind === "round" ? "Rund" : "Rechteck",
|
||||||
|
floor: floorNameOf(project, column.floorId),
|
||||||
|
floorId: column.floorId,
|
||||||
|
length: column.profile.kind === "rect" ? column.profile.width : null,
|
||||||
|
heightM: column.height,
|
||||||
|
thickness:
|
||||||
|
column.profile.kind === "rect" ? column.profile.depth : column.profile.radius * 2,
|
||||||
|
area: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wertet ALLE Bauteil-Vorkommen eines Projekts aus (Wände, Decken, Türen,
|
* Wertet ALLE Bauteil-Vorkommen eines Projekts aus (Wände, Decken, Türen,
|
||||||
* Fenster/Öffnungen, Treppen, Extrusionen — Räume bewusst ausgenommen, siehe
|
* Fenster/Öffnungen, Treppen, Extrusionen, Dächer, Stützen — Räume bewusst
|
||||||
* Dateikopf). Reine Ableitung aus dem Modell — nichts wird gespeichert.
|
* ausgenommen, siehe Dateikopf). Reine Ableitung aus dem Modell — nichts wird
|
||||||
* Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen, Extrusionen (je in
|
* gespeichert. Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen,
|
||||||
* Dokumentreihenfolge).
|
* Extrusionen, Dächer, Stützen (je in Dokumentreihenfolge).
|
||||||
*/
|
*/
|
||||||
export function scheduleRows(project: Project): ScheduleRow[] {
|
export function scheduleRows(project: Project): ScheduleRow[] {
|
||||||
const rows: ScheduleRow[] = [];
|
const rows: ScheduleRow[] = [];
|
||||||
@@ -230,6 +270,8 @@ export function scheduleRows(project: Project): ScheduleRow[] {
|
|||||||
for (const o of project.openings ?? []) rows.push(evalOpening(project, o));
|
for (const o of project.openings ?? []) rows.push(evalOpening(project, o));
|
||||||
for (const s of project.stairs ?? []) rows.push(evalStair(project, s));
|
for (const s of project.stairs ?? []) rows.push(evalStair(project, s));
|
||||||
for (const e of project.extrudedSolids ?? []) rows.push(evalExtrudedSolid(project, e));
|
for (const e of project.extrudedSolids ?? []) rows.push(evalExtrudedSolid(project, e));
|
||||||
|
for (const r of project.roofs ?? []) rows.push(evalRoof(project, r));
|
||||||
|
for (const c of project.columns ?? []) rows.push(evalColumn(project, c));
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,16 +17,25 @@ export const de = {
|
|||||||
"view3d.group": "Ansicht 3D",
|
"view3d.group": "Ansicht 3D",
|
||||||
"view3d.front": "Vorne",
|
"view3d.front": "Vorne",
|
||||||
"view3d.front.hint": "Frontalansicht von vorne",
|
"view3d.front.hint": "Frontalansicht von vorne",
|
||||||
|
"view3d.front.abbr": "V",
|
||||||
"view3d.back": "Hinten",
|
"view3d.back": "Hinten",
|
||||||
"view3d.back.hint": "Rückansicht von hinten",
|
"view3d.back.hint": "Rückansicht von hinten",
|
||||||
|
"view3d.back.abbr": "H",
|
||||||
"view3d.top": "Oben",
|
"view3d.top": "Oben",
|
||||||
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
||||||
|
"view3d.top.abbr": "O",
|
||||||
"view3d.side": "Rechts",
|
"view3d.side": "Rechts",
|
||||||
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
|
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
|
||||||
|
"view3d.side.abbr": "R",
|
||||||
"view3d.left": "Links",
|
"view3d.left": "Links",
|
||||||
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
|
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
|
||||||
|
"view3d.left.abbr": "L",
|
||||||
"view3d.perspective": "Perspektive",
|
"view3d.perspective": "Perspektive",
|
||||||
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
|
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
|
||||||
|
"topbar.views": "Ansichten",
|
||||||
|
"topbar.views.menu": "Ansicht wählen (Vorne/Hinten/Links/Rechts/Oben)",
|
||||||
|
"topbar.axo": "Axonometrie",
|
||||||
|
"topbar.axo.hint": "3D-Axonometrie (freier 3/4-Blick)",
|
||||||
"view3d.iso": "Isometrie",
|
"view3d.iso": "Isometrie",
|
||||||
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
|
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
|
||||||
"view3d.isoFrontLeft": "Vorne-Links",
|
"view3d.isoFrontLeft": "Vorne-Links",
|
||||||
@@ -86,6 +95,7 @@ export const de = {
|
|||||||
"display.style.shaded": "Schattiert",
|
"display.style.shaded": "Schattiert",
|
||||||
"display.style.shadedEdges": "Schattiert mit Kanten",
|
"display.style.shadedEdges": "Schattiert mit Kanten",
|
||||||
"display.style.white": "Weiss",
|
"display.style.white": "Weiss",
|
||||||
|
"display.style.contour": "Nur Konturen",
|
||||||
"display.style.textured": "Texturiert",
|
"display.style.textured": "Texturiert",
|
||||||
"display.style.wireframe": "Drahtgitter",
|
"display.style.wireframe": "Drahtgitter",
|
||||||
"display.style.hidden": "Kanten",
|
"display.style.hidden": "Kanten",
|
||||||
@@ -98,6 +108,41 @@ export const de = {
|
|||||||
"topbar.resources": "Ressourcen",
|
"topbar.resources": "Ressourcen",
|
||||||
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
|
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
|
||||||
|
|
||||||
|
// ── Menüleiste (Topbar, AppMenuBar) ──────────────────────────────────────
|
||||||
|
"menu.edit": "Bearbeiten",
|
||||||
|
"menu.edit.undo": "Rückgängig",
|
||||||
|
"menu.edit.redo": "Wiederholen",
|
||||||
|
"menu.edit.delete": "Löschen",
|
||||||
|
"menu.view": "Ansicht",
|
||||||
|
"menu.view.floorplan": "Grundriss",
|
||||||
|
"menu.view.perspective": "Perspektive",
|
||||||
|
"menu.view.referenceLines": "Referenzlinien",
|
||||||
|
"menu.window": "Fenster",
|
||||||
|
"menu.window.resources": "Ressourcen",
|
||||||
|
"menu.window.settings": "Einstellungen",
|
||||||
|
"menu.window.drawingLevels": "Zeichnungsebenen (Fenster)",
|
||||||
|
"menu.window.layerSettings": "Ebeneneinstellungen (aktive Ebene)",
|
||||||
|
"menu.window.contextImport": "Standort importieren",
|
||||||
|
|
||||||
|
// ── Native macOS-Systemmenüleiste (appMenu.ts) — eigener Namensraum, da sie
|
||||||
|
// ausser Bearbeiten/Ansicht/Fenster (oben) auch Datei/Export/Transformieren
|
||||||
|
// zeigt, was die In-App-AppMenuBar nicht hat. ─────────────────────────────
|
||||||
|
"menu.native.file": "Datei",
|
||||||
|
"menu.native.open": "Öffnen …",
|
||||||
|
"menu.native.saveAs": "Speichern unter …",
|
||||||
|
"menu.native.import": "Importieren …",
|
||||||
|
"menu.native.export": "Exportieren",
|
||||||
|
"menu.native.exportSchedule": "Bauteilliste (CSV)",
|
||||||
|
"menu.native.edit": "Bearbeiten",
|
||||||
|
"menu.native.move": "Bewegen",
|
||||||
|
"menu.native.mirror": "Spiegeln",
|
||||||
|
"menu.native.rotate": "Drehen",
|
||||||
|
"menu.native.view": "Ansicht",
|
||||||
|
"menu.native.window": "Fenster",
|
||||||
|
"menu.native.panels": "Panels",
|
||||||
|
"menu.native.about": "Über Dossier",
|
||||||
|
"menu.native.quit": "Beenden",
|
||||||
|
|
||||||
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
||||||
"layout.title": "Arbeitsumgebung",
|
"layout.title": "Arbeitsumgebung",
|
||||||
"layout.placeholder": "Arbeitsumgebung …",
|
"layout.placeholder": "Arbeitsumgebung …",
|
||||||
@@ -148,6 +193,12 @@ export const de = {
|
|||||||
"ribbon.group.components": "Bauteile",
|
"ribbon.group.components": "Bauteile",
|
||||||
"ribbon.group.sections": "Schnitte",
|
"ribbon.group.sections": "Schnitte",
|
||||||
"ribbon.group.custom": "Eigene Leiste",
|
"ribbon.group.custom": "Eigene Leiste",
|
||||||
|
"tools.view.label": "Darstellung",
|
||||||
|
"tools.view.list": "Liste",
|
||||||
|
"tools.view.icons": "Symbole",
|
||||||
|
"tools.cat.2d": "2D",
|
||||||
|
"tools.cat.3d": "3D",
|
||||||
|
"tools.cat.edit": "Bearbeiten",
|
||||||
"ribbon.custom.add": "+ Hinzufügen",
|
"ribbon.custom.add": "+ Hinzufügen",
|
||||||
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
||||||
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
|
"ribbon.empty": "Noch keine Werkzeuge in diesem Tab",
|
||||||
@@ -292,6 +343,16 @@ export const de = {
|
|||||||
"objinfo.kind.extrudedSolid": "Extrusion",
|
"objinfo.kind.extrudedSolid": "Extrusion",
|
||||||
"objinfo.kind.column": "Stütze",
|
"objinfo.kind.column": "Stütze",
|
||||||
"objinfo.kind.roof": "Dach",
|
"objinfo.kind.roof": "Dach",
|
||||||
|
"objinfo.kind.contextObject": "Kontext-Objekt",
|
||||||
|
// ── Kontext-Objekt-Attribute (Object-Info-Panel) ────────────────────────
|
||||||
|
"objinfo.contextObject.section": "Georeferenzierung",
|
||||||
|
"objinfo.contextObject.type": "Art",
|
||||||
|
"objinfo.contextObject.type.importedMesh": "Importiertes Mesh",
|
||||||
|
"objinfo.contextObject.type.terrainMesh": "Gelände",
|
||||||
|
"objinfo.contextObject.type.contourSet": "Höhenlinien",
|
||||||
|
"objinfo.contextObject.lv95E": "LV95 Ost (E)",
|
||||||
|
"objinfo.contextObject.lv95N": "LV95 Nord (N)",
|
||||||
|
"objinfo.contextObject.noAnchor": "Kein Standort-Bezug im Projekt",
|
||||||
// ── Dach-Attribute (Object-Info-Panel) ──────────────────────────────────
|
// ── Dach-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||||
"objinfo.roof.section": "Dach",
|
"objinfo.roof.section": "Dach",
|
||||||
"objinfo.roof.shape": "Dachform",
|
"objinfo.roof.shape": "Dachform",
|
||||||
@@ -501,6 +562,9 @@ export const de = {
|
|||||||
"objinfo.stair.direction": "Laufrichtung",
|
"objinfo.stair.direction": "Laufrichtung",
|
||||||
"objinfo.stair.direction.up": "Aufwärts",
|
"objinfo.stair.direction.up": "Aufwärts",
|
||||||
"objinfo.stair.direction.down": "Abwärts",
|
"objinfo.stair.direction.down": "Abwärts",
|
||||||
|
"objinfo.stair.arrow": "Pfeil",
|
||||||
|
"objinfo.stair.arrow.line": "Offen",
|
||||||
|
"objinfo.stair.arrow.filled": "Gefüllt",
|
||||||
"objinfo.stair.refFloor": "Referenzgeschoss",
|
"objinfo.stair.refFloor": "Referenzgeschoss",
|
||||||
"objinfo.stair.bottom": "Unterkante (UK)",
|
"objinfo.stair.bottom": "Unterkante (UK)",
|
||||||
"objinfo.stair.top": "Oberkante (OK)",
|
"objinfo.stair.top": "Oberkante (OK)",
|
||||||
@@ -523,6 +587,7 @@ export const de = {
|
|||||||
"objinfo.sectionLine.kind.elevation": "Ansicht",
|
"objinfo.sectionLine.kind.elevation": "Ansicht",
|
||||||
"objinfo.sectionLine.name": "Name",
|
"objinfo.sectionLine.name": "Name",
|
||||||
"objinfo.sectionLine.flip": "Blickrichtung umkehren",
|
"objinfo.sectionLine.flip": "Blickrichtung umkehren",
|
||||||
|
"objinfo.sectionLine.cut3d": "Im 3D schneiden",
|
||||||
"objinfo.sectionLine.startX": "Start X",
|
"objinfo.sectionLine.startX": "Start X",
|
||||||
"objinfo.sectionLine.startY": "Start Y",
|
"objinfo.sectionLine.startY": "Start Y",
|
||||||
"objinfo.sectionLine.endX": "Ende X",
|
"objinfo.sectionLine.endX": "Ende X",
|
||||||
@@ -557,6 +622,8 @@ export const de = {
|
|||||||
"nav.show": "Einblenden",
|
"nav.show": "Einblenden",
|
||||||
"nav.categoryVisible": "{name} sichtbar",
|
"nav.categoryVisible": "{name} sichtbar",
|
||||||
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
|
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
|
||||||
|
"nav.categorySettings": "Ebeneneinstellungen für {name}",
|
||||||
|
"nav.drawingLevelsWindow": "Zeichnungsebenen als eigenes Fenster öffnen",
|
||||||
|
|
||||||
// ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
|
// ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
|
||||||
"badge.floor": "Geschoss",
|
"badge.floor": "Geschoss",
|
||||||
@@ -571,6 +638,7 @@ export const de = {
|
|||||||
"display.active": "Nur aktive",
|
"display.active": "Nur aktive",
|
||||||
"display.grey": "Andere grau",
|
"display.grey": "Andere grau",
|
||||||
"display.grey_locked": "Andere grau & gesperrt",
|
"display.grey_locked": "Andere grau & gesperrt",
|
||||||
|
"display.locked": "Andere gesperrt",
|
||||||
|
|
||||||
// ── Dock ─────────────────────────────────────────────────────────────────
|
// ── Dock ─────────────────────────────────────────────────────────────────
|
||||||
"dock.resize": "Breite anpassen",
|
"dock.resize": "Breite anpassen",
|
||||||
@@ -692,6 +760,7 @@ export const de = {
|
|||||||
"resources.title": "Ressourcen",
|
"resources.title": "Ressourcen",
|
||||||
"resources.label": "Ressourcen-Manager",
|
"resources.label": "Ressourcen-Manager",
|
||||||
"resources.close": "Schließen",
|
"resources.close": "Schließen",
|
||||||
|
"resources.loading": "Verbinde mit dem Hauptfenster …",
|
||||||
"resources.tab.components": "Bauteile",
|
"resources.tab.components": "Bauteile",
|
||||||
"resources.tab.hatches": "Schraffuren",
|
"resources.tab.hatches": "Schraffuren",
|
||||||
"resources.tab.lines": "Linien",
|
"resources.tab.lines": "Linien",
|
||||||
@@ -774,6 +843,8 @@ export const de = {
|
|||||||
"resources.field.frameWidth": "Rahmenbreite (m)",
|
"resources.field.frameWidth": "Rahmenbreite (m)",
|
||||||
"resources.field.insetFromFace": "Schichteinzug (m)",
|
"resources.field.insetFromFace": "Schichteinzug (m)",
|
||||||
"resources.field.insetFace": "Einzug ab Fläche",
|
"resources.field.insetFace": "Einzug ab Fläche",
|
||||||
|
"resources.field.innerLayerRecess": "Laibungs-Rücksprung (innerste Schicht)",
|
||||||
|
"resources.field.innerLayerRecess.hint": "Die innerste Wandschicht (meist Gips/Holzverkleidung) weicht an diesem Fenster rundum um ihre eigene Dicke von der Laibung zurück, statt bündig mit den übrigen Schichten abzuschliessen — ändert die 3D-Wandgeometrie, nicht nur die Rahmenposition.",
|
||||||
"resources.field.transomHeight": "Oberlicht-Höhe (m)",
|
"resources.field.transomHeight": "Oberlicht-Höhe (m)",
|
||||||
"resources.field.mullionRows": "Kämpfer-Zeilen",
|
"resources.field.mullionRows": "Kämpfer-Zeilen",
|
||||||
"resources.frameKind.zarge": "Zarge",
|
"resources.frameKind.zarge": "Zarge",
|
||||||
@@ -1145,9 +1216,15 @@ export const de = {
|
|||||||
"site.contourSet": "Kontur-Satz",
|
"site.contourSet": "Kontur-Satz",
|
||||||
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
|
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
|
||||||
"site.delete": "Entfernen",
|
"site.delete": "Entfernen",
|
||||||
|
"site.deleteLayer": "Ganze Ebene entfernen",
|
||||||
|
"site.group.imported": "Gebäude",
|
||||||
|
"site.group.terrain": "Terrain",
|
||||||
|
"site.group.contours": "Höhenlinien",
|
||||||
"site.type.mesh": "Mesh",
|
"site.type.mesh": "Mesh",
|
||||||
"site.type.contours": "Konturen",
|
"site.type.contours": "Konturen",
|
||||||
"site.type.terrain": "Gelände",
|
"site.type.terrain": "Gelände",
|
||||||
|
"site.type.image": "Luftbild",
|
||||||
|
"site.group.image": "Luftbild",
|
||||||
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
|
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
|
||||||
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
|
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
|
||||||
|
|
||||||
@@ -1254,6 +1331,9 @@ export const de = {
|
|||||||
"settings.title": "Einstellungen",
|
"settings.title": "Einstellungen",
|
||||||
"settings.section.workspace": "Arbeitsumgebung",
|
"settings.section.workspace": "Arbeitsumgebung",
|
||||||
"settings.section.display": "Darstellung",
|
"settings.section.display": "Darstellung",
|
||||||
|
"settings.theme": "Erscheinungsbild",
|
||||||
|
"settings.theme.light": "Hell",
|
||||||
|
"settings.theme.dark": "Dunkel",
|
||||||
"settings.marqueeColor": "Auswahlrahmen-Farbe",
|
"settings.marqueeColor": "Auswahlrahmen-Farbe",
|
||||||
"settings.marqueeColor.hint":
|
"settings.marqueeColor.hint":
|
||||||
"Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.",
|
"Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.",
|
||||||
@@ -1281,8 +1361,10 @@ export const de = {
|
|||||||
"ctxImport.title": "Standort importieren",
|
"ctxImport.title": "Standort importieren",
|
||||||
"ctxImport.close": "Schließen",
|
"ctxImport.close": "Schließen",
|
||||||
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
|
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
|
||||||
|
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
|
||||||
"ctxImport.geoAnchor.reset": "Zurücksetzen",
|
"ctxImport.geoAnchor.reset": "Zurücksetzen",
|
||||||
"ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.",
|
"ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.",
|
||||||
|
"ctxImport.rawHint": "Wird an den echten LV95-Koordinaten importiert (weit vom Nullpunkt). Danach mit „Neuer Bezugspunkt“ an einen bequemen Modellpunkt holen — der IFC-/DXF-Export schreibt trotzdem die echten Koordinaten.",
|
||||||
"ctxImport.location": "Ort / Adresse",
|
"ctxImport.location": "Ort / Adresse",
|
||||||
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
|
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
|
||||||
"ctxImport.search": "Suchen",
|
"ctxImport.search": "Suchen",
|
||||||
@@ -1290,6 +1372,12 @@ export const de = {
|
|||||||
"ctxImport.noResults": "Keine Treffer.",
|
"ctxImport.noResults": "Keine Treffer.",
|
||||||
"ctxImport.searchError": "Suche fehlgeschlagen.",
|
"ctxImport.searchError": "Suche fehlgeschlagen.",
|
||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
|
"ctxImport.refPoint": "Bezugspunkt (Modell-Nullpunkt)",
|
||||||
|
"ctxImport.refPoint.e": "LV95 Ost (E)",
|
||||||
|
"ctxImport.refPoint.n": "LV95 Nord (N)",
|
||||||
|
"ctxImport.refPoint.h": "Höhe (m ü. M.)",
|
||||||
|
"ctxImport.refPoint.hint": "Optional: reale Koordinate des Bezugspunkts (wie ArchiCADs Vermessungspunkt). Ost/Nord (LV95) legen fest, welcher Punkt im Modell auf (0,0) liegt; die Höhe (m ü. M.) wird informativ verankert und beim IFC-Export als Standort-Datumshöhe geschrieben. Leer = das Suchzentrum wird der Nullpunkt.",
|
||||||
|
"ctxImport.refPoint.anchorLabel": "Bezugspunkt {e} / {n}",
|
||||||
"ctxImport.sources": "Quellen",
|
"ctxImport.sources": "Quellen",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
||||||
"ctxImport.buildings.off": "Aus",
|
"ctxImport.buildings.off": "Aus",
|
||||||
@@ -1297,6 +1385,8 @@ export const de = {
|
|||||||
"ctxImport.buildings.v2": "Echt 2.0 (stabil)",
|
"ctxImport.buildings.v2": "Echt 2.0 (stabil)",
|
||||||
"ctxImport.buildings.v3": "Echt 3.0 (Beta)",
|
"ctxImport.buildings.v3": "Echt 3.0 (Beta)",
|
||||||
"ctxImport.src.swissTerrain": "Swisstopo-Gelände",
|
"ctxImport.src.swissTerrain": "Swisstopo-Gelände",
|
||||||
|
"ctxImport.src.swissImage": "SWISSIMAGE-Luftbild (2D-Hintergrund)",
|
||||||
|
"ctxImport.src.swissImageDrape": "Luftbild aufs Gelände legen (3D)",
|
||||||
"ctxImport.terrain.resolution": "Auflösung",
|
"ctxImport.terrain.resolution": "Auflösung",
|
||||||
"ctxImport.terrain.res2": "2 m (schnell)",
|
"ctxImport.terrain.res2": "2 m (schnell)",
|
||||||
"ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)",
|
"ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)",
|
||||||
@@ -1379,6 +1469,7 @@ export const de = {
|
|||||||
"viewport3d.section.label": "Schnittebene",
|
"viewport3d.section.label": "Schnittebene",
|
||||||
"viewport3d.section.show": "Schnittebene ein",
|
"viewport3d.section.show": "Schnittebene ein",
|
||||||
"viewport3d.section.hide": "Schnittebene aus",
|
"viewport3d.section.hide": "Schnittebene aus",
|
||||||
|
"viewport3d.section.bound": "3D-Schnitt aufheben (an Schnittlinie gebunden)",
|
||||||
"viewport3d.grip.vertex": "Wandende ziehen",
|
"viewport3d.grip.vertex": "Wandende ziehen",
|
||||||
"viewport3d.grip.height": "Höhe ziehen",
|
"viewport3d.grip.height": "Höhe ziehen",
|
||||||
"viewport3d.grip.move": "Verschieben",
|
"viewport3d.grip.move": "Verschieben",
|
||||||
@@ -1396,6 +1487,8 @@ export const de = {
|
|||||||
"elements.kind.opening": "Öffnung",
|
"elements.kind.opening": "Öffnung",
|
||||||
"elements.kind.stair": "Treppe",
|
"elements.kind.stair": "Treppe",
|
||||||
"elements.kind.extrusion": "Extrusion",
|
"elements.kind.extrusion": "Extrusion",
|
||||||
|
"elements.kind.roof": "Dach",
|
||||||
|
"elements.kind.column": "Stütze",
|
||||||
|
|
||||||
// Ausschnitte / View-Snapshots (DOSSIER A2).
|
// Ausschnitte / View-Snapshots (DOSSIER A2).
|
||||||
"viewsnap.title": "Ausschnitte",
|
"viewsnap.title": "Ausschnitte",
|
||||||
|
|||||||
@@ -16,16 +16,25 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"view3d.group": "3D view",
|
"view3d.group": "3D view",
|
||||||
"view3d.front": "Front",
|
"view3d.front": "Front",
|
||||||
"view3d.front.hint": "Front view (frontal)",
|
"view3d.front.hint": "Front view (frontal)",
|
||||||
|
"view3d.front.abbr": "F",
|
||||||
"view3d.back": "Back",
|
"view3d.back": "Back",
|
||||||
"view3d.back.hint": "Back view (rear)",
|
"view3d.back.hint": "Back view (rear)",
|
||||||
|
"view3d.back.abbr": "B",
|
||||||
"view3d.top": "Top",
|
"view3d.top": "Top",
|
||||||
"view3d.top.hint": "Top view (straight down)",
|
"view3d.top.hint": "Top view (straight down)",
|
||||||
|
"view3d.top.abbr": "T",
|
||||||
"view3d.side": "Right",
|
"view3d.side": "Right",
|
||||||
"view3d.side.hint": "Right side view (90° to the front)",
|
"view3d.side.hint": "Right side view (90° to the front)",
|
||||||
|
"view3d.side.abbr": "R",
|
||||||
"view3d.left": "Left",
|
"view3d.left": "Left",
|
||||||
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
|
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
|
||||||
|
"view3d.left.abbr": "L",
|
||||||
"view3d.perspective": "Perspective",
|
"view3d.perspective": "Perspective",
|
||||||
"view3d.perspective.hint": "Free perspective (3/4 view)",
|
"view3d.perspective.hint": "Free perspective (3/4 view)",
|
||||||
|
"topbar.views": "Views",
|
||||||
|
"topbar.views.menu": "Pick a view (Front/Back/Left/Right/Top)",
|
||||||
|
"topbar.axo": "Axonometric",
|
||||||
|
"topbar.axo.hint": "3D axonometric (free 3/4 view)",
|
||||||
"view3d.iso": "Isometric",
|
"view3d.iso": "Isometric",
|
||||||
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
|
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
|
||||||
"view3d.isoFrontLeft": "Front Left",
|
"view3d.isoFrontLeft": "Front Left",
|
||||||
@@ -85,6 +94,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"display.style.shaded": "Shaded",
|
"display.style.shaded": "Shaded",
|
||||||
"display.style.shadedEdges": "Shaded with edges",
|
"display.style.shadedEdges": "Shaded with edges",
|
||||||
"display.style.white": "White",
|
"display.style.white": "White",
|
||||||
|
"display.style.contour": "Contours only",
|
||||||
"display.style.textured": "Textured",
|
"display.style.textured": "Textured",
|
||||||
"display.style.wireframe": "Wireframe",
|
"display.style.wireframe": "Wireframe",
|
||||||
"display.style.hidden": "Hidden line",
|
"display.style.hidden": "Hidden line",
|
||||||
@@ -97,6 +107,40 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"topbar.resources": "Resources",
|
"topbar.resources": "Resources",
|
||||||
"topbar.resources.hint": "Show/hide the resources window",
|
"topbar.resources.hint": "Show/hide the resources window",
|
||||||
|
|
||||||
|
// Menu bar (Topbar, AppMenuBar).
|
||||||
|
"menu.edit": "Edit",
|
||||||
|
"menu.edit.undo": "Undo",
|
||||||
|
"menu.edit.redo": "Redo",
|
||||||
|
"menu.edit.delete": "Delete",
|
||||||
|
"menu.view": "View",
|
||||||
|
"menu.view.floorplan": "Floor plan",
|
||||||
|
"menu.view.perspective": "Perspective",
|
||||||
|
"menu.view.referenceLines": "Reference lines",
|
||||||
|
"menu.window": "Window",
|
||||||
|
"menu.window.resources": "Resources",
|
||||||
|
"menu.window.settings": "Settings",
|
||||||
|
"menu.window.drawingLevels": "Drawing Levels (Window)",
|
||||||
|
"menu.window.layerSettings": "Layer Settings (active layer)",
|
||||||
|
"menu.window.contextImport": "Import Location",
|
||||||
|
|
||||||
|
// Native macOS system menu bar (appMenu.ts) — separate namespace since it
|
||||||
|
// also shows File/Export/Transform, unlike the in-app AppMenuBar.
|
||||||
|
"menu.native.file": "File",
|
||||||
|
"menu.native.open": "Open…",
|
||||||
|
"menu.native.saveAs": "Save As…",
|
||||||
|
"menu.native.import": "Import…",
|
||||||
|
"menu.native.export": "Export",
|
||||||
|
"menu.native.exportSchedule": "Schedule (CSV)",
|
||||||
|
"menu.native.edit": "Edit",
|
||||||
|
"menu.native.move": "Move",
|
||||||
|
"menu.native.mirror": "Mirror",
|
||||||
|
"menu.native.rotate": "Rotate",
|
||||||
|
"menu.native.view": "View",
|
||||||
|
"menu.native.window": "Window",
|
||||||
|
"menu.native.panels": "Panels",
|
||||||
|
"menu.native.about": "About Dossier",
|
||||||
|
"menu.native.quit": "Quit",
|
||||||
|
|
||||||
// Layout menu.
|
// Layout menu.
|
||||||
"layout.title": "Workspace",
|
"layout.title": "Workspace",
|
||||||
"layout.placeholder": "Workspace …",
|
"layout.placeholder": "Workspace …",
|
||||||
@@ -147,6 +191,12 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ribbon.group.components": "Components",
|
"ribbon.group.components": "Components",
|
||||||
"ribbon.group.sections": "Sections",
|
"ribbon.group.sections": "Sections",
|
||||||
"ribbon.group.custom": "Custom bar",
|
"ribbon.group.custom": "Custom bar",
|
||||||
|
"tools.view.label": "Display",
|
||||||
|
"tools.view.list": "List",
|
||||||
|
"tools.view.icons": "Icons",
|
||||||
|
"tools.cat.2d": "2D",
|
||||||
|
"tools.cat.3d": "3D",
|
||||||
|
"tools.cat.edit": "Modify",
|
||||||
"ribbon.custom.add": "+ Add",
|
"ribbon.custom.add": "+ Add",
|
||||||
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
||||||
"ribbon.empty": "No tools in this tab yet",
|
"ribbon.empty": "No tools in this tab yet",
|
||||||
@@ -291,6 +341,16 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.kind.extrudedSolid": "Extrusion",
|
"objinfo.kind.extrudedSolid": "Extrusion",
|
||||||
"objinfo.kind.column": "Column",
|
"objinfo.kind.column": "Column",
|
||||||
"objinfo.kind.roof": "Roof",
|
"objinfo.kind.roof": "Roof",
|
||||||
|
"objinfo.kind.contextObject": "Context object",
|
||||||
|
// ── Context object attributes (object info panel) ───────────────────────
|
||||||
|
"objinfo.contextObject.section": "Georeferencing",
|
||||||
|
"objinfo.contextObject.type": "Type",
|
||||||
|
"objinfo.contextObject.type.importedMesh": "Imported mesh",
|
||||||
|
"objinfo.contextObject.type.terrainMesh": "Terrain",
|
||||||
|
"objinfo.contextObject.type.contourSet": "Contours",
|
||||||
|
"objinfo.contextObject.lv95E": "LV95 East (E)",
|
||||||
|
"objinfo.contextObject.lv95N": "LV95 North (N)",
|
||||||
|
"objinfo.contextObject.noAnchor": "No location reference in project",
|
||||||
// ── Roof attributes (object info panel) ─────────────────────────────────
|
// ── Roof attributes (object info panel) ─────────────────────────────────
|
||||||
"objinfo.roof.section": "Roof",
|
"objinfo.roof.section": "Roof",
|
||||||
"objinfo.roof.shape": "Roof shape",
|
"objinfo.roof.shape": "Roof shape",
|
||||||
@@ -499,6 +559,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.stair.direction": "Run direction",
|
"objinfo.stair.direction": "Run direction",
|
||||||
"objinfo.stair.direction.up": "Upwards",
|
"objinfo.stair.direction.up": "Upwards",
|
||||||
"objinfo.stair.direction.down": "Downwards",
|
"objinfo.stair.direction.down": "Downwards",
|
||||||
|
"objinfo.stair.arrow": "Arrow",
|
||||||
|
"objinfo.stair.arrow.line": "Open",
|
||||||
|
"objinfo.stair.arrow.filled": "Filled",
|
||||||
"objinfo.stair.refFloor": "Reference floor",
|
"objinfo.stair.refFloor": "Reference floor",
|
||||||
"objinfo.stair.bottom": "Bottom (UK)",
|
"objinfo.stair.bottom": "Bottom (UK)",
|
||||||
"objinfo.stair.top": "Top (OK)",
|
"objinfo.stair.top": "Top (OK)",
|
||||||
@@ -520,6 +583,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.sectionLine.kind.elevation": "Elevation",
|
"objinfo.sectionLine.kind.elevation": "Elevation",
|
||||||
"objinfo.sectionLine.name": "Name",
|
"objinfo.sectionLine.name": "Name",
|
||||||
"objinfo.sectionLine.flip": "Flip view direction",
|
"objinfo.sectionLine.flip": "Flip view direction",
|
||||||
|
"objinfo.sectionLine.cut3d": "Cut in 3D",
|
||||||
"objinfo.sectionLine.startX": "Start X",
|
"objinfo.sectionLine.startX": "Start X",
|
||||||
"objinfo.sectionLine.startY": "Start Y",
|
"objinfo.sectionLine.startY": "Start Y",
|
||||||
"objinfo.sectionLine.endX": "End X",
|
"objinfo.sectionLine.endX": "End X",
|
||||||
@@ -553,6 +617,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"nav.show": "Show",
|
"nav.show": "Show",
|
||||||
"nav.categoryVisible": "{name} visible",
|
"nav.categoryVisible": "{name} visible",
|
||||||
"nav.categorySelect": "Make {name} the active layer (drawing target)",
|
"nav.categorySelect": "Make {name} the active layer (drawing target)",
|
||||||
|
"nav.categorySettings": "Layer settings for {name}",
|
||||||
|
"nav.drawingLevelsWindow": "Open drawing levels as its own window",
|
||||||
|
|
||||||
// Badges.
|
// Badges.
|
||||||
"badge.floor": "Floor",
|
"badge.floor": "Floor",
|
||||||
@@ -567,6 +633,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"display.active": "Active only",
|
"display.active": "Active only",
|
||||||
"display.grey": "Others grey",
|
"display.grey": "Others grey",
|
||||||
"display.grey_locked": "Others grey & locked",
|
"display.grey_locked": "Others grey & locked",
|
||||||
|
"display.locked": "Others locked",
|
||||||
|
|
||||||
// Dock.
|
// Dock.
|
||||||
"dock.resize": "Adjust width",
|
"dock.resize": "Adjust width",
|
||||||
@@ -687,6 +754,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.title": "Resources",
|
"resources.title": "Resources",
|
||||||
"resources.label": "Resource manager",
|
"resources.label": "Resource manager",
|
||||||
"resources.close": "Close",
|
"resources.close": "Close",
|
||||||
|
"resources.loading": "Connecting to the main window…",
|
||||||
"resources.tab.components": "Components",
|
"resources.tab.components": "Components",
|
||||||
"resources.tab.hatches": "Hatches",
|
"resources.tab.hatches": "Hatches",
|
||||||
"resources.tab.lines": "Lines",
|
"resources.tab.lines": "Lines",
|
||||||
@@ -769,6 +837,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.field.frameWidth": "Frame width (m)",
|
"resources.field.frameWidth": "Frame width (m)",
|
||||||
"resources.field.insetFromFace": "Layer inset (m)",
|
"resources.field.insetFromFace": "Layer inset (m)",
|
||||||
"resources.field.insetFace": "Inset from face",
|
"resources.field.insetFace": "Inset from face",
|
||||||
|
"resources.field.innerLayerRecess": "Reveal recess (innermost layer)",
|
||||||
|
"resources.field.innerLayerRecess.hint": "The innermost wall layer (usually plasterboard/wood lining) steps back all around this window's reveal by its own thickness, instead of finishing flush with the other layers — changes the 3D wall geometry, not just the frame position.",
|
||||||
"resources.field.transomHeight": "Transom height (m)",
|
"resources.field.transomHeight": "Transom height (m)",
|
||||||
"resources.field.mullionRows": "Transom rows",
|
"resources.field.mullionRows": "Transom rows",
|
||||||
"resources.frameKind.zarge": "Lining",
|
"resources.frameKind.zarge": "Lining",
|
||||||
@@ -1134,9 +1204,15 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"site.contourSet": "Contour set",
|
"site.contourSet": "Contour set",
|
||||||
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
|
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
|
||||||
"site.delete": "Remove",
|
"site.delete": "Remove",
|
||||||
|
"site.deleteLayer": "Remove entire layer",
|
||||||
|
"site.group.imported": "Buildings",
|
||||||
|
"site.group.terrain": "Terrain",
|
||||||
|
"site.group.contours": "Contours",
|
||||||
"site.type.mesh": "Mesh",
|
"site.type.mesh": "Mesh",
|
||||||
"site.type.contours": "Contours",
|
"site.type.contours": "Contours",
|
||||||
"site.type.terrain": "Terrain",
|
"site.type.terrain": "Terrain",
|
||||||
|
"site.type.image": "Aerial",
|
||||||
|
"site.group.image": "Aerial imagery",
|
||||||
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
|
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
|
||||||
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
|
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
|
||||||
|
|
||||||
@@ -1242,6 +1318,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
"settings.section.workspace": "Workspace",
|
"settings.section.workspace": "Workspace",
|
||||||
"settings.section.display": "Display",
|
"settings.section.display": "Display",
|
||||||
|
"settings.theme": "Appearance",
|
||||||
|
"settings.theme.light": "Light",
|
||||||
|
"settings.theme.dark": "Dark",
|
||||||
"settings.marqueeColor": "Selection box color",
|
"settings.marqueeColor": "Selection box color",
|
||||||
"settings.marqueeColor.hint":
|
"settings.marqueeColor.hint":
|
||||||
"Color of the marquee selection box drawn while dragging a selection in the plan view.",
|
"Color of the marquee selection box drawn while dragging a selection in the plan view.",
|
||||||
@@ -1269,8 +1348,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.title": "Import location",
|
"ctxImport.title": "Import location",
|
||||||
"ctxImport.close": "Close",
|
"ctxImport.close": "Close",
|
||||||
"ctxImport.geoAnchor": "Georeferenced from: {label}",
|
"ctxImport.geoAnchor": "Georeferenced from: {label}",
|
||||||
|
"ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}",
|
||||||
"ctxImport.geoAnchor.reset": "Reset",
|
"ctxImport.geoAnchor.reset": "Reset",
|
||||||
"ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.",
|
"ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.",
|
||||||
|
"ctxImport.rawHint": "Imported at its real LV95 coordinates (far from the origin). Then use \"New reference point\" to bring it to a convenient model point — the IFC/DXF export still writes the real coordinates.",
|
||||||
"ctxImport.location": "Place / address",
|
"ctxImport.location": "Place / address",
|
||||||
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
|
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
|
||||||
"ctxImport.search": "Search",
|
"ctxImport.search": "Search",
|
||||||
@@ -1278,6 +1359,12 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.noResults": "No matches.",
|
"ctxImport.noResults": "No matches.",
|
||||||
"ctxImport.searchError": "Search failed.",
|
"ctxImport.searchError": "Search failed.",
|
||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
|
"ctxImport.refPoint": "Reference point (model origin)",
|
||||||
|
"ctxImport.refPoint.e": "LV95 East (E)",
|
||||||
|
"ctxImport.refPoint.n": "LV95 North (N)",
|
||||||
|
"ctxImport.refPoint.h": "Elevation (m a.s.l.)",
|
||||||
|
"ctxImport.refPoint.hint": "Optional: real coordinate of the reference point (like ArchiCAD's survey point). East/North (LV95) set which model point maps to (0,0); the elevation (m a.s.l.) is anchored informationally and written as the site datum elevation on IFC export. Empty = the search centre becomes the origin.",
|
||||||
|
"ctxImport.refPoint.anchorLabel": "Reference point {e} / {n}",
|
||||||
"ctxImport.sources": "Sources",
|
"ctxImport.sources": "Sources",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
||||||
"ctxImport.buildings.off": "Off",
|
"ctxImport.buildings.off": "Off",
|
||||||
@@ -1285,6 +1372,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.buildings.v2": "Real 2.0 (stable)",
|
"ctxImport.buildings.v2": "Real 2.0 (stable)",
|
||||||
"ctxImport.buildings.v3": "Real 3.0 (Beta)",
|
"ctxImport.buildings.v3": "Real 3.0 (Beta)",
|
||||||
"ctxImport.src.swissTerrain": "Swisstopo terrain",
|
"ctxImport.src.swissTerrain": "Swisstopo terrain",
|
||||||
|
"ctxImport.src.swissImage": "SWISSIMAGE aerial (2D backdrop)",
|
||||||
|
"ctxImport.src.swissImageDrape": "Drape aerial onto terrain (3D)",
|
||||||
"ctxImport.terrain.resolution": "Resolution",
|
"ctxImport.terrain.resolution": "Resolution",
|
||||||
"ctxImport.terrain.res2": "2 m (fast)",
|
"ctxImport.terrain.res2": "2 m (fast)",
|
||||||
"ctxImport.terrain.res05": "0.5 m (fine, larger tiles)",
|
"ctxImport.terrain.res05": "0.5 m (fine, larger tiles)",
|
||||||
@@ -1367,6 +1456,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"viewport3d.section.label": "Section plane",
|
"viewport3d.section.label": "Section plane",
|
||||||
"viewport3d.section.show": "Section plane on",
|
"viewport3d.section.show": "Section plane on",
|
||||||
"viewport3d.section.hide": "Section plane off",
|
"viewport3d.section.hide": "Section plane off",
|
||||||
|
"viewport3d.section.bound": "Clear 3D section (bound to section line)",
|
||||||
"viewport3d.grip.vertex": "Drag wall endpoint",
|
"viewport3d.grip.vertex": "Drag wall endpoint",
|
||||||
"viewport3d.grip.edge": "Drag edge",
|
"viewport3d.grip.edge": "Drag edge",
|
||||||
"viewport3d.grip.height": "Drag height",
|
"viewport3d.grip.height": "Drag height",
|
||||||
@@ -1384,6 +1474,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"elements.kind.opening": "Opening",
|
"elements.kind.opening": "Opening",
|
||||||
"elements.kind.stair": "Stair",
|
"elements.kind.stair": "Stair",
|
||||||
"elements.kind.extrusion": "Extrusion",
|
"elements.kind.extrusion": "Extrusion",
|
||||||
|
"elements.kind.roof": "Roof",
|
||||||
|
"elements.kind.column": "Column",
|
||||||
|
|
||||||
// Views / view snapshots (DOSSIER A2).
|
// Views / view snapshots (DOSSIER A2).
|
||||||
"viewsnap.title": "Views",
|
"viewsnap.title": "Views",
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { shiftMeshToOrigin, clipMeshToBbox } from "./swissBuildings3d";
|
import {
|
||||||
|
shiftMeshToOrigin,
|
||||||
|
clipMeshToBbox,
|
||||||
|
splitMeshComponents,
|
||||||
|
stripOverhangSoffits,
|
||||||
|
BUILDING_LAYER,
|
||||||
|
} from "./swissBuildings3d";
|
||||||
import type { ImportedMesh } from "../model/types";
|
import type { ImportedMesh } from "../model/types";
|
||||||
|
|
||||||
describe("shiftMeshToOrigin", () => {
|
describe("shiftMeshToOrigin", () => {
|
||||||
@@ -79,3 +85,137 @@ describe("clipMeshToBbox", () => {
|
|||||||
expect(clipped.indices).toEqual([0, 1, 2]);
|
expect(clipped.indices).toEqual([0, 1, 2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("splitMeshComponents", () => {
|
||||||
|
// Zwei räumlich getrennte Dreiecke (kein gemeinsamer Vertex) = zwei Gebäude.
|
||||||
|
const twoBuildings: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Kachel",
|
||||||
|
positions: [
|
||||||
|
0, 0, 0, 1, 0, 0, 0, 1, 0, // Gebäude A
|
||||||
|
100, 100, 0, 101, 100, 0, 100, 101, 0, // Gebäude B (weit weg)
|
||||||
|
],
|
||||||
|
indices: [0, 1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("trennt zwei unverbundene Dreiecke in zwei Meshes mit eigener Id + Ebene", () => {
|
||||||
|
const parts = splitMeshComponents(twoBuildings);
|
||||||
|
expect(parts.length).toBe(2);
|
||||||
|
expect(parts[0].id).not.toBe(parts[1].id);
|
||||||
|
for (const p of parts) {
|
||||||
|
expect(p.layer).toBe(BUILDING_LAYER);
|
||||||
|
expect(p.indices.length).toBe(3); // je ein Dreieck
|
||||||
|
expect(p.positions.length).toBe(9); // kompaktiert: nur die 3 eigenen Vertices
|
||||||
|
// Indizes sind neu gemappt (0..2), zeigen nicht mehr auf die alten Globalindizes.
|
||||||
|
expect(Math.max(...p.indices)).toBeLessThan(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschweisst geteilte Eckpunkte: zwei Dreiecke mit gemeinsamer Kante bleiben EIN Gebäude", () => {
|
||||||
|
// Zwei Dreiecke teilen die Kante (1,0)-(0,1), aber über DUPLIZIERTE Vertices
|
||||||
|
// (getrennte Indizes, gleiche Koordinaten) — das Welding muss sie verbinden.
|
||||||
|
const shared: ImportedMesh = {
|
||||||
|
id: "m2",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Haus",
|
||||||
|
positions: [
|
||||||
|
0, 0, 0, 1, 0, 0, 0, 1, 0, // Dreieck 1: (0,0),(1,0),(0,1)
|
||||||
|
1, 0, 0, 0, 1, 0, 1, 1, 0, // Dreieck 2: (1,0),(0,1),(1,1) — dupl. Ecken
|
||||||
|
],
|
||||||
|
indices: [0, 1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
const parts = splitMeshComponents(shared);
|
||||||
|
expect(parts.length).toBe(1);
|
||||||
|
expect(parts[0].indices.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein einzelnes zusammenhängendes Mesh bleibt ein Mesh (bekommt aber die Ebene)", () => {
|
||||||
|
const one: ImportedMesh = {
|
||||||
|
id: "m3",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Solo",
|
||||||
|
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
};
|
||||||
|
const parts = splitMeshComponents(one);
|
||||||
|
expect(parts.length).toBe(1);
|
||||||
|
expect(parts[0].layer).toBe(BUILDING_LAYER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leeres/entartetes Mesh -> leeres Ergebnis (kein Crash)", () => {
|
||||||
|
const empty: ImportedMesh = { id: "e", type: "importedMesh", name: "x", positions: [], indices: [] };
|
||||||
|
expect(splitMeshComponents(empty)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stripOverhangSoffits", () => {
|
||||||
|
// Sockel (z=0) + Soffit/Wandkopf-Ring (z=2.5, EINE Dreieck-Fläche zeigt nach
|
||||||
|
// unten = Überstand-Untersicht) + Dach (z=3, nach oben) + zwei Wanddreiecke
|
||||||
|
// (senkrecht, nz≈0). Simuliert einen minimalen swissBUILDINGS3D-Baukörper mit
|
||||||
|
// einer sichtbaren Dachüberstand-Untersicht (Nutzer-Report: "man sieht die
|
||||||
|
// Wände von unten").
|
||||||
|
it("entfernt eine nach unten zeigende Überstand-Untersicht oberhalb der Grundmauer", () => {
|
||||||
|
const mesh: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Haus",
|
||||||
|
positions: [
|
||||||
|
0, 0, 0, // 0: Sockel
|
||||||
|
1, 0, 0, // 1: Sockel
|
||||||
|
0, 1, 0, // 2: Sockel
|
||||||
|
0, 0, 2.5, // 3: Wandkopf
|
||||||
|
1, 0, 2.5, // 4: Wandkopf
|
||||||
|
0, 1, 2.5, // 5: Wandkopf (nur vom Soffit referenziert)
|
||||||
|
0, 0, 3, // 6: Dach
|
||||||
|
1, 0, 3, // 7: Dach
|
||||||
|
0, 1, 3, // 8: Dach
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
0, 1, 2, // Sockel-Deckel (bleibt immer)
|
||||||
|
3, 5, 4, // Soffit (Normale zeigt nach unten, nz=-1) -> entfernt
|
||||||
|
6, 7, 8, // Dach (Normale zeigt nach oben, nz=+1) -> bleibt
|
||||||
|
0, 1, 4, // Wand (senkrecht, nz≈0) -> bleibt
|
||||||
|
0, 4, 3, // Wand (senkrecht, nz≈0) -> bleibt
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = stripOverhangSoffits(mesh);
|
||||||
|
// 5 Dreiecke -> 4 (Soffit weg) = 12 Indizes.
|
||||||
|
expect(out.indices.length).toBe(12);
|
||||||
|
// Vertex 5 wird nur vom Soffit referenziert -> nach Kompaktierung weg
|
||||||
|
// (8 statt 9 Vertices).
|
||||||
|
expect(out.positions.length).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt ein Mesh ohne Soffiten unverändert", () => {
|
||||||
|
const mesh: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Dreieck",
|
||||||
|
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
};
|
||||||
|
const out = stripOverhangSoffits(mesh);
|
||||||
|
expect(out.indices).toEqual([0, 1, 2]);
|
||||||
|
expect(out.positions).toEqual(mesh.positions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behält den Boden-/Sockel-Deckel unabhängig von seiner Windung", () => {
|
||||||
|
// Ein Dreieck exakt auf der Grundmauer-Höhe mit nach-unten-Windung -> die
|
||||||
|
// Boden-Ausnahme greift VOR der Normalen-Prüfung, bleibt also erhalten.
|
||||||
|
const mesh: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Sockel",
|
||||||
|
positions: [0, 0, 0, 0, 1, 0, 1, 0, 0], // a,c,b-Reihenfolge -> nz=-1
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
};
|
||||||
|
const out = stripOverhangSoffits(mesh);
|
||||||
|
expect(out.indices.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leeres/entartetes Mesh -> unverändert (kein Crash)", () => {
|
||||||
|
const empty: ImportedMesh = { id: "e", type: "importedMesh", name: "x", positions: [], indices: [] };
|
||||||
|
expect(stripOverhangSoffits(empty)).toBe(empty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,6 +105,189 @@ export function clipMeshToBbox(
|
|||||||
return { ...mesh, positions, indices };
|
return { ...mesh, positions, indices };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sichtbarer Ebenen-/Gruppierungsname der importierten Gebäude (SitePanel). */
|
||||||
|
export const BUILDING_LAYER = "Gebäude";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt ein Mesh in seine räumlich ZUSAMMENHÄNGENDEN Komponenten (Union-Find
|
||||||
|
* über verschweisste Vertices) — je Komponente ein eigenes {@link ImportedMesh}.
|
||||||
|
* So wird eine swissBUILDINGS3D-Kachel (ein Mesh mit ALLEN Gebäuden, s.
|
||||||
|
* `dxfParser.ts::parseDxf`) in EINZELNE, getrennt anwähl-/löschbare Gebäude
|
||||||
|
* aufgeteilt (Nutzer-Wunsch). Vertices werden per quantisierter Position
|
||||||
|
* (`weldMm`) verschweisst, damit Dreiecke, die sich einen Eckpunkt teilen, aber
|
||||||
|
* getrennte Indizes tragen (typisch für 3DFACE-DXF), als verbunden gelten. Jede
|
||||||
|
* Komponente bekommt eine eigene Id + fortlaufenden Namen + `layer`. Rein (kein
|
||||||
|
* Netzwerk), exportiert für Unit-Tests.
|
||||||
|
*/
|
||||||
|
export function splitMeshComponents(mesh: ImportedMesh, weldMm = 1): ImportedMesh[] {
|
||||||
|
const pos = mesh.positions;
|
||||||
|
const vcount = Math.floor(pos.length / 3);
|
||||||
|
const idx = mesh.indices;
|
||||||
|
if (vcount < 3 || idx.length < 3) return idx.length >= 3 ? [mesh] : [];
|
||||||
|
|
||||||
|
// 1) Weld: gleiche (quantisierte) Position -> gemeinsamer kanonischer Index.
|
||||||
|
const q = weldMm / 1000; // Meter
|
||||||
|
const weldMap = new Map<string, number>();
|
||||||
|
const canonical = new Int32Array(vcount);
|
||||||
|
for (let i = 0; i < vcount; i++) {
|
||||||
|
const key = `${Math.round(pos[i * 3] / q)},${Math.round(pos[i * 3 + 1] / q)},${Math.round(pos[i * 3 + 2] / q)}`;
|
||||||
|
let c = weldMap.get(key);
|
||||||
|
if (c === undefined) {
|
||||||
|
c = i;
|
||||||
|
weldMap.set(key, i);
|
||||||
|
}
|
||||||
|
canonical[i] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Union-Find über die kanonischen Vertices via Dreieckskanten.
|
||||||
|
const parent = new Int32Array(vcount);
|
||||||
|
for (let i = 0; i < vcount; i++) parent[i] = i;
|
||||||
|
const find = (x: number): number => {
|
||||||
|
let r = x;
|
||||||
|
while (parent[r] !== r) r = parent[r];
|
||||||
|
while (parent[x] !== r) {
|
||||||
|
const next = parent[x];
|
||||||
|
parent[x] = r;
|
||||||
|
x = next;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
const union = (a: number, b: number) => {
|
||||||
|
const ra = find(a);
|
||||||
|
const rb = find(b);
|
||||||
|
if (ra !== rb) parent[ra] = rb;
|
||||||
|
};
|
||||||
|
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||||
|
const a = idx[i], b = idx[i + 1], c = idx[i + 2];
|
||||||
|
if (a >= vcount || b >= vcount || c >= vcount) continue;
|
||||||
|
union(canonical[a], canonical[b]);
|
||||||
|
union(canonical[b], canonical[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Dreiecke nach Komponenten-Wurzel gruppieren.
|
||||||
|
const groups = new Map<number, number[]>();
|
||||||
|
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||||
|
const a = idx[i], b = idx[i + 1], c = idx[i + 2];
|
||||||
|
if (a >= vcount || b >= vcount || c >= vcount) continue;
|
||||||
|
const root = find(canonical[a]);
|
||||||
|
let g = groups.get(root);
|
||||||
|
if (!g) {
|
||||||
|
g = [];
|
||||||
|
groups.set(root, g);
|
||||||
|
}
|
||||||
|
g.push(a, b, c);
|
||||||
|
}
|
||||||
|
if (groups.size <= 1) return [{ ...mesh, layer: mesh.layer ?? BUILDING_LAYER }];
|
||||||
|
|
||||||
|
// 4) Je Komponente ein eigenes Mesh (Positions kompaktiert, Indizes neu gemappt).
|
||||||
|
const out: ImportedMesh[] = [];
|
||||||
|
let n = 0;
|
||||||
|
for (const tri of groups.values()) {
|
||||||
|
const remap = new Map<number, number>();
|
||||||
|
const positions: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
for (const old of tri) {
|
||||||
|
let ni = remap.get(old);
|
||||||
|
if (ni === undefined) {
|
||||||
|
ni = positions.length / 3;
|
||||||
|
positions.push(pos[old * 3], pos[old * 3 + 1], pos[old * 3 + 2]);
|
||||||
|
remap.set(old, ni);
|
||||||
|
}
|
||||||
|
indices.push(ni);
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
...mesh,
|
||||||
|
id: `${mesh.id}-b${n}`,
|
||||||
|
name: `${mesh.name} #${n + 1}`,
|
||||||
|
layer: mesh.layer ?? BUILDING_LAYER,
|
||||||
|
positions,
|
||||||
|
indices,
|
||||||
|
});
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ab welcher Höhe (Meter) über der Grundmauer ein Dreieck NICHT mehr als
|
||||||
|
* Boden-/Sockel-Deckel gilt (siehe {@link stripOverhangSoffits}). */
|
||||||
|
const BASE_CAP_TOL = 0.05;
|
||||||
|
/** Normalen-Höhenkomponente (z, Modell-Hoch-Achse): darunter gilt ein Dreieck
|
||||||
|
* als „zeigt deutlich nach unten" (Soffit-Kandidat). cos(70°) ≈ 0.34 — nur
|
||||||
|
* weitgehend WAAGRECHTE Unterseiten, keine steilen Dachflächen. */
|
||||||
|
const SOFFIT_DOWN_THRESHOLD = -0.35;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt Dachüberstand-Untersichten (Soffiten) aus einem swissBUILDINGS3D-
|
||||||
|
* Mesh: Dreiecke, deren Fläche deutlich nach UNTEN zeigt UND nicht auf
|
||||||
|
* Grundmauer-Höhe liegt (kein Boden-/Sockel-Deckel). Solche Flächen sind reine
|
||||||
|
* LOD2-Detailtreue (die Untersicht eines Dachüberstands) — im Konturen-
|
||||||
|
* Anzeigemodus fallen sie als zusätzliche „man sieht die Wand von unten"-
|
||||||
|
* Silhouettenkanten auf (Nutzer-Report), für den gewünschten Gipsmodell-/
|
||||||
|
* Massenmodell-Look unerwünscht.
|
||||||
|
*
|
||||||
|
* ANNAHME: die DXF-Quelle liefert pro Gebäude ein KONSISTENT (aussen = positive
|
||||||
|
* Hoch-Komponente) gewundenes Solid (Standard bei swisstopo-LOD2 — die
|
||||||
|
* doppelseitige Darstellung in render3d ist eine reine GPU-Vorsichtsmassnahme
|
||||||
|
* gegen Einzelfälle, keine Aussage über zufällige Windung). Bei inkonsistenter
|
||||||
|
* Windung könnte dies vereinzelt eine echte Dachfläche statt einer Soffite
|
||||||
|
* entfernen — visuell in Tauri zu verifizieren.
|
||||||
|
*
|
||||||
|
* `positions` wird KOMPAKTIERT (analog `clipMeshToBbox`). Rein (kein Netzwerk),
|
||||||
|
* exportiert für Unit-Tests.
|
||||||
|
*/
|
||||||
|
export function stripOverhangSoffits(mesh: ImportedMesh): ImportedMesh {
|
||||||
|
const pos = mesh.positions;
|
||||||
|
const idx = mesh.indices;
|
||||||
|
if (idx.length < 3 || pos.length < 9) return mesh;
|
||||||
|
|
||||||
|
let minZ = Infinity;
|
||||||
|
for (let i = 2; i < pos.length; i += 3) {
|
||||||
|
if (pos[i] < minZ) minZ = pos[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const keptTris: [number, number, number][] = [];
|
||||||
|
for (let i = 0; i + 2 < idx.length; i += 3) {
|
||||||
|
const a = idx[i];
|
||||||
|
const b = idx[i + 1];
|
||||||
|
const c = idx[i + 2];
|
||||||
|
const ax = pos[a * 3], ay = pos[a * 3 + 1], az = pos[a * 3 + 2];
|
||||||
|
const bx = pos[b * 3], by = pos[b * 3 + 1], bz = pos[b * 3 + 2];
|
||||||
|
const cx = pos[c * 3], cy = pos[c * 3 + 1], cz = pos[c * 3 + 2];
|
||||||
|
// Boden-/Sockel-Deckel (alle drei Ecken nahe der Grundmauer-Höhe) bleibt
|
||||||
|
// in jedem Fall erhalten — kein Soffit-Kandidat.
|
||||||
|
const isBaseCap =
|
||||||
|
az <= minZ + BASE_CAP_TOL && bz <= minZ + BASE_CAP_TOL && cz <= minZ + BASE_CAP_TOL;
|
||||||
|
if (isBaseCap) {
|
||||||
|
keptTris.push([a, b, c]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Flächennormale (Modell x,y,z=Höhe): z-Komponente = "nach oben/unten".
|
||||||
|
const ux = bx - ax, uy = by - ay, uz = bz - az;
|
||||||
|
const vx = cx - ax, vy = cy - ay, vz = cz - az;
|
||||||
|
const nx = uy * vz - uz * vy;
|
||||||
|
const ny = uz * vx - ux * vz;
|
||||||
|
const nz = ux * vy - uy * vx;
|
||||||
|
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
|
||||||
|
if (len > 1e-9 && nz / len < SOFFIT_DOWN_THRESHOLD) continue; // Soffit: weg
|
||||||
|
keptTris.push([a, b, c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remap = new Map<number, number>();
|
||||||
|
const positions: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
const mapVertex = (old: number): number => {
|
||||||
|
let ni = remap.get(old);
|
||||||
|
if (ni === undefined) {
|
||||||
|
ni = positions.length / 3;
|
||||||
|
positions.push(pos[old * 3], pos[old * 3 + 1], pos[old * 3 + 2]);
|
||||||
|
remap.set(old, ni);
|
||||||
|
}
|
||||||
|
return ni;
|
||||||
|
};
|
||||||
|
for (const [a, b, c] of keptTris) indices.push(mapVertex(a), mapVertex(b), mapVertex(c));
|
||||||
|
return { ...mesh, positions, indices };
|
||||||
|
}
|
||||||
|
|
||||||
export interface FetchBuildings3dResult {
|
export interface FetchBuildings3dResult {
|
||||||
origin: GeoOrigin;
|
origin: GeoOrigin;
|
||||||
meshes: ImportedMesh[];
|
meshes: ImportedMesh[];
|
||||||
@@ -169,7 +352,14 @@ export async function fetchBuildings3d(
|
|||||||
const parsed = parseDxf(text);
|
const parsed = parseDxf(text);
|
||||||
for (const m of parsed.meshes) {
|
for (const m of parsed.meshes) {
|
||||||
const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal);
|
const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal);
|
||||||
if (clipped.indices.length > 0) meshes.push(clipped);
|
if (clipped.indices.length < 3) continue;
|
||||||
|
// In EINZELNE Gebäude aufteilen (eine Kachel = ein Mesh mit allen
|
||||||
|
// Häusern) — so ist jedes Haus getrennt anwähl-/löschbar. Je Gebäude
|
||||||
|
// die Dachüberstand-Untersichten (Soffiten) entfernen (Gipsmodell-Look,
|
||||||
|
// s. stripOverhangSoffits-Moduldoc).
|
||||||
|
for (const b of splitMeshComponents(clipped)) {
|
||||||
|
meshes.push(stripOverhangSoffits(b));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { meshes, skipped };
|
return { meshes, skipped };
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Reine (netzwerkfreie) Bausteine des SWISSIMAGE-Imports: WMS-URL-Bau +
|
||||||
|
// Pixelgrössen-Deckelung. Der Fetch selbst bleibt ungetestet (kein Fetch-
|
||||||
|
// Mocking im Repo, analog swissTopo/swissAlti3d).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
buildSwissImageUrl,
|
||||||
|
imagePixelSize,
|
||||||
|
sampleColorsFromImageData,
|
||||||
|
} from "./swissImage";
|
||||||
|
|
||||||
|
describe("buildSwissImageUrl", () => {
|
||||||
|
it("baut eine WMS-1.1.1-GetMap-URL mit EPSG:2056 und E,N-bbox-Reihenfolge", () => {
|
||||||
|
const url = buildSwissImageUrl([2600000, 1200000, 2600400, 1200400], 2000, 2000);
|
||||||
|
expect(url).toContain("SERVICE=WMS");
|
||||||
|
expect(url).toContain("VERSION=1.1.1");
|
||||||
|
expect(url).toContain("REQUEST=GetMap");
|
||||||
|
expect(url).toContain("LAYERS=ch.swisstopo.swissimage");
|
||||||
|
expect(url).toContain("SRS=EPSG%3A2056");
|
||||||
|
// bbox in E,N: eMin,nMin,eMax,nMax.
|
||||||
|
expect(url).toContain("BBOX=2600000%2C1200000%2C2600400%2C1200400");
|
||||||
|
expect(url).toContain("WIDTH=2000");
|
||||||
|
expect(url).toContain("HEIGHT=2000");
|
||||||
|
expect(url).toContain("FORMAT=image%2Fjpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("imagePixelSize", () => {
|
||||||
|
it("skaliert mit der Spannweite (~0.2 m/px)", () => {
|
||||||
|
expect(imagePixelSize(400)).toBe(2000); // 400 m / 0.2
|
||||||
|
});
|
||||||
|
it("deckelt bei der Maximalgrösse", () => {
|
||||||
|
expect(imagePixelSize(100000)).toBe(2500);
|
||||||
|
});
|
||||||
|
it("hat eine sinnvolle Untergrenze", () => {
|
||||||
|
expect(imagePixelSize(1)).toBe(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sampleColorsFromImageData (Luftbild-Drape)", () => {
|
||||||
|
// 2×2-Bild: oben-links rot, oben-rechts grün, unten-links blau, unten-rechts weiss.
|
||||||
|
// Reihenfolge im RGBA-Puffer ist zeilenweise von OBEN (Zeile 0 = Nord/+Y).
|
||||||
|
const W = 2;
|
||||||
|
const H = 2;
|
||||||
|
const px = new Uint8ClampedArray([
|
||||||
|
255, 0, 0, 255, /* (0,0) rot */ 0, 255, 0, 255, /* (1,0) grün */
|
||||||
|
0, 0, 255, 255, /* (0,1) blau */ 255, 255, 255, 255, /* (1,1) weiss */
|
||||||
|
]);
|
||||||
|
const min = { x: 0, y: 0 };
|
||||||
|
const max = { x: 10, y: 10 };
|
||||||
|
|
||||||
|
it("mappt die Modell-Ecken auf die richtigen Bildpixel (Nord = Bild-oben)", () => {
|
||||||
|
// NW (x=0,y=10) -> Bild oben-links = rot; NE (10,10) -> oben-rechts = grün;
|
||||||
|
// SW (0,0) -> unten-links = blau; SE (10,0) -> unten-rechts = weiss.
|
||||||
|
const positions = [
|
||||||
|
0, 10, 0, // NW
|
||||||
|
10, 10, 0, // NE
|
||||||
|
0, 0, 0, // SW
|
||||||
|
10, 0, 0, // SE
|
||||||
|
];
|
||||||
|
const c = sampleColorsFromImageData(px, W, H, positions, min, max);
|
||||||
|
expect(c.slice(0, 3)).toEqual([1, 0, 0]); // rot
|
||||||
|
expect(c.slice(3, 6)).toEqual([0, 1, 0]); // grün
|
||||||
|
expect(c.slice(6, 9)).toEqual([0, 0, 1]); // blau
|
||||||
|
expect(c.slice(9, 12)).toEqual([1, 1, 1]); // weiss
|
||||||
|
});
|
||||||
|
|
||||||
|
it("klemmt Vertices ausserhalb der Bildbox auf den Randpixel", () => {
|
||||||
|
const c = sampleColorsFromImageData(px, W, H, [-100, 100, 0], min, max);
|
||||||
|
// x<min, y>max -> u=0, v=1 -> Bild oben-links = rot.
|
||||||
|
expect(c).toEqual([1, 0, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// SWISSIMAGE-Luftbild (swisstopo WMS) für eine LV95-bbox → georeferenziertes
|
||||||
|
// Rasterbild (Data-URL + Modell-Meter-Bounding-Box). Wird als Hintergrund-
|
||||||
|
// Ebene in den Grundriss gelegt (Kontext-Schicht, `ContextImage`).
|
||||||
|
//
|
||||||
|
// Bezug via WMS 1.1.1 GetMap (EPSG:2056, eindeutige E,N-Achsreihenfolge) auf
|
||||||
|
// `ch.swisstopo.swissimage` — dieselbe offene geo.admin-Infrastruktur wie die
|
||||||
|
// übrigen Importe. Das Bild wird als Blob geladen und in eine Data-URL gewandelt,
|
||||||
|
// damit es (wie die Bild-Schraffuren) ohne externen Host im Projekt/Export lebt.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||||
|
|
||||||
|
import { bboxAround, makeOrigin } from "./lv95";
|
||||||
|
import type { GeoOrigin, LV95 } from "./lv95";
|
||||||
|
import { viaProxy } from "./geoContext";
|
||||||
|
|
||||||
|
const WMS = "https://wms.geo.admin.ch/";
|
||||||
|
const LAYER = "ch.swisstopo.swissimage";
|
||||||
|
/** Ziel-Bodenauflösung (Meter/Pixel) — SWISSIMAGE liegt bei ~0.1–0.25 m/px. */
|
||||||
|
const TARGET_MPP = 0.2;
|
||||||
|
/** Max. Kantenlänge des angeforderten Bildes (Pixel) — WMS-Server-Limit + Speicher. */
|
||||||
|
const MAX_PX = 2500;
|
||||||
|
|
||||||
|
/** Ergebnis eines SWISSIMAGE-Abrufs. */
|
||||||
|
export interface FetchSwissImageResult {
|
||||||
|
origin: GeoOrigin;
|
||||||
|
/** Data-URL (JPEG) oder null (kein Bild / Fehler). */
|
||||||
|
dataUrl: string | null;
|
||||||
|
/** Modell-Meter-Bounding-Box, die das Bild ausfüllt (aus `origin` abgeleitet). */
|
||||||
|
min: { x: number; y: number };
|
||||||
|
max: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die WMS-1.1.1-GetMap-URL für eine LV95-bbox (Reihenfolge E,N) — rein,
|
||||||
|
* für Unit-Tests exportiert.
|
||||||
|
*/
|
||||||
|
export function buildSwissImageUrl(
|
||||||
|
bboxLv95: [number, number, number, number],
|
||||||
|
widthPx: number,
|
||||||
|
heightPx: number,
|
||||||
|
): string {
|
||||||
|
const [eMin, nMin, eMax, nMax] = bboxLv95;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
SERVICE: "WMS",
|
||||||
|
VERSION: "1.1.1",
|
||||||
|
REQUEST: "GetMap",
|
||||||
|
LAYERS: LAYER,
|
||||||
|
STYLES: "",
|
||||||
|
SRS: "EPSG:2056",
|
||||||
|
BBOX: `${eMin},${nMin},${eMax},${nMax}`,
|
||||||
|
WIDTH: String(widthPx),
|
||||||
|
HEIGHT: String(heightPx),
|
||||||
|
FORMAT: "image/jpeg",
|
||||||
|
});
|
||||||
|
return `${WMS}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pixelmaße für eine bbox-Spannweite (Meter) bei {@link TARGET_MPP}, gedeckelt. */
|
||||||
|
export function imagePixelSize(spanMeters: number): number {
|
||||||
|
const px = Math.round(spanMeters / TARGET_MPP);
|
||||||
|
return Math.max(64, Math.min(MAX_PX, px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes → base64 (chunked `btoa`) — umgeht `FileReader` (nicht in jeder
|
||||||
|
* Umgebung vorhanden) und die Argument-Längengrenze von `String.fromCharCode`.
|
||||||
|
*/
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
const CHUNK = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ArrayBuffer → Data-URL des Bildes. */
|
||||||
|
function bufferToDataUrl(buf: ArrayBuffer, mime: string): string {
|
||||||
|
return `data:${mime};base64,${bytesToBase64(new Uint8Array(buf))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sampelt für jeden Mesh-Vertex die Bildfarbe an seiner Lage — die reine
|
||||||
|
* (testbare) Kernrechnung des Luftbild-„Drapierens" aufs Gelände-TIN. `positions`
|
||||||
|
* ist das flache Modell-Array `[x,y,z, …]` (z = Höhe, ignoriert); `min`/`max` die
|
||||||
|
* Modell-Meter-Bounding-Box, die das Bild ausfüllt (Bild-Oben = +Y/Nord). Liefert
|
||||||
|
* eine flache Pro-Vertex-Albedo `[r,g,b, …]` (0..1). Vertices ausserhalb der Box
|
||||||
|
* klemmen auf den Randpixel.
|
||||||
|
*/
|
||||||
|
export function sampleColorsFromImageData(
|
||||||
|
data: Uint8ClampedArray | Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
positions: readonly number[],
|
||||||
|
min: { x: number; y: number },
|
||||||
|
max: { x: number; y: number },
|
||||||
|
): number[] {
|
||||||
|
const spanX = max.x - min.x || 1;
|
||||||
|
const spanY = max.y - min.y || 1;
|
||||||
|
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||||
|
const u = clamp01((positions[i] - min.x) / spanX);
|
||||||
|
// Bildzeile 0 (oben) entspricht Nord (+Y) -> v=1; daher (1 - v) spiegeln.
|
||||||
|
const v = clamp01((positions[i + 1] - min.y) / spanY);
|
||||||
|
const px = Math.min(width - 1, Math.max(0, Math.round(u * (width - 1))));
|
||||||
|
const py = Math.min(height - 1, Math.max(0, Math.round((1 - v) * (height - 1))));
|
||||||
|
const idx = (py * width + px) * 4;
|
||||||
|
out.push(data[idx] / 255, data[idx + 1] / 255, data[idx + 2] / 255);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dekodiert eine Bild-Data-URL und sampelt daraus die Pro-Vertex-Albedo für ein
|
||||||
|
* Gelände-TIN (siehe {@link sampleColorsFromImageData}). Nutzt `createImageBitmap`
|
||||||
|
* + `OffscreenCanvas` (im App-/WebView-Kontext vorhanden; in Node/Tests fehlen sie
|
||||||
|
* — dann `null`, kein Wurf). So bleibt der Render-Pfad synchron: gesampelt wird
|
||||||
|
* EINMAL beim Import, das Ergebnis lebt auf `TerrainMesh.vertexColors`.
|
||||||
|
*/
|
||||||
|
export async function sampleTerrainColors(
|
||||||
|
dataUrl: string,
|
||||||
|
positions: readonly number[],
|
||||||
|
min: { x: number; y: number },
|
||||||
|
max: { x: number; y: number },
|
||||||
|
): Promise<number[] | null> {
|
||||||
|
if (
|
||||||
|
typeof createImageBitmap !== "function" ||
|
||||||
|
typeof OffscreenCanvas !== "function"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(dataUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const bmp = await createImageBitmap(blob);
|
||||||
|
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(bmp, 0, 0);
|
||||||
|
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
|
||||||
|
return sampleColorsFromImageData(img.data, bmp.width, bmp.height, positions, min, max);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dekodiertes Bild: dichte RGBA8-Bytes + Maße (zeilenweise von oben). */
|
||||||
|
export interface DecodedImage {
|
||||||
|
data: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dekodiert eine Bild-Data-URL zu dichten RGBA8-Bytes (für den WASM-Textur-Upload
|
||||||
|
* beim 3D-Luftbild-Drapieren, `WebModelRenderer.set_aerial_texture`). Nutzt
|
||||||
|
* `createImageBitmap` + `OffscreenCanvas` (App-/WebView-Kontext; in Node/Tests
|
||||||
|
* fehlen sie → `null`, kein Wurf).
|
||||||
|
*/
|
||||||
|
export async function decodeImageToRgba(dataUrl: string): Promise<DecodedImage | null> {
|
||||||
|
if (
|
||||||
|
typeof createImageBitmap !== "function" ||
|
||||||
|
typeof OffscreenCanvas !== "function"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(dataUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const bmp = await createImageBitmap(blob);
|
||||||
|
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(bmp, 0, 0);
|
||||||
|
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
|
||||||
|
return {
|
||||||
|
data: new Uint8Array(img.data.buffer.slice(0)),
|
||||||
|
width: bmp.width,
|
||||||
|
height: bmp.height,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt ein SWISSIMAGE-Luftbild für eine LV95-bbox (Zentrum + Radius) und liefert
|
||||||
|
* es als Data-URL samt Modell-Meter-Bounding-Box. `originIn` wiederverwenden
|
||||||
|
* (wie die übrigen Importe), damit das Bild lagerichtig zu Gebäuden/Terrain
|
||||||
|
* liegt. Wirft nicht — bei Netz-/Serverfehler ist `dataUrl` null.
|
||||||
|
*/
|
||||||
|
export async function fetchSwissImage(
|
||||||
|
center: LV95,
|
||||||
|
radius: number,
|
||||||
|
originIn?: GeoOrigin,
|
||||||
|
): Promise<FetchSwissImageResult> {
|
||||||
|
const origin = originIn ?? makeOrigin(center);
|
||||||
|
const bboxLv95 = bboxAround(center, radius);
|
||||||
|
const px = imagePixelSize(2 * radius);
|
||||||
|
const min = { x: bboxLv95[0] - origin.e, y: bboxLv95[1] - origin.n };
|
||||||
|
const max = { x: bboxLv95[2] - origin.e, y: bboxLv95[3] - origin.n };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(viaProxy(buildSwissImageUrl(bboxLv95, px, px)));
|
||||||
|
if (!res.ok) return { origin, dataUrl: null, min, max };
|
||||||
|
// WMS liefert bei Fehlern manchmal eine XML-Fehlermeldung statt eines Bildes.
|
||||||
|
const mime = res.headers.get("content-type") ?? "";
|
||||||
|
if (!mime.startsWith("image/")) return { origin, dataUrl: null, min, max };
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
if (buf.byteLength === 0) return { origin, dataUrl: null, min, max };
|
||||||
|
return { origin, dataUrl: bufferToDataUrl(buf, mime.split(";")[0]), min, max };
|
||||||
|
} catch {
|
||||||
|
return { origin, dataUrl: null, min, max };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ResourceWindowApp } from "./native/ResourceWindowApp";
|
||||||
|
import { SettingsWindowApp } from "./native/SettingsWindowApp";
|
||||||
|
import { DrawingLevelsWindowApp } from "./native/DrawingLevelsWindowApp";
|
||||||
|
import { LayerSettingsWindowApp } from "./native/LayerSettingsWindowApp";
|
||||||
|
import { ContextImportWindowApp } from "./native/ContextImportWindowApp";
|
||||||
import { ErrorBoundary } from "./ui/ErrorBoundary";
|
import { ErrorBoundary } from "./ui/ErrorBoundary";
|
||||||
import { getLanguage, setLanguage } from "./i18n";
|
import { getLanguage, setLanguage } from "./i18n";
|
||||||
import type { Language } from "./i18n";
|
import type { Language } from "./i18n";
|
||||||
|
import { applyThemeMode, getStoredThemeMode } from "./theme/themeMode";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import "./text/fonts.css";
|
import "./text/fonts.css";
|
||||||
|
|
||||||
|
// Hell/Dunkel VOR dem ersten Render setzen (jedes native Fenster ist ein
|
||||||
|
// eigenes Dokument und mountet über dieselbe main.tsx) — verhindert einen
|
||||||
|
// Hell/Dunkel-Flackerer beim Laden.
|
||||||
|
applyThemeMode(getStoredThemeMode());
|
||||||
|
|
||||||
|
/** Eigenes natives Nebenfenster statt der Haupt-App? Steuert per URL-Query,
|
||||||
|
* welcher Root gemountet wird (siehe native/resourceWindow.ts bzw.
|
||||||
|
* native/settingsWindow.ts, die die URL so setzen). `?window=resources`/
|
||||||
|
* `?window=settings` → nur der jeweilige Manager/Dialog, ohne den ganzen
|
||||||
|
* App-Baum (eigener Prozesskontext, kein gemeinsamer Store). */
|
||||||
|
const windowKind = new URLSearchParams(window.location.search).get("window");
|
||||||
|
|
||||||
// Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n-
|
// Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n-
|
||||||
// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar.
|
// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar.
|
||||||
declare global {
|
declare global {
|
||||||
@@ -52,10 +70,19 @@ document.addEventListener("dragstart", (e) => {
|
|||||||
if (!isTextEntry(e.target)) e.preventDefault();
|
if (!isTextEntry(e.target)) e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function RootByWindowKind() {
|
||||||
|
if (windowKind === "resources") return <ResourceWindowApp />;
|
||||||
|
if (windowKind === "settings") return <SettingsWindowApp />;
|
||||||
|
if (windowKind === "drawing-levels") return <DrawingLevelsWindowApp />;
|
||||||
|
if (windowKind === "layer-settings") return <LayerSettingsWindowApp />;
|
||||||
|
if (windowKind === "context-import") return <ContextImportWindowApp />;
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<RootByWindowKind />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ export const MATERIAL_LIBRARY: MaterialAsset[] = [
|
|||||||
ao: "/assets/materials/Bricks104/ao.jpg",
|
ao: "/assets/materials/Bricks104/ao.jpg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "RoofingTiles013A",
|
||||||
|
name: "Dachziegel",
|
||||||
|
category: "Roofing Tiles",
|
||||||
|
maps: {
|
||||||
|
color: "/assets/materials/RoofingTiles013A/color.jpg",
|
||||||
|
normal: "/assets/materials/RoofingTiles013A/normal.jpg",
|
||||||
|
roughness: "/assets/materials/RoofingTiles013A/roughness.jpg",
|
||||||
|
displacement: "/assets/materials/RoofingTiles013A/displacement.jpg",
|
||||||
|
ao: "/assets/materials/RoofingTiles013A/ao.jpg",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "Tiles141",
|
id: "Tiles141",
|
||||||
name: "Bodenfliesen",
|
name: "Bodenfliesen",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { Column, ExtrudedSolid, Project } from "./types";
|
import type { Column, ExtrudedSolid, Project } from "./types";
|
||||||
import { sampleProject } from "./sampleProject";
|
import { sampleProject } from "./sampleProject";
|
||||||
import { translateProject } from "./geoRebase";
|
import { translateProject, projectToWorldCoords, projectGeoOrigin } from "./geoRebase";
|
||||||
|
|
||||||
const DX = 100;
|
const DX = 100;
|
||||||
const DY = -50;
|
const DY = -50;
|
||||||
@@ -201,3 +201,49 @@ describe("translateProject", () => {
|
|||||||
expect(out.geoAnchor).toBeUndefined();
|
expect(out.geoAnchor).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("projectGeoOrigin / projectToWorldCoords", () => {
|
||||||
|
it("ohne geoAnchor -> Origin null, projectToWorldCoords ist Identität", () => {
|
||||||
|
expect(projectGeoOrigin(sampleProject)).toBeNull();
|
||||||
|
expect(projectToWorldCoords(sampleProject)).toBe(sampleProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Origin = lv95 − model", () => {
|
||||||
|
const p: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
geoAnchor: { lv95: { e: 2600500, n: 1200300 }, model: { x: 500, y: 300 } },
|
||||||
|
};
|
||||||
|
expect(projectGeoOrigin(p)).toEqual({ e: 2600000, n: 1200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Round-Trip: lokal bearbeitetes Modell exportiert an echten LV95-Koordinaten", () => {
|
||||||
|
// Rohimport (model == LV95): eine Wand steht an LV95 (2600010, 1200020).
|
||||||
|
// Danach per georef um (−2600000, −1200000) lokalisiert -> Wand bei (10,20),
|
||||||
|
// geoAnchor.model wandert mit. Der Welt-Export muss (2600010,1200020) liefern.
|
||||||
|
const raw: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
walls: [
|
||||||
|
{
|
||||||
|
id: "W1",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: { x: 2600010, y: 1200020 },
|
||||||
|
end: { x: 2600015, y: 1200020 },
|
||||||
|
wallTypeId: "aw",
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 2600000, y: 1200000 } },
|
||||||
|
};
|
||||||
|
// georef: klick auf (2600000,1200000) -> translate um (−2600000,−1200000).
|
||||||
|
const local = translateProject(raw, -2600000, -1200000);
|
||||||
|
expect(local.walls[0].start).toEqual({ x: 10, y: 20 });
|
||||||
|
// geoAnchor.model ist mitgewandert, lv95 fix.
|
||||||
|
expect(local.geoAnchor).toEqual({ lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } });
|
||||||
|
// Welt-Export rechnet exakt auf die ursprünglichen LV95-Koordinaten zurück.
|
||||||
|
const world = projectToWorldCoords(local);
|
||||||
|
expect(world.walls[0].start).toEqual({ x: 2600010, y: 1200020 });
|
||||||
|
expect(world.walls[0].end).toEqual({ x: 2600015, y: 1200020 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ function shiftContextObject(obj: ContextObject, dx: number, dy: number): Context
|
|||||||
if (obj.type === "contourSet") {
|
if (obj.type === "contourSet") {
|
||||||
return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) };
|
return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) };
|
||||||
}
|
}
|
||||||
|
if (obj.type === "image") {
|
||||||
|
return { ...obj, min: shift(obj.min, dx, dy), max: shift(obj.max, dx, dy) };
|
||||||
|
}
|
||||||
const positions = obj.positions.slice();
|
const positions = obj.positions.slice();
|
||||||
for (let i = 0; i + 2 < positions.length; i += 3) {
|
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||||
positions[i] += dx;
|
positions[i] += dx;
|
||||||
@@ -118,3 +121,28 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
|
|||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der LV95-Ursprung eines Projekts: die reale Landeskoordinate, die auf den
|
||||||
|
* Modell-Nullpunkt (0,0) abgebildet wird. Aus `geoAnchor` abgeleitet
|
||||||
|
* (`origin = lv95 − model`), oder `null`, wenn KEIN Standort-Bezug existiert.
|
||||||
|
*/
|
||||||
|
export function projectGeoOrigin(project: Project): { e: number; n: number } | null {
|
||||||
|
const a = project.geoAnchor;
|
||||||
|
if (!a) return null;
|
||||||
|
return { e: a.lv95.e - a.model.x, n: a.lv95.n - a.model.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopie des Projekts mit SÄMTLICHER Geometrie auf die REALEN Weltkoordinaten
|
||||||
|
* (LV95) verschoben — für georeferenzierte MODELL-Exporte (IFC/DXF): das Modell
|
||||||
|
* wird lokal nahe (0,0) bearbeitet, der Export schreibt aber die wahren
|
||||||
|
* Landeskoordinaten, damit die Datei in anderer CAD-/BIM-Software lagerichtig
|
||||||
|
* sitzt. Ohne `geoAnchor` (kein Bezug) bleibt das Projekt unverändert (Identität).
|
||||||
|
* NICHT für Papier-/Layout-Exporte (die bleiben in lokalen Koordinaten).
|
||||||
|
*/
|
||||||
|
export function projectToWorldCoords(project: Project): Project {
|
||||||
|
const origin = projectGeoOrigin(project);
|
||||||
|
if (!origin) return project;
|
||||||
|
return translateProject(project, origin.e, origin.n);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { materialFromAsset } from "../materials/runtime";
|
|||||||
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
|
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
|
||||||
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
|
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
|
||||||
const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!;
|
const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!;
|
||||||
|
const ROOF_TILE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "RoofingTiles013A")!;
|
||||||
|
|
||||||
// Demo-Haus mit zwei Geschossen:
|
// Demo-Haus mit zwei Geschossen:
|
||||||
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
|
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
|
||||||
@@ -148,6 +149,19 @@ export const sampleProject: Project = {
|
|||||||
joinPriority: 10,
|
joinPriority: 10,
|
||||||
material: materialFromAsset(PLASTER_ASSET),
|
material: materialFromAsset(PLASTER_ASSET),
|
||||||
},
|
},
|
||||||
|
// Dacheindeckung (Tonziegel) — eigenes Bauteil statt des früheren
|
||||||
|
// "brick"-Platzhalters, damit Dächer eine ECHTE Ziegel-Textur zeigen
|
||||||
|
// (Stil „Texturiert") statt der Backstein-Fassadentextur. `color` (Flach-
|
||||||
|
// Fallback für „Schattiert") entspricht dem bisherigen `ROOF_RGB` in
|
||||||
|
// toWalls3d.ts, damit der Nicht-Textur-Look unverändert warm/terracotta bleibt.
|
||||||
|
{
|
||||||
|
id: "roof-tiles",
|
||||||
|
name: "Dachziegel",
|
||||||
|
color: "#b8735c",
|
||||||
|
hatchId: "sia-brick",
|
||||||
|
joinPriority: 15,
|
||||||
|
material: materialFromAsset(ROOF_TILE_ASSET),
|
||||||
|
},
|
||||||
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
|
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
|
||||||
{
|
{
|
||||||
id: "concrete",
|
id: "concrete",
|
||||||
@@ -208,7 +222,7 @@ export const sampleProject: Project = {
|
|||||||
name: "Steildach gedämmt",
|
name: "Steildach gedämmt",
|
||||||
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
|
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
|
||||||
layers: [
|
layers: [
|
||||||
{ componentId: "brick", thickness: 0.04 }, // Eindeckung (Ziegel, Platzhalter)
|
{ componentId: "roof-tiles", thickness: 0.04 }, // Eindeckung (Tonziegel)
|
||||||
{ componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung
|
{ componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung
|
||||||
{ componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK)
|
{ componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK)
|
||||||
],
|
],
|
||||||
@@ -546,3 +560,24 @@ export const sampleProject: Project = {
|
|||||||
// über den DXF-Import bzw. die Gelände-Generierung gefüllt.
|
// über den DXF-Import bzw. die Gelände-Generierung gefüllt.
|
||||||
context: [],
|
context: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leeres Projekt (nur Bibliotheken, keine Geometrie) — für manuelle Tests, die
|
||||||
|
* ohne das Demo-Haus starten wollen (z. B. Standort-Import auf leerer Fläche).
|
||||||
|
*/
|
||||||
|
export const emptyProject: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
id: "empty",
|
||||||
|
name: "Leeres Projekt",
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
roofs: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
columns: [],
|
||||||
|
extrudedSolids: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import type { Contour, TerrainMesh } from "./types";
|
|||||||
|
|
||||||
let terrainSeq = 0;
|
let terrainSeq = 0;
|
||||||
|
|
||||||
|
/** Sichtbarer Ebenen-/Gruppierungsname des Geländes (SitePanel). */
|
||||||
|
export const TERRAIN_LAYER = "Terrain";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt ein TIN aus Konturen.
|
* Erzeugt ein TIN aus Konturen.
|
||||||
*
|
*
|
||||||
@@ -96,7 +99,7 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
|
|||||||
indices.push(a, b, c);
|
indices.push(a, b, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id, type: "terrainMesh", name, positions, indices };
|
return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,5 +156,5 @@ export function terrainMeshFromGrid(grid: TerrainGrid): TerrainMesh {
|
|||||||
if (positions.length < 9 || indices.length < 3) {
|
if (positions.length < 9 || indices.length < 3) {
|
||||||
return { id, type: "terrainMesh", name, positions: [], indices: [] };
|
return { id, type: "terrainMesh", name, positions: [], indices: [] };
|
||||||
}
|
}
|
||||||
return { id, type: "terrainMesh", name, positions, indices };
|
return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,6 +453,14 @@ export interface WindowType {
|
|||||||
insetFromFace?: number;
|
insetFromFace?: number;
|
||||||
/** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */
|
/** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */
|
||||||
insetFace?: "aussen" | "innen";
|
insetFace?: "aussen" | "innen";
|
||||||
|
/**
|
||||||
|
* Schicht-Rücksprung an der Laibung: weicht die INNERSTE Wandschicht (meist
|
||||||
|
* Gipsplatte/Holzverkleidung) an diesem Fenster um ihre eigene Dicke von der
|
||||||
|
* Öffnung zurück, statt bündig mit den übrigen Schichten abzuschliessen —
|
||||||
|
* anders als {@link insetFromFace} (der Rahmen-Position), ändert dies die
|
||||||
|
* WAND-GEOMETRIE selbst an der Laibung. Default false (bündig, Alt-Verhalten).
|
||||||
|
*/
|
||||||
|
innerLayerRecess?: boolean;
|
||||||
/**
|
/**
|
||||||
* Oberlicht: Höhe eines festen, verglasten Oberlichts über dem Hauptflügel
|
* Oberlicht: Höhe eines festen, verglasten Oberlichts über dem Hauptflügel
|
||||||
* (Meter), abgeteilt durch einen Kämpfer. 0/fehlt ⇒ kein Oberlicht.
|
* (Meter), abgeteilt durch einen Kämpfer. 0/fehlt ⇒ kein Oberlicht.
|
||||||
@@ -1356,6 +1364,12 @@ export interface Stair {
|
|||||||
* Kategorie-Farbe.
|
* Kategorie-Farbe.
|
||||||
*/
|
*/
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/**
|
||||||
|
* Darstellung des Auf-/Abpfeils an der Lauflinie: "line" (offener
|
||||||
|
* Winkelpfeil, Default = heutiges Verhalten) oder "filled" (gefülltes
|
||||||
|
* Dreieck als SIA-Vollpfeil). Fehlt sie, gilt "line".
|
||||||
|
*/
|
||||||
|
arrowStyle?: "line" | "filled";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1761,12 +1775,49 @@ export interface TerrainMesh {
|
|||||||
id: string;
|
id: string;
|
||||||
type: "terrainMesh";
|
type: "terrainMesh";
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Terrain". Optional. */
|
||||||
|
layer?: string;
|
||||||
positions: number[];
|
positions: number[];
|
||||||
indices: number[];
|
indices: number[];
|
||||||
|
/**
|
||||||
|
* Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je
|
||||||
|
* Positions-Vertex, beim Import aus einem SWISSIMAGE-Luftbild gesampelt
|
||||||
|
* („Luftbild aufs Gelände drapieren"). render3d färbt das TIN dann pro Vertex
|
||||||
|
* statt mit der Grundfarbe. Fehlt es, bleibt das Gelände einfarbig.
|
||||||
|
*/
|
||||||
|
vertexColors?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */
|
/**
|
||||||
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
|
* Ein georeferenziertes Rasterbild (Luftbild/SWISSIMAGE, eingefügtes Bild) —
|
||||||
|
* planar von oben auf die Modell-Ebene gelegt. `src` = Data-URL (PNG/JPEG),
|
||||||
|
* `min`/`max` = Modell-Meter-Bounding-Box, die das Bild ausfüllt (Bild-Oben =
|
||||||
|
* +Y). Wird im Grundriss als Hintergrund-Ebene gezeichnet (unter der Geometrie).
|
||||||
|
* Gelände-Drapierung im 3D ist Folge-Arbeit.
|
||||||
|
*/
|
||||||
|
export interface ContextImage {
|
||||||
|
id: string;
|
||||||
|
type: "image";
|
||||||
|
name: string;
|
||||||
|
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Luftbild". Optional. */
|
||||||
|
layer?: string;
|
||||||
|
/** Bildquelle als Data-URL (PNG/JPEG). */
|
||||||
|
src: string;
|
||||||
|
/** Modell-Meter Bounding-Box (das Bild füllt [min..max] planar von oben). */
|
||||||
|
min: Vec2;
|
||||||
|
max: Vec2;
|
||||||
|
/** Deckkraft 0..1 (Default 1). */
|
||||||
|
opacity?: number;
|
||||||
|
/**
|
||||||
|
* Aufs Gelände-TIN „drapieren": im 3D-Viewer wird das Bild als Textur über das
|
||||||
|
* Terrain gelegt (pixelscharf, s. render3d `set_aerial_texture`). Unabhängig von
|
||||||
|
* der 2D-Hintergrund-Darstellung. Default false.
|
||||||
|
*/
|
||||||
|
drape?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz, TIN oder Rasterbild. */
|
||||||
|
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh | ContextImage;
|
||||||
|
|
||||||
/** Das gesamte Projekt. */
|
/** Das gesamte Projekt. */
|
||||||
/**
|
/**
|
||||||
@@ -2196,6 +2247,13 @@ export interface Project {
|
|||||||
geoAnchor?: {
|
geoAnchor?: {
|
||||||
/** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */
|
/** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */
|
||||||
lv95: { e: number; n: number };
|
lv95: { e: number; n: number };
|
||||||
|
/**
|
||||||
|
* Reale Höhe des Bezugspunkts in Metern über Meer (m ü. M.). Wie ArchiCADs
|
||||||
|
* „Vermessungspunkt" (Ost/Nord/Höhe): rein informativ verankert, VERSCHIEBT
|
||||||
|
* das Modell NICHT. Wird beim IFC-Export als `IfcSite.RefElevation`
|
||||||
|
* (Standort-Datumshöhe) geschrieben. Optional.
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
/** Zugehöriger Punkt im Modell (lokale Meter), Default (0,0). */
|
/** Zugehöriger Punkt im Modell (lokale Meter), Default (0,0). */
|
||||||
model: Vec2;
|
model: Vec2;
|
||||||
/** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */
|
/** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Eigenständiger React-Root für das native Standort-Import-Fenster (siehe
|
||||||
|
// contextImportWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
|
||||||
|
// main.tsx gerendert, wenn die URL `?window=context-import` trägt.
|
||||||
|
//
|
||||||
|
// Rendert `ContextImportDialog` UNVERÄNDERT (windowed=true) — der Dialog
|
||||||
|
// selbst braucht keinerlei Host-/Store-Zugriff (reine `io/*`-Netzabrufe),
|
||||||
|
// daher hier kein Proxy-Pattern nötig wie bei den anderen Fenstern: nur
|
||||||
|
// `onImport`/`onSetGeoAnchor` laufen als einzelne Events ans Hauptfenster.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
||||||
|
import type { GeoAnchor } from "../ui/ContextImportDialog";
|
||||||
|
import type { ContextImportSyncState } from "./contextImportWindow";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
export function ContextImportWindowApp() {
|
||||||
|
const [state, setState] = useState<ContextImportSyncState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<ContextImportSyncState>("context-import-sync-state", (e) =>
|
||||||
|
setState(e.payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await emitTo("main", "context-import-window-ready").catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const send = (name: string, ...args: unknown[]) => {
|
||||||
|
void import("@tauri-apps/api/event").then(({ emitTo }) =>
|
||||||
|
emitTo("main", "context-import-command", { name, args }).catch(() => {}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
|
||||||
|
getCurrentWindow().close(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
color: "var(--ink-dim, #8a8a8a)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("resources.loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextImportDialog
|
||||||
|
onImport={(objs) => send("onImport", objs)}
|
||||||
|
onClose={onClose}
|
||||||
|
geoAnchor={state.geoAnchor}
|
||||||
|
onSetGeoAnchor={(anchor: GeoAnchor | undefined) => send("onSetGeoAnchor", anchor)}
|
||||||
|
windowed
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Eigenständiger React-Root für das native Zeichnungsebenen-Fenster (siehe
|
||||||
|
// drawingLevelsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
|
||||||
|
// main.tsx gerendert, wenn die URL `?window=drawing-levels` trägt.
|
||||||
|
//
|
||||||
|
// Spiegelt die Kategorie-/Zeilen-Darstellung des Dock-Panels
|
||||||
|
// (panels/DrawingLevelsPanel.tsx) — nutzt dessen exportierte `LevelRow` +
|
||||||
|
// Kategorie-Konstanten direkt (keine Duplikation), aber NICHT `usePanelHost`
|
||||||
|
// (das ist an den riesigen, docked-Panel-spezifischen PanelHostContext
|
||||||
|
// gebunden); stattdessen der schmale Sync-/Command-Zustand von
|
||||||
|
// drawingLevelsWindow.ts, analog SettingsWindowApp.tsx.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
LevelRow,
|
||||||
|
CATEGORY_ORDER,
|
||||||
|
CATEGORY_TITLE_KEY,
|
||||||
|
CATEGORY_ADD_KEY,
|
||||||
|
} from "../panels/DrawingLevelsPanel";
|
||||||
|
import type { DrawingLevelsCommandHandlers, DrawingLevelsSyncState } from "./drawingLevelsWindow";
|
||||||
|
import type { DrawingLevelKind } from "../model/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
type HandlerName = keyof DrawingLevelsCommandHandlers;
|
||||||
|
|
||||||
|
export function DrawingLevelsWindowApp() {
|
||||||
|
const [state, setState] = useState<DrawingLevelsSyncState | null>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<DrawingLevelKind, boolean>>({
|
||||||
|
floor: false,
|
||||||
|
section: false,
|
||||||
|
elevation: false,
|
||||||
|
drawing: false,
|
||||||
|
});
|
||||||
|
const toggleCategory = (kind: DrawingLevelKind) =>
|
||||||
|
setCollapsed((c) => ({ ...c, [kind]: !c[kind] }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<DrawingLevelsSyncState>("drawing-levels-sync-state", (e) =>
|
||||||
|
setState(e.payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await emitTo("main", "drawing-levels-window-ready").catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlers = useMemo<DrawingLevelsCommandHandlers>(
|
||||||
|
() =>
|
||||||
|
new Proxy({} as DrawingLevelsCommandHandlers, {
|
||||||
|
get(_target, prop: string) {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
void import("@tauri-apps/api/event").then(({ emitTo }) =>
|
||||||
|
emitTo("main", "drawing-levels-command", { name: prop as HandlerName, args }).catch(
|
||||||
|
() => {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
color: "var(--ink-dim, #8a8a8a)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("resources.loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAddByKind: Record<DrawingLevelKind, () => void> = {
|
||||||
|
floor: handlers.onAddFloor,
|
||||||
|
section: handlers.onAddSection,
|
||||||
|
elevation: handlers.onAddElevation,
|
||||||
|
drawing: handlers.onAddDrawing,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Oberstes Geschoss zuoberst (wie das Dock-Panel/DOSSIER): Anzeige umkehren.
|
||||||
|
const levelsTopDown = [...state.drawingLevels].reverse();
|
||||||
|
|
||||||
|
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
|
||||||
|
// „Ausgewählte" — dieselbe DOSSIER-Regel wie im Dock-Panel.
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
if (state.displayMode === "all_force" || state.displayMode === "active") {
|
||||||
|
handlers.onSetDisplayMode("all");
|
||||||
|
}
|
||||||
|
handlers.onToggleLevel(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="nav-group"
|
||||||
|
style={{ height: "100vh", overflowY: "auto" }}
|
||||||
|
>
|
||||||
|
{/* Selber Kopf wie die übrigen vier Fenster (.res-head/.imp-head):
|
||||||
|
44px, border-bottom, 76px Ampel-Abstand — kein Dock-Panel-Look mehr. */}
|
||||||
|
<header className="res-head" data-tauri-drag-region style={{ paddingLeft: 88 }}>
|
||||||
|
<span className="res-head-title">{t("nav.drawingLevels")}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{CATEGORY_ORDER.map((kind) => {
|
||||||
|
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
|
||||||
|
const isOpen = !collapsed[kind];
|
||||||
|
return (
|
||||||
|
<div className="level-cat" key={kind}>
|
||||||
|
<header className="level-cat-head" onClick={() => toggleCategory(kind)}>
|
||||||
|
<span className={`level-cat-chevron${isOpen ? " open" : ""}`} aria-hidden="true">
|
||||||
|
▸
|
||||||
|
</span>
|
||||||
|
<span className="level-cat-title">{t(CATEGORY_TITLE_KEY[kind])}</span>
|
||||||
|
<span
|
||||||
|
className="level-cat-add"
|
||||||
|
role="button"
|
||||||
|
aria-label={t(CATEGORY_ADD_KEY[kind])}
|
||||||
|
title={t(CATEGORY_ADD_KEY[kind])}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddByKind[kind]();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="nav-list">
|
||||||
|
{levelsOfKind.map((level) => (
|
||||||
|
<LevelRow
|
||||||
|
key={level.id}
|
||||||
|
level={level}
|
||||||
|
active={level.id === state.activeLevelId}
|
||||||
|
mode={state.displayMode}
|
||||||
|
onSelect={() => handlers.onSelectLevel(level.id)}
|
||||||
|
onToggle={() => toggle(level.id)}
|
||||||
|
// Rechtsklick-Kontextmenü bewusst kein Bestandteil des
|
||||||
|
// Fensters (s. drawingLevelsWindow.ts Kopfkommentar).
|
||||||
|
onContext={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Eigenständiger React-Root für das native Ebeneneinstellungs-Fenster (siehe
|
||||||
|
// layerSettingsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
|
||||||
|
// main.tsx gerendert, wenn die URL `?window=layer-settings` trägt.
|
||||||
|
//
|
||||||
|
// Dieselben Felder wie der bisherige `InlineEditor`-Popover in App.tsx
|
||||||
|
// (`editor?.kind === "layer"`): Name, Farbe, Linienstärke, Schraffur-Auswahl
|
||||||
|
// — siehe layerSettingsWindow.ts Kopfkommentar für den bewussten Scope-Schnitt
|
||||||
|
// gegenüber der Rhino-Vorlage.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { EditorField } from "../ui/EditorField";
|
||||||
|
import type { LayerSettingsCommandHandlers, LayerSettingsSyncState } from "./layerSettingsWindow";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
function initialCodeFromUrl(): string {
|
||||||
|
return new URLSearchParams(window.location.search).get("code") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayerSettingsWindowApp() {
|
||||||
|
const [state, setState] = useState<LayerSettingsSyncState | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<LayerSettingsSyncState | null>("layer-settings-sync-state", (e) =>
|
||||||
|
setState(e.payload),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Beim Wechsel auf eine andere Ebene (erneutes Öffnen desselben
|
||||||
|
// Fensters aus dem Kontextmenü) — nur der Titel/URL-Code als Hinweis;
|
||||||
|
// der eigentliche State kommt weiterhin per Sync vom Hauptfenster.
|
||||||
|
unlisten.push(await listen<string>("layer-settings-set-code", () => {}));
|
||||||
|
await emitTo("main", "layer-settings-window-ready").catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const patch = (p: Partial<{ name: string; color: string; lw: number; hatch: string | undefined }>) => {
|
||||||
|
if (!state) return;
|
||||||
|
void import("@tauri-apps/api/event").then(({ emitTo }) =>
|
||||||
|
emitTo("main", "layer-settings-command", {
|
||||||
|
name: "onPatchCategory" as keyof LayerSettingsCommandHandlers,
|
||||||
|
args: [state.code, p],
|
||||||
|
}).catch(() => {}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const code = state?.code ?? initialCodeFromUrl();
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
color: "var(--ink-dim, #8a8a8a)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("resources.loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||||
|
<header
|
||||||
|
className="imp-head"
|
||||||
|
data-tauri-drag-region
|
||||||
|
style={{ paddingLeft: 88 }}
|
||||||
|
>
|
||||||
|
<span className="imp-head-title">
|
||||||
|
{t("editor.layerTitle", { code })}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div className="imp-body" style={{ overflowY: "auto" }}>
|
||||||
|
<section className="imp-section">
|
||||||
|
<EditorField label={t("editor.name")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={state.name}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => patch({ name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
<EditorField label={t("editor.color")}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={state.color}
|
||||||
|
onChange={(e) => patch({ color: e.target.value })}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
<EditorField label={t("editor.lineWeight")}>
|
||||||
|
<span className="editor-num">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
min={0.01}
|
||||||
|
value={state.lw}
|
||||||
|
onChange={(e) => patch({ lw: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<span className="editor-unit">{t("editor.unit.mm")}</span>
|
||||||
|
</span>
|
||||||
|
</EditorField>
|
||||||
|
<EditorField label={t("editor.hatch")}>
|
||||||
|
<span className="editor-hatch">
|
||||||
|
<select
|
||||||
|
value={state.hatchId ?? ""}
|
||||||
|
onChange={(e) => patch({ hatch: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">{t("resources.viewHatch.none")}</option>
|
||||||
|
{state.hatches.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</EditorField>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Eigenständiger React-Root für das native Ressourcen-Fenster (siehe
|
||||||
|
// resourceWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
|
||||||
|
// main.tsx gerendert, wenn die URL `?window=resources` trägt — KEIN Teil des
|
||||||
|
// normalen App-Baums (kein Zugriff auf den Haupt-Store, eigener Prozess).
|
||||||
|
//
|
||||||
|
// Projekt-Zustand kommt ausschliesslich per Tauri-Event vom Hauptfenster
|
||||||
|
// („resource-sync-project"); Bearbeitungen laufen NICHT lokal, sondern werden
|
||||||
|
// als „resource-command"-Events ans Hauptfenster geschickt, das die echten
|
||||||
|
// Handler aufruft und die Änderung über den nächsten Sync-Push zurückspiegelt
|
||||||
|
// — dasselbe Prinzip wie die native 2D/3D-Fensterspiegelung (nativeSync.ts),
|
||||||
|
// nur bidirektional statt nur lesend.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ResourceManager } from "../ui/ResourceManager";
|
||||||
|
import type { ResourceManagerHandlers, TabId } from "../ui/ResourceManager";
|
||||||
|
import type { Project } from "../model/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
/** Alle Felder von ResourceManagerHandlers sind Funktionen — für den Proxy unten. */
|
||||||
|
type HandlerName = keyof ResourceManagerHandlers;
|
||||||
|
|
||||||
|
/** Liest `?tab=…` aus der Fenster-URL (initialer Tab beim Öffnen aus einem Kontext). */
|
||||||
|
function initialTabFromUrl(): TabId | undefined {
|
||||||
|
const v = new URLSearchParams(window.location.search).get("tab");
|
||||||
|
return (v as TabId) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenständige Seite des Ressourcen-Fensters. Zeigt einen Lade-Hinweis, bis
|
||||||
|
* der erste Projekt-Sync vom Hauptfenster eintrifft (i. d. R. Millisekunden).
|
||||||
|
*/
|
||||||
|
export function ResourceWindowApp() {
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [tab, setTab] = useState<TabId | undefined>(() => initialTabFromUrl());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<Project>("resource-sync-project", (e) => setProject(e.payload)),
|
||||||
|
);
|
||||||
|
unlisten.push(await listen<TabId>("resource-set-tab", (e) => setTab(e.payload)));
|
||||||
|
// Hauptfenster um den aktuellen Stand bitten — deckt den Fall ab, dass
|
||||||
|
// dieses Fenster NACH dem letzten Projekt-Push gestartet ist.
|
||||||
|
await emitTo("main", "resource-window-ready").catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Jeder Handler-Aufruf wird zu EINEM „resource-command"-Event ans
|
||||||
|
// Hauptfenster (Name + Argumente, beides JSON-serialisierbar — alle
|
||||||
|
// ResourceManagerHandlers-Signaturen sind reine Datenpatches). Per Proxy
|
||||||
|
// statt 27 einzeln ausgeschriebenen Weiterleitungen (Drift-Gefahr).
|
||||||
|
const handlers = useMemo<ResourceManagerHandlers>(
|
||||||
|
() =>
|
||||||
|
new Proxy({} as ResourceManagerHandlers, {
|
||||||
|
get(_target, prop: string) {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
void import("@tauri-apps/api/event").then(({ emitTo }) =>
|
||||||
|
emitTo("main", "resource-command", { name: prop as HandlerName, args }).catch(
|
||||||
|
() => {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
|
||||||
|
getCurrentWindow().close(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
color: "var(--ink-dim, #8a8a8a)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("resources.loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceManager
|
||||||
|
project={project}
|
||||||
|
handlers={handlers}
|
||||||
|
initialTab={tab}
|
||||||
|
onClose={onClose}
|
||||||
|
windowed
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Eigenständiger React-Root für das native Einstellungs-Fenster (siehe
|
||||||
|
// settingsWindow.ts für die Hauptfenster-Seite der Sync-Brücke). Von
|
||||||
|
// main.tsx gerendert, wenn die URL `?window=settings` trägt. Gleiches Prinzip
|
||||||
|
// wie ResourceWindowApp.tsx, nur mit dem schmaleren Settings-Zustand statt
|
||||||
|
// des ganzen Projekts.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { SettingsDialog } from "../ui/SettingsDialog";
|
||||||
|
import type { SettingsCommandHandlers, SettingsSyncState } from "./settingsWindow";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { applyThemeMode } from "../theme/themeMode";
|
||||||
|
|
||||||
|
type HandlerName = keyof SettingsCommandHandlers;
|
||||||
|
|
||||||
|
export function SettingsWindowApp() {
|
||||||
|
const [state, setState] = useState<SettingsSyncState | null>(null);
|
||||||
|
|
||||||
|
// Dieses Fenster ist ein eigenes Dokument — Hell/Dunkel muss hier separat
|
||||||
|
// angewendet werden, sobald der synchronisierte Zustand eintrifft.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state) applyThemeMode(state.themeMode);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<SettingsSyncState>("settings-sync-state", (e) => setState(e.payload)),
|
||||||
|
);
|
||||||
|
// Hauptfenster um den aktuellen Stand bitten (Race-Schutz: dieses
|
||||||
|
// Fenster könnte nach dem letzten Sync-Push gestartet sein).
|
||||||
|
await emitTo("main", "settings-window-ready").catch(() => {});
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Jeder Handler-Aufruf → EIN „settings-command"-Event ans Hauptfenster
|
||||||
|
// (Name + Argumente), analog ResourceWindowApp.tsx.
|
||||||
|
const handlers = useMemo<SettingsCommandHandlers>(
|
||||||
|
() =>
|
||||||
|
new Proxy({} as SettingsCommandHandlers, {
|
||||||
|
get(_target, prop: string) {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
void import("@tauri-apps/api/event").then(({ emitTo }) =>
|
||||||
|
emitTo("main", "settings-command", { name: prop as HandlerName, args }).catch(
|
||||||
|
() => {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
void import("@tauri-apps/api/window").then(({ getCurrentWindow }) =>
|
||||||
|
getCurrentWindow().close(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
color: "var(--ink-dim, #8a8a8a)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("resources.loading")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsDialog
|
||||||
|
themeMode={state.themeMode}
|
||||||
|
onThemeModeChange={handlers.onThemeModeChange}
|
||||||
|
marqueeColor={state.marqueeColor}
|
||||||
|
onMarqueeColorChange={handlers.onMarqueeColorChange}
|
||||||
|
snapColor={state.snapColor}
|
||||||
|
onSnapColorChange={handlers.onSnapColorChange}
|
||||||
|
referenceElevationMasl={state.referenceElevationMasl}
|
||||||
|
onReferenceElevationChange={handlers.onReferenceElevationChange}
|
||||||
|
rendererMode={state.rendererMode}
|
||||||
|
webGpuAvailable={state.webGpuAvailable}
|
||||||
|
onRendererModeChange={handlers.onRendererModeChange}
|
||||||
|
layout={state.layout}
|
||||||
|
onApplyLayout={handlers.onApplyLayout}
|
||||||
|
onClose={onClose}
|
||||||
|
windowed
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
// Native macOS-Systemmenüleiste (oben am Bildschirm) — NICHT zu verwechseln mit
|
||||||
|
// AppMenuBar (die In-App-Menüleiste in der TopBar). Nutzt die Tauri-v2-Menu-API
|
||||||
|
// (@tauri-apps/api/menu), die einen echten macOS-App-Menü-Baum baut. No-op im
|
||||||
|
// reinen Browser (kein Tauri) — s. isTauriRuntime.
|
||||||
|
//
|
||||||
|
// Aufbau: EIN Menü-Baum wird beim Mount EINMAL erzeugt (MenuItem.new ist async
|
||||||
|
// und teuer genug, um es nicht bei jedem Render neu zu bauen); alle Aktionen
|
||||||
|
// laufen über eine Ref auf die jeweils aktuellen Handler (klassisches Ref-
|
||||||
|
// Pattern gegen veraltete Closures, wie `toolShortcutRef` in App.tsx). Enabled-
|
||||||
|
// /Checked-Zustand (Undo/Redo/Löschen/Ansicht) wird in einem separaten Effekt
|
||||||
|
// synchron zum React-State über `setEnabled`/`setChecked` nachgezogen.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { APP_VERSION } from "../ui/TopBar";
|
||||||
|
import type { View3d, ViewType } from "../ui/TopBar";
|
||||||
|
|
||||||
|
/** Alle Aktionen, die die native Menüleiste auslösen kann. */
|
||||||
|
export interface NativeAppMenuHandlers {
|
||||||
|
onOpenProject: () => void;
|
||||||
|
onSaveProject: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
onExportPdf: () => void;
|
||||||
|
onExportDxf: () => void;
|
||||||
|
onExportSchedule: () => void;
|
||||||
|
onExportIfc: () => void;
|
||||||
|
onExportObj: () => void;
|
||||||
|
onExportStl: () => void;
|
||||||
|
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
onDeleteSelection: () => void;
|
||||||
|
onMove: () => void;
|
||||||
|
onMirror: () => void;
|
||||||
|
onRotate: () => void;
|
||||||
|
|
||||||
|
onViewType: (v: ViewType) => void;
|
||||||
|
onView3d: (v: View3d) => void;
|
||||||
|
onReferenceLines: (v: boolean) => void;
|
||||||
|
|
||||||
|
onToggleResources: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
/** Macht das Panel mit dieser ID sichtbar (siehe `revealPanel` in panels/layout.ts). */
|
||||||
|
onOpenPanel: (panelId: string) => void;
|
||||||
|
/** Öffnet den Zeichnungsebenen-Manager als eigenes Fenster (zusätzlich zum Dock-Panel). */
|
||||||
|
onOpenDrawingLevelsWindow: () => void;
|
||||||
|
/** Öffnet die Ebeneneinstellungen für die aktuell aktive Ebene (activeCategoryCode). */
|
||||||
|
onOpenLayerSettingsWindow: () => void;
|
||||||
|
/** Öffnet den Standort-Import (swisstopo/OSM). */
|
||||||
|
onOpenContextImportWindow: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Eintrag für „Fenster > Panels" — id + fertiges i18n-Label. */
|
||||||
|
export interface NativeAppMenuPanel {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ortsabhängiger Zustand, der Enabled-/Checked-Häkchen der Menüpunkte treibt. */
|
||||||
|
export interface NativeAppMenuState {
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
hasSelection: boolean;
|
||||||
|
viewType: ViewType;
|
||||||
|
view3d: View3d;
|
||||||
|
referenceLines: boolean;
|
||||||
|
resourcesOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW3D_IDS: View3d[] = [
|
||||||
|
"front",
|
||||||
|
"back",
|
||||||
|
"side",
|
||||||
|
"left",
|
||||||
|
"top",
|
||||||
|
"iso",
|
||||||
|
"perspective",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die native macOS-Menüleiste einmal auf und hält Enabled-/Checked-
|
||||||
|
* Zustand synchron zum übergebenen `state`. `handlers` darf sich bei jedem
|
||||||
|
* Render ändern (wird über eine Ref gelesen, kein Neu-Erzeugen der Menüpunkte).
|
||||||
|
*/
|
||||||
|
export function useNativeAppMenu(
|
||||||
|
handlers: NativeAppMenuHandlers,
|
||||||
|
state: NativeAppMenuState,
|
||||||
|
panels: NativeAppMenuPanel[],
|
||||||
|
): void {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
// Panel-Liste ist beim Mount bekannt (Registry ist statisch) — als Ref nur,
|
||||||
|
// damit der Aufbau-Effekt (deps: []) sie ohne Lint-Warnung lesen darf.
|
||||||
|
const panelsRef = useRef(panels);
|
||||||
|
panelsRef.current = panels;
|
||||||
|
|
||||||
|
// Menüpunkte, die Enabled-/Checked-Zustand nachgezogen bekommen — als Ref,
|
||||||
|
// damit der Sync-Effekt unten sie ohne Neu-Aufbau erreicht.
|
||||||
|
const itemsRef = useRef<{
|
||||||
|
undo?: import("@tauri-apps/api/menu").MenuItem;
|
||||||
|
redo?: import("@tauri-apps/api/menu").MenuItem;
|
||||||
|
delete?: import("@tauri-apps/api/menu").MenuItem;
|
||||||
|
floorplan?: import("@tauri-apps/api/menu").CheckMenuItem;
|
||||||
|
perspective?: import("@tauri-apps/api/menu").CheckMenuItem;
|
||||||
|
view3d: Map<View3d, import("@tauri-apps/api/menu").CheckMenuItem>;
|
||||||
|
referenceLines?: import("@tauri-apps/api/menu").CheckMenuItem;
|
||||||
|
resources?: import("@tauri-apps/api/menu").CheckMenuItem;
|
||||||
|
}>({ view3d: new Map() });
|
||||||
|
|
||||||
|
// Baut den Menü-Baum EINMAL. Aktionen rufen ausschliesslich über
|
||||||
|
// `handlersRef.current` — so bleibt der Baum stabil, während die
|
||||||
|
// eigentlichen Callbacks (aus App.tsx, jeden Render neu) trotzdem aktuell
|
||||||
|
// bleiben.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let disposed = false;
|
||||||
|
void (async () => {
|
||||||
|
const {
|
||||||
|
Menu,
|
||||||
|
Submenu,
|
||||||
|
MenuItem,
|
||||||
|
CheckMenuItem,
|
||||||
|
PredefinedMenuItem,
|
||||||
|
} = await import("@tauri-apps/api/menu");
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
|
const fileMenu = await Submenu.new({
|
||||||
|
text: t("menu.native.file"),
|
||||||
|
items: [
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.open"),
|
||||||
|
accelerator: "CmdOrCtrl+O",
|
||||||
|
action: () => handlersRef.current.onOpenProject(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.saveAs"),
|
||||||
|
accelerator: "CmdOrCtrl+S",
|
||||||
|
action: () => handlersRef.current.onSaveProject(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.import"),
|
||||||
|
action: () => handlersRef.current.onImport(),
|
||||||
|
}),
|
||||||
|
await Submenu.new({
|
||||||
|
text: t("menu.native.export"),
|
||||||
|
items: [
|
||||||
|
await MenuItem.new({
|
||||||
|
text: "PDF",
|
||||||
|
action: () => handlersRef.current.onExportPdf(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: "DXF",
|
||||||
|
action: () => handlersRef.current.onExportDxf(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.exportSchedule"),
|
||||||
|
action: () => handlersRef.current.onExportSchedule(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: "IFC",
|
||||||
|
action: () => handlersRef.current.onExportIfc(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: "OBJ",
|
||||||
|
action: () => handlersRef.current.onExportObj(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: "STL",
|
||||||
|
action: () => handlersRef.current.onExportStl(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoItem = await MenuItem.new({
|
||||||
|
text: t("menu.edit.undo"),
|
||||||
|
accelerator: "CmdOrCtrl+Z",
|
||||||
|
action: () => handlersRef.current.onUndo(),
|
||||||
|
});
|
||||||
|
const redoItem = await MenuItem.new({
|
||||||
|
text: t("menu.edit.redo"),
|
||||||
|
accelerator: "CmdOrCtrl+Shift+Z",
|
||||||
|
action: () => handlersRef.current.onRedo(),
|
||||||
|
});
|
||||||
|
const deleteItem = await MenuItem.new({
|
||||||
|
text: t("menu.edit.delete"),
|
||||||
|
action: () => handlersRef.current.onDeleteSelection(),
|
||||||
|
});
|
||||||
|
itemsRef.current.undo = undoItem;
|
||||||
|
itemsRef.current.redo = redoItem;
|
||||||
|
itemsRef.current.delete = deleteItem;
|
||||||
|
|
||||||
|
const editMenu = await Submenu.new({
|
||||||
|
text: t("menu.native.edit"),
|
||||||
|
items: [
|
||||||
|
undoItem,
|
||||||
|
redoItem,
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
deleteItem,
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.move"),
|
||||||
|
action: () => handlersRef.current.onMove(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.mirror"),
|
||||||
|
action: () => handlersRef.current.onMirror(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.native.rotate"),
|
||||||
|
action: () => handlersRef.current.onRotate(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const floorplanItem = await CheckMenuItem.new({
|
||||||
|
text: t("menu.view.floorplan"),
|
||||||
|
action: () => handlersRef.current.onViewType("grundriss"),
|
||||||
|
});
|
||||||
|
const perspectiveItem = await CheckMenuItem.new({
|
||||||
|
text: t("menu.view.perspective"),
|
||||||
|
action: () => handlersRef.current.onViewType("perspektive"),
|
||||||
|
});
|
||||||
|
itemsRef.current.floorplan = floorplanItem;
|
||||||
|
itemsRef.current.perspective = perspectiveItem;
|
||||||
|
|
||||||
|
const view3dLabels: Record<View3d, string> = {
|
||||||
|
front: t("view3d.front"),
|
||||||
|
back: t("view3d.back"),
|
||||||
|
side: t("view3d.side"),
|
||||||
|
left: t("view3d.left"),
|
||||||
|
top: t("view3d.top"),
|
||||||
|
iso: t("view3d.iso"),
|
||||||
|
isoFrontLeft: t("view3d.iso"),
|
||||||
|
isoBackRight: t("view3d.iso"),
|
||||||
|
isoBackLeft: t("view3d.iso"),
|
||||||
|
perspective: t("view3d.perspective"),
|
||||||
|
};
|
||||||
|
const view3dItems: import("@tauri-apps/api/menu").CheckMenuItem[] = [];
|
||||||
|
for (const id of VIEW3D_IDS) {
|
||||||
|
const item = await CheckMenuItem.new({
|
||||||
|
text: view3dLabels[id],
|
||||||
|
action: () => handlersRef.current.onView3d(id),
|
||||||
|
});
|
||||||
|
itemsRef.current.view3d.set(id, item);
|
||||||
|
view3dItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceLinesItem = await CheckMenuItem.new({
|
||||||
|
text: t("menu.view.referenceLines"),
|
||||||
|
action: () => handlersRef.current.onReferenceLines(!state.referenceLines),
|
||||||
|
});
|
||||||
|
itemsRef.current.referenceLines = referenceLinesItem;
|
||||||
|
|
||||||
|
const viewMenu = await Submenu.new({
|
||||||
|
text: t("menu.native.view"),
|
||||||
|
items: [
|
||||||
|
floorplanItem,
|
||||||
|
perspectiveItem,
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
...view3dItems,
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
referenceLinesItem,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourcesItem = await CheckMenuItem.new({
|
||||||
|
text: t("menu.window.resources"),
|
||||||
|
action: () => handlersRef.current.onToggleResources(),
|
||||||
|
});
|
||||||
|
itemsRef.current.resources = resourcesItem;
|
||||||
|
|
||||||
|
// Ein Menüpunkt je registriertem Panel (ToolsPanel/Attribute/Objekt-Info/
|
||||||
|
// Zeichnungsebenen/Ebenen/Umgebung/Flächenbilanz/Element-Baum/Ausschnitte/
|
||||||
|
// Layouts) — macht das jeweilige Panel sichtbar (`revealPanel`), egal ob es
|
||||||
|
// gerade angedockt, schwebend oder gar nicht offen ist.
|
||||||
|
const panelItems = await Promise.all(
|
||||||
|
panelsRef.current.map((p) =>
|
||||||
|
MenuItem.new({
|
||||||
|
text: p.label,
|
||||||
|
action: () => handlersRef.current.onOpenPanel(p.id),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const panelsMenu = await Submenu.new({
|
||||||
|
text: t("menu.native.panels"),
|
||||||
|
items: panelItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
const windowMenu = await Submenu.new({
|
||||||
|
text: t("menu.native.window"),
|
||||||
|
items: [
|
||||||
|
resourcesItem,
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
panelsMenu,
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.window.drawingLevels"),
|
||||||
|
action: () => handlersRef.current.onOpenDrawingLevelsWindow(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.window.layerSettings"),
|
||||||
|
action: () => handlersRef.current.onOpenLayerSettingsWindow(),
|
||||||
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.window.contextImport"),
|
||||||
|
action: () => handlersRef.current.onOpenContextImportWindow(),
|
||||||
|
}),
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
await MenuItem.new({
|
||||||
|
text: t("menu.window.settings"),
|
||||||
|
action: () => handlersRef.current.onOpenSettings(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// macOS: der allererste Eintrag ist das App-Menü (Über/Beenden). Ohne ihn
|
||||||
|
// rutscht „Datei" an die Stelle, an der macOS sonst den App-Namen zeigt.
|
||||||
|
// „Über Dossier" nutzt das NATIVE macOS-Info-Fenster (PredefinedMenuItem
|
||||||
|
// „About") statt des eigenen In-App-Dialogs (AboutDialog in TopBar.tsx) —
|
||||||
|
// an dieser Stelle erwartet macOS exakt diesen Eintrag, kein Custom-Dialog.
|
||||||
|
const appMenu = await Submenu.new({
|
||||||
|
text: "Dossier",
|
||||||
|
items: [
|
||||||
|
await PredefinedMenuItem.new({
|
||||||
|
item: { About: { name: "Dossier", version: APP_VERSION } },
|
||||||
|
text: t("menu.native.about"),
|
||||||
|
}),
|
||||||
|
await PredefinedMenuItem.new({ item: "Separator" }),
|
||||||
|
await PredefinedMenuItem.new({ item: "Quit", text: t("menu.native.quit") }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
const menu = await Menu.new({
|
||||||
|
items: [appMenu, fileMenu, editMenu, viewMenu, windowMenu],
|
||||||
|
});
|
||||||
|
await menu.setAsAppMenu();
|
||||||
|
})().catch((e) => {
|
||||||
|
console.debug("useNativeAppMenu: Aufbau fehlgeschlagen (kein Tauri-Menu?)", e);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
// Nur beim Mount aufbauen — Aktionen laufen über handlersRef, State-
|
||||||
|
// Synchronisation über den Effekt unten. Ein Neu-Aufbau bei jedem Render
|
||||||
|
// wäre teuer (mehrere async IPC-Aufrufe) und unnötig.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Enabled-/Checked-Zustand synchron zum React-State halten (billig, kein
|
||||||
|
// Neu-Aufbau der Menüpunkte).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
const items = itemsRef.current;
|
||||||
|
void items.undo?.setEnabled(state.canUndo);
|
||||||
|
void items.redo?.setEnabled(state.canRedo);
|
||||||
|
void items.delete?.setEnabled(state.hasSelection);
|
||||||
|
void items.floorplan?.setChecked(state.viewType === "grundriss");
|
||||||
|
void items.perspective?.setChecked(state.viewType === "perspektive");
|
||||||
|
for (const [id, item] of items.view3d) {
|
||||||
|
void item.setChecked(state.view3d === id);
|
||||||
|
void item.setEnabled(state.viewType === "perspektive");
|
||||||
|
}
|
||||||
|
void items.referenceLines?.setChecked(state.referenceLines);
|
||||||
|
void items.resources?.setChecked(state.resourcesOpen);
|
||||||
|
}, [
|
||||||
|
state.canUndo,
|
||||||
|
state.canRedo,
|
||||||
|
state.hasSelection,
|
||||||
|
state.viewType,
|
||||||
|
state.view3d,
|
||||||
|
state.referenceLines,
|
||||||
|
state.resourcesOpen,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Standort-Import ("Standort importieren…", geo.admin/swisstopo/OSM) als
|
||||||
|
// EIGENES natives Tauri-Fenster statt Overlay-Dialog im Hauptfenster.
|
||||||
|
//
|
||||||
|
// Anders als Ressourcen/Einstellungen/Ebenen braucht dieser Dialog fast
|
||||||
|
// keinen geteilten Zustand: der gesamte Netz-Abruf (Geocoding, swissBUILDINGS3D/
|
||||||
|
// ALTI3D/SWISSIMAGE, OSM) läuft komplett INNERHALB von ContextImportDialog
|
||||||
|
// selbst (reine `io/*`-Funktionen, kein Store-Zugriff) — ein eigenes Fenster
|
||||||
|
// ist hier sogar naheliegender als beim Rest: ein langer Netz-Abruf blockiert
|
||||||
|
// so nicht einmal gedanklich das Hauptfenster. Nur das ENDERGEBNIS
|
||||||
|
// (fertige Kontext-Objekte + optionaler Georeferenzierungs-Bezug) muss zurück.
|
||||||
|
//
|
||||||
|
// Hauptfenster → Fenster: „context-import-sync-state" mit `{ geoAnchor }`.
|
||||||
|
// Fenster → Hauptfenster: „context-import-command" mit `{name,args}` — wie
|
||||||
|
// die übrigen Brücken (resourceWindow.ts etc.).
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import type { GeoAnchor } from "../ui/ContextImportDialog";
|
||||||
|
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
|
||||||
|
|
||||||
|
const CONTEXT_IMPORT_WINDOW_LABEL = "context-import";
|
||||||
|
|
||||||
|
export interface ContextImportSyncState {
|
||||||
|
geoAnchor: GeoAnchor | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextImportCommandHandlers {
|
||||||
|
onImport: (objs: unknown[]) => void;
|
||||||
|
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet das native Standort-Import-Fenster (bzw. holt es nach vorne). */
|
||||||
|
export async function openContextImportWindowNative(): Promise<void> {
|
||||||
|
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||||
|
const existing = await WebviewWindow.getByLabel(CONTEXT_IMPORT_WINDOW_LABEL);
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new WebviewWindow(CONTEXT_IMPORT_WINDOW_LABEL, {
|
||||||
|
url: "index.html?window=context-import",
|
||||||
|
title: "Standort importieren — Dossier",
|
||||||
|
width: 480,
|
||||||
|
height: 680,
|
||||||
|
minWidth: 400,
|
||||||
|
minHeight: 480,
|
||||||
|
...BORDERLESS_WINDOW_CHROME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hauptfenster-Seite der Sync-Brücke. */
|
||||||
|
export function useContextImportWindowHost(
|
||||||
|
geoAnchor: GeoAnchor | undefined,
|
||||||
|
handlers: ContextImportCommandHandlers,
|
||||||
|
): void {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
const geoAnchorRef = useRef(geoAnchor);
|
||||||
|
geoAnchorRef.current = geoAnchor;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<{ name: string; args: unknown[] }>("context-import-command", (e) => {
|
||||||
|
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
|
||||||
|
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unlisten.push(
|
||||||
|
await listen("context-import-window-ready", () => {
|
||||||
|
void emitTo(CONTEXT_IMPORT_WINDOW_LABEL, "context-import-sync-state", {
|
||||||
|
geoAnchor: geoAnchorRef.current,
|
||||||
|
}).catch(() => {});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
void (async () => {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(CONTEXT_IMPORT_WINDOW_LABEL, "context-import-sync-state", {
|
||||||
|
geoAnchor,
|
||||||
|
}).catch(() => {});
|
||||||
|
})();
|
||||||
|
}, [geoAnchor]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Zeichnungsebenen-Manager als EIGENES natives Tauri-Fenster — Referenz:
|
||||||
|
// DOSSIER-Rhino-Plugin (`git.kgva.ch/karim/DOSSIER`, `src/DrawingLevelsApp.jsx`
|
||||||
|
// + `rhino/layers_panel.py`), dort ein eigenständiges Rhino-Panel statt
|
||||||
|
// angedockt. Hier: dieselbe Liste wie das bestehende Dock-Panel
|
||||||
|
// (DrawingLevelsPanel.tsx), zusätzlich als Fenster öffenbar — das Dock-Panel
|
||||||
|
// bleibt bestehen (bewusst additiv, nicht ersetzt, anders als beim
|
||||||
|
// Ressourcen-Manager, der nie ein Dock-Panel war).
|
||||||
|
//
|
||||||
|
// Gleiches Sync-/Command-Prinzip wie settingsWindow.ts: ein expliziter, enger
|
||||||
|
// Zustand (nicht das ganze Project) + eine {name,args}-Command-RPC.
|
||||||
|
// **Bewusst NICHT unterstützt** (Scope-Entscheidung, s. PENDENZEN.md): das
|
||||||
|
// Rechtsklick-Kontextmenü einer Zeile (Duplizieren/Löschen/Einstellungen…) —
|
||||||
|
// das lebt heute als App-weites Overlay im HAUPTFENSTER; ein Klick im
|
||||||
|
// Zeichnungsebenen-FENSTER kann es dort nicht sinnvoll positionieren. Bis das
|
||||||
|
// eigens gelöst ist, bleibt Rechtsklick im Fenster ein No-op (Auswahl/
|
||||||
|
// Sichtbarkeit/Hinzufügen funktionieren voll).
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import type { DrawingLevel } from "../model/types";
|
||||||
|
import type { DisplayMode } from "../panels/types";
|
||||||
|
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
|
||||||
|
|
||||||
|
const DRAWING_LEVELS_WINDOW_LABEL = "drawing-levels";
|
||||||
|
|
||||||
|
export interface DrawingLevelsSyncState {
|
||||||
|
drawingLevels: DrawingLevel[];
|
||||||
|
activeLevelId: string;
|
||||||
|
displayMode: DisplayMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawingLevelsCommandHandlers {
|
||||||
|
onSelectLevel: (id: string) => void;
|
||||||
|
onToggleLevel: (id: string) => void;
|
||||||
|
onAddFloor: () => void;
|
||||||
|
onAddSection: () => void;
|
||||||
|
onAddElevation: () => void;
|
||||||
|
onAddDrawing: () => void;
|
||||||
|
onSetDisplayMode: (mode: DisplayMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet das native Zeichnungsebenen-Fenster (bzw. holt es nach vorne). */
|
||||||
|
export async function openDrawingLevelsWindowNative(): Promise<void> {
|
||||||
|
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||||
|
const existing = await WebviewWindow.getByLabel(DRAWING_LEVELS_WINDOW_LABEL);
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new WebviewWindow(DRAWING_LEVELS_WINDOW_LABEL, {
|
||||||
|
url: "index.html?window=drawing-levels",
|
||||||
|
title: "Zeichnungsebenen — Dossier",
|
||||||
|
width: 340,
|
||||||
|
height: 560,
|
||||||
|
minWidth: 260,
|
||||||
|
minHeight: 300,
|
||||||
|
...BORDERLESS_WINDOW_CHROME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hauptfenster-Seite der Sync-Brücke — Pendant zu useSettingsWindowHost. */
|
||||||
|
export function useDrawingLevelsWindowHost(
|
||||||
|
state: DrawingLevelsSyncState,
|
||||||
|
handlers: DrawingLevelsCommandHandlers,
|
||||||
|
): void {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<{ name: string; args: unknown[] }>("drawing-levels-command", (e) => {
|
||||||
|
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
|
||||||
|
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unlisten.push(
|
||||||
|
await listen("drawing-levels-window-ready", () => {
|
||||||
|
void emitTo(
|
||||||
|
DRAWING_LEVELS_WINDOW_LABEL,
|
||||||
|
"drawing-levels-sync-state",
|
||||||
|
stateRef.current,
|
||||||
|
).catch(() => {});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
void (async () => {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(DRAWING_LEVELS_WINDOW_LABEL, "drawing-levels-sync-state", state).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
}, [state]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Ebeneneinstellungen als EIGENES natives Tauri-Fenster — Referenz: DOSSIER-
|
||||||
|
// Rhino-Plugin (`git.kgva.ch/karim/DOSSIER`, `rhino/layers_panel.py`
|
||||||
|
// `_open_ebenen_settings`), das dort exakt so als „Satelliten-Fenster"
|
||||||
|
// (Eto-Form, kein Docked-Panel) läuft. Ersetzt unter Tauri den bisherigen
|
||||||
|
// `InlineEditor`-Popover (App.tsx, `editor?.kind === "layer"`); im reinen
|
||||||
|
// Browser bleibt der Popover (kein OS-Fenster-Konzept dort).
|
||||||
|
//
|
||||||
|
// **Bewusst NUR die heute schon im Modell vorhandenen Felder** (Name/Farbe/
|
||||||
|
// Linienstärke/Schraffur-Auswahl) — die Rhino-Version kennt zusätzlich eine
|
||||||
|
// PRO-EBENE Schraffur-Skalierung/-Rotation/-Stiftstärke (`section.hatchScale`
|
||||||
|
// etc.), die es in `LayerCategory` (model/types.ts) schlicht nicht gibt
|
||||||
|
// (dort nur `hatch?: string`, eine Referenz auf die geteilte Hatch-Resource
|
||||||
|
// ohne Per-Kategorie-Override). Das nachzurüsten wäre eine eigene
|
||||||
|
// Modell-Erweiterung + generatePlan.ts-Verdrahtung, keine reine Fenster-
|
||||||
|
// Frage — bewusst nicht mitgemacht, um hier nichts zu erfinden, das der Rest
|
||||||
|
// der App nicht einlöst.
|
||||||
|
//
|
||||||
|
// Nur EIN Fenster gleichzeitig (Label fix "layer-settings", wie Rhinos
|
||||||
|
// Satellit): erneutes Öffnen für eine andere Ebene schiebt nur den neuen Code
|
||||||
|
// nach (kein Fenster-Zoo), analog dem Dropdown-Wechsel im Original.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import { findCategory } from "../state/projectSlice";
|
||||||
|
import type { HatchStyle, LayerCategory, Project } from "../model/types";
|
||||||
|
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
|
||||||
|
|
||||||
|
const LAYER_SETTINGS_WINDOW_LABEL = "layer-settings";
|
||||||
|
|
||||||
|
export interface LayerSettingsSyncState {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
lw: number;
|
||||||
|
hatchId: string | undefined;
|
||||||
|
hatches: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerSettingsCommandHandlers {
|
||||||
|
onPatchCategory: (code: string, patch: Partial<LayerCategory>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet das native Ebeneneinstellungs-Fenster für `code` (bzw. schiebt den Code nach, falls schon offen). */
|
||||||
|
export async function openLayerSettingsWindowNative(code: string): Promise<void> {
|
||||||
|
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||||
|
const existing = await WebviewWindow.getByLabel(LAYER_SETTINGS_WINDOW_LABEL);
|
||||||
|
if (existing) {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(LAYER_SETTINGS_WINDOW_LABEL, "layer-settings-set-code", code).catch(() => {});
|
||||||
|
await existing.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new WebviewWindow(LAYER_SETTINGS_WINDOW_LABEL, {
|
||||||
|
url: `index.html?window=layer-settings&code=${encodeURIComponent(code)}`,
|
||||||
|
title: "Ebeneneinstellungen — Dossier",
|
||||||
|
width: 360,
|
||||||
|
height: 420,
|
||||||
|
minWidth: 300,
|
||||||
|
minHeight: 340,
|
||||||
|
...BORDERLESS_WINDOW_CHROME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut den Sync-Zustand für eine Kategorie (bzw. `null`, wenn der Code nicht mehr existiert — z. B. gelöscht). */
|
||||||
|
function stateFor(project: Project, code: string): LayerSettingsSyncState | null {
|
||||||
|
const cat = findCategory(project.layers, code);
|
||||||
|
if (!cat) return null;
|
||||||
|
return {
|
||||||
|
code: cat.code,
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
lw: cat.lw,
|
||||||
|
hatchId: cat.hatch,
|
||||||
|
hatches: project.hatches.map((h: HatchStyle) => ({ id: h.id, name: h.name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hauptfenster-Seite der Sync-Brücke. `code` = aktuell im Fenster editierte Ebene (folgt `openLayerSettingsWindowNative`-Aufrufen). */
|
||||||
|
export function useLayerSettingsWindowHost(
|
||||||
|
project: Project,
|
||||||
|
code: string | null,
|
||||||
|
onPatchCategory: (code: string, patch: Partial<LayerCategory>) => void,
|
||||||
|
): void {
|
||||||
|
const stateRef = useRef<LayerSettingsSyncState | null>(code ? stateFor(project, code) : null);
|
||||||
|
stateRef.current = code ? stateFor(project, code) : null;
|
||||||
|
const onPatchRef = useRef(onPatchCategory);
|
||||||
|
onPatchRef.current = onPatchCategory;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<{ name: string; args: unknown[] }>("layer-settings-command", (e) => {
|
||||||
|
if (e.payload.name === "onPatchCategory") {
|
||||||
|
const [c, patch] = e.payload.args as [string, Partial<LayerCategory>];
|
||||||
|
onPatchRef.current(c, patch);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unlisten.push(
|
||||||
|
await listen("layer-settings-window-ready", () => {
|
||||||
|
void emitTo(
|
||||||
|
LAYER_SETTINGS_WINDOW_LABEL,
|
||||||
|
"layer-settings-sync-state",
|
||||||
|
stateRef.current,
|
||||||
|
).catch(() => {});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
void (async () => {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(
|
||||||
|
LAYER_SETTINGS_WINDOW_LABEL,
|
||||||
|
"layer-settings-sync-state",
|
||||||
|
stateRef.current,
|
||||||
|
).catch(() => {});
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- stateRef.current ist bereits die aktuelle Ableitung
|
||||||
|
}, [project, code]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Ressourcen-Manager als EIGENES natives Tauri-Fenster (statt schwebendes
|
||||||
|
// Overlay im Hauptfenster) — Hauptfenster-Seite der Brücke.
|
||||||
|
//
|
||||||
|
// Ein natives Zweitfenster ist ein separater Webview-Prozesskontext: KEIN
|
||||||
|
// gemeinsamer JS-Speicher, also kein direkter Store-Zugriff. Die Brücke
|
||||||
|
// synchronisiert daher explizit über Tauri-Events (@tauri-apps/api/event):
|
||||||
|
// • Hauptfenster → Ressourcen-Fenster: „resource-sync-project" mit dem
|
||||||
|
// kompletten (JSON-serialisierbaren) Project bei jeder Änderung + einmal
|
||||||
|
// sofort, sobald das Ressourcen-Fenster „resource-window-ready" meldet
|
||||||
|
// (Race-Schutz: das Fenster könnte später starten als der erste Push).
|
||||||
|
// • Ressourcen-Fenster → Hauptfenster: „resource-command" mit
|
||||||
|
// `{ name, args }` — der Name eines ResourceManagerHandlers-Feldes plus
|
||||||
|
// seine Argumente. Das Hauptfenster ruft den ECHTEN Handler auf (der
|
||||||
|
// Projekt-Patches über setProject anwendet); die Antwort kommt implizit
|
||||||
|
// über den nächsten „resource-sync-project"-Push zurück.
|
||||||
|
// Das Gegenstück (Fenster-Seite: Proxy-Handlers + Projekt-State) lebt in
|
||||||
|
// ResourceWindowApp.tsx.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import type { Project } from "../model/types";
|
||||||
|
import type { ResourceManagerHandlers, TabId } from "../ui/ResourceManager";
|
||||||
|
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
|
||||||
|
|
||||||
|
/** Fenster-Label des Ressourcen-Fensters (für emitTo/getByLabel). */
|
||||||
|
const RESOURCE_WINDOW_LABEL = "resources";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet das native Ressourcen-Fenster (bzw. holt es nach vorne, falls schon
|
||||||
|
* offen). No-op-Fallback existiert NICHT hier — Aufrufer prüfen
|
||||||
|
* `isTauriRuntime()` selbst und nutzen sonst das alte Overlay (App.tsx).
|
||||||
|
*/
|
||||||
|
export async function openResourceWindowNative(initialTab?: TabId): Promise<void> {
|
||||||
|
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||||
|
const existing = await WebviewWindow.getByLabel(RESOURCE_WINDOW_LABEL);
|
||||||
|
if (existing) {
|
||||||
|
if (initialTab) {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(RESOURCE_WINDOW_LABEL, "resource-set-tab", initialTab).catch(() => {});
|
||||||
|
}
|
||||||
|
await existing.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url =
|
||||||
|
"index.html?window=resources" + (initialTab ? `&tab=${encodeURIComponent(initialTab)}` : "");
|
||||||
|
new WebviewWindow(RESOURCE_WINDOW_LABEL, {
|
||||||
|
url,
|
||||||
|
title: "Ressourcen — Dossier",
|
||||||
|
width: 780,
|
||||||
|
height: 660,
|
||||||
|
minWidth: 480,
|
||||||
|
minHeight: 360,
|
||||||
|
...BORDERLESS_WINDOW_CHROME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauptfenster-Seite der Sync-Brücke: hält das Ressourcen-Fenster (falls
|
||||||
|
* offen) mit dem aktuellen Projekt synchron und wendet dessen Befehle auf die
|
||||||
|
* ECHTEN Handler an. No-op ausserhalb von Tauri. In App.tsx neben der
|
||||||
|
* bestehenden `<ResourceManager>`-Einbettung aufrufen (dieselben
|
||||||
|
* `resourceHandlers`).
|
||||||
|
*/
|
||||||
|
export function useResourceWindowHost(
|
||||||
|
project: Project,
|
||||||
|
handlers: ResourceManagerHandlers,
|
||||||
|
): void {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
const projectRef = useRef(project);
|
||||||
|
projectRef.current = project;
|
||||||
|
|
||||||
|
// Befehle vom Ressourcen-Fenster entgegennehmen + auf die echten Handler
|
||||||
|
// anwenden, UND bei „ready" (Fenster gerade erst gestartet) sofort das
|
||||||
|
// aktuelle Projekt nachschieben.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<{ name: string; args: unknown[] }>("resource-command", (e) => {
|
||||||
|
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
|
||||||
|
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unlisten.push(
|
||||||
|
await listen("resource-window-ready", () => {
|
||||||
|
void emitTo(RESOURCE_WINDOW_LABEL, "resource-sync-project", projectRef.current).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Bei jeder Projektänderung ans (evtl. offene) Ressourcen-Fenster pushen.
|
||||||
|
// `emitTo` auf ein nicht existierendes Fenster ist harmlos (kein Empfänger).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
void (async () => {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(RESOURCE_WINDOW_LABEL, "resource-sync-project", project).catch(() => {});
|
||||||
|
})();
|
||||||
|
}, [project]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Einstellungen als EIGENES natives Tauri-Fenster (statt Overlay-Dialog im
|
||||||
|
// Hauptfenster) — Hauptfenster-Seite der Brücke. Gleiches Prinzip wie
|
||||||
|
// resourceWindow.ts, aber schmaler: SettingsDialog braucht nur eine Handvoll
|
||||||
|
// einzelner Felder (nicht das ganze Project), daher ein explizites
|
||||||
|
// Sync-/Command-Paar statt eines generischen Proxys über 27 Handler.
|
||||||
|
//
|
||||||
|
// Hauptfenster → Einstellungs-Fenster: „settings-sync-state" mit
|
||||||
|
// { marqueeColor, snapColor, referenceElevationMasl, rendererMode,
|
||||||
|
// webGpuAvailable, layout } bei jeder Änderung + auf „settings-window-ready".
|
||||||
|
// Einstellungs-Fenster → Hauptfenster: „settings-command" mit
|
||||||
|
// { name, args } — dieselbe {name,args}-RPC-Form wie resource-command.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import type { LayoutState } from "../panels/types";
|
||||||
|
import type { RendererMode } from "../ui/StatusBar";
|
||||||
|
import type { ThemeMode } from "../theme/themeMode";
|
||||||
|
import { BORDERLESS_WINDOW_CHROME } from "./windowChrome";
|
||||||
|
|
||||||
|
const SETTINGS_WINDOW_LABEL = "settings";
|
||||||
|
|
||||||
|
/** Alles, was SettingsDialog zum Rendern braucht — wandert 1:1 rüber. */
|
||||||
|
export interface SettingsSyncState {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
marqueeColor: string;
|
||||||
|
snapColor: string;
|
||||||
|
referenceElevationMasl: number | undefined;
|
||||||
|
rendererMode: RendererMode;
|
||||||
|
webGpuAvailable: boolean;
|
||||||
|
layout: LayoutState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Aktionen, die das Einstellungs-Fenster auslösen kann. */
|
||||||
|
export interface SettingsCommandHandlers {
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
|
onMarqueeColorChange: (hex: string) => void;
|
||||||
|
onSnapColorChange: (hex: string) => void;
|
||||||
|
onReferenceElevationChange: (n: number | undefined) => void;
|
||||||
|
onRendererModeChange: (mode: RendererMode) => void;
|
||||||
|
onApplyLayout: (layout: LayoutState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet das native Einstellungs-Fenster (bzw. holt es nach vorne). */
|
||||||
|
export async function openSettingsWindowNative(): Promise<void> {
|
||||||
|
const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow");
|
||||||
|
const existing = await WebviewWindow.getByLabel(SETTINGS_WINDOW_LABEL);
|
||||||
|
if (existing) {
|
||||||
|
await existing.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new WebviewWindow(SETTINGS_WINDOW_LABEL, {
|
||||||
|
url: "index.html?window=settings",
|
||||||
|
title: "Einstellungen — Dossier",
|
||||||
|
width: 520,
|
||||||
|
height: 640,
|
||||||
|
minWidth: 420,
|
||||||
|
minHeight: 420,
|
||||||
|
...BORDERLESS_WINDOW_CHROME,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hauptfenster-Seite der Sync-Brücke: hält das Einstellungs-Fenster (falls
|
||||||
|
* offen) synchron zu `state` und wendet dessen Befehle auf `handlers` an.
|
||||||
|
* No-op ausserhalb von Tauri.
|
||||||
|
*/
|
||||||
|
export function useSettingsWindowHost(
|
||||||
|
state: SettingsSyncState,
|
||||||
|
handlers: SettingsCommandHandlers,
|
||||||
|
): void {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
stateRef.current = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
let disposed = false;
|
||||||
|
const unlisten: Array<() => void> = [];
|
||||||
|
void (async () => {
|
||||||
|
const { listen, emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
if (disposed) return;
|
||||||
|
unlisten.push(
|
||||||
|
await listen<{ name: string; args: unknown[] }>("settings-command", (e) => {
|
||||||
|
const fn = (handlersRef.current as unknown as Record<string, unknown>)[e.payload.name];
|
||||||
|
if (typeof fn === "function") (fn as (...a: unknown[]) => void)(...e.payload.args);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unlisten.push(
|
||||||
|
await listen("settings-window-ready", () => {
|
||||||
|
void emitTo(SETTINGS_WINDOW_LABEL, "settings-sync-state", stateRef.current).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten.forEach((u) => u());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
void (async () => {
|
||||||
|
const { emitTo } = await import("@tauri-apps/api/event");
|
||||||
|
await emitTo(SETTINGS_WINDOW_LABEL, "settings-sync-state", state).catch(() => {});
|
||||||
|
})();
|
||||||
|
}, [state]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Gemeinsame Fenster-Chrome für native Nebenfenster (Ressourcen, Einstellungen,
|
||||||
|
// künftige weitere) — spiegelt das Hauptfenster (tauri.conf.json: randlos, macOS-
|
||||||
|
// Overlay-Titelleiste, eigener Drag-Header) statt der nativen OS-Titelzeile samt
|
||||||
|
// Titeltext. Ohne das trägt jedes Nebenfenster ZWEI optisch übereinander
|
||||||
|
// liegende „Kopfzeilen" (OS-Titel + unser eigener `res-head`/`.win-head`) — genau
|
||||||
|
// das vom Nutzer bemängelte „wirkt am Header etwas weird".
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { LogicalPosition } from "@tauri-apps/api/dpi";
|
||||||
|
|
||||||
|
/** In `new WebviewWindow(label, { ...BORDERLESS_WINDOW_CHROME, ... })` mergen.
|
||||||
|
* `trafficLightPosition` zentriert die macOS-Ampeln vertikal in unserer 40px-
|
||||||
|
* Kopfzeile (sonst sitzen sie höher als der Titeltext). */
|
||||||
|
export const BORDERLESS_WINDOW_CHROME = {
|
||||||
|
titleBarStyle: "overlay" as const,
|
||||||
|
hiddenTitle: true,
|
||||||
|
trafficLightPosition: new LogicalPosition(20, 22),
|
||||||
|
};
|
||||||
@@ -13,8 +13,9 @@ import { t } from "../i18n";
|
|||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die
|
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — die fünf
|
||||||
* fünf DOSSIER-Modi aus docs/design/context-menu.md.
|
* DOSSIER-Modi aus docs/design/context-menu.md plus die eigene Ergänzung
|
||||||
|
* "locked" (aktiv farbig, andere gesperrt aber NICHT grau).
|
||||||
*/
|
*/
|
||||||
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
|
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
|
||||||
{ value: "all_force", labelKey: "display.all_force" },
|
{ value: "all_force", labelKey: "display.all_force" },
|
||||||
@@ -22,6 +23,7 @@ const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
|
|||||||
{ value: "active", labelKey: "display.active" },
|
{ value: "active", labelKey: "display.active" },
|
||||||
{ value: "grey", labelKey: "display.grey" },
|
{ value: "grey", labelKey: "display.grey" },
|
||||||
{ value: "grey_locked", labelKey: "display.grey_locked" },
|
{ value: "grey_locked", labelKey: "display.grey_locked" },
|
||||||
|
{ value: "locked", labelKey: "display.locked" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { itemDisplay } from "./displayMode";
|
|||||||
import { usePanelHost } from "./host";
|
import { usePanelHost } from "./host";
|
||||||
import type { DrawingLevel } from "./host";
|
import type { DrawingLevel } from "./host";
|
||||||
import type { DisplayMode } from "./types";
|
import type { DisplayMode } from "./types";
|
||||||
|
import { DisplayModeSelect } from "./DisplayModeSelect";
|
||||||
import type { DrawingLevelKind } from "../model/types";
|
import type { DrawingLevelKind } from "../model/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
@@ -31,8 +32,9 @@ const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
|
|||||||
drawing: "badge.drawing",
|
drawing: "badge.drawing",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. */
|
/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. Exportiert für das
|
||||||
const CATEGORY_ORDER: DrawingLevelKind[] = [
|
* native Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx). */
|
||||||
|
export const CATEGORY_ORDER: DrawingLevelKind[] = [
|
||||||
"floor",
|
"floor",
|
||||||
"section",
|
"section",
|
||||||
"elevation",
|
"elevation",
|
||||||
@@ -40,7 +42,7 @@ const CATEGORY_ORDER: DrawingLevelKind[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** i18n-Key des Kategorie-Titels. */
|
/** i18n-Key des Kategorie-Titels. */
|
||||||
const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
|
export const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
|
||||||
floor: "levelCat.floor",
|
floor: "levelCat.floor",
|
||||||
section: "levelCat.section",
|
section: "levelCat.section",
|
||||||
elevation: "levelCat.elevation",
|
elevation: "levelCat.elevation",
|
||||||
@@ -48,7 +50,7 @@ const CATEGORY_TITLE_KEY: Record<DrawingLevelKind, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** i18n-Key der Beschriftung des „+"-Glyphs (Tooltip/aria-label). */
|
/** i18n-Key der Beschriftung des „+"-Glyphs (Tooltip/aria-label). */
|
||||||
const CATEGORY_ADD_KEY: Record<DrawingLevelKind, string> = {
|
export const CATEGORY_ADD_KEY: Record<DrawingLevelKind, string> = {
|
||||||
floor: "levels.addFloor",
|
floor: "levels.addFloor",
|
||||||
section: "levels.addSection",
|
section: "levels.addSection",
|
||||||
elevation: "levels.addElevation",
|
elevation: "levels.addElevation",
|
||||||
@@ -93,10 +95,13 @@ export function DrawingLevelsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="nav-group">
|
<section className="nav-group">
|
||||||
<header className="nav-group-head">
|
{/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE
|
||||||
<span className="nav-group-title">{t("nav.drawingLevels")}</span>
|
Kopfzeile; die Fenster-Ausklinken-Aktion sitzt dort via headerActions
|
||||||
</header>
|
(DrawingLevelsHeaderActions), kein doppelter Titel. */}
|
||||||
|
<div className="panel-vis">
|
||||||
|
<span className="panel-vis-label">{t("nav.visibility")}</span>
|
||||||
|
<DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
|
||||||
|
</div>
|
||||||
{CATEGORY_ORDER.map((kind) => {
|
{CATEGORY_ORDER.map((kind) => {
|
||||||
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
|
const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind);
|
||||||
const isOpen = !collapsed[kind];
|
const isOpen = !collapsed[kind];
|
||||||
@@ -151,8 +156,28 @@ export function DrawingLevelsPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine Zeile im Zeichnungsebenen-Bereich. */
|
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
|
||||||
function LevelRow({
|
* Zahnrad → Einstellungen (Darstellungsfarben/Projekt-Felder). */
|
||||||
|
export function DrawingLevelsHeaderActions() {
|
||||||
|
const host = usePanelHost();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="nav-settings"
|
||||||
|
aria-label={t("topbar.settings")}
|
||||||
|
title={t("topbar.settings")}
|
||||||
|
onClick={host.onOpenSettings}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" aria-hidden="true">
|
||||||
|
settings
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile im Zeichnungsebenen-Bereich. Exportiert, damit das native
|
||||||
|
* Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx) dieselbe
|
||||||
|
* Zeilen-Darstellung wiederverwendet statt sie zu duplizieren. */
|
||||||
|
export function LevelRow({
|
||||||
level,
|
level,
|
||||||
active,
|
active,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { t } from "../i18n";
|
|||||||
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
|
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
|
||||||
"Wand",
|
"Wand",
|
||||||
"Decke",
|
"Decke",
|
||||||
|
"Dach",
|
||||||
|
"Stütze",
|
||||||
"Fenster",
|
"Fenster",
|
||||||
"Öffnung",
|
"Öffnung",
|
||||||
"Treppe",
|
"Treppe",
|
||||||
@@ -36,6 +38,8 @@ const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
|
|||||||
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
|
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
|
||||||
Wand: "elements.kind.wall",
|
Wand: "elements.kind.wall",
|
||||||
Decke: "elements.kind.ceiling",
|
Decke: "elements.kind.ceiling",
|
||||||
|
Dach: "elements.kind.roof",
|
||||||
|
Stütze: "elements.kind.column",
|
||||||
Fenster: "elements.kind.window",
|
Fenster: "elements.kind.window",
|
||||||
Öffnung: "elements.kind.opening",
|
Öffnung: "elements.kind.opening",
|
||||||
Treppe: "elements.kind.stair",
|
Treppe: "elements.kind.stair",
|
||||||
|
|||||||
@@ -4,14 +4,18 @@
|
|||||||
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
|
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
|
||||||
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
|
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
|
||||||
//
|
//
|
||||||
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive"
|
// Das Panel deklariert hasDisplayMode. „Aktiv" ist die per Klick gewählte
|
||||||
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false;
|
// Kategorie (activeCategoryCode, Zeichen-Ziel für neue Elemente); dieselbe
|
||||||
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi):
|
// activeCategoryCode speist auch App.tsx' categoryDisplay-Resolver, der die
|
||||||
|
// tatsächliche Grundriss-/3D-Darstellung steuert. `itemDisplay` ergibt damit
|
||||||
|
// (fünf DOSSIER-Modi plus eigene Ergänzung "locked"):
|
||||||
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
|
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
|
||||||
// • "all" — alle sichtbaren normal (Standard, wie bisher).
|
// • "all" — alle sichtbaren normal (Standard, wie bisher).
|
||||||
// • "active" — keine Kategorie aktiv → alle gedimmt.
|
// • "active" — nur die gewählte Kategorie sichtbar, Rest ausgeblendet.
|
||||||
// • "grey" — sichtbare Kategorien ausgegraut.
|
// • "grey" — gewählte normal, übrige sichtbare ausgegraut.
|
||||||
// • "grey_locked" — wie „grey", zusätzlich gesperrt.
|
// • "grey_locked" — wie „grey", übrige zusätzlich gesperrt.
|
||||||
|
// • "locked" — gewählte normal, übrige sichtbare UNVERÄNDERT farbig,
|
||||||
|
// nur gesperrt (nicht anwählbar/bearbeitbar).
|
||||||
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
|
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
|
||||||
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
|
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
|
||||||
// (styles.css bleibt unangetastet).
|
// (styles.css bleibt unangetastet).
|
||||||
@@ -23,6 +27,7 @@ import { itemDisplay } from "./displayMode";
|
|||||||
import { usePanelHost } from "./host";
|
import { usePanelHost } from "./host";
|
||||||
import type { LayerCategory } from "./host";
|
import type { LayerCategory } from "./host";
|
||||||
import type { DisplayMode } from "./types";
|
import type { DisplayMode } from "./types";
|
||||||
|
import { DisplayModeSelect } from "./DisplayModeSelect";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
/** Inhalt des Panels „Ebenen". */
|
/** Inhalt des Panels „Ebenen". */
|
||||||
@@ -46,15 +51,13 @@ export function LayersPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="nav-group">
|
<section className="nav-group">
|
||||||
<header className="nav-group-head">
|
{/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE
|
||||||
<span className="nav-group-title">{t("nav.layers")}</span>
|
Kopfzeile; die „+ Kategorie"-Aktion sitzt dort via headerActions
|
||||||
<span className="nav-group-actions">
|
(LayersHeaderActions), kein doppelter „Ebenen"-Titel. */}
|
||||||
<button className="nav-add" onClick={host.onAddCategory}>
|
<div className="panel-vis">
|
||||||
{t("layers.addCategory")}
|
<span className="panel-vis-label">{t("nav.visibility")}</span>
|
||||||
</button>
|
<DisplayModeSelect value={displayMode.mode} onChange={displayMode.setMode} />
|
||||||
</span>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="nav-list">
|
<div className="nav-list">
|
||||||
{project.layers.map((cat) => (
|
{project.layers.map((cat) => (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
@@ -66,6 +69,7 @@ export function LayersPanel() {
|
|||||||
onSelect={host.onSelectCategory}
|
onSelect={host.onSelectCategory}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
onContext={onRowContext}
|
onContext={onRowContext}
|
||||||
|
onSettings={host.onOpenLayerSettings}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,6 +86,7 @@ function CategoryRow({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggle,
|
onToggle,
|
||||||
onContext,
|
onContext,
|
||||||
|
onSettings,
|
||||||
}: {
|
}: {
|
||||||
category: LayerCategory;
|
category: LayerCategory;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -90,6 +95,7 @@ function CategoryRow({
|
|||||||
onSelect: (code: string) => void;
|
onSelect: (code: string) => void;
|
||||||
onToggle: (code: string) => void;
|
onToggle: (code: string) => void;
|
||||||
onContext: (code: string, e: React.MouseEvent) => void;
|
onContext: (code: string, e: React.MouseEvent) => void;
|
||||||
|
onSettings: (code: string, clientX: number, clientY: number) => void;
|
||||||
}) {
|
}) {
|
||||||
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
|
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
|
||||||
const isActive = category.code === activeCode;
|
const isActive = category.code === activeCode;
|
||||||
@@ -137,6 +143,19 @@ function CategoryRow({
|
|||||||
<span className="cat-code">{category.code}</span>
|
<span className="cat-code">{category.code}</span>
|
||||||
<span className="cat-swatch" style={{ background: category.color }} />
|
<span className="cat-swatch" style={{ background: category.color }} />
|
||||||
<span className="cat-name">{category.name}</span>
|
<span className="cat-name">{category.name}</span>
|
||||||
|
<button
|
||||||
|
className="cat-settings"
|
||||||
|
aria-label={t("nav.categorySettings", { name: category.name })}
|
||||||
|
title={t("nav.categorySettings", { name: category.name })}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSettings(category.code, e.clientX, e.clientY);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" aria-hidden="true">
|
||||||
|
settings
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{category.children?.map((child) => (
|
{category.children?.map((child) => (
|
||||||
<CategoryRow
|
<CategoryRow
|
||||||
@@ -148,8 +167,38 @@ function CategoryRow({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onContext={onContext}
|
onContext={onContext}
|
||||||
|
onSettings={onSettings}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile):
|
||||||
|
* „+ Kategorie" + Zahnrad → Einstellungen (wie im Zeichnungsebenen-Kopf).
|
||||||
|
* Läuft im PanelHostContext, nutzt daher usePanelHost. */
|
||||||
|
export function LayersHeaderActions() {
|
||||||
|
const host = usePanelHost();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="nav-settings"
|
||||||
|
onClick={host.onOpenSettings}
|
||||||
|
aria-label={t("topbar.settings")}
|
||||||
|
title={t("topbar.settings")}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" aria-hidden="true">
|
||||||
|
settings
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="nav-add"
|
||||||
|
onClick={host.onAddCategory}
|
||||||
|
aria-label={t("layers.addCategory")}
|
||||||
|
title={t("layers.addCategory")}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -242,6 +242,35 @@ export function ObjectInfoPanel() {
|
|||||||
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
|
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Kontext-Objekt (importiertes Gebäude/Terrain/Konturen): reale LV95-
|
||||||
|
Koordinate des Bbox-Mittelpunkts — direkt sichtbar, ob/wohin
|
||||||
|
georeferenziert importiert wurde (Nutzer-Wunsch zum Prüfen). */}
|
||||||
|
{sel.contextObject && (
|
||||||
|
<>
|
||||||
|
<div className="objinfo-sep" />
|
||||||
|
<div className="objinfo-section-label">{t("objinfo.contextObject.section")}</div>
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.contextObject.type")}</span>
|
||||||
|
<span className="objinfo-fval">{t(`objinfo.contextObject.type.${sel.contextObject.objType}`)}</span>
|
||||||
|
</div>
|
||||||
|
{sel.contextObject.lv95Center ? (
|
||||||
|
<>
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.contextObject.lv95E")}</span>
|
||||||
|
<span className="objinfo-fval">{sel.contextObject.lv95Center.e.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.contextObject.lv95N")}</span>
|
||||||
|
<span className="objinfo-fval">{sel.contextObject.lv95Center.n.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-fval objinfo-muted">{t("objinfo.contextObject.noAnchor")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
|
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
|
||||||
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||||
die exportierten *Section-Komponenten). */}
|
die exportierten *Section-Komponenten). */}
|
||||||
@@ -393,6 +422,20 @@ export function SectionLineSection({
|
|||||||
{t("objinfo.sectionLine.flip")}
|
{t("objinfo.sectionLine.flip")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* 3D-Live-Schnitt an diese Linie binden (nur mit gesetzter Linie sinnvoll):
|
||||||
|
der Viewport clippt dann vertikal entlang dieser Ebene. */}
|
||||||
|
{line && (
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.sectionLine.cut3d")}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={host.section3dCutActive}
|
||||||
|
onChange={() => host.onToggleSection3dCut(level.id)}
|
||||||
|
title={t("objinfo.sectionLine.cut3d")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */}
|
{/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */}
|
||||||
{line && (
|
{line && (
|
||||||
<>
|
<>
|
||||||
@@ -854,6 +897,27 @@ export function StairSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pfeil-Darstellung (offener Winkel- oder gefüllter SIA-Vollpfeil). */}
|
||||||
|
<div className="objinfo-field">
|
||||||
|
<span className="objinfo-flabel">{t("objinfo.stair.arrow")}</span>
|
||||||
|
<div className="objinfo-segment">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"objinfo-seg-btn" + ((stair.arrowStyle ?? "line") === "line" ? " active" : "")}
|
||||||
|
onClick={() => host.onSetStairArrowStyle("line")}
|
||||||
|
>
|
||||||
|
{t("objinfo.stair.arrow.line")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"objinfo-seg-btn" + (stair.arrowStyle === "filled" ? " active" : "")}
|
||||||
|
onClick={() => host.onSetStairArrowStyle("filled")}
|
||||||
|
>
|
||||||
|
{t("objinfo.stair.arrow.filled")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Editierbare Maße. */}
|
{/* Editierbare Maße. */}
|
||||||
<DimensionField
|
<DimensionField
|
||||||
label={t("objinfo.stair.width")}
|
label={t("objinfo.stair.width")}
|
||||||
|
|||||||
@@ -54,12 +54,15 @@ export function PanelFrame({
|
|||||||
<section className="panel-frame">
|
<section className="panel-frame">
|
||||||
<header className="panel-head">
|
<header className="panel-head">
|
||||||
<span className="panel-title">{t(def.title)}</span>
|
<span className="panel-title">{t(def.title)}</span>
|
||||||
{showMode && (
|
<span className="panel-head-actions">
|
||||||
<DisplayModeSelect
|
{def.headerActions}
|
||||||
value={displayMode as DisplayMode}
|
{showMode && (
|
||||||
onChange={onDisplayModeChange as (m: DisplayMode) => void}
|
<DisplayModeSelect
|
||||||
/>
|
value={displayMode as DisplayMode}
|
||||||
)}
|
onChange={onDisplayModeChange as (m: DisplayMode) => void}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="panel-body">{def.render(ctx ?? {})}</div>
|
<div className="panel-body">{def.render(ctx ?? {})}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type { ContextObject } from "../model/types";
|
|||||||
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
||||||
import { Dropdown } from "../ui/Dropdown";
|
import { Dropdown } from "../ui/Dropdown";
|
||||||
import { useStore } from "../state/appStore";
|
import { useStore } from "../state/appStore";
|
||||||
|
import { isTauriRuntime } from "../io/projectFile";
|
||||||
|
import { openContextImportWindowNative } from "../native/contextImportWindow";
|
||||||
|
|
||||||
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
|
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
|
||||||
function typeBadgeKey(obj: ContextObject): TranslationKey {
|
function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||||
@@ -29,9 +31,43 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
|
|||||||
return "site.type.contours";
|
return "site.type.contours";
|
||||||
case "terrainMesh":
|
case "terrainMesh":
|
||||||
return "site.type.terrain";
|
return "site.type.terrain";
|
||||||
|
case "image":
|
||||||
|
return "site.type.image";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ebenen-/Gruppenname eines Kontext-Objekts (aus `layer`, sonst Typ-Fallback). */
|
||||||
|
function layerOf(obj: ContextObject): string {
|
||||||
|
if (obj.layer && obj.layer.trim() !== "") return obj.layer;
|
||||||
|
switch (obj.type) {
|
||||||
|
case "importedMesh":
|
||||||
|
return t("site.group.imported");
|
||||||
|
case "terrainMesh":
|
||||||
|
return t("site.group.terrain");
|
||||||
|
case "contourSet":
|
||||||
|
return t("site.group.contours");
|
||||||
|
case "image":
|
||||||
|
return t("site.group.image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppiert Kontext-Objekte nach Ebene, Reihenfolge des ersten Auftretens. */
|
||||||
|
function groupByLayer(objects: ContextObject[]): { layer: string; items: ContextObject[] }[] {
|
||||||
|
const order: string[] = [];
|
||||||
|
const byLayer = new Map<string, ContextObject[]>();
|
||||||
|
for (const obj of objects) {
|
||||||
|
const layer = layerOf(obj);
|
||||||
|
let items = byLayer.get(layer);
|
||||||
|
if (!items) {
|
||||||
|
items = [];
|
||||||
|
byLayer.set(layer, items);
|
||||||
|
order.push(layer);
|
||||||
|
}
|
||||||
|
items.push(obj);
|
||||||
|
}
|
||||||
|
return order.map((layer) => ({ layer, items: byLayer.get(layer)! }));
|
||||||
|
}
|
||||||
|
|
||||||
export function SitePanel() {
|
export function SitePanel() {
|
||||||
const host = usePanelHost();
|
const host = usePanelHost();
|
||||||
const notify = useStore((s) => s.notify);
|
const notify = useStore((s) => s.notify);
|
||||||
@@ -72,7 +108,10 @@ export function SitePanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="site-btn"
|
className="site-btn"
|
||||||
onClick={() => setCtxImportOpen(true)}
|
onClick={() => {
|
||||||
|
if (isTauriRuntime()) void openContextImportWindowNative();
|
||||||
|
else setCtxImportOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("site.importLocation")}
|
{t("site.importLocation")}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,37 +156,57 @@ export function SitePanel() {
|
|||||||
|
|
||||||
<div className="site-sep" />
|
<div className="site-sep" />
|
||||||
|
|
||||||
{/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */}
|
{/* Kontext-Objekte nach Ebene (`layer`) gruppiert: importierte Gebäude
|
||||||
|
liegen als EINZELNE, getrennt lösch-/anwählbare Elemente unter ihrer
|
||||||
|
Ebene (z. B. „Gebäude"), Terrain unter „Terrain". Je Gruppe ein Kopf
|
||||||
|
mit Anzahl + „ganze Ebene löschen". */}
|
||||||
{objects.length === 0 ? (
|
{objects.length === 0 ? (
|
||||||
<div className="site-empty">{t("site.empty")}</div>
|
<div className="site-empty">{t("site.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="site-list">
|
groupByLayer(objects).map(({ layer, items }) => (
|
||||||
{objects.map((obj) => (
|
<div key={layer} className="site-group">
|
||||||
<li
|
<div className="site-group-head">
|
||||||
key={obj.id}
|
<span className="site-group-name">{layer}</span>
|
||||||
className={
|
<span className="site-group-count">{items.length}</span>
|
||||||
"site-row" +
|
|
||||||
(host.selectedContextObjectIds.includes(obj.id) ? " site-row-selected" : "")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="site-name" title={obj.name}>
|
|
||||||
{obj.name}
|
|
||||||
</span>
|
|
||||||
<span className={"site-badge site-badge-" + obj.type}>
|
|
||||||
{t(typeBadgeKey(obj))}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="site-remove"
|
className="site-remove"
|
||||||
title={t("site.delete")}
|
title={t("site.deleteLayer")}
|
||||||
aria-label={t("site.delete")}
|
aria-label={t("site.deleteLayer")}
|
||||||
onClick={() => host.onRemoveContextObject(obj.id)}
|
onClick={() => host.onRemoveContextObjects(items.map((o) => o.id))}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</div>
|
||||||
))}
|
<ul className="site-list">
|
||||||
</ul>
|
{items.map((obj) => (
|
||||||
|
<li
|
||||||
|
key={obj.id}
|
||||||
|
className={
|
||||||
|
"site-row" +
|
||||||
|
(host.selectedContextObjectIds.includes(obj.id) ? " site-row-selected" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="site-name" title={obj.name}>
|
||||||
|
{obj.name}
|
||||||
|
</span>
|
||||||
|
<span className={"site-badge site-badge-" + obj.type}>
|
||||||
|
{t(typeBadgeKey(obj))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="site-remove"
|
||||||
|
title={t("site.delete")}
|
||||||
|
aria-label={t("site.delete")}
|
||||||
|
onClick={() => host.onRemoveContextObject(obj.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="site-hint">{t("site.dwgHint")}</div>
|
<div className="site-hint">{t("site.dwgHint")}</div>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/
|
// Inhalts-Panel „Werkzeuge" — die Werkzeug-Palette (ArchiCAD-/Vectorworks-Stil).
|
||||||
// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das
|
// Oben ein Umschalter „Liste ↔ Symbole" (Icon+Name als Zeilen ODER kompaktes
|
||||||
// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist —
|
// Icon-Raster). Darunter drei einheitlich gestylte Rubriken: 2D (Zeichnen),
|
||||||
// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen.
|
// 3D (BIM-Bauteile) und Bearbeiten (Ändern-Aktionen) — Werkzeuge UND Befehle
|
||||||
|
// teilen denselben Knopf-Look (`.tp-item`), damit „Ändern" nicht mehr optisch
|
||||||
|
// aus der Reihe fällt.
|
||||||
//
|
//
|
||||||
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Die Werkzeug-
|
// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Werkzeug-Metadaten
|
||||||
// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool).
|
// aus der Tools-Registry (getTool), Befehls-Labels aus der Command-Registry.
|
||||||
//
|
//
|
||||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
|
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
|
||||||
// Texte über t(...).
|
// Texte über t(...).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { t } from "../i18n";
|
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 { getTool } from "../tools/tools";
|
||||||
|
import { getCommand } from "../commands/registry";
|
||||||
|
import { CommandIcon } from "../ui/ribbon/CommandIcon";
|
||||||
|
|
||||||
// ── Icons ──────────────────────────────────────────────────────────────────
|
// ── Icons ──────────────────────────────────────────────────────────────────
|
||||||
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
// Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug
|
||||||
@@ -31,18 +36,17 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
};
|
};
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "select":
|
case "select":
|
||||||
// Pfeil-Cursor.
|
// Pfeil-Cursor (klassischer Zeiger mit Schweif).
|
||||||
return (
|
return (
|
||||||
<svg {...common} fill="currentColor" stroke="none">
|
<svg {...common} fill="currentColor" stroke="currentColor" strokeWidth={0.8}>
|
||||||
<path d="M3 2l9 5-3.6 1.1L10.4 13 8.5 13.7 6.6 9.4 3 11z" />
|
<path d="M4 2.5 L4 13.2 L6.9 10.4 L8.9 14.6 L10.7 13.8 L8.7 9.7 L12.6 9.6 Z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "wall":
|
case "wall":
|
||||||
// Zwei parallele Linien (Wandband).
|
// Wandband: zwei parallele Kanten mit geschlossenen Enden (Wandstück).
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<line x1="2" y1="5.5" x2="14" y2="5.5" />
|
<rect x="2" y="5.5" width="12" height="5" />
|
||||||
<line x1="2" y1="10.5" x2="14" y2="10.5" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "ceiling":
|
case "ceiling":
|
||||||
@@ -55,29 +59,37 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "window":
|
case "window":
|
||||||
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung).
|
// Fenster (SIA-Aufsicht): Wandöffnung mit doppelter Glaslinie.
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<rect x="2.5" y="4" width="11" height="8" />
|
<line x1="2" y1="5.5" x2="14" y2="5.5" />
|
||||||
<line x1="8" y1="4" x2="8" y2="12" />
|
<line x1="2" y1="10.5" x2="14" y2="10.5" />
|
||||||
|
<line x1="2" y1="8" x2="14" y2="8" />
|
||||||
|
<line x1="2" y1="5.5" x2="2" y2="10.5" />
|
||||||
|
<line x1="14" y1="5.5" x2="14" y2="10.5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "door":
|
case "door":
|
||||||
// Tür: Pfosten + Türblatt mit Schwenkbogen.
|
// Tür (SIA-Aufsicht): Türblatt aufrecht + Viertelkreis-Schwenkbogen.
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<line x1="3" y1="13" x2="3" y2="4" />
|
<line x1="3.5" y1="13" x2="3.5" y2="3" />
|
||||||
<line x1="3" y1="4" x2="11" y2="4" />
|
<path d="M3.5 3 A9.5 9.5 0 0 1 13 12.5" strokeDasharray="1.4 1.4" />
|
||||||
<path d="M3 13 A9 9 0 0 0 11 4" />
|
<line x1="3.5" y1="13" x2="13" y2="13" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "stair":
|
case "stair":
|
||||||
// Treppensymbol: gestufte Linie (Tritte) + Lauflinie mit Pfeil.
|
// Treppe (SIA-Aufsicht): Wangen, gleichmäßige Tritte, Lauflinie mit
|
||||||
|
// Aufwärts-Pfeil (Idee: Vollbogen ersetzt durch eine echte Treppe).
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" />
|
<rect x="3" y="2" width="10" height="12" />
|
||||||
<line x1="4" y1="12.5" x2="12" y2="3.5" />
|
<line x1="3" y1="4.4" x2="13" y2="4.4" />
|
||||||
<polyline points="10.5,3 12,3.5 11.5,5" />
|
<line x1="3" y1="6.8" x2="13" y2="6.8" />
|
||||||
|
<line x1="3" y1="9.2" x2="13" y2="9.2" />
|
||||||
|
<line x1="3" y1="11.6" x2="13" y2="11.6" />
|
||||||
|
<line x1="8" y1="13.2" x2="8" y2="3.4" />
|
||||||
|
<polyline points="6.3,5.1 8,3.2 9.7,5.1" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "room":
|
case "room":
|
||||||
@@ -118,10 +130,12 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "arc":
|
case "arc":
|
||||||
// Offener Halbbogen.
|
// Offener Bogen (gut 180°) mit Endpunkt-Markierungen.
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
|
<path d="M 2.5 11 A 5.6 5.6 0 0 1 13.5 11" fill="none" />
|
||||||
|
<circle cx="2.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="13.5" cy="11" r="0.9" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case "text":
|
case "text":
|
||||||
@@ -147,44 +161,131 @@ export function ToolIcon({ id }: { id: ToolId }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Kategorien ───────────────────────────────────────────────────────────────
|
||||||
|
// Werkzeuge/Befehle in drei Rubriken sortiert (Nutzerwunsch: „sortiere nach 2D
|
||||||
|
// und 3D und Bearbeiten"). Befehlsnamen spiegeln die Ribbon-Registry
|
||||||
|
// (ui/ribbon/ribbonItems.ts) — dort liegt die kanonische Liste; hier nur die
|
||||||
|
// Panel-Gruppierung (2D-Zeichnen · 3D/BIM-Bauteile · Bearbeiten-Aktionen).
|
||||||
|
const TOOLS_2D: ToolId[] = ["line", "polyline", "rect", "circle", "arc", "text", "textbox"];
|
||||||
|
const TOOLS_3D: ToolId[] = ["wall", "ceiling", "window", "door", "stair", "room"];
|
||||||
|
const COMMANDS_3D = ["column", "ceilingopening", "roof", "extrude", "sectionline", "viewline"];
|
||||||
|
const COMMANDS_EDIT = ["move", "copy", "rotate", "mirror", "offset", "trim", "join", "measure"];
|
||||||
|
|
||||||
|
type ToolsView = "list" | "icons";
|
||||||
|
const VIEW_STORAGE_KEY = "dossier.toolsPanel.view";
|
||||||
|
|
||||||
|
function loadView(): ToolsView {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(VIEW_STORAGE_KEY) === "icons" ? "icons" : "list";
|
||||||
|
} catch {
|
||||||
|
return "list";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Panel ────────────────────────────────────────────────────────────────────
|
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ToolsPanel() {
|
export function ToolsPanel() {
|
||||||
const host = usePanelHost();
|
const host = usePanelHost();
|
||||||
|
const [view, setView] = useState<ToolsView>(loadView);
|
||||||
|
|
||||||
|
const chooseView = (v: ToolsView) => {
|
||||||
|
setView(v);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEW_STORAGE_KEY, v);
|
||||||
|
} catch {
|
||||||
|
/* Persistenz ist Best-Effort (privater Modus o. ä.). */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ein Werkzeug-Knopf (Icon + Name). Boden-gebundene (`floorOnly`) Werkzeuge
|
||||||
|
// sind ohne aktives Geschoss gesperrt; freie 2D-Werkzeuge bleiben nutzbar.
|
||||||
|
const toolButton = (id: ToolId) => {
|
||||||
|
const tool = getTool(id);
|
||||||
|
const active = host.activeTool === id;
|
||||||
|
const disabled = !!tool.floorOnly && !host.toolsEnabled;
|
||||||
|
const label = t(tool.labelKey);
|
||||||
|
const title = disabled ? t("tool.floorOnlyDisabled") : t(tool.hintKey(tool.init()));
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
className={"tp-item" + (active ? " active" : "") + (disabled ? " disabled" : "")}
|
||||||
|
title={view === "icons" ? label : title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => host.onSelectTool(id)}
|
||||||
|
>
|
||||||
|
<ToolIcon id={id} />
|
||||||
|
<span className="tp-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ein Befehls-Knopf (Ändern-/BIM-Kommando ohne eigenen Werkzeug-Modus) —
|
||||||
|
// gleicher Look wie die Werkzeuge (Nutzerwunsch: nicht „ganz anders").
|
||||||
|
const commandButton = (name: string) => {
|
||||||
|
const cmd = getCommand(name);
|
||||||
|
const label = cmd ? t(cmd.labelKey) : t(`cmd.${name}.label`);
|
||||||
|
const active = host.activeCommand === name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
className={"tp-item" + (active ? " active" : "")}
|
||||||
|
title={label}
|
||||||
|
onClick={() => host.onRunCommand(name)}
|
||||||
|
>
|
||||||
|
<CommandIcon name={name} />
|
||||||
|
<span className="tp-label">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyClass = "tp-body" + (view === "icons" ? " icons" : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tools-panel">
|
<div className="tools-panel">
|
||||||
{/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */}
|
{/* Anzeige-Umschalter: nur Symbole ODER Symbol + Name (Liste). */}
|
||||||
{TOOL_ORDER.map((id) => {
|
<div className="tp-head">
|
||||||
const tool = getTool(id);
|
<div className="tp-viewtoggle" role="group" aria-label={t("tools.view.label")}>
|
||||||
const active = host.activeTool === id;
|
|
||||||
// Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss
|
|
||||||
// gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf
|
|
||||||
// Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar).
|
|
||||||
const disabled = !!tool.floorOnly && !host.toolsEnabled;
|
|
||||||
const title = disabled
|
|
||||||
? t("tool.floorOnlyDisabled")
|
|
||||||
: t(tool.hintKey(tool.init()));
|
|
||||||
const cls =
|
|
||||||
"tool-row" + (active ? " active" : "") + (disabled ? " disabled" : "");
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={id}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={cls}
|
className={"tp-viewbtn" + (view === "list" ? " active" : "")}
|
||||||
title={title}
|
aria-pressed={view === "list"}
|
||||||
disabled={disabled}
|
onClick={() => chooseView("list")}
|
||||||
onClick={() => host.onSelectTool(id)}
|
|
||||||
>
|
>
|
||||||
<ToolIcon id={id} />
|
{t("tools.view.list")}
|
||||||
<span>{t(tool.labelKey)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
<button
|
||||||
})}
|
type="button"
|
||||||
|
className={"tp-viewbtn" + (view === "icons" ? " active" : "")}
|
||||||
|
aria-pressed={view === "icons"}
|
||||||
|
onClick={() => chooseView("icons")}
|
||||||
|
>
|
||||||
|
{t("tools.view.icons")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI
|
{/* Auswahl-Werkzeug: immer als volle Zeile zuoberst (meistgenutzt). */}
|
||||||
Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker
|
<div className="tp-select">{toolButton("select")}</div>
|
||||||
erscheint dort bei aktivem Wand-/Decken-Werkzeug. */}
|
|
||||||
|
<section className="tp-section">
|
||||||
|
<div className="tp-section-title">{t("tools.cat.2d")}</div>
|
||||||
|
<div className={bodyClass}>{TOOLS_2D.map(toolButton)}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tp-section">
|
||||||
|
<div className="tp-section-title">{t("tools.cat.3d")}</div>
|
||||||
|
<div className={bodyClass}>
|
||||||
|
{TOOLS_3D.map(toolButton)}
|
||||||
|
{COMMANDS_3D.map(commandButton)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tp-section">
|
||||||
|
<div className="tp-section-title">{t("tools.cat.edit")}</div>
|
||||||
|
<div className={bodyClass}>{COMMANDS_EDIT.map(commandButton)}</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import { registerPanel } from "./registry";
|
import { registerPanel } from "./registry";
|
||||||
import { DrawingLevelsPanel } from "./DrawingLevelsPanel";
|
import { DrawingLevelsPanel, DrawingLevelsHeaderActions } from "./DrawingLevelsPanel";
|
||||||
import { LayersPanel } from "./LayersPanel";
|
import { LayersPanel, LayersHeaderActions } from "./LayersPanel";
|
||||||
import { ToolsPanel } from "./ToolsPanel";
|
import { ToolsPanel } from "./ToolsPanel";
|
||||||
import { AttributesPanel } from "./AttributesPanel";
|
import { AttributesPanel } from "./AttributesPanel";
|
||||||
import { ObjectInfoPanel } from "./ObjectInfoPanel";
|
import { ObjectInfoPanel } from "./ObjectInfoPanel";
|
||||||
@@ -82,7 +82,10 @@ registerPanel({
|
|||||||
// damit der Titel der gewählten Sprache folgt.
|
// damit der Titel der gewählten Sprache folgt.
|
||||||
title: "nav.drawingLevels",
|
title: "nav.drawingLevels",
|
||||||
icon: <DrawingLevelsTabIcon />,
|
icon: <DrawingLevelsTabIcon />,
|
||||||
hasDisplayMode: true,
|
// Der Sichtbarkeits-Umschalter sitzt jetzt IM Körper mit „Sichtbarkeit"-
|
||||||
|
// Beschriftung (nicht mehr im PanelFrame-Kopf) — hasDisplayMode: false.
|
||||||
|
hasDisplayMode: false,
|
||||||
|
headerActions: <DrawingLevelsHeaderActions />,
|
||||||
render: () => <DrawingLevelsPanel />,
|
render: () => <DrawingLevelsPanel />,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +93,8 @@ registerPanel({
|
|||||||
id: BUILTIN_PANEL_IDS.layers,
|
id: BUILTIN_PANEL_IDS.layers,
|
||||||
title: "nav.layers",
|
title: "nav.layers",
|
||||||
icon: <LayersTabIcon />,
|
icon: <LayersTabIcon />,
|
||||||
hasDisplayMode: true,
|
hasDisplayMode: false,
|
||||||
|
headerActions: <LayersHeaderActions />,
|
||||||
render: () => <LayersPanel />,
|
render: () => <LayersPanel />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ export interface ItemDisplay {
|
|||||||
* ausgegraut.
|
* ausgegraut.
|
||||||
* • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich
|
* • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich
|
||||||
* gesperrt.
|
* gesperrt.
|
||||||
|
* • "locked" — aktives normal; übrige sichtbare UNVERÄNDERT farbig,
|
||||||
|
* aber gesperrt (nicht anwählbar/bearbeitbar).
|
||||||
*
|
*
|
||||||
* Das aktive Element wird in "active"/"grey"/"grey_locked" stets gezeichnet,
|
* Das aktive Element wird in "active"/"grey"/"grey_locked"/"locked" stets gezeichnet,
|
||||||
* auch wenn seine Sichtbarkeit abgeschaltet ist — es ist der Arbeitsfokus. Ein
|
* auch wenn seine Sichtbarkeit abgeschaltet ist — es ist der Arbeitsfokus. Ein
|
||||||
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie
|
* nicht aktives, unsichtbares Element wird (außer in "all_force") nie
|
||||||
* gezeichnet.
|
* gezeichnet.
|
||||||
@@ -65,5 +67,12 @@ export function itemDisplay(
|
|||||||
const other = isVisible && !isActive;
|
const other = isVisible && !isActive;
|
||||||
return { render: isActive || isVisible, greyed: other, locked: other };
|
return { render: isActive || isVisible, greyed: other, locked: other };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "locked": {
|
||||||
|
// Wie „grey_locked", aber OHNE das Ausgrauen — übrige sichtbare bleiben
|
||||||
|
// farbig, sind nur nicht mehr anwählbar/bearbeitbar.
|
||||||
|
const other = isVisible && !isActive;
|
||||||
|
return { render: isActive || isVisible, greyed: false, locked: other };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export interface PanelHostValue {
|
|||||||
onSelectTool: (id: ToolId) => void;
|
onSelectTool: (id: ToolId) => void;
|
||||||
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
|
/** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */
|
||||||
toolsEnabled: boolean;
|
toolsEnabled: boolean;
|
||||||
|
/** Laufender Engine-Befehl (Highlight der Befehls-Knöpfe im ToolsPanel). */
|
||||||
|
activeCommand: string | null;
|
||||||
|
/** Engine-Befehl starten (Ändern-/BIM-Befehle im ToolsPanel — gleicher Pfad
|
||||||
|
* wie das Tippen des Befehlsnamens in der Befehlszeile). */
|
||||||
|
onRunCommand: (name: string) => void;
|
||||||
/** Aktiver Wandtyp (für das Wand-Werkzeug). */
|
/** Aktiver Wandtyp (für das Wand-Werkzeug). */
|
||||||
activeWallTypeId: string;
|
activeWallTypeId: string;
|
||||||
onActiveWallTypeId: (id: string) => void;
|
onActiveWallTypeId: (id: string) => void;
|
||||||
@@ -112,6 +117,14 @@ export interface PanelHostValue {
|
|||||||
* Löschen) und hält das offene Menü.
|
* Löschen) und hält das offene Menü.
|
||||||
*/
|
*/
|
||||||
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
|
onLayerContextMenu: (code: string, clientX: number, clientY: number) => void;
|
||||||
|
/** Öffnet die Ebeneneinstellungen direkt (Zahnrad-Symbol je Zeile) — unter
|
||||||
|
* Tauri als natives Fenster, sonst derselbe InlineEditor-Popover wie über
|
||||||
|
* das Kontextmenü (siehe App.tsx `openLayerSettings`). */
|
||||||
|
onOpenLayerSettings: (code: string, clientX: number, clientY: number) => void;
|
||||||
|
/** Öffnet das Einstellungs-Fenster (Darstellungsfarben, Projekt-Felder) —
|
||||||
|
* unter Tauri als natives Fenster, sonst als In-App-Dialog (App.openSettings).
|
||||||
|
* Als Zahnrad in den Panel-Kopfzeilen „Zeichnungsebenen"/„Ebenen". */
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
|
||||||
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
|
// Ressourcen-Handler (Bauteile / Schraffuren / Linienstile). Spiegelt
|
||||||
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
|
// ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von
|
||||||
@@ -351,6 +364,15 @@ export interface PanelHostValue {
|
|||||||
* linePoints/depth) — wirkt NUR auf die selektierte Linie.
|
* linePoints/depth) — wirkt NUR auf die selektierte Linie.
|
||||||
*/
|
*/
|
||||||
onSetSectionLinePatch: (patch: Partial<DrawingLevel>) => void;
|
onSetSectionLinePatch: (patch: Partial<DrawingLevel>) => void;
|
||||||
|
/**
|
||||||
|
* Ob die aktuell im Objekt-Info gezeigte Schnittlinie (`sectionLine`) den
|
||||||
|
* 3D-Live-Schnitt treibt — der `Wasm3DViewport` clippt dann vertikal entlang
|
||||||
|
* dieser Ebene (`sectionPlaneFromLevel`). Reiner Ansichts-Zustand (nicht im
|
||||||
|
* Dokument).
|
||||||
|
*/
|
||||||
|
section3dCutActive: boolean;
|
||||||
|
/** Bindet den 3D-Live-Schnitt an diese Schnittlinie / hebt die Bindung auf. */
|
||||||
|
onToggleSection3dCut: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die
|
* Löscht die Schnittlinie der gewählten Ebene (`linePoints` = undefined); die
|
||||||
* Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis).
|
* Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis).
|
||||||
@@ -368,6 +390,8 @@ export interface PanelHostValue {
|
|||||||
onSetStairRise: (totalRise: number) => void;
|
onSetStairRise: (totalRise: number) => void;
|
||||||
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
||||||
onSetStairUp: (up: boolean) => void;
|
onSetStairUp: (up: boolean) => void;
|
||||||
|
/** Setzt die Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). */
|
||||||
|
onSetStairArrowStyle: (style: "line" | "filled") => void;
|
||||||
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
|
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
|
||||||
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
||||||
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
|
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
|
||||||
@@ -410,6 +434,8 @@ export interface PanelHostValue {
|
|||||||
onAddContextObjects: (objs: ContextObject[]) => void;
|
onAddContextObjects: (objs: ContextObject[]) => void;
|
||||||
/** Entfernt ein Kontext-Objekt per ID. */
|
/** Entfernt ein Kontext-Objekt per ID. */
|
||||||
onRemoveContextObject: (id: string) => void;
|
onRemoveContextObject: (id: string) => void;
|
||||||
|
/** Entfernt mehrere Kontext-Objekte in einem Schritt (ganze Ebene/Gruppe). */
|
||||||
|
onRemoveContextObjects: (ids: string[]) => void;
|
||||||
/**
|
/**
|
||||||
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue
|
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN. Liefert die neue
|
||||||
* TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN).
|
* TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN).
|
||||||
@@ -421,7 +447,9 @@ export interface PanelHostValue {
|
|||||||
* den Bezug (nächster Import setzt ihn automatisch neu).
|
* den Bezug (nächster Import setzt ihn automatisch neu).
|
||||||
*/
|
*/
|
||||||
onSetGeoAnchor: (
|
onSetGeoAnchor: (
|
||||||
anchor: { lv95: { e: number; n: number }; model: { x: number; y: number }; label?: string } | undefined,
|
anchor:
|
||||||
|
| { lv95: { e: number; n: number }; height?: number; model: { x: number; y: number }; label?: string }
|
||||||
|
| undefined,
|
||||||
) => void;
|
) => void;
|
||||||
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
||||||
onImportDxf: () => void;
|
onImportDxf: () => void;
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ import type {
|
|||||||
// als weiterer Tab in der rechten unteren Gruppe.
|
// als weiterer Tab in der rechten unteren Gruppe.
|
||||||
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
|
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
|
||||||
// Tab in der rechten unteren Gruppe.
|
// Tab in der rechten unteren Gruppe.
|
||||||
export const LAYOUT_VERSION = 11;
|
// v12: Werkzeug-Palette ZURÜCK ins Default-Layout (Nutzer-Entscheid: Werkzeuge
|
||||||
|
// gehören in ein andockbares Panel statt ins Ribbon-Band) — wieder oben links,
|
||||||
|
// Attribute darunter (Vectorworks-Bild aus v6, vor dem Ribbon-UI-Phase-3-Bump).
|
||||||
|
export const LAYOUT_VERSION = 12;
|
||||||
|
|
||||||
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
|
||||||
const CURRENT_KEY = "cad.layout";
|
const CURRENT_KEY = "cad.layout";
|
||||||
@@ -64,11 +67,11 @@ 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-
|
// v12: Werkzeug-Palette ("tools") wieder im Default-Layout — oben links, mit
|
||||||
// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im
|
// der Attribute-Palette darunter (Vectorworks-Bild). Das Ribbon-Band bleibt als
|
||||||
// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar),
|
// Reiter erreichbar, ist aber nicht mehr der primäre Weg zu den Werkzeugen.
|
||||||
// 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 },
|
||||||
];
|
];
|
||||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||||
@@ -376,6 +379,26 @@ export function setActiveTab(
|
|||||||
return { ...layout, [dock]: { ...d, groups } };
|
return { ...layout, [dock]: { ...d, groups } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht ein Panel sichtbar, gleich wo es gerade steckt — NIE destruktiv (schliesst/
|
||||||
|
* versteckt nie etwas anderes). Für „Fenster > Panels" (native Menüleiste,
|
||||||
|
* appMenu.ts) und künftige ähnliche Einstiege:
|
||||||
|
* • schon schwebend → nach vorne holen (bringToFront).
|
||||||
|
* • angedockt, inaktiver Tab → als aktiven Tab seiner Gruppe setzen.
|
||||||
|
* • nirgends vorhanden → als neues schwebendes Fenster öffnen (Default-Rect).
|
||||||
|
*/
|
||||||
|
export function revealPanel(layout: LayoutState, panelId: string): LayoutState {
|
||||||
|
if (layout.floating.some((f) => f.panelId === panelId)) {
|
||||||
|
return bringToFront(layout, panelId);
|
||||||
|
}
|
||||||
|
for (const dock of ["left", "right"] as DockId[]) {
|
||||||
|
if (layout[dock].groups.some((g) => g.tabs.includes(panelId))) {
|
||||||
|
return setActiveTab(layout, dock, panelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moveToFloat(layout, panelId, { x: 120, y: 120, w: 320, h: 420 });
|
||||||
|
}
|
||||||
|
|
||||||
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
|
// `otherDock` wird (noch) nicht extern gebraucht, ist aber Teil der Aufräum-
|
||||||
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
|
// Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann.
|
||||||
export { otherDock };
|
export { otherDock };
|
||||||
|
|||||||
@@ -97,16 +97,18 @@ export interface LayoutState {
|
|||||||
/**
|
/**
|
||||||
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
|
* Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss).
|
||||||
* Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden — die
|
* Steuert, welche Elemente bezogen auf die aktive Auswahl gezeigt werden — die
|
||||||
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md), für Ebenen UND
|
* fünf DOSSIER-Modi (siehe docs/design/context-menu.md) plus eine eigene
|
||||||
* Zeichnungsebenen identisch:
|
* Ergänzung ("locked"), für Ebenen UND Zeichnungsebenen identisch:
|
||||||
* • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
|
* • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge
|
||||||
* wechselt zu „Ausgewählte".
|
* wechselt zu „Ausgewählte".
|
||||||
* • "all" — sichtbar nach per-Zeile-Flag (Standard).
|
* • "all" — sichtbar nach per-Zeile-Flag (Standard).
|
||||||
* • "active" — nur das aktive Element; andere stark gedimmt.
|
* • "active" — nur das aktive Element; andere stark gedimmt.
|
||||||
* • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
|
* • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten).
|
||||||
* • "grey_locked" — wie „grey", andere zusätzlich gesperrt.
|
* • "grey_locked" — wie „grey", andere zusätzlich gesperrt.
|
||||||
|
* • "locked" — aktives normal, andere sichtbare UNVERÄNDERT farbig,
|
||||||
|
* aber gesperrt (nicht anwählbar/bearbeitbar).
|
||||||
*/
|
*/
|
||||||
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked";
|
export type DisplayMode = "all_force" | "all" | "active" | "grey" | "grey_locked" | "locked";
|
||||||
|
|
||||||
// ── Panel-Definition & Kontext ─────────────────────────────────────────────
|
// ── Panel-Definition & Kontext ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -151,6 +153,14 @@ export interface PanelDef {
|
|||||||
* Kopfzeile anbieten soll. Default: kein Umschalter.
|
* Kopfzeile anbieten soll. Default: kein Umschalter.
|
||||||
*/
|
*/
|
||||||
hasDisplayMode?: boolean;
|
hasDisplayMode?: boolean;
|
||||||
|
/**
|
||||||
|
* Optionale Aktions-Elemente rechts in der EINEN Kopfzeile des PanelFrame
|
||||||
|
* (z. B. „+ Kategorie", Fenster-Ausklinken). So bündeln Panels ihre Aktionen
|
||||||
|
* in der gemeinsamen Frame-Kopfzeile statt einer zweiten eigenen Titelzeile
|
||||||
|
* (kein doppelter Titel mehr). Wird INNERHALB des PanelHostContext gerendert,
|
||||||
|
* darf also `usePanelHost()` nutzen.
|
||||||
|
*/
|
||||||
|
headerActions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Host-Context (App-Zustand + Handler für Panels) ────────────────────────
|
// ── Host-Context (App-Zustand + Handler für Panels) ────────────────────────
|
||||||
|
|||||||
@@ -213,6 +213,12 @@ export interface PlanSelection {
|
|||||||
* über allem — ein Klick genau darauf meint die Linie).
|
* über allem — ein Klick genau darauf meint die Linie).
|
||||||
*/
|
*/
|
||||||
sectionLineId: string | null;
|
sectionLineId: string | null;
|
||||||
|
/**
|
||||||
|
* ID des getroffenen Kontext-Objekts (importiertes Gebäude/Terrain), wenn der
|
||||||
|
* Klick sein Grundriss-Pick-Band traf und KEIN Element höherer Priorität
|
||||||
|
* (alle anderen gehen vor, analog `roofId`); sonst `null`.
|
||||||
|
*/
|
||||||
|
contextObjectId: string | null;
|
||||||
/**
|
/**
|
||||||
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
|
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
|
||||||
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
|
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
|
||||||
@@ -303,6 +309,12 @@ export interface PlanViewProps {
|
|||||||
selectedRoomIds?: string[];
|
selectedRoomIds?: string[];
|
||||||
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
|
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
|
||||||
selectedRoofIds?: string[];
|
selectedRoofIds?: string[];
|
||||||
|
/**
|
||||||
|
* Aktuell ausgewählte Kontext-Objekt-IDs (importiertes Gebäude/Terrain; von
|
||||||
|
* App). Die Ansicht markiert das Grundriss-Pick-Band JEDES dieser Objekte mit
|
||||||
|
* einer Akzentkontur.
|
||||||
|
*/
|
||||||
|
selectedContextObjectIds?: string[];
|
||||||
/**
|
/**
|
||||||
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
|
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
|
||||||
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
|
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
|
||||||
@@ -331,6 +343,9 @@ export interface PlanViewProps {
|
|||||||
onStampEdit?: (roomId: string) => void;
|
onStampEdit?: (roomId: string) => void;
|
||||||
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
|
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
|
||||||
onEditDrawingText?: (drawingId: string) => void;
|
onEditDrawingText?: (drawingId: string) => void;
|
||||||
|
/** Doppelklick auf eine Schnittlinie: in deren 2D-Schnittansicht springen
|
||||||
|
* (Zeichnungsebene aktivieren). */
|
||||||
|
onOpenSectionLevel?: (levelId: string) => void;
|
||||||
/**
|
/**
|
||||||
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
|
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
|
||||||
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
|
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
|
||||||
@@ -447,6 +462,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
selectedStairIds,
|
selectedStairIds,
|
||||||
selectedRoomIds,
|
selectedRoomIds,
|
||||||
selectedRoofIds,
|
selectedRoofIds,
|
||||||
|
selectedContextObjectIds,
|
||||||
selectedSectionLineIds,
|
selectedSectionLineIds,
|
||||||
selectedExtrudedSolidIds,
|
selectedExtrudedSolidIds,
|
||||||
selectedColumnIds,
|
selectedColumnIds,
|
||||||
@@ -454,6 +470,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
onStampMove,
|
onStampMove,
|
||||||
onStampEdit,
|
onStampEdit,
|
||||||
onEditDrawingText,
|
onEditDrawingText,
|
||||||
|
onOpenSectionLevel,
|
||||||
activeTool,
|
activeTool,
|
||||||
toolInputActive,
|
toolInputActive,
|
||||||
toolHandlers,
|
toolHandlers,
|
||||||
@@ -537,9 +554,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
|
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
|
||||||
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
|
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
|
||||||
const wantGpu = wantGl || wantWasm;
|
const wantGpu = wantGl || wantWasm;
|
||||||
|
// Georeferenzierte Hintergrund-Rasterbilder (Luftbild) vorhanden? Der GPU-
|
||||||
|
// Canvas löscht das Blatt UNDURCHSICHTIG (clearColor …,1) und würde ein
|
||||||
|
// dahinterliegendes Bild verdecken. Daher bei aktivem Luftbild bewusst den
|
||||||
|
// vollen SVG-Pfad nutzen — dort liegt das Bild als eigene Ebene ZWISCHEN
|
||||||
|
// Blatt-Hintergrund und Geometrie (sichtbar, ohne den GL-Renderer zu ändern).
|
||||||
|
const hasBgImages = (plan.backgroundImages?.length ?? 0) > 0;
|
||||||
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
|
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
|
||||||
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
|
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
|
||||||
const useGpuRenderer = wantGpu && glReady;
|
const useGpuRenderer = wantGpu && glReady && !hasBgImages;
|
||||||
|
|
||||||
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
|
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
|
||||||
// breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim
|
// breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim
|
||||||
@@ -1060,6 +1083,22 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Getroffenes Kontext-Objekt (importiertes Gebäude/Terrain): sein unsichtbares
|
||||||
|
// Grundriss-Pick-Band (Bounding-Box) per Punkt-in-Polygon — macht es wie eine
|
||||||
|
// Wand irgendwo innerhalb seiner Fläche anklickbar, nicht nur exakt auf der
|
||||||
|
// dünnen Umrisslinie.
|
||||||
|
const pickContextObject = (clientX: number, clientY: number): string | null => {
|
||||||
|
const v = clientToView(clientX, clientY, view);
|
||||||
|
const m = viewToModel(v.x, v.y);
|
||||||
|
for (let i = plan.primitives.length - 1; i >= 0; i--) {
|
||||||
|
const p = plan.primitives[i];
|
||||||
|
if (p.kind === "polygon" && p.contextObjectId && pointInPolygon(m, p.pts)) {
|
||||||
|
return p.contextObjectId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
|
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
|
||||||
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
|
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
|
||||||
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
|
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
|
||||||
@@ -1589,6 +1628,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
roofId: null,
|
roofId: null,
|
||||||
sectionLineId,
|
sectionLineId,
|
||||||
|
contextObjectId: null,
|
||||||
shift: e.shiftKey,
|
shift: e.shiftKey,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -1644,6 +1684,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId
|
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId
|
||||||
? null
|
? null
|
||||||
: pickRoof(e.clientX, e.clientY);
|
: pickRoof(e.clientX, e.clientY);
|
||||||
|
// Kontext-Objekt (Gebäude/Terrain) als ALLERLETZTE Priorität — seine
|
||||||
|
// Bbox-Pick-Fläche kann grosszügig überlappen, daher erst wenn wirklich
|
||||||
|
// nichts anderes getroffen wurde.
|
||||||
|
const contextObjectId =
|
||||||
|
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId || roofId
|
||||||
|
? null
|
||||||
|
: pickContextObject(e.clientX, e.clientY);
|
||||||
onSelect({
|
onSelect({
|
||||||
model: viewToModel(v.x, v.y),
|
model: viewToModel(v.x, v.y),
|
||||||
hitIndex,
|
hitIndex,
|
||||||
@@ -1657,6 +1704,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
columnId,
|
columnId,
|
||||||
roofId,
|
roofId,
|
||||||
sectionLineId: null,
|
sectionLineId: null,
|
||||||
|
contextObjectId,
|
||||||
shift: e.shiftKey,
|
shift: e.shiftKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1729,6 +1777,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Doppelklick auf eine Schnittlinie → in deren 2D-Schnittansicht springen.
|
||||||
|
if (onOpenSectionLevel) {
|
||||||
|
const sid = pickSectionLine(e.clientX, e.clientY);
|
||||||
|
if (sid) {
|
||||||
|
onOpenSectionLevel(sid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setView(defaultView);
|
setView(defaultView);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1856,6 +1912,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
);
|
);
|
||||||
}, [plan, selectedRoofIds]);
|
}, [plan, selectedRoofIds]);
|
||||||
|
|
||||||
|
// Hervorhebung der selektierten Kontext-Objekte (Gebäude/Terrain): das
|
||||||
|
// (unsichtbare) Bbox-Pick-Band mit contextObjectId als Akzentkontur
|
||||||
|
// überzeichnen — analog zur Dach-/Raum-Auswahl.
|
||||||
|
const highlightContextObjectPolys = useMemo(() => {
|
||||||
|
const ids =
|
||||||
|
selectedContextObjectIds && selectedContextObjectIds.length
|
||||||
|
? new Set(selectedContextObjectIds)
|
||||||
|
: null;
|
||||||
|
if (!ids) return [];
|
||||||
|
return plan.primitives.filter(
|
||||||
|
(p): p is Extract<Primitive, { kind: "polygon" }> =>
|
||||||
|
p.kind === "polygon" && p.contextObjectId != null && ids.has(p.contextObjectId),
|
||||||
|
);
|
||||||
|
}, [plan, selectedContextObjectIds]);
|
||||||
|
|
||||||
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
|
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
|
||||||
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
|
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
|
||||||
const highlightSectionLinePolys = useMemo(() => {
|
const highlightSectionLinePolys = useMemo(() => {
|
||||||
@@ -1988,8 +2059,9 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
darüber erreichen. An `wantGpu` (Absicht) montiert — NICHT an `useGpuRenderer`
|
darüber erreichen. An `wantGpu` (Absicht) montiert — NICHT an `useGpuRenderer`
|
||||||
(=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt →
|
(=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt →
|
||||||
glReady bleibt false → Deadlock). Bei WebGL-Fehler bleibt es leer hinten
|
glReady bleibt false → Deadlock). Bei WebGL-Fehler bleibt es leer hinten
|
||||||
und der volle SVG-Pfad übernimmt sichtbar. */}
|
und der volle SVG-Pfad übernimmt sichtbar. NICHT montiert, wenn ein
|
||||||
{wantGpu && (
|
Luftbild aktiv ist — dann rendert der SVG-Pfad alles inkl. Bild-Ebene. */}
|
||||||
|
{wantGpu && !hasBgImages && (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -2058,6 +2130,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
className={useGpuRenderer ? undefined : "plan-bg"}
|
className={useGpuRenderer ? undefined : "plan-bg"}
|
||||||
fill={useGpuRenderer ? "transparent" : undefined}
|
fill={useGpuRenderer ? "transparent" : undefined}
|
||||||
/>
|
/>
|
||||||
|
{/* Georeferenzierte Luftbilder ZWISCHEN Blatt-Hintergrund und Geometrie:
|
||||||
|
Modell-Meter × PX_PER_M mit negierter y-Achse (s. toScreen) → Bild-Norden
|
||||||
|
(max.y) oben bei y=−max.y·PX; das Bild zeichnet natürlich von oben (kein
|
||||||
|
Flip). Nur im SVG-Pfad relevant (bei Luftbild ist GPU deaktiviert). */}
|
||||||
|
{hasBgImages && plan.backgroundImages!.map((img) => {
|
||||||
|
const w = (img.max.x - img.min.x) * PX_PER_M;
|
||||||
|
const h = (img.max.y - img.min.y) * PX_PER_M;
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
return (
|
||||||
|
<image
|
||||||
|
key={img.id}
|
||||||
|
href={img.src}
|
||||||
|
x={img.min.x * PX_PER_M}
|
||||||
|
y={-img.max.y * PX_PER_M}
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
opacity={img.opacity}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug
|
{/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug
|
||||||
gezeichnet → gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */}
|
gezeichnet → gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */}
|
||||||
{primitiveEls}
|
{primitiveEls}
|
||||||
@@ -2114,6 +2208,18 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Auswahl-Hervorhebung der gewählten Kontext-Objekte (Bbox-Pick-Band). */}
|
||||||
|
{highlightContextObjectPolys.map((poly, i) => (
|
||||||
|
<polygon
|
||||||
|
key={`selctx-${i}`}
|
||||||
|
className="plan-selected"
|
||||||
|
points={poly.pts
|
||||||
|
.map(toScreen)
|
||||||
|
.map((s) => `${s.x},${s.y}`)
|
||||||
|
.join(" ")}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
|
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
|
||||||
{highlightSectionLinePolys.map((poly, i) => (
|
{highlightSectionLinePolys.map((poly, i) => (
|
||||||
<polygon
|
<polygon
|
||||||
@@ -2332,8 +2438,16 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
hudFields={hudFields ?? null}
|
hudFields={hudFields ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</svg>
|
||||||
{/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung
|
{/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung
|
||||||
und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt. */}
|
und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt.
|
||||||
|
BEWUSST ein eigenes Sibling-SVG (nicht mehr Kind von `svgRef`): beim
|
||||||
|
imperativen GL-Pan (`applySvgTransform`, s.o.) bekommt NUR `svgRef`
|
||||||
|
während der Geste einen CSS-transform verpasst — läge das Lineal
|
||||||
|
darin, würde es sichtbar mit dem Inhalt mitwandern statt am Rand
|
||||||
|
fixiert zu bleiben. Als eigenständiges Element bleibt es während der
|
||||||
|
Geste stehen (aktualisiert erst beim Loslassen, wenn `view` neu
|
||||||
|
gesetzt wird) — exakt das gewünschte „statische" Verhalten. */}
|
||||||
{showRulers && (() => {
|
{showRulers && (() => {
|
||||||
const m = measure(view);
|
const m = measure(view);
|
||||||
if (!m.rect) return null;
|
if (!m.rect) return null;
|
||||||
@@ -2345,15 +2459,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
|||||||
h: (m.rect.height || 1) / scale,
|
h: (m.rect.height || 1) / scale,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<PlanRulers
|
<svg
|
||||||
vb={vb}
|
className="plan-ruler-overlay"
|
||||||
vbPerPx={1 / scale}
|
viewBox={`${vb.x0} ${vb.y0} ${vb.w} ${vb.h}`}
|
||||||
pxPerMeter={scale * PX_PER_M}
|
preserveAspectRatio="none"
|
||||||
cursor={rulerCursor}
|
style={{
|
||||||
/>
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlanRulers
|
||||||
|
vb={vb}
|
||||||
|
vbPerPx={1 / scale}
|
||||||
|
pxPerMeter={scale * PX_PER_M}
|
||||||
|
cursor={rulerCursor}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2385,7 +2512,7 @@ function DraftOverlay({
|
|||||||
} | null;
|
} | null;
|
||||||
}) {
|
}) {
|
||||||
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
|
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
|
||||||
const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar)
|
const markSize = 4.75 * vbPerPx; // halbe Kantenlänge des Snap-Markers (klein + fein)
|
||||||
return (
|
return (
|
||||||
<g className="tool-overlay" pointerEvents="none">
|
<g className="tool-overlay" pointerEvents="none">
|
||||||
{/* Vorschau-Formen. */}
|
{/* Vorschau-Formen. */}
|
||||||
@@ -3271,8 +3398,29 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
|
|||||||
return runs;
|
return runs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opazität gedimmter („grau") Primitive im Darstellungsmodus „andere grau". */
|
/**
|
||||||
const GREYED_OPACITY = 0.3;
|
* Fester Grauton gedimmter Primitive im Darstellungsmodus „andere grau" —
|
||||||
|
* ECHTE Umfärbung (nicht nur Deckkraft), da dünne Haarlinien auf dem hellen
|
||||||
|
* Zeichenblatt (--sheet: #f0f0f0) bei reiner Transparenz kaum erkennbar
|
||||||
|
* gedimmt wirken. Gleicher Ton in allen drei Render-Pfaden (SVG/WebGL/nativ,
|
||||||
|
* siehe glPlanCompile.ts `GREY_RGB` und toRenderScene.ts `GREY_RGB`).
|
||||||
|
*/
|
||||||
|
const GREYED_COLOR = "#8c8c8c";
|
||||||
|
|
||||||
|
/** Ersetzt Fill-/Stroke-/Farbfelder (inkl. Schraffur) eines Primitivs durch den festen Grauton. */
|
||||||
|
function greyColor(c: string | undefined): string | undefined {
|
||||||
|
return !c || c === "none" || c === "transparent" ? c : GREYED_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flache Kopie eines Primitivs mit allen Farbfeldern auf `GREYED_COLOR` umgefärbt. */
|
||||||
|
function greyedCopy<T extends Primitive>(p: T): T {
|
||||||
|
const q: any = { ...p };
|
||||||
|
if ("fill" in q) q.fill = greyColor(q.fill);
|
||||||
|
if ("stroke" in q) q.stroke = greyColor(q.stroke);
|
||||||
|
if ("color" in q) q.color = greyColor(q.color);
|
||||||
|
if ("hatch" in q && q.hatch) q.hatch = { ...q.hatch, color: greyColor(q.hatch.color) };
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
/** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */
|
/** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */
|
||||||
function DrawingRunShape({
|
function DrawingRunShape({
|
||||||
@@ -3315,12 +3463,13 @@ function DrawingRunShape({
|
|||||||
// Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente
|
// Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente
|
||||||
// als Dots erscheinen (Punktlinie / Strich-Punkt).
|
// als Dots erscheinen (Punktlinie / Strich-Punkt).
|
||||||
const cap = dashHasDot(run.dash) ? ("round" as const) : undefined;
|
const cap = dashHasDot(run.dash) ? ("round" as const) : undefined;
|
||||||
|
const stroke = run.greyed ? greyColor(run.color) : run.color;
|
||||||
const shape = run.closed ? (
|
const shape = run.closed ? (
|
||||||
<polygon
|
<polygon
|
||||||
points={pts}
|
points={pts}
|
||||||
className={run.cls}
|
className={run.cls}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={run.color}
|
stroke={stroke}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashStr}
|
strokeDasharray={dashStr}
|
||||||
strokeLinecap={cap}
|
strokeLinecap={cap}
|
||||||
@@ -3331,14 +3480,14 @@ function DrawingRunShape({
|
|||||||
points={pts}
|
points={pts}
|
||||||
className={run.cls}
|
className={run.cls}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={run.color}
|
stroke={stroke}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeDasharray={dashStr}
|
strokeDasharray={dashStr}
|
||||||
strokeLinecap={cap}
|
strokeLinecap={cap}
|
||||||
vectorEffect={vfx}
|
vectorEffect={vfx}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return run.greyed ? <g opacity={GREYED_OPACITY}>{shape}</g> : shape;
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrimitiveShape({
|
function PrimitiveShape({
|
||||||
@@ -3354,12 +3503,9 @@ function PrimitiveShape({
|
|||||||
hairline: boolean;
|
hairline: boolean;
|
||||||
paperScale: number | null;
|
paperScale: number | null;
|
||||||
}) {
|
}) {
|
||||||
// Gedimmte Elemente bekommen eine niedrige Gruppen-Opazität, sodass Strich
|
// Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung
|
||||||
// UND Füllung gleichmäßig zurücktreten (best-effort, ohne Farbumrechnung).
|
// UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke.
|
||||||
const shape = renderPrimitive(p, index, toScreen, hairline, paperScale);
|
const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale);
|
||||||
if (p.greyed) {
|
|
||||||
return <g opacity={GREYED_OPACITY}>{shape}</g>;
|
|
||||||
}
|
|
||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3887,17 +4033,17 @@ function PlanRulers({
|
|||||||
<>
|
<>
|
||||||
<rect
|
<rect
|
||||||
className="plan-ruler-cursor"
|
className="plan-ruler-cursor"
|
||||||
x={cursor.x * PX_PER_M - 1.5 * vbPerPx}
|
x={cursor.x * PX_PER_M - 1 * vbPerPx}
|
||||||
y={y0}
|
y={y0}
|
||||||
width={3 * vbPerPx}
|
width={2 * vbPerPx}
|
||||||
height={TH}
|
height={TH}
|
||||||
/>
|
/>
|
||||||
<rect
|
<rect
|
||||||
className="plan-ruler-cursor"
|
className="plan-ruler-cursor"
|
||||||
x={x0}
|
x={x0}
|
||||||
y={-cursor.y * PX_PER_M - 1.5 * vbPerPx}
|
y={-cursor.y * PX_PER_M - 1 * vbPerPx}
|
||||||
width={TH}
|
width={TH}
|
||||||
height={3 * vbPerPx}
|
height={2 * vbPerPx}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// Kontext-Schicht im Grundriss (generatePlan → addContextContours):
|
||||||
|
// • ImportedMesh (Gebäude) -> Sockelkanten-Fussabdruck (context-line).
|
||||||
|
// • TerrainMesh -> nur 2D-Bounding-Box-Umriss (4 Linien).
|
||||||
|
// • Nutzer-Report: importierte Gebäude waren im Plan UNSICHTBAR (nur 3D).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generatePlan } from "./generatePlan";
|
||||||
|
import type { Project } from "../model/types";
|
||||||
|
|
||||||
|
function projectWith(context: Project["context"]): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context,
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = new Set<string>();
|
||||||
|
|
||||||
|
function contextLines(p: Project) {
|
||||||
|
return generatePlan(p, "eg", visible).primitives.filter(
|
||||||
|
(pr) => pr.kind === "line" && (pr as { cls?: string }).cls === "context-line",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generatePlan — Kontext-Fussabdruck (ImportedMesh)", () => {
|
||||||
|
it("zeichnet die Sockelkanten eines importierten Gebäudes als context-line", () => {
|
||||||
|
// Ein simpler Quader-„Bau": Grundfläche 0..2 × 0..3 bei z=0 (Sockel), Dach
|
||||||
|
// bei z=5. Zwei Sockel-Dreiecke bilden das Boden-Rechteck.
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "B1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Haus",
|
||||||
|
positions: [
|
||||||
|
0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0, // 0..3: Boden (z=0)
|
||||||
|
1, 1, 5, // 4: Dachspitze (z=5, über der Toleranz)
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
0, 1, 2, 0, 2, 3, // Boden (Sockel)
|
||||||
|
0, 1, 4, // Dachdreieck (nur eine Kante 0-1 am Sockel, 4 ist hoch)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const lines = contextLines(p);
|
||||||
|
// Sockelkanten: Rechteck-Umriss 0-1,1-2,2-3,3-0 + Diagonale 0-2 = 5 eindeutige.
|
||||||
|
// Die hohen Dach-Kanten (0-4, 1-4) fallen weg (z=5 > Toleranz).
|
||||||
|
expect(lines.length).toBe(5);
|
||||||
|
// Keine Linie berührt die Dachspitze (1,1) auf z-Höhe — aber XY (1,1) könnte
|
||||||
|
// von der Diagonale getroffen werden; hier prüfen wir, dass keine Kante zu
|
||||||
|
// einem Punkt führt, dessen Original-z über der Toleranz lag: alle Endpunkte
|
||||||
|
// liegen im Boden-Rechteck [0..2]×[0..3].
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.kind !== "line") continue;
|
||||||
|
for (const pt of [l.a, l.b]) {
|
||||||
|
expect(pt.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(pt.x).toBeLessThanOrEqual(2);
|
||||||
|
expect(pt.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(pt.y).toBeLessThanOrEqual(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("entdoppelt geteilte Kanten (kein doppeltes Zeichnen)", () => {
|
||||||
|
// Zwei Sockel-Dreiecke teilen sich die Kante 0-2 -> darf nur EINMAL erscheinen.
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "B1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Haus",
|
||||||
|
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
|
||||||
|
indices: [0, 1, 2, 0, 2, 3],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const lines = contextLines(p);
|
||||||
|
// 4 Rand + 1 geteilte Diagonale = 5 (nicht 6).
|
||||||
|
expect(lines.length).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatePlan — Kontext-Objekte anklickbar (Pick-Region)", () => {
|
||||||
|
it("importedMesh bekommt ein unsichtbares Bbox-Pick-Band mit contextObjectId", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "B1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Haus",
|
||||||
|
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
|
||||||
|
indices: [0, 1, 2, 0, 2, 3],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const plan = generatePlan(p, "eg", visible);
|
||||||
|
const pick = plan.primitives.find(
|
||||||
|
(pr) => pr.kind === "polygon" && pr.contextObjectId === "B1",
|
||||||
|
);
|
||||||
|
expect(pick).toBeDefined();
|
||||||
|
if (pick?.kind === "polygon") {
|
||||||
|
expect(pick.fill).toBe("none");
|
||||||
|
expect(pick.stroke).toBe("none");
|
||||||
|
// Bbox der Grundfläche: [0..2]×[0..3].
|
||||||
|
const xs = pick.pts.map((p) => p.x);
|
||||||
|
const ys = pick.pts.map((p) => p.y);
|
||||||
|
expect(Math.min(...xs)).toBe(0);
|
||||||
|
expect(Math.max(...xs)).toBe(2);
|
||||||
|
expect(Math.min(...ys)).toBe(0);
|
||||||
|
expect(Math.max(...ys)).toBe(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terrainMesh bekommt ebenfalls ein Pick-Band mit contextObjectId", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "T1",
|
||||||
|
type: "terrainMesh",
|
||||||
|
name: "Gelände",
|
||||||
|
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const plan = generatePlan(p, "eg", visible);
|
||||||
|
const pick = plan.primitives.find(
|
||||||
|
(pr) => pr.kind === "polygon" && pr.contextObjectId === "T1",
|
||||||
|
);
|
||||||
|
expect(pick).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatePlan — Kontext-Umriss (TerrainMesh)", () => {
|
||||||
|
it("zeichnet nur die 2D-Bounding-Box (4 Linien), nicht das ganze TIN", () => {
|
||||||
|
// 3 Vertices spannen ein Dreieck; die Bbox-Ausdehnung ist [0..10]×[0..8].
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "T1",
|
||||||
|
type: "terrainMesh",
|
||||||
|
name: "Gelände",
|
||||||
|
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const lines = contextLines(p);
|
||||||
|
expect(lines.length).toBe(4);
|
||||||
|
const xs = lines.flatMap((l) => (l.kind === "line" ? [l.a.x, l.b.x] : []));
|
||||||
|
const ys = lines.flatMap((l) => (l.kind === "line" ? [l.a.y, l.b.y] : []));
|
||||||
|
expect(Math.min(...xs)).toBe(0);
|
||||||
|
expect(Math.max(...xs)).toBe(10);
|
||||||
|
expect(Math.min(...ys)).toBe(0);
|
||||||
|
expect(Math.max(...ys)).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatePlan — Luftbild (ContextImage)", () => {
|
||||||
|
it("gibt georeferenzierte Bilder als backgroundImages aus (nicht als Primitive)", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "IMG1",
|
||||||
|
type: "image",
|
||||||
|
name: "Luftbild",
|
||||||
|
layer: "Luftbild",
|
||||||
|
src: "data:image/jpeg;base64,AAAA",
|
||||||
|
min: { x: -100, y: -80 },
|
||||||
|
max: { x: 100, y: 80 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const plan = generatePlan(p, "eg", visible);
|
||||||
|
expect(plan.backgroundImages).toHaveLength(1);
|
||||||
|
expect(plan.backgroundImages![0]).toMatchObject({
|
||||||
|
id: "IMG1",
|
||||||
|
src: "data:image/jpeg;base64,AAAA",
|
||||||
|
min: { x: -100, y: -80 },
|
||||||
|
max: { x: 100, y: 80 },
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
// Kein Bild-Primitiv in den Vektor-Primitiven (DXF/PDF lassen es aus).
|
||||||
|
expect(plan.primitives.some((pr) => (pr as { kind?: string }).kind === "image")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("im Mono-Modus wird das Luftbild ausgeblendet (reiner S/W-Plan)", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{
|
||||||
|
id: "IMG1",
|
||||||
|
type: "image",
|
||||||
|
name: "Luftbild",
|
||||||
|
src: "data:image/jpeg;base64,AAAA",
|
||||||
|
min: { x: 0, y: 0 },
|
||||||
|
max: { x: 10, y: 10 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const plan = generatePlan(p, "eg", visible, undefined, "mittel", false, true);
|
||||||
|
expect(plan.backgroundImages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,24 @@ describe("generatePlan — Schnittführungs-Symbol", () => {
|
|||||||
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
|
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("zeichnet NICHTS für eine unsichtbare Schnittebene (visible=false)", () => {
|
||||||
|
const p = baseProject([
|
||||||
|
floor,
|
||||||
|
{
|
||||||
|
id: "s-hidden",
|
||||||
|
name: "Schnitt C",
|
||||||
|
kind: "section",
|
||||||
|
visible: false,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 0, y: 2 }, { x: 6, y: 2 }],
|
||||||
|
directionSign: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const prims = generatePlan(p, "eg", visible).primitives;
|
||||||
|
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId === "s-hidden")).toBe(false);
|
||||||
|
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("behandelt Ansichtsebenen (elevation) gleich", () => {
|
it("behandelt Ansichtsebenen (elevation) gleich", () => {
|
||||||
const p = baseProject([
|
const p = baseProject([
|
||||||
floor,
|
floor,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Treppen-Pfeil-Darstellung (generatePlan → addStairSymbol): Default "line"
|
||||||
|
* zeichnet den Auf-/Abpfeil als zwei offene Linien (cls "stair-arrow");
|
||||||
|
* `arrowStyle:"filled"` ersetzt sie durch EIN gefülltes Dreieck-Polygon
|
||||||
|
* (SIA-Vollpfeil).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generatePlan } from "./generatePlan";
|
||||||
|
import type { DrawingLevel, Project, Stair } from "../model/types";
|
||||||
|
|
||||||
|
const visible = new Set(["40"]);
|
||||||
|
|
||||||
|
const floor: DrawingLevel = {
|
||||||
|
id: "eg",
|
||||||
|
name: "EG",
|
||||||
|
kind: "floor",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
floorHeight: 2.6,
|
||||||
|
cutHeight: 1.0,
|
||||||
|
baseElevation: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function baseStair(extra: Partial<Stair> = {}): Stair {
|
||||||
|
return {
|
||||||
|
id: "st-1",
|
||||||
|
type: "stair",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "40",
|
||||||
|
shape: "gerade",
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
dir: { x: 1, y: 0 },
|
||||||
|
runLength: 4,
|
||||||
|
width: 1.2,
|
||||||
|
stepCount: 16,
|
||||||
|
totalRise: 2.6,
|
||||||
|
...extra,
|
||||||
|
} as Stair;
|
||||||
|
}
|
||||||
|
|
||||||
|
function project(stair: Stair): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [floor],
|
||||||
|
layers: [{ code: "40", name: "Treppen", color: "#333", lw: 0.18, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [stair],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generatePlan — Treppen-Pfeil", () => {
|
||||||
|
it("Default: offener Winkelpfeil = zwei stair-arrow-Linien, kein gefülltes Polygon", () => {
|
||||||
|
const prims = generatePlan(project(baseStair()), "eg", visible).primitives;
|
||||||
|
const arrowLines = prims.filter((p) => p.kind === "line" && p.cls === "stair-arrow");
|
||||||
|
expect(arrowLines.length).toBe(2);
|
||||||
|
// Keine gefüllte Pfeil-Fläche (Tritt-Polygone tragen fill:"none").
|
||||||
|
const filled = prims.filter(
|
||||||
|
(p) => p.kind === "polygon" && p.stairId === "st-1" && p.fill !== "none",
|
||||||
|
);
|
||||||
|
expect(filled.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filled: ein gefülltes Dreieck-Polygon, keine stair-arrow-Linien", () => {
|
||||||
|
const prims = generatePlan(project(baseStair({ arrowStyle: "filled" })), "eg", visible)
|
||||||
|
.primitives;
|
||||||
|
expect(prims.some((p) => p.kind === "line" && p.cls === "stair-arrow")).toBe(false);
|
||||||
|
const filled = prims.filter(
|
||||||
|
(p) => p.kind === "polygon" && p.stairId === "st-1" && p.fill !== "none",
|
||||||
|
);
|
||||||
|
expect(filled.length).toBe(1);
|
||||||
|
expect(filled[0].kind === "polygon" && filled[0].pts.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
Ceiling,
|
Ceiling,
|
||||||
Column,
|
Column,
|
||||||
Component,
|
Component,
|
||||||
|
ContextObject,
|
||||||
Drawing2D,
|
Drawing2D,
|
||||||
ExtrudedSolid,
|
ExtrudedSolid,
|
||||||
HatchPattern,
|
HatchPattern,
|
||||||
@@ -191,19 +192,22 @@ export interface HatchRender {
|
|||||||
/**
|
/**
|
||||||
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
|
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
|
||||||
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
|
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
|
||||||
* `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für
|
* `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked")
|
||||||
* jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten).
|
* verbietet die Anwahl (siehe App.tsx onPlanSelect). Wird kein Resolver
|
||||||
|
* übergeben, gilt für jede sichtbare Kategorie {render:true, greyed:false,
|
||||||
|
* locked:false} (bisheriges Verhalten).
|
||||||
*/
|
*/
|
||||||
export interface CategoryDisplay {
|
export interface CategoryDisplay {
|
||||||
render: boolean;
|
render: boolean;
|
||||||
greyed: boolean;
|
greyed: boolean;
|
||||||
|
locked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
|
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
|
||||||
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
|
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
|
||||||
|
|
||||||
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
|
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
|
||||||
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false });
|
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false, locked: false });
|
||||||
|
|
||||||
export type Primitive =
|
export type Primitive =
|
||||||
| {
|
| {
|
||||||
@@ -257,6 +261,12 @@ export type Primitive =
|
|||||||
* Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
|
* Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
|
||||||
*/
|
*/
|
||||||
extrudedSolidId?: string;
|
extrudedSolidId?: string;
|
||||||
|
/**
|
||||||
|
* ID des Kontext-Objekts (importiertes Gebäude/Terrain, `project.context`),
|
||||||
|
* falls dieses (unsichtbare) Pick-Band dessen Grundriss-Bounding-Box
|
||||||
|
* umhüllt (Links-Klick-Auswahl — analog `sectionLineId`, s. u.).
|
||||||
|
*/
|
||||||
|
contextObjectId?: string;
|
||||||
/**
|
/**
|
||||||
* ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
|
* ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
|
||||||
* (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
|
* (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
|
||||||
@@ -628,9 +638,23 @@ export function resolveCeilingSectionStyle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein georeferenziertes Hintergrund-Rasterbild (Luftbild) im Grundriss —
|
||||||
|
* Modell-Meter-Rechteck [min..max], das die interaktive Ansicht UNTER der
|
||||||
|
* Geometrie zeichnet (Kontext-Schicht, `ContextImage`). NICHT Teil der
|
||||||
|
* Vektor-`primitives` (DXF/PDF-Export lassen es aus). */
|
||||||
|
export interface PlanImage {
|
||||||
|
id: string;
|
||||||
|
src: string;
|
||||||
|
min: Vec2;
|
||||||
|
max: Vec2;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Plan {
|
export interface Plan {
|
||||||
primitives: Primitive[];
|
primitives: Primitive[];
|
||||||
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
/** Hintergrund-Rasterbilder (Luftbild) — nur für die interaktive Ansicht. */
|
||||||
|
backgroundImages?: PlanImage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -908,9 +932,18 @@ export function generatePlan(
|
|||||||
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
|
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
|
||||||
if (mono) toMono(primitives);
|
if (mono) toMono(primitives);
|
||||||
|
|
||||||
|
// Georeferenzierte Luftbilder als Hintergrund-Ebenen (nur interaktive Ansicht,
|
||||||
|
// nicht Teil der Vektor-Primitive) — im Mono-Modus ausgeblendet (reiner S/W-Plan).
|
||||||
|
const backgroundImages: PlanImage[] = mono
|
||||||
|
? []
|
||||||
|
: (project.context ?? [])
|
||||||
|
.filter((o): o is Extract<ContextObject, { type: "image" }> => o.type === "image")
|
||||||
|
.map((o) => ({ id: o.id, src: o.src, min: o.min, max: o.max, opacity: o.opacity ?? 1 }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primitives,
|
primitives,
|
||||||
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs),
|
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs),
|
||||||
|
backgroundImages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,34 +1092,164 @@ const CONTEXT_LINE_COLOR = "#9aa3ad";
|
|||||||
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
|
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
|
||||||
const CONTEXT_LINE_MM = 0.1;
|
const CONTEXT_LINE_MM = 0.1;
|
||||||
|
|
||||||
|
/** Höhen-Toleranz (Meter): Dreieckskanten, deren beide Endpunkte höchstens so
|
||||||
|
* weit über der Objekt-Unterkante (minZ) liegen, gelten als „Sockelkante" und
|
||||||
|
* bilden den Grundriss-Fussabdruck eines importierten Gebäudes. */
|
||||||
|
const FOOTPRINT_Z_TOL = 0.6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als
|
* Zeichnet die Kontext-Schicht `project.context` in den Grundriss:
|
||||||
* dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert
|
* • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert).
|
||||||
* (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT
|
* • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe
|
||||||
* (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
|
* der Objekt-Unterkante (Sockel), auf die XY-Ebene projiziert und über
|
||||||
|
* quantisierte Endpunkte entdoppelt — so erscheint das importierte Haus als
|
||||||
|
* erkennbarer Umriss im Plan (Nutzer-Wunsch: „ich sehe es sonst nicht"),
|
||||||
|
* ohne das gesamte Dach-Drahtgitter dicht darüberzulegen.
|
||||||
|
* • TerrainMesh → nur der 2D-Umriss (Bounding-Box), da ein volles TIN-Gitter
|
||||||
|
* im Plan unbrauchbar dicht wäre.
|
||||||
|
* Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
|
||||||
*/
|
*/
|
||||||
function addContextContours(out: Primitive[], project: Project): void {
|
function addContextContours(out: Primitive[], project: Project): void {
|
||||||
const context = project.context ?? [];
|
const context = project.context ?? [];
|
||||||
for (const obj of context) {
|
for (const obj of context) {
|
||||||
if (obj.type !== "contourSet") continue;
|
if (obj.type === "contourSet") {
|
||||||
for (const c of obj.contours) {
|
for (const c of obj.contours) {
|
||||||
const pts = c.pts;
|
const pts = c.pts;
|
||||||
if (pts.length < 2) continue;
|
if (pts.length < 2) continue;
|
||||||
const segEnd = c.closed ? pts.length : pts.length - 1;
|
const segEnd = c.closed ? pts.length : pts.length - 1;
|
||||||
for (let i = 0; i < segEnd; i++) {
|
for (let i = 0; i < segEnd; i++) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
a: pts[i],
|
a: pts[i],
|
||||||
b: pts[(i + 1) % pts.length],
|
b: pts[(i + 1) % pts.length],
|
||||||
cls: "context-line",
|
cls: "context-line",
|
||||||
weightMm: CONTEXT_LINE_MM,
|
weightMm: CONTEXT_LINE_MM,
|
||||||
color: CONTEXT_LINE_COLOR,
|
color: CONTEXT_LINE_COLOR,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (obj.type === "importedMesh") {
|
||||||
|
addMeshFootprint(out, obj.positions, obj.indices);
|
||||||
|
addContextPickRegion(out, obj.positions, obj.id);
|
||||||
|
} else if (obj.type === "terrainMesh") {
|
||||||
|
addMeshBboxOutline(out, obj.positions);
|
||||||
|
addContextPickRegion(out, obj.positions, obj.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichnet den Grundriss-Fussabdruck eines Meshes: Dreieckskanten nahe der
|
||||||
|
* Unterkante (minZ + {@link FOOTPRINT_Z_TOL}), auf XY projiziert, über
|
||||||
|
* quantisierte Endpunkt-Schlüssel entdoppelt. `positions` = flaches [x,y,z…]-
|
||||||
|
* Array (Modell-Meter, z = Höhe), `indices` = Dreiecks-Indizes.
|
||||||
|
*/
|
||||||
|
function addMeshFootprint(out: Primitive[], positions: number[], indices: number[]): void {
|
||||||
|
const vcount = Math.floor(positions.length / 3);
|
||||||
|
if (vcount < 3 || indices.length < 3) return;
|
||||||
|
let minZ = Infinity;
|
||||||
|
for (let i = 2; i < positions.length; i += 3) if (positions[i] < minZ) minZ = positions[i];
|
||||||
|
if (!Number.isFinite(minZ)) return;
|
||||||
|
const zLimit = minZ + FOOTPRINT_Z_TOL;
|
||||||
|
const q = (v: number) => Math.round(v * 100); // 1-cm-Quantisierung (Entdopplung)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const edge = (ia: number, ib: number) => {
|
||||||
|
if (ia >= vcount || ib >= vcount) return;
|
||||||
|
const za = positions[ia * 3 + 2];
|
||||||
|
const zb = positions[ib * 3 + 2];
|
||||||
|
if (za > zLimit || zb > zLimit) return; // nur Sockelkanten
|
||||||
|
const ax = positions[ia * 3], ay = positions[ia * 3 + 1];
|
||||||
|
const bx = positions[ib * 3], by = positions[ib * 3 + 1];
|
||||||
|
// Kanten-Schlüssel richtungsunabhängig (kleinerer Endpunkt zuerst).
|
||||||
|
const ka = `${q(ax)},${q(ay)}`;
|
||||||
|
const kb = `${q(bx)},${q(by)}`;
|
||||||
|
if (ka === kb) return;
|
||||||
|
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: { x: ax, y: ay },
|
||||||
|
b: { x: bx, y: by },
|
||||||
|
cls: "context-line",
|
||||||
|
weightMm: CONTEXT_LINE_MM,
|
||||||
|
color: CONTEXT_LINE_COLOR,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for (let i = 0; i + 2 < indices.length; i += 3) {
|
||||||
|
const a = indices[i], b = indices[i + 1], c = indices[i + 2];
|
||||||
|
edge(a, b);
|
||||||
|
edge(b, c);
|
||||||
|
edge(c, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet nur den achsenparallelen 2D-Umriss (Bounding-Box) eines Meshes —
|
||||||
|
* für Terrain, dessen volles TIN-Gitter im Plan zu dicht wäre. */
|
||||||
|
function addMeshBboxOutline(out: Primitive[], positions: number[]): void {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||||
|
const x = positions[i], y = positions[i + 1];
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minX)) return;
|
||||||
|
const corners = [
|
||||||
|
{ x: minX, y: minY },
|
||||||
|
{ x: maxX, y: minY },
|
||||||
|
{ x: maxX, y: maxY },
|
||||||
|
{ x: minX, y: maxY },
|
||||||
|
];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
out.push({
|
||||||
|
kind: "line",
|
||||||
|
a: corners[i],
|
||||||
|
b: corners[(i + 1) % 4],
|
||||||
|
cls: "context-line",
|
||||||
|
weightMm: CONTEXT_LINE_MM,
|
||||||
|
color: CONTEXT_LINE_COLOR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsichtbares Pick-Band (Grundriss-Bounding-Box) für ein Kontext-Objekt
|
||||||
|
* (importiertes Gebäude/Terrain) — klickbar per Punkt-in-Polygon in PlanView,
|
||||||
|
* trägt die `ContextObject.id` (analog `sectionLineId`, s. dort). Macht das
|
||||||
|
* Objekt IRGENDWO innerhalb seiner Grundfläche anklickbar (nicht nur exakt auf
|
||||||
|
* der dünnen Umriss-/Fussabdrucklinie) — wie bei Wänden, deren Poché-Fläche
|
||||||
|
* ebenfalls als Ganzes klickbar ist (Nutzer-Wunsch: „eigene Elemente wie Wände").
|
||||||
|
* `positions` = flaches [x,y,z…]-Array (Modell-Meter); die Bbox nutzt NUR x/y,
|
||||||
|
* unabhängig von der Höhe.
|
||||||
|
*/
|
||||||
|
function addContextPickRegion(out: Primitive[], positions: number[], id: string): void {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||||
|
const x = positions[i], y = positions[i + 1];
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minX)) return;
|
||||||
|
out.push({
|
||||||
|
kind: "polygon",
|
||||||
|
pts: [
|
||||||
|
{ x: minX, y: minY },
|
||||||
|
{ x: maxX, y: minY },
|
||||||
|
{ x: maxX, y: maxY },
|
||||||
|
{ x: minX, y: maxY },
|
||||||
|
],
|
||||||
|
fill: "none",
|
||||||
|
stroke: "none",
|
||||||
|
strokeWidthMm: 0,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
contextObjectId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
|
* Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
|
||||||
* Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place.
|
* Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place.
|
||||||
@@ -2736,6 +2899,10 @@ function sectionLabelTag(name: string): string {
|
|||||||
function addSectionLines(out: Primitive[], project: Project): void {
|
function addSectionLines(out: Primitive[], project: Project): void {
|
||||||
for (const level of project.drawingLevels) {
|
for (const level of project.drawingLevels) {
|
||||||
if (level.kind !== "section" && level.kind !== "elevation") continue;
|
if (level.kind !== "section" && level.kind !== "elevation") continue;
|
||||||
|
// Schnittebene ist ein-/ausblendbar wie andere Ebenen: eine unsichtbare
|
||||||
|
// Schnitt-/Ansichtsebene zeigt ihre Linie im Grundriss nicht (Nutzer-Vision
|
||||||
|
// „eigene Ebene Schnittebenen"). `visible` fehlt = sichtbar (Alt-Projekte).
|
||||||
|
if (level.visible === false) continue;
|
||||||
const line = level.linePoints;
|
const line = level.linePoints;
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const [p0, p1] = line;
|
const [p0, p1] = line;
|
||||||
@@ -3053,10 +3220,24 @@ function addStairSymbol(
|
|||||||
stairId,
|
stairId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Auf-/Abpfeil an der Spitze der Lauflinie.
|
// Auf-/Abpfeil an der Spitze der Lauflinie. "filled" = gefülltes Dreieck
|
||||||
|
// (SIA-Vollpfeil), sonst der offene Winkelpfeil (zwei Linien, Default).
|
||||||
const [h1, tip, h2] = geo.arrow.head;
|
const [h1, tip, h2] = geo.arrow.head;
|
||||||
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
if (stair.arrowStyle === "filled") {
|
||||||
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
out.push({
|
||||||
|
kind: "polygon",
|
||||||
|
pts: [h1, tip, h2],
|
||||||
|
fill: stroke,
|
||||||
|
stroke,
|
||||||
|
strokeWidthMm: lwMm,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
greyed,
|
||||||
|
stairId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||||
|
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||||
|
}
|
||||||
|
|
||||||
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
|
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
|
||||||
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
|
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
|
||||||
@@ -3121,10 +3302,20 @@ function computeBounds(
|
|||||||
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
|
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
|
||||||
} else if (g.shape === "text") acc(g.at);
|
} else if (g.shape === "text") acc(g.at);
|
||||||
}
|
}
|
||||||
// Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt.
|
// GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild)
|
||||||
|
// mit einrahmen, damit „Einpassen"/die Standard-Ansicht sie zeigt — sonst
|
||||||
|
// sässe die Ansicht bei einem reinen Kontext-Import (keine Wände) leer am
|
||||||
|
// Ursprung und der Import wäre „unsichtbar" (Nutzer-Report).
|
||||||
for (const obj of project.context ?? []) {
|
for (const obj of project.context ?? []) {
|
||||||
if (obj.type !== "contourSet") continue;
|
if (obj.type === "contourSet") {
|
||||||
for (const c of obj.contours) for (const p of c.pts) acc(p);
|
for (const c of obj.contours) for (const p of c.pts) acc(p);
|
||||||
|
} else if (obj.type === "image") {
|
||||||
|
acc(obj.min);
|
||||||
|
acc(obj.max);
|
||||||
|
} else {
|
||||||
|
const pos = obj.positions;
|
||||||
|
for (let i = 0; i + 1 < pos.length; i += 3) acc({ x: pos[i], y: pos[i + 1] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
||||||
return { minX, minY, maxX, maxY };
|
return { minX, minY, maxX, maxY };
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ function parseColor(hex: string | undefined): Rgba | null {
|
|||||||
return [r / 255, g / 255, b / 255, a];
|
return [r / 255, g / 255, b / 255, a];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fester Grauton gedimmter (greyed) Primitive — ECHTE Umfärbung statt
|
||||||
|
* Deckkraft (dünne Haarlinien auf dem hellen Zeichenblatt wären bei reiner
|
||||||
|
* Transparenz kaum erkennbar gedimmt). Gleicher Ton wie GREYED_COLOR in
|
||||||
|
* PlanView.tsx / GREY_RGB in toRenderScene.ts (#8c8c8c ≈ 0.549).
|
||||||
|
*/
|
||||||
|
const GREY_RGB = 0.549;
|
||||||
|
|
||||||
|
/** Färbt auf den festen Grauton um, wenn das Primitiv im Darstellungsmodus „grau" ist. */
|
||||||
|
function dim(color: Rgba, greyed: boolean | undefined): Rgba {
|
||||||
|
return greyed ? [GREY_RGB, GREY_RGB, GREY_RGB, color[3]] : color;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
|
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
|
||||||
@@ -387,7 +399,7 @@ export function compilePrimitivesToGpu(
|
|||||||
track(pt.x, pt.y);
|
track(pt.x, pt.y);
|
||||||
}
|
}
|
||||||
for (const t of tris) fillIdx.push(base + t);
|
for (const t of tris) fillIdx.push(base + t);
|
||||||
addFillBatch(tris.length, fill);
|
addFillBatch(tris.length, dim(fill, prim.greyed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
|
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
|
||||||
@@ -414,7 +426,7 @@ export function compilePrimitivesToGpu(
|
|||||||
prim.hatch.pattern !== 'none' &&
|
prim.hatch.pattern !== 'none' &&
|
||||||
prim.hatch.pattern !== 'solid'
|
prim.hatch.pattern !== 'solid'
|
||||||
) {
|
) {
|
||||||
const hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1];
|
const hatchColor = dim(parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
|
||||||
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
|
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
|
||||||
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
|
||||||
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm, false);
|
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm, false);
|
||||||
@@ -424,10 +436,10 @@ export function compilePrimitivesToGpu(
|
|||||||
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
|
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
|
||||||
const stroke = parseColor(prim.stroke);
|
const stroke = parseColor(prim.stroke);
|
||||||
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
|
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
|
||||||
strokePolygonOutline(prim.pts, stroke, prim.strokeWidthMm, prim.noStrokeEdges);
|
strokePolygonOutline(prim.pts, dim(stroke, prim.greyed), prim.strokeWidthMm, prim.noStrokeEdges);
|
||||||
}
|
}
|
||||||
} else if (prim.kind === 'line') {
|
} else if (prim.kind === 'line') {
|
||||||
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
|
const color = dim(parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
|
||||||
if (prim.zigzag) {
|
if (prim.zigzag) {
|
||||||
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
|
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
|
||||||
// → Modell-Meter über dieselbe Kopplung wie Strichmuster).
|
// → Modell-Meter über dieselbe Kopplung wie Strichmuster).
|
||||||
@@ -444,7 +456,10 @@ export function compilePrimitivesToGpu(
|
|||||||
}
|
}
|
||||||
} else if (prim.kind === 'arc') {
|
} else if (prim.kind === 'arc') {
|
||||||
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
|
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
|
||||||
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
|
const color = dim(
|
||||||
|
parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1],
|
||||||
|
prim.greyed,
|
||||||
|
);
|
||||||
const strokeMm = prim.weightMm || 0.18;
|
const strokeMm = prim.weightMm || 0.18;
|
||||||
const c = prim.center;
|
const c = prim.center;
|
||||||
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
|
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
|
||||||
@@ -482,7 +497,7 @@ export function compilePrimitivesToGpu(
|
|||||||
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
|
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
|
||||||
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
|
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
|
||||||
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
|
// 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 stroke = dim(parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
|
||||||
const strokeMm = prim.weightMm || 0.18;
|
const strokeMm = prim.weightMm || 0.18;
|
||||||
const c = prim.center;
|
const c = prim.center;
|
||||||
const TAU = Math.PI * 2;
|
const TAU = Math.PI * 2;
|
||||||
@@ -519,7 +534,7 @@ export function compilePrimitivesToGpu(
|
|||||||
track(pt.x, pt.y);
|
track(pt.x, pt.y);
|
||||||
}
|
}
|
||||||
for (const t of tris) fillIdx.push(base + t);
|
for (const t of tris) fillIdx.push(base + t);
|
||||||
addFillBatch(tris.length, fill);
|
addFillBatch(tris.length, dim(fill, prim.greyed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,20 +132,26 @@ const CLS_STROKE: Record<string, string> = {
|
|||||||
"door-swing": "#1a1a1a",
|
"door-swing": "#1a1a1a",
|
||||||
"door-frame": "#1a1a1a",
|
"door-frame": "#1a1a1a",
|
||||||
"window-glass": "#1a1a1a",
|
"window-glass": "#1a1a1a",
|
||||||
"wall-axis": "#2f5d54", // var(--accent) des hellen Standard-Themes
|
"wall-axis": "#4a453e", // var(--accent) des hellen Standard-Themes
|
||||||
};
|
};
|
||||||
const CLS_OPACITY: Record<string, number> = {
|
const CLS_OPACITY: Record<string, number> = {
|
||||||
"wall-axis": 0.85,
|
"wall-axis": 0.85,
|
||||||
"context-line": 0.7,
|
"context-line": 0.7,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Deckkraft gedimmter (greyed) Primitive — wie GREYED_OPACITY in PlanView. */
|
/**
|
||||||
const GREYED_ALPHA = 0.3;
|
* Fester Grauton gedimmter (greyed) Primitive — ECHTE Umfärbung statt
|
||||||
|
* Deckkraft (dünne Haarlinien auf dem hellen Zeichenblatt wären bei reiner
|
||||||
|
* Transparenz kaum erkennbar gedimmt). Gleicher Ton wie GREYED_COLOR in
|
||||||
|
* PlanView.tsx / GREY_RGB in glPlanCompile.ts (#8c8c8c ≈ 0.549).
|
||||||
|
*/
|
||||||
|
const GREY_RGB = 0.549;
|
||||||
|
|
||||||
/** Farbe mit Klassen-Deckkraft und Greyed-Dimmung multiplizieren. */
|
/** Farbe mit Klassen-Deckkraft multiplizieren, bei Greyed-Dimmung auf den festen Grauton umfärben. */
|
||||||
function withOpacity(col: RRgba, cls: string | undefined, greyed: boolean | undefined): RRgba {
|
function withOpacity(col: RRgba, cls: string | undefined, greyed: boolean | undefined): RRgba {
|
||||||
const o = (cls ? CLS_OPACITY[cls] ?? 1 : 1) * (greyed ? GREYED_ALPHA : 1);
|
const o = cls ? CLS_OPACITY[cls] ?? 1 : 1;
|
||||||
return o === 1 ? col : [col[0], col[1], col[2], col[3] * o];
|
const rgb: RRgba = greyed ? [GREY_RGB, GREY_RGB, GREY_RGB, col[3]] : col;
|
||||||
|
return o === 1 ? rgb : [rgb[0], rgb[1], rgb[2], rgb[3] * o];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strichfarbe einer line/arc-Primitive: CSS-Klassen-Farbe > Modell-Farbe > Tinte. */
|
/** Strichfarbe einer line/arc-Primitive: CSS-Klassen-Farbe > Modell-Farbe > Tinte. */
|
||||||
|
|||||||
@@ -216,6 +216,69 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("innerLayerRecess: nur die INNERSTE Schicht weicht rundum um ihre Dicke zurück, andere Schichten bündig", () => {
|
||||||
|
// Isolierte Wand W1 (wallTypeId "aw" = 4 Schichten aussen->innen: render-ext,
|
||||||
|
// insulation, brick, render-int) ohne Nachbarn (keine Gehrung), EIN Fenster
|
||||||
|
// mit `innerLayerRecess: true`. Die innerste Schicht (render-int, 0.015 m) muss
|
||||||
|
// an DIESEM Fenster ein rundum um 0.015 m grösseres Loch bekommen; die drei
|
||||||
|
// äusseren Schichten bleiben bei der bündigen (unveränderten) Öffnungsgrösse.
|
||||||
|
const w1 = sampleProject.walls.find((w) => w.id === "W1")!;
|
||||||
|
const p: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
walls: [w1],
|
||||||
|
doors: [],
|
||||||
|
ceilings: [],
|
||||||
|
context: [],
|
||||||
|
windowTypes: [
|
||||||
|
...(sampleProject.windowTypes ?? []),
|
||||||
|
{
|
||||||
|
id: "win-recess-test",
|
||||||
|
name: "Test Schichteinzug",
|
||||||
|
kind: "fest",
|
||||||
|
wingCount: 1,
|
||||||
|
glazing: "einfach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
defaultHeight: 1.2,
|
||||||
|
innerLayerRecess: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
openings: [
|
||||||
|
{
|
||||||
|
id: "OR",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
typeId: "win-recess-test",
|
||||||
|
position: 2.0,
|
||||||
|
width: 1.0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const bodies = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
|
||||||
|
expect(bodies.length).toBe(4); // 4 Schicht-Bänder, keine Gehrung/Span-Brüche
|
||||||
|
const withHoles = bodies.filter((b) => b.holes && b.holes.length === 1);
|
||||||
|
expect(withHoles.length).toBe(4);
|
||||||
|
// Innerste Schicht = kleinster |offset| Richtung Innenseite -> hier einfach
|
||||||
|
// die mit der GRÖSSTEN Loch-Fläche identifizieren (der Rücksprung macht ihr
|
||||||
|
// Loch strikt grösser als die der anderen drei, bündigen Schichten).
|
||||||
|
const area = (h: { from: number; to: number; zBottom: number; zTop: number }) =>
|
||||||
|
(h.to - h.from) * (h.zTop - h.zBottom);
|
||||||
|
const holeAreas = withHoles.map((b) => area(b.holes![0]));
|
||||||
|
const sorted = [...holeAreas].sort((a, b) => a - b);
|
||||||
|
// 3 bündige Schichten: identische Lochfläche (1.0 × 1.2 = 1.2 m²).
|
||||||
|
expect(sorted[0]).toBeCloseTo(1.2, 6);
|
||||||
|
expect(sorted[1]).toBeCloseTo(1.2, 6);
|
||||||
|
expect(sorted[2]).toBeCloseTo(1.2, 6);
|
||||||
|
// Innerste Schicht: rundum +0.015 m (render-int-Dicke) -> (1.0+0.03)×(1.2+0.03).
|
||||||
|
expect(sorted[3]).toBeCloseTo(1.03 * 1.23, 6);
|
||||||
|
});
|
||||||
|
|
||||||
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
|
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
|
||||||
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
|
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
|
||||||
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
|
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
|
||||||
@@ -354,6 +417,26 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
|
|||||||
expect(a.max).toBeLessThanOrEqual(b.min + 1e-9);
|
expect(a.max).toBeLessThanOrEqual(b.min + 1e-9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Legacy-Tür (project.doors, ohne Öffnungs-Typsystem) bekommt ein Blatt-Panel statt eines blanken Lochs", () => {
|
||||||
|
// D1 ist eine LEGACY-Tür (project.doors, kein typeId/Öffnungs-Typsystem) —
|
||||||
|
// vor der Behebung durchliefen emitOpeningGlass/emitOpeningFrames nur
|
||||||
|
// project.openings, D1 blieb im 3D ein reines Loch in der Wand (Nutzer-
|
||||||
|
// Report: "Türen fehlen im 3D komplett"). DOOR_LEAF_RGB = [0.93,0.9,0.85].
|
||||||
|
const { meshes } = projectToModel3d(sampleProject);
|
||||||
|
const leafMeshes = meshes.filter(
|
||||||
|
(m) => m.color[0] === 0.93 && m.color[1] === 0.9 && m.color[2] === 0.85,
|
||||||
|
);
|
||||||
|
expect(leafMeshes.length).toBe(1);
|
||||||
|
const leaf = leafMeshes[0];
|
||||||
|
expect(leaf.positions.length).toBe(24); // Quader: 8 Ecken
|
||||||
|
expect(leaf.indices.length).toBe(36); // 12 Dreiecke
|
||||||
|
// D1: height 2.1, sitzt am Wand-UK (0) -> Blatt UK 0 .. OK 2.1.
|
||||||
|
const zs: number[] = [];
|
||||||
|
for (let i = 2; i < leaf.positions.length; i += 3) zs.push(leaf.positions[i]);
|
||||||
|
expect(Math.min(...zs)).toBeCloseTo(0, 6);
|
||||||
|
expect(Math.max(...zs)).toBeCloseTo(2.1, 6);
|
||||||
|
});
|
||||||
|
|
||||||
it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => {
|
it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => {
|
||||||
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
|
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
|
||||||
// Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben
|
// Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben
|
||||||
@@ -1825,6 +1908,33 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
|
|||||||
expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6);
|
expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Dach mit Bauteil-Material (roofTypeId) bekommt eine Textur-Ebene (Stil 'Textured')", () => {
|
||||||
|
// Vorher waren Dächer im Textured-Modus IMMER auf das Fallback-Schachbrett
|
||||||
|
// geklemmt (Nutzer-Report: "Dach soll wie Dachziegel aussehen, nicht so
|
||||||
|
// billig"). Mehrschichtiges Dach über `roofTypeId: "roof-warm"` (sample-
|
||||||
|
// project), äusserste Schicht = "roof-tiles" (echtes Bibliotheks-Material).
|
||||||
|
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm" };
|
||||||
|
const project: Project = { ...sampleProject, roofs: [roof] };
|
||||||
|
const { meshes } = projectToModel3d(project);
|
||||||
|
// Die äusserste (Eindeckungs-)Schicht hat KEINE Roof.color-Übersteuerung ->
|
||||||
|
// trägt eine aufgelöste materialIndex (nicht mehr die ROOF_RGB-Testfarbe,
|
||||||
|
// deshalb hier nicht über isRoofMesh gefiltert, sondern über materialIndex).
|
||||||
|
const withMaterial = meshes.filter((m) => typeof m.materialIndex === "number");
|
||||||
|
expect(withMaterial.length).toBeGreaterThan(0);
|
||||||
|
expect(withMaterial.every((m) => (m.materialIndex ?? 0) >= 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Roof.color-Übersteuerung unterdrückt die Material-Textur (bewusst einheitliche Farbe)", () => {
|
||||||
|
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm", color: "#ff0000" };
|
||||||
|
const project: Project = { ...sampleProject, roofs: [roof] };
|
||||||
|
const { meshes } = projectToModel3d(project);
|
||||||
|
const roofRedMeshes = meshes.filter(
|
||||||
|
(m) => m.color[0] === 1 && m.color[1] === 0 && m.color[2] === 0,
|
||||||
|
);
|
||||||
|
expect(roofRedMeshes.length).toBeGreaterThan(0);
|
||||||
|
expect(roofRedMeshes.every((m) => m.materialIndex === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
|
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
|
||||||
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
|
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
|
||||||
const project: Project = { ...sampleProject, roofs: [walmRoof] };
|
const project: Project = { ...sampleProject, roofs: [walmRoof] };
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ export function wallMaterialColorMaps(project: Project): string[] {
|
|||||||
// Kein auflösbarer Deckentyp.
|
// Kein auflösbarer Deckentyp.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const r of project.roofs ?? []) {
|
||||||
|
for (const l of roofLayers(project, r)) {
|
||||||
|
if (l.componentId) consider(l.componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
materialMapCache.set(project, list);
|
materialMapCache.set(project, list);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -367,6 +372,16 @@ export interface RMesh {
|
|||||||
indices: number[];
|
indices: number[];
|
||||||
kind: RMeshKind;
|
kind: RMeshKind;
|
||||||
color: RRgb;
|
color: RRgb;
|
||||||
|
/** Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je
|
||||||
|
* Positions-Vertex. Damit wird z. B. ein SWISSIMAGE-Luftbild aufs Gelände-TIN
|
||||||
|
* „drapiert" (jeder Vertex trägt seinen Bildpunkt). Fehlt/zu kurz -> `color`
|
||||||
|
* fürs ganze Mesh. Matcht `render3d::types::MeshInput.vertexColors`. */
|
||||||
|
vertexColors?: number[];
|
||||||
|
/** Material-Textur-Ebene (1-basiert, siehe {@link wallMaterialColorMaps}) für
|
||||||
|
* den Stil „Textured" — bislang nur von Dach-Schichten gesetzt. Fehlt sie,
|
||||||
|
* zeigt render3d das Fallback-Schachbrett. Matcht
|
||||||
|
* `render3d::types::MeshInput.materialIndex`. */
|
||||||
|
materialIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
|
/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
|
||||||
@@ -1138,12 +1153,22 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
|
|||||||
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
|
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
|
||||||
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
|
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
|
||||||
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
|
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
|
||||||
const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
|
const cutouts: Array<{
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
oBottom: number;
|
||||||
|
oTop: number;
|
||||||
|
/** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster
|
||||||
|
* mit `WindowType.innerLayerRecess`), s. {@link emitWall}-Moduldoc unten. */
|
||||||
|
recessInnerLayer: boolean;
|
||||||
|
}> = [];
|
||||||
for (const op of openingsOfWall(project, wall.id)) {
|
for (const op of openingsOfWall(project, wall.id)) {
|
||||||
const iv = openingInterval(wall, op);
|
const iv = openingInterval(wall, op);
|
||||||
if (!iv) continue;
|
if (!iv) continue;
|
||||||
const v = openingVerticalExtent(project, wall, op);
|
const v = openingVerticalExtent(project, wall, op);
|
||||||
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop });
|
const recessInnerLayer =
|
||||||
|
op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true;
|
||||||
|
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer });
|
||||||
}
|
}
|
||||||
for (const d of project.doors ?? []) {
|
for (const d of project.doors ?? []) {
|
||||||
if (d.hostWallId !== wall.id) continue;
|
if (d.hostWallId !== wall.id) continue;
|
||||||
@@ -1152,7 +1177,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
|
|||||||
if (to - from < EPS) continue;
|
if (to - from < EPS) continue;
|
||||||
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
|
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
|
||||||
const oTop = Math.min(zTop, zBottom + d.height);
|
const oTop = Math.min(zTop, zBottom + d.height);
|
||||||
cutouts.push({ from, to, oBottom: zBottom, oTop });
|
cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false });
|
||||||
}
|
}
|
||||||
cutouts.sort((a, b) => a.from - b.from);
|
cutouts.sort((a, b) => a.from - b.from);
|
||||||
|
|
||||||
@@ -1174,11 +1199,12 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
|
|||||||
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
|
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
|
||||||
// jeweiligen Box-Start korrekt stanzt.
|
// jeweiligen Box-Start korrekt stanzt.
|
||||||
if (layered) {
|
if (layered) {
|
||||||
const holes: RHole[] = cutouts.map((c) => ({
|
const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({
|
||||||
from: c.from,
|
from: c.from,
|
||||||
to: c.to,
|
to: c.to,
|
||||||
zBottom: c.oBottom,
|
zBottom: c.oBottom,
|
||||||
zTop: c.oTop,
|
zTop: c.oTop,
|
||||||
|
recessInnerLayer: c.recessInnerLayer,
|
||||||
}));
|
}));
|
||||||
const startCut = cuts?.startCut ?? null;
|
const startCut = cuts?.startCut ?? null;
|
||||||
const endCut = cuts?.endCut ?? null;
|
const endCut = cuts?.endCut ?? null;
|
||||||
@@ -1240,11 +1266,26 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
|
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
|
||||||
// `a` umrechnen (render3d klemmt u/z selbst auf die Box).
|
// `a` umrechnen (render3d klemmt u/z selbst auf die Box). An der INNERSTEN
|
||||||
|
// Schicht (li = letztes Band) weicht das Loch bei Fenstern mit
|
||||||
|
// `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück
|
||||||
|
// (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig.
|
||||||
|
const isInnermostBand = li === bands.length - 1;
|
||||||
|
const recessDepth = b.thickness;
|
||||||
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
|
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
|
||||||
const segHoles = holes
|
const segHoles = holes
|
||||||
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
|
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
|
||||||
.map((h) => ({ from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop }));
|
.map((h) => {
|
||||||
|
const widen = isInnermostBand && h.recessInnerLayer;
|
||||||
|
return widen
|
||||||
|
? {
|
||||||
|
from: h.from - a - recessDepth,
|
||||||
|
to: h.to - a + recessDepth,
|
||||||
|
zBottom: h.zBottom - recessDepth,
|
||||||
|
zTop: h.zTop + recessDepth,
|
||||||
|
}
|
||||||
|
: { from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop };
|
||||||
|
});
|
||||||
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
|
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1868,6 +1909,13 @@ function emitRoofs(project: Project): RMesh[] {
|
|||||||
const color =
|
const color =
|
||||||
overrideColor ??
|
overrideColor ??
|
||||||
(layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB);
|
(layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB);
|
||||||
|
// Material-Textur-Ebene fürs Textured-Rendering (z. B. echte Dachziegel-
|
||||||
|
// Textur der Eindeckung) — nur wenn KEIN Farb-Override greift (Override
|
||||||
|
// = bewusst eine einheitliche Farbe für alle Schichten, kein Bauteil-Bezug).
|
||||||
|
const materialIndex =
|
||||||
|
!overrideColor && layer.componentId
|
||||||
|
? resolveComponentMaterialLayer(project, layer.componentId)
|
||||||
|
: undefined;
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
const indices: number[] = [];
|
const indices: number[] = [];
|
||||||
for (const plane of g.planes) {
|
for (const plane of g.planes) {
|
||||||
@@ -1881,7 +1929,7 @@ function emitRoofs(project: Project): RMesh[] {
|
|||||||
]);
|
]);
|
||||||
pushRoofSlab(positions, indices, top, t);
|
pushRoofSlab(positions, indices, top, t);
|
||||||
}
|
}
|
||||||
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color });
|
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex });
|
||||||
return offsetAbove + t;
|
return offsetAbove + t;
|
||||||
}, 0);
|
}, 0);
|
||||||
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
|
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
|
||||||
@@ -1919,6 +1967,13 @@ function emitMeshes(project: Project): RMesh[] {
|
|||||||
indices: obj.indices,
|
indices: obj.indices,
|
||||||
kind: obj.type === "terrainMesh" ? "terrain" : "imported",
|
kind: obj.type === "terrainMesh" ? "terrain" : "imported",
|
||||||
color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB,
|
color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB,
|
||||||
|
// Luftbild-Drape: das Gelände-TIN kann Pro-Vertex-Farben tragen (beim
|
||||||
|
// Import aus dem Luftbild gesampelt) — dann zeigt render3d das Ortho
|
||||||
|
// statt der Flächen-Grundfarbe.
|
||||||
|
vertexColors:
|
||||||
|
obj.type === "terrainMesh" && obj.vertexColors?.length
|
||||||
|
? obj.vertexColors
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2645,6 +2700,48 @@ function emitOpeningGlass(project: Project): RMesh[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emittiert je LEGACY-Tür (`project.doors` — das alte flache Türfeld VOR dem
|
||||||
|
* Öffnungs-Typsystem, z. B. `Door.hostWallId/position/width/height` ohne
|
||||||
|
* `typeId`) EIN Blatt-Panel als rohen Quader. `emitWall` schneidet für diese
|
||||||
|
* Türen zwar bereits ein Loch in die Wand, aber `emitOpeningGlass`/
|
||||||
|
* `emitOpeningFrames` durchlaufen NUR `project.openings` (das neue Typsystem) —
|
||||||
|
* ohne diese Funktion blieb eine Legacy-Tür ein blankes Loch, durch das man
|
||||||
|
* hindurchsieht (Nutzer-Report: "Türen fehlen im 3D komplett"). Analog zum
|
||||||
|
* Alt-Verhalten typloser Fenster ({@link emitOpeningGlass}: EINE Vollscheibe
|
||||||
|
* ohne Rahmen) — hier EIN Vollblatt ohne Rahmen/Zarge (das Rahmen-System gehört
|
||||||
|
* exklusiv zu `project.openings`, s. {@link resolveOpeningFrame}). `positions`
|
||||||
|
* in MODELL-Metern wie {@link emitMeshes}.
|
||||||
|
*/
|
||||||
|
function emitLegacyDoorLeaves(project: Project): RMesh[] {
|
||||||
|
const out: RMesh[] = [];
|
||||||
|
for (const wall of project.walls) {
|
||||||
|
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||||||
|
if (axisLen < 1e-9) continue;
|
||||||
|
const { zBottom } = wallVerticalExtent(project, wall);
|
||||||
|
for (const d of project.doors ?? []) {
|
||||||
|
if (d.hostWallId !== wall.id) continue;
|
||||||
|
const from = Math.max(0, Math.min(d.position, axisLen));
|
||||||
|
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
|
||||||
|
if (to - from <= EPS) continue;
|
||||||
|
const zTop = zBottom + d.height;
|
||||||
|
if (zTop - zBottom <= EPS) continue;
|
||||||
|
const mesh = openingAxisBox(
|
||||||
|
wall,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
zBottom,
|
||||||
|
zTop,
|
||||||
|
-DOOR_LEAF_THICK / 2,
|
||||||
|
DOOR_LEAF_THICK / 2,
|
||||||
|
DOOR_LEAF_RGB,
|
||||||
|
);
|
||||||
|
if (mesh) out.push(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
|
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
|
||||||
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
|
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
|
||||||
@@ -2803,6 +2900,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
|
|||||||
meshes: [
|
meshes: [
|
||||||
...emitMeshes(project),
|
...emitMeshes(project),
|
||||||
...emitOpeningGlass(project),
|
...emitOpeningGlass(project),
|
||||||
|
...emitLegacyDoorLeaves(project),
|
||||||
...emitOpeningFrames(project, detail),
|
...emitOpeningFrames(project, detail),
|
||||||
...emitOpeningShading(project, detail),
|
...emitOpeningShading(project, detail),
|
||||||
...emitStairMeshes(project),
|
...emitStairMeshes(project),
|
||||||
@@ -2888,7 +2986,7 @@ export function pickGeometry(
|
|||||||
// Indizes robust überspringen statt abzustürzen (importierte Daten).
|
// Indizes robust überspringen statt abzustürzen (importierte Daten).
|
||||||
const contextMeshes: PickContextMesh[] = [];
|
const contextMeshes: PickContextMesh[] = [];
|
||||||
for (const obj of project.context ?? []) {
|
for (const obj of project.context ?? []) {
|
||||||
if (obj.type === "contourSet") continue;
|
if (obj.type === "contourSet" || obj.type === "image") continue;
|
||||||
const pos = obj.positions;
|
const pos = obj.positions;
|
||||||
const vcount = pos.length / 3;
|
const vcount = pos.length / 3;
|
||||||
const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = [];
|
const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = [];
|
||||||
@@ -3045,7 +3143,7 @@ export function selectionHighlightLines(
|
|||||||
// tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht.
|
// tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht.
|
||||||
if (contextObjSet.size > 0) {
|
if (contextObjSet.size > 0) {
|
||||||
for (const obj of project.context ?? []) {
|
for (const obj of project.context ?? []) {
|
||||||
if (obj.type === "contourSet" || !contextObjSet.has(obj.id)) continue;
|
if (obj.type === "contourSet" || obj.type === "image" || !contextObjSet.has(obj.id)) continue;
|
||||||
const pos = obj.positions;
|
const pos = obj.positions;
|
||||||
let minX = Infinity, maxX = -Infinity;
|
let minX = Infinity, maxX = -Infinity;
|
||||||
let minY = Infinity, maxY = -Infinity;
|
let minY = Infinity, maxY = -Infinity;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Kontext-Objekt-Auswahl: deriveSelection liefert für ein selektiertes
|
||||||
|
// Kontext-Objekt (importiertes Mesh/Terrain/Konturen) Bbox + reale LV95-
|
||||||
|
// Koordinate (falls project.geoAnchor gesetzt) — s. Nutzer-Wunsch, direkt im
|
||||||
|
// Object-Info-Panel prüfen zu können, ob/wohin georeferenziert importiert wurde.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { deriveSelection } from "./selectionInfo";
|
||||||
|
import type { Project } from "../model/types";
|
||||||
|
|
||||||
|
const baseProject = {
|
||||||
|
id: "p",
|
||||||
|
name: "T",
|
||||||
|
layers: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
walls: [],
|
||||||
|
drawings2d: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deriveSelection — Kontext-Objekt", () => {
|
||||||
|
it("importedMesh: Bbox aus x/y (Höhe z ignoriert), ohne geoAnchor kein lv95Center", () => {
|
||||||
|
const project = {
|
||||||
|
...baseProject,
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CTX1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Test",
|
||||||
|
positions: [0, 0, 5, 10, 6, 8, 4, 3, 2],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Project;
|
||||||
|
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
|
||||||
|
expect(sel?.kind).toBe("contextObject");
|
||||||
|
expect(sel?.id).toBe("CTX1");
|
||||||
|
expect(sel?.bbox).toEqual({ minX: 0, minY: 0, maxX: 10, maxY: 6 });
|
||||||
|
expect(sel?.contextObject?.objType).toBe("importedMesh");
|
||||||
|
expect(sel?.contextObject?.lv95Center).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mit geoAnchor: lv95Center = Bbox-Mittelpunkt zurückgerechnet auf LV95", () => {
|
||||||
|
const project = {
|
||||||
|
...baseProject,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CTX1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Test",
|
||||||
|
positions: [0, 0, 0, 100, 50, 0],
|
||||||
|
indices: [0, 1, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Project;
|
||||||
|
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
|
||||||
|
// Bbox-Mittelpunkt (50,25) -> LV95 = origin + Mittelpunkt (origin = lv95 - model = lv95, model (0,0)).
|
||||||
|
expect(sel?.contextObject?.lv95Center).toEqual({ e: 2600050, n: 1200025 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mit geoAnchor.model != (0,0): Origin korrekt berücksichtigt", () => {
|
||||||
|
const project = {
|
||||||
|
...baseProject,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 500, y: 300 } },
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CTX1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Test",
|
||||||
|
positions: [500, 300, 0],
|
||||||
|
indices: [0, 0, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Project;
|
||||||
|
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
|
||||||
|
// Der Modellpunkt (500,300) IST der Anker -> muss exakt auf lv95 (2600000,1200000) zurückrechnen.
|
||||||
|
expect(sel?.contextObject?.lv95Center).toEqual({ e: 2600000, n: 1200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contourSet: Bbox aus contours[].pts statt positions", () => {
|
||||||
|
const project = {
|
||||||
|
...baseProject,
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CS1",
|
||||||
|
type: "contourSet",
|
||||||
|
name: "Höhenlinien",
|
||||||
|
contours: [
|
||||||
|
{ z: 400, pts: [{ x: -5, y: 2 }, { x: 8, y: -3 }], closed: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Project;
|
||||||
|
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CS1");
|
||||||
|
expect(sel?.contextObject?.objType).toBe("contourSet");
|
||||||
|
expect(sel?.bbox).toEqual({ minX: -5, minY: -3, maxX: 8, maxY: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unbekannte Id -> null", () => {
|
||||||
|
const project = { ...baseProject, context: [] } as unknown as Project;
|
||||||
|
expect(deriveSelection(project, [], null, null, null, null, null, null, null, null, "does-not-exist")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keine Kontext-Auswahl (null) -> fällt durch zu anderen Kanälen (hier auch leer -> null)", () => {
|
||||||
|
const project = { ...baseProject, context: [] } as unknown as Project;
|
||||||
|
expect(deriveSelection(project, [], null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
|
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
|
||||||
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
|
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
|
||||||
|
|
||||||
import { sampleProject } from "../model/sampleProject";
|
import { emptyProject } from "../model/sampleProject";
|
||||||
import {
|
import {
|
||||||
flattenCategories,
|
flattenCategories,
|
||||||
recomputeFloorElevations,
|
recomputeFloorElevations,
|
||||||
@@ -363,7 +363,10 @@ export function createProjectSlice(
|
|||||||
return {
|
return {
|
||||||
...history,
|
...history,
|
||||||
|
|
||||||
project: sampleProject,
|
// TEMPORÄR für manuellen Test (Nutzer-Wunsch): leeres Projekt statt Demo-
|
||||||
|
// Haus. NICHT committen — vor dem nächsten echten Commit zurück auf
|
||||||
|
// `sampleProject` stellen.
|
||||||
|
project: emptyProject,
|
||||||
|
|
||||||
setProject,
|
setProject,
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
Ceiling,
|
Ceiling,
|
||||||
Column,
|
Column,
|
||||||
ColumnProfile,
|
ColumnProfile,
|
||||||
|
ContextObject,
|
||||||
Drawing2D,
|
Drawing2D,
|
||||||
Drawing2DGeom,
|
Drawing2DGeom,
|
||||||
ExtrudedSolid,
|
ExtrudedSolid,
|
||||||
@@ -41,6 +42,7 @@ import type {
|
|||||||
Wall,
|
Wall,
|
||||||
WallReferenceLine,
|
WallReferenceLine,
|
||||||
} from "../model/types";
|
} from "../model/types";
|
||||||
|
import { localToLv95 } from "../io/lv95";
|
||||||
import { columnVerticalExtent } from "../model/wall";
|
import { columnVerticalExtent } from "../model/wall";
|
||||||
import { columnFootprint } from "../geometry/column";
|
import { columnFootprint } from "../geometry/column";
|
||||||
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
|
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
|
||||||
@@ -258,6 +260,8 @@ export interface StairInfo {
|
|||||||
treadDepth: number;
|
treadDepth: number;
|
||||||
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
||||||
up: boolean;
|
up: boolean;
|
||||||
|
/** Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). Fehlt → "line". */
|
||||||
|
arrowStyle?: "line" | "filled";
|
||||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
||||||
referenz?: "links" | "mitte" | "rechts";
|
referenz?: "links" | "mitte" | "rechts";
|
||||||
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
|
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
|
||||||
@@ -348,7 +352,8 @@ export interface Selection {
|
|||||||
| "door"
|
| "door"
|
||||||
| "extrudedSolid"
|
| "extrudedSolid"
|
||||||
| "column"
|
| "column"
|
||||||
| "roof";
|
| "roof"
|
||||||
|
| "contextObject";
|
||||||
id: string;
|
id: string;
|
||||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||||
categoryCode: string;
|
categoryCode: string;
|
||||||
@@ -424,6 +429,20 @@ export interface Selection {
|
|||||||
extrudedSolid?: ExtrudedSolidInfo;
|
extrudedSolid?: ExtrudedSolidInfo;
|
||||||
/** Stützen-Attribute (nur bei `kind === "column"`). */
|
/** Stützen-Attribute (nur bei `kind === "column"`). */
|
||||||
column?: ColumnInfo;
|
column?: ColumnInfo;
|
||||||
|
/** Kontext-Objekt-Attribute (nur bei `kind === "contextObject"`). */
|
||||||
|
contextObject?: ContextObjectInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontext-Objekt-spezifische Attribute (importiertes Mesh/Terrain/Konturen-
|
||||||
|
* Satz) für das Object-Info-Panel — v. a. zum Prüfen, OB/WOHIN georeferenziert
|
||||||
|
* importiert wurde (Nutzer-Wunsch: reale Koordinaten direkt sichtbar).
|
||||||
|
*/
|
||||||
|
export interface ContextObjectInfo {
|
||||||
|
objType: ContextObject["type"];
|
||||||
|
/** Reale LV95-Koordinate des Bbox-Mittelpunkts, falls `Project.geoAnchor`
|
||||||
|
* gesetzt ist (sonst `undefined` — kein Bezug bekannt). */
|
||||||
|
lv95Center?: { e: number; n: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALL_FALLBACK_MM = 0.18;
|
const WALL_FALLBACK_MM = 0.18;
|
||||||
@@ -765,6 +784,7 @@ function stairSelection(project: Project, s: Stair): Selection {
|
|||||||
riserHeight: geo.riserHeight,
|
riserHeight: geo.riserHeight,
|
||||||
treadDepth: geo.treadDepth,
|
treadDepth: geo.treadDepth,
|
||||||
up: s.up !== false,
|
up: s.up !== false,
|
||||||
|
arrowStyle: s.arrowStyle,
|
||||||
referenz: s.referenz,
|
referenz: s.referenz,
|
||||||
typeId: s.typeId,
|
typeId: s.typeId,
|
||||||
floorId: s.floorId,
|
floorId: s.floorId,
|
||||||
@@ -908,6 +928,53 @@ function columnSelection(project: Project, col: Column): Selection {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selektion für ein Kontext-Objekt (importiertes Mesh/Terrain/Konturen-Satz)
|
||||||
|
* ableiten. Bbox aus den rohen x/y-Koordinaten (Meshes: flaches [x,y,z…]-
|
||||||
|
* Array, nur x/y je Vertex; Konturen: `pts` je Kontur) — die Höhe (z) bleibt
|
||||||
|
* unberücksichtigt (2D-Bbox wie bei allen anderen Elementarten). `lv95Center`
|
||||||
|
* nur, wenn `project.geoAnchor` gesetzt ist (sonst kein Rückrechnungs-Bezug).
|
||||||
|
*/
|
||||||
|
function contextObjectSelection(project: Project, obj: ContextObject): Selection {
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
const consider = (x: number, y: number) => {
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
};
|
||||||
|
if (obj.type === "contourSet") {
|
||||||
|
for (const c of obj.contours) for (const p of c.pts) consider(p.x, p.y);
|
||||||
|
} else if (obj.type === "image") {
|
||||||
|
consider(obj.min.x, obj.min.y);
|
||||||
|
consider(obj.max.x, obj.max.y);
|
||||||
|
} else {
|
||||||
|
const pos = obj.positions;
|
||||||
|
for (let i = 0; i + 2 < pos.length; i += 3) consider(pos[i], pos[i + 1]);
|
||||||
|
}
|
||||||
|
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
|
||||||
|
const anchor = project.geoAnchor;
|
||||||
|
const lv95Center = anchor
|
||||||
|
? localToLv95(
|
||||||
|
(minX + maxX) / 2,
|
||||||
|
(minY + maxY) / 2,
|
||||||
|
{ e: anchor.lv95.e - anchor.model.x, n: anchor.lv95.n - anchor.model.y },
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
kind: "contextObject",
|
||||||
|
id: obj.id,
|
||||||
|
categoryCode: "",
|
||||||
|
color: "#888888",
|
||||||
|
weightMm: WALL_FALLBACK_MM,
|
||||||
|
bbox: { minX, minY, maxX, maxY },
|
||||||
|
contextObject: { objType: obj.type, lv95Center },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
|
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
|
||||||
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||||
const ls = d.lineStyleId
|
const ls = d.lineStyleId
|
||||||
@@ -963,7 +1030,12 @@ export function deriveSelection(
|
|||||||
selectedExtrudedSolidId: string | null = null,
|
selectedExtrudedSolidId: string | null = null,
|
||||||
selectedColumnId: string | null = null,
|
selectedColumnId: string | null = null,
|
||||||
selectedRoofId: string | null = null,
|
selectedRoofId: string | null = null,
|
||||||
|
selectedContextObjectId: string | null = null,
|
||||||
): Selection | null {
|
): Selection | null {
|
||||||
|
if (selectedContextObjectId) {
|
||||||
|
const o = (project.context ?? []).find((x) => x.id === selectedContextObjectId);
|
||||||
|
if (o) return contextObjectSelection(project, o);
|
||||||
|
}
|
||||||
if (selectedDrawingId) {
|
if (selectedDrawingId) {
|
||||||
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
||||||
return d ? drawingSelection(project, d) : null;
|
return d ? drawingSelection(project, d) : null;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface SiteSlice {
|
|||||||
addContextObjects: (objs: ContextObject[]) => void;
|
addContextObjects: (objs: ContextObject[]) => void;
|
||||||
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
|
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
|
||||||
removeContextObject: (id: string) => void;
|
removeContextObject: (id: string) => void;
|
||||||
|
/** Entfernt mehrere Kontext-Objekte in EINEM Schritt (z. B. eine ganze
|
||||||
|
* Ebene/Gruppe importierter Gebäude) — ein einziger Undo-Eintrag. */
|
||||||
|
removeContextObjects: (ids: string[]) => void;
|
||||||
/**
|
/**
|
||||||
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als
|
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als
|
||||||
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
|
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
|
||||||
@@ -64,6 +67,12 @@ export function createSiteSlice(
|
|||||||
removeContextObject: (id) =>
|
removeContextObject: (id) =>
|
||||||
setContext((ctx) => ctx.filter((o) => o.id !== id)),
|
setContext((ctx) => ctx.filter((o) => o.id !== id)),
|
||||||
|
|
||||||
|
removeContextObjects: (ids) => {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const drop = new Set(ids);
|
||||||
|
setContext((ctx) => ctx.filter((o) => !drop.has(o.id)));
|
||||||
|
},
|
||||||
|
|
||||||
generateTerrain: (contourSetId) => {
|
generateTerrain: (contourSetId) => {
|
||||||
const ctx = get().project.context ?? [];
|
const ctx = get().project.context ?? [];
|
||||||
const set = ctx.find(
|
const set = ctx.find(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Hell/Dunkel-Umschalter (Einstellungen → Darstellung). Hell ist Standard
|
||||||
|
// (siehe styles.css); Dunkel wird per `data-theme="dark"` auf <html> opt-in
|
||||||
|
// gesetzt und in localStorage persistiert, damit die Wahl einen Neustart
|
||||||
|
// übersteht. `applyThemeMode` wird SYNCHRON vor dem ersten Render aufgerufen
|
||||||
|
// (main.tsx), damit es keinen Hell/Dunkel-Flackerer beim Laden gibt.
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "cad.themeMode";
|
||||||
|
|
||||||
|
export function getStoredThemeMode(): ThemeMode {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
return window.localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeMode(mode: ThemeMode): void {
|
||||||
|
if (mode === "dark") {
|
||||||
|
document.documentElement.setAttribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredThemeMode(mode: ThemeMode): void {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||||
|
applyThemeMode(mode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Klassische Menüleiste (Bearbeiten/Ansicht/Fenster) neben der Wortmarke in
|
||||||
|
// der TopBar — ergänzt die bestehende Quick-Access-Icon-Leiste um textuelle
|
||||||
|
// Menüs im Rhino-/Desktop-Stil. Reine Darstellung + Callbacks (voll gesteuert,
|
||||||
|
// wie die übrige TopBar); jeder Eintrag ruft eine bereits existierende
|
||||||
|
// App.tsx-Funktion auf (kein Dummy-/Platzhalter-Eintrag).
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text über i18n (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { Dropdown } from "./Dropdown";
|
||||||
|
import type { DropdownItem } from "./Dropdown";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import type { View3d, ViewType } from "./TopBar";
|
||||||
|
|
||||||
|
export interface AppMenuBarProps {
|
||||||
|
/** Bearbeiten: Undo/Redo (Store) + Löschen der aktuellen Auswahl. */
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
onDeleteSelection: () => void;
|
||||||
|
hasSelection: boolean;
|
||||||
|
|
||||||
|
/** Ansicht: Grundriss/Perspektive-Umschalter, 3D-Blickwinkel-Presets,
|
||||||
|
* Referenzlinien-Toggle. */
|
||||||
|
viewType: ViewType;
|
||||||
|
onViewType: (v: ViewType) => void;
|
||||||
|
view3d: View3d;
|
||||||
|
onView3d: (v: View3d) => void;
|
||||||
|
referenceLines: boolean;
|
||||||
|
onReferenceLines: (v: boolean) => void;
|
||||||
|
|
||||||
|
/** Fenster: Ressourcen-Fenster + Einstellungen (bestehende Panels). */
|
||||||
|
resourcesOpen: boolean;
|
||||||
|
onToggleResources: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menüleiste (Bearbeiten/Ansicht/Fenster) — sitzt in der TopBar neben der Wortmarke. */
|
||||||
|
export function AppMenuBar({
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onDeleteSelection,
|
||||||
|
hasSelection,
|
||||||
|
viewType,
|
||||||
|
onViewType,
|
||||||
|
view3d,
|
||||||
|
onView3d,
|
||||||
|
referenceLines,
|
||||||
|
onReferenceLines,
|
||||||
|
resourcesOpen,
|
||||||
|
onToggleResources,
|
||||||
|
onOpenSettings,
|
||||||
|
}: AppMenuBarProps) {
|
||||||
|
const editItems: DropdownItem[] = [
|
||||||
|
{ label: t("menu.edit.undo"), onSelect: onUndo, disabled: !canUndo },
|
||||||
|
{ label: t("menu.edit.redo"), onSelect: onRedo, disabled: !canRedo },
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
label: t("menu.edit.delete"),
|
||||||
|
onSelect: onDeleteSelection,
|
||||||
|
disabled: !hasSelection,
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIEW3D_PRESETS: [View3d, string][] = [
|
||||||
|
["front", t("view3d.front")],
|
||||||
|
["back", t("view3d.back")],
|
||||||
|
["side", t("view3d.side")],
|
||||||
|
["left", t("view3d.left")],
|
||||||
|
["top", t("view3d.top")],
|
||||||
|
["iso", t("view3d.iso")],
|
||||||
|
["perspective", t("view3d.perspective")],
|
||||||
|
];
|
||||||
|
const viewItems: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
label: t("menu.view.floorplan"),
|
||||||
|
onSelect: () => onViewType("grundriss"),
|
||||||
|
active: viewType === "grundriss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("menu.view.perspective"),
|
||||||
|
onSelect: () => onViewType("perspektive"),
|
||||||
|
active: viewType === "perspektive",
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
...VIEW3D_PRESETS.map(([id, label]) => ({
|
||||||
|
label,
|
||||||
|
onSelect: () => onView3d(id),
|
||||||
|
active: view3d === id,
|
||||||
|
disabled: viewType !== "perspektive",
|
||||||
|
})),
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
label: t("menu.view.referenceLines"),
|
||||||
|
onSelect: () => onReferenceLines(!referenceLines),
|
||||||
|
active: referenceLines,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const windowItems: DropdownItem[] = [
|
||||||
|
{
|
||||||
|
label: t("menu.window.resources"),
|
||||||
|
onSelect: onToggleResources,
|
||||||
|
active: resourcesOpen,
|
||||||
|
},
|
||||||
|
{ divider: true },
|
||||||
|
{ label: t("menu.window.settings"), onSelect: onOpenSettings },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tb-menubar" role="menubar">
|
||||||
|
<Dropdown
|
||||||
|
items={editItems}
|
||||||
|
placeholder={t("menu.edit")}
|
||||||
|
title={t("menu.edit")}
|
||||||
|
triggerClassName="tb-menubar-item"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
items={viewItems}
|
||||||
|
placeholder={t("menu.view")}
|
||||||
|
title={t("menu.view")}
|
||||||
|
triggerClassName="tb-menubar-item"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
items={windowItems}
|
||||||
|
placeholder={t("menu.window")}
|
||||||
|
title={t("menu.window")}
|
||||||
|
triggerClassName="tb-menubar-item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,13 +17,15 @@ import { fetchBuildings3d } from "../io/swissBuildings3d";
|
|||||||
import type { BuildingsVersion } from "../io/swissBuildings3d";
|
import type { BuildingsVersion } from "../io/swissBuildings3d";
|
||||||
import { fetchTerrainXyz } from "../io/swissAlti3d";
|
import { fetchTerrainXyz } from "../io/swissAlti3d";
|
||||||
import type { TerrainResolution } from "../io/swissAlti3d";
|
import type { TerrainResolution } from "../io/swissAlti3d";
|
||||||
|
import { fetchSwissImage } from "../io/swissImage";
|
||||||
import { fetchOsm } from "../io/osm";
|
import { fetchOsm } from "../io/osm";
|
||||||
import type { OsmSelection } from "../io/osm";
|
import type { OsmSelection } from "../io/osm";
|
||||||
import { featuresToContextObjects } from "../io/geoContext";
|
import { featuresToContextObjects } from "../io/geoContext";
|
||||||
import type { GeoFeature } from "../io/geoContext";
|
import type { GeoFeature } from "../io/geoContext";
|
||||||
import { terrainMeshFromGrid } from "../model/terrain";
|
import { terrainMeshFromGrid } from "../model/terrain";
|
||||||
import type { GeoOrigin } from "../io/lv95";
|
import { makeOrigin } from "../io/lv95";
|
||||||
import type { ContextObject } from "../model/types";
|
import type { GeoOrigin, LV95 } from "../io/lv95";
|
||||||
|
import type { ContextObject, TerrainMesh } from "../model/types";
|
||||||
|
|
||||||
/** Gebäude-Import-Modus: aus, vereinfachte Box (schnell, alt) oder echte
|
/** Gebäude-Import-Modus: aus, vereinfachte Box (schnell, alt) oder echte
|
||||||
* swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */
|
* swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */
|
||||||
@@ -33,6 +35,8 @@ export type SwissBuildingsMode = "off" | "flach" | "v2" | "v3";
|
|||||||
* LV95-Koordinate (s. `Project.geoAnchor`). */
|
* LV95-Koordinate (s. `Project.geoAnchor`). */
|
||||||
export interface GeoAnchor {
|
export interface GeoAnchor {
|
||||||
lv95: { e: number; n: number };
|
lv95: { e: number; n: number };
|
||||||
|
/** Reale Höhe (m ü. M.) des Bezugspunkts — informativ, s. Project.geoAnchor. */
|
||||||
|
height?: number;
|
||||||
model: { x: number; y: number };
|
model: { x: number; y: number };
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
@@ -45,11 +49,21 @@ export interface ContextImportDialogProps {
|
|||||||
geoAnchor?: GeoAnchor;
|
geoAnchor?: GeoAnchor;
|
||||||
/** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */
|
/** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */
|
||||||
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
|
onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void;
|
||||||
|
/**
|
||||||
|
* Läuft in einem EIGENEN nativen Fenster (siehe native/contextImportWindow.ts)
|
||||||
|
* statt als abgedunkelter Overlay-Dialog im Hauptfenster — kein Backdrop,
|
||||||
|
* füllt die Fensterfläche, Kopfzeile wird zum Ziehbereich statt Schliessen-
|
||||||
|
* Knopf (Abbrechen/Importieren im Fuss bleiben — das sind echte Aktionen,
|
||||||
|
* kein reiner Schliessen-Button wie bei SettingsDialog).
|
||||||
|
*/
|
||||||
|
windowed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sources = {
|
type Sources = {
|
||||||
swissBuildings: SwissBuildingsMode;
|
swissBuildings: SwissBuildingsMode;
|
||||||
swissTerrain: boolean;
|
swissTerrain: boolean;
|
||||||
|
swissImage: boolean;
|
||||||
|
swissImageDrape: boolean;
|
||||||
osmBuildings: boolean;
|
osmBuildings: boolean;
|
||||||
osmRoads: boolean;
|
osmRoads: boolean;
|
||||||
osmWater: boolean;
|
osmWater: boolean;
|
||||||
@@ -64,6 +78,8 @@ const DEFAULT_SOURCES: Sources = {
|
|||||||
// Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar).
|
// Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar).
|
||||||
swissBuildings: "v2",
|
swissBuildings: "v2",
|
||||||
swissTerrain: true,
|
swissTerrain: true,
|
||||||
|
swissImage: false,
|
||||||
|
swissImageDrape: false,
|
||||||
osmBuildings: false,
|
osmBuildings: false,
|
||||||
osmRoads: true,
|
osmRoads: true,
|
||||||
osmWater: true,
|
osmWater: true,
|
||||||
@@ -78,6 +94,7 @@ export function ContextImportDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
geoAnchor,
|
geoAnchor,
|
||||||
onSetGeoAnchor,
|
onSetGeoAnchor,
|
||||||
|
windowed = false,
|
||||||
}: ContextImportDialogProps) {
|
}: ContextImportDialogProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -91,6 +108,14 @@ export function ContextImportDialog({
|
|||||||
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
|
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
|
||||||
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
||||||
const [radius, setRadius] = useState(200);
|
const [radius, setRadius] = useState(200);
|
||||||
|
// Optionaler Bezugspunkt: welche LV95-Koordinate soll im Modell auf (0,0)
|
||||||
|
// liegen (der „neue Nullpunkt", z. B. ein amtlicher Vermessungspunkt aus dem
|
||||||
|
// Kataster). Leer = das Suchzentrum wird Modell-(0,0) (bisheriges Verhalten).
|
||||||
|
const [refPoint, setRefPoint] = useState<{ e: string; n: string; h: string }>({
|
||||||
|
e: "",
|
||||||
|
n: "",
|
||||||
|
h: "",
|
||||||
|
});
|
||||||
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
|
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
|
||||||
// 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit.
|
// 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit.
|
||||||
const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("2");
|
const [terrainResolution, setTerrainResolution] = useState<TerrainResolution>("2");
|
||||||
@@ -129,6 +154,8 @@ export function ContextImportDialog({
|
|||||||
const anySource =
|
const anySource =
|
||||||
sources.swissBuildings !== "off" ||
|
sources.swissBuildings !== "off" ||
|
||||||
sources.swissTerrain ||
|
sources.swissTerrain ||
|
||||||
|
sources.swissImage ||
|
||||||
|
sources.swissImageDrape ||
|
||||||
sources.osmBuildings ||
|
sources.osmBuildings ||
|
||||||
sources.osmRoads ||
|
sources.osmRoads ||
|
||||||
sources.osmWater ||
|
sources.osmWater ||
|
||||||
@@ -144,13 +171,29 @@ export function ContextImportDialog({
|
|||||||
setStatus(t("ctxImport.working"));
|
setStatus(t("ctxImport.working"));
|
||||||
try {
|
try {
|
||||||
const center = selected.lv95;
|
const center = selected.lv95;
|
||||||
// Bestehenden Georeferenzierungs-Bezug wiederverwenden, statt bei jedem
|
// Optionaler Bezugspunkt (LV95): dieser Punkt wird zu Modell-(0,0). Leer
|
||||||
// Import einen neuen (inkonsistenten) zu berechnen — der Bezugspunkt
|
// -> das Suchzentrum wird der Nullpunkt (bisheriges Verhalten).
|
||||||
// bleibt fix im Modell, nur die GESUCHTE Stelle (center/radius) steuert,
|
const refE = Number(refPoint.e);
|
||||||
// WOVON Daten geladen werden (s. Project.geoAnchor-Doc).
|
const refN = Number(refPoint.n);
|
||||||
let origin: GeoOrigin | undefined = geoAnchor
|
const hasRef =
|
||||||
|
refPoint.e.trim() !== "" &&
|
||||||
|
refPoint.n.trim() !== "" &&
|
||||||
|
Number.isFinite(refE) &&
|
||||||
|
Number.isFinite(refN);
|
||||||
|
const refLv95: LV95 = hasRef ? { e: refE, n: refN } : center;
|
||||||
|
// Optionale Höhe (m ü. M.) des Bezugspunkts — rein informativ (kein Shift).
|
||||||
|
const refH = Number(refPoint.h);
|
||||||
|
const hasRefH = refPoint.h.trim() !== "" && Number.isFinite(refH);
|
||||||
|
// Der gesuchte Ort landet nahe dem Modell-Nullpunkt (Modell rechnet in
|
||||||
|
// float32 — LV95-Koordinaten im Millionenbereich würden 2D/3D unbrauchbar
|
||||||
|
// ungenau darstellen). Der Bezug (geoAnchor) merkt sich „Modell-(0,0) =
|
||||||
|
// dieser LV95-Punkt", damit der IFC-/DXF-Export trotzdem die echten
|
||||||
|
// Landeskoordinaten schreibt und weitere Importe lagerichtig dazu liegen.
|
||||||
|
// Mit „Neuer Bezugspunkt" (georef) lässt sich der Ursprung später auf einen
|
||||||
|
// beliebigen Modellpunkt verschieben.
|
||||||
|
let origin: GeoOrigin = geoAnchor
|
||||||
? { e: geoAnchor.lv95.e - geoAnchor.model.x, n: geoAnchor.lv95.n - geoAnchor.model.y }
|
? { e: geoAnchor.lv95.e - geoAnchor.model.x, n: geoAnchor.lv95.n - geoAnchor.model.y }
|
||||||
: undefined;
|
: makeOrigin(refLv95);
|
||||||
const all: GeoFeature[] = [];
|
const all: GeoFeature[] = [];
|
||||||
// Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject
|
// Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject
|
||||||
// (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects).
|
// (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects).
|
||||||
@@ -200,8 +243,10 @@ export function ContextImportDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gelände-Höhenraster (echtes swissALTI3D-XYZ, native Auflösung) → Terrain-Mesh.
|
// Gelände-Höhenraster (echtes swissALTI3D-XYZ, native Auflösung) → Terrain-Mesh.
|
||||||
let terrain: ContextObject | null = null;
|
// Die Drapierung braucht das TIN -> Terrain auch dann holen, wenn nur
|
||||||
if (sources.swissTerrain) {
|
// „Luftbild aufs Gelände" (ohne eigenen Terrain-Haken) gewählt wäre.
|
||||||
|
let terrain: TerrainMesh | null = null;
|
||||||
|
if (sources.swissTerrain || sources.swissImageDrape) {
|
||||||
const r = await fetchTerrainXyz(center, radius, origin, terrainResolution);
|
const r = await fetchTerrainXyz(center, radius, origin, terrainResolution);
|
||||||
origin = r.origin;
|
origin = r.origin;
|
||||||
if (r.grid) {
|
if (r.grid) {
|
||||||
@@ -210,7 +255,30 @@ export function ContextImportDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (all.length === 0 && !terrain && extraObjs.length === 0) {
|
// SWISSIMAGE-Luftbild (WMS) → georeferenziertes Rasterbild (EIN ContextImage).
|
||||||
|
// • `swissImage` → 2D-Grundriss-Hintergrund.
|
||||||
|
// • `swissImageDrape` → `drape:true`: der 3D-Viewer legt es pixelscharf als
|
||||||
|
// Textur aufs Gelände (render3d `set_aerial_texture`).
|
||||||
|
// Beide Modi teilen dasselbe Bild; das Bild wird nur EINMAL geladen.
|
||||||
|
let image: ContextObject | null = null;
|
||||||
|
if (sources.swissImage || sources.swissImageDrape) {
|
||||||
|
const r = await fetchSwissImage(center, radius, origin);
|
||||||
|
origin = r.origin;
|
||||||
|
if (r.dataUrl) {
|
||||||
|
image = {
|
||||||
|
id: `img-${Date.now()}`,
|
||||||
|
type: "image",
|
||||||
|
name: `Luftbild ${selected.label}`,
|
||||||
|
layer: "Luftbild",
|
||||||
|
src: r.dataUrl,
|
||||||
|
min: r.min,
|
||||||
|
max: r.max,
|
||||||
|
drape: sources.swissImageDrape,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (all.length === 0 && !terrain && !image && extraObjs.length === 0) {
|
||||||
setError(
|
setError(
|
||||||
buildingsSkipped > 0
|
buildingsSkipped > 0
|
||||||
? t("ctxImport.buildingsSkipped", { count: buildingsSkipped })
|
? t("ctxImport.buildingsSkipped", { count: buildingsSkipped })
|
||||||
@@ -220,16 +288,26 @@ export function ContextImportDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erster Import in diesem Projekt (noch kein Bezug) -> DIESEN Lauf als
|
// Erster Import in diesem Projekt (noch kein Bezug) -> Bezug speichern:
|
||||||
// Georeferenzierungs-Bezug speichern (Modell-(0,0) = der gesuchte Ort),
|
// Modell-(0,0) entspricht dem gesuchten Ort (LV95 `center`). Damit landet
|
||||||
// damit künftige Importe lagerichtig zueinander bleiben.
|
// der Import nahe dem Ursprung (gut darstellbar) UND der Modell-Export
|
||||||
if (!geoAnchor && origin) {
|
// kann später auf die echten LV95-Koordinaten zurückrechnen.
|
||||||
onSetGeoAnchor({ lv95: { e: origin.e, n: origin.n }, model: { x: 0, y: 0 }, label: selected.label });
|
if (!geoAnchor) {
|
||||||
|
const anchorLabel = hasRef
|
||||||
|
? t("ctxImport.refPoint.anchorLabel", { e: Math.round(refE), n: Math.round(refN) })
|
||||||
|
: selected.label;
|
||||||
|
onSetGeoAnchor({
|
||||||
|
lv95: refLv95,
|
||||||
|
height: hasRefH ? refH : undefined,
|
||||||
|
model: { x: 0, y: 0 },
|
||||||
|
label: anchorLabel,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const objs = featuresToContextObjects(all, selected.label);
|
const objs = featuresToContextObjects(all, selected.label);
|
||||||
objs.push(...extraObjs);
|
objs.push(...extraObjs);
|
||||||
if (terrain) objs.push(terrain);
|
if (terrain) objs.push(terrain);
|
||||||
|
if (image) objs.push(image);
|
||||||
onImport(objs);
|
onImport(objs);
|
||||||
setStatus(
|
setStatus(
|
||||||
buildingsSkipped > 0
|
buildingsSkipped > 0
|
||||||
@@ -252,32 +330,47 @@ export function ContextImportDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const dialog = (
|
||||||
<div className="imp-overlay" onClick={onClose}>
|
|
||||||
<div
|
<div
|
||||||
className="imp-dialog"
|
className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("ctxImport.title")}
|
aria-label={t("ctxImport.title")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={windowed ? undefined : (e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<header className="imp-head">
|
<header
|
||||||
|
className="imp-head"
|
||||||
|
{...(windowed ? { "data-tauri-drag-region": true } : {})}
|
||||||
|
>
|
||||||
<span className="imp-head-title">{t("ctxImport.title")}</span>
|
<span className="imp-head-title">{t("ctxImport.title")}</span>
|
||||||
<button
|
{!windowed && (
|
||||||
className="imp-close"
|
<button
|
||||||
onClick={onClose}
|
className="imp-close"
|
||||||
aria-label={t("ctxImport.close")}
|
onClick={onClose}
|
||||||
>
|
aria-label={t("ctxImport.close")}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="imp-body">
|
<div className="imp-body">
|
||||||
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe
|
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe
|
||||||
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). */}
|
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor).
|
||||||
{geoAnchor && (
|
Ohne Bezug wird ROH importiert (echte LV95-Koordinaten, weit vom
|
||||||
|
Nullpunkt) — ein Hinweis erklärt den Folge-Schritt „Neuer
|
||||||
|
Bezugspunkt". */}
|
||||||
|
{geoAnchor ? (
|
||||||
<div className="cim-geo-anchor">
|
<div className="cim-geo-anchor">
|
||||||
<span>{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}</span>
|
<span>
|
||||||
|
{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}
|
||||||
|
{" — "}
|
||||||
|
{t("ctxImport.geoAnchor.coords", {
|
||||||
|
e: geoAnchor.lv95.e.toFixed(2),
|
||||||
|
n: geoAnchor.lv95.n.toFixed(2),
|
||||||
|
h: typeof geoAnchor.height === "number" ? geoAnchor.height.toFixed(2) : "—",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="imp-linklike"
|
className="imp-linklike"
|
||||||
@@ -287,6 +380,8 @@ export function ContextImportDialog({
|
|||||||
{t("ctxImport.geoAnchor.reset")}
|
{t("ctxImport.geoAnchor.reset")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="cim-geo-anchor cim-geo-hint">{t("ctxImport.rawHint")}</div>
|
||||||
)}
|
)}
|
||||||
{/* Ortssuche */}
|
{/* Ortssuche */}
|
||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
@@ -355,6 +450,45 @@ export function ContextImportDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Bezugspunkt (Modell-Nullpunkt): nur solange noch kein Bezug steht.
|
||||||
|
Leer -> Suchzentrum wird (0,0); gefüllt -> dieser LV95-Punkt wird
|
||||||
|
(0,0) (z. B. amtlicher Vermessungspunkt aus dem Kataster). */}
|
||||||
|
{!geoAnchor && (
|
||||||
|
<section className="imp-section">
|
||||||
|
<div className="imp-section-title">{t("ctxImport.refPoint")}</div>
|
||||||
|
<div className="cim-radius-row">
|
||||||
|
<input
|
||||||
|
className="cim-input cim-input-num"
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
value={refPoint.e}
|
||||||
|
placeholder={t("ctxImport.refPoint.e")}
|
||||||
|
onChange={(e) => setRefPoint((p) => ({ ...p, e: e.target.value }))}
|
||||||
|
aria-label={t("ctxImport.refPoint.e")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="cim-input cim-input-num"
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
value={refPoint.n}
|
||||||
|
placeholder={t("ctxImport.refPoint.n")}
|
||||||
|
onChange={(e) => setRefPoint((p) => ({ ...p, n: e.target.value }))}
|
||||||
|
aria-label={t("ctxImport.refPoint.n")}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="cim-input cim-input-num"
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
value={refPoint.h}
|
||||||
|
placeholder={t("ctxImport.refPoint.h")}
|
||||||
|
onChange={(e) => setRefPoint((p) => ({ ...p, h: e.target.value }))}
|
||||||
|
aria-label={t("ctxImport.refPoint.h")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="cim-geo-hint">{t("ctxImport.refPoint.hint")}</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quellen */}
|
{/* Quellen */}
|
||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
<div className="imp-section-title">{t("ctxImport.sources")}</div>
|
<div className="imp-section-title">{t("ctxImport.sources")}</div>
|
||||||
@@ -397,6 +531,22 @@ export function ContextImportDialog({
|
|||||||
<option value="0.5">{t("ctxImport.terrain.res05")}</option>
|
<option value="0.5">{t("ctxImport.terrain.res05")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="cim-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sources.swissImage}
|
||||||
|
onChange={() => toggle("swissImage")}
|
||||||
|
/>
|
||||||
|
{t("ctxImport.src.swissImage")}
|
||||||
|
</label>
|
||||||
|
<label className="cim-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sources.swissImageDrape}
|
||||||
|
onChange={() => toggle("swissImageDrape")}
|
||||||
|
/>
|
||||||
|
{t("ctxImport.src.swissImageDrape")}
|
||||||
|
</label>
|
||||||
<label className="cim-check">
|
<label className="cim-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -473,6 +623,12 @@ export function ContextImportDialog({
|
|||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (windowed) return dialog;
|
||||||
|
return (
|
||||||
|
<div className="imp-overlay" onClick={onClose}>
|
||||||
|
{dialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Beschriftetes Feld des Ebenen-/Geschoss-InlineEditors (App.tsx) — extrahiert,
|
||||||
|
// damit das native Ebeneneinstellungs-Fenster (native/LayerSettingsWindowApp.tsx)
|
||||||
|
// dasselbe Layout wiederverwendet, ohne den ganzen App.tsx-Modulgraph in das
|
||||||
|
// (bewusst schlanke) Nebenfenster-Bundle zu ziehen.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function EditorField({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="editor-field">
|
||||||
|
<span className="editor-label">{label}</span>
|
||||||
|
<span className="editor-control">{children}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Arbeitsumgebung-Menü (Fenster-/Dock-Layouts) — aktuelles Layout speichern,
|
||||||
|
// benanntes Layout laden/löschen. Extrahiert aus App.tsx, damit SettingsDialog
|
||||||
|
// es sowohl inline (Haupt-App) als auch in seinem eigenen nativen Fenster
|
||||||
|
// (native/SettingsWindowApp.tsx) rendern kann, ohne es als vorgefertigtes
|
||||||
|
// ReactNode über die Fenstergrenze reichen zu müssen (JSX ist nicht
|
||||||
|
// serialisierbar). Speichern/Laden/Löschen laufen direkt über localStorage
|
||||||
|
// (panels/layout.ts) — funktioniert in JEDEM Fenster identisch, da alle
|
||||||
|
// Tauri-Fenster derselben App dieselbe Origin/denselben Storage teilen. Nur
|
||||||
|
// „aktuelles Layout übernehmen" (`onApply`) muss zurück ins Fenster mit dem
|
||||||
|
// echten App-Zustand — daher weiterhin ein Callback-Prop, kein Store-Zugriff
|
||||||
|
// hier.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Dropdown } from "./Dropdown";
|
||||||
|
import type { DropdownItem } from "./Dropdown";
|
||||||
|
import { PromptDialog } from "./PromptDialog";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import {
|
||||||
|
deleteNamedLayout,
|
||||||
|
listNamedLayouts,
|
||||||
|
loadNamedLayout,
|
||||||
|
saveNamedLayout,
|
||||||
|
} from "../panels/layout";
|
||||||
|
import type { LayoutState } from "../panels/types";
|
||||||
|
|
||||||
|
export function LayoutMenu({
|
||||||
|
layout,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
layout: LayoutState;
|
||||||
|
onApply: (layout: LayoutState) => void;
|
||||||
|
}) {
|
||||||
|
const [names, setNames] = useState<string[]>([]);
|
||||||
|
// Name-Erfassung über PromptDialog statt window.prompt (im Tauri-WKWebView
|
||||||
|
// deaktiviert — liefert sofort null, nichts würde je gespeichert).
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const refresh = () => setNames(listNamedLayouts());
|
||||||
|
|
||||||
|
const onSave = () => setSaving(true);
|
||||||
|
const commitSave = (name: string) => {
|
||||||
|
saveNamedLayout(name.trim(), layout);
|
||||||
|
refresh();
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aktions-Menü (analog ComboMenu in TopBar.tsx): „Aktuelles speichern" oben,
|
||||||
|
// dann je eine Gruppe „Laden" und „Löschen" (Trenner statt <optgroup>, die
|
||||||
|
// die Dropdown-Komponente nicht kennt).
|
||||||
|
const items: DropdownItem[] = [];
|
||||||
|
items.push({ label: t("layout.saveCurrent"), onSelect: onSave });
|
||||||
|
if (names.length > 0) {
|
||||||
|
items.push({ divider: true });
|
||||||
|
for (const n of names) {
|
||||||
|
items.push({
|
||||||
|
label: n,
|
||||||
|
onSelect: () => {
|
||||||
|
const loaded = loadNamedLayout(n);
|
||||||
|
if (loaded) onApply(loaded);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({ divider: true });
|
||||||
|
for (const n of names) {
|
||||||
|
items.push({
|
||||||
|
label: `✕ ${n}`,
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => {
|
||||||
|
deleteNamedLayout(n);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span onPointerDownCapture={refresh}>
|
||||||
|
<Dropdown items={items} placeholder={t("layout.placeholder")} title={t("layout.title")} />
|
||||||
|
{saving && (
|
||||||
|
<PromptDialog
|
||||||
|
title={t("layout.title")}
|
||||||
|
label={t("layout.savePrompt")}
|
||||||
|
onConfirm={commitSave}
|
||||||
|
onClose={() => setSaving(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2393,6 +2393,14 @@ function WindowStylesTab({
|
|||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
|
<FieldRow label={t("resources.field.innerLayerRecess")}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!win.innerLayerRecess}
|
||||||
|
onChange={(e) => patch({ innerLayerRecess: e.target.checked })}
|
||||||
|
title={t("resources.field.innerLayerRecess.hint")}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
<FieldRow label={t("resources.field.transomHeight")}>
|
<FieldRow label={t("resources.field.transomHeight")}>
|
||||||
<NumberField
|
<NumberField
|
||||||
value={win.transomHeight ?? 0}
|
value={win.transomHeight ?? 0}
|
||||||
@@ -3114,12 +3122,20 @@ export function ResourceManager({
|
|||||||
onClose,
|
onClose,
|
||||||
handlers,
|
handlers,
|
||||||
initialTab,
|
initialTab,
|
||||||
|
windowed = false,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
handlers: ResourceManagerHandlers;
|
handlers: ResourceManagerHandlers;
|
||||||
/** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */
|
/** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */
|
||||||
initialTab?: TabId;
|
initialTab?: TabId;
|
||||||
|
/**
|
||||||
|
* Läuft in einem EIGENEN nativen Fenster (siehe native/resourceWindow.ts)
|
||||||
|
* statt als schwebendes Overlay im Hauptfenster: füllt die Fensterfläche
|
||||||
|
* komplett (kein eigenes Verschieben/Grössenändern per JS — das übernimmt
|
||||||
|
* das OS-Fenster), Kopfzeile bleibt nur als Tab-Träger ohne Drag-Cursor.
|
||||||
|
*/
|
||||||
|
windowed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<TabId>(initialTab ?? "components");
|
const [tab, setTab] = useState<TabId>(initialTab ?? "components");
|
||||||
// Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer
|
// Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer
|
||||||
@@ -3185,30 +3201,44 @@ export function ResourceManager({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="res-window"
|
className={`res-window${windowed ? " res-window-embedded" : ""}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t("resources.label")}
|
aria-label={t("resources.label")}
|
||||||
style={{
|
style={
|
||||||
left: `${rect.x}px`,
|
windowed
|
||||||
top: `${rect.y}px`,
|
? undefined
|
||||||
width: `${rect.w}px`,
|
: {
|
||||||
height: `${rect.h}px`,
|
left: `${rect.x}px`,
|
||||||
}}
|
top: `${rect.y}px`,
|
||||||
|
width: `${rect.w}px`,
|
||||||
|
height: `${rect.h}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<header className="res-head" onPointerDown={onTitlePointerDown}>
|
<header
|
||||||
|
className="res-head"
|
||||||
|
onPointerDown={windowed ? undefined : onTitlePointerDown}
|
||||||
|
// Im eigenen nativen Fenster (randlos, s. BORDERLESS_WINDOW_CHROME)
|
||||||
|
// ist diese Kopfzeile der einzige Ziehbereich — genau wie die
|
||||||
|
// TopBar des Hauptfensters (`data-tauri-drag-region`). Ohne OS-
|
||||||
|
// Titelzeile bleibt so trotzdem EIN Griff zum Verschieben.
|
||||||
|
{...(windowed ? { "data-tauri-drag-region": true } : {})}
|
||||||
|
>
|
||||||
<div className="res-head-title">
|
<div className="res-head-title">
|
||||||
<span className="res-head-icon">
|
<span className="res-head-icon">
|
||||||
<EyeIcon open />
|
<EyeIcon open />
|
||||||
</span>
|
</span>
|
||||||
{t("resources.title")}
|
{t("resources.title")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{!windowed && (
|
||||||
className="res-close"
|
<button
|
||||||
onClick={onClose}
|
className="res-close"
|
||||||
aria-label={t("resources.close")}
|
onClick={onClose}
|
||||||
>
|
aria-label={t("resources.close")}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="res-tabs" role="tablist">
|
<nav className="res-tabs" role="tablist">
|
||||||
@@ -3309,14 +3339,17 @@ export function ResourceManager({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Greifer unten rechts (Größe ändern). */}
|
{/* Greifer unten rechts (Größe ändern) — im eigenen OS-Fenster übernimmt
|
||||||
<div
|
das Betriebssystem das Grössenändern, daher windowed ohne Greifer. */}
|
||||||
className="floating-resize res-resize"
|
{!windowed && (
|
||||||
role="separator"
|
<div
|
||||||
aria-label={t("dock.resizePanel")}
|
className="floating-resize res-resize"
|
||||||
title={t("dock.resizePanel")}
|
role="separator"
|
||||||
onPointerDown={onResizePointerDown}
|
aria-label={t("dock.resizePanel")}
|
||||||
/>
|
title={t("dock.resizePanel")}
|
||||||
|
onPointerDown={onResizePointerDown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,20 +10,26 @@
|
|||||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { ACCENT_SWATCHES } from "../theme/accents";
|
import { ACCENT_SWATCHES } from "../theme/accents";
|
||||||
import type { Project } from "../model/types";
|
|
||||||
import { ColorHexField } from "./ColorHexField";
|
import { ColorHexField } from "./ColorHexField";
|
||||||
|
import { LayoutMenu } from "./LayoutMenu";
|
||||||
import type { RendererMode } from "./StatusBar";
|
import type { RendererMode } from "./StatusBar";
|
||||||
|
import type { LayoutState } from "../panels/types";
|
||||||
|
import type { ThemeMode } from "../theme/themeMode";
|
||||||
|
|
||||||
export interface SettingsDialogProps {
|
export interface SettingsDialogProps {
|
||||||
|
/** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
marqueeColor: string;
|
marqueeColor: string;
|
||||||
onMarqueeColorChange: (hex: string) => void;
|
onMarqueeColorChange: (hex: string) => void;
|
||||||
snapColor: string;
|
snapColor: string;
|
||||||
onSnapColorChange: (hex: string) => void;
|
onSnapColorChange: (hex: string) => void;
|
||||||
project: Project;
|
/** Referenzhöhe des Erdgeschosses (m ü. M.) — statt des ganzen Projekts nur
|
||||||
setProject: (next: Project | ((p: Project) => Project)) => void;
|
* dieses eine Feld + sein Setter (schmale Grenzfläche, s. `windowed`). */
|
||||||
|
referenceElevationMasl: number | undefined;
|
||||||
|
onReferenceElevationChange: (n: number | undefined) => void;
|
||||||
/** Aktuell gewählter Plan-Renderer (aus dem `?engine`-URL-Param abgeleitet). */
|
/** Aktuell gewählter Plan-Renderer (aus dem `?engine`-URL-Param abgeleitet). */
|
||||||
rendererMode: RendererMode;
|
rendererMode: RendererMode;
|
||||||
/** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */
|
/** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */
|
||||||
@@ -33,23 +39,37 @@ export interface SettingsDialogProps {
|
|||||||
* Live-Remount der Renderer-Hooks).
|
* Live-Remount der Renderer-Hooks).
|
||||||
*/
|
*/
|
||||||
onRendererModeChange: (mode: RendererMode) => void;
|
onRendererModeChange: (mode: RendererMode) => void;
|
||||||
/** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */
|
/** Arbeitsumgebung (Fenster-/Dock-Layouts) — LayoutMenu wird HIER gerendert
|
||||||
layoutMenu: ReactNode;
|
* (nicht mehr als vorgefertigtes ReactNode hereingereicht: JSX ist über eine
|
||||||
|
* Fenstergrenze nicht serialisierbar, s. `windowed`). */
|
||||||
|
layout: LayoutState;
|
||||||
|
onApplyLayout: (layout: LayoutState) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Läuft in einem EIGENEN nativen Fenster (siehe native/settingsWindow.ts)
|
||||||
|
* statt als abgedunkelter Overlay-Dialog im Hauptfenster: kein Backdrop/
|
||||||
|
* Klick-aussen-schliesst (kein „aussen" in einem echten OS-Fenster), füllt
|
||||||
|
* die Fensterfläche, Kopfzeile wird zum Ziehbereich statt zum Schliessen-Klick.
|
||||||
|
*/
|
||||||
|
windowed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsDialog({
|
export function SettingsDialog({
|
||||||
|
themeMode,
|
||||||
|
onThemeModeChange,
|
||||||
marqueeColor,
|
marqueeColor,
|
||||||
onMarqueeColorChange,
|
onMarqueeColorChange,
|
||||||
snapColor,
|
snapColor,
|
||||||
onSnapColorChange,
|
onSnapColorChange,
|
||||||
project,
|
referenceElevationMasl,
|
||||||
setProject,
|
onReferenceElevationChange,
|
||||||
rendererMode,
|
rendererMode,
|
||||||
webGpuAvailable,
|
webGpuAvailable,
|
||||||
onRendererModeChange,
|
onRendererModeChange,
|
||||||
layoutMenu,
|
layout,
|
||||||
|
onApplyLayout,
|
||||||
onClose,
|
onClose,
|
||||||
|
windowed = false,
|
||||||
}: SettingsDialogProps) {
|
}: SettingsDialogProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -64,47 +84,49 @@ export function SettingsDialog({
|
|||||||
// gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das
|
// gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das
|
||||||
// Projekt von aussen ändert (z. B. Undo/Import).
|
// Projekt von aussen ändert (z. B. Undo/Import).
|
||||||
const [elevationText, setElevationText] = useState(
|
const [elevationText, setElevationText] = useState(
|
||||||
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
|
referenceElevationMasl != null ? String(referenceElevationMasl) : "",
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setElevationText(
|
setElevationText(referenceElevationMasl != null ? String(referenceElevationMasl) : "");
|
||||||
project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "",
|
}, [referenceElevationMasl]);
|
||||||
);
|
|
||||||
}, [project.referenceElevationMasl]);
|
|
||||||
|
|
||||||
const commitElevation = (raw: string) => {
|
const commitElevation = (raw: string) => {
|
||||||
setElevationText(raw);
|
setElevationText(raw);
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
setProject((p) => ({ ...p, referenceElevationMasl: undefined }));
|
onReferenceElevationChange(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const n = Number(trimmed);
|
const n = Number(trimmed);
|
||||||
if (Number.isFinite(n)) {
|
if (Number.isFinite(n)) {
|
||||||
setProject((p) => ({ ...p, referenceElevationMasl: n }));
|
onReferenceElevationChange(n);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const dialog = (
|
||||||
<div className="imp-overlay" onClick={onClose}>
|
|
||||||
<div
|
<div
|
||||||
className="imp-dialog"
|
className={`imp-dialog${windowed ? " imp-dialog-embedded" : ""}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("settings.title")}
|
aria-label={t("settings.title")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={windowed ? undefined : (e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<header className="imp-head">
|
<header
|
||||||
|
className="imp-head"
|
||||||
|
{...(windowed ? { "data-tauri-drag-region": true } : {})}
|
||||||
|
>
|
||||||
<span className="imp-head-title">{t("settings.title")}</span>
|
<span className="imp-head-title">{t("settings.title")}</span>
|
||||||
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
|
{!windowed && (
|
||||||
×
|
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="imp-body">
|
<div className="imp-body">
|
||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
<div className="imp-section-title">{t("settings.section.workspace")}</div>
|
<div className="imp-section-title">{t("settings.section.workspace")}</div>
|
||||||
{layoutMenu}
|
<LayoutMenu layout={layout} onApply={onApplyLayout} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
@@ -136,6 +158,25 @@ export function SettingsDialog({
|
|||||||
|
|
||||||
<section className="imp-section">
|
<section className="imp-section">
|
||||||
<div className="imp-section-title">{t("settings.section.display")}</div>
|
<div className="imp-section-title">{t("settings.section.display")}</div>
|
||||||
|
<span className="settings-field-label">{t("settings.theme")}</span>
|
||||||
|
<span className="sb-renderer-toggle" role="group" aria-label={t("settings.theme")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`sb-renderer-btn${themeMode === "light" ? " active" : ""}`}
|
||||||
|
aria-pressed={themeMode === "light"}
|
||||||
|
onClick={() => onThemeModeChange("light")}
|
||||||
|
>
|
||||||
|
{t("settings.theme.light")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`sb-renderer-btn${themeMode === "dark" ? " active" : ""}`}
|
||||||
|
aria-pressed={themeMode === "dark"}
|
||||||
|
onClick={() => onThemeModeChange("dark")}
|
||||||
|
>
|
||||||
|
{t("settings.theme.dark")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<AccentColorField
|
<AccentColorField
|
||||||
label={t("settings.marqueeColor")}
|
label={t("settings.marqueeColor")}
|
||||||
hint={t("settings.marqueeColor.hint")}
|
hint={t("settings.marqueeColor.hint")}
|
||||||
@@ -169,12 +210,20 @@ export function SettingsDialog({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="imp-foot">
|
{!windowed && (
|
||||||
<button className="imp-btn primary" onClick={onClose}>
|
<footer className="imp-foot">
|
||||||
{t("export.close")}
|
<button className="imp-btn primary" onClick={onClose}>
|
||||||
</button>
|
{t("export.close")}
|
||||||
</footer>
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (windowed) return dialog;
|
||||||
|
return (
|
||||||
|
<div className="imp-overlay" onClick={onClose}>
|
||||||
|
{dialog}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ import { Dropdown } from "./Dropdown";
|
|||||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||||
import { PromptDialog } from "./PromptDialog";
|
import { PromptDialog } from "./PromptDialog";
|
||||||
import { WindowControls } from "./WindowControls";
|
import { WindowControls } from "./WindowControls";
|
||||||
import { RibbonTabs } from "./ribbon/RibbonBar";
|
|
||||||
import type { RibbonTabId } from "./ribbon/ribbonItems";
|
|
||||||
import { getCommand } from "../commands/registry";
|
|
||||||
import { CommandIcon } from "./ribbon/CommandIcon";
|
|
||||||
import {
|
import {
|
||||||
applyMark,
|
applyMark,
|
||||||
applyPreset,
|
applyPreset,
|
||||||
@@ -57,6 +53,7 @@ export type DetailLevel = "grob" | "mittel" | "fein";
|
|||||||
export type RenderMode =
|
export type RenderMode =
|
||||||
| "shaded"
|
| "shaded"
|
||||||
| "white"
|
| "white"
|
||||||
|
| "contour"
|
||||||
| "textured"
|
| "textured"
|
||||||
| "wireframe"
|
| "wireframe"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
@@ -105,14 +102,11 @@ const ZOOM_CLUSTER_WIDTH = 140;
|
|||||||
export interface TopBarProps {
|
export interface TopBarProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
|
||||||
// Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon-
|
// Kein Ribbon-Tab-Reiter mehr (Werkzeuge leben im ToolsPanel, siehe
|
||||||
// Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State).
|
// ribbonItems.ts/layout.ts v12) — die TopBar ist nur noch die schmale
|
||||||
ribbonTab: RibbonTabId;
|
// globale Leiste: Marke/Menü · Datei/Export/Einstellungen. Ansichts-/Zoom-/
|
||||||
onRibbonTab: (id: RibbonTabId) => void;
|
// Darstellungs-Steuerung folgt direkt darunter (siehe ViewRibbonTab, von
|
||||||
|
// App unconditional gerendert statt über Tab-Umschaltung).
|
||||||
// Ansichts-/Zoom-/Darstellungs-Steuerung ist in den „Ansichten"-Ribbon-Tab
|
|
||||||
// gewandert (siehe ViewRibbonTab). Die TopBar ist nur noch die schmale globale
|
|
||||||
// Leiste: Marke/Ressourcen · Tabs · Text-Formatierung · Datei/Export/Einstell.
|
|
||||||
|
|
||||||
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
/** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */
|
||||||
zoomEnabled: boolean;
|
zoomEnabled: boolean;
|
||||||
@@ -155,8 +149,16 @@ export interface TopBarProps {
|
|||||||
/** Projekt aus einer Datei laden. */
|
/** Projekt aus einer Datei laden. */
|
||||||
onOpenProject: () => void;
|
onOpenProject: () => void;
|
||||||
|
|
||||||
// Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab
|
/** Menüleiste (Bearbeiten/Ansicht/Fenster, {@link AppMenuBar}) — von App
|
||||||
// (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste.
|
* zusammengesetzt (dort leben Undo/Redo/Auswahl/View-State), hier nur
|
||||||
|
* platziert. `undefined` blendet sie aus (z. B. für künftige eingebettete
|
||||||
|
* Kontexte ohne volle Chrome). */
|
||||||
|
menuBar?: React.ReactNode;
|
||||||
|
|
||||||
|
/** Ansichts-/Zoom-/Darstellungs-Steuerung ({@link ViewRibbonTab}) — von App
|
||||||
|
* geliefert (dort leben View-State/Setter), hier direkt in DERSELBEN
|
||||||
|
* schmalen Leiste platziert (kein eigenes Band mehr, Mockup: EINE Zeile). */
|
||||||
|
viewControls?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,18 +193,6 @@ function Sep() {
|
|||||||
return <span className="tb-sep" aria-hidden="true" />;
|
return <span className="tb-sep" aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
|
|
||||||
* MenuIcon). Rein dekorativ — der sprechende Text steckt im `title`/aria-label
|
|
||||||
* des umgebenden Buttons.
|
|
||||||
*/
|
|
||||||
function TbIcon({ name }: { name: string }) {
|
|
||||||
return (
|
|
||||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kleines Kombinations-Menü (Vectorworks-Stil) — gespiegelt von `LayoutMenu`:
|
* Kleines Kombinations-Menü (Vectorworks-Stil) — gespiegelt von `LayoutMenu`:
|
||||||
@@ -288,88 +278,6 @@ function ComboMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kamera-Einstellungen als kleines Popover (Vectorworks-Stil): ein FOV-Regler
|
|
||||||
* für den Sichtwinkel der Perspektivkamera. Öffnet/schliesst per Button; ein
|
|
||||||
* Klick ausserhalb schliesst es. Rein gesteuert über `fov`/`onFov`.
|
|
||||||
*/
|
|
||||||
function CameraMenu({
|
|
||||||
fov,
|
|
||||||
onFov,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
fov: number;
|
|
||||||
onFov: (n: number) => void;
|
|
||||||
disabled: boolean;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
// Popover-Position (fixed), aus dem Button-Rect berechnet — die Oberleiste
|
|
||||||
// hat overflow:hidden und würde ein absolut positioniertes Popover abschneiden.
|
|
||||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
|
||||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onDown = (e: MouseEvent) => {
|
|
||||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onDown);
|
|
||||||
return () => document.removeEventListener("mousedown", onDown);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const next = !open;
|
|
||||||
if (next && btnRef.current) {
|
|
||||||
const r = btnRef.current.getBoundingClientRect();
|
|
||||||
setPos({ left: r.left, top: r.bottom + 6 });
|
|
||||||
}
|
|
||||||
setOpen(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tb-popwrap" ref={wrapRef}>
|
|
||||||
<button
|
|
||||||
ref={btnRef}
|
|
||||||
className={`view-icon${open ? " active" : ""}`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={toggle}
|
|
||||||
title={`${t("view3d.camera")} — ${t("view3d.camera.hint")}`}
|
|
||||||
aria-label={t("view3d.camera")}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<ViewIcon name="camera" />
|
|
||||||
</button>
|
|
||||||
{open && !disabled && (
|
|
||||||
<div
|
|
||||||
className="tb-popover"
|
|
||||||
role="dialog"
|
|
||||||
aria-label={t("view3d.camera")}
|
|
||||||
style={{ left: pos.left, top: pos.top }}
|
|
||||||
>
|
|
||||||
<label className="tb-poprow">
|
|
||||||
<span className="tb-label">
|
|
||||||
{t("view3d.fov")}: {Math.round(fov)}°
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={20}
|
|
||||||
max={90}
|
|
||||||
step={1}
|
|
||||||
value={fov}
|
|
||||||
onChange={(e) => onFov(Number(e.target.value))}
|
|
||||||
title={t("view3d.fov.hint")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */
|
/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */
|
||||||
const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"];
|
const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"];
|
||||||
|
|
||||||
@@ -389,27 +297,42 @@ function IsoCheck() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Elevations-Ansichten (Vorne/Hinten/Links/Rechts/Oben) — als EIN Symbol
|
||||||
|
* + Dropdown zusammengefasst (statt fünf Buchstaben-Knöpfen). */
|
||||||
|
const ELEVATION_VIEWS: View3d[] = ["front", "back", "left", "side", "top"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iso-Oktanten als Split-Button (Vectorworks-Stil): Klick auf das Würfel-Icon
|
* Split-Button mit Dropdown (Vectorworks-Stil): Klick auf das Symbol springt
|
||||||
* springt direkt auf die zuletzt gewählte Iso-Ansicht (Default „iso" =
|
* direkt auf die aktuelle/zuletzt gewählte Ansicht der Gruppe; der schmale
|
||||||
* vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover
|
* Pfeil daneben öffnet ein Popover mit allen Varianten. Generisch für die
|
||||||
* mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt).
|
* „Ansichten" (Elevations) UND „Iso" (Oktanten) — beide teilen dieselbe
|
||||||
* Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`),
|
* robuste Popover-Logik (fixed positioniert, entkommt dem Oberleisten-
|
||||||
* Positionierung wie `CameraMenu` (fixed, kein Portal — entkommt dem
|
* Overflow; schliesst bei Aussenklick).
|
||||||
* `overflow:hidden` der Oberleiste).
|
|
||||||
*/
|
*/
|
||||||
function IsoMenu({
|
function ViewSplitMenu({
|
||||||
view3d,
|
view3d,
|
||||||
onView3d,
|
onView3d,
|
||||||
viewType,
|
viewType,
|
||||||
onViewType,
|
onViewType,
|
||||||
disabled,
|
disabled,
|
||||||
|
variants,
|
||||||
|
fallback,
|
||||||
|
icon,
|
||||||
|
menuLabel,
|
||||||
}: {
|
}: {
|
||||||
view3d: View3d;
|
view3d: View3d;
|
||||||
onView3d: (v: View3d) => void;
|
onView3d: (v: View3d) => void;
|
||||||
viewType: ViewType;
|
viewType: ViewType;
|
||||||
onViewType: (v: ViewType) => void;
|
onViewType: (v: ViewType) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/** Die im Dropdown wählbaren Ansichten dieser Gruppe. */
|
||||||
|
variants: View3d[];
|
||||||
|
/** Ziel des Symbol-Klicks, falls die aktuelle Ansicht nicht zur Gruppe gehört. */
|
||||||
|
fallback: View3d;
|
||||||
|
/** Symbol des Haupt-Knopfs. */
|
||||||
|
icon: React.ReactNode;
|
||||||
|
/** Beschriftung (aria/Tooltip) der Gruppe. */
|
||||||
|
menuLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||||
@@ -427,8 +350,8 @@ function IsoMenu({
|
|||||||
return () => document.removeEventListener("mousedown", onDown);
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso";
|
const current: View3d = variants.includes(view3d) ? view3d : fallback;
|
||||||
const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d);
|
const active = viewType === "perspektive" && variants.includes(view3d);
|
||||||
|
|
||||||
const select = (v: View3d) => {
|
const select = (v: View3d) => {
|
||||||
onView3d(v);
|
onView3d(v);
|
||||||
@@ -448,21 +371,23 @@ function IsoMenu({
|
|||||||
return (
|
return (
|
||||||
<div className="tb-popwrap view-icon-split" ref={wrapRef}>
|
<div className="tb-popwrap view-icon-split" ref={wrapRef}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={`view-icon view-icon-split-main${active ? " active" : ""}`}
|
className={`view-icon view-icon-split-main${active ? " active" : ""}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => select(current)}
|
onClick={() => select(current)}
|
||||||
title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)} — ${t(`view3d.${current}.hint`)}`}
|
title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)} — ${t(`view3d.${current}.hint`)}`}
|
||||||
aria-label={t("view3d.iso")}
|
aria-label={menuLabel}
|
||||||
>
|
>
|
||||||
<ViewIcon name="iso" />
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
ref={caretRef}
|
ref={caretRef}
|
||||||
|
type="button"
|
||||||
className={`view-icon-caret${open ? " active" : ""}`}
|
className={`view-icon-caret${open ? " active" : ""}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
title={disabled ? t("topbar.viewGroup.disabled") : `${t("view3d.iso.menu")} — ${t("view3d.iso.menu.hint")}`}
|
title={disabled ? t("topbar.viewGroup.disabled") : menuLabel}
|
||||||
aria-label={t("view3d.iso.menu")}
|
aria-label={menuLabel}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
@@ -470,29 +395,32 @@ function IsoMenu({
|
|||||||
<path d="M0 0l5 6 5-6z" fill="currentColor" />
|
<path d="M0 0l5 6 5-6z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{open && !disabled && (
|
{open &&
|
||||||
<div
|
!disabled &&
|
||||||
className="ctx-menu tb-dd-menu"
|
createPortal(
|
||||||
role="menu"
|
<div
|
||||||
aria-label={t("view3d.iso.menu")}
|
className="ctx-menu tb-dd-menu"
|
||||||
style={{ left: pos.left, top: pos.top }}
|
role="menu"
|
||||||
>
|
aria-label={menuLabel}
|
||||||
{ISO_VARIANTS.map((v) => (
|
style={{ left: pos.left, top: pos.top }}
|
||||||
<button
|
>
|
||||||
key={v}
|
{variants.map((v) => (
|
||||||
type="button"
|
<button
|
||||||
role="menuitemradio"
|
key={v}
|
||||||
aria-checked={view3d === v}
|
type="button"
|
||||||
className={`ctx-item tb-dd-item${view3d === v ? " is-active" : ""}`}
|
role="menuitemradio"
|
||||||
onClick={() => select(v)}
|
aria-checked={view3d === v}
|
||||||
title={t(`view3d.${v}.hint`)}
|
className={`ctx-item tb-dd-item${view3d === v ? " is-active" : ""}`}
|
||||||
>
|
onClick={() => select(v)}
|
||||||
<span className="tb-dd-check-slot">{view3d === v && <IsoCheck />}</span>
|
title={t(`view3d.${v}.hint`)}
|
||||||
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
|
>
|
||||||
</button>
|
<span className="tb-dd-check-slot">{view3d === v && <IsoCheck />}</span>
|
||||||
))}
|
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
|
||||||
</div>
|
</button>
|
||||||
)}
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -596,6 +524,28 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Symbol für die „Ansichten" (Elevations): eine Fassade — Rechteck mit Tür +
|
||||||
|
* Bodenlinie (statt des unklaren Quadrat-mit-Steg-Icons). */
|
||||||
|
function ElevationIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3.5" y="2.5" width="9" height="10.5" />
|
||||||
|
<rect x="6.4" y="8" width="3.2" height="5" />
|
||||||
|
<line x1="1.5" y1="13" x2="14.5" y2="13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Eine Zelle einer Segmentpille. */
|
/** Eine Zelle einer Segmentpille. */
|
||||||
interface SegmentCell {
|
interface SegmentCell {
|
||||||
/** Stabiler Schlüssel. */
|
/** Stabiler Schlüssel. */
|
||||||
@@ -1059,191 +1009,10 @@ function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[]
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** App-Version (spiegelt src-tauri/tauri.conf.json). Exportiert für appMenu.ts
|
||||||
* Datei-Menü der Oberleiste (Burger, ganz rechts): Speichern/Öffnen des
|
* (native macOS-Menüleiste, „Über Dossier"), damit die Versionsnummer nur an
|
||||||
* Projekts sowie Import (DXF/DWG) und Export (PDF/DXF) — vorher eigene
|
* dieser einen Stelle gepflegt wird. */
|
||||||
* Icon-Pillen im Zoom-Bereich, jetzt gebündelt hinter einem Menü, damit die
|
export const APP_VERSION = "0.1.0";
|
||||||
* Leiste ruhiger bleibt (GTK-Burger-Stil).
|
|
||||||
*/
|
|
||||||
/** 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).
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Kompakte Kamera-Gruppe der Quick-Access-Leiste: NUR das Kamera-Settings-
|
|
||||||
* Popover (FOV), damit es immer erreichbar ist, ohne die Ansichts-Presets
|
|
||||||
* (die bleiben unverändert im „Ansichten"-Tab und im 3D-Ribbon-Tab).
|
|
||||||
*/
|
|
||||||
function QaCameraGroup({
|
|
||||||
viewType,
|
|
||||||
fov,
|
|
||||||
onFov,
|
|
||||||
cameraEnabled,
|
|
||||||
}: {
|
|
||||||
viewType: ViewType;
|
|
||||||
fov: number;
|
|
||||||
onFov: (n: number) => void;
|
|
||||||
cameraEnabled: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="tb-qa-camera" role="toolbar" aria-label={t("topbar.viewGroup")}>
|
|
||||||
<CameraMenu
|
|
||||||
fov={fov}
|
|
||||||
onFov={onFov}
|
|
||||||
disabled={!cameraEnabled || viewType !== "perspektive"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** App-Version (spiegelt src-tauri/tauri.conf.json). */
|
|
||||||
const APP_VERSION = "0.1.0";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export-Sammelmenü: EIN „Export"-Knopf in der Quick-Access-Leiste öffnet ein
|
|
||||||
* Dropdown (wie ein Kontextmenü) mit allen Export-Formaten — ersetzt die früher
|
|
||||||
* einzeln aufgereihten Export-Icons. Popover fixed positioniert (die Oberleiste
|
|
||||||
* hat overflow:hidden), Muster wie {@link CameraMenu}.
|
|
||||||
*/
|
|
||||||
function ExportMenu({
|
|
||||||
onExportPdf,
|
|
||||||
onExportDxf,
|
|
||||||
onExportSchedule,
|
|
||||||
onExportIfc,
|
|
||||||
onExportObj,
|
|
||||||
onExportStl,
|
|
||||||
exportEnabled,
|
|
||||||
}: {
|
|
||||||
onExportPdf: () => void;
|
|
||||||
onExportDxf: () => void;
|
|
||||||
onExportSchedule: () => void;
|
|
||||||
onExportIfc: () => void;
|
|
||||||
onExportObj: () => void;
|
|
||||||
onExportStl: () => void;
|
|
||||||
exportEnabled: boolean;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
|
||||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
// Das Popover hängt per Portal an body (NICHT in wrapRef) — der Outside-Handler
|
|
||||||
// muss es daher separat als „innen" behandeln, sonst schliesst der mousedown
|
|
||||||
// das Menü vor dem Klick.
|
|
||||||
const popRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const onDown = (e: MouseEvent) => {
|
|
||||||
const target = e.target as Node;
|
|
||||||
if (wrapRef.current?.contains(target)) return;
|
|
||||||
if (popRef.current?.contains(target)) return;
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onDown);
|
|
||||||
return () => document.removeEventListener("mousedown", onDown);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const next = !open;
|
|
||||||
if (next && btnRef.current) {
|
|
||||||
const r = btnRef.current.getBoundingClientRect();
|
|
||||||
setPos({ left: r.left, top: r.bottom + 6 });
|
|
||||||
}
|
|
||||||
setOpen(next);
|
|
||||||
};
|
|
||||||
const pick = (fn: () => void) => {
|
|
||||||
setOpen(false);
|
|
||||||
fn();
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: { icon: string; label: string; onClick: () => void; disabled?: boolean }[] = [
|
|
||||||
{ icon: "picture_as_pdf", label: t("file.exportPdf"), onClick: onExportPdf, disabled: !exportEnabled },
|
|
||||||
{ icon: "output", label: t("file.exportDxf"), onClick: onExportDxf, disabled: !exportEnabled },
|
|
||||||
{ icon: "table_view", label: t("file.exportSchedule"), onClick: onExportSchedule },
|
|
||||||
{ icon: "deployed_code", label: t("file.exportIfc"), onClick: onExportIfc },
|
|
||||||
{ icon: "view_in_ar", label: t("file.exportObj"), onClick: onExportObj },
|
|
||||||
{ icon: "landscape", label: t("file.exportStl"), onClick: onExportStl },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="tb-popwrap" ref={wrapRef}>
|
|
||||||
<button
|
|
||||||
ref={btnRef}
|
|
||||||
type="button"
|
|
||||||
className={`tb-qa-btn${open ? " active" : ""}`}
|
|
||||||
onClick={toggle}
|
|
||||||
title={t("file.export")}
|
|
||||||
aria-label={t("file.export")}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<TbIcon name="ios_share" />
|
|
||||||
</button>
|
|
||||||
{open &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
ref={popRef}
|
|
||||||
className="tb-popover tb-menu"
|
|
||||||
role="menu"
|
|
||||||
aria-label={t("file.export")}
|
|
||||||
style={{ left: pos.left, top: pos.top }}
|
|
||||||
>
|
|
||||||
{items.map((it) => (
|
|
||||||
<button
|
|
||||||
key={it.label}
|
|
||||||
type="button"
|
|
||||||
className="ctx-item"
|
|
||||||
role="menuitem"
|
|
||||||
disabled={it.disabled}
|
|
||||||
onClick={() => pick(it.onClick)}
|
|
||||||
>
|
|
||||||
<span className="ctx-item-icon">
|
|
||||||
<TbIcon name={it.icon} />
|
|
||||||
</span>
|
|
||||||
<span className="ctx-item-label">{it.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* „Über"-Dialog (In-App, dunkel) — App-Marke, Version, Kurzbeschreibung und die
|
* „Über"-Dialog (In-App, dunkel) — App-Marke, Version, Kurzbeschreibung und die
|
||||||
@@ -1298,77 +1067,6 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickAccess({
|
|
||||||
onOpenProject,
|
|
||||||
onSaveProject,
|
|
||||||
onImport,
|
|
||||||
onExportPdf,
|
|
||||||
onExportDxf,
|
|
||||||
onExportSchedule,
|
|
||||||
onExportIfc,
|
|
||||||
onExportObj,
|
|
||||||
onExportStl,
|
|
||||||
onToggleResources,
|
|
||||||
onOpenSettings,
|
|
||||||
resourcesOpen,
|
|
||||||
exportEnabled,
|
|
||||||
viewType,
|
|
||||||
fov,
|
|
||||||
onFov,
|
|
||||||
cameraEnabled,
|
|
||||||
}: {
|
|
||||||
onOpenProject: () => void;
|
|
||||||
onSaveProject: () => void;
|
|
||||||
onImport: () => void;
|
|
||||||
onExportPdf: () => void;
|
|
||||||
onExportDxf: () => void;
|
|
||||||
onExportSchedule: () => void;
|
|
||||||
onExportIfc: () => void;
|
|
||||||
onExportObj: () => void;
|
|
||||||
onExportStl: () => void;
|
|
||||||
onToggleResources: () => void;
|
|
||||||
onOpenSettings: () => void;
|
|
||||||
resourcesOpen: boolean;
|
|
||||||
exportEnabled: boolean;
|
|
||||||
viewType: ViewType;
|
|
||||||
fov: number;
|
|
||||||
onFov: (n: number) => void;
|
|
||||||
cameraEnabled: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
|
|
||||||
<QaButton icon="folder_open" label={t("file.open")} onClick={onOpenProject} />
|
|
||||||
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
|
|
||||||
<QaSep />
|
|
||||||
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
|
|
||||||
<ExportMenu
|
|
||||||
onExportPdf={onExportPdf}
|
|
||||||
onExportDxf={onExportDxf}
|
|
||||||
onExportSchedule={onExportSchedule}
|
|
||||||
onExportIfc={onExportIfc}
|
|
||||||
onExportObj={onExportObj}
|
|
||||||
onExportStl={onExportStl}
|
|
||||||
exportEnabled={exportEnabled}
|
|
||||||
/>
|
|
||||||
<QaSep />
|
|
||||||
<QaCameraGroup
|
|
||||||
viewType={viewType}
|
|
||||||
fov={fov}
|
|
||||||
onFov={onFov}
|
|
||||||
cameraEnabled={cameraEnabled}
|
|
||||||
/>
|
|
||||||
<QaSep />
|
|
||||||
<QaButton
|
|
||||||
icon="widgets"
|
|
||||||
label={t("topbar.resources")}
|
|
||||||
onClick={onToggleResources}
|
|
||||||
active={resourcesOpen}
|
|
||||||
/>
|
|
||||||
<QaButton icon="settings" label={t("topbar.settings")} onClick={onOpenSettings} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props des „Ansichten"-Tabs — die Ansichts-/Zoom-/Darstellungs-Steuerung, die
|
* Props des „Ansichten"-Tabs — die Ansichts-/Zoom-/Darstellungs-Steuerung, die
|
||||||
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
|
* früher in der Mitte der TopBar sass. Eine Teilmenge von {@link TopBarProps}.
|
||||||
@@ -1469,70 +1167,61 @@ export function ViewRibbonTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ribbon-views">
|
<div className="ribbon-views">
|
||||||
{/* Ansichten als EIN kompaktes Icon-Grid (2D-Grundriss + 3D-Blicke). */}
|
{/* Reihenfolge: Grundriss · Ansichten · Iso · Axo — ohne Trennstriche
|
||||||
<div className="tb-group" role="group" aria-label={t("topbar.viewGroup")}>
|
(eine zusammenhängende Symbol-Zeile, Nutzer-Wunsch). */}
|
||||||
<div className="view-grid view-grid-4">
|
<span className="tb-viewtoggle" role="group" aria-label={t("topbar.viewGroup")}>
|
||||||
<button
|
<button
|
||||||
className={`view-icon${
|
type="button"
|
||||||
viewToggleEnabled && viewType === "grundriss" ? " active" : ""
|
className={`tb-vt-btn${
|
||||||
}`}
|
viewToggleEnabled && viewType === "grundriss" ? " active" : ""
|
||||||
onClick={() => onViewType("grundriss")}
|
}`}
|
||||||
disabled={!viewToggleEnabled}
|
onClick={() => onViewType("grundriss")}
|
||||||
title={
|
disabled={!viewToggleEnabled}
|
||||||
viewToggleEnabled
|
title={viewToggleEnabled ? t("topbar.grundriss") : t("topbar.viewGroup.disabled")}
|
||||||
? t("topbar.grundriss")
|
aria-label={t("topbar.grundriss")}
|
||||||
: t("topbar.viewGroup.disabled")
|
>
|
||||||
}
|
<ViewIcon name="grundriss" />
|
||||||
aria-label={t("topbar.grundriss")}
|
</button>
|
||||||
>
|
<ViewSplitMenu
|
||||||
<ViewIcon name="grundriss" />
|
view3d={view3d}
|
||||||
</button>
|
onView3d={onView3d}
|
||||||
{(
|
viewType={viewType}
|
||||||
[
|
onViewType={onViewType}
|
||||||
["top", t("view3d.top")],
|
disabled={!viewToggleEnabled}
|
||||||
["iso", t("view3d.iso")],
|
variants={ELEVATION_VIEWS}
|
||||||
["perspective", t("view3d.perspective")],
|
fallback="front"
|
||||||
["front", t("view3d.front")],
|
icon={<ElevationIcon />}
|
||||||
["side", t("view3d.side")],
|
menuLabel={t("topbar.views")}
|
||||||
["back", t("view3d.back")],
|
/>
|
||||||
["left", t("view3d.left")],
|
<ViewSplitMenu
|
||||||
] as [View3d, string][]
|
view3d={view3d}
|
||||||
).map(([key, label]) =>
|
onView3d={onView3d}
|
||||||
key === "iso" ? (
|
viewType={viewType}
|
||||||
<IsoMenu
|
onViewType={onViewType}
|
||||||
key={key}
|
disabled={!viewToggleEnabled}
|
||||||
view3d={view3d}
|
variants={ISO_VARIANTS}
|
||||||
onView3d={onView3d}
|
fallback="iso"
|
||||||
viewType={viewType}
|
icon={<ViewIcon name="iso" />}
|
||||||
onViewType={onViewType}
|
menuLabel={t("view3d.iso.menu")}
|
||||||
disabled={!viewToggleEnabled}
|
/>
|
||||||
/>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<button
|
className={`tb-vt-btn${
|
||||||
key={key}
|
viewToggleEnabled && viewType === "perspektive" && view3d === "perspective"
|
||||||
className={`view-icon${
|
? " active"
|
||||||
viewToggleEnabled && viewType === "perspektive" && view3d === key
|
: ""
|
||||||
? " active"
|
}`}
|
||||||
: ""
|
onClick={() => {
|
||||||
}`}
|
onView3d("perspective");
|
||||||
onClick={() => {
|
if (viewType !== "perspektive") onViewType("perspektive");
|
||||||
onView3d(key);
|
}}
|
||||||
if (viewType !== "perspektive") onViewType("perspektive");
|
disabled={!viewToggleEnabled}
|
||||||
}}
|
title={viewToggleEnabled ? `${t("topbar.axo")} — ${t("topbar.axo.hint")}` : t("topbar.viewGroup.disabled")}
|
||||||
disabled={!viewToggleEnabled}
|
aria-label={t("topbar.axo")}
|
||||||
title={
|
>
|
||||||
viewToggleEnabled
|
<ViewIcon name="perspective" />
|
||||||
? `${label} — ${t(`view3d.${key}.hint`)}`
|
</button>
|
||||||
: t("topbar.viewGroup.disabled")
|
</span>
|
||||||
}
|
|
||||||
aria-label={label}
|
|
||||||
>
|
|
||||||
<ViewIcon name={key} />
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
{/* Sichtbarkeits-Kombinationen (Ebenen-/Zeichnungskombinationen). */}
|
||||||
@@ -1595,6 +1284,7 @@ export function ViewRibbonTab({
|
|||||||
{ value: "shaded", label: t("display.style.shaded") },
|
{ value: "shaded", label: t("display.style.shaded") },
|
||||||
{ value: "shaded-edges", label: t("display.style.shadedEdges") },
|
{ value: "shaded-edges", label: t("display.style.shadedEdges") },
|
||||||
{ value: "white", label: t("display.style.white") },
|
{ value: "white", label: t("display.style.white") },
|
||||||
|
{ value: "contour", label: t("display.style.contour") },
|
||||||
{ value: "textured", label: t("display.style.textured") },
|
{ value: "textured", label: t("display.style.textured") },
|
||||||
{ value: "wireframe", label: t("display.style.wireframe") },
|
{ value: "wireframe", label: t("display.style.wireframe") },
|
||||||
{ value: "hidden", label: t("display.style.hidden") },
|
{ value: "hidden", label: t("display.style.hidden") },
|
||||||
@@ -1613,9 +1303,9 @@ export function ViewRibbonTab({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<Sep />
|
|
||||||
|
|
||||||
{/* Massstab/Zoom-Cluster (2×2). */}
|
{/* Massstab/Zoom + Text-Formatierung OHNE Trennstriche, gleich gross wie
|
||||||
|
die Dropdowns (Mockup). */}
|
||||||
<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${
|
||||||
@@ -1671,8 +1361,6 @@ export function ViewRibbonTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Sep />
|
|
||||||
|
|
||||||
|
|
||||||
{/* Schatten ein/aus — NUR für eine aktive Ansicht (kind "elevation").
|
{/* Schatten ein/aus — NUR für eine aktive Ansicht (kind "elevation").
|
||||||
Schreibt DrawingLevel.shadows; der Ansichts-Generator (toElevation)
|
Schreibt DrawingLevel.shadows; der Ansichts-Generator (toElevation)
|
||||||
@@ -1710,149 +1398,10 @@ export function ViewRibbonTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Ribbon3dTabProps {
|
|
||||||
viewToggleEnabled: boolean;
|
|
||||||
viewType: ViewType;
|
|
||||||
onViewType: (v: ViewType) => void;
|
|
||||||
view3d: View3d;
|
|
||||||
onView3d: (v: View3d) => void;
|
|
||||||
renderMode: RenderMode;
|
|
||||||
onRenderMode: (m: RenderMode) => void;
|
|
||||||
renderModeEnabled: boolean;
|
|
||||||
onRunCommand: (name: string) => void;
|
|
||||||
activeCommand: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
renderMode,
|
|
||||||
onRenderMode,
|
|
||||||
renderModeEnabled,
|
|
||||||
onRunCommand,
|
|
||||||
activeCommand,
|
|
||||||
}: 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")],
|
|
||||||
["back", t("view3d.back")],
|
|
||||||
["left", t("view3d.left")],
|
|
||||||
] as [View3d, string][]
|
|
||||||
).map(([key, label]) =>
|
|
||||||
key === "iso" ? (
|
|
||||||
<IsoMenu
|
|
||||||
key={key}
|
|
||||||
view3d={view3d}
|
|
||||||
onView3d={onView3d}
|
|
||||||
viewType={viewType}
|
|
||||||
onViewType={onViewType}
|
|
||||||
disabled={!viewToggleEnabled}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</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: "shaded-edges", label: t("display.style.shadedEdges") },
|
|
||||||
{ 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>
|
|
||||||
<Sep />
|
|
||||||
<div className="tb-group" role="group" aria-label={t("cmd.extrude.label")}>
|
|
||||||
{(() => {
|
|
||||||
const cmd = getCommand("extrude");
|
|
||||||
const label = cmd ? t(cmd.labelKey) : "extrude";
|
|
||||||
const active = activeCommand === "extrude";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`ribbon-btn${active ? " active" : ""}`}
|
|
||||||
disabled={!viewToggleEnabled}
|
|
||||||
title={viewToggleEnabled ? label : t("tool.floorOnlyDisabled")}
|
|
||||||
onClick={() => onRunCommand("extrude")}
|
|
||||||
>
|
|
||||||
<CommandIcon name="extrude" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
project,
|
project,
|
||||||
ribbonTab,
|
menuBar,
|
||||||
onRibbonTab,
|
viewControls,
|
||||||
zoomEnabled,
|
|
||||||
onExportPdf,
|
|
||||||
onExportDxf,
|
|
||||||
onExportSchedule,
|
|
||||||
onExportIfc,
|
|
||||||
onExportObj,
|
|
||||||
onExportStl,
|
|
||||||
viewType,
|
|
||||||
fov,
|
|
||||||
onFov,
|
|
||||||
cameraEnabled,
|
|
||||||
resourcesOpen,
|
|
||||||
onToggleResources,
|
|
||||||
onOpenSettings,
|
|
||||||
onImport,
|
|
||||||
onSaveProject,
|
|
||||||
onOpenProject,
|
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||||
@@ -1911,36 +1460,17 @@ export function TopBar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
|
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
|
||||||
<QuickAccess
|
{menuBar}
|
||||||
onOpenProject={onOpenProject}
|
|
||||||
onSaveProject={onSaveProject}
|
|
||||||
onImport={onImport}
|
|
||||||
onExportPdf={onExportPdf}
|
|
||||||
onExportDxf={onExportDxf}
|
|
||||||
onExportSchedule={onExportSchedule}
|
|
||||||
onExportIfc={onExportIfc}
|
|
||||||
onExportObj={onExportObj}
|
|
||||||
onExportStl={onExportStl}
|
|
||||||
onToggleResources={onToggleResources}
|
|
||||||
onOpenSettings={onOpenSettings}
|
|
||||||
resourcesOpen={resourcesOpen}
|
|
||||||
exportEnabled={zoomEnabled}
|
|
||||||
viewType={viewType}
|
|
||||||
fov={fov}
|
|
||||||
onFov={onFov}
|
|
||||||
cameraEnabled={cameraEnabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Sep />
|
{/* Ansichts-/Zoom-/Darstellungs-Steuerung direkt in DERSELBEN Zeile
|
||||||
|
(Mockup: EINE schmale Leiste, kein eigenes Band mehr). */}
|
||||||
|
{viewControls && <Sep />}
|
||||||
|
{viewControls}
|
||||||
|
|
||||||
{/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) — sitzen in dieser Zeile,
|
{/* Rechte Gruppe: Datei-/Export-Aktionen (Quick-Access) + Projektname —
|
||||||
der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI
|
per margin-left:auto an den rechten Rand geschoben (Mockup: ⤓ ⤒
|
||||||
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
|
Projektname ganz rechts). Die Fensterknöpfe sitzen ganz links
|
||||||
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
|
(macOS-Titelleisten-Konvention). */}
|
||||||
|
|
||||||
{/* Rechte Gruppe: Projektname. Die Fensterknöpfe sitzen ganz links (macOS-
|
|
||||||
Titelleisten-Konvention); Datei-/Export-Aktionen in der Quick-Access-
|
|
||||||
Leiste; die Arbeitsumgebung (Layout) liegt jetzt in den Einstellungen. */}
|
|
||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
<span className="project-name">{project.name}</span>
|
<span className="project-name">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,6 +116,34 @@ export function CommandIcon({ name }: { name: string }) {
|
|||||||
<rect x="6" y="6.5" width="4" height="3" strokeDasharray="1.4 1.2" />
|
<rect x="6" y="6.5" width="4" height="3" strokeDasharray="1.4 1.2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case "extrude":
|
||||||
|
// Extrusion: Grundfläche + nach oben herausgezogenes Volumen (Prisma).
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="2.5" y="7.5" width="7" height="5" />
|
||||||
|
<path d="M2.5 7.5 L5.5 4.5 L12.5 4.5 L9.5 7.5" />
|
||||||
|
<path d="M9.5 12.5 L12.5 9.5 L12.5 4.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "sectionline":
|
||||||
|
// Schnittlinie: Achse mit Blickrichtungs-Winkeln (SIA-Schnittsymbol).
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<line x1="2.5" y1="8" x2="13.5" y2="8" />
|
||||||
|
<polyline points="4.5,4.5 4.5,8 8,8" />
|
||||||
|
<polyline points="11.5,11.5 11.5,8 8,8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "viewline":
|
||||||
|
// Ansichtslinie: Standpunkt (Kreis) mit Blickkegel.
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="4" cy="8" r="1.6" />
|
||||||
|
<line x1="5.4" y1="8" x2="13" y2="4" />
|
||||||
|
<line x1="5.4" y1="8" x2="13" y2="12" />
|
||||||
|
<line x1="13" y1="4" x2="13" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des
|
// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert die Gruppen
|
||||||
// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine
|
// EINES festen Tabs (kein Tab-Umschalter mehr, v12: Werkzeuge leben im
|
||||||
// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile
|
// ToolsPanel). App rendert mehrere `<RibbonBar tab="...">`-Instanzen permanent
|
||||||
// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop
|
// nebeneinander (2d/bim/custom) statt einer einzigen, umschaltbaren — jede
|
||||||
// hereingereicht (State liegt in App, geteilt mit der TopBar).
|
// zeigt nur die Befehls-Gruppen ohne Panel-Entsprechung.
|
||||||
//
|
//
|
||||||
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
|
// Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie
|
||||||
// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der
|
// im ToolsPanel); Befehls-Elemente über `onRunCommand` (engine.start).
|
||||||
// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar
|
|
||||||
// gemergt) statt datengetriebener Item-Gruppen.
|
|
||||||
//
|
//
|
||||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { ToolIcon } from "../../panels/ToolsPanel";
|
import { ToolIcon } from "../../panels/ToolsPanel";
|
||||||
import { getTool } from "../../tools/tools";
|
import { getTool } from "../../tools/tools";
|
||||||
@@ -43,13 +40,6 @@ export interface RibbonBarProps {
|
|||||||
onSelectTool: (id: ToolId) => void;
|
onSelectTool: (id: ToolId) => void;
|
||||||
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
|
/** Engine-Befehl starten (Ändern-Gruppe u. a.). */
|
||||||
onRunCommand: (name: string) => void;
|
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"). */
|
/** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */
|
||||||
customItems: string[];
|
customItems: string[];
|
||||||
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
|
/** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */
|
||||||
@@ -106,35 +96,6 @@ function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* 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
|
* plus einen „+"-Picker, der ALLE verfügbaren Werkzeuge/Befehle mit Häkchen für
|
||||||
@@ -184,7 +145,6 @@ function CustomBar({
|
|||||||
|
|
||||||
export function RibbonBar(props: RibbonBarProps) {
|
export function RibbonBar(props: RibbonBarProps) {
|
||||||
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
|
const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0];
|
||||||
const custom = props.tabContent?.[current.id];
|
|
||||||
const ctx: ButtonCtx = {
|
const ctx: ButtonCtx = {
|
||||||
activeTool: props.activeTool,
|
activeTool: props.activeTool,
|
||||||
activeCommand: props.activeCommand,
|
activeCommand: props.activeCommand,
|
||||||
@@ -195,9 +155,8 @@ export function RibbonBar(props: RibbonBarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ribbon">
|
<div className="ribbon">
|
||||||
{/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt („Ansichten",
|
{/* Der „Eigene"-Tab rendert die modulare Custom-Bar; sonst die
|
||||||
„Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst
|
datengetriebenen Item-Gruppen. */}
|
||||||
die datengetriebenen Item-Gruppen. */}
|
|
||||||
<div className="ribbon-body">
|
<div className="ribbon-body">
|
||||||
{current.id === "custom" ? (
|
{current.id === "custom" ? (
|
||||||
<CustomBar
|
<CustomBar
|
||||||
@@ -205,8 +164,6 @@ export function RibbonBar(props: RibbonBarProps) {
|
|||||||
onToggle={props.onToggleCustomItem}
|
onToggle={props.onToggleCustomItem}
|
||||||
ctx={ctx}
|
ctx={ctx}
|
||||||
/>
|
/>
|
||||||
) : custom ? (
|
|
||||||
custom
|
|
||||||
) : current.groups.length === 0 ? (
|
) : current.groups.length === 0 ? (
|
||||||
<div className="ribbon-empty">{t("ribbon.empty")}</div>
|
<div className="ribbon-empty">{t("ribbon.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface RibbonGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
|
/** Kennung eines Ribbon-Tabs (fest, für Tab-State + i18n). */
|
||||||
export type RibbonTabId = "views" | "2d" | "3d" | "bim" | "custom";
|
export type RibbonTabId = "2d" | "bim" | "custom";
|
||||||
|
|
||||||
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
|
/** Ein Ribbon-Tab: Beschriftung + gruppierte Elemente. */
|
||||||
export interface RibbonTab {
|
export interface RibbonTab {
|
||||||
@@ -37,41 +37,24 @@ export interface RibbonTab {
|
|||||||
groups: RibbonGroup[];
|
groups: RibbonGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = (id: ToolId): RibbonItem => ({ kind: "tool", id });
|
|
||||||
const c = (name: string): RibbonItem => ({ kind: "command", name });
|
const c = (name: string): RibbonItem => ({ kind: "command", name });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). „Ansichten" ist der
|
* Die Ribbon-Tabs (2D·BIM·Eigene) — keine Tab-Reiter mehr in der UI (v12,
|
||||||
* Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus
|
* siehe layout.ts): App rendert alle drei permanent nebeneinander (kein
|
||||||
* der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font-
|
* Umschalter). Reine Werkzeug-Elemente (`t(...)`, persistentes Werkzeug wie
|
||||||
* Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` — `groups` bleibt leer). 2D
|
* Wand/Linie/Fenster) leben AUSSCHLIESSLICH im ToolsPanel (kein Doppel-
|
||||||
* (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/
|
* Auftritt). Reine Befehls-Elemente (`c(...)`, Einmal-Aktionen wie
|
||||||
* Befehlen; 3D ist vorerst leer (spätere Phase).
|
* Verschieben/Kopieren/Säule setzen ohne eigenen Werkzeug-Modus) bleiben hier,
|
||||||
|
* da sie im Panel keine Entsprechung haben. Ansichts-/Zoom-/Darstellungs-
|
||||||
|
* Steuerung + Text-Formatierung sitzen direkt in App (ViewRibbonTab), nicht
|
||||||
|
* in dieser Registry.
|
||||||
*/
|
*/
|
||||||
export const RIBBON_TABS: RibbonTab[] = [
|
export const RIBBON_TABS: RibbonTab[] = [
|
||||||
{
|
|
||||||
// Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text.
|
|
||||||
id: "views",
|
|
||||||
labelKey: "ribbon.tab.views",
|
|
||||||
groups: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "2d",
|
id: "2d",
|
||||||
labelKey: "ribbon.tab.2d",
|
labelKey: "ribbon.tab.2d",
|
||||||
groups: [
|
groups: [
|
||||||
{
|
|
||||||
titleKey: "ribbon.group.draw",
|
|
||||||
items: [
|
|
||||||
t("select"),
|
|
||||||
t("line"),
|
|
||||||
t("polyline"),
|
|
||||||
t("rect"),
|
|
||||||
t("circle"),
|
|
||||||
t("arc"),
|
|
||||||
t("text"),
|
|
||||||
t("textbox"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
titleKey: "ribbon.group.modify",
|
titleKey: "ribbon.group.modify",
|
||||||
items: [
|
items: [
|
||||||
@@ -87,28 +70,16 @@ export const RIBBON_TABS: RibbonTab[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "3d",
|
|
||||||
labelKey: "ribbon.tab.3d",
|
|
||||||
groups: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "bim",
|
id: "bim",
|
||||||
labelKey: "ribbon.tab.bim",
|
labelKey: "ribbon.tab.bim",
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
|
// "extrude" war vormals im (jetzt entfernten) 3D-Ribbon-Tab
|
||||||
|
// (Ribbon3dTab) — einziger Zugang zum Befehl, daher hier statt
|
||||||
|
// gestrichen.
|
||||||
titleKey: "ribbon.group.components",
|
titleKey: "ribbon.group.components",
|
||||||
items: [
|
items: [c("column"), c("ceilingopening"), c("roof"), c("extrude")],
|
||||||
t("wall"),
|
|
||||||
t("window"),
|
|
||||||
t("door"),
|
|
||||||
t("stair"),
|
|
||||||
c("column"),
|
|
||||||
t("ceiling"),
|
|
||||||
c("ceilingopening"),
|
|
||||||
c("roof"),
|
|
||||||
t("room"),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titleKey: "ribbon.group.sections",
|
titleKey: "ribbon.group.sections",
|
||||||
|
|||||||
@@ -135,9 +135,22 @@ export function Viewport3D(
|
|||||||
* Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr
|
* Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr
|
||||||
* eigenes Material hervor (highlightMat in ThreeViewport3D). */
|
* eigenes Material hervor (highlightMat in ThreeViewport3D). */
|
||||||
highlightLines?: Float32Array | null;
|
highlightLines?: Float32Array | null;
|
||||||
|
/** Aktive 3D-Schnittebene (nur WASM-Pfad) — s. Wasm3DViewport `section3d`.
|
||||||
|
* Bindet den Live-Schnitt an eine Grundriss-Schnittlinie. */
|
||||||
|
section3d?: { point: [number, number, number]; normal: [number, number, number] } | null;
|
||||||
|
/** Klick auf den gebundenen Schnitt-Knopf im WASM-Viewport → Bindung lösen. */
|
||||||
|
onClearSection3d?: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { gridVisible, onToggleGrid, onPick3d, highlightLines, ...threeProps } = props;
|
const {
|
||||||
|
gridVisible,
|
||||||
|
onToggleGrid,
|
||||||
|
onPick3d,
|
||||||
|
highlightLines,
|
||||||
|
section3d,
|
||||||
|
onClearSection3d,
|
||||||
|
...threeProps
|
||||||
|
} = props;
|
||||||
if (WASM_ENGINE_ACTIVE) {
|
if (WASM_ENGINE_ACTIVE) {
|
||||||
return (
|
return (
|
||||||
<Wasm3DViewport
|
<Wasm3DViewport
|
||||||
@@ -162,6 +175,8 @@ export function Viewport3D(
|
|||||||
onEditRoofPitch={props.onEditRoofPitch}
|
onEditRoofPitch={props.onEditRoofPitch}
|
||||||
onEditWallTop={props.onEditWallTop}
|
onEditWallTop={props.onEditWallTop}
|
||||||
onEditEnd={props.onEditEnd}
|
onEditEnd={props.onEditEnd}
|
||||||
|
section3d={section3d}
|
||||||
|
onClearSection3d={onClearSection3d}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,14 @@ function fitTargetDist(project: Project): {
|
|||||||
grow(p[0], s.zTop, p[1]);
|
grow(p[0], s.zTop, p[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Meshes (Dächer, Extrusionen, Stützen UND importierte Gebäude/Terrain) —
|
||||||
|
// ohne die kann „Alles einpassen" den (evtl. weit entfernten) Import nicht
|
||||||
|
// rahmen: model (x,y,z=Höhe) → world (x, z, y). Nutzer-Report: „im 3D
|
||||||
|
// funktioniert das Fokussieren / Alles-anzeigen nicht".
|
||||||
|
for (const m of model.meshes) {
|
||||||
|
const pos = m.positions;
|
||||||
|
for (let i = 0; i + 2 < pos.length; i += 3) grow(pos[i], pos[i + 2], pos[i + 1]);
|
||||||
|
}
|
||||||
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start und dem
|
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start und dem
|
||||||
// Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box).
|
// Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box).
|
||||||
if (!Number.isFinite(minX)) {
|
if (!Number.isFinite(minX)) {
|
||||||
@@ -334,6 +342,8 @@ export function Wasm3DViewport({
|
|||||||
onEditRoofPitch,
|
onEditRoofPitch,
|
||||||
onEditWallTop,
|
onEditWallTop,
|
||||||
onEditEnd,
|
onEditEnd,
|
||||||
|
section3d = null,
|
||||||
|
onClearSection3d,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
||||||
@@ -393,6 +403,12 @@ export function Wasm3DViewport({
|
|||||||
onEditWallTop?: (wallId: string, topZ: number) => void;
|
onEditWallTop?: (wallId: string, topZ: number) => void;
|
||||||
/** Ende eines Griff-Drags. */
|
/** Ende eines Griff-Drags. */
|
||||||
onEditEnd?: () => void;
|
onEditEnd?: () => void;
|
||||||
|
/** Aktive 3D-Schnittebene (world point+normal) aus der gebundenen Schnittlinie
|
||||||
|
* (App-State, `sectionPlaneFromLevel`), oder `null` = kein Live-Schnitt.
|
||||||
|
* Ersetzt den früheren lokalen Hardcode-Toggle. */
|
||||||
|
section3d?: { point: [number, number, number]; normal: [number, number, number] } | null;
|
||||||
|
/** Klick auf den Viewport-Schnitt-Knopf: hebt die Bindung auf (App löscht die Id). */
|
||||||
|
onClearSection3d?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
|
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
|
||||||
@@ -438,11 +454,9 @@ export function Wasm3DViewport({
|
|||||||
ready,
|
ready,
|
||||||
failed,
|
failed,
|
||||||
} = useWasm3dRenderer(canvasRef, true);
|
} = useWasm3dRenderer(canvasRef, true);
|
||||||
// Live-Schnitt-Zustand (lokaler Overlay-Umschalter, MVP): vertikale
|
// Live-Schnitt: die aktive Ebene kommt jetzt aus `section3d` (App-State, an
|
||||||
// Schnittebene durch die Modellmitte, Blick +Y (Grundriss-Y = world Z, s.
|
// eine Schnittlinie gebunden — `sectionPlaneFromLevel`) statt aus einem
|
||||||
// section.rs `looking_plus_y`: normal = [0,0,1]). Kein Store-/App-Prop —
|
// lokalen Hardcode-Toggle. `null` = kein Schnitt.
|
||||||
// reine Viewport-Ansichts-Option analog zum Grid-Overlay.
|
|
||||||
const [sectionActive, setSectionActive] = useState(false);
|
|
||||||
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
|
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
|
||||||
// direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der
|
// direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der
|
||||||
// freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar).
|
// freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar).
|
||||||
@@ -515,36 +529,33 @@ export function Wasm3DViewport({
|
|||||||
}, [ready, renderMode, setRenderStyle]);
|
}, [ready, renderMode, setRenderStyle]);
|
||||||
|
|
||||||
// Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/
|
// Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/
|
||||||
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m /
|
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (300 m /
|
||||||
// 1 m) — ausreichend fuer die ueblichen Grundriss-Groessen dieses Spikes.
|
// 1 m) — bei den bisherigen 40 m lief man bei flachem Blickwinkel (Iso/
|
||||||
|
// Perspektive) schnell auf den harten Gitterrand (wirkte wie ein Clipping-
|
||||||
|
// Bug, war aber nur das Ende der Flaeche). 300 m ist bei gleicher
|
||||||
|
// Liniendichte immer noch sehr billig (nur ein LineList-Puffer).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
setGroundGrid(gridVisible, groundElevation, 40, 1);
|
setGroundGrid(gridVisible, groundElevation, 300, 1);
|
||||||
redrawRef.current();
|
redrawRef.current();
|
||||||
}, [ready, gridVisible, groundElevation, setGroundGrid]);
|
}, [ready, gridVisible, groundElevation, setGroundGrid]);
|
||||||
|
|
||||||
// Live-Schnitt: bei Bereitschaft, Umschalten (`sectionActive`) oder
|
// Live-Schnitt: die gebundene Schnittlinie liefert `section3d` = world
|
||||||
// Modelländerung neu setzen. MVP-Ebene = vertikal durch die Modellmitte
|
// point+normal (vertikale Ebene entlang der Grundriss-Linie, Blickrichtung
|
||||||
// (`fitTargetDist`-Ziel), Blick nach +Y -> Normale [0,0,1] (Grundriss-Y =
|
// aus `directionSign` — s. sectionPlaneFromLevel). Ohne Bindung (`null`) kein
|
||||||
// world Z, s. section.rs `looking_plus_y`). Bei aktivem Schnitt baut Rust die
|
// Schnitt. Bei aktivem Schnitt baut Rust die Schnittflaechen-Schraffur (Caps)
|
||||||
// Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken.
|
// aus den gecachten Waenden/Decken. Neu setzen bei Bereitschaft, Wechsel der
|
||||||
|
// Ebene oder Modelländerung (Caps folgen dem aktuellen Modell).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
// Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar)
|
if (!section3d) {
|
||||||
// — `fitTargetDist` NICHT aufrufen, das würde sonst bei JEDER Projekt-
|
|
||||||
// Änderung (auch waehrend eines Griff-Drags) unnoetig die komplette
|
|
||||||
// Wand-/Oeffnungs-/Decken-Geometrie erneut flatten, nur um eine Bounding-
|
|
||||||
// Box zu ziehen, die `updateModel` fuer denselben `project`-Stand gerade
|
|
||||||
// erst berechnet hat.
|
|
||||||
if (!sectionActive) {
|
|
||||||
setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
|
setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
|
||||||
redrawRef.current();
|
redrawRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { target } = fitTargetDist(project);
|
setSectionPlane(true, section3d.point, section3d.normal);
|
||||||
setSectionPlane(true, target, [0, 0, 1]);
|
|
||||||
redrawRef.current();
|
redrawRef.current();
|
||||||
}, [ready, sectionActive, project, setSectionPlane]);
|
}, [ready, section3d, project, setSectionPlane]);
|
||||||
|
|
||||||
// Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien
|
// Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien
|
||||||
// (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das
|
// (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das
|
||||||
@@ -1030,37 +1041,39 @@ export function Wasm3DViewport({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Live-Schnitt-Umschalter (eigener Overlay-Button, MVP): vertikale
|
{/* Live-Schnitt-Knopf: nur sichtbar, wenn eine Schnittlinie den 3D-Schnitt
|
||||||
Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem
|
treibt (`section3d` gebunden, Bindung im Objekt-Info gesetzt). Klick
|
||||||
Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */}
|
hebt die Bindung auf. Sitzt ueber dem Grid-Button (falls vorhanden). */}
|
||||||
<button
|
{section3d && (
|
||||||
type="button"
|
<button
|
||||||
aria-label={t("viewport3d.section.label")}
|
type="button"
|
||||||
onClick={() => setSectionActive((v) => !v)}
|
aria-label={t("viewport3d.section.label")}
|
||||||
title={t(sectionActive ? "viewport3d.section.hide" : "viewport3d.section.show")}
|
onClick={() => onClearSection3d?.()}
|
||||||
aria-pressed={sectionActive}
|
title={t("viewport3d.section.bound")}
|
||||||
style={{
|
aria-pressed
|
||||||
position: "absolute",
|
style={{
|
||||||
right: 10,
|
position: "absolute",
|
||||||
bottom: onToggleGrid ? 48 : 10,
|
right: 10,
|
||||||
width: 30,
|
bottom: onToggleGrid ? 48 : 10,
|
||||||
height: 30,
|
width: 30,
|
||||||
display: "flex",
|
height: 30,
|
||||||
alignItems: "center",
|
display: "flex",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
padding: 0,
|
justifyContent: "center",
|
||||||
borderRadius: 7,
|
padding: 0,
|
||||||
border: "1px solid rgba(255,255,255,0.14)",
|
borderRadius: 7,
|
||||||
background: sectionActive ? "rgba(255,255,255,0.14)" : "rgba(20,20,24,0.65)",
|
border: "1px solid rgba(255,255,255,0.14)",
|
||||||
color: sectionActive ? "#f2f2f2" : "#9a9a9a",
|
background: "rgba(255,255,255,0.14)",
|
||||||
cursor: "pointer",
|
color: "#f2f2f2",
|
||||||
backdropFilter: "blur(4px)",
|
cursor: "pointer",
|
||||||
}}
|
backdropFilter: "blur(4px)",
|
||||||
>
|
}}
|
||||||
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 18 }}>
|
>
|
||||||
content_cut
|
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 18 }}>
|
||||||
</span>
|
content_cut
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onToggleGrid && (
|
{onToggleGrid && (
|
||||||
// Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport`
|
// Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport`
|
||||||
// ist `position: relative` — s. styles.css), stört die Maus-Navigation
|
// ist `position: relative` — s. styles.css), stört die Maus-Navigation
|
||||||
|
|||||||
@@ -17,11 +17,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Project } from "../model/types";
|
import type { ContextImage, Project } from "../model/types";
|
||||||
import { projectToModel3d } from "../plan/toWalls3d";
|
import { projectToModel3d } from "../plan/toWalls3d";
|
||||||
import type { DetailLevel } from "../ui/TopBar";
|
import type { DetailLevel } from "../ui/TopBar";
|
||||||
import { loadEngine3d } from "../engine/engine3d";
|
import { loadEngine3d } from "../engine/engine3d";
|
||||||
import { decodeMaterialTextures } from "./materialTextures";
|
import { decodeMaterialTextures } from "./materialTextures";
|
||||||
|
import { decodeImageToRgba } from "../io/swissImage";
|
||||||
|
|
||||||
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
|
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
|
||||||
export interface Camera3d {
|
export interface Camera3d {
|
||||||
@@ -82,6 +83,22 @@ interface WebModelRendererLike {
|
|||||||
* die Karten belegen Ebenen 1..N (siehe `WallInput.materialIndex`). Optional —
|
* die Karten belegen Ebenen 1..N (siehe `WallInput.materialIndex`). Optional —
|
||||||
* ältere WASM-Builds ohne diesen Export lassen den Aufrufer still zurückfallen. */
|
* ältere WASM-Builds ohne diesen Export lassen den Aufrufer still zurückfallen. */
|
||||||
set_material_textures?(rgba: Uint8Array, layerCount: number): void;
|
set_material_textures?(rgba: Uint8Array, layerCount: number): void;
|
||||||
|
/** Luftbild aufs Gelände drapieren (`web::set_aerial_texture`): `rgba` =
|
||||||
|
* gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von oben = Nord), plus
|
||||||
|
* Modell-Meter-Bbox (min/max, x=Ost/y=Nord). render3d legt es pixelscharf als
|
||||||
|
* Textur über das Terrain-TIN. Optional — ältere WASM-Builds ohne diesen
|
||||||
|
* Export lassen den Aufrufer still zurückfallen. */
|
||||||
|
set_aerial_texture?(
|
||||||
|
rgba: Uint8Array,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
minX: number,
|
||||||
|
minY: number,
|
||||||
|
maxX: number,
|
||||||
|
maxY: number,
|
||||||
|
): void;
|
||||||
|
/** Drapiertes Luftbild löschen (Gelände wieder einfarbig/shaded). Optional. */
|
||||||
|
clear_aerial?(): void;
|
||||||
set_camera(
|
set_camera(
|
||||||
eyeX: number,
|
eyeX: number,
|
||||||
eyeY: number,
|
eyeY: number,
|
||||||
@@ -166,6 +183,9 @@ export function useWasm3dRenderer(
|
|||||||
// erneutes (teures) Dekodieren/Hochladen, wenn sich die Materialien nicht
|
// erneutes (teures) Dekodieren/Hochladen, wenn sich die Materialien nicht
|
||||||
// geändert haben (updateModel läuft bei JEDER Projektänderung).
|
// geändert haben (updateModel läuft bei JEDER Projektänderung).
|
||||||
const materialSigRef = useRef<string | null>(null);
|
const materialSigRef = useRef<string | null>(null);
|
||||||
|
// Signatur des zuletzt hochgeladenen Drape-Luftbilds (src + Bbox) — verhindert
|
||||||
|
// ein erneutes (teures) Dekodieren/Hochladen bei unveränderter Lage.
|
||||||
|
const aerialSigRef = useRef<string | null>(null);
|
||||||
|
|
||||||
/** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). `detail`
|
/** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). `detail`
|
||||||
* steuert den Öffnungs-Detailgrad (grob/mittel/fein) im 3D wie im Grundriss. */
|
* steuert den Öffnungs-Detailgrad (grob/mittel/fein) im 3D wie im Grundriss. */
|
||||||
@@ -197,6 +217,47 @@ export function useWasm3dRenderer(
|
|||||||
console.warn("render3d set_material_textures fehlgeschlagen:", e);
|
console.warn("render3d set_material_textures fehlgeschlagen:", e);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Luftbild aufs Gelände drapieren: ein ContextImage mit `drape:true` wird als
|
||||||
|
// GPU-Textur hochgeladen (render3d legt es pixelscharf über das Terrain).
|
||||||
|
// Wie die Materialien asynchron + signatur-gecacht (Bild ändert sich selten).
|
||||||
|
if (typeof r.set_aerial_texture !== "function") return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const drape = project.context?.find(
|
||||||
|
(o): o is ContextImage => o.type === "image" && o.drape === true,
|
||||||
|
);
|
||||||
|
const sig = drape
|
||||||
|
? `${drape.src.length}:${drape.src.slice(0, 64)}:${drape.min.x},${drape.min.y},${drape.max.x},${drape.max.y}`
|
||||||
|
: "";
|
||||||
|
if (sig === aerialSigRef.current) return; // unverändert
|
||||||
|
aerialSigRef.current = sig;
|
||||||
|
const cur = rendererRef.current;
|
||||||
|
if (!cur || typeof cur.set_aerial_texture !== "function") return;
|
||||||
|
if (!drape) {
|
||||||
|
cur.clear_aerial?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dec = await decodeImageToRgba(drape.src);
|
||||||
|
const cur2 = rendererRef.current;
|
||||||
|
if (!cur2 || typeof cur2.set_aerial_texture !== "function") return;
|
||||||
|
if (dec) {
|
||||||
|
cur2.set_aerial_texture(
|
||||||
|
dec.data,
|
||||||
|
dec.width,
|
||||||
|
dec.height,
|
||||||
|
drape.min.x,
|
||||||
|
drape.min.y,
|
||||||
|
drape.max.x,
|
||||||
|
drape.max.y,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cur2.clear_aerial?.();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("render3d set_aerial_texture fehlgeschlagen:", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||