diff --git a/PENDENZEN.md b/PENDENZEN.md index 6dd551a..0da4197 100644 --- a/PENDENZEN.md +++ b/PENDENZEN.md @@ -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 ``.) - [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 (``→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 ``) + `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.** @@ -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. - **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen. - [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten): + - ✅ **Phase 1 erledigt 2026-07-17 (Kopplung + Navigation, KEIN Rust — plan: `.claude/plans/cozy-bubbling-goose.md`):** der 3D-Live-Schnitt (`Wasm3DViewport`) folgt jetzt der gewählten Schnittlinie statt einer fest verdrahteten horizontalen Ebene — die Engine kann beliebige Ebenen längst (`set_section_plane(point,normal)`), es fehlte nur die Bindung. App-State `section3dCutId` + abgeleitete `section3dPlane` via `sectionPlaneFromLevel` (world point+normal aus `linePoints`/`directionSign`, `plan/toSection.ts`) → durch `Viewport3D`→`Wasm3DViewport` (neue Props `section3d`/`onClearSection3d`). Schalter „Im 3D schneiden" in der Schnittlinien-Sektion des Objekt-Info-Panels (`host.section3dCutActive`/`onToggleSection3dCut`); der Viewport-Overlay-Knopf ist an die aktive Linie gebunden und hebt sie auf. **Doppelklick auf die Schnittlinie im Grundriss → springt in die 2D-Schnittansicht** (`PlanView.onOpenSectionLevel` → `onSelectLevel`). 2D-Editieren (Endpunkte/Blickrichtung/Tiefe) existierte schon im Objekt-Info. `tsc` + `vitest` 866 grün. **Nutzer prüft visuell in Tauri** (render3d-Pfad). + - ✅ **Phase 2 Teil „eigene Ebene / Sichtbarkeit" erledigt 2026-07-17:** eine unsichtbare Schnitt-/Ansichtsebene (`visible===false`) zeigt ihre Führungslinie im Grundriss nicht mehr (`addSectionLines` in `generatePlan.ts`, +1 Test). Ein-/ausblendbar wie andere Ebenen (Zeichnungsebenen-Panel). + - **Offen Phase 2 (engine-schwer, mit Nutzer im Tauri-Loop):** Farbe der 3D-Schnittebene + **Cut-away-Schleier/Schraffur auf der weggeschnittenen Seite** (wgpu-Shader/`section_fill.rs`/`section.rs`) — blind über Nacht bewusst NICHT gebaut (unverifizierbar ohne Tauri-Sicht, s. Arbeitsstil „Nutzer prüft render3d visuell"). + - **Offen Phase 3 (engine-schwer):** 3D-POV je Schnitt (Augen-/Zielhöhe/Winkel getrennt, pro Projektionsart) im Datenmodell + eigenes 3D-Schnittfenster (Doppelklick 3D-Symbol → Clip+Kamera); koppelt an das Ausschnitte-/Kamera-System. Kamera-Kopplung an `Wasm3DViewport`-Orbit → Tauri-Verifikation nötig. + - Restliche Vision-Bausteine (unverändert offen): - **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln). - **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D. - **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion. @@ -209,6 +214,7 @@ _Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._ +- [x] 2026-07-17 **Schnittebenen 2D↔3D — Phase 1 (Kopplung + Navigation) + Phase-2-Sichtbarkeit** (uncommittet) — der 3D-Live-Schnitt folgt jetzt der gewählten Grundriss-Schnittlinie statt einer fest verdrahteten horizontalen Ebene (`Wasm3DViewport`/`Viewport3D` neue Props `section3d`/`onClearSection3d`; App-State `section3dCutId` → `sectionPlaneFromLevel`). „Im 3D schneiden"-Schalter im Objekt-Info (`host.section3dCutActive`/`onToggleSection3dCut`), Viewport-Knopf an die Linie gebunden. Doppelklick auf die Schnittlinie → 2D-Schnittansicht (`PlanView.onOpenSectionLevel`). Unsichtbare Schnitt-/Ansichtsebene blendet ihre Grundriss-Linie aus (`addSectionLines`). `tsc` + `vitest` 867 grün (+1). Cut-away-Schleier/Farbe (Phase 2) + POV/3D-Schnittfenster (Phase 3) bewusst offen (engine-schwer, Tauri-Verifikation nötig — s. Backlog-Eintrag). Nutzer prüft Phase 1 visuell in Tauri. UI-Nebenarbeit derselben Session (uncommittet): Werkzeug-Panel (Symbole/Liste-Umschalter, neue SIA-Icons, helle Auswahl), Topbar-Quick-Access-Icons entfernt (dupliziert das native Menü), Zahnrad→Einstellungen in Zeichnungsebenen/Ebenen-Köpfen, Footerbar + Snap-Marker + Maß-HUD auf die helle Pillen-Sprache umgestellt. - [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün. - [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün. - [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen. diff --git a/index.html b/index.html index 242f517..c4cecd6 100644 --- a/index.html +++ b/index.html @@ -15,7 +15,7 @@ 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" /> - cad — Phase 0 Spike + Dossier
diff --git a/package.json b/package.json index bf67ff4..9ef7e72 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "cad", + "name": "dossier", "private": true, "version": "0.0.0", "type": "module", diff --git a/public/assets/materials/RoofingTiles013A/ao.jpg b/public/assets/materials/RoofingTiles013A/ao.jpg new file mode 100644 index 0000000..b4e667f Binary files /dev/null and b/public/assets/materials/RoofingTiles013A/ao.jpg differ diff --git a/public/assets/materials/RoofingTiles013A/color.jpg b/public/assets/materials/RoofingTiles013A/color.jpg new file mode 100644 index 0000000..7f11ac7 Binary files /dev/null and b/public/assets/materials/RoofingTiles013A/color.jpg differ diff --git a/public/assets/materials/RoofingTiles013A/displacement.jpg b/public/assets/materials/RoofingTiles013A/displacement.jpg new file mode 100644 index 0000000..78770f6 Binary files /dev/null and b/public/assets/materials/RoofingTiles013A/displacement.jpg differ diff --git a/public/assets/materials/RoofingTiles013A/normal.jpg b/public/assets/materials/RoofingTiles013A/normal.jpg new file mode 100644 index 0000000..d73933c Binary files /dev/null and b/public/assets/materials/RoofingTiles013A/normal.jpg differ diff --git a/public/assets/materials/RoofingTiles013A/roughness.jpg b/public/assets/materials/RoofingTiles013A/roughness.jpg new file mode 100644 index 0000000..aa4e5f0 Binary files /dev/null and b/public/assets/materials/RoofingTiles013A/roughness.jpg differ diff --git a/public/assets/materials/manifest.json b/public/assets/materials/manifest.json index 2f017b4..c131344 100644 --- a/public/assets/materials/manifest.json +++ b/public/assets/materials/manifest.json @@ -61,6 +61,18 @@ "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", "name": "Bodenfliesen", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 784a713..b403288 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region).", - "windows": ["main"], + "description": "Basis-Permissions + Fenstersteuerung fuer die randlose (decorations:false) Titelleiste: Minimieren/Maximieren/Schliessen + Ziehen der eigenen Titelleiste (data-tauri-drag-region). Menu: fuer die native macOS-Systemmenueleiste (src/native/appMenu.ts). Webview/Event: fuer die nativen Ressourcen-/Einstellungs-/Zeichnungsebenen-/Ebeneneinstellungs-Fenster + ihre Sync-Bruecken (src/native/*Window.ts) - gilt fuer alle Fensterlabels, da jedes Events senden/empfangen muss.", + "windows": ["main", "resources", "settings", "drawing-levels", "layer-settings", "context-import"], "permissions": [ "core:default", "core:window:allow-minimize", @@ -12,6 +12,10 @@ "core:window:allow-is-maximized", "core:window:allow-close", "core:window:allow-start-dragging", + "core:window:allow-create", + "core:webview:allow-create-webview-window", + "core:menu:default", + "core:event:default", "dialog:allow-save", "fs:allow-write-text-file" ] diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 007ee29..c5800cf 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/render3d/src/edges.rs b/src-tauri/render3d/src/edges.rs index c8e4d90..e22074a 100644 --- a/src-tauri/render3d/src/edges.rs +++ b/src-tauri/render3d/src/edges.rs @@ -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] } -/// 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 { +/// Eine Kante samt ihrer deduplizierten Repraesentanten-Normalen (Rueckseiten +/// bereits gepaart, s. `representatives`). Grundlage BEIDER Kanten-Stile: die +/// statischen Feature-Edges (`build_mesh_edges`) UND die blickabhaengigen +/// Silhouetten-Konturen (`build_silhouette_edges`). +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 { if mesh.indices.len() < 3 { return Vec::new(); } @@ -137,20 +147,83 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec { } } - let mut verts: Vec = Vec::new(); - let mut push = |p: [f32; 3]| { - verts.push(p[0]); - verts.push(p[1]); - verts.push(p[2]); - verts.push(EDGE_COLOR[0]); - verts.push(EDGE_COLOR[1]); - verts.push(EDGE_COLOR[2]); - }; + edges + .into_values() + .map(|rec| EdgeAdj { + p0: rec.p0, + p1: rec.p1, + reps: representatives(&rec.normals), + }) + .collect() +} - for rec in edges.values() { - if should_draw(&rec.normals) { - push(rec.p0); - push(rec.p1); +fn push_edge(verts: &mut Vec, p0: [f32; 3], p1: [f32; 3]) { + for p in [p0, p1] { + verts.extend_from_slice(&[p[0], p[1], p[2], EDGE_COLOR[0], EDGE_COLOR[1], EDGE_COLOR[2]]); + } +} + +/// 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 { + let adj = build_edge_adjacency(mesh); + let mut verts: Vec = 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 { + let mut verts: Vec = 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 @@ -171,15 +244,18 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec { /// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe /// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon, /// ob doppelseitig gerendert wurde oder nicht. -fn should_draw(normals: &[[f32; 3]]) -> bool { - let mut representatives: Vec<[f32; 3]> = Vec::new(); +/// Dedupliziert die angrenzenden Normalen zu Repraesentanten: paart die von der +/// 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()]; for i in 0..normals.len() { if used[i] { continue; } used[i] = true; - representatives.push(normals[i]); + reps.push(normals[i]); for j in (i + 1)..normals.len() { if used[j] { 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, 1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck _ => { - // Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt. - for i in 0..representatives.len() { - for j in (i + 1)..representatives.len() { - if dot(representatives[i], representatives[j]) < CREASE_COS { + for i in 0..reps.len() { + for j in (i + 1)..reps.len() { + if dot(reps[i], reps[j]) < CREASE_COS { return true; } } @@ -277,6 +359,57 @@ mod tests { let m = Mesh::default(); 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 = 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)] diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index e9ebee9..b7b4cee 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -12,17 +12,20 @@ use bytemuck::{Pod, Zeroable}; 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::math::{view_projection, Mat4}; 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_fill::{ 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::{ Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, @@ -51,6 +54,11 @@ pub enum RenderStyle { /// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt /// Weiss-Flaechen). 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 { @@ -62,6 +70,7 @@ impl RenderStyle { "wireframe" => RenderStyle::Wireframe, "hidden" => RenderStyle::Hidden, "shaded-edges" => RenderStyle::ShadedEdges, + "contour" => RenderStyle::Contour, _ => RenderStyle::Shaded, } } @@ -72,7 +81,8 @@ impl RenderStyle { fn mesh_mode(self) -> f32 { match self { 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 // gezeichnet), ShadedEdges -> Material. _ => 0.0, @@ -98,7 +108,10 @@ impl RenderStyle { /// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch /// `ShadedEdges`. 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, } +/// 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. const TEXTURE_SIZE: u32 = 256; @@ -268,6 +294,30 @@ pub struct Renderer { /// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur /// Shader/Layout unterscheiden sich. 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, + /// 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, + /// Gecachte Kanten-Adjazenz des Modells (bei `upload_*` erzeugt) — Grundlage + /// der blickabhaengigen Silhouette (Stil `Contour`). Leer = kein Modell. + edge_adj: Vec, + /// Zuletzt gebaute Silhouetten-LineList (Stil `Contour`) + die Kamera-Signatur, + /// fuer die sie gilt — bei Blickwechsel neu abgeleitet (nicht je Frame blind). + silhouette: Option, + silhouette_key: Option<[i64; 6]>, /// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler. /// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt /// 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, /// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight. caps: Option, + /// 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, /// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList /// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber /// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps. @@ -733,6 +789,115 @@ impl Renderer { 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, // damit die im Shader gesampelten Werte im linearen Raum landen (die // Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das @@ -809,6 +974,14 @@ impl Renderer { cap_pipeline, cut_line_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, texture_bind_group_layout, @@ -823,6 +996,7 @@ impl Renderer { grid: None, highlight: None, caps: None, + wall_end_caps: None, cut_lines: None, grid_visible: false, style: RenderStyle::Shaded, @@ -859,19 +1033,63 @@ impl Renderer { build_scene_mesh(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 /// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer /// die Stile wireframe/hidden — einmalig hier, nicht je Frame. 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() { self.mesh = None; self.textured_mesh = None; self.edges = None; + self.edge_adj = Vec::new(); 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. let edge_verts = build_mesh_edges(&mesh); self.edges = if edge_verts.is_empty() { @@ -1111,6 +1329,111 @@ impl Renderer { /// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne /// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder /// 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( &mut self, device: &wgpu::Device, @@ -1307,6 +1630,51 @@ impl Renderer { self.ensure_depth(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 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 // Kanten: sie fuellen die an der Schnittebene weggeschnittene Wunde // 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 -> // das Modell verdeckt das Gitter korrekt). Unabhaengig vom Stil, nur // wenn sichtbar + vorhanden. diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 81764c2..064c6ad 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -55,10 +55,13 @@ pub use types::{ #[cfg(test)] mod tests { 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::{ - 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. @@ -161,6 +164,65 @@ mod tests { 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] fn mehrere_waende_addieren_sich() { let mesh = build_walls_mesh(&[ @@ -840,6 +902,8 @@ mod tests { indices: vec![0, 1, 2], kind, 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]); } + #[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] fn context_mesh_ueberspringt_kaputte_indizes_und_entartung() { let mut mesh = Mesh::default(); @@ -892,6 +1023,8 @@ mod tests { indices: vec![0, 1, 9], kind: MeshKind::Imported, color: None, + material_index: None, + vertex_colors: Vec::new(), }, ); assert_eq!(mesh.vertex_count(), 0); @@ -903,6 +1036,8 @@ mod tests { indices: vec![0, 1, 2], kind: MeshKind::Imported, color: None, + material_index: None, + vertex_colors: Vec::new(), }, ); assert_eq!(mesh.vertex_count(), 0); @@ -1078,5 +1213,14 @@ mod tests { Validator::new(ValidationFlags::all(), Capabilities::all()) .validate(&tex_module) .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:?}")); } } diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index d5d5b5a..84e02e5 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -835,6 +835,160 @@ fn compute_wall_miters(walls: &[WallInput]) -> Vec { 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 = 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> = 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, + 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 { + let free_ends = compute_free_wall_ends(walls); + let mut verts: Vec = 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), /// an ihren echten Achsenenden ggf. gehrt — bitgenau dieselbe Ecken-Konstruktion /// wie `extrude_layer_segment` fuer das Vollstueck (Einzel-Layer, keine @@ -1026,7 +1180,10 @@ pub fn build_scene_mesh_textured( for m in meshes { let before = mesh.vertex_count(); 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); @@ -1063,6 +1220,20 @@ pub fn build_scene_mesh(walls: &[WallInput], slabs: &[SlabInput], meshes: &[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) ────────────────────── /// 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 vcount = pos.len() / 3; 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). let vworld = |i: usize| -> [f32; 3] { let b = i * 3; @@ -1099,6 +1285,7 @@ pub fn append_context_mesh(mesh: &mut Mesh, input: &MeshInput) { let a = vworld(ia); let b = vworld(ib); let c = vworld(ic); + let (ca, cb, cc) = (vcol(ia), vcol(ib), vcol(ic)); // 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 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]; // Vorderseite + Rueckseite (siehe Moduldoc: doppelseitig). - push_ctx_tri(mesh, a, b, c, n, color); - push_ctx_tri(mesh, a, c, b, [-n[0], -n[1], -n[2]], 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]], [ca, cc, cb]); } } @@ -1127,10 +1314,10 @@ fn push_ctx_tri( b: [f32; 3], c: [f32; 3], normal: [f32; 3], - color: Rgb, + colors: [Rgb; 3], ) { 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(&[ p[0], p[1], p[2], normal[0], normal[1], normal[2], color[0], color[1], color[2], ]); diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index c669dc5..8b4f76c 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -220,6 +220,90 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { } "#; +/// 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, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, + mode : vec4, + section_plane : vec4, +}; +@group(0) @binding(0) var 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; +@group(1) @binding(1) var samp : sampler; +struct Aerial { bbox : vec4, }; +@group(1) @binding(2) var aerial : Aerial; + +struct VsIn { + @location(0) position : vec3, + @location(1) normal : vec3, + @location(2) color : vec3, +}; + +struct VsOut { + @builtin(position) clip_pos : vec4, + @location(0) world_normal : vec3, + @location(1) world_pos : vec3, +}; + +@vertex +fn vs_main(in : VsIn) -> VsOut { + var out : VsOut; + out.clip_pos = globals.view_proj * vec4(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 { + // 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(-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(0.24, 0.27, 0.30); + + // UV aus der Welt-Lage + Bild-Bbox. world.x = Ost, world.z = Nord. + let span = vec2(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(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(shaded, 1.0); +} +"#; + /// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline: /// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion /// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline. diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index 30c4597..1af8f50 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -336,6 +336,20 @@ pub struct MeshInput { /// Default-Farbe je `kind` (siehe `effective_color`). #[serde(default)] pub color: Option, + /// 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, + /// 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, } impl MeshInput { @@ -398,8 +412,15 @@ fn default_ortho_half_height() -> f32 { fn default_near() -> f32 { 0.05 } -fn default_far() -> f32 { - 1000.0 +// `pub(crate)`, damit `web::set_camera` denselben Basiswert als Untergrenze fuer +// 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 { diff --git a/src-tauri/render3d/src/web.rs b/src-tauri/render3d/src/web.rs index d5cebdc..76c8c32 100644 --- a/src-tauri/render3d/src/web.rs +++ b/src-tauri/render3d/src/web.rs @@ -114,7 +114,11 @@ pub struct WebModelRenderer { /// Decken. Mirror der TS-`fitTargetDist`-Grow-Schleife (Wasm3DViewport.tsx): /// Wand-Achsenpunkte auf Basis-/Firsthoehe, Decken-Umriss auf Unter-/Oberkante. /// `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 max = [f32::NEG_INFINITY; 3]; 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]); } } + // 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() { return None; } @@ -247,7 +262,7 @@ impl WebModelRenderer { pub fn set_model(&mut self, json: &str) -> Result<(), JsValue> { let model: ModelInput = serde_json::from_str(json) .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 .upload_model(&self.device, &model.walls, &model.slabs, &model.meshes); // 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 /// `eye` aus Orbit-Winkeln/Distanz oder einem Praeset (front/top/side/iso/persp) /// — 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)] pub fn set_camera( &mut self, @@ -316,6 +340,32 @@ impl WebModelRenderer { fov_y: 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 { eye: [eye_x, eye_y, eye_z], target: [target_x, target_y, target_z], @@ -327,6 +377,7 @@ impl WebModelRenderer { }, fov_y, ortho_half_height, + far, ..Camera::default() }; } @@ -455,6 +506,39 @@ impl WebModelRenderer { 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). pub fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cad57c1..d1308db 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "cad", + "productName": "Dossier", "version": "0.1.0", "identifier": "ch.dossier.cad", "build": { @@ -11,12 +11,13 @@ "windows": [ { "label": "main", - "title": "cad", + "title": "Dossier", "width": 1400, "height": 900, "dragDropEnabled": false, "titleBarStyle": "Overlay", - "hiddenTitle": true + "hiddenTitle": true, + "trafficLightPosition": { "x": 20, "y": 22 } } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 054c0db..000e47e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ // fünf DOSSIER-Modi): das Zeichnungsebenen-Panel steuert das 3D, das // Ebenen-Panel die Kategorie-Darstellung in Grundriss UND 3D. -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { collectVisibleCodes, flattenCategories, @@ -49,7 +49,7 @@ import type { } from "./model/types"; import { generatePlan, generateSectionPlan } from "./plan/generatePlan"; import type { CategoryDisplayResolver } from "./plan/generatePlan"; -import { computeSection } from "./plan/toSection"; +import { computeSection, sectionPlaneFromLevel } from "./plan/toSection"; import type { SectionOutput } from "./plan/toSection"; import { generateElevationPlan } from "./plan/toElevation"; import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; @@ -66,6 +66,7 @@ import { savePlanPdf } from "./export/exportPdf"; import { ExportDxfDialog } from "./ui/ExportDxfDialog"; import type { ExportDxfDecision } from "./ui/ExportDxfDialog"; import { savePlanDxf } from "./export/exportDxf"; +import { projectToWorldCoords } from "./model/geoRebase"; import { exportScheduleCsv } from "./export/exportSchedule"; import { exportIfcSpf } from "./export/exportIfc"; import { exportObj, exportStl } from "./export/exportMesh"; @@ -120,16 +121,34 @@ import { } from "./panels/layoutModel"; import { saveFolderPdf } from "./export/layoutPdf"; import { HatchSwatch } from "./ui/hatchPreview"; -import { TopBar, ViewRibbonTab, Ribbon3dTab } from "./ui/TopBar"; -import { RibbonBar } from "./ui/ribbon/RibbonBar"; -import type { RibbonTabId } from "./ui/ribbon/ribbonItems"; -import { Dropdown } from "./ui/Dropdown"; -import type { DropdownItem } from "./ui/Dropdown"; +import { TopBar, ViewRibbonTab } from "./ui/TopBar"; +import { AppMenuBar } from "./ui/AppMenuBar"; +import { EditorField } from "./ui/EditorField"; +import { useNativeAppMenu } from "./native/appMenu"; +import { openResourceWindowNative, useResourceWindowHost } from "./native/resourceWindow"; +import { openSettingsWindowNative, useSettingsWindowHost } from "./native/settingsWindow"; +import { + openDrawingLevelsWindowNative, + useDrawingLevelsWindowHost, +} from "./native/drawingLevelsWindow"; +import { + openLayerSettingsWindowNative, + useLayerSettingsWindowHost, +} from "./native/layerSettingsWindow"; +import { + openContextImportWindowNative, + useContextImportWindowHost, +} from "./native/contextImportWindow"; import { TextEditorDialog } from "./ui/TextEditorDialog"; import { RoomStampEditor } from "./panels/RoomStampEditor"; import { defaultRoomStamp } from "./model/roomStamp"; import type { DetailLevel, RenderMode, View3d, ViewType } from "./ui/TopBar"; import { StatusBar, type RendererMode } from "./ui/StatusBar"; +import { + type ThemeMode, + getStoredThemeMode, + setStoredThemeMode, +} from "./theme/themeMode"; import { getTool } from "./tools/tools"; import { applyAngleConstraint, computeSnap } from "./tools/snapping"; import { @@ -188,24 +207,14 @@ import { FloatingPanel } from "./panels/FloatingPanel"; import { DragOverlay, usePanelDrag } from "./panels/panelDrag"; import { itemDisplay } from "./panels/displayMode"; import { PanelHostContext } from "./panels/types"; -import type { - DisplayMode, - DockId, - LayoutState, - PanelHost, -} from "./panels/types"; +import type { DisplayMode, DockId, PanelHost } from "./panels/types"; import type { PanelHostValue } from "./panels/host"; import { deriveSelection } from "./state/selectionInfo"; import { centroid as roomCentroid } from "./geometry/roomArea"; import { docFromText, plainText } from "./text/richText"; import type { RichTextDoc } from "./text/richText"; -import { - deleteNamedLayout, - listNamedLayouts, - loadNamedLayout, - saveLayout, - saveNamedLayout, -} from "./panels/layout"; +import { revealPanel, saveLayout } from "./panels/layout"; +import { listPanels } from "./panels/registry"; import { deleteDrawingCombo, deleteLayerCombo, @@ -289,31 +298,6 @@ function coupledCommandFor(id: ToolId): string | null { return TOOL_COMMAND[id] ?? null; } -/** localStorage-Schlüssel der modularen Custom-Bar (Ribbon-Tab „Eigene"). */ -const RIBBON_CUSTOM_KEY = "dossier.ribbon.customItems"; - -/** Persistierte Custom-Bar-Item-Schlüssel lesen (leer bei Fehler/Erststart). */ -function loadCustomItems(): string[] { - if (typeof window === "undefined") return []; - try { - const raw = window.localStorage.getItem(RIBBON_CUSTOM_KEY); - const arr = raw ? JSON.parse(raw) : null; - return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : []; - } catch { - return []; - } -} - -/** Custom-Bar-Item-Schlüssel persistieren (Fehler still schlucken). */ -function saveCustomItems(items: string[]): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(RIBBON_CUSTOM_KEY, JSON.stringify(items)); - } catch { - // localStorage nicht verfügbar (privater Modus o. ä.) → ignorieren. - } -} - /** Umriss (bbox) aller Drawing2D-Geometrien in Modell-Metern (null = leer). */ function drawingsBBox( drawings: Drawing2D[], @@ -352,6 +336,9 @@ function contextObjectsBounds(objs: ContextObject[]): Vec2[] | null { for (const obj of objs) { if (obj.type === "contourSet") { for (const c of obj.contours) for (const p of c.pts) acc(p.x, p.y); + } else if (obj.type === "image") { + acc(obj.min.x, obj.min.y); + acc(obj.max.x, obj.max.y); } else { // importedMesh / terrainMesh: flaches (x,y,z…)-Array, Stride 3. const pos = obj.positions; @@ -406,6 +393,8 @@ export default function App() { const notify = useStore((s) => s.notify); const undo = useStore((s) => s.undo); const redo = useStore((s) => s.redo); + const canUndo = useStore((s) => s.canUndo); + const canRedo = useStore((s) => s.canRedo); const activeLevelId = useStore((s) => s.activeLevelId); const setActiveLevelId = useStore((s) => s.setActiveLevelId); const viewType = useStore((s) => s.viewType); @@ -503,22 +492,6 @@ export default function App() { // Einstellungen sowie der laufende Werkzeug-Zustand (Ref, kein Re-Render je // Mausbewegung) und die Live-Vorschau (Draft → Overlay in PlanView). const [activeTool, setActiveTool] = useState("select"); - // Aktiver Ribbon-Tab (2D/3D/BIM/Ansichten). Liegt hier (nicht in der RibbonBar), - // weil die Tab-Reiter in der TopBar-Zeile sitzen und der Ribbon-Inhalt darunter - // — beide teilen sich denselben aktiven Tab. - const [ribbonTab, setRibbonTab] = useState("views"); - // Modulare Custom-Bar (Ribbon-Tab „Eigene"): persistierte Item-Schlüssel. Der - // Picker im Tab wählt Werkzeuge/Befehle an/ab (localStorage-persistiert). - const [customItems, setCustomItems] = useState(loadCustomItems); - const onToggleCustomItem = (key: string) => { - setCustomItems((prev) => { - const next = prev.includes(key) - ? prev.filter((k) => k !== key) - : [...prev, key]; - saveCustomItems(next); - return next; - }); - }; const [activeWallTypeId, setActiveWallTypeId] = useState( project.wallTypes[0].id, ); @@ -586,6 +559,9 @@ export default function App() { // Schnitt-/Ansichtslinien-Auswahl (Einzel-DrawingLevel-Id oder null). const selectedSectionLineId = useStore((s) => s.selectedSectionLineId); const setSelectedSectionLineId = useStore((s) => s.setSelectedSectionLineId); + // Welche Schnittlinie treibt den 3D-Live-Schnitt (reiner Ansichts-Zustand, + // unabhängig von der Auswahl — der Schnitt bleibt beim Klick woanders aktiv). + const [section3dCutId, setSection3dCutId] = useState(null); const updateRoof = useStore((s) => s.updateRoof); const moveRoofGrip = useStore((s) => s.moveRoofGrip); const moveRoofBy = useStore((s) => s.moveRoofBy); @@ -622,6 +598,9 @@ export default function App() { const [menu, setMenu] = useState(null); // Offener Inline-Editor (Ebene bzw. Geschoss) — aus einem Menüpunkt geöffnet. const [editor, setEditor] = useState(null); + // Welcher Ebenen-Code aktuell im nativen Ebeneneinstellungs-Fenster offen + // ist (Tauri) — Pendant zu `editor?.kind === "layer"` im Browser-Fallback. + const [nativeLayerSettingsCode, setNativeLayerSettingsCode] = useState(null); // Offener Raum-Stempel-Editor (Rich-Text) — per Doppelklick/Button geöffnet. const [stampEditorRoomId, setStampEditorRoomId] = useState(null); // Text-Inhalt-Editor: Id des per Doppelklick bearbeiteten Text-2D-Elements. @@ -1837,6 +1816,72 @@ export default function App() { }, }; + // Ob irgendeine löschbare Auswahl ansteht — geteilt zwischen dem Entf- + // Tastatur-Handler unten und dem Menüpunkt „Bearbeiten > Löschen" (AppMenuBar). + const hasSelectionToDelete = () => + !!( + selectedSectionLineId || + selectedDrawingIds.length || + selectedWallIds.length || + selectedCeilingIds.length || + selectedOpeningIds.length || + selectedStairIds.length || + selectedRoomIds.length || + selectedExtrudedSolidIds.length || + selectedColumnIds.length || + selectedRoofIds.length || + selectedContextObjectIds.length + ); + + // Löscht die AKTUELLE Auswahl (alle Kanäle) — geteilte Logik zwischen dem + // Entf-Tastatur-Handler und dem Menüpunkt „Bearbeiten > Löschen". + const deleteSelection = () => { + // Schnittlinie: Entf löscht NUR ihre Linie (linePoints), die Ebene bleibt. + if (selectedSectionLineId) { + patchLevel(selectedSectionLineId, { linePoints: undefined }); + setSelectedSectionLineId(null); + return; + } + const dids = new Set(selectedDrawingIds); + const wids = new Set(selectedWallIds); + const cids = new Set(selectedCeilingIds); + const oids = new Set(selectedOpeningIds); + const stids = new Set(selectedStairIds); + const rmids = new Set(selectedRoomIds); + const exids = new Set(selectedExtrudedSolidIds); + const colids = new Set(selectedColumnIds); + const rfids = new Set(selectedRoofIds); + const ctxids = new Set(selectedContextObjectIds); + setProject((p) => ({ + ...p, + drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)), + walls: p.walls.filter((w) => !wids.has(w.id)), + ceilings: (p.ceilings ?? []).filter((c) => !cids.has(c.id)), + roofs: (p.roofs ?? []).filter((r) => !rfids.has(r.id)), + stairs: (p.stairs ?? []).filter((s) => !stids.has(s.id)), + rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)), + extrudedSolids: (p.extrudedSolids ?? []).filter((s) => !exids.has(s.id)), + columns: (p.columns ?? []).filter((c) => !colids.has(c.id)), + doors: p.doors.filter((dr) => !wids.has(dr.hostWallId)), + // Öffnungen: eigene Auswahl löschen UND alle, deren Host-Wand entfällt. + openings: (p.openings ?? []).filter( + (o) => !oids.has(o.id) && !wids.has(o.hostWallId), + ), + context: (p.context ?? []).filter((o) => !ctxids.has(o.id)), + })); + setSelectedDrawingIds([]); + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + setSelectedRoofIds([]); + setSelectedContextObjectIds([]); + setSelectedSectionLineId(null); + }; + // Tastatur im Auswahl-Werkzeug: Ctrl/Cmd+A = alles auf der aktiven Ebene // wählen; Entf/Backspace = ALLE Selektierten (Wände + 2D-Elemente) löschen. useEffect(() => { @@ -1891,63 +1936,8 @@ export default function App() { } if (e.key !== "Delete" && e.key !== "Backspace") return; if (activeTransform) return; // während einer Transformation nicht löschen - // Schnittlinie: Entf löscht NUR ihre Linie (linePoints), die Ebene bleibt. - if (selectedSectionLineId) { - patchLevel(selectedSectionLineId, { linePoints: undefined }); - setSelectedSectionLineId(null); - e.preventDefault(); - return; - } - if ( - selectedDrawingIds.length || - selectedWallIds.length || - selectedCeilingIds.length || - selectedOpeningIds.length || - selectedStairIds.length || - selectedRoomIds.length || - selectedExtrudedSolidIds.length || - selectedColumnIds.length || - selectedRoofIds.length || - selectedContextObjectIds.length - ) { - const dids = new Set(selectedDrawingIds); - const wids = new Set(selectedWallIds); - const cids = new Set(selectedCeilingIds); - const oids = new Set(selectedOpeningIds); - const stids = new Set(selectedStairIds); - const rmids = new Set(selectedRoomIds); - const exids = new Set(selectedExtrudedSolidIds); - const colids = new Set(selectedColumnIds); - const rfids = new Set(selectedRoofIds); - const ctxids = new Set(selectedContextObjectIds); - setProject((p) => ({ - ...p, - drawings2d: p.drawings2d.filter((d) => !dids.has(d.id)), - walls: p.walls.filter((w) => !wids.has(w.id)), - ceilings: (p.ceilings ?? []).filter((c) => !cids.has(c.id)), - roofs: (p.roofs ?? []).filter((r) => !rfids.has(r.id)), - stairs: (p.stairs ?? []).filter((s) => !stids.has(s.id)), - rooms: (p.rooms ?? []).filter((r) => !rmids.has(r.id)), - extrudedSolids: (p.extrudedSolids ?? []).filter((s) => !exids.has(s.id)), - columns: (p.columns ?? []).filter((c) => !colids.has(c.id)), - doors: p.doors.filter((dr) => !wids.has(dr.hostWallId)), - // Öffnungen: eigene Auswahl löschen UND alle, deren Host-Wand entfällt. - openings: (p.openings ?? []).filter( - (o) => !oids.has(o.id) && !wids.has(o.hostWallId), - ), - context: (p.context ?? []).filter((o) => !ctxids.has(o.id)), - })); - setSelectedDrawingIds([]); - setSelectedWallIds([]); - setSelectedCeilingIds([]); - setSelectedOpeningIds([]); - setSelectedStairIds([]); - setSelectedRoomIds([]); - setSelectedExtrudedSolidIds([]); - setSelectedColumnIds([]); - setSelectedRoofIds([]); - setSelectedContextObjectIds([]); - setSelectedSectionLineId(null); + if (hasSelectionToDelete()) { + deleteSelection(); e.preventDefault(); } }; @@ -2520,6 +2510,7 @@ export default function App() { if (pts) planViewRef.current?.fitBounds(pts); }; const removeContextObject = useStore((s) => s.removeContextObject); + const removeContextObjects = useStore((s) => s.removeContextObjects); const generateTerrain = useStore((s) => s.generateTerrain); const moveGripOf = useStore((s) => s.moveGripOf); const moveElementByOf = useStore((s) => s.moveElementByOf); @@ -2744,6 +2735,7 @@ export default function App() { selectedExtrudedSolidId, selectedColumnId, selectedRoofId, + selectedContextObjectIds.length === 1 ? selectedContextObjectIds[0] : null, ), [ project, @@ -2756,6 +2748,7 @@ export default function App() { selectedExtrudedSolidId, selectedColumnId, selectedRoofId, + selectedContextObjectIds, ], ); @@ -2774,6 +2767,25 @@ export default function App() { [project.drawingLevels, selectedSectionLineId], ); + // Abgeleitete 3D-Schnittebene der gebundenen Schnittlinie (world point+normal + // via sectionPlaneFromLevel) — speist Viewport3D/Wasm3DViewport. Fehlt die + // Linie (gelöscht/ohne linePoints), liefert der Helfer null → kein Schnitt. + const section3dPlane = useMemo(() => { + if (!section3dCutId) return null; + const lvl = project.drawingLevels.find( + (z) => + z.id === section3dCutId && + (z.kind === "section" || z.kind === "elevation"), + ); + return lvl ? sectionPlaneFromLevel(lvl) : null; + }, [project.drawingLevels, section3dCutId]); + + // Bindet den 3D-Live-Schnitt an eine Schnittlinie (Toggle im Objekt-Info). + const onToggleSection3dCut = useCallback( + (id: string) => setSection3dCutId((cur) => (cur === id ? null : id)), + [], + ); + // ── DXF/DWG-Import (Dialog) ─────────────────────────────────────────────── // Eine Datei (Button ODER Drop) öffnet den modalen Import-Dialog. DXF wird als // Text geparst, DWG als ArrayBuffer über LibreDWG-WASM (lazy, asynchron); beide @@ -2949,6 +2961,56 @@ export default function App() { projectFileInputRef.current?.click(); } }; + + // Panel-Liste für „Fenster > Panels" (native Menüleiste) — die Registry ist + // statisch (Panels tragen sich beim Modul-Import ein), daher einmalig. + const nativeMenuPanels = useMemo( + () => listPanels().map((p) => ({ id: p.id, label: t(p.title) })), + [], + ); + + // Native macOS-Systemmenüleiste (oben am Bildschirm) — dieselben Aktionen + // wie AppMenuBar/QuickAccess, nur als echter App-Menü-Baum. No-op ausserhalb + // von Tauri (isTauriRuntime-Guard steckt im Hook selbst). + useNativeAppMenu( + { + onOpenProject, + onSaveProject, + onImport: () => dxfInputRef.current?.click(), + onExportPdf: () => setExportOpen(true), + onExportDxf: () => setExportDxfOpen(true), + onExportSchedule, + onExportIfc, + onExportObj, + onExportStl, + onUndo: undo, + onRedo: redo, + onDeleteSelection: deleteSelection, + onMove: () => startTransformOp("move"), + onMirror: () => startTransformOp("mirror"), + onRotate: () => startTransformOp("rotate"), + onViewType: setViewType, + onView3d: setView3d, + onReferenceLines: setReferenceLines, + onToggleResources: () => toggleResources(), + onOpenSettings: () => openSettings(), + onOpenPanel: (panelId) => setLayout((l) => revealPanel(l, panelId)), + onOpenDrawingLevelsWindow: () => void openDrawingLevelsWindowNative(), + onOpenLayerSettingsWindow: () => openLayerSettings(activeCategoryCode, 0, 0), + onOpenContextImportWindow: () => void openContextImportWindowNative(), + }, + { + canUndo, + canRedo, + hasSelection: hasSelectionToDelete(), + viewType, + view3d, + referenceLines, + resourcesOpen, + }, + nativeMenuPanels, + ); + const onProjectFileChosen = (file: File) => { const r = new FileReader(); r.onload = () => { @@ -3017,6 +3079,8 @@ export default function App() { // Geschoss-Arbeitsebene (OKFF) projiziert und über onWorkplanePoint in // dieselbe Engine wie im Grundriss gespeist (Viewport3D). toolsEnabled: activeLevel.kind === "floor", + activeCommand: engineView.commandName, + onRunCommand, activeWallTypeId, onActiveWallTypeId: setActiveWallTypeId, snap, @@ -3035,6 +3099,8 @@ export default function App() { onAddCategory: addCategory, onLayerContextMenu: (code, x, y) => setMenu({ kind: "layer", code, x, y }), + onOpenLayerSettings: (code, x, y) => openLayerSettings(code, x, y), + onOpenSettings: () => openSettings(), onPatchComponent: patchComponent, onAddComponent: addComponent, onDeleteComponent: deleteComponent, @@ -3390,8 +3456,7 @@ export default function App() { updateOpening(selection.id, { typeId: typeId || undefined }); }, onEditOpeningType: (kind) => { - setResourcesTab(kind === "door" ? "doorStyles" : "windowStyles"); - setResourcesOpen(true); + openResources(kind === "door" ? "doorStyles" : "windowStyles"); }, onOpenOpeningEditor: (id) => setOpeningEditorId(id), // ── Dach-Attribute (nur bei selektiertem Dach) ───────────────────────── @@ -3406,6 +3471,9 @@ export default function App() { onDeleteSectionLine: () => { if (selectedSectionLineId) patchLevel(selectedSectionLineId, { linePoints: undefined }); }, + // 3D-Live-Schnitt an die gewählte Schnittlinie binden (Toggle im Objekt-Info). + section3dCutActive: section3dCutId != null && section3dCutId === sectionLine?.id, + onToggleSection3dCut, // ── Treppen-Attribute (nur bei selektierter Treppe) ──────────────────── onSetStairShape: (shape) => { if (selection?.kind === "stair") updateStair(selection.id, { shape }); @@ -3425,6 +3493,9 @@ export default function App() { onSetStairUp: (up) => { if (selection?.kind === "stair") updateStair(selection.id, { up }); }, + onSetStairArrowStyle: (arrowStyle) => { + if (selection?.kind === "stair") updateStair(selection.id, { arrowStyle }); + }, onSetStairReferenz: (referenz) => { if (selection?.kind === "stair") updateStair(selection.id, { referenz }); }, @@ -3544,6 +3615,7 @@ export default function App() { selectedContextObjectIds, onAddContextObjects: addContextObjectsAndFit, onRemoveContextObject: removeContextObject, + onRemoveContextObjects: removeContextObjects, onGenerateTerrain: generateTerrain, onSetGeoAnchor: (anchor) => { setProject((p) => ({ ...p, geoAnchor: anchor })); @@ -3586,6 +3658,12 @@ export default function App() { case "Extrusion": setSelectedExtrudedSolidIds([id]); break; + case "Dach": + setSelectedRoofIds([id]); + break; + case "Stütze": + setSelectedColumnIds([id]); + break; default: // z. B. Legacy-„Tür"-Datensätze (project.doors) — kein Auswahl-Kanal. return; @@ -3934,6 +4012,7 @@ export default function App() { activeLevelId, activeCategoryCode, activeTool, + engineView.commandName, activeWallTypeId, snap, viewType, @@ -3944,6 +4023,8 @@ export default function App() { selection, sectionLine, selectedSectionLineId, + section3dCutId, + onToggleSection3dCut, selectedViewSnapshotId, selectedContextObjectIds, ]); @@ -4103,6 +4184,29 @@ export default function App() { onSetOverrideRules: setOverrideRules, }; + // Ressourcen-Manager als eigenes natives Fenster (Tauri) statt Overlay im + // Hauptfenster — hält es (falls offen) synchron zum Projekt, wendet dessen + // Befehle auf die ECHTEN Handler oben an (siehe native/resourceWindow.ts). + // No-op ausserhalb von Tauri. + useResourceWindowHost(project, resourceHandlers); + // Öffnet die Ressourcen: unter Tauri das native Zweitfenster, sonst (reiner + // Browser, kein OS-Fenster-Konzept) das bisherige schwebende Overlay. + const openResources = (tab?: ResourceTabId) => { + if (isTauriRuntime()) { + void openResourceWindowNative(tab); + } else { + if (tab) setResourcesTab(tab); + setResourcesOpen(true); + } + }; + const toggleResources = () => { + if (isTauriRuntime()) { + void openResourceWindowNative(); + } else { + setResourcesOpen((v) => !v); + } + }; + // ── Layout-Blatt (DOSSIER A3, Phase 2b): das offene Blatt + Viewport-Handler ─ // Das offene Layout wird live aus dem Projekt gelesen (Single Source of Truth); // wurde es zwischenzeitlich gelöscht, ist es undefined → der Haupt-Viewport @@ -4144,16 +4248,19 @@ export default function App() { itemDisplay(drawingLevelsMode, id === activeLevelId, visible.get(id) ?? false); }, [project.drawingLevels, drawingLevelsMode, activeLevelId]); - // Resolver: je Kategorie-Code die Render-Entscheidung (Kategorien sind nie - // „aktiv"). Wirkt in Grundriss UND 3D. + // Resolver: je Kategorie-Code die Render-Entscheidung. „Aktiv" = die per + // Klick im Ebenen-Panel gewählte Kategorie (activeCategoryCode, wie schon + // in LayersPanel.tsx für die Zeilen-Hervorhebung genutzt). Wirkt in + // Grundriss UND 3D. const categoryDisplay: CategoryDisplayResolver & DisplayResolver = useMemo( - () => (code) => itemDisplay(layersMode, false, codeVisible.get(code) ?? false), - [layersMode, codeVisible], + () => (code) => + itemDisplay(layersMode, code === activeCategoryCode, codeVisible.get(code) ?? false), + [layersMode, codeVisible, activeCategoryCode], ); // Stabiler Schlüssel der 3D-Display-Entscheidungen (Resolver sind nicht // referenzstabil → triggert sonst keinen Neuaufbau). - const displayKey = `${drawingLevelsMode}|${layersMode}|${activeLevelId}`; + const displayKey = `${drawingLevelsMode}|${layersMode}|${activeLevelId}|${activeCategoryCode}`; // Dokument-Tabs der Zeichenflächen-Kopfzeile — ein Tab je Geschoss- // Grundriss (siehe DrawingTabs.tsx). Teilt sich `activeLevelId`/ @@ -4193,6 +4300,84 @@ export default function App() { else url.searchParams.delete("engine"); window.location.assign(url.toString()); }; + + // Hell/Dunkel (Einstellungen → Darstellung). Standard Hell (styles.css); + // Dunkel ist Opt-in und persistiert in localStorage. + const [themeMode, setThemeModeState] = useState(() => getStoredThemeMode()); + const setThemeMode = (mode: ThemeMode) => { + setThemeModeState(mode); + setStoredThemeMode(mode); + }; + + // Einstellungen als eigenes natives Fenster (Tauri) statt Overlay-Dialog — + // hält es (falls offen) synchron zu den betroffenen Feldern, wendet dessen + // Befehle auf die echten Setter an (siehe native/settingsWindow.ts). + useSettingsWindowHost( + { + themeMode, + marqueeColor, + snapColor, + referenceElevationMasl: project.referenceElevationMasl, + rendererMode, + webGpuAvailable, + layout, + }, + { + onThemeModeChange: setThemeMode, + onMarqueeColorChange: setMarqueeColor, + onSnapColorChange: setSnapColor, + onReferenceElevationChange: (n) => + setProject((p) => ({ ...p, referenceElevationMasl: n })), + onRendererModeChange: setRendererMode, + onApplyLayout: (l) => setLayout(reconcileLayout(l)), + }, + ); + const openSettings = () => { + if (isTauriRuntime()) { + void openSettingsWindowNative(); + } else { + setSettingsOpen(true); + } + }; + + // Ebeneneinstellungen als eigenes natives Fenster (DOSSIER-Rhino-Referenz) + // — ersetzt unter Tauri den InlineEditor-Popover, s. native/layerSettingsWindow.ts. + useLayerSettingsWindowHost(project, nativeLayerSettingsCode, patchCategory); + const openLayerSettings = (code: string, x: number, y: number) => { + if (isTauriRuntime()) { + setNativeLayerSettingsCode(code); + void openLayerSettingsWindowNative(code); + } else { + setEditor({ kind: "layer", code, x, y }); + } + }; + + // Standort-Import als eigenes natives Fenster — s. native/contextImportWindow.ts. + useContextImportWindowHost(project.geoAnchor, { + onImport: (objs) => addContextObjectsAndFit(objs as ContextObject[]), + onSetGeoAnchor: (anchor) => setProject((p) => ({ ...p, geoAnchor: anchor })), + }); + + // Zeichnungsebenen-Manager als eigenes natives Fenster (DOSSIER-Rhino- + // Referenz) — additiv NEBEN dem bestehenden Dock-Panel, siehe + // native/drawingLevelsWindow.ts Kopfkommentar (Rechtsklick-Kontextmenü + // bewusst nicht mitgenommen). + useDrawingLevelsWindowHost( + { + drawingLevels: project.drawingLevels, + activeLevelId, + displayMode: drawingLevelsMode, + }, + { + onSelectLevel: setActiveLevelId, + onToggleLevel: toggleLevelVisible, + onAddFloor: addFloor, + onAddSection: addSection, + onAddElevation: addElevation, + onAddDrawing: addDrawing, + onSetDisplayMode: (mode) => setModeFor(BUILTIN_PANEL_IDS.drawingLevels, mode), + }, + ); // Übernimmt ein Zeichenwerkzeug ODER eine laufende Transformation die linke // Maustaste der Plan-Ansicht? Dann liegt der Pointer-Input beim Tool/Transform // (statt Auswahl/Marquee), und die Editier-Griffe sind ausgeblendet. @@ -4299,8 +4484,14 @@ export default function App() { // schreibt ihn als Modell-Space-DXF in echten Metern (1:1). Layer aus den // Kategorie-Ebenen; jede Entity auf ihrem Kategorie-Layer. const onExportDxf = (decision: ExportDxfDecision) => { + // Georeferenziert exportieren: bei gesetztem Standort-Bezug (geoAnchor) + // schreibt der Modell-DXF die ECHTEN LV95-Koordinaten (weit vom Nullpunkt), + // damit die Datei in anderer CAD-Software lagerichtig ankommt — auch wenn + // lokal nahe (0,0) gezeichnet wurde. Ohne Bezug bleibt alles lokal. + // (Der Papier-/Layout-Export bleibt bewusst in lokalen Koordinaten.) + const exportProject = projectToWorldCoords(project); const plan = generatePlan( - project, + exportProject, activeLevel.id, visibleCodes, categoryDisplay, @@ -4308,7 +4499,7 @@ export default function App() { referenceLines, planColorMode === "mono", ); - savePlanDxf(plan, project, { + savePlanDxf(plan, exportProject, { title: activeLevel.name, includeHatches: decision.includeHatches, fileName: `${project.name}-${activeLevel.name}`, @@ -4469,7 +4660,32 @@ export default function App() { } setEditTextId(null); }; + // Kategorie-Code des getroffenen Elements (für die Sperr-Prüfung unten) — + // nur die Kanäle, deren Modell-Typ eine categoryCode trägt (ExtrudedSolid/ + // Kontext-Objekte bewusst ohne, siehe deren Typdefinitionen). + const categoryCodeOfSelection = (sel: PlanSelection): string | undefined => { + if (sel.wallId) return project.walls.find((w) => w.id === sel.wallId)?.categoryCode; + if (sel.ceilingId) + return (project.ceilings ?? []).find((c) => c.id === sel.ceilingId)?.categoryCode; + if (sel.openingId) + return (project.openings ?? []).find((o) => o.id === sel.openingId)?.categoryCode; + if (sel.stairId) + return (project.stairs ?? []).find((s) => s.id === sel.stairId)?.categoryCode; + if (sel.roomId) return (project.rooms ?? []).find((r) => r.id === sel.roomId)?.categoryCode; + if (sel.columnId) + return (project.columns ?? []).find((c) => c.id === sel.columnId)?.categoryCode; + if (sel.roofId) return (project.roofs ?? []).find((r) => r.id === sel.roofId)?.categoryCode; + if (sel.drawingId) + return project.drawings2d.find((d) => d.id === sel.drawingId)?.categoryCode; + return undefined; + }; + const onPlanSelect = (sel: PlanSelection) => { + // Darstellungsmodus „Andere grau & gesperrt": ein gesperrtes Element lässt + // sich nicht anwählen (und damit auch nicht verschieben/bearbeiten) — der + // Klick wird ignoriert, als läge dort nichts. + const hitCode = categoryCodeOfSelection(sel); + if (hitCode !== undefined && categoryDisplay(hitCode).locked) return; // Shift+Klick: Mehrfach-Auswahl — getroffenes Element zu-/abschalten, den // Rest behalten (Wände und 2D-Zeichnungen unabhängig, gemischte Auswahl // erlaubt). Leerraum-Klick mit Shift lässt die Auswahl unverändert. @@ -4544,6 +4760,13 @@ export default function App() { ? selectedRoofIds.filter((x) => x !== id) : [...selectedRoofIds, id], ); + } else if (sel.contextObjectId) { + const id = sel.contextObjectId; + setSelectedContextObjectIds( + selectedContextObjectIds.includes(id) + ? selectedContextObjectIds.filter((x) => x !== id) + : [...selectedContextObjectIds, id], + ); } return; } @@ -4562,7 +4785,7 @@ export default function App() { if (keep !== "extrudedSolid") setSelectedExtrudedSolidIds([]); if (keep !== "column") setSelectedColumnIds([]); if (keep !== "roof") setSelectedRoofIds([]); - setSelectedContextObjectIds([]); + if (keep !== "contextObject") setSelectedContextObjectIds([]); if (keep !== "sectionLine") setSelectedSectionLineId(null); }; if (sel.sectionLineId) { @@ -4595,6 +4818,9 @@ export default function App() { } else if (sel.roofId) { setSelectedRoofIds([sel.roofId]); clearOthers("roof"); + } else if (sel.contextObjectId) { + setSelectedContextObjectIds([sel.contextObjectId]); + clearOthers("contextObject"); } else { clearOthers(""); } @@ -4865,13 +5091,11 @@ export default function App() { e.target.value = ""; }} /> - {/* Schmale globale Leiste: Marke/Ressourcen · Text-Formatierung · - Datei/Export/Einstellungen. Ansichts-/Zoom-/Darstellungs-Steuerung ist - in den „Ansichten"-Ribbon-Tab gewandert (ViewRibbonTab, unten). */} + {/* Schmale globale Leiste: Marke/Menü · Datei/Export/Einstellungen. + Ansichts-/Zoom-/Darstellungs-Steuerung folgt direkt darunter + (ViewRibbonTab, permanent — kein Tab-Umschalter mehr). */} setExportOpen(true)} onExportDxf={() => setExportDxfOpen(true)} @@ -4884,27 +5108,31 @@ export default function App() { onFov={setFov} cameraEnabled={activeLevel.kind === "floor"} resourcesOpen={resourcesOpen} - onToggleResources={() => setResourcesOpen((v) => !v)} - onOpenSettings={() => setSettingsOpen(true)} + onToggleResources={() => toggleResources()} + onOpenSettings={() => openSettings()} onImport={() => dxfInputRef.current?.click()} onSaveProject={onSaveProject} onOpenProject={onOpenProject} - /> - - {/* Ribbon-Oberleiste (2D · 3D · BIM · Ansichten). Der „Ansichten"-Tab trägt - die aus der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung - (ViewRibbonTab); die Werkzeug-Sidebar bleibt vorerst erhalten. */} - toggleResources()} + onOpenSettings={() => openSettings()} + /> + } + viewControls={ - ), - "3d": ( - - ), - }} + } />
@@ -5066,6 +5279,8 @@ export default function App() { fov={fov} grid3dVisible={grid3dVisible} onToggleGrid3d={() => setGrid3dVisible(!grid3dVisible)} + section3d={section3dPlane} + onClearSection3d={() => setSection3dCutId(null)} mono={planColorMode === "mono"} drawings={drawings} onSelectLevel={setActiveLevelId} @@ -5337,21 +5552,21 @@ export default function App() { {settingsOpen && ( + setProject((p) => ({ ...p, referenceElevationMasl: n })) + } rendererMode={rendererMode} webGpuAvailable={webGpuAvailable} onRendererModeChange={setRendererMode} - layoutMenu={ - setLayout(reconcileLayout(l))} - /> - } + layout={layout} + onApplyLayout={(l) => setLayout(reconcileLayout(l))} onClose={() => setSettingsOpen(false)} /> )} @@ -5472,8 +5687,7 @@ export default function App() { project, selectedWallId, layerClipboard, - openLayerEditor: (code) => - setEditor({ kind: "layer", code, x: menu.x, y: menu.y }), + openLayerEditor: (code) => openLayerSettings(code, menu.x, menu.y), openLevelEditor: (id) => setEditor({ kind: "level", id, x: menu.x, y: menu.y }), addSubCategory, @@ -6101,20 +6315,6 @@ function InlineEditor({ } /** Eine Editor-Zeile: Beschriftung links, Steuerelement rechts. */ -function EditorField({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( - - ); -} // ── Layout-Menü (Oberleiste) ───────────────────────────────────────────────── @@ -6123,71 +6323,6 @@ function EditorField({ * gespeichertes Layout wählen (oder löschen). Liest die Namen frisch aus dem * localStorage, sobald das Menü geöffnet wird. */ -function LayoutMenu({ - layout, - onApply, -}: { - layout: LayoutState; - onApply: (layout: LayoutState) => void; -}) { - const [names, setNames] = useState([]); - // 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 , 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 ( - - - {saving && ( - setSaving(false)} - /> - )} - - ); -} - // ── Inhalt (Mitte) ─────────────────────────────────────────────────────────── /** Hauptinhalt — abhängig von der aktiven Zeichnungsebene und dem Ansichtstyp. */ @@ -6206,6 +6341,8 @@ function Content({ fov, grid3dVisible, onToggleGrid3d, + section3d, + onClearSection3d, mono, drawings, onSelectLevel, @@ -6283,6 +6420,11 @@ function Content({ grid3dVisible: boolean; /** Klick auf den Grid-Umschalter-Button im 3D-Viewport (WASM-Pfad). */ onToggleGrid3d: () => void; + /** Aktive 3D-Schnittebene (world point+normal) der gebundenen Schnittlinie, + * oder null. Speist Viewport3D/Wasm3DViewport (Live-Schnitt). */ + section3d: { point: [number, number, number]; normal: [number, number, number] } | null; + /** Schnitt-Bindung im 3D-Viewport lösen (Klick auf den gebundenen Knopf). */ + onClearSection3d: () => void; /** Grundriss in Schwarz-Weiss (mono) statt mit Kategorie-/Element-Farben. */ mono: boolean; /** Dokument-Tabs der Kopfzeile — ein Tab je Geschoss-Grundriss. */ @@ -6432,6 +6574,7 @@ function Content({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onOpenSectionLevel={onSelectLevel} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -6484,11 +6627,13 @@ function Content({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedContextObjectIds={selectedContextObjectIds} selectedSectionLineId={selectedSectionLineId} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onOpenSectionLevel={onSelectLevel} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -6535,6 +6680,8 @@ function Content({ gridElevation={level.baseElevation ?? 0} gridVisible={grid3dVisible} onToggleGrid={onToggleGrid3d} + section3d={section3d} + onClearSection3d={onClearSection3d} onSelectWall={onViewportSelectWall} onSelectCeiling={onViewportSelectCeiling} onSelectOpening={onViewportSelectOpening} @@ -6581,11 +6728,13 @@ function Content({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedContextObjectIds={selectedContextObjectIds} selectedSectionLineId={selectedSectionLineId} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onOpenSectionLevel={onSelectLevel} onPlanSelect={onPlanSelect} onPlanMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -6628,11 +6777,13 @@ function LevelPlanView({ selectedColumnIds, selectedRoomIds, selectedRoofIds, + selectedContextObjectIds, selectedSectionLineId, roomStamp, onStampMove, onStampEdit, onEditDrawingText, + onOpenSectionLevel, onPlanSelect, onPlanMarquee, onSegmentCut, @@ -6668,11 +6819,13 @@ function LevelPlanView({ selectedColumnIds: string[]; selectedRoomIds: string[]; selectedRoofIds: string[]; + selectedContextObjectIds?: string[]; selectedSectionLineId: string | null; roomStamp: { roomId: string; anchor: Vec2 } | null; onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + onOpenSectionLevel?: (id: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; @@ -6747,11 +6900,13 @@ function LevelPlanView({ selectedColumnIds={selectedColumnIds} selectedRoomIds={selectedRoomIds} selectedRoofIds={selectedRoofIds} + selectedContextObjectIds={selectedContextObjectIds} selectedSectionLineIds={selectedSectionLineId ? [selectedSectionLineId] : []} roomStamp={roomStamp} onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onOpenSectionLevel={onOpenSectionLevel} onSelect={onPlanSelect} onMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} @@ -6807,6 +6962,7 @@ function SectionPlanView({ onStampMove, onStampEdit, onEditDrawingText, + onOpenSectionLevel, onPlanSelect, onPlanMarquee, onSegmentCut, @@ -6843,6 +6999,7 @@ function SectionPlanView({ onStampMove: (roomId: string, delta: Vec2) => void; onStampEdit: (roomId: string) => void; onEditDrawingText?: (drawingId: string) => void; + onOpenSectionLevel?: (id: string) => void; onPlanSelect: (sel: PlanSelection) => void; onPlanMarquee: (sel: MarqueeSelection) => void; onSegmentCut: (drawingId: string, model: Vec2) => void; @@ -6949,6 +7106,7 @@ function SectionPlanView({ onStampMove={onStampMove} onStampEdit={onStampEdit} onEditDrawingText={onEditDrawingText} + onOpenSectionLevel={onOpenSectionLevel} onSelect={onPlanSelect} onMarquee={onPlanMarquee} onSegmentCut={onSegmentCut} diff --git a/src/export/exportIfc.test.ts b/src/export/exportIfc.test.ts index f444bf7..f926b0c 100644 --- a/src/export/exportIfc.test.ts +++ b/src/export/exportIfc.test.ts @@ -308,6 +308,49 @@ describe("exportIfcSpf — IFC4-Export", () => { 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)", () => { const proj = fixtureProject(); proj.walls = []; diff --git a/src/export/exportIfc.ts b/src/export/exportIfc.ts index c6fbc09..c3c019e 100644 --- a/src/export/exportIfc.ts +++ b/src/export/exportIfc.ts @@ -59,6 +59,7 @@ import { openingLabel, wallTypeThickness, } from "../model/types"; +import { projectToWorldCoords } from "../model/geoRebase"; import { ceilingVerticalExtent, stairVerticalExtent, @@ -283,7 +284,13 @@ interface Structure { * File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal- * 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(); // Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts- @@ -341,9 +348,14 @@ export function exportIfcSpf(project: Project): string { "IFCPROJECT", `${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( "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( "IFCBUILDING", diff --git a/src/export/exportSchedule.test.ts b/src/export/exportSchedule.test.ts index 7a64adc..b747558 100644 --- a/src/export/exportSchedule.test.ts +++ b/src/export/exportSchedule.test.ts @@ -199,3 +199,108 @@ describe("exportScheduleCsv — Bauteilliste", () => { 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); + }); +}); diff --git a/src/export/exportSchedule.ts b/src/export/exportSchedule.ts index e7f52e7..e5bab23 100644 --- a/src/export/exportSchedule.ts +++ b/src/export/exportSchedule.ts @@ -18,6 +18,8 @@ import type { Opening, Stair, ExtrudedSolid, + Roof, + Column, } from "../model/types"; import { getWallType, @@ -63,7 +65,9 @@ export type ScheduleKind = | "Fenster" | "Öffnung" | "Treppe" - | "Extrusion"; + | "Extrusion" + | "Dach" + | "Stütze"; /** Eine ausgewertete Zeile der Bauteilliste (je Bauteil-Vorkommen ein Datensatz). */ 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, - * Fenster/Öffnungen, Treppen, Extrusionen — Räume bewusst ausgenommen, siehe - * Dateikopf). Reine Ableitung aus dem Modell — nichts wird gespeichert. - * Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen, Extrusionen (je in - * Dokumentreihenfolge). + * Fenster/Öffnungen, Treppen, Extrusionen, Dächer, Stützen — Räume bewusst + * ausgenommen, siehe Dateikopf). Reine Ableitung aus dem Modell — nichts wird + * gespeichert. Reihenfolge: Wände, Decken, Türen, Öffnungen, Treppen, + * Extrusionen, Dächer, Stützen (je in Dokumentreihenfolge). */ export function scheduleRows(project: Project): 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 s of project.stairs ?? []) rows.push(evalStair(project, s)); 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; } diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 9b847d8..aad961e 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -17,16 +17,25 @@ export const de = { "view3d.group": "Ansicht 3D", "view3d.front": "Vorne", "view3d.front.hint": "Frontalansicht von vorne", + "view3d.front.abbr": "V", "view3d.back": "Hinten", "view3d.back.hint": "Rückansicht von hinten", + "view3d.back.abbr": "H", "view3d.top": "Oben", "view3d.top.hint": "Draufsicht (senkrecht von oben)", + "view3d.top.abbr": "O", "view3d.side": "Rechts", "view3d.side.hint": "Rechte Seitenansicht (90° zur Front)", + "view3d.side.abbr": "R", "view3d.left": "Links", "view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)", + "view3d.left.abbr": "L", "view3d.perspective": "Perspektive", "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.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)", "view3d.isoFrontLeft": "Vorne-Links", @@ -86,6 +95,7 @@ export const de = { "display.style.shaded": "Schattiert", "display.style.shadedEdges": "Schattiert mit Kanten", "display.style.white": "Weiss", + "display.style.contour": "Nur Konturen", "display.style.textured": "Texturiert", "display.style.wireframe": "Drahtgitter", "display.style.hidden": "Kanten", @@ -98,6 +108,41 @@ export const de = { "topbar.resources": "Ressourcen", "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.title": "Arbeitsumgebung", "layout.placeholder": "Arbeitsumgebung …", @@ -148,6 +193,12 @@ export const de = { "ribbon.group.components": "Bauteile", "ribbon.group.sections": "Schnitte", "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.empty": "Elemente über + Hinzufügen wählen", "ribbon.empty": "Noch keine Werkzeuge in diesem Tab", @@ -292,6 +343,16 @@ export const de = { "objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.column": "Stütze", "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) ────────────────────────────────── "objinfo.roof.section": "Dach", "objinfo.roof.shape": "Dachform", @@ -501,6 +562,9 @@ export const de = { "objinfo.stair.direction": "Laufrichtung", "objinfo.stair.direction.up": "Aufwä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.bottom": "Unterkante (UK)", "objinfo.stair.top": "Oberkante (OK)", @@ -523,6 +587,7 @@ export const de = { "objinfo.sectionLine.kind.elevation": "Ansicht", "objinfo.sectionLine.name": "Name", "objinfo.sectionLine.flip": "Blickrichtung umkehren", + "objinfo.sectionLine.cut3d": "Im 3D schneiden", "objinfo.sectionLine.startX": "Start X", "objinfo.sectionLine.startY": "Start Y", "objinfo.sectionLine.endX": "Ende X", @@ -557,6 +622,8 @@ export const de = { "nav.show": "Einblenden", "nav.categoryVisible": "{name} sichtbar", "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) ─────────────────────────────────── "badge.floor": "Geschoss", @@ -571,6 +638,7 @@ export const de = { "display.active": "Nur aktive", "display.grey": "Andere grau", "display.grey_locked": "Andere grau & gesperrt", + "display.locked": "Andere gesperrt", // ── Dock ───────────────────────────────────────────────────────────────── "dock.resize": "Breite anpassen", @@ -692,6 +760,7 @@ export const de = { "resources.title": "Ressourcen", "resources.label": "Ressourcen-Manager", "resources.close": "Schließen", + "resources.loading": "Verbinde mit dem Hauptfenster …", "resources.tab.components": "Bauteile", "resources.tab.hatches": "Schraffuren", "resources.tab.lines": "Linien", @@ -774,6 +843,8 @@ export const de = { "resources.field.frameWidth": "Rahmenbreite (m)", "resources.field.insetFromFace": "Schichteinzug (m)", "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.mullionRows": "Kämpfer-Zeilen", "resources.frameKind.zarge": "Zarge", @@ -1145,9 +1216,15 @@ export const de = { "site.contourSet": "Kontur-Satz", "site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).", "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.contours": "Konturen", "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.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).", @@ -1254,6 +1331,9 @@ export const de = { "settings.title": "Einstellungen", "settings.section.workspace": "Arbeitsumgebung", "settings.section.display": "Darstellung", + "settings.theme": "Erscheinungsbild", + "settings.theme.light": "Hell", + "settings.theme.dark": "Dunkel", "settings.marqueeColor": "Auswahlrahmen-Farbe", "settings.marqueeColor.hint": "Farbe des Auswahlrahmens beim Aufziehen einer Auswahl (Marquee) im Grundriss.", @@ -1281,8 +1361,10 @@ export const de = { "ctxImport.title": "Standort importieren", "ctxImport.close": "Schließen", "ctxImport.geoAnchor": "Georeferenziert ab: {label}", + "ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}", "ctxImport.geoAnchor.reset": "Zurücksetzen", "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.searchPlaceholder": "z. B. Bundesplatz Bern", "ctxImport.search": "Suchen", @@ -1290,6 +1372,12 @@ export const de = { "ctxImport.noResults": "Keine Treffer.", "ctxImport.searchError": "Suche fehlgeschlagen.", "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.src.swissBuildings": "Swisstopo-Gebäude", "ctxImport.buildings.off": "Aus", @@ -1297,6 +1385,8 @@ export const de = { "ctxImport.buildings.v2": "Echt 2.0 (stabil)", "ctxImport.buildings.v3": "Echt 3.0 (Beta)", "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.res2": "2 m (schnell)", "ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)", @@ -1379,6 +1469,7 @@ export const de = { "viewport3d.section.label": "Schnittebene", "viewport3d.section.show": "Schnittebene ein", "viewport3d.section.hide": "Schnittebene aus", + "viewport3d.section.bound": "3D-Schnitt aufheben (an Schnittlinie gebunden)", "viewport3d.grip.vertex": "Wandende ziehen", "viewport3d.grip.height": "Höhe ziehen", "viewport3d.grip.move": "Verschieben", @@ -1396,6 +1487,8 @@ export const de = { "elements.kind.opening": "Öffnung", "elements.kind.stair": "Treppe", "elements.kind.extrusion": "Extrusion", + "elements.kind.roof": "Dach", + "elements.kind.column": "Stütze", // Ausschnitte / View-Snapshots (DOSSIER A2). "viewsnap.title": "Ausschnitte", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f214e70..98ee5eb 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -16,16 +16,25 @@ export const en: Record = { "view3d.group": "3D view", "view3d.front": "Front", "view3d.front.hint": "Front view (frontal)", + "view3d.front.abbr": "F", "view3d.back": "Back", "view3d.back.hint": "Back view (rear)", + "view3d.back.abbr": "B", "view3d.top": "Top", "view3d.top.hint": "Top view (straight down)", + "view3d.top.abbr": "T", "view3d.side": "Right", "view3d.side.hint": "Right side view (90° to the front)", + "view3d.side.abbr": "R", "view3d.left": "Left", "view3d.left.hint": "Left side view (90° to the front, opposite Right)", + "view3d.left.abbr": "L", "view3d.perspective": "Perspective", "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.hint": "Isometric view (equiangular from front-top-right)", "view3d.isoFrontLeft": "Front Left", @@ -85,6 +94,7 @@ export const en: Record = { "display.style.shaded": "Shaded", "display.style.shadedEdges": "Shaded with edges", "display.style.white": "White", + "display.style.contour": "Contours only", "display.style.textured": "Textured", "display.style.wireframe": "Wireframe", "display.style.hidden": "Hidden line", @@ -97,6 +107,40 @@ export const en: Record = { "topbar.resources": "Resources", "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.title": "Workspace", "layout.placeholder": "Workspace …", @@ -147,6 +191,12 @@ export const en: Record = { "ribbon.group.components": "Components", "ribbon.group.sections": "Sections", "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.empty": "Pick elements via “+ Add”", "ribbon.empty": "No tools in this tab yet", @@ -291,6 +341,16 @@ export const en: Record = { "objinfo.kind.extrudedSolid": "Extrusion", "objinfo.kind.column": "Column", "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) ───────────────────────────────── "objinfo.roof.section": "Roof", "objinfo.roof.shape": "Roof shape", @@ -499,6 +559,9 @@ export const en: Record = { "objinfo.stair.direction": "Run direction", "objinfo.stair.direction.up": "Upwards", "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.bottom": "Bottom (UK)", "objinfo.stair.top": "Top (OK)", @@ -520,6 +583,7 @@ export const en: Record = { "objinfo.sectionLine.kind.elevation": "Elevation", "objinfo.sectionLine.name": "Name", "objinfo.sectionLine.flip": "Flip view direction", + "objinfo.sectionLine.cut3d": "Cut in 3D", "objinfo.sectionLine.startX": "Start X", "objinfo.sectionLine.startY": "Start Y", "objinfo.sectionLine.endX": "End X", @@ -553,6 +617,8 @@ export const en: Record = { "nav.show": "Show", "nav.categoryVisible": "{name} visible", "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. "badge.floor": "Floor", @@ -567,6 +633,7 @@ export const en: Record = { "display.active": "Active only", "display.grey": "Others grey", "display.grey_locked": "Others grey & locked", + "display.locked": "Others locked", // Dock. "dock.resize": "Adjust width", @@ -687,6 +754,7 @@ export const en: Record = { "resources.title": "Resources", "resources.label": "Resource manager", "resources.close": "Close", + "resources.loading": "Connecting to the main window…", "resources.tab.components": "Components", "resources.tab.hatches": "Hatches", "resources.tab.lines": "Lines", @@ -769,6 +837,8 @@ export const en: Record = { "resources.field.frameWidth": "Frame width (m)", "resources.field.insetFromFace": "Layer inset (m)", "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.mullionRows": "Transom rows", "resources.frameKind.zarge": "Lining", @@ -1134,9 +1204,15 @@ export const en: Record = { "site.contourSet": "Contour set", "site.empty": "No context loaded. Import a DXF file (contour lines or mesh).", "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.contours": "Contours", "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.terrainFailed": "No terrain could be generated (too few/collinear contour points).", @@ -1242,6 +1318,9 @@ export const en: Record = { "settings.title": "Settings", "settings.section.workspace": "Workspace", "settings.section.display": "Display", + "settings.theme": "Appearance", + "settings.theme.light": "Light", + "settings.theme.dark": "Dark", "settings.marqueeColor": "Selection box color", "settings.marqueeColor.hint": "Color of the marquee selection box drawn while dragging a selection in the plan view.", @@ -1269,8 +1348,10 @@ export const en: Record = { "ctxImport.title": "Import location", "ctxImport.close": "Close", "ctxImport.geoAnchor": "Georeferenced from: {label}", + "ctxImport.geoAnchor.coords": "E {e} / N {n} / H {h}", "ctxImport.geoAnchor.reset": "Reset", "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.searchPlaceholder": "e.g. Bundesplatz Bern", "ctxImport.search": "Search", @@ -1278,6 +1359,12 @@ export const en: Record = { "ctxImport.noResults": "No matches.", "ctxImport.searchError": "Search failed.", "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.src.swissBuildings": "Swisstopo buildings", "ctxImport.buildings.off": "Off", @@ -1285,6 +1372,8 @@ export const en: Record = { "ctxImport.buildings.v2": "Real 2.0 (stable)", "ctxImport.buildings.v3": "Real 3.0 (Beta)", "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.res2": "2 m (fast)", "ctxImport.terrain.res05": "0.5 m (fine, larger tiles)", @@ -1367,6 +1456,7 @@ export const en: Record = { "viewport3d.section.label": "Section plane", "viewport3d.section.show": "Section plane on", "viewport3d.section.hide": "Section plane off", + "viewport3d.section.bound": "Clear 3D section (bound to section line)", "viewport3d.grip.vertex": "Drag wall endpoint", "viewport3d.grip.edge": "Drag edge", "viewport3d.grip.height": "Drag height", @@ -1384,6 +1474,8 @@ export const en: Record = { "elements.kind.opening": "Opening", "elements.kind.stair": "Stair", "elements.kind.extrusion": "Extrusion", + "elements.kind.roof": "Roof", + "elements.kind.column": "Column", // Views / view snapshots (DOSSIER A2). "viewsnap.title": "Views", diff --git a/src/io/swissBuildings3d.test.ts b/src/io/swissBuildings3d.test.ts index 56b83ef..7d18afa 100644 --- a/src/io/swissBuildings3d.test.ts +++ b/src/io/swissBuildings3d.test.ts @@ -5,7 +5,13 @@ // `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo). 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"; describe("shiftMeshToOrigin", () => { @@ -79,3 +85,137 @@ describe("clipMeshToBbox", () => { 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); + }); +}); diff --git a/src/io/swissBuildings3d.ts b/src/io/swissBuildings3d.ts index b78d370..0897741 100644 --- a/src/io/swissBuildings3d.ts +++ b/src/io/swissBuildings3d.ts @@ -105,6 +105,189 @@ export function clipMeshToBbox( 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(); + 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(); + 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(); + 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(); + 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 { origin: GeoOrigin; meshes: ImportedMesh[]; @@ -169,7 +352,14 @@ export async function fetchBuildings3d( const parsed = parseDxf(text); for (const m of parsed.meshes) { 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 }; diff --git a/src/io/swissImage.test.ts b/src/io/swissImage.test.ts new file mode 100644 index 0000000..9d93775 --- /dev/null +++ b/src/io/swissImage.test.ts @@ -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); + // xmax -> u=0, v=1 -> Bild oben-links = rot. + expect(c).toEqual([1, 0, 0]); + }); +}); diff --git a/src/io/swissImage.ts b/src/io/swissImage.ts new file mode 100644 index 0000000..ea8ece9 --- /dev/null +++ b/src/io/swissImage.ts @@ -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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/main.tsx b/src/main.tsx index c13ffd9..eb1a843 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,30 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; 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 { getLanguage, setLanguage } from "./i18n"; import type { Language } from "./i18n"; +import { applyThemeMode, getStoredThemeMode } from "./theme/themeMode"; import "./styles.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- // Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar. declare global { @@ -52,10 +70,19 @@ document.addEventListener("dragstart", (e) => { if (!isTextEntry(e.target)) e.preventDefault(); }); +function RootByWindowKind() { + if (windowKind === "resources") return ; + if (windowKind === "settings") return ; + if (windowKind === "drawing-levels") return ; + if (windowKind === "layer-settings") return ; + if (windowKind === "context-import") return ; + return ; +} + createRoot(document.getElementById("root")!).render( - + , ); diff --git a/src/materials/library.ts b/src/materials/library.ts index 25d80ce..6eb9ea6 100644 --- a/src/materials/library.ts +++ b/src/materials/library.ts @@ -84,6 +84,18 @@ export const MATERIAL_LIBRARY: MaterialAsset[] = [ 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", name: "Bodenfliesen", diff --git a/src/model/geoRebase.test.ts b/src/model/geoRebase.test.ts index f6b9c97..edc05a3 100644 --- a/src/model/geoRebase.test.ts +++ b/src/model/geoRebase.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import type { Column, ExtrudedSolid, Project } from "./types"; import { sampleProject } from "./sampleProject"; -import { translateProject } from "./geoRebase"; +import { translateProject, projectToWorldCoords, projectGeoOrigin } from "./geoRebase"; const DX = 100; const DY = -50; @@ -201,3 +201,49 @@ describe("translateProject", () => { 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 }); + }); +}); diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts index d8adcfb..c915763 100644 --- a/src/model/geoRebase.ts +++ b/src/model/geoRebase.ts @@ -42,6 +42,9 @@ function shiftContextObject(obj: ContextObject, dx: number, dy: number): Context if (obj.type === "contourSet") { 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(); for (let i = 0; i + 2 < positions.length; i += 3) { positions[i] += dx; @@ -118,3 +121,28 @@ export function translateProject(project: Project, dx: number, dy: number): Proj : 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); +} diff --git a/src/model/sampleProject.ts b/src/model/sampleProject.ts index a245871..0610551 100644 --- a/src/model/sampleProject.ts +++ b/src/model/sampleProject.ts @@ -10,6 +10,7 @@ import { materialFromAsset } from "../materials/runtime"; const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!; const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!; 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: // EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand. @@ -148,6 +149,19 @@ export const sampleProject: Project = { joinPriority: 10, 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). { id: "concrete", @@ -208,7 +222,7 @@ export const sampleProject: Project = { name: "Steildach gedämmt", // Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek. 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: "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. 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: [], +}; diff --git a/src/model/terrain.ts b/src/model/terrain.ts index 7314a27..b83fcfe 100644 --- a/src/model/terrain.ts +++ b/src/model/terrain.ts @@ -13,6 +13,9 @@ import type { Contour, TerrainMesh } from "./types"; let terrainSeq = 0; +/** Sichtbarer Ebenen-/Gruppierungsname des Geländes (SitePanel). */ +export const TERRAIN_LAYER = "Terrain"; + /** * Erzeugt ein TIN aus Konturen. * @@ -96,7 +99,7 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh { 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) { return { id, type: "terrainMesh", name, positions: [], indices: [] }; } - return { id, type: "terrainMesh", name, positions, indices }; + return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices }; } diff --git a/src/model/types.ts b/src/model/types.ts index 8c4ecb4..5b6f927 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -453,6 +453,14 @@ export interface WindowType { insetFromFace?: number; /** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */ 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 * (Meter), abgeteilt durch einen Kämpfer. 0/fehlt ⇒ kein Oberlicht. @@ -1356,6 +1364,12 @@ export interface Stair { * Kategorie-Farbe. */ 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; type: "terrainMesh"; name: string; + /** Ebenen-/Gruppierungsname (SitePanel), z. B. „Terrain". Optional. */ + layer?: string; positions: 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. */ /** @@ -2196,6 +2247,13 @@ export interface Project { geoAnchor?: { /** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */ 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). */ model: Vec2; /** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */ diff --git a/src/native/ContextImportWindowApp.tsx b/src/native/ContextImportWindowApp.tsx new file mode 100644 index 0000000..9195434 --- /dev/null +++ b/src/native/ContextImportWindowApp.tsx @@ -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(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("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 ( +
+ {t("resources.loading")} +
+ ); + } + + return ( + send("onImport", objs)} + onClose={onClose} + geoAnchor={state.geoAnchor} + onSetGeoAnchor={(anchor: GeoAnchor | undefined) => send("onSetGeoAnchor", anchor)} + windowed + /> + ); +} diff --git a/src/native/DrawingLevelsWindowApp.tsx b/src/native/DrawingLevelsWindowApp.tsx new file mode 100644 index 0000000..27d0a77 --- /dev/null +++ b/src/native/DrawingLevelsWindowApp.tsx @@ -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(null); + const [collapsed, setCollapsed] = useState>({ + 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("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( + () => + 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 ( +
+ {t("resources.loading")} +
+ ); + } + + const onAddByKind: Record 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 ( +
+ {/* Selber Kopf wie die übrigen vier Fenster (.res-head/.imp-head): + 44px, border-bottom, 76px Ampel-Abstand — kein Dock-Panel-Look mehr. */} +
+ {t("nav.drawingLevels")} +
+ + {CATEGORY_ORDER.map((kind) => { + const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind); + const isOpen = !collapsed[kind]; + return ( +
+
toggleCategory(kind)}> + + {t(CATEGORY_TITLE_KEY[kind])} + { + e.stopPropagation(); + onAddByKind[kind](); + }} + > + + + +
+ + {isOpen && ( +
+ {levelsOfKind.map((level) => ( + handlers.onSelectLevel(level.id)} + onToggle={() => toggle(level.id)} + // Rechtsklick-Kontextmenü bewusst kein Bestandteil des + // Fensters (s. drawingLevelsWindow.ts Kopfkommentar). + onContext={(e) => e.preventDefault()} + /> + ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/src/native/LayerSettingsWindowApp.tsx b/src/native/LayerSettingsWindowApp.tsx new file mode 100644 index 0000000..7256f05 --- /dev/null +++ b/src/native/LayerSettingsWindowApp.tsx @@ -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(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("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("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 ( +
+ {t("resources.loading")} +
+ ); + } + + return ( +
+
+ + {t("editor.layerTitle", { code })} + +
+
+
+ + patch({ name: e.target.value })} + /> + + + patch({ color: e.target.value })} + /> + + + + patch({ lw: Number(e.target.value) })} + /> + {t("editor.unit.mm")} + + + + + + + +
+
+
+ ); +} diff --git a/src/native/ResourceWindowApp.tsx b/src/native/ResourceWindowApp.tsx new file mode 100644 index 0000000..74afb7a --- /dev/null +++ b/src/native/ResourceWindowApp.tsx @@ -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(null); + const [tab, setTab] = useState(() => 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("resource-sync-project", (e) => setProject(e.payload)), + ); + unlisten.push(await listen("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( + () => + 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 ( +
+ {t("resources.loading")} +
+ ); + } + + return ( + + ); +} diff --git a/src/native/SettingsWindowApp.tsx b/src/native/SettingsWindowApp.tsx new file mode 100644 index 0000000..656cca7 --- /dev/null +++ b/src/native/SettingsWindowApp.tsx @@ -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(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("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( + () => + 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 ( +
+ {t("resources.loading")} +
+ ); + } + + return ( + + ); +} diff --git a/src/native/appMenu.ts b/src/native/appMenu.ts new file mode 100644 index 0000000..ab68d0e --- /dev/null +++ b/src/native/appMenu.ts @@ -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; + 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 = { + 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, + ]); +} diff --git a/src/native/contextImportWindow.ts b/src/native/contextImportWindow.ts new file mode 100644 index 0000000..7b7627d --- /dev/null +++ b/src/native/contextImportWindow.ts @@ -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 { + 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)[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]); +} diff --git a/src/native/drawingLevelsWindow.ts b/src/native/drawingLevelsWindow.ts new file mode 100644 index 0000000..770a2c8 --- /dev/null +++ b/src/native/drawingLevelsWindow.ts @@ -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 { + 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)[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]); +} diff --git a/src/native/layerSettingsWindow.ts b/src/native/layerSettingsWindow.ts new file mode 100644 index 0000000..5727ca2 --- /dev/null +++ b/src/native/layerSettingsWindow.ts @@ -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) => void; +} + +/** Öffnet das native Ebeneneinstellungs-Fenster für `code` (bzw. schiebt den Code nach, falls schon offen). */ +export async function openLayerSettingsWindowNative(code: string): Promise { + 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) => void, +): void { + const stateRef = useRef(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]; + 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]); +} diff --git a/src/native/resourceWindow.ts b/src/native/resourceWindow.ts new file mode 100644 index 0000000..a3474c1 --- /dev/null +++ b/src/native/resourceWindow.ts @@ -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 { + 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 ``-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)[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]); +} diff --git a/src/native/settingsWindow.ts b/src/native/settingsWindow.ts new file mode 100644 index 0000000..a7c8eb0 --- /dev/null +++ b/src/native/settingsWindow.ts @@ -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 { + 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)[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]); +} diff --git a/src/native/windowChrome.ts b/src/native/windowChrome.ts new file mode 100644 index 0000000..c6b89db --- /dev/null +++ b/src/native/windowChrome.ts @@ -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), +}; diff --git a/src/panels/DisplayModeSelect.tsx b/src/panels/DisplayModeSelect.tsx index e8d0a76..634d896 100644 --- a/src/panels/DisplayModeSelect.tsx +++ b/src/panels/DisplayModeSelect.tsx @@ -13,8 +13,9 @@ import { t } from "../i18n"; import { Dropdown } from "../ui/Dropdown"; /** - * Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die - * fünf DOSSIER-Modi aus docs/design/context-menu.md. + * Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — die fünf + * 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 }[] = [ { value: "all_force", labelKey: "display.all_force" }, @@ -22,6 +23,7 @@ const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [ { value: "active", labelKey: "display.active" }, { value: "grey", labelKey: "display.grey" }, { value: "grey_locked", labelKey: "display.grey_locked" }, + { value: "locked", labelKey: "display.locked" }, ]; /** diff --git a/src/panels/DrawingLevelsPanel.tsx b/src/panels/DrawingLevelsPanel.tsx index d559a93..54c4832 100644 --- a/src/panels/DrawingLevelsPanel.tsx +++ b/src/panels/DrawingLevelsPanel.tsx @@ -20,6 +20,7 @@ import { itemDisplay } from "./displayMode"; import { usePanelHost } from "./host"; import type { DrawingLevel } from "./host"; import type { DisplayMode } from "./types"; +import { DisplayModeSelect } from "./DisplayModeSelect"; import type { DrawingLevelKind } from "../model/types"; import { t } from "../i18n"; @@ -31,8 +32,9 @@ const KIND_BADGE_KEY: Record = { drawing: "badge.drawing", }; -/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. */ -const CATEGORY_ORDER: DrawingLevelKind[] = [ +/** Kategorien der Zeichnungsebenen, in Anzeigereihenfolge. Exportiert für das + * native Zeichnungsebenen-Fenster (native/DrawingLevelsWindowApp.tsx). */ +export const CATEGORY_ORDER: DrawingLevelKind[] = [ "floor", "section", "elevation", @@ -40,7 +42,7 @@ const CATEGORY_ORDER: DrawingLevelKind[] = [ ]; /** i18n-Key des Kategorie-Titels. */ -const CATEGORY_TITLE_KEY: Record = { +export const CATEGORY_TITLE_KEY: Record = { floor: "levelCat.floor", section: "levelCat.section", elevation: "levelCat.elevation", @@ -48,7 +50,7 @@ const CATEGORY_TITLE_KEY: Record = { }; /** i18n-Key der Beschriftung des „+"-Glyphs (Tooltip/aria-label). */ -const CATEGORY_ADD_KEY: Record = { +export const CATEGORY_ADD_KEY: Record = { floor: "levels.addFloor", section: "levels.addSection", elevation: "levels.addElevation", @@ -93,10 +95,13 @@ export function DrawingLevelsPanel() { return (
-
- {t("nav.drawingLevels")} -
- + {/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE + Kopfzeile; die Fenster-Ausklinken-Aktion sitzt dort via headerActions + (DrawingLevelsHeaderActions), kein doppelter Titel. */} +
+ {t("nav.visibility")} + +
{CATEGORY_ORDER.map((kind) => { const levelsOfKind = levelsTopDown.filter((l) => l.kind === kind); const isOpen = !collapsed[kind]; @@ -151,8 +156,28 @@ export function DrawingLevelsPanel() { ); } -/** Eine Zeile im Zeichnungsebenen-Bereich. */ -function LevelRow({ +/** Aktionen für die gemeinsame PanelFrame-Kopfzeile (statt eigener Titelzeile): + * Zahnrad → Einstellungen (Darstellungsfarben/Projekt-Felder). */ +export function DrawingLevelsHeaderActions() { + const host = usePanelHost(); + return ( + + ); +} + +/** 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, active, mode, diff --git a/src/panels/ElementTreePanel.tsx b/src/panels/ElementTreePanel.tsx index 9087d6d..7fb0d2a 100644 --- a/src/panels/ElementTreePanel.tsx +++ b/src/panels/ElementTreePanel.tsx @@ -26,6 +26,8 @@ import { t } from "../i18n"; const CLASS_ORDER: Exclude[] = [ "Wand", "Decke", + "Dach", + "Stütze", "Fenster", "Öffnung", "Treppe", @@ -36,6 +38,8 @@ const CLASS_ORDER: Exclude[] = [ const CLASS_LABEL_KEY: Record, string> = { Wand: "elements.kind.wall", Decke: "elements.kind.ceiling", + Dach: "elements.kind.roof", + Stütze: "elements.kind.column", Fenster: "elements.kind.window", Öffnung: "elements.kind.opening", Treppe: "elements.kind.stair", diff --git a/src/panels/LayersPanel.tsx b/src/panels/LayersPanel.tsx index 20d0def..cdfeca8 100644 --- a/src/panels/LayersPanel.tsx +++ b/src/panels/LayersPanel.tsx @@ -4,14 +4,18 @@ // Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte // Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost. // -// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive" -// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false; -// `itemDisplay` ergibt damit (fünf DOSSIER-Modi): +// Das Panel deklariert hasDisplayMode. „Aktiv" ist die per Klick gewählte +// Kategorie (activeCategoryCode, Zeichen-Ziel für neue Elemente); dieselbe +// 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" — alle sichtbaren normal (Standard, wie bisher). -// • "active" — keine Kategorie aktiv → alle gedimmt. -// • "grey" — sichtbare Kategorien ausgegraut. -// • "grey_locked" — wie „grey", zusätzlich gesperrt. +// • "active" — nur die gewählte Kategorie sichtbar, Rest ausgeblendet. +// • "grey" — gewählte normal, übrige sichtbare ausgegraut. +// • "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 // Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity // (styles.css bleibt unangetastet). @@ -23,6 +27,7 @@ import { itemDisplay } from "./displayMode"; import { usePanelHost } from "./host"; import type { LayerCategory } from "./host"; import type { DisplayMode } from "./types"; +import { DisplayModeSelect } from "./DisplayModeSelect"; import { t } from "../i18n"; /** Inhalt des Panels „Ebenen". */ @@ -46,15 +51,13 @@ export function LayersPanel() { return (
-
- {t("nav.layers")} - - - -
- + {/* Kein eigener Titel/Kopf mehr — der PanelFrame liefert die EINE + Kopfzeile; die „+ Kategorie"-Aktion sitzt dort via headerActions + (LayersHeaderActions), kein doppelter „Ebenen"-Titel. */} +
+ {t("nav.visibility")} + +
{project.layers.map((cat) => ( ))}
@@ -82,6 +86,7 @@ function CategoryRow({ onSelect, onToggle, onContext, + onSettings, }: { category: LayerCategory; depth: number; @@ -90,6 +95,7 @@ function CategoryRow({ onSelect: (code: string) => void; onToggle: (code: string) => 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). const isActive = category.code === activeCode; @@ -137,6 +143,19 @@ function CategoryRow({ {category.code} {category.name} +
{category.children?.map((child) => ( ))} ); } + +/** 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 ( + <> + + + + ); +} diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index e774983..c903cb1 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -242,6 +242,35 @@ export function ObjectInfoPanel() { 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 && ( + <> +
+
{t("objinfo.contextObject.section")}
+
+ {t("objinfo.contextObject.type")} + {t(`objinfo.contextObject.type.${sel.contextObject.objType}`)} +
+ {sel.contextObject.lv95Center ? ( + <> +
+ {t("objinfo.contextObject.lv95E")} + {sel.contextObject.lv95Center.e.toFixed(1)} +
+
+ {t("objinfo.contextObject.lv95N")} + {sel.contextObject.lv95Center.n.toFixed(1)} +
+ + ) : ( +
+ {t("objinfo.contextObject.noAnchor")} +
+ )} + + )} {/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert die exportierten *Section-Komponenten). */} @@ -393,6 +422,20 @@ export function SectionLineSection({ {t("objinfo.sectionLine.flip")} + {/* 3D-Live-Schnitt an diese Linie binden (nur mit gesetzter Linie sinnvoll): + der Viewport clippt dann vertikal entlang dieser Ebene. */} + {line && ( +
+ {t("objinfo.sectionLine.cut3d")} + host.onToggleSection3dCut(level.id)} + title={t("objinfo.sectionLine.cut3d")} + /> +
+ )} + {/* Endpunkt-Koordinaten (nur wenn eine Linie gesetzt ist). */} {line && ( <> @@ -854,6 +897,27 @@ export function StairSection({
+ {/* Pfeil-Darstellung (offener Winkel- oder gefüllter SIA-Vollpfeil). */} +
+ {t("objinfo.stair.arrow")} +
+ + +
+
+ {/* Editierbare Maße. */}
{t(def.title)} - {showMode && ( - void} - /> - )} + + {def.headerActions} + {showMode && ( + void} + /> + )} +
{def.render(ctx ?? {})}
diff --git a/src/panels/SitePanel.tsx b/src/panels/SitePanel.tsx index db83847..19523b2 100644 --- a/src/panels/SitePanel.tsx +++ b/src/panels/SitePanel.tsx @@ -19,6 +19,8 @@ import type { ContextObject } from "../model/types"; import { ContextImportDialog } from "../ui/ContextImportDialog"; import { Dropdown } from "../ui/Dropdown"; 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. */ function typeBadgeKey(obj: ContextObject): TranslationKey { @@ -29,9 +31,43 @@ function typeBadgeKey(obj: ContextObject): TranslationKey { return "site.type.contours"; case "terrainMesh": 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(); + 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() { const host = usePanelHost(); const notify = useStore((s) => s.notify); @@ -72,7 +108,10 @@ export function SitePanel() { @@ -117,37 +156,57 @@ export function SitePanel() {
- {/* 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 ? (
{t("site.empty")}
) : ( -
    - {objects.map((obj) => ( -
  • - - {obj.name} - - - {t(typeBadgeKey(obj))} - + groupByLayer(objects).map(({ layer, items }) => ( +
    +
    + {layer} + {items.length} -
  • - ))} -
+
+
    + {items.map((obj) => ( +
  • + + {obj.name} + + + {t(typeBadgeKey(obj))} + + +
  • + ))} +
+ + )) )}
{t("site.dwgHint")}
diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index 6fbbdfa..d8441f7 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -1,18 +1,23 @@ -// Inhalts-Panel „Werkzeuge" — die vertikale Werkzeug-Palette (ArchiCAD-/ -// Vectorworks-Stil): eine Zeile je Zeichenwerkzeug, jeweils Icon + Name, das -// aktive Werkzeug hervorgehoben. Darunter — wenn das Wand-Werkzeug aktiv ist — -// ein Wandtyp-Auswahlfeld, und ein kompakter Fang-Abschnitt mit Checkboxen. +// Inhalts-Panel „Werkzeuge" — die Werkzeug-Palette (ArchiCAD-/Vectorworks-Stil). +// Oben ein Umschalter „Liste ↔ Symbole" (Icon+Name als Zeilen ODER kompaktes +// Icon-Raster). Darunter drei einheitlich gestylte Rubriken: 2D (Zeichnen), +// 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- -// Liste und ihre Metadaten stammen aus der Tools-Registry (TOOL_ORDER/getTool). +// Daten/Handler kommen über usePanelHost (kein Prop-Drilling). Werkzeug-Metadaten +// aus der Tools-Registry (getTool), Befehls-Labels aus der Command-Registry. // // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren // Texte über t(...). +import { useState } from "react"; import { t } from "../i18n"; import { usePanelHost } 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 ────────────────────────────────────────────────────────────────── // Keine echten Asset-Icons vorhanden: schlichte Inline-SVG-Glyphen je Werkzeug @@ -31,18 +36,17 @@ export function ToolIcon({ id }: { id: ToolId }) { }; switch (id) { case "select": - // Pfeil-Cursor. + // Pfeil-Cursor (klassischer Zeiger mit Schweif). return ( - - + + ); case "wall": - // Zwei parallele Linien (Wandband). + // Wandband: zwei parallele Kanten mit geschlossenen Enden (Wandstück). return ( - - + ); case "ceiling": @@ -55,29 +59,37 @@ export function ToolIcon({ id }: { id: ToolId }) { ); case "window": - // Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung). + // Fenster (SIA-Aufsicht): Wandöffnung mit doppelter Glaslinie. return ( - - + + + + + ); case "door": - // Tür: Pfosten + Türblatt mit Schwenkbogen. + // Tür (SIA-Aufsicht): Türblatt aufrecht + Viertelkreis-Schwenkbogen. return ( - - - + + + ); 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 ( - - - + + + + + + + ); case "room": @@ -118,10 +130,12 @@ export function ToolIcon({ id }: { id: ToolId }) { ); case "arc": - // Offener Halbbogen. + // Offener Bogen (gut 180°) mit Endpunkt-Markierungen. return ( - + + + ); 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 ──────────────────────────────────────────────────────────────────── export function ToolsPanel() { const host = usePanelHost(); + const [view, setView] = useState(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 ( + + ); + }; + + // 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 ( + + ); + }; + + const bodyClass = "tp-body" + (view === "icons" ? " icons" : ""); return (
- {/* Werkzeug-Zeilen: Icon + Name, aktives hervorgehoben. */} - {TOOL_ORDER.map((id) => { - const tool = getTool(id); - 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 ( + {/* Anzeige-Umschalter: nur Symbole ODER Symbol + Name (Liste). */} +
+
- ); - })} + +
+
- {/* Wandtyp-/Deckentyp-Auswahl ist ins Attribute-Panel gewandert (Ribbon-UI - Phase 3: Werkzeug-Sidebar nicht mehr im Default-Layout). Der Picker - erscheint dort bei aktivem Wand-/Decken-Werkzeug. */} + {/* Auswahl-Werkzeug: immer als volle Zeile zuoberst (meistgenutzt). */} +
{toolButton("select")}
+ +
+
{t("tools.cat.2d")}
+
{TOOLS_2D.map(toolButton)}
+
+ +
+
{t("tools.cat.3d")}
+
+ {TOOLS_3D.map(toolButton)} + {COMMANDS_3D.map(commandButton)} +
+
+ +
+
{t("tools.cat.edit")}
+
{COMMANDS_EDIT.map(commandButton)}
+
); } diff --git a/src/panels/builtinPanels.tsx b/src/panels/builtinPanels.tsx index a442f10..f19c03c 100644 --- a/src/panels/builtinPanels.tsx +++ b/src/panels/builtinPanels.tsx @@ -15,8 +15,8 @@ // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). import { registerPanel } from "./registry"; -import { DrawingLevelsPanel } from "./DrawingLevelsPanel"; -import { LayersPanel } from "./LayersPanel"; +import { DrawingLevelsPanel, DrawingLevelsHeaderActions } from "./DrawingLevelsPanel"; +import { LayersPanel, LayersHeaderActions } from "./LayersPanel"; import { ToolsPanel } from "./ToolsPanel"; import { AttributesPanel } from "./AttributesPanel"; import { ObjectInfoPanel } from "./ObjectInfoPanel"; @@ -82,7 +82,10 @@ registerPanel({ // damit der Titel der gewählten Sprache folgt. title: "nav.drawingLevels", icon: , - hasDisplayMode: true, + // Der Sichtbarkeits-Umschalter sitzt jetzt IM Körper mit „Sichtbarkeit"- + // Beschriftung (nicht mehr im PanelFrame-Kopf) — hasDisplayMode: false. + hasDisplayMode: false, + headerActions: , render: () => , }); @@ -90,7 +93,8 @@ registerPanel({ id: BUILTIN_PANEL_IDS.layers, title: "nav.layers", icon: , - hasDisplayMode: true, + hasDisplayMode: false, + headerActions: , render: () => , }); diff --git a/src/panels/displayMode.ts b/src/panels/displayMode.ts index 3bbacb8..6ec8d86 100644 --- a/src/panels/displayMode.ts +++ b/src/panels/displayMode.ts @@ -32,8 +32,10 @@ export interface ItemDisplay { * ausgegraut. * • "grey_locked" — wie „grey", übrige (sichtbare, nicht aktive) zusätzlich * 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 * nicht aktives, unsichtbares Element wird (außer in "all_force") nie * gezeichnet. @@ -65,5 +67,12 @@ export function itemDisplay( const other = isVisible && !isActive; 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 }; + } } } diff --git a/src/panels/host.ts b/src/panels/host.ts index f9e889c..12261f8 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -74,6 +74,11 @@ export interface PanelHostValue { onSelectTool: (id: ToolId) => void; /** Werkzeuge nur im Grundriss eines Geschosses sinnvoll. */ 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). */ activeWallTypeId: string; onActiveWallTypeId: (id: string) => void; @@ -112,6 +117,14 @@ export interface PanelHostValue { * Löschen) und hält das offene Menü. */ 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 // ResourceManagerHandlers, hier flach getippt, damit dieses Modul nicht von @@ -351,6 +364,15 @@ export interface PanelHostValue { * linePoints/depth) — wirkt NUR auf die selektierte Linie. */ onSetSectionLinePatch: (patch: Partial) => 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 * Ebene selbst bleibt bestehen (der Schnitt zeigt dann wieder den Hinweis). @@ -368,6 +390,8 @@ export interface PanelHostValue { onSetStairRise: (totalRise: number) => void; /** Setzt die Laufrichtung (aufwärts/abwärts). */ 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). */ onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void; /** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */ @@ -410,6 +434,8 @@ export interface PanelHostValue { onAddContextObjects: (objs: ContextObject[]) => void; /** Entfernt ein Kontext-Objekt per ID. */ 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 * 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). */ 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; /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ onImportDxf: () => void; diff --git a/src/panels/layout.ts b/src/panels/layout.ts index 322d9a4..3d9d05b 100644 --- a/src/panels/layout.ts +++ b/src/panels/layout.ts @@ -40,7 +40,10 @@ import type { // als weiterer Tab in der rechten unteren Gruppe. // v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer // 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. */ const CURRENT_KEY = "cad.layout"; @@ -64,11 +67,11 @@ const NAMED_PREFIX = "cad.layouts."; * OBEN, Zeichnungsebenen + Ebenen als Tab-Stapel UNTEN. (Ressourcen bleibt ein * schwebendes Fenster.) Jede Gruppe trägt ein Höhen-Gewicht. */ -// Ribbon-UI Phase 3: die Werkzeug-Sidebar ("tools") ist NICHT mehr im Default- -// Layout — die Werkzeuge liegen im Ribbon, der Wandtyp-/Deckentyp-Picker im -// Attribute-Panel. `ToolsPanel` bleibt registriert (per Fenster-Menü andockbar), -// nur nicht mehr per Default sichtbar → Attribute bekommt die volle linke Höhe. +// v12: Werkzeug-Palette ("tools") wieder im Default-Layout — oben links, mit +// der Attribute-Palette darunter (Vectorworks-Bild). Das Ribbon-Band bleibt als +// Reiter erreichbar, ist aber nicht mehr der primäre Weg zu den Werkzeugen. const DEFAULT_LEFT_GROUPS: DockGroup[] = [ + { tabs: ["tools"], activeTab: "tools", weight: 1 }, { tabs: ["attributes"], activeTab: "attributes", weight: 1 }, ]; const DEFAULT_RIGHT_GROUPS: DockGroup[] = [ @@ -376,6 +379,26 @@ export function setActiveTab( 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- // Semantik; als Helfer exportiert, damit Wiring-Code symmetrisch arbeiten kann. export { otherDock }; diff --git a/src/panels/types.ts b/src/panels/types.ts index 89c6d1e..c25951d 100644 --- a/src/panels/types.ts +++ b/src/panels/types.ts @@ -97,16 +97,18 @@ export interface LayoutState { /** * Darstellungsmodus für ortsabhängige Inhalte (Navigator-Listen, 3D, Grundriss). * 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 - * Zeichnungsebenen identisch: + * fünf DOSSIER-Modi (siehe docs/design/context-menu.md) plus eine eigene + * Ergänzung ("locked"), für Ebenen UND Zeichnungsebenen identisch: * • "all_force" — alle erzwungen sichtbar; Augen gedimmt; Klick aufs Auge * wechselt zu „Ausgewählte". * • "all" — sichtbar nach per-Zeile-Flag (Standard). * • "active" — nur das aktive Element; andere stark gedimmt. * • "grey" — aktives normal, andere 45 % (Sichtbarkeits-Flags gelten). * • "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 ───────────────────────────────────────────── @@ -151,6 +153,14 @@ export interface PanelDef { * Kopfzeile anbieten soll. Default: kein Umschalter. */ 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) ──────────────────────── diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 40f9b5d..0869455 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -213,6 +213,12 @@ export interface PlanSelection { * über allem — ein Klick genau darauf meint die Linie). */ 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 * Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu @@ -303,6 +309,12 @@ export interface PlanViewProps { selectedRoomIds?: string[]; /** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */ 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 * Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur. @@ -331,6 +343,9 @@ export interface PlanViewProps { onStampEdit?: (roomId: string) => void; /** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */ 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 * der CAD-Konvention (siehe {@link MarqueeSelection}). Optional. @@ -447,6 +462,7 @@ export const PlanView = forwardRef( selectedStairIds, selectedRoomIds, selectedRoofIds, + selectedContextObjectIds, selectedSectionLineIds, selectedExtrudedSolidIds, selectedColumnIds, @@ -454,6 +470,7 @@ export const PlanView = forwardRef( onStampMove, onStampEdit, onEditDrawingText, + onOpenSectionLevel, activeTool, toolInputActive, toolHandlers, @@ -537,9 +554,15 @@ export const PlanView = forwardRef( // „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). 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 // 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- // breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim @@ -1060,6 +1083,22 @@ export const PlanView = forwardRef( 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 // ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon // trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie). @@ -1589,6 +1628,7 @@ export const PlanView = forwardRef( columnId: null, roofId: null, sectionLineId, + contextObjectId: null, shift: e.shiftKey, }); return; @@ -1644,6 +1684,13 @@ export const PlanView = forwardRef( openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId ? null : 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({ model: viewToModel(v.x, v.y), hitIndex, @@ -1657,6 +1704,7 @@ export const PlanView = forwardRef( columnId, roofId, sectionLineId: null, + contextObjectId, shift: e.shiftKey, }); } @@ -1729,6 +1777,14 @@ export const PlanView = forwardRef( 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); }; @@ -1856,6 +1912,21 @@ export const PlanView = forwardRef( ); }, [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 => + p.kind === "polygon" && p.contextObjectId != null && ids.has(p.contextObjectId), + ); + }, [plan, selectedContextObjectIds]); + // Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare) // Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen. const highlightSectionLinePolys = useMemo(() => { @@ -1988,8 +2059,9 @@ export const PlanView = forwardRef( darüber erreichen. An `wantGpu` (Absicht) montiert — NICHT an `useGpuRenderer` (=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt → glReady bleibt false → Deadlock). Bei WebGL-Fehler bleibt es leer hinten - und der volle SVG-Pfad übernimmt sichtbar. */} - {wantGpu && ( + und der volle SVG-Pfad übernimmt sichtbar. NICHT montiert, wenn ein + Luftbild aktiv ist — dann rendert der SVG-Pfad alles inkl. Bild-Ebene. */} + {wantGpu && !hasBgImages && ( ( className={useGpuRenderer ? undefined : "plan-bg"} 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 ( + + ); + })} {/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug gezeichnet → gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */} {primitiveEls} @@ -2114,6 +2208,18 @@ export const PlanView = forwardRef( pointerEvents="none" /> ))} + {/* Auswahl-Hervorhebung der gewählten Kontext-Objekte (Bbox-Pick-Band). */} + {highlightContextObjectPolys.map((poly, i) => ( + `${s.x},${s.y}`) + .join(" ")} + pointerEvents="none" + /> + ))} {/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */} {highlightSectionLinePolys.map((poly, i) => ( ( hudFields={hudFields ?? null} /> )} + {/* 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 && (() => { const m = measure(view); if (!m.rect) return null; @@ -2345,15 +2459,28 @@ export const PlanView = forwardRef( h: (m.rect.height || 1) / scale, }; return ( - + + + ); })()} - ); }); @@ -2385,7 +2512,7 @@ function DraftOverlay({ } | null; }) { 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 ( {/* Vorschau-Formen. */} @@ -3271,8 +3398,29 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] { 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(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). */ function DrawingRunShape({ @@ -3315,12 +3463,13 @@ function DrawingRunShape({ // Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente // als Dots erscheinen (Punktlinie / Strich-Punkt). const cap = dashHasDot(run.dash) ? ("round" as const) : undefined; + const stroke = run.greyed ? greyColor(run.color) : run.color; const shape = run.closed ? ( ); - return run.greyed ? {shape} : shape; + return shape; } function PrimitiveShape({ @@ -3354,12 +3503,9 @@ function PrimitiveShape({ hairline: boolean; paperScale: number | null; }) { - // Gedimmte Elemente bekommen eine niedrige Gruppen-Opazität, sodass Strich - // UND Füllung gleichmäßig zurücktreten (best-effort, ohne Farbumrechnung). - const shape = renderPrimitive(p, index, toScreen, hairline, paperScale); - if (p.greyed) { - return {shape}; - } + // Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung + // UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke. + const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale); return shape; } @@ -3887,17 +4033,17 @@ function PlanRulers({ <> )} diff --git a/src/plan/generatePlan.context.test.ts b/src/plan/generatePlan.context.test.ts new file mode 100644 index 0000000..b591fc2 --- /dev/null +++ b/src/plan/generatePlan.context.test.ts @@ -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(); + +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); + }); +}); diff --git a/src/plan/generatePlan.sectionLine.test.ts b/src/plan/generatePlan.sectionLine.test.ts index a51e4d8..833c487 100644 --- a/src/plan/generatePlan.sectionLine.test.ts +++ b/src/plan/generatePlan.sectionLine.test.ts @@ -79,6 +79,24 @@ describe("generatePlan — Schnittführungs-Symbol", () => { 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", () => { const p = baseProject([ floor, diff --git a/src/plan/generatePlan.stairArrow.test.ts b/src/plan/generatePlan.stairArrow.test.ts new file mode 100644 index 0000000..da39729 --- /dev/null +++ b/src/plan/generatePlan.stairArrow.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 70dd0eb..1be80c2 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -10,6 +10,7 @@ import type { Ceiling, Column, Component, + ContextObject, Drawing2D, ExtrudedSolid, HatchPattern, @@ -191,19 +192,22 @@ export interface HatchRender { /** * Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus * dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus, - * `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für - * jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten). + * `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked") + * 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 { render: boolean; greyed: boolean; + locked: boolean; } /** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */ export type CategoryDisplayResolver = (code: string) => CategoryDisplay; /** 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 = | { @@ -257,6 +261,12 @@ export type Primitive = * Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet). */ 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 * (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 { primitives: Primitive[]; 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. 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 => o.type === "image") + .map((o) => ({ id: o.id, src: o.src, min: o.min, max: o.max, opacity: o.opacity ?? 1 })); + return { primitives, 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). */ 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 - * dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert - * (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT - * (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt. + * Zeichnet die Kontext-Schicht `project.context` in den Grundriss: + * • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert). + * • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe + * 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 { const context = project.context ?? []; for (const obj of context) { - if (obj.type !== "contourSet") continue; - for (const c of obj.contours) { - const pts = c.pts; - if (pts.length < 2) continue; - const segEnd = c.closed ? pts.length : pts.length - 1; - for (let i = 0; i < segEnd; i++) { - out.push({ - kind: "line", - a: pts[i], - b: pts[(i + 1) % pts.length], - cls: "context-line", - weightMm: CONTEXT_LINE_MM, - color: CONTEXT_LINE_COLOR, - }); + if (obj.type === "contourSet") { + for (const c of obj.contours) { + const pts = c.pts; + if (pts.length < 2) continue; + const segEnd = c.closed ? pts.length : pts.length - 1; + for (let i = 0; i < segEnd; i++) { + out.push({ + kind: "line", + a: pts[i], + b: pts[(i + 1) % pts.length], + cls: "context-line", + weightMm: CONTEXT_LINE_MM, + 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(); + 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 * 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 { for (const level of project.drawingLevels) { 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; if (!line) continue; const [p0, p1] = line; @@ -3053,10 +3220,24 @@ function addStairSymbol( 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; - 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 }); + if (stair.arrowStyle === "filled") { + 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) // 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 }); } 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 ?? []) { - if (obj.type !== "contourSet") continue; - for (const c of obj.contours) for (const p of c.pts) acc(p); + if (obj.type === "contourSet") { + 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 }; return { minX, minY, maxX, maxY }; diff --git a/src/plan/glPlan/glPlanCompile.ts b/src/plan/glPlan/glPlanCompile.ts index add8cce..472517d 100644 --- a/src/plan/glPlan/glPlanCompile.ts +++ b/src/plan/glPlan/glPlanCompile.ts @@ -129,6 +129,18 @@ function parseColor(hex: string | undefined): Rgba | null { 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 + @@ -387,7 +399,7 @@ export function compilePrimitivesToGpu( track(pt.x, pt.y); } 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): @@ -414,7 +426,7 @@ export function compilePrimitivesToGpu( prim.hatch.pattern !== 'none' && 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 runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash); 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). const stroke = parseColor(prim.stroke); 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') { - 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) { // Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm // → Modell-Meter über dieselbe Kopplung wie Strichmuster). @@ -444,7 +456,10 @@ export function compilePrimitivesToGpu( } } else if (prim.kind === 'arc') { // 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 c = prim.center; 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'). // Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW, // offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar. - const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1]; + const stroke = dim(parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1], prim.greyed); const strokeMm = prim.weightMm || 0.18; const c = prim.center; const TAU = Math.PI * 2; @@ -519,7 +534,7 @@ export function compilePrimitivesToGpu( track(pt.x, pt.y); } for (const t of tris) fillIdx.push(base + t); - addFillBatch(tris.length, fill); + addFillBatch(tris.length, dim(fill, prim.greyed)); } } } diff --git a/src/plan/toRenderScene.ts b/src/plan/toRenderScene.ts index d0d7a43..0f938c0 100644 --- a/src/plan/toRenderScene.ts +++ b/src/plan/toRenderScene.ts @@ -132,20 +132,26 @@ const CLS_STROKE: Record = { "door-swing": "#1a1a1a", "door-frame": "#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 = { "wall-axis": 0.85, "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 { - const o = (cls ? CLS_OPACITY[cls] ?? 1 : 1) * (greyed ? GREYED_ALPHA : 1); - return o === 1 ? col : [col[0], col[1], col[2], col[3] * o]; + const o = cls ? CLS_OPACITY[cls] ?? 1 : 1; + 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. */ diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index b33ece0..0617d5d 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -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", () => { const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1"); // 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); }); + 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)", () => { // Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" = // 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); }); + 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", () => { const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" }; const project: Project = { ...sampleProject, roofs: [walmRoof] }; diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index fea2520..52a8777 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -222,6 +222,11 @@ export function wallMaterialColorMaps(project: Project): string[] { // 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); return list; } @@ -367,6 +372,16 @@ export interface RMesh { indices: number[]; kind: RMeshKind; 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). */ @@ -1138,12 +1153,22 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu // • Öffnungen (project.openings, Fenster mit Brüstung/Sturz), // • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz). // 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)) { const iv = openingInterval(wall, op); if (!iv) continue; 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 ?? []) { 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; // Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe. 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); @@ -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 // jeweiligen Box-Start korrekt stanzt. if (layered) { - const holes: RHole[] = cutouts.map((c) => ({ + const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({ from: c.from, to: c.to, zBottom: c.oBottom, zTop: c.oTop, + recessInnerLayer: c.recessInnerLayer, })); const startCut = cuts?.startCut ?? 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 - // `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 segHoles = holes .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)); }; @@ -1868,6 +1909,13 @@ function emitRoofs(project: Project): RMesh[] { const color = overrideColor ?? (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 indices: number[] = []; for (const plane of g.planes) { @@ -1881,7 +1929,7 @@ function emitRoofs(project: Project): RMesh[] { ]); 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; }, 0); // Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override. @@ -1919,6 +1967,13 @@ function emitMeshes(project: Project): RMesh[] { indices: obj.indices, kind: obj.type === "terrainMesh" ? "terrain" : "imported", 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; } +/** + * 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}/ * {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz, @@ -2803,6 +2900,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R meshes: [ ...emitMeshes(project), ...emitOpeningGlass(project), + ...emitLegacyDoorLeaves(project), ...emitOpeningFrames(project, detail), ...emitOpeningShading(project, detail), ...emitStairMeshes(project), @@ -2888,7 +2986,7 @@ export function pickGeometry( // Indizes robust überspringen statt abzustürzen (importierte Daten). const contextMeshes: PickContextMesh[] = []; for (const obj of project.context ?? []) { - if (obj.type === "contourSet") continue; + if (obj.type === "contourSet" || obj.type === "image") continue; const pos = obj.positions; const vcount = pos.length / 3; const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = []; @@ -3045,7 +3143,7 @@ export function selectionHighlightLines( // tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht. if (contextObjSet.size > 0) { 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; let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; diff --git a/src/state/contextObjectSelection.test.ts b/src/state/contextObjectSelection.test.ts new file mode 100644 index 0000000..13aa3f5 --- /dev/null +++ b/src/state/contextObjectSelection.test.ts @@ -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(); + }); +}); diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 5931cc2..197b6f2 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -8,7 +8,7 @@ // laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert // RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu. -import { sampleProject } from "../model/sampleProject"; +import { emptyProject } from "../model/sampleProject"; import { flattenCategories, recomputeFloorElevations, @@ -363,7 +363,10 @@ export function createProjectSlice( return { ...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, diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 51ecd36..e1cf8ef 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -24,6 +24,7 @@ import type { Ceiling, Column, ColumnProfile, + ContextObject, Drawing2D, Drawing2DGeom, ExtrudedSolid, @@ -41,6 +42,7 @@ import type { Wall, WallReferenceLine, } from "../model/types"; +import { localToLv95 } from "../io/lv95"; import { columnVerticalExtent } from "../model/wall"; import { columnFootprint } from "../geometry/column"; import { evaluateRoom, polygonArea } from "../geometry/roomArea"; @@ -258,6 +260,8 @@ export interface StairInfo { treadDepth: number; /** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */ up: boolean; + /** Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). Fehlt → "line". */ + arrowStyle?: "line" | "filled"; /** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */ referenz?: "links" | "mitte" | "rechts"; /** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */ @@ -348,7 +352,8 @@ export interface Selection { | "door" | "extrudedSolid" | "column" - | "roof"; + | "roof" + | "contextObject"; id: string; /** Grafik-Kategorie (Ebene) des Elements. */ categoryCode: string; @@ -424,6 +429,20 @@ export interface Selection { extrudedSolid?: ExtrudedSolidInfo; /** Stützen-Attribute (nur bei `kind === "column"`). */ 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; @@ -765,6 +784,7 @@ function stairSelection(project: Project, s: Stair): Selection { riserHeight: geo.riserHeight, treadDepth: geo.treadDepth, up: s.up !== false, + arrowStyle: s.arrowStyle, referenz: s.referenz, typeId: s.typeId, 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). */ function drawingSelection(project: Project, d: Drawing2D): Selection { const ls = d.lineStyleId @@ -963,7 +1030,12 @@ export function deriveSelection( selectedExtrudedSolidId: string | null = null, selectedColumnId: string | null = null, selectedRoofId: string | null = null, + selectedContextObjectId: string | null = null, ): Selection | null { + if (selectedContextObjectId) { + const o = (project.context ?? []).find((x) => x.id === selectedContextObjectId); + if (o) return contextObjectSelection(project, o); + } if (selectedDrawingId) { const d = project.drawings2d.find((x) => x.id === selectedDrawingId); return d ? drawingSelection(project, d) : null; diff --git a/src/state/siteSlice.ts b/src/state/siteSlice.ts index ab453a7..0e3fc09 100644 --- a/src/state/siteSlice.ts +++ b/src/state/siteSlice.ts @@ -22,6 +22,9 @@ export interface SiteSlice { addContextObjects: (objs: ContextObject[]) => void; /** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */ 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 * weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder @@ -64,6 +67,12 @@ export function createSiteSlice( removeContextObject: (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) => { const ctx = get().project.context ?? []; const set = ctx.find( diff --git a/src/styles.css b/src/styles.css index 71d01dd..d1f2114 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,69 +1,13 @@ -/* Erscheinungsbild nach DOSSIER ("RAPPORT": Creme + Petrol-Grün), inkl. +/* Erscheinungsbild nach DOSSIER ("RAPPORT": Creme + Neutralgrau), inkl. Dark Mode. Der linke Navigator (Zeichnungsebenen + Ebenen-Baum) übernimmt - das DOSSIER-Look: aktive Zeile als Petrol-Pille, nicht-aktive gedimmt, + das DOSSIER-Look: aktive Zeile als neutrale Pille, nicht-aktive gedimmt, ausgeblendete grau; Augen-Schalter, Farbtupfer, eingerückte Kinder. */ -/* Dark ist der feste Standard (Nutzer-Wunsch, unabhängig von OS/Browser- - Präferenz — vorher hing das an `prefers-color-scheme` und zeigte je nach - Umgebung fälschlich Light). Light bleibt als Opt-in erhalten (`[data-theme= - "light"]`) für ein späteres manuelles Umschalt-Feature. */ +/* Hell (Standard) ist der feste Startzustand; Dunkel ist Opt-in über + `[data-theme="dark"]`, gesetzt/persistiert per Umschalter in den + Einstellungen (src/theme/themeMode.ts) — unabhängig von OS/Browser- + Präferenz, damit die App nicht je nach Umgebung unterschiedlich startet. */ :root { - color-scheme: dark; - /* Gestufte Elevations-Palette (Wunsch: dunkler + mehr Tiefe). Tiefster - Grund hinten, Flächen werden nach vorn schrittweise heller — leicht - kühl-neutral, harmoniert mit dem Petrol-Akzent. - --bg Ebene 0 — App-Grund / Rinnen hinter allem (am dunkelsten) - --panel Ebene 1 — Oberleiste, Docks, Panel-Körper - --panel-2 Ebene 2 — angehobene Flächen: Panel-Köpfe, Hover-Zeilen - --input versenkt — Eingabefelder (dunkler als die Fläche → Inset) */ - /* Zwei-Ton-Grundlage (Nutzer-Vorgabe): Fast-Schwarz hinten, dunkles - Neutralgrau für die Flächen. Neutral (kein Blaustich), mit dezenten - Elevations-Schritten für Tiefe. */ - --bg: #0e0e0e; - --panel: #1d1d1d; - --panel-2: #262626; - --input: #0a0a0a; - --line: #2b2b2b; - --border: #383838; - - /* --sheet bleibt bewusst hell (#f0f0f0): das Zeichenblatt ist immer - „Papier", unabhängig vom UI-Theme (Wunsch des Nutzers). */ - --sheet: #f0f0f0; - - --ink: #ededed; - --ink-2: #a8a8a8; - --muted: #6e6e6e; - --label: #c8c8c8; - - /* Akzent NEUTRAL (kein Petrol-Grün, Nutzer-Vorgabe): helles Neutralgrau für - aktive/ausgewählte Flächen; weiße Schrift darauf bleibt lesbar. Hover/Dim - sind dezente weiße Aufhellungen. */ - --accent: #4d4d4d; - --accent-light: #5e5e5e; - --accent-dim: rgba(255, 255, 255, 0.07); - --accent-border: rgba(255, 255, 255, 0.22); - --active: #6a6a6a; - --active-light: #7d7d7d; - --active-dim: rgba(255, 255, 255, 0.1); - - --wall: #c8ccd2; - --swing: #7e8794; - --danger: #c85050; - --danger-light: #d86060; - /* Tiefere Schatten für klare Schichtung auf dunklem Grund. */ - --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5); - --shadow-2: 0 3px 12px rgba(0, 0, 0, 0.55); - --shadow-3: 0 12px 34px rgba(0, 0, 0, 0.62); - --overlay: rgba(0, 0, 0, 0.62); - - --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, - sans-serif; - --font-mono: "DM Mono", ui-monospace, "SF Mono", monospace; -} - -/* Light-Palette als Opt-in (aktuell von nichts gesetzt — bereit für einen - künftigen manuellen Theme-Umschalter in den Einstellungen). */ -:root[data-theme="light"] { color-scheme: light; --bg: #e0dbd4; @@ -73,20 +17,27 @@ --line: #d4cfc8; --border: #c8c2ba; - --sheet: #f0f0f0; + /* --sheet bleibt bewusst hell (#f0f0f0): das Zeichenblatt ist immer + „Papier", unabhängig vom UI-Theme (Wunsch des Nutzers). */ + --sheet: #faf6ee; --ink: #1a1a18; --ink-2: #555550; --muted: #8a8580; --label: #1a1a18; - --accent: #2f5d54; - --accent-light: #4a8a7c; - --accent-dim: #e6efed; - --accent-border: rgba(47, 93, 84, 0.35); - --active: #1a655a; - --active-light: #2f8275; - --active-dim: rgba(26, 101, 90, 0.12); + /* Akzent NEUTRAL (kein Petrol-Grün, Nutzer-Vorgabe). */ + --accent: #4a453e; + --accent-light: #6b655c; + --accent-dim: #f5f2ec; + --accent-border: rgba(58, 53, 46, 0.2); + --active: #3f3a33; + --active-light: #5c564d; + --active-dim: rgba(58, 53, 46, 0.12); + /* Helle, randlose Feldfläche (Dropdowns/Pillen der Oberleiste, Mockup- + Konvention) — bewusst heller als --panel/--input, damit Felder als + eigene Fläche erkennbar sind, OHNE Rahmen zu brauchen. */ + --field-bg: #f5f2ec; --wall: #2b3039; --swing: #9aa3b2; @@ -98,6 +49,55 @@ --shadow-2: 0 2px 8px rgba(26, 26, 24, 0.14); --shadow-3: 0 6px 24px rgba(26, 26, 24, 0.18); --overlay: rgba(26, 26, 24, 0.36); + + --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + sans-serif; + --font-mono: "DM Mono", ui-monospace, "SF Mono", monospace; +} + +/* Dunkel-Palette als Opt-in (Einstellungen → Darstellung → Dunkelmodus). */ +:root[data-theme="dark"] { + color-scheme: dark; + /* Gestufte Elevations-Palette: Tiefster Grund hinten, Flächen werden nach + vorn schrittweise heller — leicht kühl-neutral. + --bg Ebene 0 — App-Grund / Rinnen hinter allem (am dunkelsten) + --panel Ebene 1 — Oberleiste, Docks, Panel-Körper + --panel-2 Ebene 2 — angehobene Flächen: Panel-Köpfe, Hover-Zeilen + --input versenkt — Eingabefelder (dunkler als die Fläche → Inset) */ + --bg: #0e0e0e; + --panel: #1d1d1d; + --panel-2: #262626; + --input: #0a0a0a; + --line: #2b2b2b; + --border: #383838; + + --sheet: #faf6ee; + + --ink: #ededed; + --ink-2: #a8a8a8; + --muted: #6e6e6e; + --label: #c8c8c8; + + --accent: #4d4d4d; + --accent-light: #5e5e5e; + --accent-dim: rgba(255, 255, 255, 0.07); + --accent-border: rgba(255, 255, 255, 0.22); + --active: #6a6a6a; + --active-light: #7d7d7d; + --active-dim: rgba(255, 255, 255, 0.1); + /* Helle, randlose Feldfläche (Dropdowns/Pillen der Oberleiste) — angehoben + gegenüber --panel-2, analog zur hellen Palette. */ + --field-bg: #2a2a2a; + + --wall: #c8ccd2; + --swing: #7e8794; + --danger: #c85050; + --danger-light: #d86060; + /* Tiefere Schatten für klare Schichtung auf dunklem Grund. */ + --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5); + --shadow-2: 0 3px 12px rgba(0, 0, 0, 0.55); + --shadow-3: 0 12px 34px rgba(0, 0, 0, 0.62); + --overlay: rgba(0, 0, 0, 0.62); } * { @@ -159,7 +159,9 @@ body { align-items: center; gap: 8px; padding: 0 10px; - height: 26px; + /* EINE Zeile für Chrome + Ansichts-Controls (Mockup) — hoch genug für die + 24px-Pillen der View-/Zoom-/Text-Steuerung. */ + height: 40px; border-bottom: 1px solid var(--line); /* Oberleiste auf Ebene 1 (angehoben über dem App-Grund) + weicher Schatten nach unten → trennt sich klar von der Zeichenfläche (Tiefe). */ @@ -167,18 +169,17 @@ body { box-shadow: var(--shadow-1); position: relative; z-index: 2; - overflow-x: auto; - overflow-y: hidden; + /* Die Leiste selbst scrollt NICHT — nur der mittlere View-Controls-Block + (.ribbon-views) scrollt bei schmalen Fenstern. So bleiben Marke, Menü und + die rechte Gruppe (Datei-Aktionen + Projekt) IMMER sichtbar. */ + overflow: visible; white-space: nowrap; - /* Dezente, normalerweise unsichtbare Scrollleiste (Firefox). */ - scrollbar-width: thin; - scrollbar-color: transparent transparent; } /* Tauri/macOS: native Ampeln (titleBarStyle "Overlay") liegen oben links über dem Inhalt → Zeile links einrücken, damit `dossier.` frei bleibt (~3 Ampeln + Rand). */ .topbar.topbar-native-inset { - padding-left: 78px; + padding-left: 88px; } /* Sehr ruhige eigene Scrollleiste (WebKit/Blink): Thumb nur beim Scrollen (`is-scrolling`, von TopBar gesetzt) oder beim Überfahren sichtbar. */ @@ -230,20 +231,25 @@ body { color: var(--muted); } -/* Kompaktes Dropdown der Oberleiste (Detailgrad, Massstab). */ +/* Kompaktes Dropdown der Oberleiste (Detailgrad, Massstab) — flache Pille ohne + Rahmen (Mockup-Konvention): gefüllte Fläche in --field-bg, kein Rand. */ .tb-select { - border: 1px solid var(--border); - background: var(--input); + border: none; + background: var(--field-bg); color: var(--ink); font-family: inherit; font-size: 11px; font-weight: 600; - padding: 3px 6px; + padding: 3px 7px; border-radius: 6px; cursor: pointer; } .tb-select:hover { - border-color: var(--accent-border); + background: var(--accent-dim); +} +.tb-select:disabled { + opacity: 0.45; + cursor: default; } .tb-scale { font-family: var(--font-mono); @@ -252,10 +258,9 @@ body { /* Gruppe aus Knöpfen (Zoom). */ /* Aktions-Pillen (100 % · Einpassen · Auswahl · PDF) — optisch eine Familie mit - den Dropdown-Triggern (.tb-dd-trigger): jede Aktion ist eine eigene gerahmte, - abgesetzte dunkle Pill mit demselben Akzent-Hover (Rand → accent-border, - Fläche → accent-dim, Text → accent). Sie bleiben Buttons (Aktion ≠ Auswahl); - nur die Optik ist angeglichen. */ + den Dropdown-Triggern (.tb-dd-trigger): flache, randlose Pille in + --field-bg, Text/Rand-Akzent bei Hover (Mockup-Konvention — kein „stets + dunkler" Sonderlook mehr). */ .tb-btns { display: inline-flex; gap: 6px; @@ -263,11 +268,9 @@ body { .tb-btn { display: inline-flex; align-items: center; - /* Gleiche stets-dunkle Fläche wie .tb-dd-trigger — einheitlicher Pillen- - Look über die ganze Oberleiste. */ - border: 1px solid #4a4a4a; - background: #2c2c2c; - color: #e8e8e8; + border: none; + background: var(--field-bg); + color: var(--ink-2); font-family: inherit; font-size: 11px; font-weight: 600; @@ -276,21 +279,17 @@ body { border-radius: 8px; cursor: pointer; outline: none; - transition: border-color 0.14s, background-color 0.14s, color 0.14s, - box-shadow 0.14s; + transition: background-color 0.14s, color 0.14s, box-shadow 0.14s; } .tb-btn:hover:not(:disabled) { - border-color: var(--accent-border); - background-color: rgba(255, 255, 255, 0.1); - color: var(--accent); + background: var(--accent-dim); + color: var(--ink); } .tb-btn:focus-visible { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-dim); + box-shadow: 0 0 0 2px var(--accent-border); } .tb-btn:disabled { - color: #8a8a8a; - opacity: 0.55; + opacity: 0.45; cursor: default; } @@ -329,11 +328,11 @@ body { text-align: right; } -/* Pillen-Toggle der Oberleiste (Referenzlinien). */ +/* Pillen-Toggle der Oberleiste (Referenzlinien) — randlos, --field-bg. */ .tb-toggle { - border: 1px solid #4a4a4a; - background: #2c2c2c; - color: #e8e8e8; + border: none; + background: var(--field-bg); + color: var(--ink-2); font-family: inherit; font-size: 11px; font-weight: 600; @@ -341,70 +340,67 @@ body { padding: 6px 12px; border-radius: 8px; cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } .tb-toggle:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-border); - color: var(--accent); + background: var(--accent-dim); + color: var(--ink); } .tb-toggle.active { background: var(--accent); - border-color: var(--accent); color: #fff; } /* ── DOSSIER-Grundprimitive: Segmentpille · Icon-Knopf · Zoom-Cluster ──────── */ -/* Segmentpille (3er/4er): Aussencontainer rund, Zellen gleich hoch, interne - Trenner über border-left. Genutzt für Zoom-Aktionen, B/I/U, L/C/R. Gleiche - stets-dunkle Fläche wie .tb-dd-trigger/.tb-btn (einheitlicher Pillen-Look). */ +/* Segmentpille (3er/4er): randloser Cluster OHNE interne Trennstriche + (Mockup-Konvention) — die Zellen sitzen als kleine Pillen auf --field-bg, + aktive Zelle in accent-dim. Gleiche 24px-Höhe wie die Dropdowns. */ .tb-seg { display: inline-flex; align-items: stretch; height: 24px; box-sizing: border-box; - border: 1px solid #4a4a4a; + border: none; border-radius: 8px; overflow: hidden; - background: #2c2c2c; + background: var(--field-bg); + padding: 2px; + gap: 1px; flex: 0 0 auto; } /* Akzent-Rand, wenn ein Text-Ziel selektiert ist (Auswahl-Bewusstsein). */ .tb-seg-accent { - border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); } .tb-seg-cell { display: inline-flex; align-items: center; justify-content: center; flex: 1 1 0; - min-width: 26px; + min-width: 22px; padding: 0 6px; border: none; - border-left: 1px solid #4a4a4a; + border-radius: 5px; background: transparent; - color: #b0b0b0; + color: var(--ink-2); cursor: pointer; transition: background 0.14s, color 0.14s; } -.tb-seg-cell:first-child { - border-left: none; -} .tb-seg-cell:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - color: #f0f0f0; + background: var(--accent-dim); + color: var(--ink); } .tb-seg-cell.active { - background: var(--accent); - color: #fff; + background: var(--accent-dim); + color: var(--ink); } .tb-seg-cell:disabled { opacity: 0.4; cursor: default; } -/* Quadratischer Icon-Knopf (BarButton). */ +/* Quadratischer Icon-Knopf (BarButton) — randlos, --field-bg. */ .tb-iconbtn { display: inline-flex; align-items: center; @@ -413,17 +409,16 @@ body { height: 24px; box-sizing: border-box; padding: 0; - border: 1px solid #4a4a4a; + border: none; border-radius: 8px; - background: #2c2c2c; - color: #b0b0b0; + background: var(--field-bg); + color: var(--ink-2); cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } .tb-iconbtn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-border); - color: var(--accent); + background: var(--accent-dim); + color: var(--ink); } .tb-iconbtn:disabled { opacity: 0.4; @@ -442,57 +437,49 @@ body { align-items: center; gap: 6px; } +/* Mockup: Massstab/Zoom als zwei kompakte, nebeneinanderliegende Readout-Pillen + (1:100 · 142%) statt einer grossen 2-zeiligen Stat-Kachel. */ .tb-stat { - display: flex; - flex-direction: column; - justify-content: center; + display: inline-flex; align-items: center; - width: 70px; - height: 50px; + gap: 6px; + height: 24px; + padding: 0 9px; box-sizing: border-box; - border: 1px solid var(--border); - border-radius: 14px; - background: var(--input); - /* Ring (Box-Shadow statt Border) für „am Massstab" — startet unsichtbar - (Alpha 0) und blendet weich ein/aus, statt die Fläche aufzuhellen. */ - box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0); + border: none; + border-radius: 6px; + background: var(--field-bg); + box-shadow: 0 0 0 1.5px transparent; transition: box-shadow 0.25s ease; } .tb-stat-scale { font-family: var(--font-mono); font-size: 11px; font-weight: 700; - line-height: 1.3; - color: var(--accent); - width: 100%; - text-align: center; - border-bottom: 1px solid var(--border); + color: var(--ink); + font-variant-numeric: tabular-nums; } .tb-stat-zoom { font-family: var(--font-mono); font-size: 11px; - line-height: 1.3; color: var(--ink-2); - width: 100%; - text-align: center; + font-variant-numeric: tabular-nums; } -/* „am Massstab" (Live == gewählter Massstab): weisser Ring statt aufgehellter - Fläche — blendet dynamisch ein/aus (s. transition oben), Fläche bleibt - unverändert. */ +/* „am Massstab" (Live == gewählter Massstab): dezenter Akzent-Ring. */ .tb-stat-atscale { - box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.9); + box-shadow: 0 0 0 1.5px var(--accent-border); } .tb-zoomcluster-right { display: inline-flex; - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: center; gap: 6px; } -/* ── Text-Gruppe (immer sichtbar, 3×2) ───────────────────────────────────── */ +/* ── Text-Gruppe (Mockup: EINE kompakte inline Zeile) ─────────────────────── */ .tb-textgroup { - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: center; gap: 6px; } .tb-textgroup-row { @@ -510,10 +497,10 @@ body { height: 24px; width: 80px; box-sizing: border-box; - border: 1px solid #4a4a4a; + border: none; border-radius: 8px; overflow: hidden; - background: #2c2c2c; + background: var(--field-bg); flex: 0 0 auto; } .tb-lineheight-btn { @@ -524,13 +511,13 @@ body { width: 22px; border: none; background: transparent; - color: #b0b0b0; + color: var(--ink-2); cursor: pointer; transition: background 0.14s, color 0.14s; } .tb-lineheight-btn:hover { - background: rgba(255, 255, 255, 0.1); - color: #f0f0f0; + background: var(--accent-dim); + color: var(--ink); } .tb-lineheight-value { flex: 1 1 auto; @@ -539,9 +526,9 @@ body { justify-content: center; font-family: var(--font-mono); font-size: 11px; - color: #e8e8e8; - border-left: 1px solid #4a4a4a; - border-right: 1px solid #4a4a4a; + color: var(--ink); + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); } /* Marken-Gruppe: Wortmarke + kleiner Icon-Stapel daneben (Settings/Ressourcen), @@ -584,6 +571,37 @@ body { color: #0f766e; } +/* Menüleiste (Bearbeiten/Ansicht/Fenster, AppMenuBar) — sitzt neben der + Wortmarke. Flache Text-Trigger statt der sonst üblichen Dropdown-Pille + (.tb-dd-trigger), damit sich die Leiste wie eine klassische Menüzeile + liest statt wie eine Reihe Wert-Auswahlfelder. */ +.tb-menubar { + display: flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} +.tb-menubar-item.tb-dd-trigger { + border: none; + background: none; + color: var(--ink-dim, #8a8a8a); + font-weight: 500; + padding: 3px 6px; + border-radius: 6px; +} +.tb-menubar-item.tb-dd-trigger:hover:not(:disabled) { + background-color: var(--accent-dim); + color: var(--accent); +} +.tb-menubar-item.tb-dd-trigger.active { + background-color: var(--accent-dim); + color: var(--accent); + box-shadow: none; +} +.tb-menubar-item .tb-dd-chevron { + display: none; +} + /* Export-Sammelmenü: Liste im Popover (fixed) über der Oberleiste. */ .tb-popover.tb-menu { padding: 4px; @@ -787,40 +805,87 @@ body { color: #fff; } -/* ── Ansichten als kompaktes Icon-Grid (DOSSIER-Stil, 3 oben / 3 unten) ──── */ -.view-grid { - display: grid; - grid-template-columns: repeat(3, auto); - grid-auto-rows: 1fr; - gap: 3px; +/* ── Ansichts-Umschalter + Blickwinkel (Mockup: viewtoggle + V/H/L/R/O) ────── */ +.tb-viewtoggle, +.tb-views { + display: inline-flex; + align-items: center; + gap: 2px; flex: 0 0 auto; } -.view-grid-4 { - grid-template-columns: repeat(4, auto); -} -.view-icon { - border: 1px solid #4a4a4a; - background: #2c2c2c; - color: #b0b0b0; +.tb-vt-btn, +.tb-view-btn { display: inline-flex; align-items: center; justify-content: center; - width: 31px; - height: 22px; + width: 26px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--ink-2); + border-radius: 6px; + cursor: pointer; + flex: 0 0 auto; + transition: background 0.14s, color 0.14s; +} +.tb-view-btn { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; +} +.tb-vt-btn svg { + width: 15px; + height: 15px; +} +.tb-vt-btn:hover:not(:disabled), +.tb-view-btn:hover:not(:disabled) { + background: var(--accent-dim); + color: var(--ink); +} +.tb-vt-btn.active, +.tb-view-btn.active { + background: var(--accent-dim); + color: var(--ink); +} +.tb-vt-btn:disabled, +.tb-view-btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* ── Ansichten als EINE kompakte Icon-Zeile (Mockup) ──────────────────────── */ +.view-grid { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} +.view-grid-4 { + /* keine Sonderbreite mehr — alle Ansichts-Icons in EINER Zeile. */ +} +.view-icon { + border: none; + background: transparent; + color: var(--ink-2); + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 24px; padding: 0; border-radius: 6px; cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } .view-icon:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - color: #f0f0f0; - border-color: var(--accent-border); + background: var(--accent-dim); + color: var(--ink); } .view-icon.active { - background: var(--accent); - color: #fff; - border-color: var(--accent); + background: var(--accent-dim); + color: var(--ink); } .view-icon:disabled { opacity: 0.4; @@ -838,36 +903,31 @@ body { display: inline-flex; } .view-icon-split-main { - width: 22px; + width: 24px; border-top-right-radius: 0; border-bottom-right-radius: 0; - border-right: none; } .view-icon-caret { - width: 9px; - height: 22px; + width: 12px; + height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; - border: 1px solid #4a4a4a; - background: #2c2c2c; - color: #b0b0b0; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border: none; + background: transparent; + color: var(--muted); border-radius: 0 6px 6px 0; cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } .view-icon-caret:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - color: #f0f0f0; - border-color: var(--accent-border); + background: var(--accent-dim); + color: var(--ink); } .view-icon-caret.active { - background: var(--accent); - color: #fff; - border-color: var(--accent); + background: var(--accent-dim); + color: var(--ink); } .view-icon-caret:disabled { opacity: 0.4; @@ -894,15 +954,23 @@ body { cursor: default; } -/* Gestapelte Gruppe (Label-Felder untereinander, z. B. Ebenen-/Zeichn.-Komb.). */ +/* Mockup: EINE kompakte Zeile — die früher gestapelten Gruppen (Kombinationen, + Detailgrad/Darstellung) laufen jetzt inline nebeneinander. */ .tb-group.tb-stack { - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: center; gap: 6px; } .tb-field-row { - justify-content: space-between; - gap: 8px; + align-items: center; + gap: 5px; +} +/* Feld-Labels sehr dezent halten (Mockup zeigt kompakte Pillen ohne grosse + Beschriftungen). */ +.tb-field .tb-label { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.04em; } /* Rumpf: Navigator + Inhalt nebeneinander. */ @@ -974,7 +1042,7 @@ body { .sb-sep { width: 1px; height: 12px; - background: var(--border); + background: var(--line); } /* Renderer-Umschalter (WebGL2 / Nordstern) — kleine Segmentpille; ursprünglich @@ -986,16 +1054,16 @@ body { align-items: stretch; align-self: flex-start; height: 16px; - border: 1px solid var(--border); + border: 1px solid var(--line); border-radius: 999px; overflow: hidden; - background: var(--input); + background: var(--field-bg); } .sb-renderer-btn { display: inline-flex; align-items: center; border: none; - border-left: 1px solid var(--border); + border-left: 1px solid var(--line); background: transparent; color: var(--muted); font-family: inherit; @@ -1010,12 +1078,12 @@ body { border-left: none; } .sb-renderer-btn:hover:not(:disabled) { - background: var(--accent-dim); - color: var(--accent); + background: var(--active-dim); + color: var(--active-light); } .sb-renderer-btn.active { - background: var(--accent); - color: #fff; + background: var(--active-dim); + color: var(--active-light); } .sb-renderer-btn:disabled { opacity: 0.4; @@ -1026,27 +1094,27 @@ body { .sb-toggle { display: inline-flex; align-items: center; - border: 1px solid var(--border); - border-radius: 5px; + border: 1px solid var(--line); + border-radius: 999px; background: transparent; color: var(--muted); font-family: inherit; font-size: 10px; font-weight: 600; letter-spacing: 0.02em; - padding: 2px 8px; + padding: 2px 10px; cursor: pointer; transition: background 0.14s, color 0.14s, border-color 0.14s; } .sb-toggle:hover:not(:disabled) { - background: var(--accent-dim); - color: var(--accent); - border-color: var(--accent); + background: var(--active-dim); + color: var(--active-light); + border-color: var(--line); } .sb-toggle.active { - background: var(--accent); - border-color: var(--accent); - color: #fff; + background: var(--active-dim); + border-color: transparent; + color: var(--active-light); } .sb-toggle:disabled { opacity: 0.4; @@ -1059,10 +1127,10 @@ body { display: inline-flex; align-items: stretch; height: 16px; - border: 1px solid var(--border); + border: 1px solid var(--line); border-radius: 999px; overflow: hidden; - background: var(--input); + background: var(--field-bg); } .sb-seg.disabled { opacity: 0.4; @@ -1071,7 +1139,7 @@ body { display: inline-flex; align-items: center; border: none; - border-left: 1px solid var(--border); + border-left: 1px solid var(--line); background: transparent; color: var(--muted); font-family: inherit; @@ -1086,12 +1154,12 @@ body { border-left: none; } .sb-seg-btn:hover:not(:disabled) { - background: var(--accent-dim); - color: var(--accent); + background: var(--active-dim); + color: var(--active-light); } .sb-seg-btn.active { - background: var(--accent); - color: #fff; + background: var(--active-dim); + color: var(--active-light); } .sb-seg-btn:disabled { opacity: 0.4; @@ -1117,10 +1185,10 @@ body { margin: 0 0 6px; padding: 6px 0; min-width: 170px; - background: var(--panel, #1c2128); - border: 1px solid var(--border); + background: var(--panel); + border: 1px solid var(--line); border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); z-index: 200; } .sb-snap-row { @@ -1336,23 +1404,77 @@ body { gap: 4px; } -.nav-add { - border: 1px solid var(--border); - background: var(--input); +/* Einheitlicher „+"-Knopf für neue Ebene / neue Zeichnungsebene: rundes + Kreis-Icon (kein Pill), dezent, accent-dim beim Überfahren. */ +.nav-add, +.level-cat-add { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--field-bg); color: var(--ink-2); font-family: inherit; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.02em; - padding: 3px 9px; - border-radius: 999px; + font-size: 15px; + line-height: 1; + font-weight: 500; cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } -.nav-add:hover { +.nav-add:hover, +.level-cat-add:hover { background: var(--accent-dim); - border-color: var(--accent-border); - color: var(--accent); + color: var(--ink); +} + +/* Zahnrad in der Panel-Kopfzeile (Zeichnungsebenen/Ebenen) → Einstellungen. + Dezent, rund, wie der „+"-Knopf daneben. */ +.nav-settings { + flex: 0 0 auto; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 50%; + padding: 0; + transition: background 0.12s, color 0.12s; +} +.nav-settings:hover { + background: var(--accent-dim); + color: var(--ink); +} +.nav-settings .material-symbols-outlined { + font-size: 15px; +} + +/* Sichtbarkeits-Umschalter im Panel-Körper (Ebenen/Zeichnungsebenen): kleine + „Sichtbarkeit"-Beschriftung über dem Modus-Dropdown. */ +.panel-vis { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 10px 12px; +} +.panel-vis-label { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} +.panel-vis .display-mode { + max-width: none; + width: 100%; } .nav-list { @@ -1367,16 +1489,19 @@ body { flex-direction: column; } +/* Randlos, kein Kasten mehr (Mockup-Konvention) — reiner uppercase-Text wie + .field-label, Chevron/Plus bleiben (Funktion), nur ohne Fläche. */ .level-cat-head { display: flex; align-items: center; gap: 6px; min-height: 24px; - padding: 3px 10px 3px 6px; - background: var(--panel-2); - border-bottom: 1px solid var(--line); + padding: 6px 10px 3px 6px; + background: transparent; + border-bottom: none; cursor: pointer; user-select: none; + border-radius: 6px; } .level-cat-head:hover { background: var(--accent-dim); @@ -1620,13 +1745,15 @@ body { .cat-row { cursor: pointer; /* Zeile ist als „aktive Ebene wählen" klickbar */ } -/* Aktive Ebene (Zeichen-Ziel): Akzentstreifen links + leichte Akzentfüllung. */ +/* Aktive Ebene (Zeichen-Ziel): identisch zur Zeichnungsebenen-Auswahl + (.nav-row.active) — helle Pille (active-dim) statt Akzentstreifen. */ .cat-row.active { - background: var(--accent-dim); - box-shadow: inset 2px 0 0 var(--accent); + background: var(--active-dim); + border-radius: 999px; + border-bottom-color: transparent; } .cat-row.active .cat-name { - color: var(--text, #e6e6e6); + color: var(--active-light); font-weight: 600; } .cat-row.hidden { @@ -1670,6 +1797,37 @@ body { font-weight: 500; } +/* Zahnrad — öffnet die Ebeneneinstellungen (natives Fenster unter Tauri, s. + native/layerSettingsWindow.ts). Nur bei Zeilen-Hover eingeblendet, damit + die Liste nicht überladen wirkt (anders als das Auge, das den Zustand + dauerhaft zeigt). */ +.cat-settings { + flex: 0 0 auto; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + border-radius: 50%; + padding: 0; + opacity: 0; + transition: opacity 0.12s, background 0.12s, color 0.12s; +} +.cat-row:hover .cat-settings { + opacity: 1; +} +.cat-settings:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--ink); +} +.cat-settings .material-symbols-outlined { + font-size: 15px; +} + /* ── Inhalt (rechts) ─────────────────────────────────────────────────── */ .content { flex: 1; @@ -1695,11 +1853,14 @@ body { Leiste auf Ebene 1 (--panel), der aktive Tab hebt sich auf Ebene 2 an und verschmilzt unten mit dem Grund der Zeichenfläche darunter (--bg), wie bei klassischen Dokument-Tabs. */ +/* Geschoss-/Dokument-Tabs: keine Ordnerreiter mehr, sondern die gleiche helle + Pillen-Sprache wie Panels/Umschalter — flache Chips, aktiver Tab = helle + Füllung (field-bg) mit Akzent-Unterstrich statt gerahmter Reiter. */ .drawing-tabs { display: flex; - align-items: flex-end; - gap: 2px; - padding: 6px 10px 0; + align-items: center; + gap: 4px; + padding: 6px 10px; border-bottom: 1px solid var(--line); background: var(--panel); overflow-x: auto; @@ -1707,31 +1868,26 @@ body { } .drawing-tab { - border: 1px solid transparent; - border-bottom: none; + border: none; background: transparent; color: var(--muted); font-family: inherit; font-size: 12px; font-weight: 600; letter-spacing: 0.01em; - padding: 7px 16px; - border-radius: 8px 8px 0 0; + padding: 5px 14px; + border-radius: 8px; cursor: pointer; white-space: nowrap; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s, box-shadow 0.14s; } .drawing-tab:hover { - background: var(--panel-2); + background: var(--accent-dim); color: var(--ink); } .drawing-tab.active { - background: var(--bg); - border-color: var(--line); - color: var(--ink); - /* Nahtloser Übergang: die Unterkante verschwindet im Zeichenflächen-Grund. */ - position: relative; - top: 1px; + background: var(--active-dim); + color: var(--active-light); } .drawing-tab-label { pointer-events: none; @@ -2017,7 +2173,7 @@ body { .plan-svg .snap-glyph { fill: none; stroke: #ffcc44; - stroke-width: 2.2; + stroke-width: 1.5; } .plan-svg .snap-glyph.snap-fill { fill: #ffcc44; @@ -2028,62 +2184,85 @@ body { stroke-dasharray: 4 4; opacity: 0.8; } -/* Cursor-HUD (Vectorworks-Stil): weisser Kasten, blauer Rahmen, blauer - Monospace-Text „L: 3.118m W: 60.000°". */ -/* Cursor-HUD im Programm-Look: dieselbe dunkle Pille wie die Dropdown-Trigger - (.tb-dd-trigger #2c2c2c/#4a4a4a, helle Schrift) — sitzt wie die schwebenden - Viewport-Knöpfe dunkel über dem hellen Papier. */ +/* Cursor-HUD (Massband „L: 3.118m W: 60.000°"). Themen-abhängig: im Light- + Mode eine helle Papier-Pille mit dunkler Schrift, im Dark-Mode die dunkle + Pille wie die Dropdown-Trigger. Basis = Light; Dark unten überschrieben. */ .plan-svg .plan-hud-box { - fill: #2c2c2c; - fill-opacity: 0.94; - stroke: #4a4a4a; + fill: #fbf8f2; + fill-opacity: 0.96; + stroke: #cfc9c0; stroke-width: 1; } .plan-svg .plan-hud-text { - fill: #e8e8e8; + fill: #2a2926; font-family: var(--mono, ui-monospace, monospace); font-weight: 600; } -/* Aktives Tab-Feld im HUD: leicht angehobene Pille (wie Hover-Flächen), weisser Text. */ +/* Aktives Tab-Feld im HUD: leicht abgesenkte Pille, dunklerer Text. */ .plan-svg .plan-hud-active { + fill: #000000; + fill-opacity: 0.07; +} +.plan-svg .plan-hud-text.active { + fill: #111110; +} +/* Gelockter (getippter) Wert: voll dunkel + fett — hebt sich vom Live-Grau ab. */ +.plan-svg .plan-hud-text.locked { + fill: #000000; + font-weight: 700; +} + +:root[data-theme="dark"] .plan-svg .plan-hud-box { + fill: #2c2c2c; + fill-opacity: 0.94; + stroke: #4a4a4a; +} +:root[data-theme="dark"] .plan-svg .plan-hud-text { + fill: #e8e8e8; +} +:root[data-theme="dark"] .plan-svg .plan-hud-active { fill: #ffffff; fill-opacity: 0.16; } -.plan-svg .plan-hud-text.active { +:root[data-theme="dark"] .plan-svg .plan-hud-text.active, +:root[data-theme="dark"] .plan-svg .plan-hud-text.locked { fill: #ffffff; } -/* Gelockter (getippter) Wert: voll weiss + fett — hebt sich vom Live-Grau ab. */ -.plan-svg .plan-hud-text.locked { - fill: #ffffff; - font-weight: 700; -} /* Lineale (oben + links, VW-Stil): helle Papier-Leisten, Tinte-Ticks, gelber Cursor-Marker. */ -.plan-svg .plan-ruler-strip { +/* .plan-ruler-overlay: eigenständiges Sibling-SVG des Lineals (s. PlanView.tsx + „screen-fix" Kommentar) — dieselben Regeln gelten daher für BEIDE Elternteile. */ +.plan-svg .plan-ruler-strip, +.plan-ruler-overlay .plan-ruler-strip { fill: #f7f6f3; fill-opacity: 0.96; } -.plan-svg .plan-ruler-border { +.plan-svg .plan-ruler-border, +.plan-ruler-overlay .plan-ruler-border { stroke: #b9b5ae; stroke-width: 1; vector-effect: non-scaling-stroke; } -.plan-svg .plan-ruler-tick { +.plan-svg .plan-ruler-tick, +.plan-ruler-overlay .plan-ruler-tick { stroke: #6d6a64; stroke-width: 1; vector-effect: non-scaling-stroke; } -.plan-svg .plan-ruler-text { +.plan-svg .plan-ruler-text, +.plan-ruler-overlay .plan-ruler-text { fill: #3c3a36; font-family: var(--mono, ui-monospace, monospace); font-weight: 500; } -.plan-svg .plan-ruler-cursor { +.plan-svg .plan-ruler-cursor, +.plan-ruler-overlay .plan-ruler-cursor { fill: #e8c33c; fill-opacity: 0.9; } -.plan-svg .plan-ruler-corner { +.plan-svg .plan-ruler-corner, +.plan-ruler-overlay .plan-ruler-corner { fill: #f0efec; stroke: #b9b5ae; stroke-width: 1; @@ -2343,6 +2522,25 @@ body { box-shadow: var(--shadow-3, 0 12px 32px rgba(0, 0, 0, 0.45)); overflow: hidden; } +/* Eigenes natives Fenster (native/resourceWindow.ts) statt schwebendes + Overlay: füllt die ganze Fensterfläche, kein Rahmen/Schatten/rundes Eck + nötig (das IST schon das ganze Fenster), kein Move-Cursor auf der + Kopfzeile (das OS-Fenster übernimmt Verschieben/Grössenändern). */ +.res-window.res-window-embedded { + position: static; + inset: 0; + width: 100%; + height: 100%; + border: none; + border-radius: 0; + box-shadow: none; +} +.res-window.res-window-embedded .res-head { + cursor: default; + /* Platz für die macOS-Ampeln (titleBarStyle "overlay", s. windowChrome.ts) — + dieselbe Einrückung wie alle übrigen Fenster-Köpfe (76px). */ + padding-left: 88px; +} .res-window .res-head { cursor: move; touch-action: none; @@ -2360,10 +2558,10 @@ body { align-items: center; justify-content: space-between; padding: 0 16px; - height: 52px; + height: 40px; flex: 0 0 auto; border-bottom: 1px solid var(--line); - background: var(--bg); + background: var(--panel); } .res-head-title { display: inline-flex; @@ -2398,38 +2596,35 @@ body { color: var(--ink); } -/* Tab-Leiste. */ +/* Tab-Leiste — Pille wie .tb-dd-trigger statt Ordnerlasche (Mockup- + Konvention): accent-dim-Hintergrund im aktiven Zustand, sonst transparent, + kein Rahmen/Verschmelzen mit dem Rand. */ .res-tabs { display: flex; - gap: 4px; + gap: 2px; padding: 10px 16px 0; flex: 0 0 auto; border-bottom: 1px solid var(--line); - background: var(--bg); } .res-tab-btn { - border: 1px solid transparent; - border-bottom: none; + border: none; background: transparent; color: var(--muted); font-family: inherit; font-size: 12px; - font-weight: 600; - letter-spacing: 0.03em; - padding: 8px 16px; - border-radius: 8px 8px 0 0; + font-weight: 700; + letter-spacing: 0.02em; + padding: 7px 14px; + border-radius: 7px; cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } .res-tab-btn:hover { - background: var(--accent-dim); - color: var(--ink); + color: var(--ink-2); } .res-tab-btn.active { - background: var(--panel); - border-color: var(--line); - color: var(--accent); - margin-bottom: -1px; + background: var(--accent-dim); + color: var(--ink); } /* Scrollbarer Körper. */ @@ -2864,20 +3059,21 @@ body { /* ── Master-Detail (Schraffuren / Linien): Liste links, Detail rechts ──────── */ .res-md { - display: grid; - grid-template-columns: 240px 1fr; - gap: 14px; - align-items: start; + display: flex; + align-items: stretch; min-height: 320px; } +/* Seitenleiste — nur noch eine Trennlinie (border-right), keine eigene Box + mit Rahmen/Radius/Hintergrund (Mockup-Konvention: liest sich als EIN + Fenster statt Karte-in-Karte). */ .res-md-list { display: flex; flex-direction: column; gap: 2px; - padding: 6px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--panel-2); + width: 240px; + flex: 0 0 auto; + padding: 0 12px 0 0; + border-right: 1px solid var(--line); max-height: 62vh; overflow-y: auto; } @@ -2887,23 +3083,24 @@ body { gap: 9px; width: 100%; padding: 6px 8px; - border: 1px solid transparent; - border-radius: 8px; + border: none; + border-radius: 7px; background: transparent; - color: var(--ink); + color: var(--ink-2); font-family: inherit; font-size: 12px; text-align: left; cursor: pointer; - transition: background 0.12s, border-color 0.12s; + transition: background 0.12s, color 0.12s; } .res-md-row:hover { background: var(--accent-dim); + color: var(--ink); } .res-md-row.active { background: var(--accent-dim); - border-color: var(--accent-border); - color: var(--accent); + color: var(--ink); + font-weight: 600; } .res-md-row-thumb { flex: 0 0 auto; @@ -2936,18 +3133,19 @@ body { color: var(--muted); } -/* Detail-Panel (rechts). */ +/* Detail-Panel (rechts) — keine eigene Box mehr, sitzt direkt auf der + Panel-Fläche (Mockup-Konvention). */ .res-md-detail { - border: 1px solid var(--border); - border-radius: 10px; - background: var(--panel-2); + flex: 1 1 auto; + min-width: 0; min-height: 300px; + padding: 0 0 0 16px; } .res-md-detail-inner { display: flex; flex-direction: column; gap: 14px; - padding: 14px 16px 18px; + padding: 2px 0 4px; } .res-md-head { display: flex; @@ -2964,9 +3162,9 @@ body { align-items: center; justify-content: center; padding: 16px; - border: 1px solid var(--border); + border: none; border-radius: 10px; - background: var(--input); + background: var(--field-bg); color: var(--ink); } .res-md-preview .res-line-preview { @@ -3159,55 +3357,49 @@ body { } /* ── Tab-Leiste (Dock-Kopf) ──────────────────────────────────────────────── */ +/* Tab-Leiste: Icon-Pillen (Mockup-Konvention) statt Ordnerlaschen — aktive + Pille in accent-dim, keine Verschmelzung mit dem Körper, kein eigener + Tray-Hintergrund. */ .tabstrip { display: flex; - gap: 4px; + gap: 2px; flex: 0 0 auto; - padding: 8px 8px 0; + padding: 6px 8px 5px; border-bottom: 1px solid var(--line); - background: var(--bg); + background: var(--panel); overflow-x: auto; overflow-y: hidden; - scrollbar-width: thin; + scrollbar-width: none; } -/* Dünne, dezente Scrollleiste, wenn viele Tabs umlaufen (WebKit). */ .tabstrip::-webkit-scrollbar { - height: 5px; -} -.tabstrip::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 999px; + display: none; } .tab { flex: 0 0 auto; - width: 34px; - height: 30px; + width: 30px; + height: 26px; display: flex; align-items: center; justify-content: center; - border: 1px solid transparent; - border-bottom: none; + border: none; background: transparent; - color: var(--muted); + color: var(--ink-2); font-family: inherit; padding: 0; - border-radius: 8px 8px 0 0; + border-radius: 7px; cursor: pointer; - transition: background 0.14s, color 0.14s, border-color 0.14s; + transition: background 0.14s, color 0.14s; } .tab svg { flex: 0 0 auto; } .tab:hover { - background: var(--accent-dim); color: var(--ink); } .tab.active { - background: var(--panel); - border-color: var(--line); - color: var(--accent); - margin-bottom: -1px; + background: var(--accent-dim); + color: var(--ink); } /* ── Panel-Rahmen (Kopf + scrollbarer Körper) ────────────────────────────── */ @@ -3249,11 +3441,19 @@ body { font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - color: #f0f0f0; + color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Aktions-Slot rechts in der Panel-Kopfzeile (headerActions + Darstellungs- + modus) — bündelt panel-eigene Aktionen in DER EINEN Kopfzeile. */ +.panel-head-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} .panel-body { flex: 1; @@ -3315,13 +3515,11 @@ body { display: inline-flex; align-items: center; gap: 8px; - /* Trigger trägt dieselbe stets-dunkle Fläche wie sein Popover (.ctx-menu) — - beide sollen wie EIN durchgehendes Element wirken statt heller Knopf + - dunkles Menü darunter. .ctx-menu hängt per Portal ausserhalb dieses - DOM-Zweigs, darum hier dieselben Werte dupliziert statt geerbt. */ - border: 1px solid #4a4a4a; - background: #2c2c2c; - color: #e8e8e8; + /* Randlose, helle Feldfläche (Mockup-Konvention) — das geöffnete Popover + (.ctx-menu) bleibt eine eigene, davon unabhängige dunkle Schwebe-Fläche. */ + border: none; + background: var(--field-bg); + color: var(--ink); font-family: inherit; font-size: 11px; font-weight: 600; @@ -3329,22 +3527,17 @@ body { border-radius: 8px; cursor: pointer; outline: none; - transition: border-color 0.14s, background-color 0.14s, color 0.14s, - box-shadow 0.14s; + transition: background-color 0.14s, color 0.14s, box-shadow 0.14s; } .tb-dd-trigger:hover:not(:disabled) { - border-color: var(--accent-border); - background-color: rgba(255, 255, 255, 0.1); - color: var(--accent); + background-color: var(--accent-dim); } .tb-dd-trigger.active, .tb-dd-trigger:focus-visible { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-dim); + box-shadow: 0 0 0 2px var(--accent-border); } .tb-dd-trigger:disabled { - color: #8a8a8a; - opacity: 0.55; + opacity: 0.45; cursor: default; } .tb-dd-trigger-label { @@ -3356,10 +3549,10 @@ body { } .tb-dd-chevron { flex: 0 0 auto; - color: #8a8a8a; + color: var(--muted); } .tb-dd-trigger:hover:not(:disabled) .tb-dd-chevron { - color: var(--accent); + color: var(--ink); } /* Monospace-Variante (Massstab) — ziffernbündig wie das frühere .tb-scale. */ .tb-dd-mono .tb-dd-trigger-label { @@ -3420,15 +3613,15 @@ body { } .ctx-menu { - /* Eigene, stets dunkle Palette (unabhängig vom Light/Dark-Theme). */ - --ctx-surface: #2c2c2c; - --ctx-border: #4a4a4a; - --ctx-text: #e8e8e8; - --ctx-muted: #8a8a8a; - /* Hover NEUTRAL (kein Petrol-Grün mehr): dezente weiße Aufhellung, heller Text. */ - --ctx-hover: rgba(255, 255, 255, 0.1); - --ctx-hover-text: #f0f0f0; - --ctx-danger: #d86060; + /* Theme-abhängig: helle Fläche im Light-Modus, dunkle im Dark-Modus + (folgt der App-Palette statt fix dunkel zu sein). */ + --ctx-surface: var(--panel-2); + --ctx-border: var(--border); + --ctx-text: var(--ink); + --ctx-muted: var(--muted); + --ctx-hover: var(--accent-dim); + --ctx-hover-text: var(--ink); + --ctx-danger: var(--danger); position: fixed; z-index: 300; @@ -3438,7 +3631,7 @@ body { background: var(--ctx-surface); border: 1px solid var(--ctx-border); border-radius: 10px; - box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-3); color: var(--ctx-text); font-family: var(--font); font-size: 11.5px; @@ -3558,14 +3751,14 @@ body { } /* ── Inline-Editor (Ebene / Geschoss) ────────────────────────────────────── - Kleiner schwebender Editor am Anker, gleiche dunkle Palette wie das - Kontextmenü. Aus einem Menüpunkt geöffnet; schließt über Außenklick/Esc. + Kleiner schwebender Editor am Anker, folgt der App-Palette (hell im Light- + Modus). Aus einem Menüpunkt geöffnet; schließt über Außenklick/Esc. z-index 301 (knapp über dem Kontextmenü, unter Modals). */ .ctx-editor { - --ctx-surface: #2c2c2c; - --ctx-border: #4a4a4a; - --ctx-text: #e8e8e8; - --ctx-muted: #8a8a8a; + --ctx-surface: var(--panel-2); + --ctx-border: var(--border); + --ctx-text: var(--ink); + --ctx-muted: var(--muted); position: fixed; z-index: 301; @@ -3574,7 +3767,7 @@ body { background: var(--ctx-surface); border: 1px solid var(--ctx-border); border-radius: 13px; - box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-3); color: var(--ctx-text); font-family: var(--font); font-size: 13px; @@ -3644,15 +3837,19 @@ body { align-items: center; min-width: 0; } -/* Text-/Zahlenfelder im Editor: dunkel, volle Breite der Steuer-Spalte. */ +/* Text-/Zahlenfelder im Editor: randlos, --field-bg (Mockup-Konvention) — + das ist der Standard-Look (natives Ebeneneinstellungen-Fenster u. a.). + Der ältere Canvas-Popover (.ctx-editor, InlineEditor in App.tsx) bekommt + weiter unten seine eigene dunkle Schwebe-Optik zurück (gleiche Familie + wie .ctx-menu). */ .editor-control input[type="text"], .editor-control input[type="number"] { width: 100%; padding: 5px 8px; - background: #1f1f1f; - border: 1px solid var(--ctx-border); + background: var(--field-bg); + border: none; border-radius: 7px; - color: var(--ctx-text); + color: var(--ink); font-family: inherit; font-size: 13px; } @@ -3662,24 +3859,22 @@ body { } .editor-control input:focus { outline: none; - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14); + box-shadow: 0 0 0 2px var(--accent-border); } /* Auswahlfelder (z. B. Schraffur) wie die Text-/Zahlenfelder gestaltet. */ .editor-control select { width: 100%; padding: 5px 8px; - background: #1f1f1f; - border: 1px solid var(--ctx-border); + background: var(--field-bg); + border: none; border-radius: 7px; - color: var(--ctx-text); + color: var(--ink); font-family: inherit; font-size: 13px; } .editor-control select:focus { outline: none; - border-color: rgba(255, 255, 255, 0.4); - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.14); + box-shadow: 0 0 0 2px var(--accent-border); } /* Schraffur-Auswahl + kleine Vorschau nebeneinander. */ .editor-hatch { @@ -3701,7 +3896,7 @@ body { width: 40px; height: 26px; padding: 0; - border: 1px solid var(--ctx-border); + border: none; border-radius: 7px; background: transparent; cursor: pointer; @@ -3904,71 +4099,142 @@ body { box-shadow: 0 0 0 1px var(--accent-dim); } -/* Tools palette */ +/* ── Werkzeug-Palette ───────────────────────────────────────────────────────── + Einheitlicher Knopf-Look für Werkzeuge UND Befehle (`.tp-item`), zwei + Darstellungen: Liste (Icon + Name als Zeilen) oder Symbole (kompaktes + Icon-Raster). Umschalter oben (`.tp-viewtoggle`), Rubriken 2D · 3D · + Bearbeiten (`.tp-section`). */ .tools-panel { display: flex; flex-direction: column; - gap: 2px; - padding: 6px; + gap: 10px; + padding: 8px; overflow-y: auto; height: 100%; min-height: 0; } -.tools-panel .tool-row { + +/* Anzeige-Umschalter (segmentiert). */ +.tools-panel .tp-head { + position: sticky; + top: -8px; + z-index: 1; + margin: -8px -8px 0; + padding: 8px 8px 6px; + background: var(--panel, var(--bg)); + border-bottom: 1px solid var(--line); +} +.tools-panel .tp-viewtoggle { + display: flex; + gap: 4px; +} +.tools-panel .tp-viewbtn { + flex: 1 1 0; + padding: 5px 8px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.tools-panel .tp-viewbtn:hover { + background: var(--active-dim); + color: var(--active-light); +} +/* Aktiv = helle Pille (active-dim), ohne Akzentstreifen. */ +.tools-panel .tp-viewbtn.active { + background: var(--active-dim); + color: var(--active-light); +} + +/* Rubrik (2D / 3D / Bearbeiten). */ +.tools-panel .tp-section { + display: flex; + flex-direction: column; + gap: 4px; +} +.tools-panel .tp-section-title { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); + padding: 0 4px; +} + +/* Gemeinsamer Knopf. Basis = Listenzeile (Icon + Name). */ +.tools-panel .tp-item { display: flex; align-items: center; - gap: 8px; + gap: 9px; padding: 5px 8px; - border-radius: 6px; border: 1px solid transparent; + border-radius: 7px; background: transparent; color: var(--label); cursor: pointer; font-size: 12px; text-align: left; + transition: background 0.12s, color 0.12s; } -.tools-panel .tool-row:hover { +.tools-panel .tp-item:hover:not(.disabled) { background: var(--accent-dim); + color: var(--ink); } -.tools-panel .tool-row.active { - background: var(--accent); - color: #fff; +/* Aktiv = identisch zur Zeichnungsebenen-Auswahl (.nav-row.active): helle + Pille (active-dim) + active-light-Schrift, kein dunkler Akzent/Streifen. */ +.tools-panel .tp-item.active { + background: var(--active-dim); + color: var(--active-light); } -.tools-panel .tool-row.disabled { +.tools-panel .tp-item.active .tp-label { + font-weight: 600; +} +.tools-panel .tp-item.disabled { opacity: 0.4; cursor: not-allowed; } -.tools-panel .tool-row svg { +.tools-panel .tp-item svg { flex: 0 0 auto; } -.tools-panel .tools-sep { - height: 1px; - background: var(--line); - margin: 6px 2px; +.tools-panel .tp-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.tools-panel .tools-field { + +/* Auswahl-Werkzeug: immer volle Zeile. */ +.tools-panel .tp-select .tp-item { + width: 100%; +} + +/* Listen-Darstellung: Knöpfe als volle Zeilen untereinander. */ +.tools-panel .tp-body { display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 4px 8px; - font-size: 12px; + flex-direction: column; + gap: 1px; } -.tools-panel .tools-snap-row { - display: flex; - align-items: center; - gap: 8px; - padding: 3px 8px; - font-size: 12px; - cursor: pointer; - user-select: none; + +/* Symbol-Darstellung: kompaktes Icon-Raster, Namen ausgeblendet. */ +.tools-panel .tp-body.icons { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(30px, 1fr)); + gap: 3px; } -.tools-panel .tools-snap-row input { - accent-color: var(--accent); +.tools-panel .tp-body.icons .tp-item { + justify-content: center; + aspect-ratio: 1 / 1; + padding: 0; } -.tools-panel .tools-field input, -.tools-panel .tools-field select { - width: 90px; +.tools-panel .tp-body.icons .tp-item svg { + width: 15px; + height: 15px; +} +.tools-panel .tp-body.icons .tp-label { + display: none; } /* ── Gelände-/Kontext-Palette (SitePanel) ─────────────────────────────── */ @@ -4025,6 +4291,32 @@ body { padding: 8px 4px; line-height: 1.4; } +.site-panel .site-group { + margin-bottom: 6px; +} +.site-panel .site-group-head { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + font-size: 11px; + font-weight: 600; + color: var(--ink-2); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.site-panel .site-group-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.site-panel .site-group-count { + flex: 0 0 auto; + color: var(--muted); + font-weight: 400; +} .site-panel .site-list { list-style: none; margin: 0; @@ -4573,15 +4865,31 @@ body { box-shadow: var(--shadow-3); overflow: hidden; } +/* Eigenes natives Fenster (native/settingsWindow.ts) statt Overlay-Dialog: + füllt die ganze Fensterfläche, kein Rahmen/Schatten/rundes Eck nötig. */ +.imp-dialog.imp-dialog-embedded { + width: 100%; + max-height: 100vh; + height: 100vh; + border: none; + border-radius: 0; + box-shadow: none; + overflow-y: auto; +} .imp-head { display: flex; align-items: center; justify-content: space-between; padding: 0 16px; - height: 48px; + height: 40px; flex: 0 0 auto; border-bottom: 1px solid var(--line); - background: var(--bg); + background: var(--panel); +} +.imp-dialog-embedded .imp-head { + /* Platz für die macOS-Ampeln (titleBarStyle "overlay", windowChrome.ts) — + dieselbe Einrückung wie alle übrigen Fenster-Köpfe (76px). */ + padding-left: 88px; } .imp-head-title { font-weight: 650; @@ -4646,6 +4954,14 @@ body { font-size: 11px; color: var(--ink-2); } +/* Roh-Import-Hinweis (kein Bezug gesetzt) — mehrzeilig, kein Button daneben. */ +.cim-geo-hint { + display: block; + line-height: 1.5; + font-family: inherit; + font-size: 12px; + color: var(--muted); +} .imp-linklike { background: none; border: none; @@ -4742,9 +5058,9 @@ body { font-family: inherit; font-size: 13px; padding: 6px 9px; - border: 1px solid var(--border); + border: none; border-radius: 7px; - background: var(--input); + background: var(--field-bg); color: var(--ink); margin-left: 22px; width: calc(100% - 22px); @@ -4773,7 +5089,7 @@ body { padding: 12px 16px; flex: 0 0 auto; border-top: 1px solid var(--line); - background: var(--bg); + background: var(--panel); } .imp-btn { font-family: inherit; @@ -5059,15 +5375,15 @@ body { font-family: inherit; font-size: 13px; padding: 6px 9px; - border: 1px solid var(--border); + border: none; border-radius: 7px; - background: var(--input); + background: var(--field-bg); color: var(--ink); width: 120px; } .settings-num-input:focus { outline: none; - border-color: var(--accent-border); + box-shadow: 0 0 0 2px var(--accent-border); } /* ── App-weites Drop-Feedback ────────────────────────────────────────────── @@ -6250,12 +6566,21 @@ body { /* „Ansichten"-Tab: die aus der TopBar gemergten Cluster (View-Grid, Kombis, Detail, Zoom, Darstellung) in EINER Zeile — nutzt die bestehenden `.tb-*`- Stile unverändert weiter. */ +/* ViewRibbonTab-Wurzel — sitzt jetzt inline in der Oberleiste (kein eigenes + Band). NUR dieser mittlere Block scrollt horizontal, wenn das Fenster zu + schmal wird — die rechte Gruppe (Datei-Aktionen + Projekt) bleibt sichtbar. */ .ribbon-views { - display: flex; + display: inline-flex; align-items: center; - gap: 10px; - height: 100%; - overflow: hidden; + gap: 8px; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} +.ribbon-views::-webkit-scrollbar { + display: none; } /* ── Layout-Blatt im Haupt-Viewport (DOSSIER A3, Phase 2b) ───────────────────── diff --git a/src/theme/themeMode.ts b/src/theme/themeMode.ts new file mode 100644 index 0000000..cacda9e --- /dev/null +++ b/src/theme/themeMode.ts @@ -0,0 +1,27 @@ +// Hell/Dunkel-Umschalter (Einstellungen → Darstellung). Hell ist Standard +// (siehe styles.css); Dunkel wird per `data-theme="dark"` auf 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); +} diff --git a/src/ui/AppMenuBar.tsx b/src/ui/AppMenuBar.tsx new file mode 100644 index 0000000..43fbac2 --- /dev/null +++ b/src/ui/AppMenuBar.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/src/ui/ContextImportDialog.tsx b/src/ui/ContextImportDialog.tsx index c127fb5..aa1c6ef 100644 --- a/src/ui/ContextImportDialog.tsx +++ b/src/ui/ContextImportDialog.tsx @@ -17,13 +17,15 @@ import { fetchBuildings3d } from "../io/swissBuildings3d"; import type { BuildingsVersion } from "../io/swissBuildings3d"; import { fetchTerrainXyz } from "../io/swissAlti3d"; import type { TerrainResolution } from "../io/swissAlti3d"; +import { fetchSwissImage } from "../io/swissImage"; import { fetchOsm } from "../io/osm"; import type { OsmSelection } from "../io/osm"; import { featuresToContextObjects } from "../io/geoContext"; import type { GeoFeature } from "../io/geoContext"; import { terrainMeshFromGrid } from "../model/terrain"; -import type { GeoOrigin } from "../io/lv95"; -import type { ContextObject } from "../model/types"; +import { makeOrigin } from "../io/lv95"; +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 * 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`). */ export interface GeoAnchor { lv95: { e: number; n: number }; + /** Reale Höhe (m ü. M.) des Bezugspunkts — informativ, s. Project.geoAnchor. */ + height?: number; model: { x: number; y: number }; label?: string; } @@ -45,11 +49,21 @@ export interface ContextImportDialogProps { geoAnchor?: GeoAnchor; /** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */ 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 = { swissBuildings: SwissBuildingsMode; swissTerrain: boolean; + swissImage: boolean; + swissImageDrape: boolean; osmBuildings: boolean; osmRoads: boolean; osmWater: boolean; @@ -64,6 +78,8 @@ const DEFAULT_SOURCES: Sources = { // Städten sehr grosse Kacheln liefern (s. swissBuildings3d.ts-Kommentar). swissBuildings: "v2", swissTerrain: true, + swissImage: false, + swissImageDrape: false, osmBuildings: false, osmRoads: true, osmWater: true, @@ -78,6 +94,7 @@ export function ContextImportDialog({ onClose, geoAnchor, onSetGeoAnchor, + windowed = false, }: ContextImportDialogProps) { useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -91,6 +108,14 @@ export function ContextImportDialog({ const [candidates, setCandidates] = useState([]); const [selected, setSelected] = useState(null); 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(DEFAULT_SOURCES); // 2 m = Default (schneller/kleinere Kacheln); 0.5 m für Detailarbeit. const [terrainResolution, setTerrainResolution] = useState("2"); @@ -129,6 +154,8 @@ export function ContextImportDialog({ const anySource = sources.swissBuildings !== "off" || sources.swissTerrain || + sources.swissImage || + sources.swissImageDrape || sources.osmBuildings || sources.osmRoads || sources.osmWater || @@ -144,13 +171,29 @@ export function ContextImportDialog({ setStatus(t("ctxImport.working")); try { const center = selected.lv95; - // Bestehenden Georeferenzierungs-Bezug wiederverwenden, statt bei jedem - // Import einen neuen (inkonsistenten) zu berechnen — der Bezugspunkt - // bleibt fix im Modell, nur die GESUCHTE Stelle (center/radius) steuert, - // WOVON Daten geladen werden (s. Project.geoAnchor-Doc). - let origin: GeoOrigin | undefined = geoAnchor + // Optionaler Bezugspunkt (LV95): dieser Punkt wird zu Modell-(0,0). Leer + // -> das Suchzentrum wird der Nullpunkt (bisheriges Verhalten). + const refE = Number(refPoint.e); + const refN = Number(refPoint.n); + 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 } - : undefined; + : makeOrigin(refLv95); const all: GeoFeature[] = []; // Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject // (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. - let terrain: ContextObject | null = null; - if (sources.swissTerrain) { + // Die Drapierung braucht das TIN -> Terrain auch dann holen, wenn nur + // „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); origin = r.origin; 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( buildingsSkipped > 0 ? t("ctxImport.buildingsSkipped", { count: buildingsSkipped }) @@ -220,16 +288,26 @@ export function ContextImportDialog({ return; } - // Erster Import in diesem Projekt (noch kein Bezug) -> DIESEN Lauf als - // Georeferenzierungs-Bezug speichern (Modell-(0,0) = der gesuchte Ort), - // damit künftige Importe lagerichtig zueinander bleiben. - if (!geoAnchor && origin) { - onSetGeoAnchor({ lv95: { e: origin.e, n: origin.n }, model: { x: 0, y: 0 }, label: selected.label }); + // Erster Import in diesem Projekt (noch kein Bezug) -> Bezug speichern: + // Modell-(0,0) entspricht dem gesuchten Ort (LV95 `center`). Damit landet + // der Import nahe dem Ursprung (gut darstellbar) UND der Modell-Export + // kann später auf die echten LV95-Koordinaten zurückrechnen. + 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); objs.push(...extraObjs); if (terrain) objs.push(terrain); + if (image) objs.push(image); onImport(objs); setStatus( buildingsSkipped > 0 @@ -252,32 +330,47 @@ export function ContextImportDialog({ } }; - return ( -
+ const dialog = (
e.stopPropagation()} + onClick={windowed ? undefined : (e) => e.stopPropagation()} > -
+
{t("ctxImport.title")} - + {!windowed && ( + + )}
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe - dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). */} - {geoAnchor && ( + dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). + Ohne Bezug wird ROH importiert (echte LV95-Koordinaten, weit vom + Nullpunkt) — ein Hinweis erklärt den Folge-Schritt „Neuer + Bezugspunkt". */} + {geoAnchor ? (
- {t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })} + + {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) : "—", + })} +
+ ) : ( +
{t("ctxImport.rawHint")}
)} {/* Ortssuche */}
@@ -355,6 +450,45 @@ export function ContextImportDialog({
+ {/* 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 && ( +
+
{t("ctxImport.refPoint")}
+
+ setRefPoint((p) => ({ ...p, e: e.target.value }))} + aria-label={t("ctxImport.refPoint.e")} + /> + setRefPoint((p) => ({ ...p, n: e.target.value }))} + aria-label={t("ctxImport.refPoint.n")} + /> + setRefPoint((p) => ({ ...p, h: e.target.value }))} + aria-label={t("ctxImport.refPoint.h")} + /> +
+
{t("ctxImport.refPoint.hint")}
+
+ )} + {/* Quellen */}
{t("ctxImport.sources")}
@@ -397,6 +531,22 @@ export function ContextImportDialog({ + +
+ ); + + if (windowed) return dialog; + return ( +
+ {dialog}
); } diff --git a/src/ui/EditorField.tsx b/src/ui/EditorField.tsx new file mode 100644 index 0000000..d416b8c --- /dev/null +++ b/src/ui/EditorField.tsx @@ -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 ( + + ); +} diff --git a/src/ui/LayoutMenu.tsx b/src/ui/LayoutMenu.tsx new file mode 100644 index 0000000..5710769 --- /dev/null +++ b/src/ui/LayoutMenu.tsx @@ -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([]); + // 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 , 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 ( + + + {saving && ( + setSaving(false)} + /> + )} + + ); +} diff --git a/src/ui/ResourceManager.tsx b/src/ui/ResourceManager.tsx index 899794c..03e597e 100644 --- a/src/ui/ResourceManager.tsx +++ b/src/ui/ResourceManager.tsx @@ -2393,6 +2393,14 @@ function WindowStylesTab({ /> )} + + patch({ innerLayerRecess: e.target.checked })} + title={t("resources.field.innerLayerRecess.hint")} + /> + void; handlers: ResourceManagerHandlers; /** Beim Öffnen (bzw. bei erneutem Sprung) direkt aktiver Tab, z. B. „doorStyles". */ 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(initialTab ?? "components"); // Erneuter Sprung aus einem Element heraus (z. B. „Typ bearbeiten" an einer @@ -3185,30 +3201,44 @@ export function ResourceManager({ return (
- {/* Greifer unten rechts (Größe ändern). */} -
+ {/* Greifer unten rechts (Größe ändern) — im eigenen OS-Fenster übernimmt + das Betriebssystem das Grössenändern, daher windowed ohne Greifer. */} + {!windowed && ( +
+ )} ); } diff --git a/src/ui/SettingsDialog.tsx b/src/ui/SettingsDialog.tsx index 6345eb2..028d459 100644 --- a/src/ui/SettingsDialog.tsx +++ b/src/ui/SettingsDialog.tsx @@ -10,20 +10,26 @@ // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). import { useEffect, useState } from "react"; -import type { ReactNode } from "react"; import { t } from "../i18n"; import { ACCENT_SWATCHES } from "../theme/accents"; -import type { Project } from "../model/types"; import { ColorHexField } from "./ColorHexField"; +import { LayoutMenu } from "./LayoutMenu"; import type { RendererMode } from "./StatusBar"; +import type { LayoutState } from "../panels/types"; +import type { ThemeMode } from "../theme/themeMode"; export interface SettingsDialogProps { + /** Hell/Dunkel — Standard ist Hell (siehe theme/themeMode.ts). */ + themeMode: ThemeMode; + onThemeModeChange: (mode: ThemeMode) => void; marqueeColor: string; onMarqueeColorChange: (hex: string) => void; snapColor: string; onSnapColorChange: (hex: string) => void; - project: Project; - setProject: (next: Project | ((p: Project) => Project)) => void; + /** Referenzhöhe des Erdgeschosses (m ü. M.) — statt des ganzen Projekts nur + * 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). */ rendererMode: RendererMode; /** Ist `navigator.gpu` vorhanden? Steuert, ob „Nordstern" wählbar ist. */ @@ -33,23 +39,37 @@ export interface SettingsDialogProps { * Live-Remount der Renderer-Hooks). */ onRendererModeChange: (mode: RendererMode) => void; - /** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */ - layoutMenu: ReactNode; + /** Arbeitsumgebung (Fenster-/Dock-Layouts) — LayoutMenu wird HIER gerendert + * (nicht mehr als vorgefertigtes ReactNode hereingereicht: JSX ist über eine + * Fenstergrenze nicht serialisierbar, s. `windowed`). */ + layout: LayoutState; + onApplyLayout: (layout: LayoutState) => 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({ + themeMode, + onThemeModeChange, marqueeColor, onMarqueeColorChange, snapColor, onSnapColorChange, - project, - setProject, + referenceElevationMasl, + onReferenceElevationChange, rendererMode, webGpuAvailable, onRendererModeChange, - layoutMenu, + layout, + onApplyLayout, onClose, + windowed = false, }: SettingsDialogProps) { useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -64,47 +84,49 @@ export function SettingsDialog({ // gerundeten Projektwert zu schreiben. Synchronisiert sich, wenn sich das // Projekt von aussen ändert (z. B. Undo/Import). const [elevationText, setElevationText] = useState( - project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "", + referenceElevationMasl != null ? String(referenceElevationMasl) : "", ); useEffect(() => { - setElevationText( - project.referenceElevationMasl != null ? String(project.referenceElevationMasl) : "", - ); - }, [project.referenceElevationMasl]); + setElevationText(referenceElevationMasl != null ? String(referenceElevationMasl) : ""); + }, [referenceElevationMasl]); const commitElevation = (raw: string) => { setElevationText(raw); const trimmed = raw.trim(); if (trimmed === "") { - setProject((p) => ({ ...p, referenceElevationMasl: undefined })); + onReferenceElevationChange(undefined); return; } const n = Number(trimmed); if (Number.isFinite(n)) { - setProject((p) => ({ ...p, referenceElevationMasl: n })); + onReferenceElevationChange(n); } }; - return ( -
+ const dialog = (
e.stopPropagation()} + onClick={windowed ? undefined : (e) => e.stopPropagation()} > -
+
{t("settings.title")} - + {!windowed && ( + + )}
{t("settings.section.workspace")}
- {layoutMenu} +
@@ -136,6 +158,25 @@ export function SettingsDialog({
{t("settings.section.display")}
+ {t("settings.theme")} + + + +
-
- -
+ {!windowed && ( +
+ +
+ )}
+ ); + + if (windowed) return dialog; + return ( +
+ {dialog}
); } diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 013b345..a6957af 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -16,10 +16,6 @@ import { Dropdown } from "./Dropdown"; import type { DropdownItem, DropdownOption } from "./Dropdown"; import { PromptDialog } from "./PromptDialog"; 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 { applyMark, applyPreset, @@ -57,6 +53,7 @@ export type DetailLevel = "grob" | "mittel" | "fein"; export type RenderMode = | "shaded" | "white" + | "contour" | "textured" | "wireframe" | "hidden" @@ -105,14 +102,11 @@ const ZOOM_CLUSTER_WIDTH = 140; export interface TopBarProps { project: Project; - // Ribbon-Tab-Reiter (2D/3D/BIM/Ansichten) sitzen in DIESER Zeile — der Ribbon- - // Inhalt darunter (RibbonBar). Der aktive Tab liegt in App (geteilter State). - ribbonTab: RibbonTabId; - onRibbonTab: (id: RibbonTabId) => void; - - // 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. + // Kein Ribbon-Tab-Reiter mehr (Werkzeuge leben im ToolsPanel, siehe + // ribbonItems.ts/layout.ts v12) — die TopBar ist nur noch die schmale + // globale Leiste: Marke/Menü · Datei/Export/Einstellungen. Ansichts-/Zoom-/ + // Darstellungs-Steuerung folgt direkt darunter (siehe ViewRibbonTab, von + // App unconditional gerendert statt über Tab-Umschaltung). /** Ob Zoom/Plan aktiv ist — steuert nur noch, ob Export im Datei-Menü geht. */ zoomEnabled: boolean; @@ -155,8 +149,16 @@ export interface TopBarProps { /** Projekt aus einer Datei laden. */ onOpenProject: () => void; - // Die Text-/Font-Formatierung (TextGroup) liegt jetzt im „Text"-Ribbon-Tab - // (App reicht sie via RibbonBar.tabContent), nicht mehr in dieser Leiste. + /** Menüleiste (Bearbeiten/Ansicht/Fenster, {@link AppMenuBar}) — von App + * 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
{aboutOpen && setAboutOpen(false)} />} - + {menuBar} - + {/* Ansichts-/Zoom-/Darstellungs-Steuerung direkt in DERSELBEN Zeile + (Mockup: EINE schmale Leiste, kein eigenes Band mehr). */} + {viewControls && } + {viewControls} - {/* Ribbon-Tab-Reiter (2D/3D/BIM/Text/Ansichten) — sitzen in dieser Zeile, - der Ribbon-Inhalt folgt darunter (RibbonBar). So gibt es nur ZWEI - Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */} - - - {/* 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. */} + {/* Rechte Gruppe: Datei-/Export-Aktionen (Quick-Access) + Projektname — + per margin-left:auto an den rechten Rand geschoben (Mockup: ⤓ ⤒ + Projektname ganz rechts). Die Fensterknöpfe sitzen ganz links + (macOS-Titelleisten-Konvention). */}
{project.name}
diff --git a/src/ui/ribbon/CommandIcon.tsx b/src/ui/ribbon/CommandIcon.tsx index a81d652..f2f7cd7 100644 --- a/src/ui/ribbon/CommandIcon.tsx +++ b/src/ui/ribbon/CommandIcon.tsx @@ -116,6 +116,34 @@ export function CommandIcon({ name }: { name: string }) { ); + case "extrude": + // Extrusion: Grundfläche + nach oben herausgezogenes Volumen (Prisma). + return ( + + + + + + ); + case "sectionline": + // Schnittlinie: Achse mit Blickrichtungs-Winkeln (SIA-Schnittsymbol). + return ( + + + + + + ); + case "viewline": + // Ansichtslinie: Standpunkt (Kreis) mit Blickkegel. + return ( + + + + + + + ); default: return null; } diff --git a/src/ui/ribbon/RibbonBar.tsx b/src/ui/ribbon/RibbonBar.tsx index 7d62f00..7747772 100644 --- a/src/ui/ribbon/RibbonBar.tsx +++ b/src/ui/ribbon/RibbonBar.tsx @@ -1,17 +1,14 @@ -// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert den INHALT des -// aktiven Ribbon-Tabs. Die Tab-REITER selbst sitzen in der TopBar-Zeile (eine -// Leiste: globales Chrome + Tabs), damit es nur ZWEI Bänder gibt: TopBar-Zeile -// (mit Tabs) oben, Ribbon-Inhalt darunter. Der aktive Tab wird als `tab`-Prop -// hereingereicht (State liegt in App, geteilt mit der TopBar). +// Ribbon-Inhaltsleiste (docs/design/ribbon-ui-plan.md) — rendert die Gruppen +// EINES festen Tabs (kein Tab-Umschalter mehr, v12: Werkzeuge leben im +// ToolsPanel). App rendert mehrere ``-Instanzen permanent +// nebeneinander (2d/bim/custom) statt einer einzigen, umschaltbaren — jede +// zeigt nur die Befehls-Gruppen ohne Panel-Entsprechung. // // Werkzeug-Elemente laufen über `onSelectTool` (gekoppelte Befehle inklusive, wie -// in der Sidebar); Befehls-Elemente über `onRunCommand` (engine.start). Der -// „Ansichten"-Tab trägt reichen Custom-Inhalt (`viewsContent`, aus der TopBar -// gemergt) statt datengetriebener Item-Gruppen. +// im ToolsPanel); Befehls-Elemente über `onRunCommand` (engine.start). // // Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). -import type { ReactNode } from "react"; import { t } from "../../i18n"; import { ToolIcon } from "../../panels/ToolsPanel"; import { getTool } from "../../tools/tools"; @@ -43,13 +40,6 @@ export interface RibbonBarProps { onSelectTool: (id: ToolId) => void; /** Engine-Befehl starten (Ändern-Gruppe u. a.). */ onRunCommand: (name: string) => void; - /** - * Reicher Custom-Inhalt je Tab (statt datengetriebener Item-Gruppen) — z. B. - * „Ansichten" (`ViewRibbonTab`) und „Text" (`TextGroup`). Von App geliefert, - * damit deren Setter/State dort bleiben. Ein Tab ohne Custom-Inhalt und ohne - * Gruppen zeigt den Leer-Hinweis. - */ - tabContent?: Partial>; /** Persistierte Item-Schlüssel der modularen Custom-Bar (Tab „Eigene"). */ customItems: string[]; /** Ein Item in der Custom-Bar an-/abwählen (Picker im „Eigene"-Tab). */ @@ -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 ( -
- {RIBBON_TABS.map((tt) => ( - - ))} -
- ); -} - /** * 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 @@ -184,7 +145,6 @@ function CustomBar({ export function RibbonBar(props: RibbonBarProps) { const current = RIBBON_TABS.find((tt) => tt.id === props.tab) ?? RIBBON_TABS[0]; - const custom = props.tabContent?.[current.id]; const ctx: ButtonCtx = { activeTool: props.activeTool, activeCommand: props.activeCommand, @@ -195,9 +155,8 @@ export function RibbonBar(props: RibbonBarProps) { return (
- {/* Inhalt des aktiven Tabs. Tabs mit reichem Custom-Inhalt („Ansichten", - „Text") rendern diesen; der „Eigene"-Tab die modulare Custom-Bar; sonst - die datengetriebenen Item-Gruppen. */} + {/* Der „Eigene"-Tab rendert die modulare Custom-Bar; sonst die + datengetriebenen Item-Gruppen. */}
{current.id === "custom" ? ( - ) : custom ? ( - custom ) : current.groups.length === 0 ? (
{t("ribbon.empty")}
) : ( diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts index 5fe61d4..39b2a38 100644 --- a/src/ui/ribbon/ribbonItems.ts +++ b/src/ui/ribbon/ribbonItems.ts @@ -28,7 +28,7 @@ export interface RibbonGroup { } /** 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. */ export interface RibbonTab { @@ -37,41 +37,24 @@ export interface RibbonTab { groups: RibbonGroup[]; } -const t = (id: ToolId): RibbonItem => ({ kind: "tool", id }); const c = (name: string): RibbonItem => ({ kind: "command", name }); /** - * Die Ribbon-Tabs (Reihenfolge Ansichten·2D·3D·BIM). „Ansichten" ist der - * Standard-Tab (zuerst, initial aktiv) und trägt reichen Custom-Inhalt: die aus - * der TopBar gemergte Ansichts-/Zoom-/Darstellungs-Steuerung UND die Text-/Font- - * Formatierung auf DERSELBEN Leiste (`ViewRibbonTab` — `groups` bleibt leer). 2D - * (Zeichnen + Ändern) und BIM (Bauteile) sind datengetrieben aus Werkzeugen/ - * Befehlen; 3D ist vorerst leer (spätere Phase). + * Die Ribbon-Tabs (2D·BIM·Eigene) — keine Tab-Reiter mehr in der UI (v12, + * siehe layout.ts): App rendert alle drei permanent nebeneinander (kein + * Umschalter). Reine Werkzeug-Elemente (`t(...)`, persistentes Werkzeug wie + * Wand/Linie/Fenster) leben AUSSCHLIESSLICH im ToolsPanel (kein Doppel- + * Auftritt). Reine Befehls-Elemente (`c(...)`, Einmal-Aktionen wie + * 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[] = [ - { - // Custom-Inhalt (ViewRibbonTab): Ansichts-/Zoom-/Darstellungssteuerung + Text. - id: "views", - labelKey: "ribbon.tab.views", - groups: [], - }, { id: "2d", labelKey: "ribbon.tab.2d", groups: [ - { - titleKey: "ribbon.group.draw", - items: [ - t("select"), - t("line"), - t("polyline"), - t("rect"), - t("circle"), - t("arc"), - t("text"), - t("textbox"), - ], - }, { titleKey: "ribbon.group.modify", items: [ @@ -87,28 +70,16 @@ export const RIBBON_TABS: RibbonTab[] = [ }, ], }, - { - id: "3d", - labelKey: "ribbon.tab.3d", - groups: [], - }, { id: "bim", labelKey: "ribbon.tab.bim", groups: [ { + // "extrude" war vormals im (jetzt entfernten) 3D-Ribbon-Tab + // (Ribbon3dTab) — einziger Zugang zum Befehl, daher hier statt + // gestrichen. titleKey: "ribbon.group.components", - items: [ - t("wall"), - t("window"), - t("door"), - t("stair"), - c("column"), - t("ceiling"), - c("ceilingopening"), - c("roof"), - t("room"), - ], + items: [c("column"), c("ceilingopening"), c("roof"), c("extrude")], }, { titleKey: "ribbon.group.sections", diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 4e0144d..807cea3 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -135,9 +135,22 @@ export function Viewport3D( * Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr * eigenes Material hervor (highlightMat in ThreeViewport3D). */ 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) { return ( ); } diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index e6f9432..10333bc 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -274,6 +274,14 @@ function fitTargetDist(project: Project): { 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 // Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box). if (!Number.isFinite(minX)) { @@ -334,6 +342,8 @@ export function Wasm3DViewport({ onEditRoofPitch, onEditWallTop, onEditEnd, + section3d = null, + onClearSection3d, }: { project: Project; /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ @@ -393,6 +403,12 @@ export function Wasm3DViewport({ onEditWallTop?: (wallId: string, topZ: number) => void; /** Ende eines Griff-Drags. */ 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(null); // Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte) @@ -438,11 +454,9 @@ export function Wasm3DViewport({ ready, failed, } = useWasm3dRenderer(canvasRef, true); - // Live-Schnitt-Zustand (lokaler Overlay-Umschalter, MVP): vertikale - // Schnittebene durch die Modellmitte, Blick +Y (Grundriss-Y = world Z, s. - // section.rs `looking_plus_y`: normal = [0,0,1]). Kein Store-/App-Prop — - // reine Viewport-Ansichts-Option analog zum Grid-Overlay. - const [sectionActive, setSectionActive] = useState(false); + // Live-Schnitt: die aktive Ebene kommt jetzt aus `section3d` (App-State, an + // eine Schnittlinie gebunden — `sectionPlaneFromLevel`) statt aus einem + // lokalen Hardcode-Toggle. `null` = kein Schnitt. // Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen // direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der // freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar). @@ -515,36 +529,33 @@ export function Wasm3DViewport({ }, [ready, renderMode, setRenderStyle]); // Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/ - // Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m / - // 1 m) — ausreichend fuer die ueblichen Grundriss-Groessen dieses Spikes. + // Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (300 m / + // 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(() => { if (!ready) return; - setGroundGrid(gridVisible, groundElevation, 40, 1); + setGroundGrid(gridVisible, groundElevation, 300, 1); redrawRef.current(); }, [ready, gridVisible, groundElevation, setGroundGrid]); - // Live-Schnitt: bei Bereitschaft, Umschalten (`sectionActive`) oder - // Modelländerung neu setzen. MVP-Ebene = vertikal durch die Modellmitte - // (`fitTargetDist`-Ziel), Blick nach +Y -> Normale [0,0,1] (Grundriss-Y = - // world Z, s. section.rs `looking_plus_y`). Bei aktivem Schnitt baut Rust die - // Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken. + // Live-Schnitt: die gebundene Schnittlinie liefert `section3d` = world + // point+normal (vertikale Ebene entlang der Grundriss-Linie, Blickrichtung + // aus `directionSign` — s. sectionPlaneFromLevel). Ohne Bindung (`null`) kein + // Schnitt. Bei aktivem Schnitt baut Rust die Schnittflaechen-Schraffur (Caps) + // aus den gecachten Waenden/Decken. Neu setzen bei Bereitschaft, Wechsel der + // Ebene oder Modelländerung (Caps folgen dem aktuellen Modell). useEffect(() => { if (!ready) return; - // Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar) - // — `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) { + if (!section3d) { setSectionPlane(false, [0, 0, 0], [0, 0, 1]); redrawRef.current(); return; } - const { target } = fitTargetDist(project); - setSectionPlane(true, target, [0, 0, 1]); + setSectionPlane(true, section3d.point, section3d.normal); redrawRef.current(); - }, [ready, sectionActive, project, setSectionPlane]); + }, [ready, section3d, project, setSectionPlane]); // Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien // (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 - Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem - Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */} - + {/* Live-Schnitt-Knopf: nur sichtbar, wenn eine Schnittlinie den 3D-Schnitt + treibt (`section3d` gebunden, Bindung im Objekt-Info gesetzt). Klick + hebt die Bindung auf. Sitzt ueber dem Grid-Button (falls vorhanden). */} + {section3d && ( + + )} {onToggleGrid && ( // Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport` // ist `position: relative` — s. styles.css), stört die Maus-Navigation diff --git a/src/viewport/useWasm3dRenderer.ts b/src/viewport/useWasm3dRenderer.ts index 08e8d30..c7637fe 100644 --- a/src/viewport/useWasm3dRenderer.ts +++ b/src/viewport/useWasm3dRenderer.ts @@ -17,11 +17,12 @@ */ 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 type { DetailLevel } from "../ui/TopBar"; import { loadEngine3d } from "../engine/engine3d"; import { decodeMaterialTextures } from "./materialTextures"; +import { decodeImageToRgba } from "../io/swissImage"; /** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */ export interface Camera3d { @@ -82,6 +83,22 @@ interface WebModelRendererLike { * die Karten belegen Ebenen 1..N (siehe `WallInput.materialIndex`). Optional — * ältere WASM-Builds ohne diesen Export lassen den Aufrufer still zurückfallen. */ 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( eyeX: number, eyeY: number, @@ -166,6 +183,9 @@ export function useWasm3dRenderer( // erneutes (teures) Dekodieren/Hochladen, wenn sich die Materialien nicht // geändert haben (updateModel läuft bei JEDER Projektänderung). const materialSigRef = useRef(null); + // Signatur des zuletzt hochgeladenen Drape-Luftbilds (src + Bbox) — verhindert + // ein erneutes (teures) Dekodieren/Hochladen bei unveränderter Lage. + const aerialSigRef = useRef(null); /** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). `detail` * 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); } })(); + + // 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); + } + })(); }, []); /**