Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+86
View File
@@ -1,3 +1,89 @@
# HANDOVER — Stand 2026-07-08 spät (Schnitt-Parität + Projektdatei, ALLES uncommittet)
Fortsetzungs-Session (nach dem älteren 2026-07-08-Handover weiter unten). **Alles uncommittet.** Schwerpunkt: 3D-Live-Schnitt = 2D-Schnitt + eigene Projektdatei.
## Verifikations-Baseline (zuletzt grün)
`npx tsc --noEmit` sauber · `npx vitest run` **588** · `cargo test -p render3d --features render` **76** · `cargo check --features native3d` grün · WASM `npm run build:engine3d` neu gebaut (nötig nach Rust/WGSL-Änderungen). **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren.**
## Was diese Session gebaut wurde
- **3D-Live-Schnitt = 2D-Schnitt (Kernfunktion):** (a) 45°-Schraffur-Verdrehung gefixt (Shader auf 2D-`parallelLines`-Konvention kalibriert, angle 0 = horizontal); (b) geschichteter Bodenaufbau (Decke per-Schicht statt Einkörper, `emitSlabs`); (c) **Prioritäts-Verschneidung** von 2D nach Rust portiert (`section_boolean.rs` = Port von `subtractDominantBands`/`subtractRect`; Bänder tragen `join_priority` via `CutBandMeta`); (d) `collectCeilingCutters` emittiert per-Schicht-Cutter → Backstein (50) schneidet Estrich/Dämmung (30/20) korrekt weg, nur Beton (100) kappt ihn; (e) **einstellbare Schichttrennlinien** aus `jointLineStyleId`; (f) **Schraffur-Strichstärke pro Hatch** aus deren `lineStyleId` (`hatch-hair` 0.02 / `thin` 0.13); (g) `relativeToWall`-Orientierung: Boden-Dämmung vertikal, Wand horizontal (via `resolveHatch(axisAngle)` wie 2D). Betrifft `src-tauri/render3d/src/{shaders,gpu,section,section_fill,section_boolean,types}.rs` + `src/plan/toWalls3d.ts`. `native.rs` `WallInput`-Literal um die additiven Felder ergänzt.
- **Projekt als Einzeldatei `.obp`** („openbureau project"): `src/io/projectFile.ts` (`PROJECT_EXT`, nativer Öffnen/Speichern-Dialog, alte `.json` weiter ladbar) + **OS-Lock gegen Doppelöffnung** (`src-tauri/src/lock.rs`, `fs4` exklusiver flock über gehaltenes Handle → Auto-Freigabe bei Absturz/Exit; `LockConflictDialog`). QA: Lock-Logik korrekt, einzige Lücke: „schreibgeschützt" == „erzwingen" auf Datenebene (kein app-weiter Read-only-Modus).
- **Identität:** Open-Source-CAAD statt „BIM für Wohnbau"; `about.desc` (de/en) + README + `package.json` (`license: AGPL-3.0-or-later`, `author`). **LICENSE-File fehlt noch** (README verlinkt darauf) — offizieller AGPL-Text muss rein (nicht fabrizieren; `curl gnu.org/licenses/agpl-3.0.txt`). About-Dialog-Umbruch gefixt (`.about-dialog { white-space: normal }` — er erbt `nowrap` von `.topbar`).
- **2D:** Text-Platzierungswerkzeug als 2D-Element (`text`-Command + Freitext-Eingabe in der Command-Engine); 2D-Zeichenwerkzeuge auf `"drawing"`-Ebenen freigegeben (ToolsPanel-Gating = `floorOnly`, Parität mit Ribbon).
- **Teil B — 2D-Annotationen auf Layout-Blättern:** `LayoutAnnotation` (line/rect/text in mm) + CRUD (`layoutModel.ts`) + Editor in `LayoutSheet.tsx` (zeichnen/selektieren/verschieben/löschen, `PromptDialog` für Text) + `layoutSheetMath.ts` (Hit-Tests). Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping.
## Offene Fäden
1. **Idee 1 — per-Wand „an Decke enden"** (Terminierung ≠ Priorität): AGENT LÄUFT gerade (per-Wand `sliceTermination`, Default off). Details/Design im Memory `design-schicht-verschneidung-3d-schnitt.md`. **Idee 2 (Schichteinzüge, per-Schicht Endversätze)** = größeres Folge-Feature, noch nicht gebaut.
2. **Ressourcen-Fenster als eigenes OS-Fenster** (Nutzer-Wunsch): machbar (Tauri `WebviewWindow` + BroadcastChannel-Sync, „Ausklappen"-Button, Browser bleibt intern). Noch NICHT gebaut — braucht Store-Sync-Architektur-Entscheidung.
3. **Visuelle Tauri-Abnahme** aller 3D-Schnitt-Änderungen steht aus (render3d nur naga-validiert + WASM gebaut; Nutzer testet selbst). Der Backstein hat prioritäts-korrekt noch ein Stück oberhalb des Betons — das ist Idee 1 (Option), nicht ein Bug.
4. **Konventionen:** wie immer — keine KI-Spuren, nicht committen, disjunkte Agent-Lanes, echte Eigenschaft prüfen (nicht nur grüne Tests).
---
# HANDOVER — Stand 2026-07-08 (Account-/Instanzwechsel, ALLES uncommittet)
Übergabe an eine frische Instanz (Nutzer wechselt Account, Budget aufgebraucht). **Alles unten ist UNCOMMITTET** (Bearbeiter committet nie selbst). Zuerst `PENDENZEN.md` lesen (Single Source of Truth, ✅-Erledigt-Liste + offene Items ganz konkret). Diese Session war eine sehr lange Feature-/Fix-Serie mit vielen Sub-Agents.
## Verifikations-Baseline (zuletzt grün)
`npx tsc --noEmit` sauber · `npx vitest run` **491/491** · `cargo check --manifest-path src-tauri/Cargo.toml` grün · `vite build` OK. (Der letzte Layout-Viewport-Agent meldete 491; nach dem interaktiven Ausschnitte-Fix waren es 465 → 491 ist der aktuelle Stand nach dem Layout-2b-Agenten.) **Vor Weiterarbeit einmal `tsc`+`vitest` neu fahren**, da alles uncommittet nebeneinander liegt.
## Was diese Session gebaut wurde (alles in PENDENZEN ✅ dokumentiert)
- **Interop-Export:** IFC4 (`exportIfc.ts`, jetzt `IfcTriangulatedFaceSet` mit ausgeschnittenen Fenster/Tür-Löchern + SOLIDE Wände — Winding-Fix), STL+OBJ (`exportMesh.ts`), gemeinsamer Loch-Ausschnitt `src/plan/wallMeshCut.ts` (Port von render3d `extrude_layer_segment_with_holes`, `isWatertight` via Gauss/Volumen). Schnellexport-Dialog (`ExportSaveDialog`, Format+Name).
- **Nativer Speichern-Dialog:** Tauri `plugin-dialog`+`plugin-fs` (Cargo/lib.rs/capabilities), `src/io/saveFile.ts` (`saveTextFile`: nativ unter Tauri, Blob-Fallback im Browser). App.tsx `downloadTextFile``saveTextFile`.
- **Ausschnitte (View-Snapshots) Phase 1:** `Project.viewSnapshots`, `state/viewSnapshots.ts`, `ViewSnapshotsPanel` (Speicher-Fix: Inline-Input statt window.prompt).
- **Layout-Blätter Phase 2a+2b:** Modell `Layout`/`LayoutViewport`/`MasterLayout` (`Project.layouts`/`masterLayouts`), In-Viewport-Editor (`activeLayoutId` in App.tsx → `LayoutSheetView`/`LayoutSheet.tsx`, Maus-Editing, `layoutSheetMath.ts`), `LayoutsPanel`. Floating `LayoutEditor` wurde ersetzt.
- **Tragwerk-Stützen:** `Column`/`ColumnProfile`, `geometry/column.ts`, Platzieren/2D/3D/Selektion/Attribute/move.
- **Weiteres:** A1 Override-Regel-Engine, kernel2d Phase 5 komplett, Bauteil-CSV volles Element-Set, SIA-416 AGF, Kamera-Presets Kardinal+Iso, Decken-UK/OK, BIM-Tree-Panel, ObjectInfo-Volumen, Basis-Material-Texturen, OSM 7 Kat., IndexedDB-Kern, Rich-Text super/sub, Auto-Zoom-Geo, Raumstempel Personen/Rundung, diverse TopBar-Feinschliffe (Export-Sammelmenü, Über-Dialog, Petrol-Punkt, Detailgrad+Darstellung gestapelt, Arbeitsumgebung in Settings).
## ⚠️ WICHTIG / offene Fäden (Details in PENDENZEN, Abschnitt „In Arbeit"/oben)
1. **NOCH NICHTS VISUELL IN TAURI ABGENOMMEN:** IFC/OBJ/STL-Geometrie, nativer Speichern-Dialog, Layout-im-Viewport, Ausschnitte — alle agent-/testverifiziert, aber der Nutzer hat sie im laufenden Fenster noch NICHT gesehen. **Tauri-Dev NEU STARTEN** (`npm run tauri:dev`, ggf. Vite separat auf Port 5187 — es gibt KEIN `beforeDevCommand`) wegen der neuen Rust-Plugins.
2. **`window.prompt`/`confirm`-SWEEP:** in Tauri-WKWebView DEAKTIVIERT (→ null). Gefixt: ViewSnapshotsPanel, LayoutsPanel. NOCH betroffen: `ComboMenu` (Kombinationen speichern), TopBar Massstab „frei" (`topbar.scale.prompt`), evtl. weitere. Grep `window.prompt|window.confirm` über `src/`, alle auf Inline-Eingaben umstellen.
3. **NÄCHSTES GROSSES ITEM (fertig gescoped, war startbereit als Opus-Agent):** „Layouts als Ordner-/Baumstruktur" — voller Auftrag steht in PENDENZEN (Ordner-Baum, ein „+" für Ordner/Layout/Masterlayout, Inline-Rename, Layout-Erstell-Dialog mit Master-Vorlage/freie Grösse, Masterlayout mit Grössenwahl, Ordner→Mehrseiten-PDF). Direkt umsetzbar.
4. **Konventionen:** keine Fremd-Tool-Spuren/Co-Authored-By im Repo; Sub-Agents in disjunkte Datei-Lanes schicken (i18n de.ts/en.ts + App.tsx sind Hotspots → exklusiv EINEM Agent pro Welle, sonst NUL-Byte-Kollision); grüne Tests NICHT als alleinigen Beweis nehmen (echte Eigenschaft prüfen — z. B. beim IFC-Solidity-Fix war der naive Manifold-Test falsch, Gauss/Volumen war richtig).
---
# HANDOVER — Stand 2026-07-07 abends (Instanzwechsel, alles uncommittet)
Übergabe an eine frische Instanz (Nutzer wechselt bewusst die Instanz). **Alles unten Gelistete ist NOCH UNCOMMITTET** — Bearbeiter committet laut Arbeitsprotokoll nicht selbst. Zuerst `CONVENTIONS.md` + `PENDENZEN.md` lesen (dort steht die volle, aktuelle Aufgaben-Historie inkl. aller Details der truck-Integration/Deep-Review-Fixes unten — hier nur die Kurzfassung + der offene Faden).
## Sofort-Kontext für die neue Instanz
**Environment:** macOS-Gerät, Toolchain vollständig (node/npm/cargo/wasm-pack), `npm run tauri:build` läuft. Kein Blocker.
**Verifikations-Baseline (zuletzt geprüft, alles grün):** `npx tsc --noEmit` sauber · `npx vitest run` 341/341 · `cargo test` in `src-tauri/trucksolid` 15/15 · `cargo test --manifest-path src-tauri/Cargo.toml -p render3d` 58/58.
**Was in dieser Session geschah (chronologisch, Details je in PENDENZEN.md „truck-Integration" Phase 5 / Backlog):**
1. Vorherige Session ist wegen RAM-Absturz des Geräts abgebrochen (lief eine LOKALE LLM auf dem Laptop, nicht diese Instanz) — Stand war: truck-Integration Phasen 14 fertig (Extrusion + Verjüngung/Taper + Boolean-CSG via `csgrs`), aber noch uncommittet.
2. Diese Instanz hat den übernommenen Stand **deep-reviewt** (8-Winkel-Review: Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) und mehrere echte Bugs gefunden + gefixt — volle Liste in PENDENZEN.md unter „truck-Integration" → „Deep-Review + Fixes (2026-07-07)": konkave Profil-Triangulierung (Fächer→Ohr-Clipping), 6 fehlende Selektions-Resets für Extrusionen, Mirror/Copy ignorierten Extrusions-Auswahl, DXF-Export verlor den Layer, `truckMeshCache`-Leak + Flacker-Bug, unnötige `fitTargetDist`-Neuberechnung, fehlende i18n (Schnittebene-Button + Farbwähler-Tooltip).
3. Danach auf Nutzer-Wunsch **eigenständig durch PENDENZEN.md** gearbeitet (nur unblockierte Items, keine Design-Entscheidungen geraten): **Ortho-Ray-Picking gefixt** (`cameraRay` in `src/viewport/raycast3d.ts` konnte nur perspektivisch — jetzt echte parallele Strahlen bei `perspective:false`, rückwärtskompatibel; +2 Tests). Siehe PENDENZEN.md „3D-REST" Abschnitt.
## ✅ ERLEDIGT 2026-07-07 (Folge-Instanz) — Bauteil-CSV volles Element-Set (D2, vertieft A6), umgesetzt exakt nach dem Scope unten; Treppen-Fläche = null (leer), Aggregat-Zellen leer statt 0.00. Details/Status in PENDENZEN.md „✅ Erledigt".
`src/export/exportSchedule.ts` deckt aktuell nur **Wand + Decke** ab (Muster: `evalWall`/`evalCeiling``ScheduleRow`, siehe Datei). PENDENZEN-Backlog-Item „AUDIT (DOSSIER-Studie)" nennt D2 = „Bauteil-CSV mit vollem Element-Set". Ich habe das untersucht und **bewusst gescoped, aber NICHT umgesetzt** (Instanzwechsel kam dazwischen) — der Datei-Zustand ist unverändert/clean (ein Testballon-Import wurde wieder entfernt, `tsc` ist grün). Damit die nächste Instanz nicht neu recherchieren muss, hier die bereits getroffenen, gut begründeten Scope-Entscheidungen:
- **Aufnehmen:** `Door` (Tür), `Opening` (kind="window"→Fenster, kind="door"→Öffnung), `Stair` (Treppe), `ExtrudedSolid` (Extrusion, aus der truck-Integration).
- **NICHT aufnehmen: `Room`** — Räume haben bereits einen eigenen, dedizierten CSV-Export (`roomsToCsv` in `src/geometry/roomArea.ts`, SIA-Flächenbilanz-Spalten). Eine zweite Raum-Zeile in der allgemeinen Bauteilliste wäre Duplikation.
- **Spalten-Mapping** (bestehende feste Header „Länge/Höhe/Dicke/Fläche" — nur dort füllen, wo eine Ableitung wirklich verlässlich ist, sonst leer lassen wie es `evalCeiling` heute schon für Decken macht):
- Door/Opening: `length = width` (lichte Breite), `heightM = height`, `thickness = null` (keine sinnvolle Entsprechung), `area = width * height`. Geschoss auflösen über `hostWallId``project.walls.find(w => w.id === hostWallId)?.floorId` (neuer kleiner Helper nötig, Wand evtl. verwaist behandeln wie bei `evalWall`/`evalCeiling`).
- Stair: `length = runLength + (run2Length ?? 0)` (Näherung, bei Wendel evtl. wenig aussagekräftig — akzeptabel, da nur „verlässlich Ableitbares" Anspruch gilt), `heightM = stair.totalRise ?? null` (KEINE Geschosshöhen-Fallback-Auflösung nachbauen — Kopplung/Risiko vermeiden), `thickness = null`, `area`: **noch nicht entschieden** — eine echte Footprint-Fläche bräuchte `geometry/stair.ts`s `stairGeometry()` (Kopplung an Geschoss-Höhen-Fallback) → einfachster sicherer Weg ist vermutlich `area = null` lassen (ehrlich statt erfunden), aber nochmal gegen die bestehenden Tests/Konventionen prüfen, bevor final entschieden wird.
- ExtrudedSolid: `typeName = "Extrusion"`, Geschoss über `levelId`, `length = null`, `heightM = solid.height`, `thickness = null`, `area = polygonArea(solid.points)` (Import aus `geometry/roomArea.ts`, bereits im File importiert).
- Neue `kind`-Werte in `ScheduleRow`/`TypeAggregate`: `"Tür" | "Fenster" | "Öffnung" | "Treppe" | "Extrusion"` zusätzlich zu `"Wand" | "Decke"`.
- **Aggregat-Zeile (`aggregateByType`/Summary-Block):** die bestehende Zeile `fmtNum(agg.kind === "Wand" ? agg.totalLength : null)` sollte verallgemeinert werden zu `agg.totalLength > 0 ? fmtNum(agg.totalLength) : ""` — dann zeigen Tür/Fenster/Öffnung/Treppe (die jetzt auch eine `length`-Spalte befüllen) automatisch korrekt eine Summe, während Decke/Extrusion (deren `length` immer `null`→0 bleibt) weiterhin korrekt leer bleiben. Kein Sonderfall pro Kind nötig.
- **Tests:** `src/export/exportSchedule.test.ts` folgt einem klaren `fixtureProject()`-Muster (2 Wände + 1 Decke) — beim Erweitern eine Tür/ein Fenster/eine Treppe/eine Extrusion in die Fixture aufnehmen und je einen Zeilen-Assert wie bei den bestehenden Wand-/Decken-Zeilen ergänzen.
- Warum nicht in dieser Session fertig: Instanzwechsel kam mitten in der Umsetzung. **Kein Blocker, keine Nutzer-Entscheidung nötig** — reine Fleissarbeit nach obigem Schema, dann `tsc`/`vitest` grün prüfen und in PENDENZEN.md abhaken.
## Aus dieser Session bewusst NICHT angefasst (recherchiert, aber zu unsicher/blockiert)
- **Treppe Pfeil-Style „filled" + Tür `swing_invert`/`aussenseite`** (PENDENZEN „BAUTEILE… Gruppe B"): die Referenzquelle (`/tmp/dossier-ref/rhino/*.py`) existiert auf **diesem** Gerät/dieser Session NICHT (geprüft, `find` liefert nichts) — ohne das exakte Rhino-Verhalten zu kennen, wäre die visuelle Ausgestaltung geraten. Nicht angefasst, um nicht falsch zu raten.
- **E2b Schraffur-Kachel-Motiv:** `MotifEditor` (`src/ui/MotifEditor.tsx`) wird schon für Linienstil-Motive wiederverwendet (1D, entlang einer Linie) — ein Flächen-Kachel-Motiv für `HatchStyle` wäre ein NEUES Datenmodell (2D-Tile-Grid), kein reines Wiederverwenden. Braucht eigentlich einen Scope-Entscheid.
- Alles, was in PENDENZEN.md explizit „Mit Nutzer klären"/„Nutzer-Entscheid nötig" trägt (Ribbon 3D-Tab, kernel2d Phase 6, Feld-Controller, Schnitt/Ansicht Phase 3, Teamwork-Granularität, Geo-Block-Priorität, Textur-Pipeline-Scope, STRATEGIE-Priorisierung, Bildschraffur-Freigabe) — bewusst übersprungen, nicht neu bewertet.
## Wichtige Memory-Notiz (persistiert, gilt für zukünftige Instanzen dieses Repos)
Der Nutzer lässt teils eine **lokale LLM auf seinem Laptop autonom** an diesem Projekt arbeiten (kann abstürzen, z. B. RAM). Deren Output sah in dieser Session strukturell sauber + gut getestet aus, enthielt aber einen echten geometrischen Bug (Fächer-Triangulierung bricht bei konkaven Profilen), den die eigene Testsuite NICHT gefangen hatte (Tests prüften nur Form/Anzahl, nicht geometrische Korrektheit). **Bei Übernahme von Arbeit aus solchen Sessions: grüne Tests allein NICHT als Beweis nehmen, gezielt nachprüfen ob die Tests die richtige Eigenschaft testen.**
---
# HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen) # HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen)
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen. Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
+91 -10
View File
@@ -18,22 +18,40 @@
--- ---
## 📅 Geplant
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)****Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~46 Wochen. Mit Nutzer Scope/MVP klären vor Start.
## ⛔ Blocker (zuerst klären) ## ⛔ Blocker (zuerst klären)
- [x] ~~**Toolchain prüfen**~~**ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.** - [x] ~~**Toolchain prüfen**~~**ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
## 🔧 In Arbeit ## 🔧 In Arbeit
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** (Plan: [docs/design/truck-plan.md](docs/design/truck-plan.md)). Ziel: Nutzer zeichnet 2D-Querschnitt → truck-Extrusion → 3D-Körper + später Boolean gegen Wand/Decke. Umfang gesamt: ~46 Wochen. Fortschritt:
- [x] Phase 1: Geometrie-Schicht — Crate `src-tauri/trucksolid` (`extrude_polygon_core`/`extrude_circle_core`, `truck_modeling` nur zur B-Rep-Validierung/`try_attach_plane`/`tsweep`, Tessellierung manuell aus den Ring-Koordinaten — truck-rendimesh-Tessellierung für Solids in 0.3 nicht verfügbar, daher Abweichung vom Übergabe-Dokument), WASM-Bindings (Feature `web`) + `build:truck`-Script + `exclude`-Eintrag, TS-Wrapper `src/engine/truckSolid.ts` (`extrudePolygon`/`extrudeCircle`), `RMeshKind` um `"extrusion"` erweitert (`toWalls3d.ts`). Verifiziert: `cargo test` 5/5 grün, `npm run build:truck` sauber, `tsc --noEmit` 0 Fehler, `vitest run` 339/339 grün.
- [x] Phase 2 (MVP, Nutzer-Entscheid 2026-07-06: **"Nur Viewer-Wiring"**, kein Werkzeug/Store) — Ende-zu-Ende-Beweis, dass die Pipeline bis ins Bild funktioniert: ein fest verdrahtetes L-Profil (`emitTruckFixture` in `toWalls3d.ts`, 2 m abseits vom Ursprung) wird bei Bedarf per `trucksolid`-WASM extrudiert und erscheint im 3D-Viewport. **Zwei echte Lücken dabei gefunden und geschlossen:** (1) `render3d::types::MeshKind` kannte nur `Terrain`/`Imported` — ein `kind:"extrusion"` ohne passende Rust-Variante hätte die serde-Deserialisierung des GESAMTEN Modell-Pushes zum Absturz gebracht (nicht nur die Fixture); `Extrusion`-Variante + warmes Orange als Default-Farbe ergänzt (`cargo test` render3d 58/58 weiterhin grün). (2) `updateModel(project)` läuft nur bei Projektänderung (`useEffect`-Dep `project`) — die asynchron ladende Fixture hätte trotz Erfolg NIE einen Re-Push ausgelöst; Fix via Browser-Event `TRUCK_FIXTURE_READY_EVENT` (`toWalls3d.ts` dispatcht, `Wasm3DViewport.tsx` hört + stösst `updateModel` erneut an). **Visuell verifiziert** (Playwright, `?engine=wasm`, Chromium mit `--use-angle=metal` für echten WebGPU-Adapter headed): orangene Extrusion sichtbar neben dem Demo-Haus im Viewport. `tsc --noEmit` + `vitest run` 339/339 + `cargo test` render3d 58/58 grün. **Noch uncommittet** (Bearbeiter committet nicht selbst).
- [x] Phase 3: UI-Werkzeug + Store-Integration — **erledigt 2026-07-06:** `extrude`-Befehl (`src/commands/cmds/extrude.ts`, Alias „ex") nimmt Polylinie/Rechteck/Kreis als Profil (vorselektiert ODER Klick), Höhe tippen oder Enter für Default; Quell-Zeichnung wird beim Commit ENTFERNT (nicht dupliziert) — der Grundriss zeigt die Extrusions-Footprint-Kontur (`generatePlan.ts`, warmes Orange `#d98c40`) statt der alten Linie. `project.extrudedSolids`-Array statt Fixture. **Volle Auswahl-Parität mit Wand/Decke/Raum** (Nutzer-Entscheid „Voll: wie Wand/Decke/Raum"): eigener Selektionskanal, Klick-Priorität in `PlanView.tsx`, Attribute-Panel-Sektion (Höhe editierbar → löst Re-Extrusion aus, Grundfläche, Geschoss), Löschen, Ctrl+A, `move`-Befehl (`src/tools/transform.ts`). Kreis-Profil nutzt die echte `extrudeCircle`-WASM-Rundextrusion (kein N-Eck-Fallback) über eine 48-Eck-Tessellierung für 2D/Auswahl. Verifiziert: `tsc`/`vitest` 339/339 grün, Playwright-Durchlauf (zeichnen→extrudieren→auswählen→Höhe ändern→löschen) ohne Konsolenfehler. **Bewusst NICHT gebaut** (Konsistenz mit Raum/Decke/Treppe/Öffnung, die das auch nicht haben): Marquee-Auswahl, Klick-Drag-Verschieben. **Noch uncommittet.**
- [x] Phase 4: Boolean gegen Wand/Decke — Mesh-CSG statt B-Rep-Boolean. **Spike 1, 2026-07-06 (Monstertruck-Fork getestet, nicht nur Recherche):** eigenes Rust-Testprogramm gegen `monstertruck-solid` 0.3.2 (Fork von `ricosjp/truck`, wirbt mit gehärteten Booleans) — der Crate-eigene Smoke-Test (echter Überlapp, versetzt in allen 3 Achsen) läuft sauber (`or`/`and` liefern exakte Volumina). Aber: **jede Konstellation mit deckungsgleichen/berührenden Seitenflächen schlägt fehl** — exakt der Praxisfall „Extrusion flächenbündig an Wand" oder „Extrusion mit gleichem Wandquerschnitt eingebunden" (gleiche y/z-Ausdehnung wie die Wand, nur in x versetzt): 0 Überlapp → `Internal{operation:"or"/"and"}`-Fehler; 1e-4 UND sogar 0.1 m sichtbarer Überlapp bei deckungsgleichen Seitenflächen → derselbe interne Fehler; 1e-9 Gleitkomma-Rauschen (winziger Spalt statt exakt 0) → **kein Fehler, aber STILL FALSCHES Ergebnis** (2 unverschmolzene Shells, Volumen = Summe statt Vereinigung). Sobald y/z zusätzlich leicht versetzt sind (keine deckungsgleichen Seiten mehr), funktioniert derselbe x-Überlapp einwandfrei. **Schluss: B-Rep-Booleans (truck/monstertruck) sind für diesen Projekt-Praxisfall (Extrusion mit Wandbreite/-höhe eingebunden) grundsätzlich ungeeignet** — teils Absturz, teils stiller Falsch-Wert.
**Spike 2, 2026-07-06 (`csgrs`, Mesh-Ebenen-CSG/BSP-Baum statt B-Rep):** strukturell anderer Ansatz — rechnet auf Dreiecks-Ebene statt über analytische Kurven/Flächen-Schnitte, damit unempfindlich gegen genau die koinzidenten-Flächen-Fälle, an denen truck/monstertruck scheiterten. Gegen dieselben Testfälle geprüft: generischer Überlapp exakt korrekt (`or`/`and`); flächenbündig (0 Überlapp) → `or` exakt 2.0 (korrekt verschmolzen, kein doppeltes Volumen), `and` liefert korrekt LEER (als Trimesh-Fehler statt sauberem `None` — muss beim Aufrufer als "leer" interpretiert werden); 1e-9-Rauschen → weiterhin exakt korrekt (kein falsches Doppel-Volumen wie bei monstertruck); 1e-4 winziger Überlapp → exakt korrekt; **der kritische Praxisfall (0.1 m Einbindetiefe, deckungsgleicher Wandquerschnitt)**`or`/`difference` beide exakt korrekt. Jedes Ergebnis analytisch exakt (nicht nur "nah dran").
- Packungs-Caveat: alle crates.io-Releases (0.16.00.20.1) sind wegen einer harten, zurückgezogenen `core2`-Abhängigkeit nicht installierbar — auf dem unveröffentlichten `main`-Branch bereits behoben. **Nutzerautorisiert** ("Git-Fetch für den Spike erlauben", danach "alles klar mach das" zur Umsetzung als echte Abhängigkeit): `trucksolid/Cargo.toml` pinnt `csgrs` auf Commit `5e7a37a8803d4e56617734687edc9b98f4ebeed7` (Git-Dependency, kein crates.io-Release — Risiko: kein durchnummeriertes/geprüftes Release, sollte bei Gelegenheit auf ein echtes `0.21.0`-Release umgestellt werden, sobald verfügbar).
- **Umgesetzt:** `src-tauri/trucksolid/src/boolean.rs``boolean_mesh_core(a, b, op)` baut aus flachen Positions-/Indices-Arrays `csgrs::mesh::Mesh`-Polygone (`Polygon::new`/`Vertex::new`, Normale je Dreieck aus dem Kreuzprodukt, NICHT aus den Eingabe-Normalen — die Ebenen-Orientierung fürs BSP kommt aus der Vertex-REIHENFOLGE/Winding, nicht aus einem mitgelieferten Normalenfeld), ruft `union`/`difference`/`intersection` (Trait `csgrs::csg::CSG`) auf, tesselliert das Ergebnis zurück über `Triangulated3D::visit_triangles`. `empty: bool` im Output normalisiert den "Trimesh-Fehler bei leerem Schnitt"-Fall. WASM-Export `boolean_mesh` (Feature `web`, JSON-Schnittstelle wie `extrude_polygon`) + TS-Wrapper `booleanMesh()` in `src/engine/truckSolid.ts`. **Wichtige Voraussetzung für Aufrufer:** beide Eingabe-Meshes müssen konsistent nach AUSSEN gewundene Dreiecke haben (Rechte-Hand-Regel) — falsches Winding liefert ein falsches Ergebnis, OHNE Fehler zu werfen (im eigenen Test zunächst selbst hineingetappt: alle 6 Quader-Seiten der Test-Fixture waren invertiert, dadurch schlugen 3 von 4 Boolean-Tests fehl, bis das Winding korrigiert wurde — reiner Test-Fixture-Bug, nicht im Produktivcode).
- Verifiziert: `cargo test` in `trucksolid` 11/11 grün (inkl. `wall_minus_embedded_extrusion_matching_cross_section`, `flush_touching_union_is_exact`), `cargo build --target wasm32-unknown-unknown --features web` sauber (csgrs + truck-modeling gemeinsam im selben WASM-Modul, keine Konflikte), `npm run build:truck` (echter `wasm-pack`-Build) sauber — `boolean_mesh` ist reell im generierten `.d.ts` exportiert.
- **Noch offen (bewusst NICHT gebaut, eigene Scope-Entscheidung nötig):** die eigentliche Verdrahtung in die Live-3D-Szene (WANN soll eine Wand automatisch um eine eingebundene Extrusion gekürzt werden? Bei jeder geometrischen Überlappung? Nur bei explizit markierten Fällen?) ist eine UX/Scope-Frage, keine technische — analog zum Feld-Controller-Rückstand unten nicht blind entschieden, sondern auf Nutzer-Entscheid wartend. Aktuell bleibt eine Extrusion weiterhin ein unabhängiges, nicht mit der Wand verschmolzenes Solid im 3D (wie seit Phase 2/3).
- [x] Phase 5: Verjüngung (Taper) — `taper` 0 (Prisma) … 1 (Spitze/Kegel-Pyramide), linear zum Profil-Schwerpunkt skaliert. Rust-Kern (`extrude_polygon_core`/`extrude_circle_core` in `lib.rs`), WASM/TS-Wrapper, dritte Befehls-Phase in `extrude.ts` (Höhe → Verjüngung, Enter für persistenten Default), `ExtrudedSolid.taper?`, Attribute-Panel-Feld (editierbar → Re-Extrusion). Tests: Kegel-Volumen (Divergenzsatz gg. analytisch), Pyramidenstumpf-Schwerpunkt-Skalierung, Range-Check.
- [x] **Deep-Review + Fixes (2026-07-07, nach Geräte-Absturz der vorherigen Session):** 8-Winkel-Review (Korrektheit/Reuse/Simplify/Efficiency/Altitude/Conventions) über den gesamten uncommitteten Stand ergab mehrere echte Bugs, alle gefixt:
- **Konkave Profile falsch trianguliert** (`trucksolid/src/lib.rs`): Deckel/Boden nutzten eine Fächer-Triangulierung ab Vertex 0 — korrekt nur für konvexe/von Vertex 0 aus sternförmige Polygone. Bei einem T-Träger (genau das im Plan genannte Zielprofil) erzeugte das nachweislich Phantom-Dreiecke quer durch die konkave Kerbe (nachgerechnet: 2 von 6 Deckel-Dreiecken lagen mit Schwerpunkt ausserhalb). Fix: Ohr-Clipping-Triangulierung (portiert von der bereits vorhandenen, robusten `render3d::mesh::triangulate`) ersetzt den Fächer; Schwerpunkt-Berechnung für die Verjüngung ebenfalls auf die korrekte flächen-gewichtete Formel umgestellt (vorher reiner Vertex-Mittelwert, bei asymmetrischen Profilen verzerrt). Neuer Regressionstest `t_beam_caps_stay_inside_polygon` (prüft, dass jeder Deckel-/Boden-Dreieck-Schwerpunkt im wahren Polygon liegt). `cargo test` trucksolid 15/15 grün.
- **Auswahl-Zustand (`selectedExtrudedSolidIds`) an 6 Stellen in `App.tsx` nicht zurückgesetzt**, wo alle anderen Auswahl-Arten es bereits werden: Geschosswechsel-Effekt, `onOpenStampEditor`, die vier alten three.js-Viewport-Pick-Handler (Wand/Treppe/Decke/Öffnung), `onViewport3dPick`. Ohne Fix: Extrusion bleibt nach Geschosswechsel/anderer Auswahl "geisterhaft" mit-selektiert (falsches Panel, Löschen träfe das falsche Objekt).
- **Mirror/Copy ignorierten eine reine Extrusions-Auswahl** (`hasSelection`/`toTransformSel` in `mirror.ts`/`copy.ts` kannten `extrudedSolidId` nicht, obwohl `transform.ts`/`move.ts` es längst unterstützen) → stiller No-op statt Spiegeln/Kopieren.
- **DXF-Export verlor den Layer von Extrusions-Footprints**: da `extrude` die Quell-Zeichnung (mit `categoryCode`) entfernt, fiel `layerFor()` in `exportDxf.ts` auf `LAYER_DEFAULT` zurück. Fix: eigener `EXTRUSION`-Layer (gleiches Orange wie 2D/3D-Darstellung).
- **`truckMeshCache` (toWalls3d.ts) ohne Eviction** — wuchs unbegrenzt über eine Session (Extrudieren→Kopieren→Löschen in Serie liess Mesh-Daten toter Körper im Speicher). Fix: gelöschte Ids werden bei jedem `emitExtrudedSolids`-Lauf aus dem Cache entfernt. **Gleichzeitig behoben:** sichtbares Flackern beim Tippen von Höhe/Verjüngung im Attribute-Panel (jeder Tastendruck = neue Signatur = Cache-Eintrag wurde sofort auf `mesh:null` gesetzt, bevor die neue Extrusion geladen war) — die alte Mesh bleibt jetzt sichtbar, bis die neue fertig ist.
- **`fitTargetDist` im 3D-Viewport** (Wasm3DViewport.tsx) rief bei JEDER Projekt-Änderung (auch während eines laufenden Griff-Drags) unnötig die komplette Wand-/Öffnungs-/Decken-Flatten-Pipeline auf, nur um eine Bounding-Box für die Schnittebene zu ziehen — die aber nur bei aktiver Schnittebene gebraucht wird. Fix: Aufruf hinter `sectionActive` gegated.
- **Nutzer-Report währenddessen:** Schnittebene-Umschalter (Wasm3DViewport.tsx) hatte `title`/`aria-label` hart auf Deutsch verdrahtet statt über `t()` — einzige Stelle im 3D-Viewport, die das i18n-System umging. Fix + Audit der ganzen App auf dasselbe Muster (title/aria-label/placeholder ausserhalb `t()`): genau eine weitere Stelle gefunden (`ColorHexField.tsx` Swatch-Tooltip „Farbe wählen"), beide jetzt über neue i18n-Keys (`viewport3d.section.*`, `attr.pickColor`).
- Verifiziert nach jedem Schritt: `tsc --noEmit` sauber, `cargo test` trucksolid grün. `vitest run`/`cargo test render3d` am Ende der ganzen Fix-Reihe erneut komplett gegengeprüft (339/339, 58/58, 15/15).
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt: - [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d``a78e7c7` - [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d``a78e7c7`
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521` - [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf` - [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1` - [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert. - [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Phase 5 komplett (2026-07-07):** `opening` portiert (`wall_axis_length`/`opening_interval`/`wall_axis_frame`/`opening_jambs`/`opening_gap_quad`/`opening_center`/`door_symbol`/`window_symbol` + `along`-Helper, geflachte Signaturen nach PORT_PLAN; `openingVerticalExtent` bleibt bewusst TS — echte Modell-Kopplung an drawingLevels). +7 Parity-Tests (Suite 40 Parity). `cargo test` kernel2d 18/18, `build:kernel2d` sauber, vitest 348/348. Produktive Aufrufer unverändert (Phase 6 weiter offen/Nutzer-Entscheid). **Noch uncommittet.**
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären. - [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung). - **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
@@ -49,6 +67,8 @@
## 📋 Backlog (Priorität grob absteigend) ## 📋 Backlog (Priorität grob absteigend)
- [x] ~~**Shift-Ortho beim Körper-Verschieben (2D) fehlte**~~**erledigt 2026-07-06:** Nutzer-Report — Shift zum H/V-Einrasten wirkte beim Ziehen eines Vertex-Griffs (`onGripMove`) und beim freien Kanten-Zug (`onEdgeMove`), aber NICHT beim Verschieben eines ganzen Elements per Körper-Griff (`onMoveBody` — Wand/2D-Element/Decke/Treppe/Raum als Ganzes): `PlanView.tsx` reichte `mods` an dieser einen Stelle schlicht nicht durch. Fix: `GripHandlers.onMoveBody` bekommt optionales drittes Argument `mods: ToolMods`, `App.tsx` zwingt bei `mods.shift` das Delta auf die dominante Achse (H/V) — dieselbe Regel wie beim freien Kanten-Zug. `tsc`/`vitest` 339/339 grün.
- [ ] **Feld-Controller (getippte Länge/Winkel relativ zum Ausgangspunkt) fehlt beim Körper-Verschieben + im 3D.** Nutzer-Wunsch 2026-07-06: „allgemein brauchen wir ein System bei 2D- und 3D-Elementen, dass wenn man einen Punkt anwählt, man ihn verschiebt — mit der Option, Länge und Winkel relativ zur alten Position zu wählen." Bestandsaufnahme: **existiert bereits** für den 2D-Vertex-Griff-Drag (`gripEditRef`/„Feld-Controller", Tab öffnet/zykelt Länge↔Winkel-Sperre, `gripEditPoint` in `App.tsx`) — fehlt aber (a) beim 2D-Körper-Verschieben (`onMoveBody`, ganzes Element ziehen) und (b) komplett im neuen 3D-Griffsystem (`Wasm3DViewport.tsx`, s. „3D-Griffe/Editieren" oben — weder Vertex- noch Verschiebe-Griff haben dort eine Locks/HUD-Eingabe). Für (b) zusätzlich zu klären: wie eine getippte Zahl im 3D-Viewport erfasst wird, ohne mit der freien Maus-Navigation (Orbit/Pan) zu kollidieren — eigenes UI-Element (z. B. ein kleines Eingabefeld neben dem gezogenen Griff-Button) naheliegend, aber nicht mit dem 2D-HUD-Muster identisch übertragbar. Mit Nutzer Scope/Reihenfolge klären (2D-Körper zuerst, da mechanische Erweiterung des bestehenden Feld-Controllers; 3D danach als grössere UI-Frage).
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~**komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten: - [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~**komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
- [x] ~~**Kreis-Toolbar** (trivial)~~**erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.) - [x] ~~**Kreis-Toolbar** (trivial)~~**erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
- [x] ~~**Bogen-Werkzeug** (mittel)~~**erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334). - [x] ~~**Bogen-Werkzeug** (mittel)~~**erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
@@ -75,12 +95,53 @@
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen. - **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung. - **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~**bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden. - [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~**bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers. - [x] ~~**Einstellungs-Fenster (Rest)**~~**erledigt:** Verdrahtung war schon **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`); der einzig offene Punkt (Snap/Endpunkt-Default „aki") ist längst entschieden (2026-07-04: Sora #5FA1C9, s. „❓ Offene Rückfragen"). Zeile war stehen geblieben, obwohl die Frage schon geschlossen war.
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`). - [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
- [x] ~~**Tragwerk-Start: Stützen (Column) — MVP end-to-end**~~ (DOSSIER-Audit A4) — **erledigt 2026-07-07:** `Column`/`ColumnProfile` (rect/round) als platzierte Profil-Extrusion, `Project.columns`, `columnFootprint()` (gemeinsame Geometrie 2D/3D/Selektion/Transform), `columnVerticalExtent` (UK/OK-Anker wie Wand). Platzieren via BIM-Ribbon-Befehl (`column`, Alias stütze; Default Rect 0.3×0.3, Höhe=Geschosshöhe, Rechteck/Rund-Toggle + Live-Vorschau), 2D-Poché auf Kat. 50 (`addColumnPoche`, Component/Hatch-Auflösung), 3D-Prisma (`emitColumns`, synchron, Rust unberührt), eigener Selektionskanal (ALLE Reset-Stellen gespiegelt), `ColumnSection` (Profil/Masse/Höhe/Drehung editierbar), Löschen/move/copy/mirror, DXF-Layer, i18n. +8 Tests (2D-Position/Rotation, 3D-Box/Prisma, vertikale Lage). tsc/vitest 422. **Bewusst ausgelassen:** 3D-Pick (wie ExtrudedSolid), Marquee, Schedule-Zeile, eigene ToolId (Extrude-Muster gefolgt). **Noch uncommittet. Nutzer prüft 3D/2D visuell.**
- [x] ~~**Schnellexport-Dialog (Format + Dateiname) für Topbar-Export**~~ (Nutzer-Wunsch) — **erledigt 2026-07-07:** Klick auf CSV/IFC/OBJ/STL im Topbar-Export-Menü öffnet jetzt `ExportSaveDialog` (Format-Dropdown + Dateiname-Feld, Endung folgt dem Format, Enter/Speichern), statt sofort mit Default-Namen zu laden. PDF/DXF behalten ihre eigenen Options-Dialoge. `runExport(format, filename)` in App.tsx erzeugt+lädt. +i18n `exportSave.*`. tsc/vitest 434. **Offen (bewusst, Nutzer nannte es selbst als Folge):** echter Speicherort-Picker („wo") braucht den nativen Tauri-Save-Dialog (plugin-dialog/fs, Rust) — aktuell Download-Ordner + frei wählbarer Dateiname; die späteren „Ausschnitte" bekommen vordefinierten Namen+Ort. **Noch uncommittet.**
- [x] ~~**Nativer Tauri-Speichern-Dialog für Exporte**~~ (Nutzer „ausnahmsweise JA") — **erledigt 2026-07-07:** `@tauri-apps/plugin-dialog`+`plugin-fs` eingebunden (Cargo/lib.rs/capabilities `dialog:allow-save`+`fs:allow-write-text-file`, `cargo check` grün), Util `src/io/saveFile.ts` (`saveTextFile`: unter Tauri nativer „Speichern unter"-Dialog `save()`+`writeTextFile`, sonst Blob-Fallback mit octet-stream + verzögertem revoke; +2 Tests). App.tsx-`downloadTextFile` ruft jetzt `saveTextFile` (Import ergänzt). **Nutzer testet den nativen Dialog in Tauri.** **Scope-Hinweis:** falls fs-Scope-Fehler, `fs:scope` `$HOME/**` nachrüsten. **Noch uncommittet.**
- [x] ~~**Ausschnitte liessen sich nicht speichern (Tauri)**~~**erledigt 2026-07-08:** Ursache `window.prompt` ist im Tauri-WKWebView DEAKTIVIERT (→ null, kein Name, nichts gespeichert). `ViewSnapshotsPanel` nutzt jetzt In-App-Inline-Eingaben statt prompt/confirm (Speichern + Umbenennen inline, Löschen ohne confirm). tsc/vitest 465.
- [x] ~~**Layouts als Ordner-/Baumstruktur**~~**erledigt 2026-07-08:** Modell additiv `LayoutFolder{id,name,parentId?}` + `Layout.folderId?` + `Project.layoutFolders?`; freie Grösse `customWidthMm?`/`customHeightMm?` an Layout UND MasterLayout (übersteuert paper/orientation), zentraler Helfer `effectiveSheetSizeMm()`. Pure Logik `layoutModel.ts`: `buildLayoutTree`, `collectFolderLayouts`, `deleteFolder` (Kinder reparent auf Elternebene), `folderPdfPages`. `LayoutsPanel.tsx` als Baum (auf/zuklappbare Ordner, Master als eigener Vorlagen-Abschnitt), EIN „+"-Dropdown {Ordner·Layout·Masterlayout}, neues Element landet SOFORT im Inline-Rename (ViewSnapshotsPanel-Muster, kein window.prompt). Zwei Erstell-Dialoge (`LayoutCreateDialogs.tsx`): Layout mit Master-Vorlage-Dropdown ODER freie Grösse (A4/A3/Custom-mm); Masterlayout mit Format/freie Grösse+Ausrichtung. **Ordner→Mehrseiten-PDF funktioniert:** `src/export/layoutPdf.ts` (`buildFolderPdf`/`saveFolderPdf`, jsPDF, `addPage` je Layout mit eigener Grösse, Viewports via generatePlan→planToPrintSvg, Master-Titelblock als Vektor), neuer `saveBinaryFile` in `saveFile.ts` (nativer Speichern-Dialog für PDF-Bytes). +14 Tests (Custom-Grösse, Ordner-CRUD, Baumaufbau inkl. verwaister Refs, Reparenting, PDF-Seitenplan). **Nachtrag (Orchestrator):** `LayoutSheet.tsx` (In-Viewport-Editor, vom Agent bewusst nicht angefasst) nutzte noch `sheetSizeMm(layout.paper,...)` ohne Custom-Grösse zu respektieren — auf `effectiveSheetSizeMm(master ?? layout)` umgestellt (identische Regel wie der PDF-Export), damit Blätter mit freier Grösse auch im Viewport korrekt dargestellt werden. tsc/vitest 505. **Noch uncommittet. Nutzer prüft visuell in Tauri** (-Menü, Ordner-Zuordnung, Dialoge, Mehrseiten-PDF-Reihenfolge/Grössen).
- [x] ~~**Icons/A0-A6+B0-B6/aktiver Ordner/Drag&Drop im Layouts-Baum**~~**erledigt 2026-07-08:** eigene inline-SVG-Icons (`FolderIcon` auf/zu, `LayoutSheetIcon`, `MasterSheetIcon` mit Stern-Akzent), Muster wie die bestehenden Tab-Icons. `LayoutPaperFormat` volle ISO-216-Reihe A0A6+B0B6 (`PAGE_MM`/`PAPER_FORMATS`/`PAPER_FORMAT_GROUPS`), Dropdowns gruppiert A-/B-Reihe. „Aktiver Ordner" war schon korrekt (verifiziert, keine Änderung nötig). HTML5-Drag&Drop (Layout/Ordner zwischen Ordnern, Root-Drop, Ziel-Highlight) mit Zyklus-Schutz (`isDescendant`, `moveFolderToFolder` verwirft Selbst-Verschachtelung als No-op). `layoutPdf.ts`/`LayoutSheet.tsx` bekommen die neuen Formate automatisch (laufen über `effectiveSheetSizeMm`/`PAGE_MM`, kein Hardcoding). +14 Tests. tsc/vitest 519. **Noch uncommittet. Nutzer prüft visuell.**
- [x] ~~**Ausschnitte-Panel: Footer-Bar + Anwahl-Persistenz**~~**erledigt 2026-07-08 (Agent starb erst beim Schluss-Testlauf, aber vollständig verdrahtet + grün):** Footer im ViewSnapshotsPanel zeigt bei angewähltem Ausschnitt Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal `boolMapEqual`/`matchingComboName` gegen gespeicherte Combos), aktive Override-Namen, + Inline-Rename in der Footer-Bar. Anwahl bleibt markiert bis Abweichung: `selectedViewSnapshotId`-State + `snapshotMatchesLiveState`-Vergleich in App.tsx (bei Änderung eines erfassten Feldes → Auswahl weg). host.ts um `selectedViewSnapshotId`/`listLayerCombos`/`loadLayerCombo`/`listDrawingCombos`/`loadDrawingCombo` erweitert. tsc sauber, vitest 532 (+13). **Noch uncommittet. Nutzer prüft visuell.**
- [x] ~~**Ausschnitte-Panel: Ordner wie bei Layouts**~~**erledigt 2026-07-08:** echte Ordner-/Baumstruktur (spiegelbildlich zu Layouts). Modell additiv `ViewSnapshotFolder{id,name,parentId?}` + `ViewSnapshot.folderId?` (altes `folder?`-Namensfeld bleibt LEGACY-kompatibel), `Project.viewSnapshotFolders?`. Pure Baum-Logik `src/state/viewSnapshotFolders.ts` (`buildViewSnapshotTree`/`moveSnapshotToFolder`/`moveFolder` mit Zyklus-Schutz/`deleteFolder`-Reparent, +20 Tests). Panel: auf/zuklappbare Ordner, zwei Header-Buttons (FolderPlus/Plus), aktiver-Ordner-State, Inline-Rename, Löschen, Drag&Drop. **Footer-Bar + Anwahl-Persistenz UNVERÄNDERT erhalten** (unter dem Baum). +i18n. tsc/vitest 558. **Noch uncommittet. Nutzer prüft visuell.**
- [x] **2D-Zeichnen auf „Zeichnung"-Ebenen UND auf Layout-Blättern freigeben; 3D/BIM dort sperren (Nutzer-Wunsch 2026-07-08):****ALLE drei Teile erledigt 2026-07-08.** **TEIL B erledigt 2026-07-08:** 2D-Annotationen (Linie/Rechteck/Text in mm-Papier) direkt auf Layout-Blättern — `LayoutAnnotation` an `Layout` (additiv) + CRUD (`layoutModel.ts`: create/add/remove/patchAnnotation) + Editor in `LayoutSheet.tsx` (Tool-Buttons, zeichnen/Vorschau/commit, `pickAnnotation`/`translateAnnotation` in `layoutSheetMath.ts`, Selektion+Verschieben+Löschen, HUD mit Farbe/Stärke, `PromptDialog` für Text — kein window.prompt) + Handler in App.tsx. Tests: `layoutModel.test.ts` + `layoutSheetMath.test.ts`, Suite 588 grün. Bewusst ohne: Resize-Griffe, Text-Rotation, Snapping, Sheet-Clamping beim Verschieben. **TEIL A erledigt 2026-07-08** — die 2D-Zeichenwerkzeuge (select/line/polyline/rect/circle/arc/text) sind bereits nicht `floorOnly`, die zugehörigen Commands (line/rect/polyline/circle/arc/text) auch nicht → die Command-Engine erlaubt das Zeichnen auf jeder Ebene, und eine `"drawing"`-Ebene rendert schon die volle interaktive `LevelPlanView` (App.tsx:5515). Der einzige echte Blocker war das UI-Gating: die **ToolsPanel-Sidebar** sperrte ALLE Werkzeuge ausser „select", sobald `!toolsEnabled` (App.tsx `activeLevel.kind === "floor"`). Fix: `ToolsPanel.tsx` gatet jetzt per `tool.floorOnly && !toolsEnabled`**Parität mit dem Ribbon** (`RibbonBar.tsx`, das schon so gatete). Damit sind auf „drawing"-Ebenen die 2D-Tools nutzbar, BIM/3D-Tools (wall/ceiling/window/door/stair/room/column, alle `floorOnly`) bleiben grau. `AttributesPanel.tsx:78` bleibt unverändert (gatet nur den Wandtyp/Deckentyp-Picker, der ohnehin nur bei floorOnly-Tools erscheint). Verifiziert: `tsc` sauber, `vitest` 558/558. TEIL B (grösser, OFFEN): **auf Layout-Blättern 2D zeichnen** — der `LayoutSheet`-Viewport-Editor kann bisher nur Viewports platzieren; für Annotationen (Linien/Text/Rechtecke direkt aufs Blatt) fehlt ein Annotations-Datenmodell am `Layout` + das Zeichnen/Rendern auf dem Blatt (mm-Koordinaten). Scope Teil B mit Nutzer/als eigene Phase. **TEIL C erledigt 2026-07-08 („endlich"): Text-Platzierungswerkzeug als 2D-Element.** Das `{shape:"text"}`-Drawing2D-Modell + Rendering (`generatePlan``drawingText`) + Transform (`transform.ts` `at`) existierten schon (DXF-Import-Weg); es fehlte nur das PLATZIER-Werkzeug. Umgesetzt: neues `text`-Command (`src/commands/cmds/text.ts`: Ankerpunkt klicken → Freitext in der Command-Line eingeben → commit `Drawing2D {shape:"text", height:0.25m, angle:0}`), Placeholder-Tool `text` (nicht floorOnly) gekoppelt via `TOOL_COMMAND`, in Registry + Aliase (tx/txt/beschriftung) + `ToolId` + `TOOL_ORDER` + Ribbon-2D-Gruppe + ToolIcon (Serifen-A) + i18n (de/en). Neu: die Command-Engine routet jetzt Freitext-Eingabe (`AcceptKind` um `"text"` erweitert, `engine.ts` `routeTypedInput` reicht bei `accepts:["text"]` die getippte Zeile 1:1 durch — vor der numerischen Auflösung, damit auch Zahlen/Kommas als Label ankommen). Nutzer-Test: Text-Tool wählen → in den Plan klicken → Label tippen → Enter. **Noch uncommittet, WASM-unabhängig (rein TS).**
- [ ] **Masterlayout im Viewport ansehen + Elemente darauf platzieren (Nutzer-Wunsch 2026-07-08) — NACH Rollback+Footer-Agenten (gleiche Dateien LayoutsPanel/LayoutSheet):** ein Masterlayout soll wie ein Layout im `LayoutSheet`-In-Viewport-Editor geöffnet werden können, um Plankopf-Elemente/Rahmen VISUELL darauf zu platzieren (statt nur über ein Formular mit festen Titelblock-Textfeldern). `LayoutSheet.tsx` arbeitet aktuell nur mit `Layout` (hat `viewports: LayoutViewport[]`); für Master fehlt sowohl das Öffnen im Viewport als auch ein Datenmodell für frei platzierbare Grafik-/Text-Elemente (Plankopf-Felder, Rahmen-Linien) auf dem Master. Scope/Umfang mit Nutzer klären, bevor gebaut wird: welche Elementarten (Text-Feld mit Platzhalter-Variable wie {ProjectName}/{Scale}/{Date}, Linie/Rechteck als Rahmen, Logo/Bild?), wie sie sich von den normalen `LayoutViewport`s unterscheiden (kein Ausschnitt-Bezug, rein grafisch), Doppelklick-Navigation vom `LayoutsPanel` aus zum Öffnen eines Masters im Viewport (analog `onOpenLayout`).
- [x] ~~**LayoutsPanel: Doppel-Name + zwei Buttons je Abschnitt + Master in Ordnern**~~**erledigt 2026-07-08:** Doppel-„LAYOUTS" behoben durch Umbenennung des ÄUSSEREN Panel-Titels auf „Mappe" (DE) / „Portfolio" (EN, neuer Key `layouts.panelTitle`); innere Abschnitte bleiben „Layouts"/„Masterlayouts". Kombiniertes „+"-Dropdown ersetzt durch zwei Icon-Buttons je Abschnitt: `FolderPlusIcon` (Ordner) + `PlusIcon` (Layout bzw. Masterlayout). Masterlayouts jetzt in eigenen Ordnern: `MasterLayout.folderId?` + `LayoutFolder.kind?:"layout"|"master"` (fehlt=layout, rückwärtskompatibel), getrennte Bäume (`buildMasterTree`, kind-Filter), eigener `activeMasterFolderId`. Bug gefixt: `deleteFolder`/`moveFolderToFolder` verloren `kind` beim Reparent → `stripParentId`. +6 Tests. PanelFrame unangetastet. tsc/vitest 538. **Bewusst offen:** Drag&Drop im Master-Baum (bestehende Master nur beim Anlegen in Ordner). **Noch uncommittet. Nutzer prüft visuell.**
- [x] ~~**Fixer, unlöschbarer „Masterlayout"-Ordner**~~**VERWORFEN 2026-07-08 (Nutzer-Kurskorrektur):** gebaut, dann sofort zurückgebaut — Nutzer sah die bestehende Lösung (Masterlayouts als separate Kategorie/Abschnitt ausserhalb der Ordnerstruktur, aus dem vorherigen Layouts-Agenten) live und wollte sie explizit BEHALTEN, keinen Ordner. Vollständig zurückgerollt (`layoutModel.ts`/`LayoutsPanel.tsx`/`App.tsx`/i18n), verifiziert tsc sauber + vitest exakt Baseline 519 (keine Regression). Masterlayouts bleiben separater Abschnitt.
- [x] ~~**`window.prompt`/`confirm` überall ersetzen (Tauri-WKWebView deaktiviert sie) — SWEEP**~~**erledigt 2026-07-08:** neue generische `src/ui/PromptDialog.tsx` (Muster ExportSaveDialog: Overlay+Dialog, Enter bestätigt, Esc schliesst) ersetzt ALLE verbliebenen `window.prompt`-Aufrufe: `ComboMenu` (TopBar, Ebenen-/Zeichnungskombination speichern), `LayoutMenu` (Arbeitsumgebung speichern), Text-Grösse „frei" (`TextGroup`), Massstab „frei" (`ViewRibbonTab`), sowie die Tastatur-Flows O/P (Array-Kopien/Verteilen-Anzahl, `App.tsx`: `promptCount()` entfernt, ersetzt durch `pendingCopyMode`-State + Dialog). `window.confirm` war bereits an allen Fundstellen vorher entfernt (ViewSnapshots/LayoutsPanel). Verifiziert: `grep -rn 'window\.prompt(\|window\.confirm('` über src/ liefert NICHTS mehr. tsc sauber, vitest 491/491. **Noch uncommittet.**
- [x] ~~**Layout im Viewport editierbar (wie ArchiCAD/VW/OpenCADStudio) — Phase 2b**~~**erledigt 2026-07-08 (Agent-verifiziert, NICHT visuell abgenommen):** Blatt jetzt erstklassiger Ansichtsmodus im Haupt-Viewport (`activeLayoutId` in App.tsx → `LayoutSheetView` statt Content), schwebendes `LayoutEditor`-Fenster ENTFERNT. Maus-Editing (`LayoutSheet.tsx`): Viewport aufziehen→Ausschnitt-Bindung (Inline-Dropdown, kein prompt), auswählen/verschieben/8-Griff-Resize (Commit bei Pointer-Up, Live-Ghost), löschen (Entf), Massstab-HUD; Pan (Mitte/Space)/Zoom-to-Cursor/Einpassen. Pure Transform+Hit-Test `layoutSheetMath.ts` (+26 Tests). `LayoutsPanel` window.prompt/confirm ebenfalls auf Inline umgestellt. +i18n `layouts.*`, CSS `.layout-sheet-*`. tsc/vitest 491, vite build OK. **NOCH VISUELL IN TAURI ZU PRÜFEN.** Offen: Einrasten an Kanten/Nachbarn, Ribbon-Massstab wirkt im Layout-Modus nicht (nicht deaktiviert). **Noch uncommittet.**
- [x] ~~**Layout-Blätter mit Masterlayout — Phase 2a**~~ (A3, setzt auf Ausschnitte auf) — **erledigt 2026-07-08:** Modell `Layout`/`LayoutViewport`/`MasterLayout` + `Project.layouts`/`masterLayouts` (im Dokument). `LayoutsPanel` (Layouts+Master anlegen/umbenennen/löschen, im Rechts-Dock, `LAYOUT_VERSION` 10→11). `LayoutEditor` (schwebendes Fenster wie ResourceManager): Blatt im mm-Seitenverhältnis (Zoom/Einpassen), Titelblock (Master-Vererbung via `resolveTitleBlock`, leere Felder → Defaults Projektname/Blattname/Massstab/Datum), und jeder Viewport rendert ECHT den Plan seines gebundenen Ausschnitts via `generatePlan``planToPrintSvg` (viewBox-mm = Viewport-mm, 100%-Einpassung + Clipping). Viewport hinzufügen (Ausschnitt-Picker)/entfernen, Position/Grösse/Massstab numerisch; Papier/Ausrichtung/Master pro Blatt. +16 Tests (Blatt-Geometrie, immutables CRUD, Master-Vererbung, mm-Geometrie). +i18n `layouts.*`. **Ehrliche Grenzen:** Live-Viewport-Render nur per Code korrekt, NICHT im Browser abgenommen (Nutzer prüft); Ausschnitte auf Schnitt/Ansicht (activeLevelId≠Geschoss) → leerer Plan (nur Grundriss-Ausschnitte füllen den Viewport); Titelblock-Font ≠ PDF-Helvetica (Phase-2b-PDF baut es separat). **Bewusst Phase 2b:** Multi-Page-PDF-Export, Maus-Drag/Resize der Viewports, „Alle aktualisieren", erweiterte Kamera-POV (Augen-/Zielhöhe). tsc/vitest 465. **Noch uncommittet.**
- [x] ~~**Ausschnitte / View-Snapshots — Phase 1**~~ (A2, Fundament) — **erledigt 2026-07-07:** `ViewSnapshot` (name/folder + viewType/view3d/fov/scaleDenominator/detail/activeLevelId + Ebenen-/Zeichnungs-Sichtbarkeit + aktive Override-Ids) am `Project.viewSnapshots` (im Dokument). Pure Capture/Apply-Logik `src/state/viewSnapshots.ts` (+8 Tests: Roundtrip, defensives Auffüllen, Override-Menge). App.tsx: Capture sammelt View-State + `snapshotLayer/DrawingVisibility` + aktive Overrides; Apply Reihenfolge Geschoss→View→Sichtbarkeit→Overrides, Massstab-Zoom per 1 rAF. Neues `ViewSnapshotsPanel` (Liste/Ordner, Speichern/Umbenennen/Löschen), registriert im Default-Rechts-Dock (`LAYOUT_VERSION` 9→10). +i18n `viewsnap.*`. **Bewusst offen Phase 2:** Layout-Blätter+Masterlayout (A3), Viewport-Platzierung + Ausschnitt-Bindung, „Alle aktualisieren", Multi-Page-PDF, erweiterte Kamera-POV (Augen-/Zielhöhe getrennt), Ordner-Drag&Drop; Apply erzeugt mehrere Undo-Schritte (bekannt). **Noch uncommittet.**
- [x] ~~**IFC-Wände nicht solid (oben/unten offen)**~~**erledigt 2026-07-07:** ECHTE Ursache war eine invertierte Wicklung, NICHT eine fehlende Fläche — der Y-up→Z-up-Achsen-Swap im IFC-Export (`(mx,my,mz)→(mx,mz,mybase)`) ist eine Reflexion (Determinante 1) und kehrte jedes Dreieck um → Normalen nach innen → Viewer cullt Vorderseiten → hohl. Fix: beim Swap Dreieck umdrehen (`i0,i2,i1`). `Closed=.T.` gesetzt, verifiziert über `isWatertight` (`wallMeshCut.ts`): NICHT der naive „jede Kante von 2 Dreiecken"-Test (der meldet die legitimen T-Stösse aus vollen Deckel/Boden-Streifen fälschlich als offen), sondern das T-Stoss-robuste Gauss-/Divergenz-Kriterium `∮n dA=0` + signiertes Volumen >0. STL/OBJ waren korrekt (kein Swap). +15 Tests (Wasserdicht/Winding, inkl. IFC-Face-Set-Volumen aus dem geparsten SPF). tsc/vitest 449, cargo check grün. **Noch uncommittet. Nutzer prüft im Viewer.**
- [x] ~~**Exporte an 3D angleichen: Öffnungen ausschneiden + Joins (STL/OBJ/IFC)**~~ (Nutzer: „es sollte so raus wie es im 3d ist") — **erledigt 2026-07-07:** neuer TS-Helfer `src/plan/wallMeshCut.ts` portiert `render3d/mesh.rs::extrude_layer_segment_with_holes` 1:1 (Koordinaten-Kompression `solidSubrects`, Langseiten/Deckel/Boden/Stirnkappen minus Loch-Intervalle, bis zu 4 Laibungsquads pro Loch, konsistentes Winding). STL/OBJ: Wände mit `holes` → ausgeschnittenes Mesh (sonst klassische Box); Joins kommen aus `pickGeometry`. IFC: Wände als `IfcTriangulatedFaceSet` (IFC4, gespeist aus demselben Loch-Mesh) → sichtbar wie 3D in JEDEM Viewer (behebt „Void nicht subtrahiert"); Tür/Fenster bleiben eigene `IfcDoor`/`IfcWindow`-Objekte, die das Loch füllen; IfcOpeningElement/Void/Fill entfallen (Loch steckt im Mesh). +12 Geometrie-Tests (Loch-Region hat keine Voll-Wand-Dreiecke, Laibungen vorhanden, dangling-refs grün). tsc/vitest 434. **Ehrliche Rest-Lücken:** echte Miter-Gehrungsflächen (Export nutzt die achsparallele pickGeometry-Näherung), Schicht-Farben (IFC-FaceSet ohne per-Vertex-Farbe/IfcStyledItem), Fensterglas/Rahmendetail. **Noch uncommittet. Nutzer prüft im Viewer.**
- [x] ~~**Layout-Auswahl in die Einstellungen, umbenannt „Arbeitsumgebung"**~~**erledigt 2026-07-07:** `LayoutMenu` (Fenster-/Dock-Layouts) aus der TopBar-Zeile in `SettingsDialog` verschoben (neue erste Sektion), `layoutMenu`-Prop von TopBar → SettingsDialog umgehängt (ReactNode-Import in TopBar entfernt, da sonst ungenutzt). i18n `layout.*` umbenannt (DE „Arbeitsumgebung", EN „Workspace") + neue `settings.section.workspace`. tsc/vitest 423. **Noch uncommittet.**
- [x] ~~**Export-Sammelmenü + Über-Dialog + Petrol-Punkt (TopBar-Feinschliff)**~~**erledigt 2026-07-07:** (1) Die 6 einzeln aufgereihten Export-Icons (PDF/DXF/CSV/IFC/OBJ/STL) zu EINEM „Export"-Knopf (`ExportMenu`, `ios_share`) mit Dropdown-Popover zusammengefasst — entschlackt die Chrome-Zeile; Import bleibt separat. (2) Klick auf die Wortmarke „dossier" öffnet einen In-App-„Über"-Dialog (`AboutDialog`: Marke, Version 0.1.0, Kurzbeschreibung, OSS-Lizenzen; Esc/Klick-ausserhalb schliesst). Wortmarke ist jetzt `<button>` (Chrome zurückgesetzt, Optik identisch). +i18n `file.export`/`about.*`, CSS (`tb-menu`/`about-*`). tsc/vitest 422. **Fix (Nutzer-Report „IFC nicht anwählbar"):** Popover per `createPortal` nach body (entkommt dem Topbar-Stacking-Context, der die unteren Einträge unter dem Ribbon verdeckte) + zweiter `popRef` im Outside-Click-Handler (sonst schloss der mousedown das Menü vor dem Klick) — Muster wie `ContextMenu`/`Dropdown`. **Hinweis:** In-App-Dialog statt nativem macOS-About-Panel (das bräuchte ein Rust-Command in src-tauri) — falls der native Panel gewünscht ist, Folge-Item. **Noch uncommittet.**
- [x] ~~**Brand-Punkt petrolgrün**~~**erledigt 2026-07-07:** `.brand-dot` fix `#0f766e` (petrolgrün wie DOSSIER-Rhino-Plugin) statt `var(--accent)`. Feinton per Nutzer justierbar.
- [x] ~~**OSM-Import auf 7 Kategorien**~~**erledigt 2026-07-07:** 3 neue Kontext-Kategorien im Overpass-Import — `parking` (amenity=parking, Fläche), `railway` (railway~rail|tram, Linie), `forest` (landuse=forest + natural=wood, aus `green` herausgelöst; `green` jetzt nur Parks/Wiesen). `OsmSelection`/`buildQuery`/`categorize`/`isClosedCategory` (osm.ts), `GeoCategory`/Labels/Layer (geoContext.ts), 3 Checkboxen (ContextImportDialog.tsx), i18n `ctxImport.src.*`. tsc/vitest 414. **Noch uncommittet.**
- [x] ~~**IndexedDB-Persistenz-Kern**~~**erledigt 2026-07-07:** `src/state/projectStore.ts` — async CRUD `saveProject`/`loadProject`/`listProjects`/`deleteProject` über IndexedDB (DB „dossier"/Store „projects", keyPath name, `{name,savedAt,project}`), guard-sicher (fehlt `indexedDB` → degradiert: load→null/list→[]/save+delete no-op, 1× warn) — läuft daher auch in vitest/node. +7 Guard-Tests (414). **UI-Wiring (Speichern/Öffnen-Menü, Recent-Liste, Auto-Save) NOCH OFFEN — Folge-Item.** **Noch uncommittet.**
- [x] ~~**ObjectInfo Wand: UK/OK-Reihenfolge vertauscht**~~**erledigt 2026-07-07:** im Wand-Abschnitt steht jetzt OK (Oberkante) oben, UK (Unterkante) darunter — räumlich passend (Nutzer-Report „stimmt visuell nicht überein"); Decken-Abschnitt hatte die Reihenfolge schon → jetzt konsistent. Reiner JSX-Reorder. tsc/vitest 414. **Noch uncommittet.**
- [x] ~~**STL- + OBJ-Export (3D-Mesh, Interop)**~~**erledigt 2026-07-07:** pures Modul `src/export/exportMesh.ts` (`exportObj`/`exportStl`, ASCII-STL) aus `pickGeometry(project)` (echte 3D-Geometrie inkl. Joins/Decken-Dominanz): Wand-Bänder als 12-Dreieck-Boxen, Decken/Extrusionen als triangulierte Prismen (bestehender `triangulate()` aus glPlanCompile, kein WASM), y-up rechtshändig. Voll verdrahtet: Quick-Access-Buttons (`view_in_ar`/`landscape`) + `onExportObj`/`onExportStl` in App.tsx (Blob-Download) + i18n `file.exportObj`/`file.exportStl`. 8 Kern-Tests (Index-Bounds, facet/vertex-Konsistenz, Tri-Zahl-Plausibilität). tsc/vitest 407. **Bewusste v1-Limitation:** Öffnungslöcher NICHT ausgeschnitten (Wände volle Boxen) — ladbar in Blender/MeshLab, aber nicht öffnungsgenau; öffnungsgenaue Variante = Folge-Item. Extrusionen nutzen Prisma-Triangulierung statt async truckSolid-WASM (bewusst, wegen synchronem pure-Kern). **Noch uncommittet.** (IFC-Import + DWG-Schreiben bleiben offen.)
- [x] ~~**Raumstempel: Personenzahl + Flächen-Rundung**~~**erledigt 2026-07-07:** `RoomStamp.occupancy?`/`roundingStep?` (additiv, optional). `formatStampArea(area, step?)` in `roomStamp.ts` rundet auf Vielfache von `step` (0.01/0.1/0.5/1 m²), sonst 2 Nachkommastellen wie bisher; Personenzahl als „{n} Pers."-Zeile im Stempel. Editor-Zeilen (Dropdown Rundung + Zahlfeld Personen). Drag&Drop-Builder bleibt out of scope. +6 Tests, +i18n. tsc/vitest 399. **Noch uncommittet.**
- [x] ~~**Auto-Zoom nach Geo-Import**~~**erledigt 2026-07-07:** nach swisstopo/OSM/Terrain-Import (`onAddContextObjects`) passt die Plan-Ansicht automatisch auf die neuen Objekte ein. `PlanViewHandle.fitBounds(pts)` (dünner Wrapper um `fitBoxFor`), `contextObjectsBounds()` bildet die Gesamt-BBox über alle neuen ContextObjects (contourSet-Punkte + Mesh-/Terrain-Positions, Z ignoriert), `addContextObjectsAndFit` in App.tsx verdrahtet. DXF-Import-Pfad (eigene viewCenter-Logik) bewusst unberührt. Kein i18n, host.ts unverändert. tsc/vitest 393. **Noch uncommittet.**
- [x] ~~**Rich-Text Hoch-/Tiefstellung (super/sub)**~~**erledigt 2026-07-07:** zwei Toolbar-Buttons in `RichTextEditor.tsx` (Muster der bestehenden B/I/U/S-Buttons; super/sub-Exklusivität war im Modell `richText.ts::applyMark` schon gelöst); +i18n `rt.super`/`rt.sub`. Teil des ROADMAP-Items „Rich-Text-Annotationen" (Maskierung/Rahmen bleiben offen/blockiert). tsc/vitest 393. **Noch uncommittet.**
- [x] ~~**B2 — Objekt-Info numerisch erweitern**~~**erledigt 2026-07-07:** im ObjectInfo-Panel sind X/Y jetzt editierbar (Commit verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die Koordinate wandert, via neuem Host-Callback `onMoveSelectionBy`), dazu ein Dreh-Feld (`RotateField`, Delta-Grad um den Anker, `onRotateSelectionAround`) und für einzelne Drawing2D-`line`/`circle` ein Längen- bzw. Radius-Feld (`onSetDrawingLineLength`/`onSetDrawingCircleRadius`). Move/Rotate nutzen die bestehende `commitTransform`-Maschinerie (transform.ts unverändert), wirken für wall/drawing2d/extrudedSolid; für ceiling/opening/stair/room ist der Aufruf No-op (Feld rendert, revertiert — gleiches Muster wie das bestehende Breite/Höhe-Resize). +i18n. tsc/vitest 393. **Noch uncommittet.**
- [x] ~~**Komponenten-Thumbnail: PBR-Kugel-Vorschau (D3-Rest)**~~**erledigt 2026-07-07:** ComponentsTab-Listeneintrag + Detail-Preview in `ResourceManager.tsx` zeigen jetzt bei gesetztem `component.material` die live gerenderte PBR-Kugel (`ComponentMaterialSphere`, lazy via IntersectionObserver + `requestMaterialPreview`, Muster von `MaterialLibraryTile`); `materialAssetOf` löst `libraryId` gegen `MATERIAL_LIBRARY` auf, sonst Ad-hoc-Asset aus den eigenen Map-URLs. Fallback-Kette Material→Hatch→Color unverändert. Nur `ResourceManager.tsx`, kein i18n. tsc/vitest 393. **Noch uncommittet.**
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden). - [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
- [x] ~~**Linienstile aufräumen**~~**erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line``thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive``thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün. - [x] ~~**Linienstile aufräumen**~~**erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line``thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive``thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback). - [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`. - [ ] **AUSSCHNITTE- + LAYOUT-SYSTEM (Nutzer-Vision 2026-07-07, grosser Hebel).** Zwei zusammenhängende Systeme (DOSSIER A2→A3, ROADMAP §7 Phase 3 + §11 Pläne):
- **Ausschnitte / View-Snapshots (A2):** benannte, gespeicherte Ansichten, die einen kompletten Darstellungszustand einfangen und wiederherstellen. **Kern-Einsicht des Nutzers: das muss NICHT neu erfunden werden — die Bausteine existieren schon und werden nur KOMPONIERT:** ein Ausschnitt = Referenz/Snapshot auf {**Ebenenkombination** (`src/state/visibilitySets.ts` `LayerCombo`) + **Zeichnungsebenen-Kombination/-Einstellungen** (`DrawingCombo` ebd.) + aktive **Overrides**-Regelmenge (`Project.overrideRules`, A1) + Kamera/Ansichtstyp/View3d + **Massstab** + **Detailgrad**}. Also im Wesentlichen ein Container, der diese bereits vorhandenen Zustände bündelt, benennt (Ordner/Presets) und per Klick wiederherstellt. Speicherort: Projekt (nicht localStorage — Ausschnitte gehören zum Dokument). Voraussetzung für die halbe Pläne-Tabelle (Layouts, Multi-Page-PDF, Detail-Bindung, Massstab-pro-Viewport).
- **Layout-System mit Masterlayout + Layouts (A3):** Druck-/Plan-Blätter (A4/A3-Papierformate), auf denen mehrere **Details/Viewports** platziert werden, jedes an einen Ausschnitt-Snapshot gebunden (bei „Alle aktualisieren" ziehen sie nach). Ein **Masterlayout** (gemeinsame Elemente: Titelblock/Planrahmen/Logo/Plankopf, wie Vorlagen-Master in InDesign/ArchiCAD) wird von den einzelnen **Layouts** geerbt/überlagert — Änderung am Master schlägt auf alle Layouts durch. Pro Layout eigener Massstab pro Viewport (Auto-DPI, Plotweight-/Schraffur-Skalierung, `sceneToPrintSvg.ts` als Basis), PDF-Export pro Blatt bzw. Multi-Page.
- **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):
- **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.
- **3D-Schnitt-POV-Parameter:** die 3D-Schnittebene definiert zusätzlich Blickpunkt (POV), **Augenhöhe** + **Zielhöhe** (getrennt) und **Blickwinkel** — dieselben erweiterten Kamera-Settings wie im [Ausschnitte-System oben] (dort pro Projektionsart mitgespeichert). Ein Schnitt ist damit ein Ausschnitt mit Clip-Ebene + Kamera-Definition.
- Verknüpfung: baut auf dem bestehenden 2D-Schnitt (`DrawingLevel kind:"section"`, `linePoints`/`directionSign`) + der 3D-Live-Schnittebene (`03f0c40`, Clip-Uniform) auf; koppelt eng mit dem Ausschnitte-/Layout-System (View-Snapshots) und den erweiterten Kamera-Settings. Grosses, phasenweises Feature — mit Nutzer Reihenfolge/Scope festlegen (zuerst: 3D-Schnittebene als 2D-Grundriss-Objekt platzier-/editierbar + Doppelklick-Navigation).
- [ ] **AUDIT (DOSSIER-Studie):** ~~A1 Override-Regel-Engine~~ ✅ (2026-07-07, s. Erledigt); A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt; D2 volles Element-Set erledigt 2026-07-07.) Belege in `/tmp/dossier-ref/rhino/*.py`.
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen. - [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten: - [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
@@ -92,11 +153,17 @@
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI. - **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
- **Aufwand:** gross (24 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet. - **Aufwand:** gross (24 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
- [x] ~~**2D-Zeichnungen (`drawings2d`) fehlen im nativen render3d/WASM-Pfad bei z=0.**~~**erledigt 2026-07-06:** Nutzer-Report — früher wurden 2D-Plan-Elemente auch im 3D als flache Referenz bei z=0 gezeigt, das gab es nur noch im alten three.js-Fallback (`addDrawing2DLines`), nicht im nativen `render3d`/wgpu-Pfad (heute Standard-Renderer). Fix OHNE Rust-Änderungen: neuer Emitter `emitDrawingLines` (`src/plan/toWalls3d.ts`) baut je 2D-Zeichnung (line/polyline/rect — Parität zu `drawing2DSegments` im three.js-Fallback, Kreis/Bogen/Text bewusst aussen vor) ein dünnes Ribbon-Mesh (2 Dreiecke je Segment, `DRAWING_LINE_HALF_WIDTH` 0.008 m) knapp über der Geschossebene (`DRAWING_LINE_ELEVATION_EPS` 0.01 m), Farbe via `drawingColor3d` (color→LineStyle→Kategorie, wie `drawing2DColor`) → hexToRgb. Läuft über den bereits bestehenden generischen `RMesh`/`MeshInput`-Kanal (`kind:"imported"`, `append_context_mesh` rendert ohnehin doppelseitig) — kein neuer Rust-Typ nötig. `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (2026-07-06, Tauri-Dev-App selbst getestet): Linien aus dem 2D-Grundriss sichtbar in der Perspektive. **Noch uncommittet.**
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen) ### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich erledigt** (`0ca3b1d`, Schachbrett-Textur auf Wänden, group-1-Bind-Group, `MESH_TEXTURED_WGSL`, planare Meter-UVs). **Verbleibende Lücken bis „richtig gut" (Grobschätzung ~1829 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~12 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~35 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~58 · Mipmaps (Blit-Pass) + anisotropes Filtern ~12 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~34 · UI zum Zuweisen + Persistenz im Dokumentmodell ~58. - [x] ~~**Wand-Joins in 3D**~~**erledigt 2026-07-07:** `toWalls3d.ts` wendet jetzt die per-Schicht-Cuts aus `computeJoins` (pro Geschoss, `computeJoinsByFloor`) auf den layered-3D-Pfad an — dieselben Zahlen wie generatePlan im 2D: End-Cuts/Merge an L-/T-Stössen (Band-Mittellinie geschnitten, Kern läuft bis zur Rückgrat-Nahfläche durch, Putz trimmt), Durchgangswand-Span-Cutouts (Band zerfällt in Achsen-Teilstücke). **Decken-Dominanz per Schicht** (Scope-Erweiterung): `trimWallTopForCeilings` (kappte die ganze Wand) ersetzt durch per-Schicht-z-Subtraktion analog `subtractDominantBands` — Wand behält volle Höhe, nur Bänder mit strikt niedrigerer joinPriority verlieren das Decken-z-Intervall (Kern läuft durch; dominiertes Band spaltet vertikal in unter/über). Öffnungs-Löcher je Teilstück korrekt rebased. Rust unverändert (Boxen parametrisch). Pick/Highlight jetzt konsistent verschnitten. vitest 383 (25 in toWalls3d.test). **Auslassung ehrlich:** Achsenbox bildet gekippte Miter-Stirnfläche nicht exakt ab (Band-Länge = Mittellinien-Schnitt) — passgenau bei rechten/stumpfen Winkeln, kleine Rest-Approximation an sehr spitzen. **Noch uncommittet.** **Nutzer prüft visuell in der Tauri-App.**
- [x] ~~**Decken-Schnitt in 3D schneidet zu viele Schichten („Kranz durch die Dämmung")**~~**erledigt 2026-07-07:** `collectCeilingCutters`/`emitWall` in `toWalls3d.ts` prüft jetzt PER BAND, ob die Decken-Outline die Band-Mittellinie (`wall.start + u·t + n·offset`) im Grundriss überdeckt — via vorhandenem `pointInOutline` (`geometry/ceiling.ts`), Achse in ~5-cm-Schritten gerastert + Bisektion an den Überdeckungsgrenzen (neue Helfer `bandCoverageIntervals`/`bisectBoundary`); nur überdeckte Achs-Teilstücke bekommen den Decken-z-Schnitt, unbedeckte behalten volle Höhe. Repliziert `subtractDominantBands` (echter geometrischer Überlapp statt wandweiter BBox). Am Sample: Backstein/Innenputz (innen) geschnitten, Dämmung/Aussenputz (aussen) laufen voll durch — kein Kranz. Die 3 falschen Alt-Tests des Vorgängers korrigiert (kodierten „alle Schichten geschnitten"), +2 neue (Voll-Überdeckung schneidet weiterhin alle; Teil-Längen-Überdeckung splittet entlang der Achse). Schnitt-Pfad/L-T-Joins/holes/Rust unberührt. tsc sauber, vitest 393. **Noch uncommittet. Nutzer prüft visuell.**
- [~] **3D-Live-Schnitt: echte Bauteil-Schraffuren statt prozeduralem 45°-Muster (Nutzer-Wunsch 2026-07-07).****implementiert 2026-07-08 (wartet auf visuelle Nutzer-Verifikation nach WASM-Rebuild).** Der ganze Weg von der 2D-Hatch-Definition bis in den wgpu-Cap-Pass ist gelegt: `toWalls3d.ts` bildet je Materiallage/Decke via neuem `hatchPatternId()`/`resolveComponentHatch()` (aus `getHatch(comp.hatchId)`) ein `Hatch{pattern,angle,scale}``WallInput.hatch`/`SlabInput.hatch` (additiv, `#[serde(default)]`) → `section.rs` reicht es über `Prism``CutPolygon.hatch``section_fill.rs` schreibt es je Cap-Vertex (`CAP_FLOATS_PER_VERTEX` 5→8: `[pos,u,v,pattern,angle_rad,scale]`) → `gpu.rs` Cap-Pipeline-Vertexlayout erweitert → `shaders.rs` `CAP_WGSL` wählt prozedural das Muster: `0 none`→weiss, `1 solid`→Vollton (Beton-Poché), `2 diagonal`→45° (bitgleich zum alten Muster bei angle=0/scale=1 → rückwärtskompatibel), `3 crosshatch`→Kreuz, `4 insulation`→Zickzack. Monochrom (Tinte auf Papier, nur MUSTER variiert — bewusst keine Farbe). Da der 3D-Viewer je Schicht EINE Box emittiert (`layeredWalls:true`), trägt jede geschnittene Schicht ihr eigenes Muster; ohne aufgelöste Schraffur → Fallback-Diagonale. `cargo test -p render3d --features render` 63 grün (+2 section_fill-Tests, WGSL-Validierung inkl. Cap-Shader). **WASM (`npm run build:engine3d`) noch NICHT gebaut (brauchte Netz) — Nutzer muss rebuilden + Tauri-Dev neu starten, sonst greift nichts.** `cargo check --target wasm32 --features web` grün. **Noch uncommittet.**
- [ ] **3D-Snapping beim Ziehen (Nutzer-Wunsch 2026-07-07): alle Elemente sollen im 3D auf andere Punkte einrasten.** Heute rastet das 3D-Griff-Ziehen NICHT ein — es projiziert die Maus nur frei auf eine Ebene (`rayPlaneY`/`rayPlane` in `src/viewport/Wasm3DViewport.tsx:576-582`); Snap wurde beim 3D-Griffsystem bewusst weggelassen (three.js-Snap blieb 2D-exklusiv). Nutzer nennt konkret: **Wände** beim Verschieben auf andere Punkte snappen, **Decken** ebenso, „eigentlich alle Elemente". Gewünscht ist also eine gemeinsame 3D-Snap-Schicht (Kandidatenpunkte: Wand-Endpunkte/Ecken, Decken-Eckpunkte, Extrusions-/Öffnungs-Anker, evtl. Rasterpunkte), gegen die der gezogene Griff/Körper im Weltraum einrastet — analog zur 2D-Snap-Infrastruktur (`computeSnap` in `src/tools/snapping.ts`). Zu klären mit Nutzer/Scope: welche Snap-Ziele in 3D (nur Endpunkte oder auch Kanten/Flächen/Raster?), Snap-Radius im Bildschirm- vs. Weltraum, visuelles Feedback (3D-Snap-Marker), und ob die 2D-`computeSnap`-Kandidatenlogik wiederverwendbar ist oder eine eigene 3D-Variante braucht. Betrifft `Wasm3DViewport.tsx` (Drag-Pfad) + neue 3D-Snap-Hilfsschicht; die Store-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) bleiben.
- [~] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich** (`0ca3b1d`, Schachbrett). ✅ **Farb-Textur-Array implementiert 2026-07-08 (wartet auf visuelle Nutzer-Verifikation nach WASM-Rebuild):** Statt Schachbrett zeigt eine Wand mit zugewiesenem Material jetzt dessen Farb-Map. Voller Weg: `WallInput.materialIndex: Option<u32>` (1-basiert; 0 = Schachbrett-Fallback) → `mesh.rs` `build_scene_mesh_textured()` füllt je Vertex die Material-Ebene (dieselben Extrusionsfns → Geometrie-Parität) → `gpu.rs`: Schachbrett-Textur zu `texture_2d_array` erweitert, `set_material_textures()` baut `[Schachbrett, Mat0, Mat1, …]`, zweiter Vertex-Buffer für die Ebene → `shaders.rs` `MESH_TEXTURED_WGSL` samplt die Array-Ebene je Band (`layer<0`→Schachbrett) → `web.rs` WASM-Export `set_material_textures(rgba, layer_count)` → TS: `wallMaterialColorMaps()`/`resolveComponentMaterialLayer()` (deterministische Array-Ordnung), neuer `src/viewport/materialTextures.ts` dekodiert die Farb-Maps im Browser (ImageBitmap→Canvas→getImageData 256²), `useWasm3dRenderer.updateModel` lädt sie asynchron hoch (Signatur-Cache, Fallback auf Schachbrett bis geladen). **Ehrlich als Rest:** nur Farb-Map (keine Normal-/Roughness-/Metalness → Beleuchtung wie Shaded); der native Tauri-Push (`nativeSync.ts`) lädt keine Texturen → dort Schachbrett-Fallback (das WASM/Nordstern-Viewport im „Texturiert"-Modus hat den vollen Weg). `cargo check --target wasm32 --features web` grün, `cargo test` 63 grün. **WASM noch NICHT gebaut (Netz) — Nutzer: `npm run build:engine3d` + Tauri-Neustart.** **Verbleibende PBR-Lücken (~1525 PT):** Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~58 · Mipmaps (Blit-Pass) + anisotropes Filtern ~12 · Bild-Datei-Laden serverseitig statt Browser-Dekodierung ~12 · nativer Tauri-Textur-Push ~23 · UI/Persistenz-Feinschliff ~58. **Noch uncommittet.**
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~**bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung. - [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~**bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau). - [x] ~~**Ortho-Ray-Picking**~~**erledigt 2026-07-07:** `cameraRay` (`src/viewport/raycast3d.ts`) war IMMER ein perspektivischer Pinhole-Strahl (ein Ursprung `eye`, Richtung variiert je Pixel) — für Front/Top/Side/Iso-Presets (die render3d orthografisch zeichnet) nur eine Näherung. Fix: `cameraRay` nimmt jetzt optional `perspective`/`orthoHalfHeight` (neue optionale Felder auf `RayCamera`, rückwärtskompatibel — fehlt `perspective`, bleibt das Verhalten exakt wie vorher) und baut bei `perspective:false` echte PARALLELE Strahlen (Ursprung wandert lateral mit dem Pixel, Richtung immer `f` — exakte Umkehrung von `worldToScreen`s bereits vorhandenem Ortho-Zweig). Beide Aufrufer in `Wasm3DViewport.tsx` (Klick-Pick + Griff-Drag) reichten schon das volle `orbitCamera(o)`-Objekt (inkl. `perspective`/`orthoHalfHeight`) durch — der Bug sass rein in `cameraRay`, keine Änderung an den Aufrufstellen nötig. +2 Tests (`raycast3d.test.ts`: parallele Strahlen, Rückwärtskompatibilität ohne `perspective`-Feld). `tsc`/`vitest` 341/341 grün.
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit). - [x] ~~**3D-Griffe/Editieren im nativen render3d/wasm-Pfad**~~**erledigt 2026-07-06:** existierte bereits vollständig im three.js-Fallback (`Viewport3D.tsx`/`drawGrips`), fehlte aber im nativen wasm-Pfad (`Wasm3DViewport.tsx`, seit `WASM_ENGINE_ACTIVE` der Standard-Renderer) — genau wie beim drawings2d-z0-Fund oben eine „existiert, aber auf dem falschen Pfad"-Lücke. Feasibility-Spike (Nutzer-Entscheid „Spike jetzt starten") zuerst nur Wand-Endpunkte, dann auf volle Parität ausgebaut: Wand-Endpunkt-/Höhen-/Verschiebe-Griff + 2D-Zeichnungs-Vertex-/Verschiebe-Griff, dieselben App.tsx-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) wie die three.js-Sicht. **Wichtige Design-Korrektur unterwegs:** erster Versuch zeichnete Griff-Marker als In-Szene-Geometrie (3-Achsen-Kreuz, dann Drahtgitter-Kugel) über den bestehenden `setHighlightLines`-Kanal — Nutzer-Feedback zweimal `"immernoch kein GUI button sondern geometrie"`: nicht klar genug als klickbarer Punkt erkennbar UND farblich identisch mit dem Auswahl-Umriss auf derselben Ecke. Gelöst durch Umstieg auf ECHTE DOM-Buttons (`position:absolute`-Kreise über dem Canvas, Farben wie drei.js: Vertex orange/Höhe blau/Verschieben grün), deren Bildschirmposition ein `requestAnimationFrame`-Loop aus der aktuellen Kamera nachführt (`worldToScreen`, neue Umkehrfunktion zu `cameraRay` in `raycast3d.ts`) — kein WASM/GPU-Push nötig, nur 2D-Projektionsmathe. Ziehen läuft über `rayPlaneY`/`rayPlane` (neu in `raycast3d.ts`) + dieselben Store-Callbacks. `drawingGripVertices`/`drawingMoveAnchor` nach `src/viewport/drawingGrips.ts` ausgelagert (gemeinsam von beiden Renderern genutzt, sonst zyklischer Import). **Bewusst NICHT gebaut:** Snap/Koinzidenz-Mitführen beim 3D-Ziehen (bleibt three.js-exklusiv). `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (Wand-Endpunkt-Drag funktional in der Tauri-App getestet, dann Marker-Sichtbarkeit korrigiert). **Noch uncommittet.**
## ❓ Offene Rückfragen (an den Nutzer) ## ❓ Offene Rückfragen (an den Nutzer)
@@ -111,6 +178,20 @@
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._ _Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
- [x] 2026-07-07 **IFC4-Export (erste Scheibe)** — reiner Kern `src/export/exportIfc.ts` (`exportIfcSpf(project)→string`, kein WASM/Dependency, direkt IFC-SPF-Text, Muster wie exportDxf). Räumliche Hierarchie IfcProject→IfcSite→IfcBuilding→IfcBuildingStorey (je `kind:"floor"`), Elemente via IfcRelContainedInSpatialStructure. Geometrie durchgängig IfcExtrudedAreaSolid: Wand→IfcWall, Decke→IfcSlab, Öffnung→IfcOpeningElement+IfcRelVoidsElement + Tür/Fenster-Füllung+IfcRelFillsElement, Extrusion→IfcBuildingElementProxy, Treppe→IfcStair (vereinfachter Hüllkörper, keine Stufengeometrie). IFC-22-Zeichen-GUIDs deterministisch aus Element-id (FNV→128bit→IFC-Base64, gegen IfcOpenShell-Referenz verifiziert). SI-Einheiten, OwnerHistory. Quick-Access-Button (`deployed_code`) + `onExportIfc` in App.tsx (Blob `model/ifc`, `<name>.ifc`) + i18n `file.exportIfc`. 8 Tests (wichtigster: KEINE dangling refs — jede `#N`-Referenz definiert + eindeutig). vitest 391. **Bewusst offen/vereinfacht:** IfcMaterialLayerSet (DirectionSense/Offset-Semantik ohne Viewer zu riskant — Folge-Item), Treppen-Stufengeometrie, Tür/Fenster nutzt dieselbe Box wie die Öffnung (kein Rahmen/Blatt). `host.ts` bewusst NICHT angefasst (Export-Callbacks leben in TopBarProps, nicht PanelHostValue — konsistent mit DXF/Schedule). **⚠️ Unit-Tests beweisen nur STRUKTURELLE STEP-Validität — Nutzer muss die .ifc in echtem Viewer (BIMcollab Zoom / IFC.js / Revit) gegenprüfen.** **Noch uncommittet.** (STL/OBJ-Export + IFC-Import + DWG-Schreiben bleiben offen — s. Interop-Befund.)
- [x] 2026-07-07 **ObjectInfo: Volumen/Länge bei Wand + Volumen bei Decke** (Nutzer-Wunsch) — `selectionInfo.ts`: WallInfo um `length`/`grossVolume`/`openingVolume`/`netVolume` (Netto = Länge×Dicke×Höhe Σ Öffnungen[Breite×Höhe×Dicke]), CeilingInfo um `volume` (Fläche×Dicke). Panel zeigt bei Wand Länge + Netto-Volumen (Tooltip Brutto−Öffnungen), bei Decke Fläche + Volumen. +i18n `objinfo.volume`/`objinfo.wall.length`/`objinfo.wall.volumeGross`. tsc/vitest 391. **Noch uncommittet.**
- [x] 2026-07-07 **UI-Feinschliff-Runde (Nutzer-Session, Tauri live abgenommen):** (1) Kamera-Settings (FOV-Popover) ZUSÄTZLICH oben in der TopBar-Chrome neben CSV, dafür UNTEN aus beiden Ribbon-Tabs entfernt → unten saubere 2×4 View-Icons (Nutzer-Korrektur: erst waren fälschlich alle Presets mit oben). (2) ObjectInfo: Bezugspunkt-Würfel fix quadratisch (64px, skaliert nicht mehr mit Panelbreite), X/Y/Z als Spalte rechts daneben (`objinfo-refrow`/`objinfo-xyzcol`). (3) Wand/Decken-UK/OK-Unterzeile in normale Label/Wert-Struktur: „Referenzgeschoss" + Dropdown in der Wertspalte, bei Eigene „Eigene Höhe" linksbündig (+2 i18n-Keys `objinfo.anchor.*`). (4) Ansichten-Ribbon: Detailgrad + Darstellung übereinander gestapelt (eine `tb-stack`-Gruppe), alte separate Darstellungs-Gruppe entfernt. tsc sauber, vitest 377/377. **Noch uncommittet.**
- [x] 2026-07-07 **Basis-Materialien mit Default-Textur** (Nutzer-Wunsch „nicht dass ich sie zuweisen muss und jeder Reset zerstört es") — Seed-Components in `src/model/sampleProject.ts` tragen ab Werk `material` (via `materialFromAsset`, exakt der ResourceManager-Zuweisungsweg): Aussen-/Innenputz→Plaster001, Backstein→Bricks104, Beton→Concrete048. Bewusst OHNE: Dämmung + Estrich (kein passendes Asset in der 12er-Bibliothek — nicht geraten). Alt-Projekte unberührt (Feld optional). three.js-Viewport rendert automatisch texturiert; nativer Renderer weiterhin ohne Texturen (bekannter 3D-REST). tsc sauber, vitest 377/377. **Noch uncommittet.**
- [x] 2026-07-07 **Element-Übersicht / BIM-Tree-Panel** (ROADMAP §11, Phase-1) — neues `src/panels/ElementTreePanel.tsx`: dreistufiger Baum Geschoss→Bauteilklasse→Element aus `scheduleRows()` (Textfilter mit Auto-Aufklappen; Klick = selektieren, Shift/Doppelklick = selektieren+zoomen). `ScheduleRow.floorId` additiv (CSV unverändert), Host-Callback `onSelectScheduleRow` (App.tsx setzt korrekten Selektionskanal, wechselt bei Bedarf Geschoss + defert 1 rAF gegen den Geschosswechsel-Reset). Registriert in `builtinPanels.tsx` + Default-Rechts-Dock (`LAYOUT_VERSION` 8→9). Legacy `project.doors` bewusst ausgefiltert (tot; echte Türen sind `Opening kind=door`). +i18n `elements.*`. vitest 377/377, tsc sauber. **Bekannte Grobheit:** Zoom nutzt `fit()` (ganzer Plan) statt `fitSelection()`, weil letzteres nur lokal geklickte Indizes kennt und `PlanView.tsx` gesperrt war — Element wird per Auswahl-Outline sichtbar. **Nebenbei:** NUL-Byte in `exportSchedule.ts` (Kollision paralleler Schreibzugriffe) repariert. **Noch uncommittet.**
- [x] 2026-07-07 **Decken-UK-Verdrahtung nachgezogen** (Orchestrator) — `onSetCeilingBottom`-Handler in App.tsx ergänzt (Muster `onSetCeilingTop`); das UI-Feld aus dem Decken-UK/OK-Item ist damit funktional statt inert. tsc sauber, vitest 377/377.
- [x] 2026-07-07 **Kamera-Presets: Kardinal + Iso-Oktanten** (Milestone B3-Teil, OHNE Nord-Rotation — die bleibt Nutzer-Entscheid) — `View3d` jetzt front/back/side(=Rechts)/left/top/iso + 3 weitere obere Iso-Oktanten (vorne-links/hinten-rechts/hinten-links; untere 4 bewusst weggelassen) + perspective. Rust `CameraPreset`/`preset_camera`/`web.rs` synchron erweitert (+2 Tests, cargo render3d 60/60), three.js-Pfad (`Viewport3D.tsx`) in Parität, `VIEW3D_ORBIT`-Seeds analytisch. UI: 4 Kardinal-Buttons + Iso-Split-Button mit Oktanten-Popover (ViewRibbonTab + Ribbon3dTab). WASM-Rebuild sauber, tsc sauber, vitest 372/372. **Visuelle Abnahme im Tauri offen.** **Noch uncommittet.**
- [x] 2026-07-07 **Decken UK/OK-Override** (ROADMAP §11, Phase-1-Item) — `Ceiling.bottom?: VerticalAnchor` (additiv), `ceilingVerticalExtent` löst bottom-Anchor vor `zTopthickness` auf (exakt das Wand-Muster); wirkt automatisch in 3D (`toWalls3d`) UND Schnitt (`toSection`), da beide denselben Resolver konsumieren. ObjectInfo: UK-Zeile jetzt editierbare `VerticalAnchorRow` (i18n-Key existierte). Neue Tests `model/wall.test.ts` (5). vitest 377/377, tsc sauber. **⚠️ Folge-Zeile:** `onSetCeilingBottom`-Handler in App.tsx (~5 Zeilen, Muster onSetCeilingTop) war ausserhalb der Agent-Lane — wird vom Orchestrator nachverdrahtet, bis dahin ist das UI-Feld inert. **Noch uncommittet.**
- [x] 2026-07-07 **A1 Override-Regel-Engine** (Top-Item des DOSSIER-Audits) — `OverrideRule` (condition: layer_name|object_name × equals|contains|starts_with|not_equals → actions: color/lineweight/linetypeId) additiv an `Project.overrideRules`; pure Engine `src/overrides/engine.ts` (`effectiveOverrides`: additiv, oberste aktive Regel gewinnt pro Feld, case-insensitiv, layer_name matcht Name ODER Code); Einhängung als Render-Dekoration in `generatePlan.ts` NACH der By-Layer/By-Object-Kette (Wände/Decken/Räume/Öffnungen/Treppen/Drawing2D; Projektdaten unverändert, jederzeit reversibel); ResourceManager-Tab „Overrides" (Master-Detail, Prioritätsliste ▲/▼, Aktiv-Checkbox). +18 Tests (engine 12, generatePlan-Integration 6). vitest 372/372, tsc sauber. **Bewusst offen:** user_string-Bedingung (kein Tag-Feld am Element), 3D-/Schnitt-Pfad, Presets/Templates (C2), Drag-Reorder; Overrides-Tab im ANGEDOCKTEN ResourcesPanel-Adapter vorerst nur lesend (Handler optional gehalten — Mini-Folge-Item). **Noch uncommittet.**
- [x] 2026-07-07 **SIA-416: AGF-Kategorie ergänzt** (Lücken-Schluss aus dem Milestone-Abgleich) — `SiaCategory`+`AGF` in `geometry/roomArea.ts`, Rollup NUR in GF (nicht NF/NGF): Bilanz-Reihenfolge HNF·NNF·NF(Σ)·VF·FF·NGF(Σ)·KGF·AGF·GF(Σ), GF = NGF+KGF+AGF. Dropdown/Bilanz-Panel/CSV übernehmen generisch ohne Änderung. Neue Testdatei `roomArea.test.ts` (8 Tests). vitest 366/366 grün. **Noch uncommittet.**
- [x] 2026-07-07 **D2 Bauteil-CSV volles Element-Set**`exportSchedule.ts` deckt jetzt Tür/Fenster/Öffnung/Treppe/Extrusion zusätzlich zu Wand/Decke ab, exakt nach dem in HANDOVER.md hinterlegten Scope (Tür/Öffnung: Breite/Höhe/Fläche, Geschoss via Wirtswand; Treppe: Lauflänge + totalRise, Fläche bewusst leer; Extrusion: Höhe + polygonArea, Geschoss via levelId; Räume weiterhin im eigenen Raum-CSV). Aggregat-Zeile generalisiert (`totalLength`/`totalArea` > 0 ? Wert : leer — kein irreführendes 0.00 bei Treppe). Tests erweitert (Fixture + Zeilen-Asserts je neuem Element). `tsc` sauber, `vitest` 341/341 grün. **Noch uncommittet.**
- [x] 2026-07-06 **`extrude`-Befehl im 3D-Ribbon-Tab** — `Ribbon3dTab` (`src/ui/TopBar.tsx`) kannte nur Kamera/Darstellungsart, nicht den seit Phase 3 existierenden `extrude`-Befehl; neue Gruppe mit Befehls-Button (gleiches Muster wie `RibbonButton`s Befehls-Zweig: `CommandIcon` + Label aus der Registry, deaktiviert ohne aktives Geschoss, aktiv-Highlight bei laufendem Befehl). `onRunCommand`/`activeCommand` neu durchgereicht (App.tsx). tsc sauber.
- [x] 2026-07-06 **truck-Integration Phase 4 (Boolean) — Mesh-CSG via `csgrs` umgesetzt, technisch bewiesen** (Details s. oben bei „Phase 4"): zweiter Spike (`csgrs`, Mesh-Ebenen-BSP statt B-Rep) löst exakt den Fall, an dem `monstertruck-solid` scheiterte; nutzerautorisiert als echte (Git-gepinnte) Abhängigkeit in `trucksolid` eingebaut (`boolean.rs`, WASM-Export `boolean_mesh`, TS-Wrapper `booleanMesh()`). `cargo test` 11/11, `wasm32-unknown-unknown`-Build + echter `wasm-pack`-Build sauber. UI-Verdrahtung (wann automatisch subtrahieren) bewusst offen gelassen — Scope-Entscheidung, kein Technik-Risiko mehr.
- [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337. - [x] 2026-07-05 **Schnitt/Ansicht Phase 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie**`addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe". - [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie**`addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün. - [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
+19 -9
View File
@@ -1,16 +1,16 @@
# DOSSIER Standalone # DOSSIER Standalone
Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne — aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
ohne Revit. ohne Revit. Als Desktop-App und im Browser zugänglich; die Desktop-App ist die
vollständige Fassung.
Das ist die eigenständige Standalone-Variante des Rhino-Plugins Das ist die eigenständige Standalone-Variante des Rhino-Plugins
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse, [DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
React/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz. React-App mit eigener Rendering-Engine (Rust/WASM/WebGPU) statt als Rhino-Aufsatz.
Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine Als Desktop-App (Tauri) läuft sie im eigenen Fenster mit voller Engine-Leistung;
(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview browserseitig ist derselbe Kern zugänglich, die App ist die vollständige Fassung.
kleinster gemeinsamer Nenner.
## Grundgedanke ## Grundgedanke
@@ -151,7 +151,17 @@ src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WAS
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte - [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …) - [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
## Lizenz
Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der
**GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)**
siehe [LICENSE](LICENSE). DOSSIER ist Teil der **openbureau**-Suite.
Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf.
geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten
behalten ihre jeweiligen Lizenzen (siehe „Über"-Dialog in der App).
--- ---
Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten)
Swisstopo-Geodaten) sind als Differenzierer eingeplant. sind als Differenzierer eingeplant.
+20
View File
@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@mlightcad/libredwg-web": "^0.7.7", "@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"delaunator": "^5.1.0", "delaunator": "^5.1.0",
"dxf-parser": "^1.1.2", "dxf-parser": "^1.1.2",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -1842,6 +1844,24 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tweenjs/tween.js": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+13 -1
View File
@@ -3,6 +3,8 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"license": "AGPL-3.0-or-later",
"author": "Karim Gabriele Varano",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
@@ -19,11 +21,14 @@
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web", "build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web", "build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web", "build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web" "build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
}, },
"dependencies": { "dependencies": {
"@mlightcad/libredwg-web": "^0.7.7", "@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"delaunator": "^5.1.0", "delaunator": "^5.1.0",
"dxf-parser": "^1.1.2", "dxf-parser": "^1.1.2",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@@ -48,5 +53,12 @@
"vite": "^5.4.8", "vite": "^5.4.8",
"vitest": "^4.1.9", "vitest": "^4.1.9",
"wasm-pack": "^0.15.0" "wasm-pack": "^0.15.0"
},
"allowScripts": {
"core-js@3.49.0": true,
"esbuild@0.21.5": true,
"fsevents@2.3.3": true,
"fsevents@2.3.2": true,
"wasm-pack@0.15.0": true
} }
} }
+173 -3
View File
@@ -384,6 +384,7 @@ dependencies = [
name = "cad-tauri" name = "cad-tauri"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fs4",
"geometry", "geometry",
"pollster", "pollster",
"render2d", "render2d",
@@ -392,6 +393,8 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"wgpu", "wgpu",
"winit", "winit",
] ]
@@ -982,7 +985,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [ dependencies = [
"libloading 0.7.4", "libloading 0.8.9",
] ]
[[package]] [[package]]
@@ -1304,6 +1307,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs4"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@@ -1690,7 +1703,7 @@ dependencies = [
"log", "log",
"presser", "presser",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows 0.61.3", "windows 0.62.2",
] ]
[[package]] [[package]]
@@ -2892,6 +2905,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"block2 0.6.2", "block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -3705,6 +3719,30 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2 0.6.2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.20.0" version = "0.20.0"
@@ -4538,6 +4576,64 @@ dependencies = [
"tauri-utils", "tauri-utils",
] ]
[[package]]
name = "tauri-plugin"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
dependencies = [
"anyhow",
"glob",
"plist",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri-utils",
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation 0.3.2",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -5946,6 +6042,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -5979,13 +6084,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]] [[package]]
name = "windows-threading" name = "windows-threading"
version = "0.1.0" version = "0.1.0"
@@ -6025,6 +6147,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -6037,6 +6165,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.42.2" version = "0.42.2"
@@ -6049,12 +6183,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.42.2" version = "0.42.2"
@@ -6067,6 +6213,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.42.2" version = "0.42.2"
@@ -6079,6 +6231,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.42.2" version = "0.42.2"
@@ -6091,6 +6249,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.42.2" version = "0.42.2"
@@ -6103,6 +6267,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winit" name = "winit"
version = "0.30.13" version = "0.30.13"
+7 -1
View File
@@ -8,7 +8,7 @@ members = ["."]
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat). # wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts), # kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace. # per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"] exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
[package] [package]
name = "cad-tauri" name = "cad-tauri"
@@ -37,9 +37,15 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
geometry = { path = "geometry" } geometry = { path = "geometry" }
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
fs4 = { version = "0.9", default-features = false, features = ["sync"] }
# --- M2-Spike (nur mit Feature "native2d") ----------------------------------- # --- M2-Spike (nur mit Feature "native2d") -----------------------------------
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden # render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
+3 -1
View File
@@ -11,6 +11,8 @@
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-start-dragging" "core:window:allow-start-dragging",
"dialog:allow-save",
"fs:allow-write-text-file"
] ]
} }
+394
View File
@@ -2470,6 +2470,223 @@ pub fn room_from_point_inside_faces(
best best
} }
// --- Oeffnungs-Geometrie (Port von src/geometry/opening.ts, Slice 4) --------
// `Wall`/`Opening`/`Project`-Kopplung durch Vec2/number-Parameter ersetzt
// (PORT_PLAN §1 "Teilweise im Scope"): `thickness`/`refOffset` werden hier als
// bereits aufgeloeste Zahlen uebergeben (TS: `wallTypeThickness(getWallType(...))`
// bzw. `wallReferenceOffset(wall, total)` gegen ein `Project`). Nur
// `openingVerticalExtent` bleibt TS (haengt an `Project.drawingLevels` via
// `wallVerticalExtent`) und wird hier NICHT portiert.
//
// `DEG` (Grad→Radiant, Port des lokalen `DEG` in opening.ts) ist bereits als
// modul-weite Konstante aus dem stair-Slice vorhanden (identischer Wert).
/// Punkt entlang der Strecke p1→p2 im Abstand d vom Start (Port von `along`
/// aus src/model/geometry.ts).
pub fn along(p1: Vec2, p2: Vec2, d: f64) -> Vec2 {
add(p1, scale(normalize(sub(p2, p1)), d))
}
/// Achslaenge einer Wand (Port von `wallAxisLength`; Wand → start/end).
pub fn wall_axis_length(start: Vec2, end: Vec2) -> f64 {
(end.x - start.x).hypot(end.y - start.y)
}
/// Oeffnungs-Intervall entlang der Achse (Port von `openingInterval`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Interval {
pub from: f64,
pub to: f64,
}
pub fn opening_interval(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Interval> {
let axis = wall_axis_length(start, end);
let from = position.min(axis).max(0.0);
let to = (position + width).min(axis).max(from);
if to - from < 1e-4 {
return None; // entartet: Breite <= 0 oder vollstaendig ausserhalb der Achse
}
Some(Interval { from, to })
}
/// Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse (Port von
/// `wallAxisFrame`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub struct AxisFrame {
pub u: Vec2,
pub n: Vec2,
}
pub fn wall_axis_frame(start: Vec2, end: Vec2) -> AxisFrame {
let u = normalize(sub(end, start));
AxisFrame { u, n: left_normal(u) }
}
/// Die beiden Pfosten-Punkte einer Oeffnung im Grundriss (Port von `openingJambs`).
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Jambs {
pub jamb_start: Vec2,
pub jamb_end: Vec2,
}
pub fn opening_jambs(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Jambs> {
let iv = opening_interval(start, end, position, width)?;
Some(Jambs {
jamb_start: along(start, end, iv.from),
jamb_end: along(start, end, iv.to),
})
}
/// Luecken-Rechteck-Ecken ueber die volle Wanddicke (Port von `openingGapQuad`).
/// Reihenfolge: jambStart(+n/2) → jambEnd(+n/2) → jambEnd(n/2) → jambStart(n/2).
pub fn opening_gap_quad(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
) -> Option<Vec<Vec2>> {
let jambs = opening_jambs(start, end, position, width)?;
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
Some(vec![
add(jambs.jamb_start, scale(n, outer)),
add(jambs.jamb_end, scale(n, outer)),
add(jambs.jamb_end, scale(n, inner)),
add(jambs.jamb_start, scale(n, inner)),
])
}
/// Mittelpunkt einer Oeffnung im Grundriss (Port von `openingCenter`).
pub fn opening_center(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Vec2> {
let iv = opening_interval(start, end, position, width)?;
Some(along(start, end, (iv.from + iv.to) / 2.0))
}
/// Aufgeloeste Tuer-Symbol-Geometrie im Grundriss (Port von `DoorSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DoorSymbol {
pub hinge: Vec2,
pub open_end: Vec2,
pub closed_end: Vec2,
pub radius: f64,
pub jamb_start: Vec2,
pub jamb_end: Vec2,
pub normal: Vec2,
}
/// Port von `doorSymbol`. `swing`/`opening_dir`/`hinge_side` folgen der TS-
/// `??`-Fallback-Semantik (`None` → TS-Default); `swing_angle` `None` → 90°.
pub fn door_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
swing: Option<&str>,
opening_dir: Option<&str>,
hinge_side: Option<&str>,
swing_angle: Option<f64>,
) -> Option<DoorSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let frame = wall_axis_frame(start, end);
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
// TS berechnet die Breite NEU aus den geklemmten Pfosten (nicht das rohe
// `o.width`) — bewusst repliziert.
let w = (jamb_end.x - jamb_start.x).hypot(jamb_end.y - jamb_start.y);
let swing_sign = if swing.unwrap_or("left") == "left" { 1.0 } else { -1.0 };
let dir_sign = if opening_dir.unwrap_or("in") == "in" { 1.0 } else { -1.0 };
let swing_dir = scale(frame.n, swing_sign * dir_sign);
let hinge_at_start = hinge_side.unwrap_or("start") == "start";
let hinge = if hinge_at_start { jamb_start } else { jamb_end };
let closed_dir = if hinge_at_start { frame.u } else { scale(frame.u, -1.0) };
let angle = swing_angle.unwrap_or(90.0) * DEG;
let open_dir = Vec2 {
x: closed_dir.x * angle.cos() + swing_dir.x * angle.sin(),
y: closed_dir.y * angle.cos() + swing_dir.y * angle.sin(),
};
Some(DoorSymbol {
hinge,
open_end: add(hinge, scale(open_dir, w)),
closed_end: add(hinge, scale(closed_dir, w)),
radius: w,
jamb_start,
jamb_end,
normal: frame.n,
})
}
/// Aufgeloeste Fenster-Linien im Grundriss (Port von `WindowSymbol`).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct WindowSymbol {
pub frame: Vec<Vec2>,
pub glass_lines: Vec<(Vec2, Vec2)>,
pub mullion_lines: Vec<(Vec2, Vec2)>,
}
/// Port von `windowSymbol`. `thickness`/`ref_offset` sind bereits aufgeloeste
/// Werte (siehe Modul-Kommentar); `wing_count` `None` → TS-Default `1`.
pub fn window_symbol(
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
wing_count: Option<f64>,
) -> Option<WindowSymbol> {
let jambs = opening_jambs(start, end, position, width)?;
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
let n = wall_axis_frame(start, end).n;
let outer = ref_offset + thickness / 2.0;
let inner = ref_offset - thickness / 2.0;
let frame_quad = vec![
add(jamb_start, scale(n, outer)),
add(jamb_end, scale(n, outer)),
add(jamb_end, scale(n, inner)),
add(jamb_start, scale(n, inner)),
];
let mut glass_lines: Vec<(Vec2, Vec2)> = Vec::new();
let count = glass_count.max(1.0);
let mut i: i64 = 0;
while (i as f64) < count {
let spread = thickness / 8.0;
let off = if count == 1.0 {
ref_offset
} else {
ref_offset + ((i as f64) - (count - 1.0) / 2.0) * spread * 2.0
};
glass_lines.push((add(jamb_start, scale(n, off)), add(jamb_end, scale(n, off))));
i += 1;
}
let mut mullion_lines: Vec<(Vec2, Vec2)> = Vec::new();
let wings = wing_count.unwrap_or(1.0).round().min(4.0).max(1.0);
if wings > 1.0 {
let post_depth = thickness * 0.6;
let p_outer = ref_offset + post_depth / 2.0;
let p_inner = ref_offset - post_depth / 2.0;
let wings_n = wings as i64;
for k in 1..wings_n {
let t = k as f64 / wings;
let on_axis = add(jamb_start, scale(sub(jamb_end, jamb_start), t));
mullion_lines.push((add(on_axis, scale(n, p_outer)), add(on_axis, scale(n, p_inner))));
}
}
Some(WindowSymbol { frame: frame_quad, glass_lines, mullion_lines })
}
// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ---------------------------- // --- Batch-Fassaden: roomArea / ceiling (Slice 1) ----------------------------
#[cfg(feature = "web")] #[cfg(feature = "web")]
@@ -2741,6 +2958,183 @@ pub fn rb_point_in_polygon_batch_json(input_json: &str) -> Result<String, wasm_b
to_js(&out) to_js(&out)
} }
// --- Batch-Fassaden: opening (Slice 4) ---------------------------------------
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WallAxisLengthQuery {
start: Vec2,
end: Vec2,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_length_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<f64> = qs.iter().map(|q| wall_axis_length(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_interval_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Interval>> = qs
.iter()
.map(|q| opening_interval(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn wall_axis_frame_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
let out: Vec<AxisFrame> = qs.iter().map(|q| wall_axis_frame(q.start, q.end)).collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_jambs_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Jambs>> = qs
.iter()
.map(|q| opening_jambs(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpeningGapQuadQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_gap_quad_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningGapQuadQuery> = from_js(input_json)?;
let out: Vec<Option<Vec<Vec2>>> = qs
.iter()
.map(|q| opening_gap_quad(q.start, q.end, q.position, q.width, q.thickness, q.ref_offset))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn opening_center_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<OpeningQuery> = from_js(input_json)?;
let out: Vec<Option<Vec2>> = qs
.iter()
.map(|q| opening_center(q.start, q.end, q.position, q.width))
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DoorSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
#[serde(default)]
swing: Option<String>,
#[serde(default)]
opening_dir: Option<String>,
#[serde(default)]
hinge: Option<String>,
#[serde(default)]
swing_angle: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn door_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<DoorSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<DoorSymbol>> = qs
.iter()
.map(|q| {
door_symbol(
q.start,
q.end,
q.position,
q.width,
q.swing.as_deref(),
q.opening_dir.as_deref(),
q.hinge.as_deref(),
q.swing_angle,
)
})
.collect();
to_js(&out)
}
#[cfg(feature = "web")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WindowSymbolQuery {
start: Vec2,
end: Vec2,
position: f64,
width: f64,
thickness: f64,
ref_offset: f64,
glass_count: f64,
#[serde(default)]
wing_count: Option<f64>,
}
#[cfg(feature = "web")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn window_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
console_error_panic_hook::set_once();
let qs: Vec<WindowSymbolQuery> = from_js(input_json)?;
let out: Vec<Option<WindowSymbol>> = qs
.iter()
.map(|q| {
window_symbol(
q.start,
q.end,
q.position,
q.width,
q.thickness,
q.ref_offset,
q.glass_count,
q.wing_count,
)
})
.collect();
to_js(&out)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
}], }],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}, },
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X, // "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
// also groessere Tiefe von der Betrachter-Ebene aus), genau im // also groessere Tiefe von der Betrachter-Ebene aus), genau im
@@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
@@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
z_bottom: -0.2, z_bottom: -0.2,
z_top: 0.0, z_top: 0.0,
color: [0.86, 0.86, 0.88], color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}]; }];
(walls, slabs) (walls, slabs)
} }
+3
View File
@@ -40,6 +40,9 @@ fn demo_walls() -> Vec<WallInput> {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
// Raum 6 x 4 m. // Raum 6 x 4 m.
vec![ vec![
+278 -27
View File
@@ -15,12 +15,17 @@ use wgpu::util::DeviceExt;
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX}; use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX}; use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
use crate::math::{view_projection, Mat4}; use crate::math::{view_projection, Mat4};
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh}; use crate::mesh::{
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured,
};
use crate::section::SectionPlane; use crate::section::SectionPlane;
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX}; 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_TEXTURED_WGSL, MESH_WGSL};
use crate::types::{ use crate::types::{
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
TEXTURED_FLOATS_PER_VERTEX,
}; };
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/ /// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
@@ -142,6 +147,10 @@ struct MeshBuffers {
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet. /// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
struct TexturedMeshBuffers { struct TexturedMeshBuffers {
vbo: wgpu::Buffer, vbo: wgpu::Buffer,
/// Zweiter Vertex-Buffer: EIN f32 (Material-Textur-Ebene) je Vertex,
/// @location(3) im `MESH_TEXTURED_WGSL`. Immer gesetzt (Fallback: `-1` je
/// Vertex), damit das Pipeline-Layout stets zwei Vertex-Buffer erwartet.
layer_vbo: wgpu::Buffer,
ibo: wgpu::Buffer, ibo: wgpu::Buffer,
index_count: u32, index_count: u32,
} }
@@ -225,21 +234,39 @@ pub struct Renderer {
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene /// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None). /// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
cap_pipeline: wgpu::RenderPipeline, cap_pipeline: wgpu::RenderPipeline,
/// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): TriangleList von
/// Ribbon-Quads (`section_fill::build_cut_cap_lines`), Vertex-Layout [pos vec3,
/// color vec3] wie die Grid/Highlight-LineList — teilt darum das `grid_module`
/// (GRID_WGSL). KEIN Tiefentest (`depth_compare: Always`, kein Write) wie das
/// Highlight: die Fugenlinien liegen (leicht zur Kamera versetzt) sichtbar auf
/// den Cut-Caps. Sie werden NICHT an der Schnittebene weggeclippt (sie SIND die
/// Schnittflaeche) — der Kamera-Lift in `build_cut_cap_lines` haelt sie vor der
/// `depth > 0`-Kappung von GRID_WGSL.
cut_line_pipeline: wgpu::RenderPipeline,
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList, /// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet /// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel /// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur /// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
/// Shader/Layout unterscheiden sich. /// Shader/Layout unterscheiden sich.
textured_pipeline: wgpu::RenderPipeline, textured_pipeline: wgpu::RenderPipeline,
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler. /// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende). /// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
texture_bind_group: wgpu::BindGroup, texture_bind_group: wgpu::BindGroup,
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim /// Das Textur-Array selbst. Start: 1 Ebene (Schachbrett). `set_material_textures`
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine /// ersetzt es durch ein Array `[Schachbrett, Material0, Material1, …]`.
/// Queue bekommt.
texture: wgpu::Texture, texture: wgpu::Texture,
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim /// Bind-Group-Layout (group 1), festgehalten fuer den Neubau der Bind-Group
/// ersten `render` einmalig gesetzt (lazy Upload). /// beim Hochladen von Material-Texturen.
texture_bind_group_layout: wgpu::BindGroupLayout,
/// Sampler (Repeat/Linear), festgehalten fuer den Bind-Group-Neubau.
texture_sampler: wgpu::Sampler,
/// Anzahl gueltiger Ebenen im Textur-Array (>= 1). 1 = nur Schachbrett; nach
/// `set_material_textures` = 1 + Anzahl Material-Karten. Wandert je Frame nach
/// `mode.z` (Klemmung der Ebenen-Auswahl im Shader).
material_layer_count: u32,
/// Ob die Schachbrett-Texel (Ebene 0) schon hochgeladen wurden. Beim ersten
/// `render` einmalig gesetzt (lazy Upload, weil `new` keine Queue bekommt).
texture_uploaded: bool, texture_uploaded: bool,
bind_group: wgpu::BindGroup, bind_group: wgpu::BindGroup,
uniform: wgpu::Buffer, uniform: wgpu::Buffer,
@@ -264,6 +291,10 @@ pub struct Renderer {
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht, /// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight. /// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
caps: Option<CapBuffers>, caps: Option<CapBuffers>,
/// 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.
cut_lines: Option<LineBuffers>,
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false — /// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter. /// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
grid_visible: bool, grid_visible: bool,
@@ -471,6 +502,52 @@ impl Renderer {
}, },
); );
// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): dasselbe schlanke
// LineList-Vertex-Layout [pos vec3, color vec3] und `grid_module` (GRID_WGSL)
// wie Grid/Highlight, aber als TriangleList (die Kanten sind Ribbon-Quads,
// damit die Strichstaerke als Welt-Breite durchkommt). Depth wie Highlight
// (Always, kein Write) -> immer sichtbar auf den Caps.
let cut_line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("cut_line.pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &grid_module,
entry_point: Some("vs_main"),
buffers: &[grid_vertex_layout.clone()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &grid_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,
// Ribbons sind flach in der Schnittebene, aus beiden Seiten sichtbar.
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::Always),
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,
});
// Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes // Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe // schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben. // Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
@@ -478,10 +555,14 @@ impl Renderer {
label: Some("cap.wgsl"), label: Some("cap.wgsl"),
source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()), source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()),
}); });
// Vertex-Layout: [pos vec3, uv vec2, hatch vec4(pattern, angle_rad, scale,
// line_weight_mm)], stride 9*4 (`CAP_FLOATS_PER_VERTEX`). Das dritte Attribut
// treibt die musterspezifische Schraffur im Cap-Fragment-Shader (CAP_WGSL);
// `line_weight_mm` setzt die Musterlinien-Breite je Schraffur.
let cap_vertex_layout = wgpu::VertexBufferLayout { let cap_vertex_layout = wgpu::VertexBufferLayout {
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64, array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
step_mode: wgpu::VertexStepMode::Vertex, step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2], attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4],
}; };
let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("cap.pipeline"), label: Some("cap.pipeline"),
@@ -547,7 +628,7 @@ impl Renderer {
visibility: wgpu::ShaderStages::FRAGMENT, visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture { ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true }, sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2, view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false, multisampled: false,
}, },
count: None, count: None,
@@ -579,6 +660,14 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex, step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2], attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
}; };
// ZWEITER Vertex-Buffer: die Material-Textur-Ebene je Vertex (@location(3),
// 1 f32). Separat gehalten, damit das Haupt-`[pos,normal,uv]`-Layout (und
// die Geometrie-Paritaet zum Shaded-Pfad) unveraendert bleibt.
let textured_layer_layout = wgpu::VertexBufferLayout {
array_stride: 4,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![3 => Float32],
};
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format, // WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline) // Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der // — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
@@ -590,7 +679,7 @@ impl Renderer {
vertex: wgpu::VertexState { vertex: wgpu::VertexState {
module: &textured_module, module: &textured_module,
entry_point: Some("vs_main"), entry_point: Some("vs_main"),
buffers: &[textured_vertex_layout], buffers: &[textured_vertex_layout, textured_layer_layout],
compilation_options: Default::default(), compilation_options: Default::default(),
}, },
fragment: Some(wgpu::FragmentState { fragment: Some(wgpu::FragmentState {
@@ -632,8 +721,10 @@ impl Renderer {
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue // `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs, // bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
// spike3d) unveraendert. // spike3d) unveraendert.
// Textur-ARRAY (D2Array). Start: EINE Ebene (Fallback-Schachbrett).
// `set_material_textures` ersetzt das Array spaeter durch mehrere Ebenen.
let texture = device.create_texture(&wgpu::TextureDescriptor { let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("checker.texture"), label: Some("materials.texture_array"),
size: wgpu::Extent3d { size: wgpu::Extent3d {
width: TEXTURE_SIZE, width: TEXTURE_SIZE,
height: TEXTURE_SIZE, height: TEXTURE_SIZE,
@@ -646,10 +737,13 @@ impl Renderer {
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}); });
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let texture_view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung). // Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
let sampler = device.create_sampler(&wgpu::SamplerDescriptor { let texture_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("checker.sampler"), label: Some("materials.sampler"),
address_mode_u: wgpu::AddressMode::Repeat, address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat, address_mode_v: wgpu::AddressMode::Repeat,
address_mode_w: wgpu::AddressMode::Repeat, address_mode_w: wgpu::AddressMode::Repeat,
@@ -659,7 +753,7 @@ impl Renderer {
..Default::default() ..Default::default()
}); });
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("texture.bind"), label: Some("materials.bind"),
layout: &texture_bind_group_layout, layout: &texture_bind_group_layout,
entries: &[ entries: &[
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
@@ -668,7 +762,7 @@ impl Renderer {
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 1, binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler), resource: wgpu::BindingResource::Sampler(&texture_sampler),
}, },
], ],
}); });
@@ -694,9 +788,13 @@ impl Renderer {
grid_pipeline, grid_pipeline,
highlight_pipeline, highlight_pipeline,
cap_pipeline, cap_pipeline,
cut_line_pipeline,
textured_pipeline, textured_pipeline,
texture_bind_group, texture_bind_group,
texture, texture,
texture_bind_group_layout,
texture_sampler,
material_layer_count: 1,
texture_uploaded: false, texture_uploaded: false,
bind_group, bind_group,
uniform, uniform,
@@ -706,6 +804,7 @@ impl Renderer {
grid: None, grid: None,
highlight: None, highlight: None,
caps: None, caps: None,
cut_lines: None,
grid_visible: false, grid_visible: false,
style: RenderStyle::Shaded, style: RenderStyle::Shaded,
depth: None, depth: None,
@@ -724,7 +823,7 @@ impl Renderer {
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch. /// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) { pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) {
self.upload_mesh(device, build_walls_mesh(walls)); self.upload_mesh(device, build_walls_mesh(walls), build_walls_mesh_textured(walls));
} }
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes /// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
@@ -736,13 +835,17 @@ impl Renderer {
slabs: &[SlabInput], slabs: &[SlabInput],
meshes: &[MeshInput], meshes: &[MeshInput],
) { ) {
self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes)); self.upload_mesh(
device,
build_scene_mesh(walls, slabs, meshes),
build_scene_mesh_textured(walls, slabs, meshes),
);
} }
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und /// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer /// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame. /// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) { fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
if mesh.indices.is_empty() { if mesh.indices.is_empty() {
self.mesh = None; self.mesh = None;
self.textured_mesh = None; self.textured_mesh = None;
@@ -782,11 +885,10 @@ impl Renderer {
index_count: mesh.indices.len() as u32, index_count: mesh.indices.len() as u32,
}); });
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je // Texturiertes Pendant (planare Welt-UV je Vertex + Material-Ebenen-Puffer,
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten // siehe `build_scene_mesh_textured`) in zwei Vertex-Puffer hochladen. Die
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad, // Geometrie bleibt bitgleich zum Alt-Pfad; der Stil `Textured` hat ohne
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit. // Re-Meshing seine Puffer bereit.
let tex_mesh = textured_from_mesh(&mesh);
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.vbo"), label: Some("mesh.textured.vbo"),
contents: bytemuck::cast_slice(&tex_mesh.verts), contents: bytemuck::cast_slice(&tex_mesh.verts),
@@ -797,8 +899,22 @@ impl Renderer {
contents: bytemuck::cast_slice(&tex_mesh.indices), contents: bytemuck::cast_slice(&tex_mesh.indices),
usage: wgpu::BufferUsages::INDEX, usage: wgpu::BufferUsages::INDEX,
}); });
// Material-Ebenen-Puffer (1 f32 je Vertex). Fehlt er (kein Material-Bau),
// mit `-1` je Vertex fuellen -> alle Vertices auf das Fallback-Schachbrett.
let vertex_count = tex_mesh.verts.len() / TEXTURED_FLOATS_PER_VERTEX;
let layer_data: Vec<f32> = if tex_mesh.layers.len() == vertex_count {
tex_mesh.layers
} else {
vec![-1.0; vertex_count]
};
let tlvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.textured.layer.vbo"),
contents: bytemuck::cast_slice(&layer_data),
usage: wgpu::BufferUsages::VERTEX,
});
self.textured_mesh = Some(TexturedMeshBuffers { self.textured_mesh = Some(TexturedMeshBuffers {
vbo: tvbo, vbo: tvbo,
layer_vbo: tlvbo,
ibo: tibo, ibo: tibo,
index_count: tex_mesh.indices.len() as u32, index_count: tex_mesh.indices.len() as u32,
}); });
@@ -918,6 +1034,27 @@ impl Renderer {
}); });
} }
/// Setzt/loescht die Schnitt-Umrisslinien (Cut-Cap-Kanten, P2). `verts` =
/// interleaved `[px,py,pz, r,g,b, ...]` (world-Meter + Farbe, TriangleList von
/// Ribbon-Quads, `CUT_LINE_FLOATS_PER_VERTEX`), wie `section_fill::
/// build_cut_cap_lines` liefert. Leeres Slice loescht die Linien. Wirkt erst
/// beim naechsten `render`.
pub fn set_cut_lines(&mut self, device: &wgpu::Device, verts: &[f32]) {
if verts.is_empty() {
self.cut_lines = None;
return;
}
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("cut_lines.vbo"),
contents: bytemuck::cast_slice(verts),
usage: wgpu::BufferUsages::VERTEX,
});
self.cut_lines = Some(LineBuffers {
vbo,
vertex_count: (verts.len() / CUT_LINE_FLOATS_PER_VERTEX) as u32,
});
}
/// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten) /// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten)
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und /// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` + /// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
@@ -936,11 +1073,110 @@ impl Renderer {
let plane = SectionPlane::new(point, normal); let plane = SectionPlane::new(point, normal);
let caps = build_cut_caps(&plane, walls, slabs); let caps = build_cut_caps(&plane, walls, slabs);
self.set_cut_caps(device, &caps); self.set_cut_caps(device, &caps);
// P2: die Schnitt-Umrisslinien (Fugen-/Materiallinien) je ueberlebendem
// Cut-Cap-Rechteck aus DERSELBEN, dominanz-verschnittenen Rechteck-Menge.
let lines = build_cut_cap_lines(&plane, walls, slabs);
self.set_cut_lines(device, &lines);
} else { } else {
self.set_cut_caps(device, &[]); self.set_cut_caps(device, &[]);
self.set_cut_lines(device, &[]);
} }
} }
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-ARRAY hoch.
/// `rgba` = dicht gepackte RGBA8-Bytes von `layer_count` Ebenen, jede exakt
/// `TEXTURE_SIZE × TEXTURE_SIZE` (der Aufrufer skaliert/dekodiert die Bilder
/// JS-seitig, siehe `web::set_material_textures`/`Wasm3DViewport`). Baut ein
/// neues Array `[Schachbrett (Ebene 0), Material0 (Ebene 1), …]`, laedt es hoch
/// und ersetzt die group-1-Bind-Group. Ein Wand-Band verweist per
/// `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.
pub fn set_material_textures(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
rgba: &[u8],
layer_count: u32,
) {
let bytes_per_layer = (TEXTURE_SIZE * TEXTURE_SIZE * 4) as usize;
// Ungueltige/leere Eingabe -> reines Schachbrett-Array (1 Ebene).
let valid = layer_count > 0 && rgba.len() >= layer_count as usize * bytes_per_layer;
let material_layers = if valid { layer_count } else { 0 };
let total_layers = material_layers + 1; // Ebene 0 = Schachbrett
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("materials.texture_array"),
size: wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: total_layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// Ebene 0: Schachbrett-Fallback.
let checker = build_checker_texture();
let write_layer = |layer: u32, data: &[u8]| {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * TEXTURE_SIZE),
rows_per_image: Some(TEXTURE_SIZE),
},
wgpu::Extent3d {
width: TEXTURE_SIZE,
height: TEXTURE_SIZE,
depth_or_array_layers: 1,
},
);
};
write_layer(0, &checker);
for i in 0..material_layers {
let start = i as usize * bytes_per_layer;
write_layer(i + 1, &rgba[start..start + bytes_per_layer]);
}
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
self.texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("materials.bind"),
layout: &self.texture_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.texture_sampler),
},
],
});
self.texture = texture;
self.material_layer_count = total_layers;
// Ebene 0 (Schachbrett) ist bereits geladen -> den Lazy-Upload ueberspringen.
self.texture_uploaded = true;
}
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert. /// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) { fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h); let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
@@ -1019,6 +1255,8 @@ impl Renderer {
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/ // `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal. // Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
self.globals.mode[0] = self.style.mesh_mode(); self.globals.mode[0] = self.style.mesh_mode();
// `mode.z` = Anzahl gueltiger Textur-Array-Ebenen (Klemmung im Textur-Shader).
self.globals.mode[2] = self.material_layer_count as f32;
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals)); queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine // Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
@@ -1100,6 +1338,7 @@ impl Renderer {
pass.set_bind_group(0, &self.bind_group, &[]); pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_bind_group(1, &self.texture_bind_group, &[]); pass.set_bind_group(1, &self.texture_bind_group, &[]);
pass.set_vertex_buffer(0, t.vbo.slice(..)); pass.set_vertex_buffer(0, t.vbo.slice(..));
pass.set_vertex_buffer(1, t.layer_vbo.slice(..));
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32); pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..t.index_count, 0, 0..1); pass.draw_indexed(0..t.index_count, 0, 0..1);
} }
@@ -1128,6 +1367,18 @@ impl Renderer {
pass.draw(0..c.vertex_count, 0..1); pass.draw(0..c.vertex_count, 0..1);
} }
// 1c) Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) NACH den Caps: die
// Material-/Fugenlinien je ueberlebendem Cut-Rechteck (Ribbon-
// TriangleList, eigener Stil je Schicht). Ohne Tiefentest (Always)
// liegen sie sichtbar auf den Caps; der Kamera-Lift in
// `build_cut_cap_lines` haelt sie vor der Ebenen-Kappung.
if let Some(cl) = &self.cut_lines {
pass.set_pipeline(&self.cut_line_pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, cl.vbo.slice(..));
pass.draw(0..cl.vertex_count, 0..1);
}
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid). // 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten) // Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne // Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
+78 -1
View File
@@ -20,6 +20,7 @@ pub mod math;
pub mod mesh; pub mod mesh;
pub(crate) mod openings; pub(crate) mod openings;
pub mod section; pub mod section;
pub mod section_boolean;
pub mod section_fill; pub mod section_fill;
pub mod shaders; pub mod shaders;
pub mod types; pub mod types;
@@ -72,6 +73,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
} }
} }
@@ -171,6 +175,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}, },
]); ]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
@@ -250,6 +257,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
extrude_wall(&mut mesh, &w); extrude_wall(&mut mesh, &w);
@@ -272,6 +282,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let mesh = build_walls_mesh(&[w]); let mesh = build_walls_mesh(&[w]);
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
@@ -486,6 +499,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let b = WallInput { let b = WallInput {
start: [0.0, 0.0], start: [0.0, 0.0],
@@ -497,6 +513,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let mesh = build_walls_mesh(&[a, b]); let mesh = build_walls_mesh(&[a, b]);
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader"); assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
@@ -557,6 +576,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let walls = vec![ let walls = vec![
mk([0.0, 0.0], [3.0, 0.0]), mk([0.0, 0.0], [3.0, 0.0]),
@@ -586,6 +608,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let walls = vec![ let walls = vec![
mk([0.0, 0.0], [3.0, 0.0], 0.0), mk([0.0, 0.0], [3.0, 0.0], 0.0),
@@ -675,6 +700,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: layers(), layers: layers(),
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
let mut b = a.clone(); let mut b = a.clone();
b.end = [0.0, 3.0]; b.end = [0.0, 3.0];
@@ -705,6 +733,8 @@ mod tests {
z_bottom, z_bottom,
z_top, z_top,
color: [0.86, 0.86, 0.88], color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
} }
} }
@@ -761,6 +791,8 @@ mod tests {
z_bottom: 0.0, z_bottom: 0.0,
z_top: 0.3, z_top: 0.3,
color: [0.8, 0.8, 0.8], color: [0.8, 0.8, 0.8],
hatch: None,
cut: None,
}; };
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
extrude_slab(&mut mesh, &slab); extrude_slab(&mut mesh, &slab);
@@ -789,7 +821,7 @@ mod tests {
fn entarteter_slab_erzeugt_nichts() { fn entarteter_slab_erzeugt_nichts() {
let mut mesh = Mesh::default(); let mut mesh = Mesh::default();
// <3 Ecken. // <3 Ecken.
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] }); extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
assert_eq!(mesh.vertex_count(), 0); assert_eq!(mesh.vertex_count(), 0);
// Nullhoehe. // Nullhoehe.
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6)); extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
@@ -962,6 +994,51 @@ mod tests {
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel"); assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
} }
#[test]
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let front = preset_camera(CameraPreset::Front, t, 10.0);
let back = preset_camera(CameraPreset::Back, t, 10.0);
let side = preset_camera(CameraPreset::Side, t, 10.0);
let left = preset_camera(CameraPreset::Left, t, 10.0);
assert_eq!(back.projection, Projection::Orthographic);
assert_eq!(left.projection, Projection::Orthographic);
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
}
#[test]
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
use super::math::preset_camera;
use super::types::{CameraPreset, Projection};
let t = [0.0, 0.0, 0.0];
let dist = 10.0;
let iso = preset_camera(CameraPreset::Iso, t, dist);
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
for cam in [&iso, &fl, &br, &bl] {
assert_eq!(cam.projection, Projection::Orthographic);
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
// (Modell erscheint in jedem Oktanten gleich gross).
let d = ((cam.eye[0] - t[0]).powi(2)
+ (cam.eye[1] - t[1]).powi(2)
+ (cam.eye[2] - t[2]).powi(2))
.sqrt();
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
}
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
}
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt /// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga /// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
/// sonst nicht mitgebaut wird (Muster: render2d). /// sonst nicht mitgebaut wird (Muster: render2d).
+53 -11
View File
@@ -185,15 +185,26 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
] ]
} }
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target` /// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso /// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp /// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
/// ist perspektivisch. /// die Iso-Varianten diagonal); nur Persp ist perspektivisch.
///
/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx):
/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von
/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt
/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ
/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen).
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera { pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
let mut cam = Camera { let mut cam = Camera {
target, target,
..Camera::default() ..Camera::default()
}; };
// Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der
// tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist,
// wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten
// das Modell gleich gross zeigen.
let iso_d = dist / 3.0_f32.sqrt();
match preset { match preset {
// Blick entlang -Z (von vorne), orthografisch. // Blick entlang -Z (von vorne), orthografisch.
CameraPreset::Front => { CameraPreset::Front => {
@@ -202,6 +213,13 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 1.0, 0.0]; cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5; cam.ortho_half_height = dist * 0.5;
} }
// Blick entlang +Z (von hinten) — Gegenrichtung von Front.
CameraPreset::Back => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0], target[1], target[2] - dist];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit // Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht). // Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
CameraPreset::Top => { CameraPreset::Top => {
@@ -210,24 +228,48 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
cam.up = [0.0, 0.0, -1.0]; cam.up = [0.0, 0.0, -1.0];
cam.ortho_half_height = dist * 0.5; cam.ortho_half_height = dist * 0.5;
} }
// Blick entlang -X (von der Seite), orthografisch. // Blick entlang -X (von der rechten Seite), orthografisch.
CameraPreset::Side => { CameraPreset::Side => {
cam.projection = Projection::Orthographic; cam.projection = Projection::Orthographic;
cam.eye = [target[0] + dist, target[1], target[2]]; cam.eye = [target[0] + dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0]; cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5; cam.ortho_half_height = dist * 0.5;
} }
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben). // Blick entlang +X (von der linken Seite) — Gegenrichtung von Side.
CameraPreset::Left => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - dist, target[1], target[2]];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso).
// Echte Isometrie ist per Definition parallelprojiziert (keine // Echte Isometrie ist per Definition parallelprojiziert (keine
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu). // perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
CameraPreset::Iso => { CameraPreset::Iso => {
cam.projection = Projection::Orthographic; cam.projection = Projection::Orthographic;
let d = dist / 3.0_f32.sqrt(); cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d];
cam.eye = [target[0] + d, target[1] + d, target[2] + d]; cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von vorne-oben-links.
CameraPreset::IsoFrontLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-rechts.
CameraPreset::IsoBackRight => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0];
cam.ortho_half_height = dist * 0.5;
}
// Isometrischer Ueberblick von hinten-oben-links.
CameraPreset::IsoBackLeft => {
cam.projection = Projection::Orthographic;
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d];
cam.up = [0.0, 1.0, 0.0]; cam.up = [0.0, 1.0, 0.0];
// eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt
// `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort,
// damit das Modell im Bild aehnlich gross erscheint.
cam.ortho_half_height = dist * 0.5; cam.ortho_half_height = dist * 0.5;
} }
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts). // Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
+57 -1
View File
@@ -940,7 +940,10 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie /// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst. /// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh { pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
textured_from_mesh(&build_walls_mesh(walls)) // Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch
// die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu
// `textured_from_mesh(&build_walls_mesh(walls))` bleiben.
build_scene_mesh_textured(walls, &[], &[])
} }
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh` /// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
@@ -980,9 +983,62 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
TexturedMesh { TexturedMesh {
verts, verts,
indices: mesh.indices.clone(), indices: mesh.indices.clone(),
// Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten
// `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices).
layers: Vec::new(),
} }
} }
/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie
/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/
/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag
/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`),
/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges
/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen —
/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die
/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung
/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`).
pub fn build_scene_mesh_textured(
walls: &[WallInput],
slabs: &[SlabInput],
meshes: &[MeshInput],
) -> TexturedMesh {
let mut mesh = Mesh::default();
let mut layers: Vec<f32> = Vec::new();
let fill = |mesh: &Mesh, layers: &mut Vec<f32>, before: usize, layer: f32| {
let added = mesh.vertex_count().saturating_sub(before);
layers.extend(std::iter::repeat(layer).take(added));
};
let miters = compute_wall_miters(walls);
for (w, m) in walls.iter().zip(miters.iter()) {
let before = mesh.vertex_count();
extrude_wall_with_miters(&mut mesh, w, *m);
// Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett).
let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0);
fill(&mesh, &mut layers, before, layer);
}
for s in slabs {
let before = mesh.vertex_count();
extrude_slab(&mut mesh, s);
fill(&mesh, &mut layers, before, -1.0);
}
for m in meshes {
let before = mesh.vertex_count();
append_context_mesh(&mut mesh, m);
fill(&mesh, &mut layers, before, -1.0);
}
let mut tex = textured_from_mesh(&mesh);
debug_assert_eq!(
layers.len(),
tex.vertex_count(),
"ein Ebenen-Eintrag je Vertex"
);
tex.layers = layers;
tex
}
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten /// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt /// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
/// vorne (deterministische Zaehlung fuer die Wand-Tests). /// vorne (deterministische Zaehlung fuer die Wand-Tests).
+39 -1
View File
@@ -133,7 +133,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::math; use crate::math;
use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput}; use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput};
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe). /// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
const EPS: f32 = 1e-4; const EPS: f32 = 1e-4;
@@ -268,8 +268,19 @@ pub struct ComponentRef {
pub struct CutPolygon { pub struct CutPolygon {
pub component: ComponentRef, pub component: ComponentRef,
pub color: Rgb, pub color: Rgb,
/// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None`
/// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen). /// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
pub pts: Vec<[f32; 2]>, pub pts: Vec<[f32; 2]>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe
/// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean::
/// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere)
/// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None`
/// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten).
#[serde(default)]
pub cut: Option<CutBandMeta>,
} }
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter. /// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
@@ -314,9 +325,16 @@ struct Prism {
z0: f32, z0: f32,
z1: f32, z1: f32,
color: Rgb, color: Rgb,
/// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die
/// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen.
hatch: Option<Hatch>,
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer /// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
/// Decken (Slabs kennen keine Oeffnungen). /// Decken (Slabs kennen keine Oeffnungen).
wall_axis: Option<WallAxis>, wall_axis: Option<WallAxis>,
/// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der
/// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte
/// `CutPolygon` weitergereicht.
cut: Option<CutBandMeta>,
} }
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut- /// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
@@ -399,12 +417,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
z0: wall.base_elevation, z0: wall.base_elevation,
z1: wall.base_elevation + wall.height, z1: wall.base_elevation + wall.height,
color: wall.color, color: wall.color,
hatch: wall.hatch,
wall_axis: Some(WallAxis { wall_axis: Some(WallAxis {
start: wall.start, start: wall.start,
dir, dir,
length, length,
openings: wall.openings.clone(), openings: wall.openings.clone(),
}), }),
cut: wall.cut.clone(),
}) })
} }
@@ -425,7 +445,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
z0, z0,
z1, z1,
color: slab.color, color: slab.color,
hatch: slab.hatch,
wall_axis: None, wall_axis: None,
cut: slab.cut.clone(),
}) })
} }
@@ -974,7 +996,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
index: prism.index, index: prism.index,
}, },
color: prism.color, color: prism.color,
hatch: prism.hatch,
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]], pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
cut: prism.cut.clone(),
}); });
} }
} }
@@ -1017,6 +1041,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}, },
WallInput { WallInput {
start: [0.0, 3.0], start: [0.0, 3.0],
@@ -1028,6 +1055,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}, },
]; ];
let slabs = vec![SlabInput { let slabs = vec![SlabInput {
@@ -1035,6 +1065,8 @@ mod tests {
z_bottom: -0.2, z_bottom: -0.2,
z_top: 0.0, z_top: 0.0,
color: [0.86, 0.86, 0.88], color: [0.86, 0.86, 0.88],
hatch: None,
cut: None,
}]; }];
(walls, slabs) (walls, slabs)
} }
@@ -1285,6 +1317,9 @@ mod tests {
}], }],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
} }
} }
@@ -1371,6 +1406,9 @@ mod tests {
openings: vec![], openings: vec![],
layers: None, layers: None,
holes: vec![], holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter. // Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
let plane = SectionPlane::looking_plus_y(-2.0); let plane = SectionPlane::looking_plus_y(-2.0);
+516
View File
@@ -0,0 +1,516 @@
//! Schnitt-Boolean-Dominanz fuer den 3D-Live-Schnitt — die 1:1-Portierung von
//! `src/plan/toSection.ts::subtractDominantBands`/`subtractRect` nach Rust.
//!
//! GRUND: der 2D-Schnitt (`toSection.ts`) ist die Referenz-Implementierung. Er
//! sammelt alle Cut-Baender (Wand- und Deckenschichten) als achsparallele
//! (u,v)-Rechtecke, taggt jedes mit seiner `Component.joinPriority` und laesst
//! dann die STAERKERE Schicht die schwaechere per Rechteck-Subtraktion wegschneiden
//! (elementuebergreifend). Der 3D-Rust-Cut (`section.rs`) produziert bereits genau
//! dasselbe Format — achsparallele (u,v)-Rechteck-`CutPolygon`s — also laesst sich
//! die Dominanz-Zerlegung hier identisch nachbauen, damit der 3D-Live-Schnitt fuer
//! Wand+Decken-Schichten EXAKT wie der 2D-Schnitt aussieht.
//!
//! Die Prioritaet/Identitaet je Band kommt aus `CutPolygon::cut` (`CutBandMeta`,
//! aus der TS-Emission `toWalls3d`). Baender ohne `join_priority` (unaufloesbares
//! Bauteil) nehmen weder als Basis noch als Cutter teil und bleiben unveraendert.
use crate::section::CutPolygon;
/// Toleranz fuer Ueberlappungs-/Rest-Rechtecke (Meter): Null-/Negativflaechen und
/// Rest-Streifen duenner als EPS werden verworfen (Float-Rauschen der (u,v)-Meter).
/// Identisch zu `RECT_EPS` in `toSection.ts`.
const RECT_EPS: f32 = 1e-6;
/// Achsparalleles Rechteck in (u, v)-Metern — das Rust-Gegenstueck zu `Rect` in
/// `toSection.ts`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub u_min: f32,
pub u_max: f32,
pub v_min: f32,
pub v_max: f32,
}
/// Bounding-Box eines (achsparallelen) Cut-Bands als `Rect`, oder `None` bei <3
/// Ecken (analog `rectOfBand`).
pub fn rect_of_poly(poly: &CutPolygon) -> Option<Rect> {
if poly.pts.len() < 3 {
return None;
}
let mut u_min = f32::INFINITY;
let mut u_max = f32::NEG_INFINITY;
let mut v_min = f32::INFINITY;
let mut v_max = f32::NEG_INFINITY;
for p in &poly.pts {
u_min = u_min.min(p[0]);
u_max = u_max.max(p[0]);
v_min = v_min.min(p[1]);
v_max = v_max.max(p[1]);
}
Some(Rect {
u_min,
u_max,
v_min,
v_max,
})
}
/// Rechteck-minus-Rechteck (beide achsparallel): `base` ohne die Ueberlappung mit
/// `cutter`, zerlegt in 04 disjunkte Rest-Rechtecke (links/rechts ueber die volle
/// Hoehe, dann oben/unten im mittleren u-Streifen — ueberlappungsfrei). Kein echter
/// Ueberlapp (Flaeche ≤ EPS in u oder v) ⇒ `base` bleibt ganz. Vollstaendige
/// Ueberdeckung ⇒ leere Liste. Rest-Streifen duenner als EPS werden verworfen.
/// 1:1 aus `toSection.ts::subtractRect`.
pub fn subtract_rect(base: Rect, cutter: Rect) -> Vec<Rect> {
let o_min_u = base.u_min.max(cutter.u_min);
let o_max_u = base.u_max.min(cutter.u_max);
let o_min_v = base.v_min.max(cutter.v_min);
let o_max_v = base.v_max.min(cutter.v_max);
// Kein (nennenswerter) Ueberlapp — `base` unveraendert.
if o_max_u - o_min_u <= RECT_EPS || o_max_v - o_min_v <= RECT_EPS {
return vec![base];
}
let mut out = Vec::new();
let mut push = |r: Rect| {
if r.u_max - r.u_min > RECT_EPS && r.v_max - r.v_min > RECT_EPS {
out.push(r);
}
};
// Links / rechts der Ueberlappung, jeweils ueber die volle Basis-Hoehe.
push(Rect {
u_min: base.u_min,
u_max: o_min_u,
v_min: base.v_min,
v_max: base.v_max,
});
push(Rect {
u_min: o_max_u,
u_max: base.u_max,
v_min: base.v_min,
v_max: base.v_max,
});
// Unten / oben im mittleren u-Streifen (nur der von der Ueberlappung befreite Rest).
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: base.v_min,
v_max: o_min_v,
});
push(Rect {
u_min: o_min_u,
u_max: o_max_u,
v_min: o_max_v,
v_max: base.v_max,
});
out
}
/// `join_priority` eines Bands (aus `CutBandMeta`), oder `None`.
fn priority_of(poly: &CutPolygon) -> Option<i32> {
poly.cut.as_ref().and_then(|c| c.join_priority)
}
/// `component_id` eines Bands (aus `CutBandMeta`), oder `None`.
fn component_of(poly: &CutPolygon) -> Option<&str> {
poly.cut.as_ref().and_then(|c| c.component_id.as_deref())
}
/// Erzeugt aus einem Rest-`Rect` ein Cut-Band, das Stil/Referenz/Metadaten von
/// `src` erbt (analog `bandFromRect`). Ecken-Reihenfolge wie in `section.rs`
/// (CCW-analog, hier v_max oben zuerst — fuer die Fan-Triangulierung irrelevant).
fn poly_from_rect(src: &CutPolygon, r: Rect) -> CutPolygon {
CutPolygon {
component: src.component,
color: src.color,
hatch: src.hatch,
cut: src.cut.clone(),
pts: vec![
[r.u_min, r.v_max],
[r.u_max, r.v_max],
[r.u_max, r.v_min],
[r.u_min, r.v_min],
],
}
}
/// Globale Schnitt-Dominanz: fuer jedes Band wird die Rechteck-Ueberlappung ALLER
/// Baender mit STRIKT hoeherer `join_priority` subtrahiert (elementuebergreifend —
/// z. B. schneidet ein Decken-Beton-Band 100 ein ueberlappendes Wand-Putz-Band 10
/// weg). Gleiche Prioritaet schneidet NICHT (koexistiert → durchgehende Flaeche).
/// Baender ohne `join_priority` nehmen weder als Basis noch als Cutter teil und
/// bleiben unveraendert erhalten. 1:1 aus `toSection.ts::subtractDominantBands`
/// (inklusive der abschliessenden MERGE-Phase).
pub fn subtract_dominant_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let rects: Vec<Option<Rect>> = bands.iter().map(rect_of_poly).collect();
let prios: Vec<Option<i32>> = bands.iter().map(priority_of).collect();
let mut out: Vec<CutPolygon> = Vec::new();
for i in 0..bands.len() {
// Nicht-rechteckig oder ohne Prioritaet: unveraendert durchreichen.
let (base, prio) = match (rects[i], prios[i]) {
(Some(b), Some(p)) => (b, p),
_ => {
out.push(bands[i].clone());
continue;
}
};
let mut pieces = vec![base];
for j in 0..bands.len() {
if pieces.is_empty() {
break;
}
if j == i {
continue;
}
let cutter = match (rects[j], prios[j]) {
(Some(c), Some(op)) if op > prio => c,
_ => continue,
};
let mut next = Vec::new();
for p in &pieces {
next.extend(subtract_rect(*p, cutter));
}
pieces = next;
}
for p in pieces {
out.push(poly_from_rect(&bands[i], p));
}
}
merge_same_component_bands(out)
}
/// Union zweier achsparalleler Rechtecke, ABER NUR wenn das Ergebnis EXAKT wieder
/// ein Rechteck ist — sonst `None`. Enthaltensein oder Achs-Deckung + Beruehrung
/// liefern eine rechteckige Union; Teil-/Eck-Ueberlappungen (L-Form) und Baender
/// mit Spalt liefern `None`. 1:1 aus `toSection.ts::rectUnionIfRect`.
fn rect_union_if_rect(a: Rect, b: Rect) -> Option<Rect> {
const E: f32 = RECT_EPS;
// Enthaltensein (inkl. Deckungsgleichheit): Union = umschliessendes Rechteck.
let a_in_b = b.u_min - a.u_min <= E
&& a.u_max - b.u_max <= E
&& b.v_min - a.v_min <= E
&& a.v_max - b.v_max <= E;
if a_in_b {
return Some(b);
}
let b_in_a = a.u_min - b.u_min <= E
&& b.u_max - a.u_max <= E
&& a.v_min - b.v_min <= E
&& b.v_max - a.v_max <= E;
if b_in_a {
return Some(a);
}
let u_aligned = (a.u_min - b.u_min).abs() <= E && (a.u_max - b.u_max).abs() <= E;
let v_aligned = (a.v_min - b.v_min).abs() <= E && (a.v_max - b.v_max).abs() <= E;
// Beruehrung/Ueberlappung in der jeweils ANDEREN Achse (kein Spalt groesser EPS).
let v_touch = a.v_max >= b.v_min - E && b.v_max >= a.v_min - E;
let u_touch = a.u_max >= b.u_min - E && b.u_max >= a.u_min - E;
if (u_aligned && v_touch) || (v_aligned && u_touch) {
return Some(Rect {
u_min: a.u_min.min(b.u_min),
u_max: a.u_max.max(b.u_max),
v_min: a.v_min.min(b.v_min),
v_max: a.v_max.max(b.v_max),
});
}
None
}
/// MERGE-Phase der Schnitt-Dominanz: Baender mit GLEICHER `component_id` UND
/// gleicher `join_priority`, deren achsparallele Rechtecke sich beruehren/ueberlappen
/// und eine rechteckige Union bilden (`rect_union_if_rect`), werden zu EINEM Band
/// zusammengefasst — so entsteht an der Beruehrkante keine innere Trennlinie durch
/// gleichartiges Material. Baender ohne `component_id`/`join_priority` oder ohne
/// Rechteck-Form nehmen nicht teil. Iterativ bis zum Fixpunkt. 1:1 aus
/// `toSection.ts::mergeSameComponentBands`.
fn merge_same_component_bands(bands: Vec<CutPolygon>) -> Vec<CutPolygon> {
let mut out = bands;
let mut merged = true;
while merged {
merged = false;
'outer: for i in 0..out.len() {
let (ci, pi) = match (component_of(&out[i]), priority_of(&out[i])) {
(Some(c), Some(p)) => (c.to_string(), p),
_ => continue,
};
let ri = match rect_of_poly(&out[i]) {
Some(r) => r,
None => continue,
};
for j in (i + 1)..out.len() {
if component_of(&out[j]) != Some(ci.as_str()) || priority_of(&out[j]) != Some(pi) {
continue;
}
let rj = match rect_of_poly(&out[j]) {
Some(r) => r,
None => continue,
};
if let Some(u) = rect_union_if_rect(ri, rj) {
out[i] = poly_from_rect(&out[i], u);
out.remove(j);
merged = true; // Fixpunkt-Neustart
break 'outer;
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::section::{ComponentKind, ComponentRef, CutPolygon};
use crate::types::CutBandMeta;
/// Achsparalleles Cut-Band mit Prioritaet (und optionaler Komponenten-Id) —
/// Ecken wie `section.rs` (Rechteck).
fn band(
u_min: f32,
u_max: f32,
v_min: f32,
v_max: f32,
join_priority: i32,
component_id: Option<&str>,
) -> CutPolygon {
CutPolygon {
component: ComponentRef {
kind: ComponentKind::Wall,
index: 0,
},
color: [0.5, 0.5, 0.5],
hatch: None,
pts: vec![
[u_min, v_max],
[u_max, v_max],
[u_max, v_min],
[u_min, v_min],
],
cut: Some(CutBandMeta {
join_priority: Some(join_priority),
component_id: component_id.map(|s| s.to_string()),
line_color: None,
line_weight: None,
}),
}
}
fn rect_of(p: &CutPolygon) -> Rect {
rect_of_poly(p).unwrap()
}
fn area(bands: &[CutPolygon]) -> f32 {
bands
.iter()
.map(|b| {
let r = rect_of(b);
(r.u_max - r.u_min) * (r.v_max - r.v_min)
})
.sum()
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-5
}
// --- subtractRect (Referenz: toSection.boolean.test.ts) ------------------
#[test]
fn subtract_rect_kein_overlap_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 2.0, u_max: 3.0, v_min: 2.0, v_max: 3.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_kantenberuehrung_base_unveraendert() {
let base = Rect { u_min: 0.0, u_max: 1.0, v_min: 0.0, v_max: 1.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 0.0, v_max: 1.0 };
assert_eq!(subtract_rect(base, cut), vec![base]);
}
#[test]
fn subtract_rect_cutter_enthaelt_base_leer() {
let base = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let cut = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
assert!(subtract_rect(base, cut).is_empty());
}
#[test]
fn subtract_rect_cutter_mittig_vier_reste() {
let base = Rect { u_min: 0.0, u_max: 3.0, v_min: 0.0, v_max: 3.0 };
let cut = Rect { u_min: 1.0, u_max: 2.0, v_min: 1.0, v_max: 2.0 };
let rest = subtract_rect(base, cut);
assert_eq!(rest.len(), 4);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 8.0), "9 (base) - 1 (overlap) = 8, ist {a}");
}
#[test]
fn subtract_rect_eck_ueberlappung_zwei_reste() {
// cutter deckt die obere-rechte Ecke von base: overlap [1,2]x[1,2].
let base = Rect { u_min: 0.0, u_max: 2.0, v_min: 0.0, v_max: 2.0 };
let cut = Rect { u_min: 1.0, u_max: 3.0, v_min: 1.0, v_max: 3.0 };
let rest = subtract_rect(base, cut);
let a: f32 = rest
.iter()
.map(|r| (r.u_max - r.u_min) * (r.v_max - r.v_min))
.sum();
assert!(approx(a, 3.0), "4 - 1 = 3, ist {a}");
}
// --- subtractDominantBands: die geometrische Kernaussage ------------------
#[test]
fn decke_100_schneidet_wand_50_im_ueberlappungs_z_intervall_weg() {
// Nutzer-Sample-analog: eine Wandschicht (Backstein, Prio 50, u in [0,0.2],
// volle Hoehe v in [0,3]) und ein Decken-Beton-Band (Prio 100, u in [0,0.2],
// v in [2.6,2.8]) ueberlappen in (u,v). Nach der Subtraktion darf die Wand
// im Decken-z-Intervall KEINE Flaeche mehr haben; Beton bleibt voll.
let brick = band(0.0, 0.2, 0.0, 3.0, 50, Some("brick"));
let concrete = band(0.0, 0.2, 2.6, 2.8, 100, Some("concrete"));
let out = subtract_dominant_bands(vec![brick, concrete]);
// Beton unveraendert (kein staerkeres Band).
let concrete_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("concrete"))
.collect();
assert_eq!(concrete_out.len(), 1);
assert!(approx(
(rect_of(concrete_out[0]).v_max - rect_of(concrete_out[0]).v_min) as f32,
0.2
));
// Backstein: in v in [2.6,2.8] KEINE Flaeche mehr -> zwei Reste [0,2.6] und
// [2.8,3.0]. Kein Rest-Rechteck ragt ins Beton-Intervall.
let brick_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("brick"))
.collect();
assert!(!brick_out.is_empty());
for b in &brick_out {
let r = rect_of(b);
// Kein Backstein-Rest ueberlappt (2.6, 2.8) echt.
let overlaps = r.v_min < 2.8 - 1e-4 && r.v_max > 2.6 + 1e-4;
assert!(!overlaps, "Backstein-Rest {r:?} ragt ins Beton-z-Intervall");
}
// Flaechenbilanz: Backstein 0.2*3 = 0.6, minus Overlap 0.2*0.2 = 0.04 -> 0.56.
assert!(
approx(area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()), 0.56),
"Backstein-Restflaeche"
);
}
#[test]
fn wand_50_schneidet_estrich_30() {
// Estrich (Decke, Prio 30) verliert die Ueberlappung mit der Wand
// (Backstein, Prio 50). Referenz-Zahlen wie im 2D-Test.
let screed = band(0.0, 2.0, 0.0, 3.0, 30, Some("screed"));
let brick = band(0.0, 2.0, 2.0, 4.0, 50, Some("brick")); // Overlap v in [2,3]
let out = subtract_dominant_bands(vec![screed, brick]);
// Brick voll.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
4.0
));
// Estrich: [0,3] minus Overlap [2,3] = [0,2] -> Rest genau v in [0,2].
let screed_out: Vec<&CutPolygon> = out
.iter()
.filter(|b| component_of(b) == Some("screed"))
.collect();
assert_eq!(screed_out.len(), 1);
let r = rect_of(screed_out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 2.0), "Estrich-Rest {r:?}");
}
/// SAMPLE-ANALOG (Nutzer-Bug): der Schnitt durch Aussenwand + Geschossdecke.
/// Deckenschichten (u-breit, gestapelt in v nahe OK): Estrich (30, [2.54,2.6]),
/// Dämmung (20, [2.50,2.54]), Beton (100, [2.30,2.50]). Wandschichten (schmal in
/// u, an ihrer Dicken-Position): Backstein (50) läuft als ZWEI Boxen [0,2.3] +
/// [2.5,2.6] durch (Beton kappt das Mittelstück — genau die per-Schicht-Decken-
/// Dominanz aus emitWall). ERWARTUNG: nach der Verschneidung haben Estrich UND
/// Dämmung im u-Bereich des Backsteins KEINE Fläche mehr (Backstein 50 > 30, 20);
/// der Beton bleibt voll und läuft durch. Das ist die Richtung, die der Nutzer als
/// kaputt meldete ("dämmung und zementboden laufen raus").
#[test]
fn sample_analog_backstein_schneidet_estrich_und_daemmung() {
let brick_u = (0.0075_f32, 0.1575_f32); // Backstein-Dickenposition (W4-analog)
let ceil_u = (0.0_f32, 5.0_f32); // Deckenschichten über die volle Raumbreite
let bands = vec![
band(ceil_u.0, ceil_u.1, 2.54, 2.6, 30, Some("screed")),
band(ceil_u.0, ceil_u.1, 2.50, 2.54, 20, Some("insulation-ceiling")),
band(ceil_u.0, ceil_u.1, 2.30, 2.50, 100, Some("concrete")),
band(brick_u.0, brick_u.1, 0.0, 2.3, 50, Some("brick")),
band(brick_u.0, brick_u.1, 2.5, 2.6, 50, Some("brick")),
];
let out = subtract_dominant_bands(bands);
// Kein Estrich-/Dämmung-Rest überlappt den Backstein-u-Bereich ECHT.
for name in ["screed", "insulation-ceiling"] {
for b in out.iter().filter(|b| component_of(b) == Some(name)) {
let r = rect_of(b);
let u_overlap = r.u_min < brick_u.1 - 1e-4 && r.u_max > brick_u.0 + 1e-4;
assert!(
!u_overlap,
"{name}-Rest {r:?} ragt noch in den Backstein-u-Bereich (müsste weg sein)"
);
}
}
// Beton bleibt voll (kein stärkeres Band): Fläche 5*0.2 = 1.0.
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("concrete")).cloned().collect::<Vec<_>>()),
1.0
));
// Backstein bleibt in beiden Boxen erhalten (nichts Stärkeres überlappt).
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("brick")).cloned().collect::<Vec<_>>()),
0.15 * 2.3 + 0.15 * 0.1
));
}
#[test]
fn gleiche_prioritaet_koexistiert() {
// Beton-Wand + Beton-Decke, gleiche Prio 100, VERSCHIEDENE Komponenten:
// schneiden sich NICHT und verschmelzen NICHT (bleiben beide voll).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton-wand"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton-decke")); // Overlap v[2,3]
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 2);
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-wand")).cloned().collect::<Vec<_>>()),
3.0
));
assert!(approx(
area(&out.iter().filter(|b| component_of(b) == Some("beton-decke")).cloned().collect::<Vec<_>>()),
2.0
));
}
#[test]
fn ohne_prioritaet_keine_teilnahme() {
let stark = band(0.0, 2.0, 0.0, 2.0, 100, None);
let mut ohne = band(0.0, 2.0, 0.0, 2.0, 0, None);
ohne.cut = None; // keine Prioritaet
let out = subtract_dominant_bands(vec![stark, ohne]);
// Beide unveraendert (4 + 4).
assert_eq!(out.len(), 2);
assert!(approx(area(&out), 8.0));
}
#[test]
fn merge_gleiche_komponente_und_prioritaet() {
// Beton-Wand trifft Beton-Decke (u-deckungsgleich), gleiche componentId +
// Prio -> verschmelzen zu EINEM Rechteck v in [0,4] (keine innere Kante).
let wand = band(0.0, 1.0, 0.0, 3.0, 100, Some("beton"));
let decke = band(0.0, 1.0, 2.0, 4.0, 100, Some("beton"));
let out = subtract_dominant_bands(vec![wand, decke]);
assert_eq!(out.len(), 1);
let r = rect_of(&out[0]);
assert!(approx(r.v_min, 0.0) && approx(r.v_max, 4.0), "verschmolzen {r:?}");
}
}
+513 -13
View File
@@ -21,19 +21,68 @@
//! //!
//! ## Ausgabeformat //! ## Ausgabeformat
//! //!
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32, //! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als //! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges, //! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im //! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken, //! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
//! siehe `section.rs`) — Fan-Triangulierung genuegt. //! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
use crate::section::{cut_section, SectionPlane}; use crate::section::{cut_section, CutPolygon, SectionPlane};
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
use crate::types::{SlabInput, WallInput}; use crate::types::{SlabInput, WallInput};
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts /// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs). /// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
pub const CAP_FLOATS_PER_VERTEX: usize = 5; /// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
/// Breite (mm-Papier -> Welt) je Schraffur.
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
/// ein Band keinen eigenen `line_weight` traegt.
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
///
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
/// Schlussbericht/Moduldoc).
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
const CUT_LINE_LIFT: f32 = 0.002;
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
/// aufgeloeste Bauteil-Schraffur).
const FALLBACK_PATTERN: f32 = 2.0;
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale /// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die /// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
@@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5;
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die /// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt /// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
/// nicht von der Normale ab. /// nicht von der Normale ab.
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
let out = cut_section(plane, walls, slabs);
subtract_dominant_bands(out.cut_polygons)
}
pub fn build_cut_caps( pub fn build_cut_caps(
plane: &SectionPlane, plane: &SectionPlane,
walls: &[WallInput], walls: &[WallInput],
slabs: &[SlabInput], slabs: &[SlabInput],
) -> Vec<f32> { ) -> Vec<f32> {
let out = cut_section(plane, walls, slabs); let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point; let point = plane.point;
let u_axis = plane.u_axis(); let u_axis = plane.u_axis();
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v. // (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
@@ -62,14 +122,28 @@ pub fn build_cut_caps(
}; };
let mut verts: Vec<f32> = Vec::new(); let mut verts: Vec<f32> = Vec::new();
for poly in &out.cut_polygons { for poly in &polys {
let pts = &poly.pts; let pts = &poly.pts;
if pts.len() < 3 { if pts.len() < 3 {
continue; continue;
} }
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
// cos/sin rechnen kann.
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
Some(h) => (
h.pattern as f32,
h.angle.to_radians(),
h.scale.max(0.05),
h.line_weight.max(0.0),
),
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
};
let mut push_vertex = |uv: [f32; 2]| { let mut push_vertex = |uv: [f32; 2]| {
let w = to_world(uv); let w = to_world(uv);
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]); verts.extend_from_slice(&[
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
]);
}; };
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1). // Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
for i in 1..pts.len() - 1 { for i in 1..pts.len() - 1 {
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
} }
verts verts
} }
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
///
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
pub fn build_cut_cap_lines(
plane: &SectionPlane,
walls: &[WallInput],
slabs: &[SlabInput],
) -> Vec<f32> {
let polys = cut_cap_polys(plane, walls, slabs);
let point = plane.point;
let u_axis = plane.u_axis();
let normal = plane.normal;
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
let lift = [
-normal[0] * CUT_LINE_LIFT,
-normal[1] * CUT_LINE_LIFT,
-normal[2] * CUT_LINE_LIFT,
];
let to_world = |uv: [f32; 2]| -> [f32; 3] {
[
point[0] + uv[0] * u_axis[0] + lift[0],
uv[1] + lift[1],
point[2] + uv[0] * u_axis[2] + lift[2],
]
};
let mut verts: Vec<f32> = Vec::new();
for poly in &polys {
let Some(r) = rect_of_poly(poly) else {
continue;
};
// Stil aus den Band-Metadaten (oder Default).
let (color, weight_mm) = match &poly.cut {
Some(meta) => (
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
),
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
};
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
let corners = [
[r.u_min, r.v_min],
[r.u_max, r.v_min],
[r.u_max, r.v_max],
[r.u_min, r.v_max],
];
for k in 0..4 {
let a = to_world(corners[k]);
let b = to_world(corners[(k + 1) % 4]);
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
}
}
verts
}
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
fn push_line_ribbon(
out: &mut Vec<f32>,
a: [f32; 3],
b: [f32; 3],
normal: [f32; 3],
half_w: f32,
color: [f32; 3],
) {
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
if len < 1e-9 {
return;
}
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
// steht senkrecht auf der Kante.
let mut perp = [
normal[1] * d[2] - normal[2] * d[1],
normal[2] * d[0] - normal[0] * d[2],
normal[0] * d[1] - normal[1] * d[0],
];
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
if plen < 1e-9 {
return;
}
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
let mut push = |p: [f32; 3]| {
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
};
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
push(p0);
push(p1);
push(p2);
push(p0);
push(p2);
push(p3);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Hatch;
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
#[test]
fn hatch_muster_id_landet_in_den_cap_vertices() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch {
pattern: 3, // crosshatch
angle: 90.0,
scale: 2.0,
line_weight: 0.5,
}),
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
assert_eq!(
verts.len() % CAP_FLOATS_PER_VERTEX,
0,
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
);
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
assert!(
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
"angle in Radiant"
);
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
}
}
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
#[test]
fn ohne_hatch_fallback_auf_diagonale() {
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.3,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: None,
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[]);
assert!(!verts.is_empty());
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
}
use crate::types::CutBandMeta;
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
#[test]
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
let slab = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 2.0,
z_top: 2.4,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(100),
component_id: Some("concrete".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[slab]);
assert!(!verts.is_empty());
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut wall_verts = 0;
let mut concrete_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let y = verts[base + 1];
let pattern = verts[base + 5];
if (pattern - 2.0).abs() < 0.5 {
wall_verts += 1;
assert!(
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
);
} else if (pattern - 1.0).abs() < 0.5 {
concrete_verts += 1;
}
}
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
}
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
#[test]
fn wand_schneidet_schwaechere_decken_schichten() {
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: None,
line_weight: None,
}),
};
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
// Wand in u ab (Umriss ueber y in [-1,1]).
let screed = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 1.0,
z_top: 1.06,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(30),
component_id: Some("screed".into()),
line_color: None,
line_weight: None,
}),
};
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
let insulation = SlabInput {
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
z_bottom: 0.96,
z_top: 1.0,
color: [0.8, 0.8, 0.8],
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
cut: Some(CutBandMeta {
join_priority: Some(20),
component_id: Some("insulation".into()),
line_color: None,
line_weight: None,
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
assert!(!verts.is_empty());
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
let mut weak_verts = 0;
for i in 0..n {
let base = i * CAP_FLOATS_PER_VERTEX;
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
let pattern = verts[base + 5];
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
weak_verts += 1;
assert!(
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
[-0.2,0.2] (muesste weggeschnitten sein)"
);
}
}
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
}
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
/// die Ribbon-Breite messbar).
#[test]
fn build_cut_cap_lines_kantenzahl_und_stil() {
let color = [0.2, 0.4, 0.9];
let wall = WallInput {
start: [0.0, 0.0],
end: [3.0, 0.0],
thickness: 0.4,
height: 2.5,
base_elevation: 0.0,
color: [0.8, 0.8, 0.8],
openings: vec![],
layers: None,
holes: vec![],
material_index: None,
hatch: None,
cut: Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(2.0),
}),
};
let plane = SectionPlane::looking_plus_x(1.5);
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
assert!(!verts.is_empty());
assert_eq!(
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
0,
"Vielfaches des Cut-Linien-Strides"
);
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
assert_eq!(
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
24,
"4 Kanten * 6 Vertices je Ribbon"
);
// Farbe je Vertex aus dem Stil (Index 3/4/5).
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
for i in 0..n {
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
}
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
// gesamten Geometrie) im Vergleich duenn vs. dick.
let bbox_y = |vs: &[f32]| -> f32 {
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for i in 0..n {
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
lo = lo.min(y);
hi = hi.max(y);
}
hi - lo
};
let mut thin = wall.clone();
thin.cut = Some(CutBandMeta {
join_priority: Some(50),
component_id: Some("brick".into()),
line_color: Some(color),
line_weight: Some(0.2),
});
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
assert!(
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
);
}
}
+104 -22
View File
@@ -144,15 +144,20 @@ struct Globals {
}; };
@group(0) @binding(0) var<uniform> globals : Globals; @group(0) @binding(0) var<uniform> globals : Globals;
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) -> // Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen. // gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
@group(1) @binding(0) var tex : texture_2d<f32>; // Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures`
// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN
// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen.
@group(1) @binding(0) var tex : texture_2d_array<f32>;
@group(1) @binding(1) var samp : sampler; @group(1) @binding(1) var samp : sampler;
struct VsIn { struct VsIn {
@location(0) position : vec3<f32>, @location(0) position : vec3<f32>,
@location(1) normal : vec3<f32>, @location(1) normal : vec3<f32>,
@location(2) uv : vec2<f32>, @location(2) uv : vec2<f32>,
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
@location(3) layer : f32,
}; };
struct VsOut { struct VsOut {
@@ -160,6 +165,7 @@ struct VsOut {
@location(0) world_normal : vec3<f32>, @location(0) world_normal : vec3<f32>,
@location(1) uv : vec2<f32>, @location(1) uv : vec2<f32>,
@location(2) world_pos : vec3<f32>, @location(2) world_pos : vec3<f32>,
@location(3) @interpolate(flat) layer : f32,
}; };
@vertex @vertex
@@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut {
out.world_normal = in.normal; out.world_normal = in.normal;
out.uv = in.uv; out.uv = in.uv;
out.world_pos = in.position; out.world_pos = in.position;
out.layer = in.layer;
return out; return out;
} }
@@ -195,8 +202,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
* vec3<f32>(0.24, 0.27, 0.30); * vec3<f32>(0.24, 0.27, 0.30);
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe. // Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
let albedo = textureSample(tex, samp, in.uv).rgb; // `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den
// gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige
// Indizes, falls die Material-Menge kleiner ist als erwartet).
let max_layer = max(i32(globals.mode.z) - 1, 0);
let li = clamp(i32(round(in.layer)), 0, max_layer);
let albedo = textureSample(tex, samp, in.uv, li).rgb;
var shaded = albedo * (ambient + diffuse + fill); var shaded = albedo * (ambient + diffuse + fill);
@@ -261,11 +273,16 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
"#; "#;
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline). /// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2], /// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3],
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps /// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale /// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern — /// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention. /// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe
/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier,
/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die
/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton),
/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie
/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung).
pub const CAP_WGSL: &str = r#" pub const CAP_WGSL: &str = r#"
struct Globals { struct Globals {
view_proj : mat4x4<f32>, view_proj : mat4x4<f32>,
@@ -281,11 +298,18 @@ struct Globals {
struct VsIn { struct VsIn {
@location(0) position : vec3<f32>, @location(0) position : vec3<f32>,
@location(1) uv : vec2<f32>, @location(1) uv : vec2<f32>,
// hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien-
// Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe
// section_fill.rs).
@location(2) hatch : vec4<f32>,
}; };
struct VsOut { struct VsOut {
@builtin(position) clip_pos : vec4<f32>, @builtin(position) clip_pos : vec4<f32>,
@location(0) uv : vec2<f32>, @location(0) uv : vec2<f32>,
// flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht
// interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl).
@location(1) @interpolate(flat) hatch : vec4<f32>,
}; };
@vertex @vertex
@@ -293,27 +317,85 @@ fn vs_main(in : VsIn) -> VsOut {
var out : VsOut; var out : VsOut;
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0); out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
out.uv = in.uv; out.uv = in.uv;
out.hatch = in.hatch;
return out; return out;
} }
// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von
// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig.
fn line_band(s : f32, half_s : f32) -> f32 {
let frac = fract(s);
let dist = min(frac, 1.0 - frac);
let aa = max(fwidth(s), 1e-5);
return 1.0 - smoothstep(half_s, half_s + aa, dist);
}
@fragment @fragment
fn fs_main(in : VsOut) -> @location(0) vec4<f32> { fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
// 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen
// Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten
// von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm-
// groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel).
let spacing = 0.08; // Linienabstand ~8 cm
let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm)
let ink = vec3<f32>(0.06, 0.06, 0.06); let ink = vec3<f32>(0.06, 0.06, 0.06);
let paper = vec3<f32>(0.94, 0.94, 0.94); let paper = vec3<f32>(0.94, 0.94, 0.94);
let s = (in.uv.x + in.uv.y) / spacing; let pattern = i32(round(in.hatch.x));
let frac = fract(s); let angle = in.hatch.y;
let dist = min(frac, 1.0 - frac); // 0 auf der Linie let scale = max(in.hatch.z, 0.05);
let half_s = half / spacing;
let aa = max(fwidth(s), 1e-5); // none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
let line = 1.0 - smoothstep(half_s, half_s + aa, dist); if (pattern == 0) {
let color = mix(paper, ink, line); return vec4<f32>(paper, 1.0);
}
// solid (1): Vollton (Poché) — z. B. Beton.
if (pattern == 1) {
return vec4<f32>(ink, 1.0);
}
// Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention
// wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt
// HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den
// Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv`
// ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das
// Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.)
let ca = cos(angle);
let sa = sin(angle);
let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung
let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase)
let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung
// Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle
// des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin`
// 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien
// (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005),
// damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor,
// damit eine Haarlinie nicht ganz verschwindet.
let half = max(in.hatch.w * 0.005, 0.0001);
let half_s = half / spacing;
var line = 0.0;
if (pattern == 2) {
// diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 →
// horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D.
line = line_band(dv / spacing, half_s);
} else if (pattern == 3) {
// crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei
// angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D.
line = max(
line_band(dv / spacing, half_s),
line_band(du / spacing, half_s)
);
} else {
// insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer
// Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack.
let cell = spacing * 2.0;
let a = du / cell;
let b = dv / cell;
let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle
let fb = fract(b);
let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0)));
let aa = max(fwidth(fb) + fwidth(tri), 1e-5);
let hw = half / cell;
line = 1.0 - smoothstep(hw, hw + aa, d);
}
let color = mix(paper, ink, clamp(line, 0.0, 1.0));
return vec4<f32>(color, 1.0); return vec4<f32>(color, 1.0);
} }
"#; "#;
+129 -2
View File
@@ -20,6 +20,79 @@ pub type Point2 = [f32; 2];
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht. /// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
pub type Rgb = [f32; 3]; pub type Rgb = [f32; 3];
/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges
/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder,
/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id,
/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier,
/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit.
///
/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°),
/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist
/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Hatch {
/// Muster-Id (siehe oben). Default 0 (none).
#[serde(default)]
pub pattern: u32,
/// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0.
#[serde(default)]
pub angle: f32,
/// Grundmassstab (1 = Standardteilung). Default 1.
#[serde(default = "default_hatch_scale")]
pub scale: f32,
/// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch,
/// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster-
/// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur-
/// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs.
/// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie).
#[serde(default = "default_hatch_line_weight", rename = "lineWeight")]
pub line_weight: f32,
}
fn default_hatch_scale() -> f32 {
1.0
}
fn default_hatch_line_weight() -> f32 {
0.13
}
/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den
/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts`
/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv
/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut ==
/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion
/// teil).
///
/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box
/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE
/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung
/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten
/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CutBandMeta {
/// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer
/// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck-
/// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme.
#[serde(default)]
pub join_priority: Option<i32>,
/// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente +
/// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None`
/// -> keine Verschmelzung.
#[serde(default)]
pub component_id: Option<String>,
/// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem
/// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil.
#[serde(default)]
pub line_color: Option<Rgb>,
/// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle).
/// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich
/// dokumentierte) mm->Welt-Umsetzung.
#[serde(default)]
pub line_weight: Option<f32>,
}
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke, /// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh /// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`). /// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
@@ -82,6 +155,30 @@ pub struct WallInput {
/// extrudiert den vollen Achsen-Abschnitt mit Loechern. /// extrudiert den vollen Achsen-Abschnitt mit Loechern.
#[serde(default)] #[serde(default)]
pub holes: Vec<Hole>, pub holes: Vec<Hole>,
/// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine
/// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte
/// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da
/// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material-
/// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom
/// Schnitt/Serde-Pfad (`section.rs`) ignoriert.
#[serde(default, rename = "materialIndex")]
pub material_index: Option<u32>,
/// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der
/// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die
/// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen
/// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der
/// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
/// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so
/// ihre eigene Schraffur.
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
/// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`)
/// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz-
/// Subtraktion, Default-Schnittkante).
#[serde(default)]
pub cut: Option<CutBandMeta>,
} }
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe /// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
@@ -162,6 +259,13 @@ pub struct SlabInput {
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton. /// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
#[serde(default = "default_slab_color")] #[serde(default = "default_slab_color")]
pub color: Rgb, pub color: Rgb,
/// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`).
#[serde(default)]
pub hatch: Option<Hatch>,
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe
/// `CutBandMeta`/`WallInput::cut`).
#[serde(default)]
pub cut: Option<CutBandMeta>,
} }
fn default_slab_color() -> Rgb { fn default_slab_color() -> Rgb {
@@ -179,6 +283,9 @@ pub enum MeshKind {
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau. /// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
#[default] #[default]
Imported, Imported,
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
Extrusion,
} }
impl MeshKind { impl MeshKind {
@@ -191,6 +298,8 @@ impl MeshKind {
MeshKind::Terrain => [0.663, 0.690, 0.635], MeshKind::Terrain => [0.663, 0.690, 0.635],
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes. // 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
MeshKind::Imported => [0.624, 0.651, 0.682], MeshKind::Imported => [0.624, 0.651, 0.682],
// Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis).
MeshKind::Extrusion => [0.85, 0.55, 0.25],
} }
} }
} }
@@ -308,14 +417,24 @@ impl Default for Camera {
} }
} }
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind /// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch). /// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch.
/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/
/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da
/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/
/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im
/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CameraPreset { pub enum CameraPreset {
Front, Front,
Back,
Top, Top,
Side, Side,
Left,
Iso, Iso,
IsoFrontLeft,
IsoBackRight,
IsoBackLeft,
Persp, Persp,
} }
@@ -345,6 +464,14 @@ pub struct TexturedMesh {
pub verts: Vec<f32>, pub verts: Vec<f32>,
/// Dreiecks-Indizes (0-basiert auf die Vertices). /// Dreiecks-Indizes (0-basiert auf die Vertices).
pub indices: Vec<u32>, pub indices: Vec<u32>,
/// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur-
/// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material"
/// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut
/// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten
/// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts`
/// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die
/// Geometrie-Paritaets-Tests) bitgleich bleiben.
pub layers: Vec<f32>,
} }
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV. /// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
+33 -12
View File
@@ -15,12 +15,13 @@
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion // (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention), // (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt. // damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete // `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/ // top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte // ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung // orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die // perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
// weitere Navigation frei ueber `set_camera` (kein Lock). // kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
// `set_camera` (kein Lock).
// //
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch // MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts). // zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
@@ -330,19 +331,25 @@ impl WebModelRenderer {
}; };
} }
/// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht /// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf /// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend /// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch, /// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim /// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles /// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx). /// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> { pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
let preset = match preset { let preset = match preset {
"front" => CameraPreset::Front, "front" => CameraPreset::Front,
"back" => CameraPreset::Back,
"top" => CameraPreset::Top, "top" => CameraPreset::Top,
"side" => CameraPreset::Side, "side" => CameraPreset::Side,
"left" => CameraPreset::Left,
"iso" => CameraPreset::Iso, "iso" => CameraPreset::Iso,
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
"isoBackRight" => CameraPreset::IsoBackRight,
"isoBackLeft" => CameraPreset::IsoBackLeft,
"perspective" => CameraPreset::Persp, "perspective" => CameraPreset::Persp,
other => { other => {
return Err(JsValue::from_str(&format!( return Err(JsValue::from_str(&format!(
@@ -377,8 +384,8 @@ impl WebModelRenderer {
/// - `"shaded"` — beleuchtete Bauteilfarben (Default). /// - `"shaded"` — beleuchtete Bauteilfarben (Default).
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet /// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
/// (identisch zum bisherigen `set_render_mode_white(true)`). /// (identisch zum bisherigen `set_render_mode_white(true)`).
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"` /// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht. /// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen. /// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth- /// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare). /// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
@@ -431,6 +438,20 @@ impl WebModelRenderer {
Ok(()) Ok(())
} }
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
/// naechsten `render`.
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
self.renderer
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
Ok(())
}
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer). /// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
pub fn resize(&mut self, width: u32, height: u32) { pub fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1)); let (w, h) = (width.max(1), height.max(1));
+43 -3
View File
@@ -6,6 +6,32 @@
#[cfg(any(feature = "native2d", feature = "native3d"))] #[cfg(any(feature = "native2d", feature = "native3d"))]
mod native; mod native;
// Lock-Datei gegen gleichzeitiges Oeffnen derselben Projektdatei aus zwei
// App-Instanzen (OS-Advisory-Lock via `fs4`, siehe lock.rs).
mod lock;
use std::path::PathBuf;
use tauri::State;
/// Fordert den exklusiven Lock fuer eine Projektdatei an. `Ok(())` wenn frei
/// (oder schon von uns gehalten), sonst `Err(Some(LockInfo))` mit Angaben zur
/// haltenden Instanz (pid/hostname/timestamp), oder `Err(None)` falls die
/// Sidecar-Datei gesperrt, aber ihr Info-Block nicht lesbar war.
#[tauri::command]
fn acquire_project_lock(
path: String,
state: State<lock::LockState>,
) -> Result<(), Option<lock::LockInfo>> {
lock::acquire(&state, &PathBuf::from(path))
}
/// Gibt einen von dieser Instanz gehaltenen Lock frei. No-op, wenn der Pfad
/// nicht gelockt war.
#[tauri::command]
fn release_project_lock(path: String, state: State<lock::LockState>) {
lock::release(&state, &PathBuf::from(path));
}
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend. /// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
#[tauri::command] #[tauri::command]
async fn compute_joins( async fn compute_joins(
@@ -40,7 +66,15 @@ fn push_native_walls(walls: serde_json::Value) {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let builder = tauri::Builder::default().setup(|_app| { let builder = tauri::Builder::default()
// Native „Speichern unter"-Dialog + Datei-Schreiben aus der Webview.
// Immer aktiv (unabhaengig von native2d/native3d), damit Datei-Exporte
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
.manage(lock::LockState::default())
.setup(|_app| {
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen // Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt. // Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
#[cfg(any(feature = "native2d", feature = "native3d"))] #[cfg(any(feature = "native2d", feature = "native3d"))]
@@ -52,10 +86,16 @@ pub fn run() {
let builder = builder.invoke_handler(tauri::generate_handler![ let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins, compute_joins,
push_native_scene, push_native_scene,
push_native_walls push_native_walls,
acquire_project_lock,
release_project_lock
]); ]);
#[cfg(not(any(feature = "native2d", feature = "native3d")))] #[cfg(not(any(feature = "native2d", feature = "native3d")))]
let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]); let builder = builder.invoke_handler(tauri::generate_handler![
compute_joins,
acquire_project_lock,
release_project_lock
]);
builder builder
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+145
View File
@@ -0,0 +1,145 @@
// Lock-Datei gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei
// App-Instanzen. Prinzip: ein OS-Advisory-Lock (Crate `fs4`, gepflegter
// `fs2`-Nachfolger) auf einer Sidecar-Datei `<projektdatei>.lock`, gehalten
// über ein offenes `File`-Handle im Tauri-`State`. Der OS-Lock fällt beim
// Prozessende AUTOMATISCH weg (auch bei Absturz) — kein manuelles Aufräumen
// nötig, keine verwaiste Sperre blockiert dauerhaft: ohne aktiven OS-Lock kann
// die nächste Instanz dieselbe Sidecar-Datei problemlos wieder exklusiv locken,
// auch wenn die Datei selbst liegen bleibt.
//
// Zusätzlich schreiben wir einen kleinen JSON-Info-Block (pid/hostname/
// timestamp) in die Sidecar-Datei, damit eine zweite Instanz bei einem
// Konflikt eine freundliche Meldung zeigen kann ("bereits geöffnet auf …").
use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
/// Info-Block in der Lock-Sidecar-Datei — wer hält den Lock gerade.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockInfo {
pub pid: u32,
pub hostname: String,
/// Unix-Zeitstempel (Sekunden) als String — reicht für eine Anzeige,
/// keine Notwendigkeit für eine Datumsbibliothek.
pub timestamp: String,
}
impl LockInfo {
fn current() -> Self {
LockInfo {
pid: std::process::id(),
hostname: hostname(),
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string()),
}
}
}
/// Bester-Versuch-Hostname ohne zusätzliche Abhängigkeit: erst Umgebungs-
/// variablen (Windows/Unix), sonst das `hostname`-Kommando, sonst "unbekannt".
fn hostname() -> String {
for var in ["COMPUTERNAME", "HOSTNAME"] {
if let Ok(h) = std::env::var(var) {
if !h.is_empty() {
return h;
}
}
}
if let Ok(out) = std::process::Command::new("hostname").output() {
if out.status.success() {
if let Ok(s) = String::from_utf8(out.stdout) {
let s = s.trim();
if !s.is_empty() {
return s.to_string();
}
}
}
}
"unbekannt".to_string()
}
/// Alle von DIESER Instanz gehaltenen Locks: Projektdatei-Pfad → offenes
/// File-Handle auf die Sidecar-Lock-Datei. Das Handle hält den OS-Lock, solange
/// es lebt (Drop/Prozessende gibt ihn automatisch frei).
#[derive(Default)]
pub struct LockState(pub Mutex<HashMap<PathBuf, File>>);
fn sidecar_path(project_path: &Path) -> PathBuf {
let mut s = project_path.as_os_str().to_os_string();
s.push(".lock");
PathBuf::from(s)
}
/// Fordert den exklusiven Lock für `project_path` an.
///
/// - Bereits von UNS gehalten (dieselbe Datei nochmal geöffnet) → `Ok(())`.
/// - Frei → OS-Lock holen, eigenen Info-Block reinschreiben, Handle im State
/// behalten, `Ok(())`.
/// - Von einer ANDEREN Instanz gehalten → `Err(Some(info))` mit deren
/// Info-Block (falls lesbar), sonst `Err(None)`.
pub fn acquire(state: &LockState, project_path: &Path) -> Result<(), Option<LockInfo>> {
let mut held = state.0.lock().expect("lock state poisoned");
if held.contains_key(project_path) {
return Ok(());
}
let sidecar = sidecar_path(project_path);
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(&sidecar)
.map_err(|_| None)?;
match FileExt::try_lock_exclusive(&file) {
Ok(()) => {
write_info(&file);
held.insert(project_path.to_path_buf(), file);
Ok(())
}
Err(_) => {
// Gesperrt von einer anderen Instanz -> deren Info-Block lesen (best effort).
Err(read_info(&sidecar))
}
}
}
/// Gibt den von uns gehaltenen Lock für `project_path` frei (schliesst das
/// File-Handle → OS-Lock fällt weg). No-op, wenn wir diesen Pfad nicht halten.
pub fn release(state: &LockState, project_path: &Path) {
let mut held = state.0.lock().expect("lock state poisoned");
held.remove(project_path);
}
/// Schreibt den aktuellen Info-Block (pid/hostname/timestamp dieser Instanz)
/// in die bereits gelockte Sidecar-Datei. Fehler hier sind nicht fatal — der
/// Lock selbst (das OS-Lock) steht unabhängig davon schon.
fn write_info(file: &File) {
let info = LockInfo::current();
let Ok(json) = serde_json::to_string(&info) else {
return;
};
let mut f = file;
let _ = f.set_len(0);
let _ = f.seek(SeekFrom::Start(0));
let _ = f.write_all(json.as_bytes());
let _ = f.flush();
}
/// Liest den Info-Block aus einer (von einer anderen Instanz gesperrten)
/// Sidecar-Datei. `None` wenn die Datei fehlt/kein gültiges JSON enthält.
fn read_info(sidecar: &Path) -> Option<LockInfo> {
let mut contents = String::new();
File::open(sidecar).ok()?.read_to_string(&mut contents).ok()?;
serde_json::from_str(&contents).ok()
}
+6
View File
@@ -344,6 +344,12 @@ fn demo_walls() -> Vec<WallInput> {
color: grey, color: grey,
openings: vec![], openings: vec![],
layers: None, layers: None,
// Additive Felder (Loecher/Material/Schnitt) — die Demo-Waende nutzen sie
// nicht; Defaults wie im Serde-Pfad.
holes: vec![],
material_index: None,
hatch: None,
cut: None,
}; };
vec![ vec![
mk([0.0, 0.0], [6.0, 0.0]), mk([0.0, 0.0], [6.0, 0.0]),
+1488
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[workspace]
[package]
name = "trucksolid"
version = "0.1.0"
edition = "2021"
description = "Profil-Extrusion via truck B-Rep → tesselliertes Mesh für render3d"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
web = [
"dep:wasm-bindgen",
"dep:serde_json",
"dep:console_error_panic_hook",
]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
console_error_panic_hook = { version = "0.1", optional = true }
# truck-modeling bringt truck-geometry/topology/polymesh transitiv mit (alle
# auf derselben kompatiblen Version). Nur truck-modeling explizit pinnen —
# keine separaten geometry/topology-Einträge, die einen Versions-Konflikt erzeugen.
truck-modeling = "0.3"
# Mesh-Ebenen-CSG (Boolesche Operationen) — bewusst NICHT truck-modeling-Booleans
# (instabil bei koinzidenten Flächen, siehe PENDENZEN.md truck-Integration Phase 4).
# csgrs arbeitet auf Dreiecks-Ebene (BSP-Baum), robust genau in diesem Fall.
# Gepinnt auf einen Git-Commit (crates.io-Veröffentlichungen 0.160.20.1 sind alle
# wegen einer harten, zurückgezogenen core2-Abhängigkeit nicht installierbar; auf
# dem unveröffentlichten main-Branch bereits behoben). Nutzerautorisiert (Spike).
csgrs = { git = "https://github.com/timschmidt/csgrs", rev = "5e7a37a8803d4e56617734687edc9b98f4ebeed7", default-features = false, features = ["f64", "bmesh", "mesh", "earcut"] }
nalgebra = "0.34"
[dev-dependencies]
serde_json = "1"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+212
View File
@@ -0,0 +1,212 @@
// Boolesche Operationen (Union/Differenz/Schnitt) zwischen zwei Dreiecks-Meshes.
// Mesh-Ebenen-CSG via csgrs (BSP-Baum) statt truck-modeling-Booleans — letztere
// sind bei koinzidenten/tangentialen Flächen instabil (siehe PENDENZEN.md,
// truck-Integration Phase 4, monstertruck-solid-Spike). csgrs erwies sich im
// Spike an genau diesem Fall (Extrusion bündig/eingebunden in eine Wand,
// deckungsgleicher Querschnitt) als exakt korrekt.
use csgrs::csg::CSG;
use csgrs::mesh::Mesh as CsgMesh;
use csgrs::polygon::Polygon;
use csgrs::triangulated::Triangulated3D;
use csgrs::vertex::Vertex;
use nalgebra::Point3;
use serde::{Deserialize, Serialize};
/// Eingabe-Mesh für eine boolesche Operation: flache f64-Positionen (Modell-
/// Meter) + Dreiecks-Indizes. Getrennt von `MeshOutput` (dort f32, da Render-
/// Ausgabe) — hier f64, da Eingabe aus Modelldaten und Präzision für die BSP-
/// Klassifikation an koinzidenten Flächen zählt.
#[derive(Deserialize)]
pub struct BooleanMeshInput {
pub a_positions: Vec<f64>,
pub a_indices: Vec<u32>,
pub b_positions: Vec<f64>,
pub b_indices: Vec<u32>,
/// "union" | "difference" | "intersection"
pub op: String,
}
#[derive(Serialize)]
pub struct BooleanMeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
/// Ob das Ergebnis leer ist (z. B. Schnitt zweier sich nur berührender
/// Körper) — csgrs liefert das als Trimesh-Fehler statt eines leeren
/// Meshes zurück, hier auf einen sauberen Fall normalisiert.
pub empty: bool,
}
fn mesh_from_triangles(positions: &[f64], indices: &[u32]) -> Result<CsgMesh<()>, String> {
if indices.len() % 3 != 0 {
return Err("indices müssen Dreiecke sein (Vielfaches von 3)".into());
}
let n_verts = positions.len() / 3;
let mut polygons = Vec::with_capacity(indices.len() / 3);
for tri in indices.chunks(3) {
let mut pts = [Point3::origin(); 3];
for (k, &i) in tri.iter().enumerate() {
let idx = i as usize;
if idx >= n_verts {
return Err(format!("Index {idx} außerhalb der Positions-Liste"));
}
let o = idx * 3;
pts[k] = Point3::new(positions[o], positions[o + 1], positions[o + 2]);
}
let normal = (pts[1] - pts[0]).cross(&(pts[2] - pts[0]));
let verts = vec![
Vertex::new(pts[0], normal),
Vertex::new(pts[1], normal),
Vertex::new(pts[2], normal),
];
polygons.push(Polygon::new(verts, ()));
}
Ok(CsgMesh::from_polygons(&polygons, ()))
}
fn triangles_from_mesh(m: &CsgMesh<()>) -> (Vec<f32>, Vec<u32>) {
let mut positions: Vec<f32> = Vec::new();
let mut indices: Vec<u32> = Vec::new();
let mut next = 0u32;
m.visit_triangles(|tri| {
for v in &tri {
positions.push(v.position.x as f32);
positions.push(v.position.y as f32);
positions.push(v.position.z as f32);
}
indices.push(next);
indices.push(next + 1);
indices.push(next + 2);
next += 3;
});
(positions, indices)
}
pub fn boolean_mesh_core(
a_positions: &[f64],
a_indices: &[u32],
b_positions: &[f64],
b_indices: &[u32],
op: &str,
) -> Result<BooleanMeshOutput, String> {
let a = mesh_from_triangles(a_positions, a_indices)?;
let b = mesh_from_triangles(b_positions, b_indices)?;
let result = match op {
"union" => a.union(&b),
"difference" => a.difference(&b),
"intersection" => a.intersection(&b),
other => return Err(format!("unbekannte boolesche Operation: {other}")),
};
let (positions, indices) = triangles_from_mesh(&result);
Ok(BooleanMeshOutput {
empty: indices.is_empty(),
positions,
indices,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cuboid(x: f64, y: f64, z: f64, w: f64, l: f64, h: f64) -> (Vec<f64>, Vec<u32>) {
let corners = [
(x, y, z),
(x + w, y, z),
(x + w, y + l, z),
(x, y + l, z),
(x, y, z + h),
(x + w, y, z + h),
(x + w, y + l, z + h),
(x, y + l, z + h),
];
let mut positions = Vec::with_capacity(24);
for (cx, cy, cz) in corners {
positions.push(cx);
positions.push(cy);
positions.push(cz);
}
// 12 Dreiecke, konsistent nach außen orientiert (Rechte-Hand-Regel).
let indices: Vec<u32> = vec![
0, 2, 1, 0, 3, 2, // unten (-z)
4, 5, 6, 4, 6, 7, // oben (+z)
0, 5, 4, 0, 1, 5, // -y
1, 6, 5, 1, 2, 6, // +x
2, 7, 6, 2, 3, 7, // +y
3, 4, 7, 3, 0, 4, // -x
];
(positions, indices)
}
fn volume_of(positions: &[f32], indices: &[u32]) -> f64 {
// Divergenztheorem (Tetraeder vom Ursprung), robust für beliebige geschlossene Dreiecksmeshes.
let mut vol = 0.0f64;
for tri in indices.chunks(3) {
let mut p = [[0.0f64; 3]; 3];
for (k, &i) in tri.iter().enumerate() {
let o = i as usize * 3;
p[k] = [
positions[o] as f64,
positions[o + 1] as f64,
positions[o + 2] as f64,
];
}
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
/ 6.0;
}
vol.abs()
}
#[test]
fn union_of_overlapping_cuboids() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "union").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 1.875).abs() < 1e-6);
}
#[test]
fn intersection_of_overlapping_cuboids() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let (bp, bi) = cuboid(0.5, 0.5, 0.5, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&ap, &ai, &bp, &bi, "intersection").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 0.125).abs() < 1e-6);
}
/// Praxisfall: Extrusion 0.1m in eine Wand eingebunden, deckungsgleicher
/// Querschnitt — genau der Fall, an dem monstertruck-solid scheiterte.
#[test]
fn wall_minus_embedded_extrusion_matching_cross_section() {
let wall = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let embedded = cuboid(0.9, 0.0, 0.0, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&wall.0, &wall.1, &embedded.0, &embedded.1, "difference").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 0.9).abs() < 1e-6);
}
#[test]
fn flush_touching_union_is_exact() {
let a = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let b = cuboid(1.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let r = boolean_mesh_core(&a.0, &a.1, &b.0, &b.1, "union").unwrap();
assert!(!r.empty);
assert!((volume_of(&r.positions, &r.indices) - 2.0).abs() < 1e-6);
}
#[test]
fn rejects_non_triangle_indices() {
let (ap, _) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
let bad_indices = vec![0u32, 1, 2, 3];
assert!(boolean_mesh_core(&ap, &bad_indices, &ap, &bad_indices, "union").is_err());
}
#[test]
fn rejects_unknown_op() {
let (ap, ai) = cuboid(0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
assert!(boolean_mesh_core(&ap, &ai, &ap, &ai, "xor").is_err());
}
}
+546
View File
@@ -0,0 +1,546 @@
// Profil-Extrusion via truck B-Rep. Erzeugt ein tesselliertes Mesh aus einem
// 2D-Polygon-Querschnitt (XY-Ebene, Modell-Meter) durch lineare Extrusion
// entlang +Z. Ausgabe ist kompatibel mit render3d::types::MeshInput.
//
// truck-Architektur:
// truck_modeling::builder — Vertex/Edge/Wire/Face bauen + tsweep (Validierung)
// Tessellierung — direkt aus den Eingangskoordinaten (Prismen-Geometrie)
// Bewusst NICHT genutzt: truck-modeling Booleans (instabil upstream).
// truck-polymesh wird nicht benötigt (keine Tess.-API für Solids in 0.3).
use serde::{Deserialize, Serialize};
use truck_modeling::*;
mod boolean;
pub use boolean::{boolean_mesh_core, BooleanMeshInput, BooleanMeshOutput};
/// Flaches [x0,y0, x1,y1, …] Array + Höhe → Extrusion.
#[derive(Deserialize)]
pub struct ExtrudePolyInput {
pub points: Vec<f64>,
pub height: f64,
/// Verjüngung 0.0 (Prisma, Default) … 1.0 (Spitze/Kegel-Pyramide). Fehlt
/// das Feld (ältere Aufrufer), greift serde-Default 0.0 — unverändertes
/// Prisma-Verhalten.
#[serde(default)]
pub taper: f64,
}
/// Tesselliertes Mesh, kompatibel mit render3d::types::MeshInput.
/// positions: flat [x,y,z, …] in Modell-Metern; indices: Dreiecks-Indizes.
#[derive(Serialize)]
pub struct MeshOutput {
pub positions: Vec<f32>,
pub indices: Vec<u32>,
}
/// Extrudiert ein geschlossenes Polygon (≥3 Punkte) um `height` Meter nach +Z,
/// optional verjüngt (`taper` 0.0 Prisma … 1.0 Spitze/Kegel-Pyramide — linear
/// zum Schwerpunkt skaliert). Nutzt truck::builder zur Validierung der
/// Grundfläche; tesselliert dann direkt aus den Koordinaten.
pub fn extrude_polygon_core(
pts: &[(f64, f64)],
height: f64,
taper: f64,
) -> std::result::Result<MeshOutput, String> {
if pts.len() < 3 {
return Err("min 3 Punkte".into());
}
if height <= 0.0 {
return Err("height muss > 0 sein".into());
}
if !(0.0..=1.0).contains(&taper) {
return Err("taper muss zwischen 0 und 1 liegen".into());
}
// Letzten Punkt entfernen falls er den ersten wiederholt (geschlossener Ring).
let ring: Vec<(f64, f64)> = {
let mut r = pts.to_vec();
if r.len() >= 2 {
let first = r[0];
let last = *r.last().unwrap();
if (first.0 - last.0).abs() < 1e-10 && (first.1 - last.1).abs() < 1e-10 {
r.pop();
}
}
r
};
if ring.len() < 3 {
return Err("min 3 eindeutige Punkte".into());
}
let n = ring.len();
// ── truck B-Rep: try_attach_plane validiert Planarität und Degenerierung ──
let verts: Vec<Vertex> = ring
.iter()
.map(|&(x, y)| builder::vertex(Point3::new(x, y, 0.0)))
.collect();
let edges: Vec<Edge> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
let wire = Wire::from_iter(edges);
// Gibt Err zurück bei degeneriertem / nicht-ebenem Profil.
let face = builder::try_attach_plane(&[wire]).map_err(|e| format!("{e}"))?;
// tsweep erzeugt den Solid — wir nutzen ihn zur Vollständigkeit, auch wenn
// wir ihn für die Tessellierung nicht direkt traversieren (nur fürs
// ungetaperte Prisma sinnvoll validiert; Verjüngung ist reine Tessellierung).
let _solid: Solid = builder::tsweep(&face, Vector3::new(0.0, 0.0, height));
let centroid = polygon_centroid(&ring);
// Deckel-/Boden-Triangulierung fuer BELIEBIGE (auch konkave) Profile per
// Ohr-Clipping — NICHT per Fächer-ab-Vertex-0 (der nur fuer konvexe bzw.
// von Vertex 0 aus sternfoermige Polygone korrekt ist; bei einem T-Traeger
// o.ae. erzeugt ein Fächer Phantom-Dreiecke quer durch die konkave Kerbe).
// `cap_tris` ist bereits in der Konvention orientiert, die der bisherige
// "Fächer ab Vertex 0" fuer KONVEXE Ringe erzeugt hat (a,b,c mit
// aufsteigendem Index), sodass die Deck-/Boden-Zuordnung unten unveraendert
// bleibt.
let cap_tris = ear_triangulate(&ring);
// Volle Verjüngung (taper ≈ 1): Spitze statt entartetem Deck-Ring — Kegel/
// Pyramide als n Boden-Vertices + 1 Spitzen-Vertex, sonst gäbe es
// Nulldreiecke (Deckfläche + halbe Seitenflächen) mit Kreuzprodukt-Normale
// (0,0,0) am Deck.
if taper >= 1.0 - 1e-9 {
let mut positions: Vec<f32> = Vec::with_capacity((n + 1) * 3);
for &(x, y) in &ring {
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
}
positions.extend_from_slice(&[centroid.0 as f32, centroid.1 as f32, height as f32]);
let apex = n as u32;
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() + n) * 3);
// Bodenfläche: wie im Prisma-Fall die zu "oben" umgekehrte Wicklung
// (CCW von unten).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[a as u32, c as u32, b as u32]);
}
// Seitenflächen: ein Dreieck je Kante zur Spitze.
for i in 0..n as u32 {
let j = (i + 1) % n as u32;
indices.extend_from_slice(&[i, j, apex]);
}
return Ok(MeshOutput { positions, indices });
}
// ── Tessellierung (Prisma bei taper=0, sonst Pyramidenstumpf) ──
// Für ein n-Eck ergeben sich:
// Bodenfläche: len(cap_tris) Dreiecke (Ohr-Clipping, konkav-sicher)
// Deckfläche: len(cap_tris) Dreiecke
// Seitenflächen: n * 2 Dreiecke (je Kante ein Rechteck → 2 Dreiecke)
let mut positions: Vec<f32> = Vec::with_capacity(2 * n * 3);
let mut indices: Vec<u32> = Vec::with_capacity((cap_tris.len() * 2 + n * 2) * 3);
// Vertices: Boden (0..n-1) gefolgt von Dach (n..2n-1) — Dach zum
// Schwerpunkt hin um (1-taper) skaliert (taper=0 ⇒ Prisma, unveränderte
// Kontur; 0<taper<1 ⇒ Pyramidenstumpf).
let scale = 1.0 - taper;
for &(x, y) in &ring {
positions.extend_from_slice(&[x as f32, y as f32, 0.0_f32]);
}
for &(x, y) in &ring {
let tx = centroid.0 + (x - centroid.0) * scale;
let ty = centroid.1 + (y - centroid.1) * scale;
positions.extend_from_slice(&[tx as f32, ty as f32, height as f32]);
}
let base = 0u32;
let top = n as u32;
// Bodenfläche: umgekehrte Wicklung relativ zur Deckfläche (CCW von unten).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[base + a as u32, base + c as u32, base + b as u32]);
}
// Deckfläche: gleiche Wicklung wie `cap_tris` (CCW von oben).
for &(a, b, c) in &cap_tris {
indices.extend_from_slice(&[top + a as u32, top + b as u32, top + c as u32]);
}
// Seitenflächen: Rechteck pro Kante → 2 Dreiecke
for i in 0..n as u32 {
let j = (i + 1) % n as u32;
// Unteres Dreieck
indices.extend_from_slice(&[base + i, base + j, top + i]);
// Oberes Dreieck
indices.extend_from_slice(&[base + j, top + j, top + i]);
}
Ok(MeshOutput { positions, indices })
}
/// Signierte Fläche (Shoelace) — positiv bei CCW-Umlauf.
fn signed_area_f64(ring: &[(f64, f64)]) -> f64 {
let n = ring.len();
let mut a = 0.0;
let mut j = n - 1;
for i in 0..n {
a += ring[j].0 * ring[i].1 - ring[i].0 * ring[j].1;
j = i;
}
a * 0.5
}
/// Flächen-gewichteter Schwerpunkt eines einfachen Polygons (nicht der reine
/// Vertex-Mittelwert, der bei asymmetrischen/konkaven Profilen verzerrt ist).
/// Fallback auf Vertex-Mittelwert bei (nahezu) entarteter Fläche.
fn polygon_centroid(ring: &[(f64, f64)]) -> (f64, f64) {
let n = ring.len();
let a = signed_area_f64(ring);
if a.abs() < 1e-12 {
let (sx, sy) = ring.iter().fold((0.0, 0.0), |(sx, sy), &(x, y)| (sx + x, sy + y));
return (sx / n as f64, sy / n as f64);
}
let mut cx = 0.0;
let mut cy = 0.0;
for i in 0..n {
let (x0, y0) = ring[i];
let (x1, y1) = ring[(i + 1) % n];
let cross = x0 * y1 - x1 * y0;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}
(cx / (6.0 * a), cy / (6.0 * a))
}
/// Kreuzprodukt (b-a) x (c-a) im Grundriss.
#[inline]
fn cross2_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
/// Liegt p im (a,b,c)-Dreieck? (CCW-orientiert).
fn point_in_tri_f64(a: (f64, f64), b: (f64, f64), c: (f64, f64), p: (f64, f64)) -> bool {
let d1 = cross2_f64(a, b, p);
let d2 = cross2_f64(b, c, p);
let d3 = cross2_f64(c, a, p);
let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
!(has_neg && has_pos)
}
/// Ear-Clipping-Triangulierung eines einfachen (lochfreien) Rings — robust fuer
/// konvexe UND konkave Profile (z. B. T-Traeger, L-Profil, Freiform). Portiert
/// von `render3d::mesh::triangulate` (dort fuer Decken/Slabs genutzt) auf f64.
/// O(n^2), fuer Extrusionsprofile mit wenigen Ecken voellig ausreichend.
/// Liefert Dreiecke als (a,b,c)-Indextripel (0-basiert auf `ring`), in der
/// Wicklung des (intern auf CCW normalisierten) Rings.
fn ear_triangulate(ring: &[(f64, f64)]) -> Vec<(usize, usize, usize)> {
let n = ring.len();
if n < 3 {
return Vec::new();
}
let mut idx: Vec<usize> = (0..n).collect();
if signed_area_f64(ring) < 0.0 {
idx.reverse();
}
let mut tris: Vec<(usize, usize, usize)> = Vec::new();
let mut guard = 0usize;
let max_guard = n * n + 16;
while idx.len() > 3 && guard < max_guard {
guard += 1;
let mut clipped = false;
let m = idx.len();
for i in 0..m {
let i_prev = idx[(i + m - 1) % m];
let i_cur = idx[i];
let i_next = idx[(i + 1) % m];
let a = ring[i_prev];
let b = ring[i_cur];
let c = ring[i_next];
if cross2_f64(a, b, c) <= 0.0 {
continue; // konkav/kollinear -> kein Ohr
}
let mut contains = false;
for &vi in &idx {
if vi == i_prev || vi == i_cur || vi == i_next {
continue;
}
if point_in_tri_f64(a, b, c, ring[vi]) {
contains = true;
break;
}
}
if contains {
continue;
}
tris.push((i_prev, i_cur, i_next));
idx.remove(i);
clipped = true;
break;
}
if !clipped {
break;
}
}
if idx.len() == 3 {
tris.push((idx[0], idx[1], idx[2]));
}
tris
}
/// Zylinder-Extrusion (Kreis-Querschnitt), optional verjüngt (Kegel bei
/// `taper=1.0`). Tesselliert den Kreis in N Segmente und ruft
/// `extrude_polygon_core` auf.
pub fn extrude_circle_core(
cx: f64,
cy: f64,
r: f64,
height: f64,
taper: f64,
) -> std::result::Result<MeshOutput, String> {
if r <= 0.0 {
return Err("Radius muss > 0 sein".into());
}
let n = ((2.0 * std::f64::consts::PI * r / 0.02).ceil() as usize).max(16);
let pts: Vec<(f64, f64)> = (0..n)
.map(|i| {
let a = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
extrude_polygon_core(&pts, height, taper)
}
// ── WASM-Bindings (nur mit Feature "web") ────────────────────────────────────
#[cfg(feature = "web")]
mod web {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn init() {
console_error_panic_hook::set_once();
}
/// Extrudiert ein Polygon-Profil.
/// Input JSON: `{ "points": [x0,y0,…], "height": 2.5 }`
/// Output JSON: `{ "positions": […], "indices": […] }` oder JsError.
#[wasm_bindgen]
pub fn extrude_polygon(input_json: &str) -> std::result::Result<String, JsError> {
let input: ExtrudePolyInput =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
if input.points.len() % 2 != 0 {
return Err(JsError::new("points muss x/y-Paare enthalten"));
}
let pts: Vec<(f64, f64)> = input
.points
.chunks(2)
.map(|c| (c[0], c[1]))
.collect();
let mesh = extrude_polygon_core(&pts, input.height, input.taper)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Extrudiert einen Kreis-Querschnitt (Zylinder, oder Kegel bei `taper=1`).
/// Input JSON: `{ "cx": 0, "cy": 0, "r": 0.15, "height": 3.0, "taper": 0.0 }`
#[wasm_bindgen]
pub fn extrude_circle(input_json: &str) -> std::result::Result<String, JsError> {
#[derive(serde::Deserialize)]
struct In {
cx: f64,
cy: f64,
r: f64,
height: f64,
#[serde(default)]
taper: f64,
}
let i: In =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
let mesh = extrude_circle_core(i.cx, i.cy, i.r, i.height, i.taper)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
/// Boolesche Operation zwischen zwei Dreiecks-Meshes (Mesh-Ebenen-CSG via
/// csgrs). Input JSON: `{ "a_positions": […], "a_indices": […],
/// "b_positions": […], "b_indices": […], "op": "union"|"difference"|"intersection" }`
/// Output JSON: `{ "positions": […], "indices": […], "empty": bool }`
#[wasm_bindgen]
pub fn boolean_mesh(input_json: &str) -> std::result::Result<String, JsError> {
let input: BooleanMeshInput =
serde_json::from_str(input_json).map_err(|e| JsError::new(&e.to_string()))?;
let mesh = boolean_mesh_core(
&input.a_positions,
&input.a_indices,
&input.b_positions,
&input.b_indices,
&input.op,
)
.map_err(|e| JsError::new(&e))?;
serde_json::to_string(&mesh).map_err(|e| JsError::new(&e.to_string()))
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quad_extrusion_vertex_count() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
let m = extrude_polygon_core(&pts, 2.0, 0.0).unwrap();
// 2 * 4 = 8 Vertices → 24 Floats
assert_eq!(m.positions.len(), 8 * 3, "Vertex-Anzahl");
assert_eq!(m.indices.len() % 3, 0, "unvollständige Dreiecke");
// Quader: 2*(4-2) + 4*2 = 4 + 8 = 12 Dreiecke
assert_eq!(m.indices.len(), 12 * 3, "Dreieck-Anzahl für Quader");
}
#[test]
fn l_profile_extrusion() {
let pts = vec![
(0.0, 0.0),
(0.3, 0.0),
(0.3, 0.1),
(0.1, 0.1),
(0.1, 0.3),
(0.0, 0.3),
];
let m = extrude_polygon_core(&pts, 3.0, 0.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(!m.positions.is_empty());
}
#[test]
fn cylinder_extrusion() {
let m = extrude_circle_core(0.0, 0.0, 0.15, 3.0, 0.0).unwrap();
assert_eq!(m.indices.len() % 3, 0);
assert!(!m.positions.is_empty());
}
#[test]
fn rejects_too_few_points() {
assert!(extrude_polygon_core(&[(0.0, 0.0), (1.0, 0.0)], 1.0, 0.0).is_err());
}
#[test]
fn rejects_zero_height() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
assert!(extrude_polygon_core(&pts, 0.0, 0.0).is_err());
}
#[test]
fn rejects_taper_out_of_range() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)];
assert!(extrude_polygon_core(&pts, 1.0, 1.5).is_err());
assert!(extrude_polygon_core(&pts, 1.0, -0.1).is_err());
}
/// Kegel-Volumen (taper=1, Kreis-Querschnitt) via Divergenzsatz gegen die
/// analytische Formel V = π·r²·h/3 geprüft.
#[test]
fn cone_taper_one_has_correct_volume() {
let r = 0.5;
let h = 2.0;
let m = extrude_circle_core(0.0, 0.0, r, h, 1.0).unwrap();
// Spitze ist der letzte Vertex — Boden-Vertices sind alle bei z=0, kein
// separater Deck-Ring mehr (kein Nulldreieck-Risiko).
assert_eq!(m.positions.len() % 3, 0);
let n_verts = m.positions.len() / 3;
// Boden-N-Eck + 1 Spitze.
let apex_z = m.positions[(n_verts - 1) * 3 + 2];
assert!((apex_z as f64 - h).abs() < 1e-6, "Spitze muss bei z=height liegen");
let mut vol = 0.0f64;
for tri in m.indices.chunks(3) {
let mut p = [[0.0f64; 3]; 3];
for (k, &i) in tri.iter().enumerate() {
let o = i as usize * 3;
p[k] = [m.positions[o] as f64, m.positions[o + 1] as f64, m.positions[o + 2] as f64];
}
vol += (p[0][0] * (p[1][1] * p[2][2] - p[2][1] * p[1][2])
- p[0][1] * (p[1][0] * p[2][2] - p[2][0] * p[1][2])
+ p[0][2] * (p[1][0] * p[2][1] - p[2][0] * p[1][1]))
/ 6.0;
}
let expected = std::f64::consts::PI * r * r * h / 3.0;
// Kreis ist n-eck-tesselliert (nicht analytisch exakt) → grobe Toleranz.
assert!((vol.abs() - expected).abs() / expected < 0.01, "Kegel-Volumen: {} vs {}", vol.abs(), expected);
}
/// Pyramidenstumpf (taper=0.5, Quadrat-Querschnitt): Deckfläche muss auf
/// die Hälfte der Kantenlänge geschrumpft sein, zentriert um den
/// Schwerpunkt der Grundfläche.
#[test]
fn frustum_taper_half_shrinks_top_toward_centroid() {
let pts = vec![(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)];
let m = extrude_polygon_core(&pts, 1.0, 0.5).unwrap();
// Prisma-Topologie bleibt erhalten (2n Vertices, kein Spitzen-Sonderfall).
assert_eq!(m.positions.len(), 8 * 3);
// Deck-Ring: Vertices 4..7. Schwerpunkt der Grundfläche ist (1,1).
// scale=0.5 ⇒ Ecke (0,0)→(1,1)+0.5*((0,0)-(1,1))=(0.5,0.5).
let top0 = (m.positions[4 * 3] as f64, m.positions[4 * 3 + 1] as f64);
assert!((top0.0 - 0.5).abs() < 1e-6 && (top0.1 - 0.5).abs() < 1e-6, "Deck-Ecke: {:?}", top0);
}
/// Punkt-in-Polygon (Ray-Casting) für die Korrektheits-Prüfung unten.
fn point_in_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
let n = poly.len();
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (xi, yi) = poly[i];
let (xj, yj) = poly[j];
if ((yi > p.1) != (yj > p.1)) && (p.0 < (xj - xi) * (p.1 - yi) / (yj - yi) + xi) {
inside = !inside;
}
j = i;
}
inside
}
/// T-Träger-Querschnitt (konkav, NICHT von Vertex 0 aus sternförmig) —
/// genau das Zielprofil aus PENDENZEN/truck-plan.md. Eine Fächer-Triangu-
/// lierung ab Vertex 0 erzeugt hier Phantom-Dreiecke quer durch die
/// konkave Kerbe (nachgerechnet: 2 von 6 Fächer-Dreiecken liegen mit ihrem
/// Schwerpunkt ausserhalb des Profils). Regressionstest fürs Ohr-Clipping.
#[test]
fn t_beam_caps_stay_inside_polygon() {
let pts = vec![
(1.0, 0.0),
(2.0, 0.0),
(2.0, 1.0),
(3.0, 1.0),
(3.0, 2.0),
(0.0, 2.0),
(0.0, 1.0),
(1.0, 1.0),
];
let m = extrude_polygon_core(&pts, 1.0, 0.0).unwrap();
let mut checked = 0;
for tri in m.indices.chunks(3) {
let p: Vec<[f32; 3]> = tri
.iter()
.map(|&i| {
let o = i as usize * 3;
[m.positions[o], m.positions[o + 1], m.positions[o + 2]]
})
.collect();
// Nur Deckel/Boden (alle 3 Ecken auf derselben Höhe) prüfen, nicht
// die Seitenwand-Quads (die spannen z zwischen 0 und height).
if (p[0][2] - p[1][2]).abs() > 1e-6 || (p[1][2] - p[2][2]).abs() > 1e-6 {
continue;
}
let centroid = (
(p[0][0] + p[1][0] + p[2][0]) as f64 / 3.0,
(p[0][1] + p[1][1] + p[2][1]) as f64 / 3.0,
);
assert!(
point_in_polygon(centroid, &pts),
"Deckel-/Boden-Dreieck-Schwerpunkt {:?} liegt ausserhalb des T-Profils",
centroid
);
checked += 1;
}
assert!(checked > 0, "kein Deckel-/Boden-Dreieck gefunden");
}
}
+1488 -70
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
// Stütze (Column) als Engine-Befehl — DOSSIER-Audit A4 Tragwerk. Ablauf:
// 1) „Stütze setzen:" → Klick setzt den Einfügepunkt (Profil-Mitte) → commit.
//
// Optionen (togglebar, IMMER sichtbar): Profil Rechteck/Rund. Eine getippte Zahl
// setzt das Leitmass (Rechteck: Seitenlänge → quadratisch; Rund: Durchmesser)
// PERSISTENT über Aufrufe — analog `offset`/`extrude`, wo der pick-Schritt auch
// eine Zahl annimmt. Höhe = Geschosshöhe des aktiven Geschosses (Default).
//
// Herkunft der Stützen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "50" (Tragwerk; Fallback ctx.defaultCategoryCode)
// • height ← Geschosshöhe (ctx.level.floorHeight), Fallback DEF_HEIGHT
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Column, ColumnProfile } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { columnFootprint } from "../../geometry/column";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
Project,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Stützen: „50" Tragwerk (Fallback: aktive). */
const COLUMN_CATEGORY = "50";
/** Default-Kantenlänge eines Rechteckprofils (Meter). */
const DEF_SIZE = 0.3;
/** Fallback-Höhe, falls das Geschoss keine floorHeight trägt (Meter). */
const DEF_HEIGHT = 2.6;
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
const columnDefaults: { kind: "rect" | "round"; size: number } = {
kind: "rect",
size: DEF_SIZE,
};
/** Baut das aktuelle Default-Profil aus `columnDefaults`. */
function currentProfile(): ColumnProfile {
if (columnDefaults.kind === "round") {
return { kind: "round", radius: columnDefaults.size / 2 };
}
return { kind: "rect", width: columnDefaults.size, depth: columnDefaults.size };
}
/** Fügt die Stütze immutabel an `project.columns` an. */
function appendColumn(
p: Project,
position: Vec2,
ctx: CommandContext,
): Project {
const height =
ctx.level.floorHeight != null && ctx.level.floorHeight > 0
? ctx.level.floorHeight
: DEF_HEIGHT;
const column: Column = {
id: uniqueId("column"),
type: "column",
floorId: ctx.level.id,
categoryCode: COLUMN_CATEGORY,
position,
profile: currentProfile(),
rotation: 0,
height,
};
return { ...p, columns: [...(p.columns ?? []), column] };
}
interface ColState extends CommandState {
phase: "place" | "done";
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true },
];
/** Footprint-Vorschau des aktuellen Default-Profils am Cursor. */
function previewAt(pt: Vec2): CommandResult {
const ghost: Column = {
id: "_preview",
type: "column",
floorId: "",
categoryCode: COLUMN_CATEGORY,
position: pt,
profile: currentProfile(),
rotation: 0,
height: 0,
};
const size =
columnDefaults.kind === "round"
? `Ø${(columnDefaults.size * 100).toFixed(0)}`
: `${(columnDefaults.size * 100).toFixed(0)}×${(columnDefaults.size * 100).toFixed(0)}`;
return {
draft: {
preview: [{ kind: "poly", pts: columnFootprint(ghost), closed: true }],
vertices: [],
hud: { at: pt, text: size },
},
};
}
export const columnCommand: Command = {
name: "column",
labelKey: "cmd.column.label",
prompt: () => "cmd.column.place",
accepts: () => ["point", "number", "option"],
options: (): CmdOption[] => [
{
id: "profile",
labelKey: "cmd.column.profile",
value: columnDefaults.kind === "round" ? "round" : "rect",
},
],
floorOnly: true,
init: (): ColState => ({ phase: "place", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase === "done") return finish();
if (input.kind === "number") {
const v = Math.abs(input.value);
if (v > 1e-4) columnDefaults.size = v;
return [s, { draft: null }];
}
if (input.kind === "option" && input.id === "profile") {
columnDefaults.kind = columnDefaults.kind === "round" ? "rect" : "round";
return [s, { draft: null }];
}
if (input.kind === "point") {
return [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true, commit: (p) => appendColumn(p, input.point, ctx) },
];
}
return [s, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase !== "place") return [s, { draft: null }];
return [s, previewAt(point)];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
+12 -2
View File
@@ -42,10 +42,20 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
} }
function hasSelection(sel: CommandSelection): boolean { function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null; return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
} }
function toTransformSel(sel: CommandSelection): TransformSelection { function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId }; return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
} }
interface CopyBase extends CommandState { interface CopyBase extends CommandState {
+287
View File
@@ -0,0 +1,287 @@
// Extrudieren — truck-Integration Phase 3 (docs/design/truck-plan.md, PENDENZEN
// "truck-Integration"). Erzeugt aus einer geschlossenen 2D-Kurve (Polylinie-Ring,
// Rechteck oder Kreis) einen 3D-Körper: Schritte:
// 1) Profil bestimmen: ist genau EIN passendes geschlossenes Drawing2D
// selektiert, wird es genommen; sonst „Profil wählen:" → Klick auf einen Ring.
// 2) „Höhe:" → Zahl tippen (PERSISTENT als Default über Aufrufe) ODER Enter
// bestätigt den zuletzt genutzten Default.
// 3) „Verjüngung:" → Zahl 0..1 tippen (0=Prisma/Default, 1=Spitze — Kegel bei
// Kreis-, Pyramide bei Polygon-Profil) ODER Enter für den zuletzt
// genutzten Default (persistent, wie Höhe). → commit neues ExtrudedSolid.
//
// Die eigentliche Extrusion (truck-WASM) läuft asynchron erst in toWalls3d.ts
// (emitExtrudedSolids); dieser Befehl legt nur die Rohdaten (Punkte + Höhe +
// Verjüngung) im Projekt ab.
import type { Drawing2D, ExtrudedSolid } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { polylineEdges, pointSegmentDistance } from "../../geometry/kernel2d";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Profil-Wahl
/** Persistente Extrusionshöhe über Aufrufe hinweg (Default: Geschosshöhe). */
let lastHeight = 2.6;
/** Persistente Verjüngung über Aufrufe hinweg (Default: Prisma, kein Kegel/Pyramide). */
let lastTaper = 0;
// ── Profil-Abstraktion: geschlossene polyline/rect/circle → Punktliste ─────
/** Kreis-Tessellierung für Footprint/Auswahl/bbox (48-Eck, wie circle.ts). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Ein extrudierfähiges Profil: Tessellierung + optional die echte Kreis-Geometrie. */
interface Profile {
pts: Vec2[];
circle?: { center: Vec2; r: number };
}
/** Wandelt ein extrudierfähiges (geschlossenes) Drawing2D in ein Profil. */
function geomToProfile(d: Drawing2D): Profile | null {
const g = d.geom;
if (g.shape === "polyline" && g.closed && g.pts.length >= 3) return { pts: g.pts };
if (g.shape === "rect") {
return { pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }] };
}
if (g.shape === "circle") {
return { pts: circlePts(g.center, g.r), circle: { center: g.center, r: g.r } };
}
return null;
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere sein Profil. */
function singleSelectedProfile(
project: Project,
sel: CommandSelection,
): ({ d: Drawing2D } & Profile) | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (!d) return null;
const profile = geomToProfile(d);
return profile ? { d, ...profile } : null;
}
/** Nächstes extrudierfähige Drawing2D zum Klickpunkt (innerhalb Toleranz). */
function pickProfileAt(
project: Project,
levelId: string,
pt: Vec2,
): ({ d: Drawing2D } & Profile) | null {
let best: ({ d: Drawing2D } & Profile) | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const profile = geomToProfile(d);
if (!profile) continue;
for (const [a, b] of polylineEdges(profile.pts, true)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = { d, ...profile };
}
}
}
return best;
}
/**
* Hängt den neuen Körper an UND entfernt das Quell-Profil (`sourceId`) aus
* `drawings2d` die Polylinie/das Rechteck WIRD zur Extrusion, sie bleibt
* nicht als unabhängiges Duplikat liegen (sonst zwei Objekte am selben Ort,
* eines davon im Grundriss unsichtbar verwaist).
*/
function appendExtrudedSolid(
p: Project,
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): Project {
const solid: ExtrudedSolid = {
id: uniqueId("extrude"),
type: "extrudedSolid",
levelId: ctx.level.id,
points: profile.pts,
height,
circle: profile.circle,
...(taper > 0 ? { taper } : {}),
};
return {
...p,
drawings2d: p.drawings2d.filter((d) => d.id !== sourceId),
extrudedSolids: [...(p.extrudedSolids ?? []), solid],
};
}
// ── Zustand ───────────────────────────────────────────────────────────────────
interface ExPick extends CommandState {
phase: "pick";
}
interface ExHeight extends CommandState {
phase: "height";
profile: Profile;
/** Id des Quell-Drawing2D, das beim Commit ersetzt (entfernt) wird. */
sourceId: string;
cursor: Vec2 | null;
}
interface ExTaper extends CommandState {
phase: "taper";
profile: Profile;
sourceId: string;
height: number;
cursor: Vec2 | null;
}
interface ExDone extends CommandState {
phase: "done";
}
type ExState = ExPick | ExHeight | ExTaper | ExDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true },
];
/** Höhe entgegengenommen → Verjüngungs-Schritt betreten (HUD zeigt Default). */
const enterTaperPhase = (
profile: Profile,
height: number,
sourceId: string,
): [CommandState, CommandResult] => [
{ phase: "taper", profile, sourceId, height, cursor: null } as ExTaper,
{ draft: null },
];
const commitTaper = (
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true, commit: (p) => appendExtrudedSolid(p, profile, height, taper, sourceId, ctx) },
];
export const extrudeCommand: Command = {
name: "extrude",
labelKey: "cmd.extrude.label",
prompt: (s) => {
const phase = (s as ExState).phase;
if (phase === "height") return "cmd.extrude.height";
if (phase === "taper") return "cmd.extrude.taper";
return "cmd.extrude.pick";
},
// Auch der „pick"-Schritt nimmt eine Zahl an, damit eine getippte Höhe sofort
// greift, wenn ein Profil vorselektiert ist (dann wird „pick" live wie
// „height" behandelt — analog offset.ts).
accepts: () => ["point", "number"],
options: () => [],
floorOnly: true,
init: (): ExPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "done") return finish();
if (s.phase === "pick") {
const pre = singleSelectedProfile(ctx.project, ctx.selection);
if (pre) {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(pre, h, pre.d.id);
}
// Punkt im vorselektierten Fall: Höhen-Schritt betreten (HUD zeigt Default).
const ns: ExHeight = { phase: "height", profile: pre, sourceId: pre.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
if (input.kind === "point") {
const hit = pickProfileAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: ExHeight = { phase: "height", profile: hit, sourceId: hit.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (s.phase === "height") {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(s.profile, h, s.sourceId);
}
return [s, { draft: null }];
}
// phase === "taper"
const ts = s as ExTaper;
if (input.kind === "number") {
const t = Math.min(1, Math.max(0, input.value));
lastTaper = t;
return commitTaper(ts.profile, ts.height, t, ts.sourceId, ctx);
}
return [ts, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") {
const ns: ExHeight = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: `${lastHeight.toFixed(2)} m` },
},
},
];
}
if (s.phase === "taper") {
const ns: ExTaper = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: lastTaper.toFixed(2) },
},
},
];
}
return [s, { draft: null }];
},
// Enter/Rechtsklick: Höhen-Schritt → Default-Höhe übernehmen + Verjüngungs-
// Schritt betreten; Verjüngungs-Schritt → Default-Verjüngung übernehmen + committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") return enterTaperPhase(s.profile, lastHeight, s.sourceId);
if (s.phase === "taper") return commitTaper(s.profile, s.height, lastTaper, s.sourceId, ctx);
return finish();
},
onCancel: (): [CommandState, CommandResult] => finish(),
};
+74
View File
@@ -0,0 +1,74 @@
// Messen — schnelles, unverbindliches Distanz-/Winkel-Messwerkzeug (kein Element
// wird erzeugt). Schritte:
// 1) „Messen von:" → Punkt (Startpunkt)
// 2) „bis:" → Live-Vorschau (Messlinie + Distanz·Winkel im HUD); Klick
// setzt einen neuen Startpunkt (fortlaufend messen).
// Esc/Enter beendet. Rein visuell: `commit` bleibt immer leer, das Modell ändert
// sich nie — nur der Entwurf (Linie + HUD) zeigt das Mass.
import type {
Command,
CommandResult,
CommandState,
ToolDraft,
Vec2,
} from "../types";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
interface MeasureStart extends CommandState {
phase: "start";
}
interface MeasureTo extends CommandState {
phase: "to";
from: Vec2;
cursor: Vec2 | null;
}
type MeasureState = MeasureStart | MeasureTo;
/** Vorschau: Messlinie from→cursor + Distanz·Winkel im HUD am Cursor. */
function measureDraft(from: Vec2, cursor: Vec2): ToolDraft {
const dist = segLen(from, cursor);
const ang = ((segAngleDeg(from, cursor) % 360) + 360) % 360;
return {
preview: [{ kind: "line", a: from, b: cursor }],
vertices: [from, cursor],
hud: { at: cursor, text: `${dist.toFixed(3)} m · ${ang.toFixed(1)}°` },
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as MeasureStart,
{ draft: null, done: true },
];
export const measureCommand: Command = {
name: "measure",
labelKey: "cmd.measure.label",
prompt: (s) => ((s as MeasureState).phase === "to" ? "cmd.measure.to" : "cmd.measure.start"),
accepts: () => ["point"],
options: () => [],
init: (): MeasureStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "to" && s.cursor ? measureDraft(s.from, s.cursor) : null }];
}
// Jeder Klick setzt den (neuen) Startpunkt — fortlaufendes Messen, bis Esc/Enter.
const ns: MeasureTo = { phase: "to", from: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: measureDraft(input.point, input.point) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (s.phase !== "to") return [s, { draft: null }];
const ns: MeasureTo = { ...s, cursor: point };
return [ns, { draft: measureDraft(s.from, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+12 -2
View File
@@ -32,12 +32,22 @@ const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */ /** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean { function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null; return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
} }
/** Auswahl → TransformSelection (gleiche Form). */ /** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection { function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId }; return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
} }
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot). // Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
+12 -2
View File
@@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
/** Hat die Auswahl überhaupt etwas Verschiebbares? */ /** Hat die Auswahl überhaupt etwas Verschiebbares? */
function hasSelection(sel: CommandSelection): boolean { function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null; return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
} }
/** Auswahl → TransformSelection (gleiche Form). */ /** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection { function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId }; return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
} }
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start). // Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
+76
View File
@@ -0,0 +1,76 @@
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"}
//
// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die
// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der
// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
Vec2,
} from "../types";
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
interface TextIdle extends CommandState {
phase: "point";
}
interface TextLabel extends CommandState {
phase: "label";
at: Vec2;
}
type TextState = TextIdle | TextLabel;
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const textCommand: Command = {
name: "text",
labelKey: "cmd.text.label",
prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"),
accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TextState;
if (s.phase !== "label") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point };
return [ns, { draft: null }];
}
if (input.kind !== "text") return [s, { draft: null }];
const at = s.at;
return [
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
];
},
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+127
View File
@@ -0,0 +1,127 @@
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte:
// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte)
// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite;
// Live-Vorschau der Breitenlinie)
// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width}
//
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion,
// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
/** Kleinste sinnvolle Spaltenbreite (Meter). */
const MIN_WIDTH_M = 0.2;
interface TbPoint extends CommandState {
phase: "point";
}
interface TbWidth extends CommandState {
phase: "width";
at: Vec2;
cursor: Vec2 | null;
}
interface TbLabel extends CommandState {
phase: "label";
at: Vec2;
width: number;
}
type TbState = TbPoint | TbWidth | TbLabel;
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
function widthOf(at: Vec2, cursor: Vec2): number {
return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x));
}
/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */
function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
const w = widthOf(at, cursor);
const b: Vec2 = { x: at.x + w, y: at.y };
return {
preview: [{ kind: "line", a: at, b }],
vertices: [at, b],
hud: { at: b, text: `${w.toFixed(2)} m` },
};
}
function appendTextbox(
p: Project,
at: Vec2,
width: number,
text: string,
ctx: CommandContext,
): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true },
];
export const textboxCommand: Command = {
name: "textbox",
labelKey: "cmd.textbox.label",
prompt: (s) => {
const ts = s as TbState;
if (ts.phase === "label") return "cmd.textbox.enter";
if (ts.phase === "width") return "cmd.textbox.width";
return "cmd.textbox.point";
},
accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase === "point") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: widthDraft(input.point, input.point) }];
}
if (s.phase === "width") {
if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }];
const width = widthOf(s.at, input.point);
const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point };
return [ns, { draft: null }];
}
// label
if (input.kind !== "text") return [s, { draft: null }];
const { at, width } = s;
return [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase !== "width") return [s, { draft: null }];
const ns: TbWidth = { ...s, cursor: point };
return [ns, { draft: widthDraft(s.at, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+8
View File
@@ -328,6 +328,14 @@ export class CommandEngine {
} }
} }
// Freitext-Schritt (z. B. Text-Platzierung): der Schritt nimmt beliebigen
// Text als Label — die komplette getippte Zeile geht 1:1 durch (auch Zahlen/
// Kommas), bevor die numerische/Koordinaten-Auflösung greift.
if (accepts.includes("text")) {
this.feed({ kind: "text", text });
return;
}
const parsed = parseInput(text); const parsed = parseInput(text);
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als // Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
+16
View File
@@ -10,18 +10,23 @@ import { wallCommand } from "./cmds/wall";
import { ceilingCommand } from "./cmds/ceiling"; import { ceilingCommand } from "./cmds/ceiling";
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening"; import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
import { stairCommand } from "./cmds/stair"; import { stairCommand } from "./cmds/stair";
import { columnCommand } from "./cmds/column";
import { roomCommand } from "./cmds/room"; import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect"; import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle"; import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc"; import { arcCommand } from "./cmds/arc";
import { textCommand } from "./cmds/text";
import { textboxCommand } from "./cmds/textbox";
import { moveCommand } from "./cmds/move"; import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror"; import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join"; import { joinCommand } from "./cmds/join";
import { copyCommand } from "./cmds/copy"; import { copyCommand } from "./cmds/copy";
import { offsetCommand } from "./cmds/offset"; import { offsetCommand } from "./cmds/offset";
import { extrudeCommand } from "./cmds/extrude";
import { trimCommand } from "./cmds/trim"; import { trimCommand } from "./cmds/trim";
import { importCommand } from "./cmds/import"; import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain"; import { terrainCommand } from "./cmds/terrain";
import { measureCommand } from "./cmds/measure";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */ /** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = { export const COMMANDS: Record<string, Command> = {
@@ -31,20 +36,25 @@ export const COMMANDS: Record<string, Command> = {
fenster: fensterCommand, fenster: fensterCommand,
tuer: tuerCommand, tuer: tuerCommand,
stair: stairCommand, stair: stairCommand,
column: columnCommand,
room: roomCommand, room: roomCommand,
line: lineCommand, line: lineCommand,
polyline: polylineCommand, polyline: polylineCommand,
rect: rectCommand, rect: rectCommand,
circle: circleCommand, circle: circleCommand,
arc: arcCommand, arc: arcCommand,
text: textCommand,
textbox: textboxCommand,
move: moveCommand, move: moveCommand,
mirror: mirrorCommand, mirror: mirrorCommand,
join: joinCommand, join: joinCommand,
copy: copyCommand, copy: copyCommand,
offset: offsetCommand, offset: offsetCommand,
extrude: extrudeCommand,
trim: trimCommand, trim: trimCommand,
import: importCommand, import: importCommand,
terrain: terrainCommand, terrain: terrainCommand,
measure: measureCommand,
}; };
/** /**
@@ -63,6 +73,8 @@ export const ALIASES: Record<string, string> = {
oe: "opening", oe: "opening",
treppe: "stair", treppe: "stair",
tp: "stair", tp: "stair",
stuetze: "column",
stütze: "column",
raum: "room", raum: "room",
rm: "room", rm: "room",
l: "line", l: "line",
@@ -71,6 +83,9 @@ export const ALIASES: Record<string, string> = {
c: "circle", c: "circle",
a: "arc", a: "arc",
bogen: "arc", bogen: "arc",
tx: "text",
txt: "text",
beschriftung: "text",
m: "move", m: "move",
s: "mirror", s: "mirror",
spiegeln: "mirror", spiegeln: "mirror",
@@ -78,6 +93,7 @@ export const ALIASES: Record<string, string> = {
verbinden: "join", verbinden: "join",
cp: "copy", cp: "copy",
o: "offset", o: "offset",
ex: "extrude",
tr: "trim", tr: "trim",
imp: "import", imp: "import",
ter: "terrain", ter: "terrain",
+5 -1
View File
@@ -19,7 +19,7 @@ export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
// ── Eingabe-Arten ──────────────────────────────────────────────────────────── // ── Eingabe-Arten ────────────────────────────────────────────────────────────
/** Arten von Eingaben, die ein Schritt annehmen kann. */ /** Arten von Eingaben, die ein Schritt annehmen kann. */
export type AcceptKind = "point" | "number" | "option" | "selection"; export type AcceptKind = "point" | "number" | "option" | "text" | "selection";
/** /**
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der * Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
@@ -67,6 +67,10 @@ export interface CommandSelection {
* `drawingId` (falls gesetzt) als einziges Element. * `drawingId` (falls gesetzt) als einziges Element.
*/ */
drawingIds?: string[]; drawingIds?: string[];
/** Gewählter extrudierter Körper (truck-Integration), falls einer selektiert ist. */
extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk), falls eine selektiert ist. */
columnId?: string | null;
} }
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */ /** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
+70
View File
@@ -0,0 +1,70 @@
let modulePromise: Promise<any> | null = null;
async function getModule(): Promise<any> {
if (!modulePromise) {
modulePromise = import("./pkgTruck/trucksolid.js").then(async (m: any) => {
await m.default();
return m;
});
}
return modulePromise;
}
export interface ExtrudedMesh {
positions: number[];
indices: number[];
}
/** `taper` 0 (Prisma, Default) 1 (Spitze/Kegel-Pyramide) linear zum
* Profil-Schwerpunkt skaliert. */
export async function extrudePolygon(
points: number[],
height: number,
taper = 0,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_polygon(JSON.stringify({ points, height, taper }));
return JSON.parse(json) as ExtrudedMesh;
}
export async function extrudeCircle(
cx: number,
cy: number,
r: number,
height: number,
taper = 0,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height, taper }));
return JSON.parse(json) as ExtrudedMesh;
}
export type BooleanOp = "union" | "difference" | "intersection";
export interface BooleanMeshResult {
positions: number[];
indices: number[];
empty: boolean;
}
/** Boolesche Operation zweier Dreiecks-Meshes (Mesh-Ebenen-CSG via csgrs,
* truck-Integration Phase 4). Beide Meshes müssen konsistent nach außen
* gewundene Dreiecke haben (Rechte-Hand-Regel) sonst liefert csgrs ein
* falsches Ergebnis, ohne das zu erkennen. */
export async function booleanMesh(
a: ExtrudedMesh,
b: ExtrudedMesh,
op: BooleanOp,
): Promise<BooleanMeshResult> {
const m = await getModule();
const json = m.boolean_mesh(
JSON.stringify({
a_positions: a.positions,
a_indices: a.indices,
b_positions: b.positions,
b_indices: b.indices,
op,
}),
);
return JSON.parse(json) as BooleanMeshResult;
}
+10
View File
@@ -28,6 +28,10 @@ const LAYER_HATCH = "HATCH";
const LAYER_SYMBOLS = "SYMBOLS"; const LAYER_SYMBOLS = "SYMBOLS";
const LAYER_CONTEXT = "CONTEXT"; const LAYER_CONTEXT = "CONTEXT";
const LAYER_DEFAULT = "PLAN"; const LAYER_DEFAULT = "PLAN";
/** Sammel-Layer für Extrusions-Footprints (truck-Integration) die Quell-
* Drawing2D wird beim Extrudieren entfernt, es gibt also keine categoryCode
* mehr nachzuschlagen; eigener Layer statt Absturz in LAYER_DEFAULT. */
const LAYER_EXTRUSION = "EXTRUSION";
/** /**
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space). * Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
@@ -60,6 +64,8 @@ export function buildPlanDxf(
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 }); dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 }); dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 }); dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
// Warmes Orange, identisch zum 3D-/Grundriss-Footprint der Extrusionen (EXTRUSION_STROKE/EXTRUSION_RGB).
dxf.addLayer({ name: LAYER_EXTRUSION, color: aciFromHex("#d98c40"), trueColor: rgbFromHex("#d98c40"), lineWeight: 13 });
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im // 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing- // Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
@@ -68,11 +74,15 @@ export function buildPlanDxf(
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode); for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
const codeByDrawing = new Map<string, string>(); const codeByDrawing = new Map<string, string>();
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode); for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
const codeByColumn = new Map<string, string>();
for (const c of project.columns ?? []) codeByColumn.set(c.id, c.categoryCode);
const layerFor = (p: Primitive): string => { const layerFor = (p: Primitive): string => {
if (p.kind === "polygon") { if (p.kind === "polygon") {
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT; if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT; if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
if (p.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT;
if (p.extrudedSolidId) return LAYER_EXTRUSION;
return LAYER_DEFAULT; return LAYER_DEFAULT;
} }
if (p.kind === "line") { if (p.kind === "line") {
+327
View File
@@ -0,0 +1,327 @@
// Unit-Tests für den IFC4-Export (exportIfcSpf) — erste vollständige Scheibe.
// • Strukturelle Validität: KEINE dangling references (jede #N-Referenz ist
// definiert), KEINE doppelten Entity-IDs — der wichtigste Test.
// • Header/FILE_SCHEMA('IFC4') vorhanden.
// • Je "floor"-Geschoss genau ein IfcBuildingStorey.
// • Wandanzahl → IfcWall-Anzahl.
// • Eine Öffnung ⇒ IfcOpeningElement + IfcRelVoidsElement (+ IfcDoor/
// IfcWindow + IfcRelFillsElement).
// • GUID-Format (22 Zeichen, gültiger Zeichensatz), deterministisch.
// • Leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash).
//
// Fixture-Muster gespiegelt von exportSchedule.test.ts.
import { describe, it, expect } from "vitest";
import { exportIfcSpf, ifcGuid } from "./exportIfc";
import type {
Ceiling,
ExtrudedSolid,
Opening,
Project,
Stair,
Wall,
} from "../model/types";
/**
* Minimalprojekt: 2 Geschosse (EG + OG, EG auch ein "section"-Level, das
* NICHT zu einem Storey werden darf) + 2 Wände (Wandtyp T=0.4) + 1 Decke
* (Deckentyp T=0.2) + 1 Tür + 1 Fenster (an W1 gehostet) + 1 Treppe +
* 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const openings: Opening[] = [
{
id: "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "door",
position: 1,
width: 0.9,
height: 2.1,
sillHeight: 0,
},
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5,
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3,
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
return {
id: "t",
name: "Testprojekt",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 2.6 },
{ id: "schnitt-a", name: "Schnitt A", kind: "section", visible: true, locked: false, linePoints: [{ x: 0, y: 0 }, { x: 1, y: 0 }], directionSign: 1 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings,
ceilings,
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Sammelt alle definierten Entity-IDs (`#N=`) und alle referenzierten `#N`. */
function collectIds(spf: string): { defined: Set<number>; referenced: Set<number>; duplicates: number[] } {
const defined = new Set<number>();
const duplicates: number[] = [];
const referenced = new Set<number>();
for (const line of spf.split("\n")) {
const defMatch = /^#(\d+)=/.exec(line);
if (defMatch) {
const id = Number(defMatch[1]);
if (defined.has(id)) duplicates.push(id);
defined.add(id);
}
const refs = line.matchAll(/#(\d+)/g);
for (const r of refs) {
// Der erste Treffer je Zeile ist ggf. die Definition selbst — trotzdem
// harmlos mitgezählt, da sie ja in `defined` steht (Selbstreferenz-Check
// unten prüft nur: JEDE referenzierte ID muss iRGENDWO definiert sein).
referenced.add(Number(r[1]));
}
}
return { defined, referenced, duplicates };
}
describe("exportIfcSpf — IFC4-Export", () => {
it("erzeugt keine dangling references und keine doppelten Entity-IDs", () => {
const spf = exportIfcSpf(fixtureProject());
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
const dangling = [...referenced].filter((id) => !defined.has(id));
expect(dangling).toEqual([]);
expect(defined.size).toBeGreaterThan(0);
});
it("trägt einen gültigen IFC4-Header", () => {
const spf = exportIfcSpf(fixtureProject());
expect(spf).toContain("ISO-10303-21;");
expect(spf).toContain("FILE_SCHEMA(('IFC4'));");
expect(spf).toContain("END-ISO-10303-21;");
});
it("erzeugt je 'floor'-Geschoss genau ein IfcBuildingStorey (Schnitte NICHT)", () => {
const spf = exportIfcSpf(fixtureProject());
const storeyLines = spf.split("\n").filter((l) => l.includes("=IFCBUILDINGSTOREY("));
expect(storeyLines).toHaveLength(2); // EG + OG, NICHT "Schnitt A"
expect(storeyLines.some((l) => l.includes("'EG'"))).toBe(true);
expect(storeyLines.some((l) => l.includes("'OG'"))).toBe(true);
});
it("bildet jede Wand auf genau ein IfcWall ab", () => {
const spf = exportIfcSpf(fixtureProject());
const wallLines = spf.split("\n").filter((l) => l.includes("=IFCWALL("));
expect(wallLines).toHaveLength(2);
});
it("Tür/Fenster bleiben eigene Objekte (IfcDoor/IfcWindow); das Loch steckt im Wand-Mesh (kein IfcOpeningElement/Void/Fill)", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Tür + Fenster als eigene Objekte erhalten.
expect(lines.filter((l) => l.includes("=IFCDOOR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCWINDOW("))).toHaveLength(1);
// Bewusste Abwägung: Wand ist jetzt ein Face-Set mit ausgeschnittenem Loch —
// die frühere IfcOpeningElement-Void/Fill-Semantik entfällt (siehe Dateikopf).
expect(lines.filter((l) => l.includes("=IFCOPENINGELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELVOIDSELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELFILLSELEMENT("))).toHaveLength(0);
});
it("bildet Wände als IfcTriangulatedFaceSet (IfcCartesianPointList3D + CoordIndex) statt Profil-Extrusion ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Genau ein Face-Set + eine Punktliste je Wand (2 Wände).
expect(lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("))).toHaveLength(2);
expect(lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("))).toHaveLength(2);
// Wand-Shape ist als Tessellation deklariert (nicht mehr SweptSolid).
expect(spf).toContain("'Tessellation'");
// Face-Set-CoordIndex referenziert 1-basierte Punkt-Indizes (Tripel-Listen).
const fs = lines.find((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(fs).toMatch(/\(\(\d+,\d+,\d+\)/);
});
it("bildet Decke, Treppe und Extrusion auf die erwarteten Entity-Typen ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
expect(lines.filter((l) => l.includes("=IFCSLAB("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCSTAIR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCBUILDINGELEMENTPROXY("))).toHaveLength(1);
});
it("das Wand-Face-Set enthält das ausgeschnittene Fenster-Loch (Loch-Vertices + volle Dreieckszahl, keine dangling refs)", () => {
// W2 hat das Fenster F1 (position 1, width 1.2, sill 0.9, height 1.5) → das
// Loch liegt voll im Wand-Inneren; das Face-Set der Wand hat exakt die
// Rust-Zerlegung: 8 Langseiten-Teilrechtecke ×2×2 + 8 (Deckel/Boden/Kappen)
// + 8 (4 Laibungen) = 48 Dreiecke, 144 Punkte.
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
// Das Loch-Face-Set hat 48 CoordIndex-Tripel.
const triCounts = faceSets.map((l) => (l.match(/\(\d+,\d+,\d+\)/g) ?? []).length);
expect(triCounts).toContain(48);
// Zugehörige Punktliste hat 144 Punkte (3er-Koordinaten-Tupel).
const pointLists = lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("));
const ptCounts = pointLists.map((l) => (l.match(/\([^()]*,[^()]*,[^()]*\)/g) ?? []).length);
expect(ptCounts).toContain(144);
// Keine dangling refs (der Kern-Invarianten-Check gilt auch mit Face-Sets).
const { defined, referenced } = collectIds(spf);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
it("Wand-Face-Sets sind NACH AUSSEN orientiert (positives Volumen) — Regression gegen die Reflexions-Wicklung, die die Wand hohl machte", () => {
// Der IFC-Achsen-Swap (x,y,z)→(x,z,y) ist eine Reflexion und kehrte die
// Dreiecks-Wicklung um → Normalen zeigten nach INNEN → Viewer cullten die
// Vorderseiten → Wand wirkte oben/unten offen. Nach dem Wicklungs-Ausgleich
// muss das signierte Volumen jedes Wandkörpers POSITIV sein (aussen orientiert).
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const byId = new Map<number, string>();
for (const l of lines) {
const m = /^#(\d+)=/.exec(l);
if (m) byId.set(Number(m[1]), l);
}
const parsePoints = (line: string): number[][] =>
[...line.matchAll(/\(([-\d.]+),([-\d.]+),([-\d.]+)\)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
Number(m[3]),
]);
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(faceSets.length).toBeGreaterThan(0);
let closedCount = 0;
for (const fs of faceSets) {
const ptListId = Number(/=IFCTRIANGULATEDFACESET\(#(\d+),/.exec(fs)![1]);
const closed = /=IFCTRIANGULATEDFACESET\(#\d+,\$,([^,]+),/.exec(fs)![1];
if (closed === ".T.") closedCount++;
const pts = parsePoints(byId.get(ptListId)!);
const tris = [...fs.matchAll(/\((\d+),(\d+),(\d+)\)/g)].map((m) => [
Number(m[1]) - 1,
Number(m[2]) - 1,
Number(m[3]) - 1,
]);
// 6× signiertes Volumen Σ v0·(v1×v2): > 0 ⇒ Normalen zeigen nach aussen.
let vol6 = 0;
for (const [a, b, c] of tris) {
const [ax, ay, az] = pts[a];
const [bx, by, bz] = pts[b];
const [cx, cy, cz] = pts[c];
vol6 += ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx);
}
expect(vol6).toBeGreaterThan(0);
}
// Beide Wände sind geschlossene Prisma-Körper (Fenster = Durchgangsloch,
// Tür = umlaufende П-Kerbe) → Closed=.T. bei beiden.
expect(closedCount).toBe(2);
});
it("GUIDs sind 22 Zeichen lang, nutzen den gültigen IFC-Zeichensatz und sind deterministisch", () => {
const guid = ifcGuid("W1");
expect(guid).toHaveLength(22);
expect(guid).toMatch(/^[0-9A-Za-z_$]{22}$/);
expect(ifcGuid("W1")).toBe(guid); // stabil über Re-Export
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
});
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
proj.drawingLevels = [];
const spf = exportIfcSpf(proj);
expect(spf).toContain("=IFCPROJECT(");
expect(spf).toContain("=IFCSITE(");
expect(spf).toContain("=IFCBUILDING(");
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
});
+687
View File
@@ -0,0 +1,687 @@
// IFC4-Export (STEP Physical File / ISO-10303-21) des semantischen Modells.
// Reiner Rechen-/Serialisierungskern: keine UI, kein Datei-IO, kein WASM, keine
// neue Dependency — IFC wird direkt als Text geschrieben (analog exportDxf.ts/
// exportSchedule.ts). Der Download (Blob+Anchor) passiert im App-Layer.
//
// Abbildung (erste vollständige Scheibe):
// Project → IfcProject → IfcSite → IfcBuilding → je "floor"-Geschoss ein
// IfcBuildingStorey (IfcRelAggregates-Kette). Bauteile hängen über
// IfcRelContainedInSpatialStructure am jeweiligen Geschoss (verwaiste
// Geschossreferenzen fallen defensiv auf IfcBuilding zurück).
//
// Decken/Treppen/Extrusionen als IfcExtrudedAreaSolid (unser Modell IST
// Extrusion): Profil = IfcArbitraryClosedProfileDef(IfcPolyline) in der
// XY-Ebene, extrudiert entlang +Z. Die horizontale Objekt-Platzierungskette
// (Site/Building/Storey/Element) trägt bewusst NUR die Z-Verschiebung
// (Geschoss-Elevation); die Profilpunkte tragen direkt die Welt-X/Y-Koordinaten.
//
// • Wand → IfcWall mit ÖFFNUNGSGENAUEM Dreiecks-Mesh (IfcTriangulatedFace
// Set, IFC4: IfcCartesianPointList3D + CoordIndex) statt einer Profil-
// Extrusion. Gespeist aus DEMSELBEN Loch-Ausschnitt-Mesh wie STL/OBJ
// (`pickGeometry` → `plan/wallMeshCut.ts`): Joins/Gehrungen UND ausgeschnittene
// Fenster/Türen (inkl. Laibungen) sind im Körper enthalten. ABWÄGUNG (bewusst,
// Nutzer-Priorität "so wie im 3D"): dadurch verliert die Wand die parametrische
// IfcWall-Profil-Extrusion + IfcOpeningElement-Void-Semantik zugunsten
// VISUELLER PARITÄT in JEDEM Viewer (der Loch schon im Mesh sieht, ohne eine
// Boolean-Subtraktion ausführen zu müssen — genau der Bug des Nutzers: "das
// Fenster ist als Objekt da im IFC, aber die Löcher sind nicht da").
// • Decke → IfcSlab (outline-Polygon, PredefinedType FLOOR).
// • Öffnung → KEIN IfcOpeningElement/Void mehr (das Loch steckt im Wand-Mesh);
// Tür/Fenster bleiben als eigenes Objekt IfcDoor/IfcWindow mit eigener Box-
// Geometrie erhalten (füllt das ausgeschnittene Loch, "sieht aus wie 3D").
// • Extrusion → IfcBuildingElementProxy aus points+height.
// • Treppe → IfcStair, GEOMETRISCH bewusst vereinfacht auf einen
// extrudierten Bounding-Footprint (Lauf-Rechteck bei "straight"; Achsen-
// ausgerichtete Bounding-Box der Kontrollpunkte bei "L"/"spiral") — die
// echte Stufengeometrie ist ausgelassen (siehe stairFootprint()).
//
// Material-Layer (IfcMaterialLayerSet/-Usage) sind NICHT enthalten — die
// korrekte Direction/Offset-Semantik von IfcMaterialLayerSetUsage ließ sich
// ohne Gegenprüfung an einem echten Viewer nicht mit ausreichender Sicherheit
// umsetzen; Geometrie/Hierarchie hatten Vorrang (siehe Bericht/PENDENZEN).
//
// GUIDs: deterministisch aus der Element-ID über einen 128-Bit-Hash (zwei
// FNV-1a-64-Läufe) + Standard-IFC-GUID-Kompression (Base64-Variante,
// Zeichensatz 0-9,A-Z,a-z,_,$) — stabil über Re-Exporte hinweg.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
import type {
Opening,
Project,
Stair,
Vec2,
Wall,
} from "../model/types";
import {
getCeilingType,
getWallType,
openingLabel,
wallTypeThickness,
} from "../model/types";
import {
ceilingVerticalExtent,
stairVerticalExtent,
wallReferenceOffset,
wallVerticalExtent,
} from "../model/wall";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { isWatertight, wallCutMesh } from "../plan/wallMeshCut";
// ── IFC-GUID (Base64-Kompression, 22 Zeichen) ───────────────────────────────
// Standard-Kompressionsalgorithmus (IfcOpenShell guid.compress): das erste
// Byte des 128-Bit-Werts wird auf 2 Zeichen abgebildet, die restlichen 15
// Byte in 5 Dreiergruppen zu je 4 Zeichen — macht 2 + 5×4 = 22 Zeichen.
const IFC_GUID_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
/** Kodiert `v` big-endian in `len` IFC-GUID-Zeichen (Basis 64). */
function ifcGuidB64(v: number, len: number): string {
let out = "";
for (let i = len - 1; i >= 0; i--) {
out += IFC_GUID_CHARS[Math.floor(v / 64 ** i) % 64];
}
return out;
}
/** Komprimiert einen 128-Bit-Wert (32 Hex-Zeichen) zur 22-stelligen IFC-GUID. */
function compressGuidHex(hex32: string): string {
const bytes: number[] = [];
for (let i = 0; i < 32; i += 2) bytes.push(parseInt(hex32.slice(i, i + 2), 16));
let out = ifcGuidB64(bytes[0], 2);
for (let i = 1; i < 16; i += 3) {
const v = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += ifcGuidB64(v, 4);
}
return out;
}
/** FNV-1a-64 (BigInt) — reines Determinismus-/Streuungs-Werkzeug, keine Kryptografie. */
function fnv1a64(str: string, seed: bigint): bigint {
const prime = 0x100000001b3n;
const mask = 0xffffffffffffffffn;
let hash = seed & mask;
for (let i = 0; i < str.length; i++) {
hash ^= BigInt(str.charCodeAt(i));
hash = (hash * prime) & mask;
}
return hash;
}
/** Leitet aus einer stabilen Element-ID einen deterministischen 128-Bit-Hex-Wert ab. */
function idToHex32(id: string): string {
const h1 = fnv1a64(id, 0xcbf29ce484222325n);
const h2 = fnv1a64(`${id}salt`, 0x9e3779b97f4a7c15n);
return h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0");
}
/** Deterministische 22-stellige IFC-GUID aus einer beliebigen Element-ID. */
export function ifcGuid(id: string): string {
return compressGuidHex(idToHex32(id));
}
// ── STEP-Formatierung ───────────────────────────────────────────────────────
/** STEP-String-Literal ('…', Apostroph verdoppelt, Backslash verdoppelt). */
function S(s: string): string {
const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "''");
return `'${escaped}'`;
}
/** STEP-REAL-Literal — immer mit Dezimalpunkt, ohne unnötige Nachkommastellen. */
function R(x: number): string {
const v = Object.is(x, -0) ? 0 : x;
let s = v.toFixed(6);
s = s.replace(/0+$/, "");
if (s.endsWith(".")) s += "0";
if (!s.includes(".")) s += ".0";
return s;
}
/** STEP-Enumerationswert `.WERT.`. */
function ENUM(v: string): string {
return `.${v}.`;
}
/** STEP-Liste `(a,b,c)`. */
function LIST(items: string[]): string {
return `(${items.join(",")})`;
}
// ── STEP-Writer ──────────────────────────────────────────────────────────────
class StepWriter {
private lines: string[] = [];
private nextId = 1;
/** Schreibt eine neue Entity-Zeile und liefert ihre `#id`. */
add(type: string, params: string): number {
const id = this.nextId++;
this.lines.push(`#${id}=${type}(${params});`);
return id;
}
get entityLines(): readonly string[] {
return this.lines;
}
}
// ── Geometrie-Helfer (reines 2D-Vec2-Rechnen, Welt-Meter) ───────────────────
function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
function normalize(v: Vec2): Vec2 {
const len = Math.hypot(v.x, v.y) || 1;
return { x: v.x / len, y: v.y / len };
}
function leftNormal(u: Vec2): Vec2 {
return { x: -u.y, y: u.x };
}
function addScaled(p: Vec2, d: Vec2, s: number): Vec2 {
return { x: p.x + d.x * s, y: p.y + d.y * s };
}
/** Rechteck-Footprint einer Öffnung im Wandloch (volle Wanddicke tief), CCW. */
function openingFootprint(project: Project, wall: Wall, opening: Opening): Vec2[] {
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u);
const t = wallTypeThickness(getWallType(project, wall));
const off = wallReferenceOffset(wall, t);
const inner = -t / 2 + off;
const outer = t / 2 + off;
const a = addScaled(wall.start, u, opening.position);
const b = addScaled(wall.start, u, opening.position + opening.width);
return [
addScaled(a, n, inner),
addScaled(b, n, inner),
addScaled(b, n, outer),
addScaled(a, n, outer),
];
}
/**
* Vereinfachter Bounding-Footprint einer Treppe (bewusst NICHT die echte
* Stufen-/Podestkontur, siehe Dateikopf):
* "straight" echtes, ausgerichtetes Lauf-Rechteck (Länge × Breite).
* "L"/"spiral" achsenausgerichtete Bounding-Box der Kontrollpunkte
* (Start/Eckpunkt/Ende bzw. Wendel-Zentrum±Radius), um die halbe
* Laufbreite erweitert.
*/
function stairFootprint(stair: Stair): Vec2[] {
const halfW = Math.max(stair.width, 0) / 2;
if (stair.shape === "straight") {
const u = normalize(stair.dir);
const n = leftNormal(u);
const end = addScaled(stair.start, u, stair.runLength);
return [
addScaled(stair.start, n, -halfW),
addScaled(end, n, -halfW),
addScaled(end, n, halfW),
addScaled(stair.start, n, halfW),
];
}
const pts: Vec2[] = [stair.start];
const u = normalize(stair.dir);
const corner = addScaled(stair.start, u, stair.runLength);
pts.push(corner);
if (stair.shape === "L" && stair.run2Length && stair.turn) {
const n = leftNormal(u);
const turnDir: Vec2 = { x: n.x * stair.turn, y: n.y * stair.turn };
pts.push(addScaled(corner, turnDir, stair.run2Length));
}
if (stair.shape === "spiral" && stair.center) {
const r = (stair.radius ?? 0) + halfW;
const c = stair.center;
return [
{ x: c.x - r, y: c.y - r },
{ x: c.x + r, y: c.y - r },
{ x: c.x + r, y: c.y + r },
{ x: c.x - r, y: c.y + r },
];
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
minX -= halfW;
minY -= halfW;
maxX += halfW;
maxY += halfW;
return [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
}
// ── IFC4-Export ──────────────────────────────────────────────────────────────
interface StoreyRef {
entityId: number;
placementId: number;
baseElevation: number;
}
interface Structure {
entityId: number;
placementId: number;
baseElevation: number;
}
/**
* Baut aus einem Projekt einen vollständigen IFC4-SPF-String (STEP Physical
* File). Reiner Rechenkern kein Datei-IO. Leeres Projekt valider Minimal-
* IFC (Project/Site/Building, kein Crash).
*/
export function exportIfcSpf(project: Project): string {
const w = new StepWriter();
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
// Placement (Position aller ExtrudedAreaSolid — Profile tragen direkt
// Welt-X/Y, siehe Dateikopf).
const originPoint = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const extrudeDir = w.add("IFCDIRECTION", LIST([R(0), R(0), R(1)]));
const identityAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
// Owner-History (minimal, aber vorhanden — manche Importer verlangen sie).
const org = w.add("IFCORGANIZATION", `$,${S("dossier")},$,$,$`);
const person = w.add(
"IFCPERSON",
`${S("dossier")},$,$,$,$,$,$,$`,
);
const personOrg = w.add("IFCPERSONANDORGANIZATION", `#${person},#${org},$`);
const app = w.add(
"IFCAPPLICATION",
`#${org},${S("1.0")},${S("dossier")},${S("dossier")}`,
);
const ownerHistory = w.add(
"IFCOWNERHISTORY",
`#${personOrg},#${app},$,${ENUM("ADDED")},$,$,$,${Math.floor(Date.now() / 1000)}`,
);
// Einheiten (Meter, Radiant, m², m³).
const lenUnit = w.add("IFCSIUNIT", `*,${ENUM("LENGTHUNIT")},$,${ENUM("METRE")}`);
const areaUnit = w.add("IFCSIUNIT", `*,${ENUM("AREAUNIT")},$,${ENUM("SQUARE_METRE")}`);
const volUnit = w.add("IFCSIUNIT", `*,${ENUM("VOLUMEUNIT")},$,${ENUM("CUBIC_METRE")}`);
const angleUnit = w.add(
"IFCSIUNIT",
`*,${ENUM("PLANEANGLEUNIT")},$,${ENUM("RADIAN")}`,
);
const unitAssignment = w.add(
"IFCUNITASSIGNMENT",
LIST([`#${lenUnit}`, `#${areaUnit}`, `#${volUnit}`, `#${angleUnit}`]),
);
// Geometrischer Kontext (3D, Precision 1e-5).
const context = w.add(
"IFCGEOMETRICREPRESENTATIONCONTEXT",
`$,${S("Model")},3,${R(0.00001)},#${identityAxis},$`,
);
// Räumliche Hierarchie: Project → Site → Building → Storeys.
const siteAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const sitePlacement = w.add("IFCLOCALPLACEMENT", `$,#${siteAxis}`);
const buildingAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const buildingPlacement = w.add(
"IFCLOCALPLACEMENT",
`#${sitePlacement},#${buildingAxis}`,
);
const projectId = w.add(
"IFCPROJECT",
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
);
const siteId = w.add(
"IFCSITE",
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,$,$,$`,
);
const buildingId = w.add(
"IFCBUILDING",
`${S(ifcGuid(`${project.id}:building`))},#${ownerHistory},${S(project.name || "Gebäude")},$,$,#${buildingPlacement},$,$,${ENUM("ELEMENT")},$,$,$`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-site`))},#${ownerHistory},$,$,#${projectId},${LIST([`#${siteId}`])}`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-building`))},#${ownerHistory},$,$,#${siteId},${LIST([`#${buildingId}`])}`,
);
// Je "floor"-Geschoss ein IfcBuildingStorey (Elevation = baseElevation).
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
const storeyByFloorId = new Map<string, StoreyRef>();
const storeyEntityIds: number[] = [];
for (const floor of floors) {
const base = floor.baseElevation ?? 0;
const pt = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(base)]));
const axis = w.add("IFCAXIS2PLACEMENT3D", `#${pt},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${buildingPlacement},#${axis}`);
const entityId = w.add(
"IFCBUILDINGSTOREY",
`${S(ifcGuid(`${floor.id}:storey`))},#${ownerHistory},${S(floor.name)},$,$,#${placementId},$,$,${ENUM("ELEMENT")},${R(base)}`,
);
storeyByFloorId.set(floor.id, { entityId, placementId, baseElevation: base });
storeyEntityIds.push(entityId);
}
if (storeyEntityIds.length > 0) {
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-storeys`))},#${ownerHistory},$,$,#${buildingId},${LIST(storeyEntityIds.map((id) => `#${id}`))}`,
);
}
/** Geschoss → Trägerstruktur (Storey), oder defensiv das Gebäude (verwaiste floorId). */
const resolveStructure = (floorId: string): Structure => {
const s = storeyByFloorId.get(floorId);
if (s) return { entityId: s.entityId, placementId: s.placementId, baseElevation: s.baseElevation };
return { entityId: buildingId, placementId: buildingPlacement, baseElevation: 0 };
};
// Räumliche Eingliederung sammelt sich je Trägerstruktur (Storey/Building)
// und wird am Ende in EINE IfcRelContainedInSpatialStructure je Struktur
// gebündelt (Öffnungen NICHT — die hängen nur über RelVoidsElement an ihrer
// Wand, wie in IFC üblich).
const containment = new Map<number, number[]>();
const addToContainment = (structureId: number, elementId: number): void => {
const arr = containment.get(structureId);
if (arr) arr.push(elementId);
else containment.set(structureId, [elementId]);
};
/** Baut Profil+Extrusion+Shape+Placement für einen geschlossenen Footprint. */
const emitBoxProduct = (
footprint: Vec2[],
zBottomRel: number,
depth: number,
placementRelTo: number,
): { placementId: number; shapeId: number } => {
const closed = [...footprint, footprint[0]];
const ptIds = closed.map((p) => w.add("IFCCARTESIANPOINT", LIST([R(p.x), R(p.y)])));
const polylineId = w.add("IFCPOLYLINE", LIST(ptIds.map((id) => `#${id}`)));
const profileId = w.add(
"IFCARBITRARYCLOSEDPROFILEDEF",
`${ENUM("AREA")},$,#${polylineId}`,
);
const solidId = w.add(
"IFCEXTRUDEDAREASOLID",
`#${profileId},#${identityAxis},#${extrudeDir},${R(Math.max(depth, 0.001))}`,
);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("SweptSolid")},${LIST([`#${solidId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(zBottomRel)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/**
* Baut aus einem Dreiecks-Mesh (`positions` flach x,y,z in IFC-Koordinaten
* bereits Z-up und relativ zur `placementRelTo`-Herkunft, `indices` je 3 =
* 1-basiert-1 CoordIndex-Tripel) ein IfcTriangulatedFaceSet + Shape + Placement.
* IFC4-Tessellierung: IfcCartesianPointList3D (CoordList) + IfcTriangulatedFace
* Set (CoordIndex, 1-basiert). Das Element-Placement sitzt im Ursprung der
* Trägerstruktur (die Punkte tragen die Geometrie bereits absolut in deren Frame).
*/
const emitTriangulatedProduct = (
positions: number[],
indices: number[],
placementRelTo: number,
closed: boolean,
): { placementId: number; shapeId: number } => {
const coords: string[] = [];
for (let i = 0; i < positions.length; i += 3) {
coords.push(`(${R(positions[i])},${R(positions[i + 1])},${R(positions[i + 2])})`);
}
const pointListId = w.add("IFCCARTESIANPOINTLIST3D", `(${coords.join(",")})`);
const tris: string[] = [];
for (let i = 0; i < indices.length; i += 3) {
tris.push(`(${indices[i] + 1},${indices[i + 1] + 1},${indices[i + 2] + 1})`);
}
// Closed=.T. NUR wenn das Mesh nachweislich ein dichtes, aussen orientiertes
// Volumen ist (siehe isWatertight): Wände sind extrudierte Querschnitts-
// Polygone (Fenster = Durchgangsloch, Tür = umlaufende П-Kerbe) → geschlossene
// Körper → `.T.` (Viewer rendern sie als Solid statt als offene Fläche). Der
// Guard fängt echte Defekte ab (invertierte Wicklung → Volumen < 0 → `$`).
// Normals=$ (Viewer leitet sie aus der — jetzt aussen orientierten — Wicklung ab).
const closedFlag = closed ? ".T." : "$";
const faceSetId = w.add("IFCTRIANGULATEDFACESET", `#${pointListId},$,${closedFlag},(${tris.join(",")}),$`);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("Tessellation")},${LIST([`#${faceSetId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/** Baut ein IfcBuildingElement-Subtyp mit dem üblichen 9-Attribut-Flatten. */
const emitBuildingElement = (
type: string,
guid: string,
name: string,
placementId: number,
shapeId: number,
predefinedType: string | null,
): number =>
w.add(
type,
`${S(guid)},#${ownerHistory},${S(name)},$,$,#${placementId},#${shapeId},$,${predefinedType ?? "$"}`,
);
// ── Wände (öffnungsgenaues Dreiecks-Mesh statt Profil-Extrusion) ────────
// Die Wand-Schicht-Bänder kommen aus DEMSELBEN geflachten Modell wie STL/OBJ
// (`pickGeometry`, Joins/Gehrungen + Öffnungs-`holes` bereits aufgelöst). Alle
// Bänder einer Wand-Id werden zu EINEM Face-Set vereint. IFC-Koordinaten: das
// Mesh liegt in Welt (Modell-x, Höhe, Modell-y) mit Y-up → IFC (x, y, z=Höhe)
// mit Z-up, also (mx, mz, my); Z relativ zur Geschoss-UK, damit die Storey-
// Placement-Elevation nicht doppelt zählt.
//
// WICHTIG — WICKLUNG: der Achsen-Swap (x,y,z)→(x,z,y) ist eine REFLEXION
// (Determinante 1) und KEHRT die Dreiecks-Wicklung UM → aus aussen orientierten
// würden innen orientierte Normalen, der Viewer cullt dann die Vorderseiten und
// die Wand wirkt HOHL/offen (genau der gemeldete Bug). Deshalb wird beim Swap
// die Wicklung jedes Dreiecks umgedreht (i0,i2,i1), damit die Aussen-Normalen
// aussen bleiben. Watertightness (isWatertight) misst das anschliessend am
// fertigen IFC-Mesh → treibt das Closed-Flag des Face-Sets.
const bandsByWallId = new Map<string, RWall[]>();
for (const band of pickGeometry(project).walls) {
const list = bandsByWallId.get(band.wallId);
if (list) list.push(band);
else bandsByWallId.set(band.wallId, [band]);
}
const wallEntityIdByWallId = new Map<string, number>();
for (const wall of project.walls ?? []) {
const bands = bandsByWallId.get(wall.id);
if (!bands || bands.length === 0) continue; // degenerierte Wand / keine Geometrie
const structure = resolveStructure(wall.floorId);
const positions: number[] = [];
const indices: number[] = [];
for (const band of bands) {
const cut = wallCutMesh(band);
const base = positions.length / 3;
for (let i = 0; i < cut.positions.length; i += 3) {
positions.push(
cut.positions[i],
cut.positions[i + 2],
cut.positions[i + 1] - structure.baseElevation,
);
}
// Wicklung umkehren (Reflexions-Ausgleich, s. o.): (a,b,c) → (a,c,b).
for (let i = 0; i < cut.indices.length; i += 3) {
indices.push(base + cut.indices[i], base + cut.indices[i + 2], base + cut.indices[i + 1]);
}
}
if (indices.length === 0) continue;
const closed = isWatertight({ positions, indices });
const { placementId, shapeId } = emitTriangulatedProduct(positions, indices, structure.placementId, closed);
let name = wall.id;
try {
name = getWallType(project, wall).name;
} catch {
/* verwaister Wandtyp — Roh-ID als Name */
}
const entityId = emitBuildingElement("IFCWALL", ifcGuid(wall.id), name, placementId, shapeId, null);
wallEntityIdByWallId.set(wall.id, entityId);
addToContainment(structure.entityId, entityId);
}
// ── Decken ─────────────────────────────────────────────────────────────
for (const ceiling of project.ceilings ?? []) {
if (ceiling.outline.length < 3) continue;
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const structure = resolveStructure(ceiling.floorId);
const { placementId, shapeId } = emitBoxProduct(
ceiling.outline,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
let name: string = ceiling.id;
try {
name = getCeilingType(project, ceiling).name;
} catch {
/* verwaister Deckentyp — Roh-ID als Name */
}
const entityId = emitBuildingElement(
"IFCSLAB",
ifcGuid(ceiling.id),
name,
placementId,
shapeId,
ENUM("FLOOR"),
);
addToContainment(structure.entityId, entityId);
}
// ── Fenster/Türen als eigene Objekte (IfcDoor/IfcWindow) ────────────────
// KEIN IfcOpeningElement/IfcRelVoidsElement/IfcRelFillsElement mehr: das Loch
// steckt bereits im Wand-Face-Set (s. o.). Eine Void-Relation beschriebe eine
// Boolean-Subtraktion gegen einen Swept-Solid, den es nicht mehr gibt — sie
// brächte in den Viewern nur Verwirrung (der Nutzer-Bug war genau, dass die
// Void nicht subtrahiert wurde). Tür/Fenster bleiben als EIGENES Objekt mit
// eigener Box-Geometrie erhalten, die das ausgeschnittene Loch füllt ("sieht
// aus wie 3D"): kein Blatt-/Rahmendetail (bewusste Vereinfachung).
for (const opening of project.openings ?? []) {
const wall = (project.walls ?? []).find((wl) => wl.id === opening.hostWallId);
if (!wall) continue; // verwaiste Wirtswand — keine Geometrie ableitbar
if (!wallEntityIdByWallId.has(wall.id)) continue; // Wirtswand übersprungen (degeneriert)
const wallExtent = wallVerticalExtent(project, wall);
const structure = resolveStructure(wall.floorId);
const footprint = openingFootprint(project, wall, opening);
const zSillAbs = wallExtent.zBottom + opening.sillHeight;
const { placementId: fillPlacement, shapeId: fillShape } = emitBoxProduct(
footprint,
zSillAbs - structure.baseElevation,
opening.height,
structure.placementId,
);
const fillGuid = ifcGuid(`${opening.id}:fill`);
const fillName = openingLabel(opening);
const fillEntityId =
opening.kind === "door"
? w.add(
"IFCDOOR",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("DOOR")},$,$`,
)
: w.add(
"IFCWINDOW",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("WINDOW")},$,$`,
);
addToContainment(structure.entityId, fillEntityId);
}
// ── Treppen (vereinfachter Bounding-Footprint, siehe Dateikopf) ────────
for (const stair of project.stairs ?? []) {
const footprint = stairFootprint(stair);
if (footprint.length < 3) continue;
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const structure = resolveStructure(stair.floorId);
const { placementId, shapeId } = emitBoxProduct(
footprint,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
const predefinedType =
stair.shape === "straight"
? ENUM("STRAIGHT_RUN_STAIR")
: stair.shape === "spiral"
? ENUM("SPIRAL_STAIR")
: ENUM("QUARTER_TURN_STAIR");
const entityId = emitBuildingElement(
"IFCSTAIR",
ifcGuid(stair.id),
"Treppe",
placementId,
shapeId,
predefinedType,
);
addToContainment(structure.entityId, entityId);
}
// ── Extrudierte Körper (truck-Integration) → IfcBuildingElementProxy ───
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3) continue;
const floor = floors.find((f) => f.id === solid.levelId);
const base = floor?.baseElevation ?? 0;
const structure = resolveStructure(solid.levelId);
const { placementId, shapeId } = emitBoxProduct(
solid.points,
base - structure.baseElevation,
solid.height,
structure.placementId,
);
const entityId = emitBuildingElement(
"IFCBUILDINGELEMENTPROXY",
ifcGuid(solid.id),
"Extrusion",
placementId,
shapeId,
null,
);
addToContainment(structure.entityId, entityId);
}
// ── Räumliche Eingliederung (gebündelt je Trägerstruktur) ──────────────
for (const [structureId, elementIds] of containment) {
w.add(
"IFCRELCONTAINEDINSPATIALSTRUCTURE",
`${S(ifcGuid(`contain:${structureId}`))},#${ownerHistory},$,$,${LIST(elementIds.map((id) => `#${id}`))},#${structureId}`,
);
}
// ── Kopf + Zusammenbau ───────────────────────────────────────────────────
const iso = new Date().toISOString().replace(/\.\d+Z$/, "");
const fileName = `${project.name || "modell"}.ifc`;
const header = [
"ISO-10303-21;",
"HEADER;",
`FILE_DESCRIPTION(${LIST([S("")])},${S("2;1")});`,
`FILE_NAME(${S(fileName)},${S(iso)},${LIST([S("dossier")])},${LIST([S("dossier")])},${S("dossier")},${S("dossier")},${S("")});`,
"FILE_SCHEMA(('IFC4'));",
"ENDSEC;",
"",
"DATA;",
];
const footer = ["ENDSEC;", "END-ISO-10303-21;"];
return [...header, ...w.entityLines, ...footer].join("\n") + "\n";
}
+306
View File
@@ -0,0 +1,306 @@
// Unit-Tests für den STL-/OBJ-Mesh-Export (reines Modul, siehe exportMesh.ts).
// • OBJ: ≥1 v/f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich.
// • STL: solid/endsolid-Rahmen, Facettenzahl = Dreieckszahl, je Facette genau 3 vertex-Zeilen.
// • Dreieckszahl-Plausibilität: isolierte Wand-Box (12) und isoliertes N-Eck-Prisma (4N-4).
// • leeres Projekt ⇒ gültige leere Datei (kein Crash).
import { describe, it, expect } from "vitest";
import { exportObj, exportStl } from "./exportMesh";
import type { Project, Wall, Ceiling, ExtrudedSolid, Opening } from "../model/types";
/** Gemeinsame Ressourcen-Basis (Komponente/Wandtyp/Deckentyp/Geschoss/Ebene). */
function baseProject(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
extrudedSolids: [],
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Eine einzelne, frei stehende Wand (kein Nachbar ⇒ kein Gehrungs-/Anschlussschnitt). */
function projectWithOneWall(): Project {
const p = baseProject();
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
p.walls = [wall];
return p;
}
/**
* Eine frei stehende Wand mit EINER Öffnung (Fenster ODER Tür). Das Fenster
* (sillHeight>0) liegt vollständig im Wand-Inneren (vier Laibungen); die Tür
* (sillHeight 0) berührt die Wand-UK (keine untere Laibung).
*/
function projectWithOpening(kind: "window" | "door"): Project {
const p = projectWithOneWall();
const opening: Opening = {
id: kind === "window" ? "F1" : "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind,
position: 2,
width: 1,
height: kind === "window" ? 1.5 : 2.1,
sillHeight: kind === "window" ? 0.9 : 0,
};
p.openings = [opening];
return p;
}
/** Eine einzelne Extrusion mit N-Eck-Profil (kein Wand-/Deckenkontext). */
function projectWithPolygonExtrusion(pts: { x: number; y: number }[], height = 2.5): Project {
const p = baseProject();
const solid: ExtrudedSolid = {
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: pts,
height,
};
p.extrudedSolids = [solid];
return p;
}
/** Kombiniertes Fixture-Projekt: 2 Wände (Eckstoss), 1 Decke, 1 Extrusion. */
function fixtureProject(): Project {
const p = baseProject();
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
p.walls = walls;
p.ceilings = ceilings;
p.extrudedSolids = extrudedSolids;
return p;
}
/** Parst die `v`-Zeilen eines OBJ-Strings zu Koordinaten-Tripeln. */
function parseObjVertices(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("v "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
/** Parst die `f`-Zeilen eines OBJ-Strings zu 1-basierten Index-Tripeln. */
function parseObjFaces(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("f "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
describe("exportObj — Wavefront-OBJ-Export", () => {
it("liefert ≥1 v und ≥1 f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich", () => {
const obj = exportObj(fixtureProject());
const verts = parseObjVertices(obj);
const faces = parseObjFaces(obj);
expect(verts.length).toBeGreaterThan(0);
expect(faces.length).toBeGreaterThan(0);
for (const v of verts) {
expect(v).toHaveLength(3);
for (const c of v) expect(Number.isFinite(c)).toBe(true);
}
for (const f of faces) {
expect(f).toHaveLength(3);
for (const idx of f) {
expect(idx).toBeGreaterThanOrEqual(1);
expect(idx).toBeLessThanOrEqual(verts.length);
}
}
});
it("gruppiert Bauteil-Vorkommen als o-Objekte (Wand/Decke/Extrusion)", () => {
const obj = exportObj(fixtureProject());
expect(obj).toContain("o Wand_W1");
expect(obj).toContain("o Wand_W2");
expect(obj).toContain("o Decke_D1");
expect(obj).toContain("o Extrusion_E1");
});
it("leeres Projekt ⇒ gültige Datei ohne v/f (kein Crash)", () => {
const obj = exportObj(baseProject());
expect(obj.split("\n").some((l) => l.startsWith("v "))).toBe(false);
expect(obj.split("\n").some((l) => l.startsWith("f "))).toBe(false);
expect(obj.length).toBeGreaterThan(0);
});
});
describe("exportStl — ASCII-STL-Export", () => {
it("hat solid/endsolid-Rahmen, je Facette genau 3 vertex-Zeilen, Facettenzahl = Dreieckszahl", () => {
const stl = exportStl(fixtureProject());
const lines = stl.split("\n").filter((l) => l.length > 0);
expect(lines[0]).toBe("solid dossier");
expect(lines[lines.length - 1]).toBe("endsolid dossier");
const facetCount = lines.filter((l) => l.startsWith("facet normal")).length;
const loopCount = lines.filter((l) => l === "outer loop").length;
const endloopCount = lines.filter((l) => l === "endloop").length;
const endfacetCount = lines.filter((l) => l === "endfacet").length;
const vertexCount = lines.filter((l) => l.startsWith("vertex ")).length;
expect(facetCount).toBeGreaterThan(0);
expect(loopCount).toBe(facetCount);
expect(endloopCount).toBe(facetCount);
expect(endfacetCount).toBe(facetCount);
expect(vertexCount).toBe(facetCount * 3);
});
it("leeres Projekt ⇒ gültiges leeres solid-Gerüst (kein Crash)", () => {
const stl = exportStl(baseProject());
expect(stl).toBe("solid dossier\nendsolid dossier\n");
});
it("Dreieckszahl-Plausibilität: eine frei stehende Wand ergibt genau 1 Box (12 Dreiecke, 8 Ecken)", () => {
const stl = exportStl(projectWithOneWall());
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(12);
const obj = exportObj(projectWithOneWall());
expect(parseObjVertices(obj)).toHaveLength(8);
});
it("Dreieckszahl-Plausibilität: ein N-Eck-Profil ergibt ein Prisma mit 4N-4 Dreiecken (2N Ecken)", () => {
// Rechteck (N=4): 2 Kappen à 2 Dreiecke + 4 Seitenquads à 2 Dreiecke = 12 = 4·44.
const rectStl = exportStl(
projectWithPolygonExtrusion([
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 1 },
]),
);
expect(rectStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(12);
// Konvexes Fünfeck (N=5): 4·54 = 16 Dreiecke, 10 Ecken.
const pentagon = [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2.5, y: 1.5 },
{ x: 1, y: 2.5 },
{ x: -0.5, y: 1.5 },
];
const pentaStl = exportStl(projectWithPolygonExtrusion(pentagon));
expect(pentaStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(16);
const pentaObj = exportObj(projectWithPolygonExtrusion(pentagon));
expect(parseObjVertices(pentaObj)).toHaveLength(10);
});
it("Fenster wird als echtes Loch ausgeschnitten (Gitter-Zerlegung + 4 Laibungen, 48 Dreiecke statt 12)", () => {
// Fenster [x 2..3] × [y 0.9..2.4] vollständig im Wand-Inneren. Erwartung
// (= Rust-Zerlegung `extrude_layer_segment_with_holes`): 8 solide Langseiten-
// Teilrechtecke (3×3-Gitter minus Loch) × 2 Seiten × 2 Dreiecke = 32,
// + Deckel/Boden/2 Stirnkappen = 8, + 4 Laibungen × 2 = 8 → 48 Dreiecke.
const stl = exportStl(projectWithOpening("window"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(48);
// Die volle Box hätte NUR Ecken bei y∈{0,2.6}, x∈{0,5}. Der Ausschnitt
// führt echte Vertices an den Loch-Rändern ein — Beweis, dass das Loch
// wirklich in der Fläche steckt (nicht bloss eine übergelegte Scheibe).
const verts = parseObjVertices(exportObj(projectWithOpening("window")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
expect(verts.some((v) => near(v[1], 0.9))).toBe(true); // Brüstungs-OK
expect(verts.some((v) => near(v[1], 2.4))).toBe(true); // Sturz-UK
expect(verts.some((v) => near(v[0], 2))).toBe(true); // linke Loch-Kante
expect(verts.some((v) => near(v[0], 3))).toBe(true); // rechte Loch-Kante
// Deutlich mehr Vertices als die 8-Ecken-Vollbox.
expect(verts.length).toBe(144);
});
it("Tür berührt die Wand-UK: keine untere Laibung (36 Dreiecke, keine Brüstungs-Zerlegung)", () => {
// Tür [x 2..3] × [y 0..2.1]: Boden bekommt die Lücke [2,3] (statt einer
// unteren Laibung) → 5 Langseiten-Teilrechtecke × 2 × 2 = 20, Deckel(1)=2,
// Boden(2 Streifen)=4, 2 Stirnkappen=4, 3 Laibungen (links/rechts/oben)=6 → 36.
const stl = exportStl(projectWithOpening("door"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(36);
const verts = parseObjVertices(exportObj(projectWithOpening("door")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
// Sturz-UK bei y=2.1 vorhanden, aber KEIN Brüstungsvertex (Tür sitzt am Boden).
expect(verts.some((v) => near(v[1], 2.1))).toBe(true);
expect(verts.every((v) => v[1] >= -1e-6 && v[1] <= 2.6 + 1e-6)).toBe(true);
});
it("facet-Normalen sind Einheitsvektoren aus dem Kreuzprodukt der Dreiecksecken", () => {
const stl = exportStl(projectWithOneWall());
const facetLines = stl.split("\n").filter((l) => l.startsWith("facet normal"));
for (const line of facetLines) {
const [nx, ny, nz] = line.slice("facet normal ".length).trim().split(/\s+/).map(Number);
const len = Math.hypot(nx, ny, nz);
expect(len).toBeGreaterThan(0.99);
expect(len).toBeLessThan(1.01);
}
});
});
+353
View File
@@ -0,0 +1,353 @@
// OBJ-/STL-Export des 3D-Gebäudemodells als reines Dreiecks-Mesh. Pures Modul
// (kein Datei-IO, kein WASM) — Architektur wie exportDxf.ts/exportSchedule.ts:
// buildGroups() liest das Projekt und liefert reine Zahlen-Arrays, exportObj()/
// exportStl() serialisieren sie synchron zu Text.
//
// TRIANGEL-QUELLE (bewusste v1-Entscheidung, siehe PENDENZEN):
// • Wände: pickGeometry() aus toWalls3d.ts liefert dieselben Schicht-Bänder,
// die auch der 3D-Viewer zeichnet (EIN RWall je Materiallage/Achsen-
// Teilstück, bereits mit End-Cuts/Gehrungen/Decken-Dominanz aufgelöst).
// Bänder mit Öffnungs-`holes` werden über den gemeinsamen Loch-Ausschnitt-
// Generator (`plan/wallMeshCut.ts`, TS-Port von render3ds Gitter-Zerlegung
// `extrude_layer_segment_with_holes`) ÖFFNUNGSGENAU trianguliert — die
// Fenster/Türen sind sichtbar ausgestanzt inkl. Laibungsflächen, genau wie
// im 3D-Viewer. Lochlose Bänder bleiben die klassische geteilte 8-Ecken-Box.
// • Decken: pickGeometry() liefert den Umriss + zBottom/zTop je Decke;
// der Umriss wird mit dem vorhandenen Ear-Clipping-Triangulator
// (`triangulate` aus plan/glPlan/glPlanCompile.ts, bereits getestet und
// konkav-fähig) zu Boden-/Deckenkappen trianguliert, dazwischen ein
// Seitenmantel — ein einfaches Umriss-Prisma.
// • ExtrudedSolids (truck-Profile): `src/engine/truckSolid.ts` liefert zwar
// "echte" truck-Dreiecke, ABER nur asynchron über WASM
// (`extrudePolygon`/`extrudeCircle` geben Promises zurück). Das würde die
// geforderte SYNCHRONE, WASM-freie Signatur `exportObj(project): string`
// brechen. Deshalb nutzt dieser Export stattdessen DIESELBE Prisma-
// Triangulierung wie die Decken (Umriss aus `solid.points` — bei
// Kreisprofilen bereits eine 48-Eck-Tessellierung, siehe
// `ExtrudedSolid.points`-Doc) inkl. `taper`-Verjüngung (linear zum
// Schwerpunkt skaliert, wie die truck-Extrusion es tut). Das ist eine
// bewusste Abweichung vom ursprünglichen Plan ("truckSolid-Dreiecke") zu-
// gunsten der pure-Modul-Architektur — ehrlich dokumentiert, kein
// Stillschweigen.
//
// ACHSEN-KONVENTION: identisch zur bestehenden 3D-Pick-/Highlight-Geometrie
// (siehe `selectionHighlightLines` in toWalls3d.ts): Modell (x, y) → Welt
// (x, Höhe, y), Y-UP, rechtshändig. Ein Weltvertex (vx,vy,vz) entspricht also
// (Modell-x, Meter-Höhe, Modell-y) — exakt wie im 3D-Viewer.
import type { Project, Vec2 } from "../model/types";
import { getFloor } from "../model/types";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { wallCutMesh } from "../plan/wallMeshCut";
import { triangulate } from "../plan/glPlan/glPlanCompile";
/**
* Eine Mesh-Gruppe (ein Bauteil-Vorkommen: Wand/Decke/Extrusion) mit LOKALEN
* (0-basierten) Vertex-/Dreiecks-Indizes. `exportObj` verschiebt die Indizes
* beim Serialisieren auf die globale, 1-basierte OBJ-Nummerierung; `exportStl`
* braucht gar keine Indizes über Gruppen hinweg (STL ist unindiziert).
*/
interface MeshGroup {
name: string;
/** Flaches Weltkoordinaten-Array (x,y,z, x,y,z, …), Meter, Y-up. */
positions: number[];
/** Dreiecks-Indizes (3 je Dreieck), 0-basiert relativ zu `positions`. */
indices: number[];
}
function newGroup(name: string): MeshGroup {
return { name, positions: [], indices: [] };
}
/** OBJ-Gruppennamen dürfen keine Whitespaces/Sonderzeichen enthalten. */
function sanitizeName(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, "_") || "x";
}
/**
* Die 6 Quader-Seiten als Eckpunkt-Quadrupel (Index-Bits: bit0=Achse /+,
* bit1=Höhe /+, bit2=Dicke /+ dieselbe Konvention wie `emitOpeningGlass`/
* `selectionHighlightLines` in toWalls3d.ts), in nach AUSSEN gewundener
* Reihenfolge (für ein rechtshändiges Dreibein Achse×Höhe=Dicke-Normale, wie
* es die Wandbox-Konstruktion unten aufspannt). Jedes Quadrupel wird zu 2
* Dreiecken (0,1,2)+(0,2,3).
*/
const BOX_QUADS: Array<[number, number, number, number]> = [
[4, 6, 7, 5], // Dicke +
[1, 3, 2, 0], // Dicke
[2, 3, 7, 6], // Höhe + (Wandkopf)
[4, 5, 1, 0], // Höhe (Wand-UK)
[1, 5, 7, 3], // Achse + (Wandende)
[2, 6, 4, 0], // Achse (Wandanfang)
];
/**
* Hängt EIN Wand-Schicht-Band (`RWall`, siehe toWalls3d.ts) an eine Mesh-Gruppe
* an: hat das Band echte Öffnungs-`holes`, wird das öffnungsgenaue Loch-Ausschnitt-
* Mesh (siehe {@link wallCutMesh}) verwendet die Fenster/Türen sind sichtbar
* ausgestanzt inkl. Laibungen, exakt wie im 3D-Viewer. Ohne Löcher bleibt es die
* klassische, geteilte 8-Ecken-Box ({@link pushWallBox}) bitgleich zum
* bisherigen Verhalten (Joins/Gehrungen stecken schon in `start`/`end`).
*/
function pushWall(group: MeshGroup, w: RWall): void {
if (w.holes && w.holes.length > 0) {
const cut = wallCutMesh(w);
const base = group.positions.length / 3;
for (const p of cut.positions) group.positions.push(p);
for (const idx of cut.indices) group.indices.push(base + idx);
return;
}
pushWallBox(group, w);
}
/**
* Hängt EINEN vollen Wand-Schicht-Quader (`RWall`) an eine Mesh-Gruppe an Box
* über Achse×Höhe×Dicke, OHNE Öffnungs-Ausschnitt (nur für lochlose Bänder, siehe
* {@link pushWall}). Degenerierte Bänder (Länge/Höhe/Dicke 0) werden übersprungen.
*/
function pushWallBox(group: MeshGroup, w: RWall): void {
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dz = ey - sy;
const len = Math.hypot(dx, dz);
if (len < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return;
const ax = dx / len;
const az = dz / len;
// Normale = Welt-Hoch × Achse (rechtshändig), dieselbe Konvention wie
// `selectionHighlightLines`: normal = (az, 0, ax).
const nx = az;
const nz = -ax;
const halfLen = len / 2;
const halfHeight = w.height / 2;
const halfThick = w.thickness / 2;
const cx = (sx + ex) / 2;
const cz = (sy + ey) / 2;
const cy = w.baseElevation + w.height / 2;
const base = group.positions.length / 3;
for (let i = 0; i < 8; i++) {
const sL = i & 1 ? 1 : -1;
const sH = i & 2 ? 1 : -1;
const sT = i & 4 ? 1 : -1;
group.positions.push(
cx + sL * halfLen * ax + sT * halfThick * nx,
cy + sH * halfHeight,
cz + sL * halfLen * az + sT * halfThick * nz,
);
}
for (const [a, b, c, d] of BOX_QUADS) {
group.indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
}
}
/** Signierte Fläche eines Modell-XY-Umrisses (Shoelace); ≥0 = CCW. */
function signedAreaXY(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
}
return a / 2;
}
/**
* Hängt ein extrudiertes Umriss-Prisma (Decke ODER truck-Profil-Extrusion,
* siehe Moduldoc) an eine Mesh-Gruppe an: Boden-/Deckkappe (Ear-Clipping-
* Triangulierung via `triangulate`) + Seitenmantel. `taper` (0..1) verjüngt
* den OBEREN Ring linear zum Flächenschwerpunkt dieselbe Semantik wie
* `ExtrudedSolid.taper`/die truck-Extrusion (0 = Prisma, 1 = Spitze/Pyramide).
* Der Umriss wird intern auf CCW (Modell-XY) normalisiert, damit die
* Seitenmantel-Normalen unabhängig von der Eingabe-Wicklung nach außen zeigen.
*/
function pushPrism(
group: MeshGroup,
outline: Vec2[],
zBottom: number,
zTop: number,
taper = 0,
): void {
if (outline.length < 3 || zTop - zBottom <= 1e-9) return;
const ccw = signedAreaXY(outline) >= 0 ? outline : [...outline].reverse();
const n = ccw.length;
let cx = 0;
let cyModel = 0;
for (const p of ccw) {
cx += p.x;
cyModel += p.y;
}
cx /= n;
cyModel /= n;
const t = Math.min(1, Math.max(0, taper));
const base = group.positions.length / 3;
// Unterer Ring (base + 0..n-1), oberer Ring (base + n..2n-1).
for (const p of ccw) group.positions.push(p.x, zBottom, p.y);
for (const p of ccw) {
const tx = cx + (p.x - cx) * (1 - t);
const ty = cyModel + (p.y - cyModel) * (1 - t);
group.positions.push(tx, zTop, ty);
}
// Kappen: `triangulate` liefert bei CCW-Eingabe stets CCW-in-Modell-XY-
// Dreiecke — eingebettet in die Weltebene (x, const, y) entspricht das einer
// nach UNTEN (Y) zeigenden Flächennormale (Kreuzprodukt-Herleitung siehe
// Moduldoc-Kommentar oben). Die Bodenkappe nutzt diese Wicklung direkt
// (Normale zeigt nach unten = nach außen), die Deckkappe braucht die
// umgekehrte Wicklung (Normale nach oben).
const capTris = triangulate(ccw);
for (let i = 0; i < capTris.length; i += 3) {
const a = capTris[i];
const b = capTris[i + 1];
const c = capTris[i + 2];
group.indices.push(base + a, base + b, base + c);
group.indices.push(base + n + a, base + n + c, base + n + b);
}
// Seitenmantel: je Umrisskante ein Quad (2 Dreiecke), Wicklung so, dass die
// Normale nach außen zeigt (siehe Moduldoc-Herleitung).
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const bi = base + i;
const bj = base + j;
const ti = base + n + i;
const tj = base + n + j;
group.indices.push(bi, ti, tj);
group.indices.push(bi, tj, bj);
}
}
/** Baut alle Mesh-Gruppen (Wände, Decken, Extrusionen) des Projekts. */
function buildGroups(project: Project): MeshGroup[] {
const groups: MeshGroup[] = [];
const { walls, slabs } = pickGeometry(project);
// Wände: alle Schicht-Bänder DERSELBEN Wand-Id in EINE Gruppe (mehrere
// Materiallagen ergeben mehrere Boxen im selben Bauteil-Vorkommen).
const wallGroups = new Map<string, MeshGroup>();
for (const w of walls) {
let g = wallGroups.get(w.wallId);
if (!g) {
g = newGroup(`Wand_${sanitizeName(w.wallId)}`);
wallGroups.set(w.wallId, g);
groups.push(g);
}
pushWall(g, w);
}
// Decken: ein Prisma je Decke.
for (const s of slabs) {
const g = newGroup(`Decke_${sanitizeName(s.ceilingId)}`);
const outline: Vec2[] = s.outline.map(([x, y]) => ({ x, y }));
pushPrism(g, outline, s.zBottom, s.zTop);
groups.push(g);
}
// Extrudierte Profile (truck-Integration): eigenes Prisma je Körper (siehe
// Moduldoc — bewusst NICHT die truck-WASM-Dreiecke, aus Sync-/Pure-Gründen).
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3 || solid.height <= 0) continue;
let baseZ = 0;
try {
baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback, wie toWalls3d).
}
const g = newGroup(`Extrusion_${sanitizeName(solid.id)}`);
pushPrism(g, solid.points, baseZ, baseZ + solid.height, solid.taper ?? 0);
groups.push(g);
}
return groups.filter((g) => g.indices.length > 0);
}
/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */
function fmt(v: number): string {
if (!Number.isFinite(v)) return "0.0";
const s = v.toFixed(6);
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
}
/**
* Baut einen vollständigen Wavefront-OBJ-String des 3D-Gebäudemodells (Meter,
* rechtshändig, Y-up siehe Moduldoc). Ein `o`-Objekt je Bauteil-Vorkommen
* (Wand/Decke/Extrusion). Leeres Projekt gültige Datei nur mit Kopf-
* Kommentaren, kein `v`/`f` (kein Crash).
*/
export function exportObj(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = [
"# DOSSIER OBJ-Export - Dreiecks-Mesh des 3D-Gebaeudemodells",
"# Achsen: rechtshaendig, Y-up (Y = Hoehe ueber OKFF), wie der 3D-Viewer.",
"# Waende: Schicht-Baender mit oeffnungsgenau ausgeschnittenen Loechern (inkl. Laibungen).",
"# Decken/Extrusionen: Umriss-Prisma (Ear-Clipping-Triangulierung).",
];
let offset = 0;
for (const g of groups) {
const vertCount = g.positions.length / 3;
if (vertCount === 0) continue;
lines.push(`o ${g.name}`);
for (let i = 0; i < g.positions.length; i += 3) {
lines.push(`v ${fmt(g.positions[i])} ${fmt(g.positions[i + 1])} ${fmt(g.positions[i + 2])}`);
}
for (let i = 0; i < g.indices.length; i += 3) {
const a = g.indices[i] + 1 + offset;
const b = g.indices[i + 1] + 1 + offset;
const c = g.indices[i + 2] + 1 + offset;
lines.push(`f ${a} ${b} ${c}`);
}
offset += vertCount;
}
return lines.join("\n") + "\n";
}
/**
* Baut einen vollständigen ASCII-STL-String des 3D-Gebäudemodells (Meter,
* dieselbe Achsen-Konvention wie {@link exportObj}). Jedes Dreieck trägt eine
* aus dem Kreuzprodukt berechnete Facettennormale (konsistente Winding, siehe
* `pushWallBox`/`pushPrism`). Leeres Projekt gültiges leeres `solid`-Gerüst.
*/
export function exportStl(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = ["solid dossier"];
for (const g of groups) {
for (let i = 0; i < g.indices.length; i += 3) {
const ia = g.indices[i] * 3;
const ib = g.indices[i + 1] * 3;
const ic = g.indices[i + 2] * 3;
const ax = g.positions[ia];
const ay = g.positions[ia + 1];
const az = g.positions[ia + 2];
const bx = g.positions[ib];
const by = g.positions[ib + 1];
const bz = g.positions[ib + 2];
const cx = g.positions[ic];
const cy = g.positions[ic + 1];
const cz = g.positions[ic + 2];
const ux = bx - ax;
const uy = by - ay;
const uz = bz - az;
const vx = cx - ax;
const vy = cy - ay;
const vz = cz - az;
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const len = Math.hypot(nx, ny, nz);
if (len > 1e-12) {
nx /= len;
ny /= len;
nz /= len;
} else {
nx = 0;
ny = 0;
nz = 0;
}
lines.push(`facet normal ${fmt(nx)} ${fmt(ny)} ${fmt(nz)}`);
lines.push("outer loop");
lines.push(`vertex ${fmt(ax)} ${fmt(ay)} ${fmt(az)}`);
lines.push(`vertex ${fmt(bx)} ${fmt(by)} ${fmt(bz)}`);
lines.push(`vertex ${fmt(cx)} ${fmt(cy)} ${fmt(cz)}`);
lines.push("endloop");
lines.push("endfacet");
}
}
lines.push("endsolid dossier");
return lines.join("\n") + "\n";
}
+95 -15
View File
@@ -1,14 +1,18 @@
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6). // Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke). // • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände, 1 Decke, 1 Tür,
// 1 Fenster, 1 Treppe, 1 Extrusion).
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet. // • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen. // • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash). // • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { exportScheduleCsv, scheduleRows } from "./exportSchedule"; import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
import type { Project, Wall, Ceiling } from "../model/types"; import type { Project, Wall, Ceiling, Door, Opening, Stair, ExtrudedSolid } from "../model/types";
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */ /**
* Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2) +
* 1 Tür + 1 Fenster (je an W1 gehostet) + 1 Treppe + 1 Extrusion.
*/
function fixtureProject(): Project { function fixtureProject(): Project {
const walls: Wall[] = [ const walls: Wall[] = [
{ {
@@ -48,6 +52,61 @@ function fixtureProject(): Project {
ceilingTypeId: "dt", ceilingTypeId: "dt",
}, },
]; ];
const doors: Door[] = [
{
id: "T1",
type: "door",
hostWallId: "W1",
categoryCode: "21",
position: 1,
width: 0.9,
height: 2.1, // Fläche 1.89
swing: "left",
hinge: "start",
},
];
const openings: Opening[] = [
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5, // Fläche 1.80
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3, // Länge 3 (kein 2. Lauf)
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
], // 2×3 = 6 m²
height: 2.5,
},
];
return { return {
id: "t", id: "t",
name: "T", name: "T",
@@ -61,10 +120,11 @@ function fixtureProject(): Project {
], ],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls, walls,
doors: [], doors,
openings: [], openings,
ceilings, ceilings,
stairs: [], stairs,
extrudedSolids,
rooms: [], rooms: [],
drawings2d: [], drawings2d: [],
context: [], context: [],
@@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false }); const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
const lines = csv.split("\r\n"); const lines = csv.split("\r\n");
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke). // Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion).
expect(lines).toHaveLength(4); expect(lines).toHaveLength(8);
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]"); expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13. // Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
@@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => {
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40"); expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20. // Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00"); expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
// Tür T1: Breite 0.9, Höhe 2.1, keine Dicke, Fläche 1.89.
expect(lines[4]).toBe("Tür;T1;Tür;EG;0.90;2.10;;1.89");
// Fenster F1: Breite 1.2, Höhe 1.5, keine Dicke, Fläche 1.80.
expect(lines[5]).toBe("Fenster;F1;Fenster;EG;1.20;1.50;;1.80");
// Treppe S1: Lauflänge 3 (kein 2. Lauf), Steighöhe 2.6, keine Dicke/Fläche.
expect(lines[6]).toBe("Treppe;S1;Treppe;EG;3.00;2.60;;");
// Extrusion E1: keine Länge, Höhe 2.5, keine Dicke, Grundrissfläche 6.
expect(lines[7]).toBe("Extrusion;E1;Extrusion;EG;;2.50;;6.00");
}); });
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => { it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
const csv = exportScheduleCsv(fixtureProject()); const csv = exportScheduleCsv(fixtureProject());
const lines = csv.split("\r\n"); const lines = csv.split("\r\n");
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8. // Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16.
expect(lines).toHaveLength(8); expect(lines).toHaveLength(16);
expect(lines[4]).toBe(""); expect(lines[8]).toBe("");
expect(lines[5]).toContain("Zusammenfassung"); expect(lines[9]).toContain("Zusammenfassung");
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40. // Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40"); expect(lines[10]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20. // Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00"); expect(lines[11]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
// Tür-Aggregat: 1 Stk, Gesamtbreite 0.9, Fläche 1.89.
expect(lines[12]).toBe("Tür;;Tür;1 Stk;0.90;;;1.89");
// Fenster-Aggregat: 1 Stk, Gesamtbreite 1.2, Fläche 1.80.
expect(lines[13]).toBe("Fenster;;Fenster;1 Stk;1.20;;;1.80");
// Treppen-Aggregat: 1 Stk, Gesamtlänge 3, keine bekannte Fläche (leer statt 0.00).
expect(lines[14]).toBe("Treppe;;Treppe;1 Stk;3.00;;;");
// Extrusions-Aggregat: 1 Stk, keine Länge, Fläche 6.
expect(lines[15]).toBe("Extrusion;;Extrusion;1 Stk;;;;6.00");
}); });
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => { it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
@@ -114,6 +190,10 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const proj = fixtureProject(); const proj = fixtureProject();
proj.walls = []; proj.walls = [];
proj.ceilings = []; proj.ceilings = [];
proj.doors = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
const csv = exportScheduleCsv(proj); const csv = exportScheduleCsv(proj);
expect(csv.split("\r\n")).toHaveLength(1); expect(csv.split("\r\n")).toHaveLength(1);
expect(scheduleRows(proj)).toHaveLength(0); expect(scheduleRows(proj)).toHaveLength(0);
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
// Ordner → Mehrseiten-PDF (DOSSIER A3): exportiert alle Layouts eines Ordners
// als EIN gemeinsames PDF, jedes Layout = eine Seite. Das Rendering je Seite
// spiegelt exakt `ui/LayoutSheet.tsx` (Master-Rahmen + Titelblock + echte
// Viewport-Pläne via `generatePlan`→`planToPrintSvg`) und komponiert die Seiten
// per jsPDF (`addPage` je Layout) analog `export/exportPdf.ts` (buildPlanPdf).
//
// Trennung: die Seiten-Sammlung/-Reihenfolge + effektive Blattgrösse ist reine,
// testbare Logik (`folderPdfPages`, layoutModel); das SVG/jsPDF-Rendering
// (`buildFolderPdf`) braucht ein DOM und läuft nur im Browser/Tauri.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { jsPDF } from "jspdf";
import "svg2pdf.js";
import type { MasterLayout, Project } from "../model/types";
import { generatePlan } from "../plan/generatePlan";
import { planToPrintSvg } from "./planToPrintSvg";
import { saveBinaryFile } from "../io/saveFile";
import {
findMaster,
findSnapshot,
folderPdfPages,
resolveTitleBlock,
resolveViewportScale,
visibleCodesFromSnapshot,
type FolderPdfPage,
type ResolvedTitleBlock,
} from "../panels/layoutModel";
export { folderPdfPages, type FolderPdfPage };
/** Titelblock (mm-Vektor) auf die aktuelle jsPDF-Seite — Anmutung wie LayoutSheet. */
function drawTitleBlock(
doc: jsPDF,
sheetW: number,
sheetH: number,
tb: ResolvedTitleBlock,
paper: string,
orientation: string,
): void {
const boxW = 70;
const boxH = 30;
const pad = 6;
const x = sheetW - boxW - pad;
const y = sheetH - boxH - pad;
const fmt = `${paper.toUpperCase()} ${orientation === "landscape" ? "quer" : "hoch"}`;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(x, y, boxW, boxH, "FD");
doc.setLineWidth(0.2);
doc.line(x, y + 8, x + boxW, y + 8);
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
doc.setTextColor(17, 17, 17);
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.text(clip(tb.projectName, 34), x + 3, y + 5.6);
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.text(clip(tb.sheetName, 36), x + 3, y + 13);
if (tb.author) {
doc.setFontSize(7);
doc.setTextColor(68, 68, 68);
doc.text(clip(tb.author, 40), x + 3, y + 18);
doc.setTextColor(17, 17, 17);
}
doc.setFontSize(8);
doc.text(tb.scale, x + 3, y + boxH - 2.4);
doc.setFontSize(7);
doc.text(`${tb.date} · ${fmt}`, x + boxW - 3, y + boxH - 2.4, { align: "right" });
}
function clip(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
/** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */
async function renderPage(
doc: jsPDF,
project: Project,
page: FolderPdfPage,
master: MasterLayout | undefined,
): Promise<void> {
const { layout, widthMm, heightMm } = page;
// Echte Viewport-Pläne (wie ViewportPlan in LayoutSheet.tsx).
for (const vp of layout.viewports) {
const snap = findSnapshot(project, vp.snapshotId);
if (!snap) continue;
try {
const codes = visibleCodesFromSnapshot(snap);
const plan = generatePlan(
project,
snap.activeLevelId,
codes,
undefined,
snap.detail,
false,
false,
);
const scaleN = resolveViewportScale(vp, snap);
const print = planToPrintSvg(plan, {
scaleDenominator: scaleN,
pageWidthMm: vp.widthMm,
pageHeightMm: vp.heightMm,
});
await doc.svg(print.svg, {
x: vp.xMm,
y: vp.yMm,
width: vp.widthMm,
height: vp.heightMm,
});
} catch {
// Einzelner Viewport, der nicht rendert, darf die Seite nicht killen.
}
}
// Master: Rahmen + Titelblock (read-only, wie im In-Viewport-Editor).
if (master) {
if (master.border ?? true) {
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(5, 5, widthMm - 10, heightMm - 10);
}
const tb = resolveTitleBlock(project, layout, master);
drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation);
}
}
/**
* Baut das Mehrseiten-PDF eines Ordners und liefert das jsPDF-Dokument. Jede
* Seite = ein Layout in Baum-Reihenfolge. Gibt `null`, wenn der Ordner keine
* Layouts enthält (der Aufrufer meldet das dann in der UI).
*/
export async function buildFolderPdf(
project: Project,
folderId: string,
): Promise<jsPDF | null> {
const pages = folderPdfPages(project, folderId);
if (pages.length === 0) return null;
const orientOf = (p: FolderPdfPage) =>
p.widthMm > p.heightMm ? "landscape" : "portrait";
const first = pages[0];
const doc = new jsPDF({
unit: "mm",
format: [first.widthMm, first.heightMm],
orientation: orientOf(first),
compress: true,
});
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (i > 0) doc.addPage([page.widthMm, page.heightMm], orientOf(page));
const master = findMaster(project, page.layout);
await renderPage(doc, project, page, master);
}
return doc;
}
/**
* Baut das Ordner-PDF und speichert es (nativer Tauri-Dialog bzw.
* Browser-Download). Liefert `false`, wenn nichts zu exportieren war oder der
* Speichern-Dialog abgebrochen wurde.
*/
export async function saveFolderPdf(
project: Project,
folderId: string,
fileName: string,
): Promise<boolean> {
const doc = await buildFolderPdf(project, folderId);
if (!doc) return false;
const bytes = doc.output("arraybuffer");
const name = fileName.replace(/\.pdf$/i, "");
return saveBinaryFile(new Uint8Array(bytes), `${name}.pdf`, "application/pdf");
}
+46
View File
@@ -0,0 +1,46 @@
// Stützen-Geometrie: leitet aus einer Stütze (Column) ihren Grundriss-Querschnitt
// (Poché-/Footprint-Polygon) ab. Reine Funktion, keine Modell-Mutation — sowohl
// der 2D-Grundriss (generatePlan) als auch die 3D-Extrusion (toWalls3d) und die
// Auswahl-/Transform-Vorschau (transform.ts) nutzen dieselbe Kontur.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Column, Vec2 } from "../model/types";
/** Tessellierungs-Segmentzahl eines runden Stützenprofils (Grundriss + 3D-Prisma). */
export const COLUMN_ROUND_SEGMENTS = 32;
/**
* Grundriss-Querschnitt einer Stütze als geschlossenes Polygon (Modell-Meter,
* CCW), um `position` platziert und (bei Rechteck) um `rotation` gedreht. Ein
* rundes Profil wird als regelmäßiges {@link COLUMN_ROUND_SEGMENTS}-Eck
* tesselliert; der Schlusspunkt wird NICHT dupliziert.
*/
export function columnFootprint(col: Column): Vec2[] {
const { x: cx, y: cy } = col.position;
if (col.profile.kind === "round") {
const r = col.profile.radius;
const pts: Vec2[] = [];
for (let i = 0; i < COLUMN_ROUND_SEGMENTS; i++) {
const t = (i / COLUMN_ROUND_SEGMENTS) * Math.PI * 2;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return pts;
}
// Rechteck: lokale Ecken ±w/2 (X) × ±d/2 (Y), um `rotation` gedreht.
const hw = col.profile.width / 2;
const hd = col.profile.depth / 2;
const rot = col.rotation ?? 0;
const cos = Math.cos(rot);
const sin = Math.sin(rot);
const local: Vec2[] = [
{ x: -hw, y: -hd },
{ x: +hw, y: -hd },
{ x: +hw, y: +hd },
{ x: -hw, y: +hd },
];
return local.map((p) => ({
x: cx + p.x * cos - p.y * sin,
y: cy + p.x * sin + p.y * cos,
}));
}
+325 -1
View File
@@ -60,7 +60,7 @@ import {
stairGeometry, stairGeometry,
type StairGeometry as TSStairGeometry, type StairGeometry as TSStairGeometry,
} from "./stair"; } from "./stair";
import type { Stair } from "../model/types"; import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
import { import {
detectRooms, detectRooms,
pointInPolygon as rbPointInPolygon, pointInPolygon as rbPointInPolygon,
@@ -69,6 +69,19 @@ import {
type WallSegment, type WallSegment,
type WallFace, type WallFace,
} from "./roomBoundary"; } from "./roomBoundary";
import { wallReferenceOffset } from "../model/wall";
import {
doorSymbol,
openingCenter,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
wallAxisLength,
windowSymbol,
type DoorSymbol,
type WindowSymbol,
} from "./opening";
type Poly = { pts: Vec2[]; closed: boolean }; type Poly = { pts: Vec2[]; closed: boolean };
@@ -912,6 +925,317 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBou
}); });
}); });
// ── Slice 4: opening ──────────────────────────────────────────────────────────
// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch
// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert
// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port —
// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`,
// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf).
/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */
function genWall(rng: () => number): Wall {
const start = v(rng);
const angle = rng() * Math.PI * 2;
const length = 0.5 + rng() * 15;
const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length };
const refs = ["left", "center", "right"] as const;
return {
id: "w1",
type: "wall",
floorId: "f1",
categoryCode: "20",
start,
end,
wallTypeId: "wt1",
height: 2.4,
referenceLine: refs[Math.floor(rng() * 3)],
};
}
/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */
function genWallType(rng: () => number): WallType {
return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] };
}
/** Minimales Project — `getWallType` liest nur `wallTypes`. */
function makeProject(wallType: WallType): Project {
return { wallTypes: [wallType] } as unknown as Project;
}
/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse
* oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */
function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening {
const axis = wallAxisLength(wall);
const position = (rng() * 1.6 - 0.3) * axis;
const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis);
const swings = ["left", "right"] as const;
const hinges = ["start", "end"] as const;
const dirs = ["in", "out"] as const;
return {
id: "o1",
type: "opening",
hostWallId: wall.id,
categoryCode: "21",
kind,
position,
width,
height: 1.2,
sillHeight: kind === "door" ? 0 : 0.8,
wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4),
hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)],
swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)],
swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120,
openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)],
};
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => {
it("wallAxisLength (Zufallswaende, rel 1e-9)", () => {
const rng = mulberry32(50);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[];
walls.forEach((wl, i) => {
expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true);
});
});
it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => {
const rng = mulberry32(51);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as (
| { from: number; to: number }
| null
)[];
cases.forEach(({ wall, o }, i) => {
const t = openingInterval(wall, o);
expect(w[i] === null, `iv-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true);
expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true);
}
});
});
it("wallAxisFrame (u/n, rel 1e-9)", () => {
const rng = mulberry32(52);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[];
walls.forEach((wl, i) => {
const t = wallAxisFrame(wl);
expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true);
expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true);
});
});
it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => {
const rng = mulberry32(53);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as (
| { jambStart: Vec2; jambEnd: Vec2 }
| null
)[];
const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[];
cases.forEach(({ wall, o }, i) => {
const tJ = openingJambs(wall, o);
expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null);
if (tJ && wJ[i]) {
expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true);
expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true);
}
const tC = openingCenter(wall, o);
expect(wC[i] === null, `center-null#${i}`).toBe(tC === null);
if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true);
});
});
it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => {
const rng = mulberry32(54);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
const wallType = genWallType(rng);
wall.wallTypeId = wallType.id;
const project = makeProject(wallType);
const o = genOpening(rng, wall, "window");
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const glassCount = 1 + Math.floor(rng() * 3);
return { wall, o, project, total, refOffset, glassCount };
});
const qsGap = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
}));
const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[];
cases.forEach((c, i) => {
const t = openingGapQuad(c.project, c.wall, c.o);
expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null);
if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true);
});
const qsWin = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
glassCount: c.glassCount,
wingCount: c.o.wingCount,
}));
const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[];
cases.forEach((c, i) => {
const t = windowSymbol(c.project, c.wall, c.o, c.glassCount);
expect(wWin[i] === null, `win-null#${i}`).toBe(t === null);
if (t && wWin[i]) {
expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true);
}
});
});
it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => {
const rng = mulberry32(55);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "door") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
swing: o.swing,
openingDir: o.openingDir,
hinge: o.hinge,
swingAngle: o.swingAngle,
}));
const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[];
cases.forEach(({ wall, o }, i) => {
const t = doorSymbol(wall, o);
expect(w[i] === null, `door-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true);
expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true);
expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true);
expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true);
expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true);
expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true);
expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true);
}
});
});
it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => {
const wall: Wall = {
id: "w",
type: "wall",
floorId: "f",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "wt",
height: 2.4,
referenceLine: "center",
};
const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] };
const project = makeProject(wallType);
// Breite 0 → entartet.
const oZero: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 2,
width: 0,
height: 1.2,
sillHeight: 0.8,
};
const ivZero = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivZero[0] === null).toBe(true);
expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null);
// Vollstaendig ausserhalb der Achse.
const oOut: Opening = { ...oZero, position: 10, width: 1 };
const ivOut = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivOut[0] === null).toBe(true);
expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null);
// wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten.
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const oWings: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 1,
width: 2,
height: 1.2,
sillHeight: 0.8,
wingCount: 9,
};
const wWin = JSON.parse(
K.window_symbol_batch_json(
JSON.stringify([
{
start: wall.start,
end: wall.end,
position: oWings.position,
width: oWings.width,
thickness: total,
refOffset,
glassCount: 1,
wingCount: oWings.wingCount,
},
]),
),
) as (WindowSymbol | null)[];
const tWin = windowSymbol(project, wall, oWings, 1);
expect(tWin).not.toBeNull();
expect(wWin[0]).not.toBeNull();
expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length);
expect(wWin[0]!.mullionLines.length).toBe(3);
expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).toBe(true);
});
});
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => { describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
it("parallele/kollineare Strecken → null (beide)", () => { it("parallele/kollineare Strecken → null (beide)", () => {
const qs = [ const qs = [
+102
View File
@@ -0,0 +1,102 @@
// Tests für die SIA-416-Flächenbilanz (roomArea.ts): Roll-up-Hierarchie,
// insbesondere die Einhängung der Kategorie AGF (Aussengeschossfläche).
import { describe, expect, it } from "vitest";
import { balance, roomsToCsv, SIA_CATEGORIES, siaLabel } from "./roomArea";
describe("SIA_CATEGORIES", () => {
it("enthält AGF als Blatt-Kategorie", () => {
expect(SIA_CATEGORIES).toContain("AGF");
});
it("liefert das deutsche Label für AGF", () => {
expect(siaLabel("AGF")).toBe("Aussengeschossfläche");
});
});
describe("balance() — Roll-up-Hierarchie", () => {
it("rollt HNF/NNF/VF/FF/KGF wie bisher auf (Referenzfall ohne AGF)", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "NNF", area: 5 },
{ category: "VF", area: 8 },
{ category: "FF", area: 2 },
{ category: "KGF", area: 10 },
]);
expect(bal.byKey.NF).toBeCloseTo(25);
expect(bal.byKey.NGF).toBeCloseTo(35);
expect(bal.byKey.GF).toBeCloseTo(45);
expect(bal.byKey.AGF).toBe(0);
});
it("zählt AGF zur GF, aber NICHT zu NF/NGF", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "AGF", area: 12 },
]);
// NGF bleibt unverändert (nur HNF trägt bei) — AGF fliesst hier nicht ein.
expect(bal.byKey.NF).toBeCloseTo(20);
expect(bal.byKey.NGF).toBeCloseTo(20);
// GF = NGF + KGF + AGF = 20 + 0 + 12
expect(bal.byKey.AGF).toBeCloseTo(12);
expect(bal.byKey.GF).toBeCloseTo(32);
expect(bal.total).toBeCloseTo(32);
});
it("GF = NGF + KGF + AGF bei gemischter Belegung", () => {
const bal = balance([
{ category: "HNF", area: 40 },
{ category: "NNF", area: 6 },
{ category: "VF", area: 9 },
{ category: "FF", area: 3 },
{ category: "KGF", area: 15 },
{ category: "AGF", area: 7.5 },
]);
expect(bal.byKey.NF).toBeCloseTo(46);
expect(bal.byKey.NGF).toBeCloseTo(58);
expect(bal.byKey.KGF).toBeCloseTo(15);
expect(bal.byKey.AGF).toBeCloseTo(7.5);
expect(bal.byKey.GF).toBeCloseTo(bal.byKey.NGF + bal.byKey.KGF + bal.byKey.AGF);
expect(bal.byKey.GF).toBeCloseTo(80.5);
expect(bal.total).toBeCloseTo(80.5);
});
it("führt eine eigene Bilanzzeile für AGF, positioniert nach KGF und vor GF", () => {
const bal = balance([{ category: "AGF", area: 4 }]);
const keys = bal.rows.map((r) => r.key);
expect(keys).toEqual(["HNF", "NNF", "NF", "VF", "FF", "NGF", "KGF", "AGF", "GF"]);
const agfRow = bal.rows.find((r) => r.key === "AGF");
expect(agfRow).toBeDefined();
expect(agfRow?.aggregate).toBe(false);
expect(agfRow?.area).toBeCloseTo(4);
expect(agfRow?.count).toBe(1);
});
});
describe("roomsToCsv() — Bilanzblock mit AGF", () => {
it("weist einen Raum mit Kategorie AGF in Raumliste und Bilanz korrekt aus", () => {
const csv = roomsToCsv([
{ number: "R.01", name: "Wohnen", category: "HNF", area: 20 },
{ number: "R.02", name: "Balkon", category: "AGF", area: 6 },
]);
const lines = csv.split("\r\n");
// Raumliste enthält den AGF-Raum mit Kategorie + Label.
const roomLine = lines.find((l) => l.startsWith("R.02"));
expect(roomLine).toBeDefined();
expect(roomLine).toContain("AGF");
expect(roomLine).toContain("Aussengeschossfläche");
expect(roomLine).toContain("6.00");
// Bilanzblock enthält eine AGF-Zeile mit der Fläche des Raums.
const balanceAgfLine = lines.find((l) => l.includes(";AGF;"));
expect(balanceAgfLine).toBeDefined();
expect(balanceAgfLine).toContain("Aussengeschossfläche");
expect(balanceAgfLine).toContain("6.00");
// GF-Summe berücksichtigt AGF (20 HNF + 6 AGF = 26).
const gfLine = lines.find((l) => l.includes(";GF;"));
expect(gfLine).toBeDefined();
expect(gfLine).toContain("26.00");
});
});
+15 -6
View File
@@ -92,6 +92,7 @@ export function centroid(pts: Vec2[]): Vec2 {
// //
// GF Geschossfläche // GF Geschossfläche
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …) // ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …)
// └── NGF Nettogeschossfläche // └── NGF Nettogeschossfläche
// ├── NF Nutzfläche // ├── NF Nutzfläche
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck) // │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
@@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 {
// └── FF Funktionsfläche (technische Anlagen, Haustechnik) // └── FF Funktionsfläche (technische Anlagen, Haustechnik)
// //
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind // Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF. // HNF, NNF, VF, FF, KGF und AGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF+AGF = GF.
// AGF zählt zur Geschossfläche, ist aber ausserhalb der Gebäudehülle (Aussen-
// bereich) und fliesst daher NICHT in NGF (bzw. NF) ein.
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */ /** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF"; export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF";
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */ /** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
export type SiaAggregate = "NF" | "NGF" | "GF"; export type SiaAggregate = "NF" | "NGF" | "GF";
@@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
"VF", "VF",
"FF", "FF",
"KGF", "KGF",
"AGF",
] as const; ] as const;
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */ /** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
@@ -134,6 +138,7 @@ const SIA_LABELS: Record<SiaKey, string> = {
VF: "Verkehrsfläche", VF: "Verkehrsfläche",
FF: "Funktionsfläche", FF: "Funktionsfläche",
KGF: "Konstruktionsfläche", KGF: "Konstruktionsfläche",
AGF: "Aussengeschossfläche",
NF: "Nutzfläche", NF: "Nutzfläche",
NGF: "Nettogeschossfläche", NGF: "Nettogeschossfläche",
GF: "Geschossfläche", GF: "Geschossfläche",
@@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string {
/** /**
* Zuordnung Blatt-Kategorie übergeordnete Aggregate. Eine HNF trägt zu * Zuordnung Blatt-Kategorie übergeordnete Aggregate. Eine HNF trägt zu
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF. * NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
* Wird für den Bilanz-Roll-up genutzt. * AGF (Aussengeschossfläche) trägt nur zu GF bei sie liegt ausserhalb der
* Gebäudehülle und zählt NICHT zu NF/NGF. Wird für den Bilanz-Roll-up genutzt.
*/ */
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = { const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
HNF: ["NF", "NGF", "GF"], HNF: ["NF", "NGF", "GF"],
@@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
VF: ["NGF", "GF"], VF: ["NGF", "GF"],
FF: ["NGF", "GF"], FF: ["NGF", "GF"],
KGF: ["GF"], KGF: ["GF"],
AGF: ["GF"],
}; };
// ── Raum-Flächenergebnis ───────────────────────────────────────────────────── // ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
@@ -242,14 +249,14 @@ export interface Balance {
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}). * anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
* *
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge: * Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ) * HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, AGF, GF(=Σ)
* passend für die Bilanz-Tabelle und den CSV-Export. * passend für die Bilanz-Tabelle und den CSV-Export.
*/ */
export function balance( export function balance(
rooms: { category: SiaCategory; area: number }[], rooms: { category: SiaCategory; area: number }[],
): Balance { ): Balance {
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 }; const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 }; const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 }; const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 }; const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
@@ -269,6 +276,7 @@ export function balance(
VF: leaf.VF, VF: leaf.VF,
FF: leaf.FF, FF: leaf.FF,
KGF: leaf.KGF, KGF: leaf.KGF,
AGF: leaf.AGF,
NF: agg.NF, NF: agg.NF,
NGF: agg.NGF, NGF: agg.NGF,
GF: agg.GF, GF: agg.GF,
@@ -297,6 +305,7 @@ export function balance(
mkLeaf("FF"), mkLeaf("FF"),
mkAgg("NGF"), mkAgg("NGF"),
mkLeaf("KGF"), mkLeaf("KGF"),
mkLeaf("AGF"),
mkAgg("GF"), mkAgg("GF"),
]; ];
+365 -7
View File
@@ -15,16 +15,28 @@ export const de = {
"topbar.perspektive": "Perspektive", "topbar.perspektive": "Perspektive",
"view3d.group": "Ansicht 3D", "view3d.group": "Ansicht 3D",
"view3d.front": "Front", "view3d.front": "Vorne",
"view3d.front.hint": "Frontalansicht von vorne", "view3d.front.hint": "Frontalansicht von vorne",
"view3d.back": "Hinten",
"view3d.back.hint": "Rückansicht von hinten",
"view3d.top": "Oben", "view3d.top": "Oben",
"view3d.top.hint": "Draufsicht (senkrecht von oben)", "view3d.top.hint": "Draufsicht (senkrecht von oben)",
"view3d.side": "Seite", "view3d.side": "Rechts",
"view3d.side.hint": "Seitenansicht (90° zur Front)", "view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
"view3d.left": "Links",
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
"view3d.perspective": "Perspektive", "view3d.perspective": "Perspektive",
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)", "view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
"view3d.iso": "Isometrie", "view3d.iso": "Isometrie",
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)", "view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
"view3d.isoFrontLeft": "Vorne-Links",
"view3d.isoFrontLeft.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-links)",
"view3d.isoBackRight": "Hinten-Rechts",
"view3d.isoBackRight.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-rechts)",
"view3d.isoBackLeft": "Hinten-Links",
"view3d.isoBackLeft.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-links)",
"view3d.iso.menu": "Weitere Iso-Ansichten",
"view3d.iso.menu.hint": "Einen der vier oberen Iso-Oktanten wählen",
"view3d.camera": "Kamera", "view3d.camera": "Kamera",
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)", "view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
"view3d.fov": "Sichtwinkel", "view3d.fov": "Sichtwinkel",
@@ -86,12 +98,12 @@ export const de = {
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden", "topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
// ── Layout-Menü (Topbar) ───────────────────────────────────────────────── // ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
"layout.title": "Layout", "layout.title": "Arbeitsumgebung",
"layout.placeholder": "Layout …", "layout.placeholder": "Arbeitsumgebung …",
"layout.saveCurrent": " Aktuelles speichern …", "layout.saveCurrent": " Aktuelle speichern …",
"layout.loadGroup": "Laden", "layout.loadGroup": "Laden",
"layout.deleteGroup": "Löschen", "layout.deleteGroup": "Löschen",
"layout.savePrompt": "Layout speichern als:", "layout.savePrompt": "Arbeitsumgebung speichern als:",
// ── Sichtbarkeits-Kombinationen (Topbar) ───────────────────────────────── // ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
"combo.layers": "Ebenen-Komb.", "combo.layers": "Ebenen-Komb.",
@@ -120,6 +132,10 @@ export const de = {
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius", "tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen", "tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)", "tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
"tool.text": "Text",
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
"tool.textbox": "Textspalte",
"tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen",
// Ribbon-Oberleiste (Tabs + Gruppen) // Ribbon-Oberleiste (Tabs + Gruppen)
"ribbon.tab.2d": "2D", "ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D", "ribbon.tab.3d": "3D",
@@ -234,6 +250,7 @@ export const de = {
"attr.fill": "Füllung", "attr.fill": "Füllung",
"attr.fillColor": "Füllfarbe", "attr.fillColor": "Füllfarbe",
"attr.clearFill": "Füllfarbe entfernen", "attr.clearFill": "Füllfarbe entfernen",
"attr.pickColor": "Farbe wählen",
"attr.foreground": "Vordergrund", "attr.foreground": "Vordergrund",
"attr.background": "Hintergrund", "attr.background": "Hintergrund",
"attr.bySystem": "Nach System", "attr.bySystem": "Nach System",
@@ -266,12 +283,29 @@ export const de = {
"objinfo.kind.opening": "Öffnung", "objinfo.kind.opening": "Öffnung",
"objinfo.kind.stair": "Treppe", "objinfo.kind.stair": "Treppe",
"objinfo.kind.room": "Raum", "objinfo.kind.room": "Raum",
"objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Stütze",
// ── Stützen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.column.section": "Stütze",
"objinfo.column.profile": "Profil",
"objinfo.column.profile.rect": "Rechteck",
"objinfo.column.profile.round": "Rund",
"objinfo.column.width": "Breite",
"objinfo.column.depth": "Tiefe",
"objinfo.column.radius": "Radius",
"objinfo.column.height": "Höhe",
"objinfo.column.rotation": "Drehung (°)",
"objinfo.column.floor": "Geschoss",
// ── Wand-Attribute (Object-Info-Panel) ────────────────────────────────── // ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.wall.section": "Wand", "objinfo.wall.section": "Wand",
"objinfo.wall.refLine": "Referenzlinie", "objinfo.wall.refLine": "Referenzlinie",
"objinfo.wall.refLine.left": "Außen (links)", "objinfo.wall.refLine.left": "Außen (links)",
"objinfo.wall.refLine.center": "Mitte", "objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)", "objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.sliceTermination": "An Decke enden",
"objinfo.wall.sliceTermination.both": "Aus",
"objinfo.wall.sliceTermination.below": "Unten",
"objinfo.wall.sliceTermination.above": "Oben",
"objinfo.wall.buildup": "Aufbau", "objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Solid", "objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Mehrschichtig", "objinfo.wall.multi": "Mehrschichtig",
@@ -284,6 +318,11 @@ export const de = {
"objinfo.wall.top": "Oberkante (OK)", "objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "Gebunden", "objinfo.wall.anchorFloor": "Gebunden",
"objinfo.wall.anchorCustom": "Eigene", "objinfo.wall.anchorCustom": "Eigene",
"objinfo.anchor.refFloor": "Referenzgeschoss",
"objinfo.anchor.customHeight": "Eigene Höhe",
"objinfo.wall.length": "Länge",
"objinfo.wall.volumeGross": "Brutto",
"objinfo.volume": "Volumen",
"objinfo.wall.height": "Höhe", "objinfo.wall.height": "Höhe",
// ── Decken-Attribute (Object-Info-Panel) ──────────────────────────────── // ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
"objinfo.ceiling.section": "Decke", "objinfo.ceiling.section": "Decke",
@@ -299,6 +338,9 @@ export const de = {
"objinfo.ceiling.anchorCustom": "Eigene", "objinfo.ceiling.anchorCustom": "Eigene",
// ── Öffnungs-Attribute (Object-Info-Panel) ────────────────────────────── // ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
"objinfo.opening.section": "Öffnung", "objinfo.opening.section": "Öffnung",
"objinfo.opening.type": "Typ",
"objinfo.stair.type": "Typ",
"objinfo.type.none": "(kein Typ)",
"objinfo.opening.kind": "Art", "objinfo.opening.kind": "Art",
"objinfo.opening.kind.window": "Fenster", "objinfo.opening.kind.window": "Fenster",
"objinfo.opening.kind.door": "Tür", "objinfo.opening.kind.door": "Tür",
@@ -359,6 +401,15 @@ export const de = {
"objinfo.room.perimeter": "Umfang", "objinfo.room.perimeter": "Umfang",
"objinfo.room.refFloor": "Referenzgeschoss", "objinfo.room.refFloor": "Referenzgeschoss",
"objinfo.room.editStamp": "Stempel bearbeiten …", "objinfo.room.editStamp": "Stempel bearbeiten …",
// ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ─────────
"objinfo.extrudedSolid.section": "Extrusion",
"objinfo.extrudedSolid.height": "Höhe",
"objinfo.extrudedSolid.taper": "Verjüngung",
"objinfo.extrudedSolid.area": "Grundfläche",
"objinfo.extrudedSolid.floor": "Geschoss",
"objinfo.rotate": "Drehen",
"objinfo.drawing.length": "Länge",
"objinfo.drawing.radius": "Radius",
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss", "objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
"levels.addFloor": "+ Geschoss", "levels.addFloor": "+ Geschoss",
"levels.addSection": "+ Schnitt", "levels.addSection": "+ Schnitt",
@@ -489,6 +540,9 @@ export const de = {
"default.ceilingTypeSingle": "Decke {thickness} m", "default.ceilingTypeSingle": "Decke {thickness} m",
"default.wallTypeName": "Neuer Wandtyp", "default.wallTypeName": "Neuer Wandtyp",
"default.ceilingTypeName": "Neuer Deckentyp", "default.ceilingTypeName": "Neuer Deckentyp",
"default.doorTypeName": "Neuer Türtyp",
"default.windowTypeName": "Neuer Fenstertyp",
"default.stairTypeName": "Neuer Treppentyp",
// ── Warnungen (Lösch-Schutz) ───────────────────────────────────────────── // ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.", "alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
@@ -497,6 +551,9 @@ export const de = {
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.", "alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
"alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.", "alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.",
"alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.", "alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.",
"alert.doorTypeInUse": "Türtyp „{name}\" wird von einer Tür verwendet und kann nicht gelöscht werden.",
"alert.windowTypeInUse": "Fenstertyp „{name}\" wird von einem Fenster verwendet und kann nicht gelöscht werden.",
"alert.stairTypeInUse": "Treppentyp „{name}\" wird von einer Treppe verwendet und kann nicht gelöscht werden.",
// ── Ressourcen-Manager ─────────────────────────────────────────────────── // ── Ressourcen-Manager ───────────────────────────────────────────────────
"resources.title": "Ressourcen", "resources.title": "Ressourcen",
@@ -507,7 +564,38 @@ export const de = {
"resources.tab.lines": "Linien", "resources.tab.lines": "Linien",
"resources.tab.wallStyles": "Wandstile", "resources.tab.wallStyles": "Wandstile",
"resources.tab.ceilingStyles": "Deckenstile", "resources.tab.ceilingStyles": "Deckenstile",
"resources.tab.doorStyles": "Türtypen",
"resources.tab.windowStyles": "Fenstertypen",
"resources.tab.stairStyles": "Treppentypen",
"resources.tab.materials": "Materialien", "resources.tab.materials": "Materialien",
"resources.tab.overrides": "Overrides",
// Grafische Overrides (Regel-Engine).
"resources.overrides.hint":
"Regeln übersteuern Farbe/Strichstärke/Linienstil beim Rendern (Elementdaten bleiben unverändert). Reihenfolge = Priorität: pro Feld gewinnt die oberste aktive Regel.",
"resources.overrides.empty": "Noch keine Override-Regeln.",
"resources.overrides.placeholder": "Regel",
"resources.overrides.defaultName": "Neue Regel",
"resources.overrides.delete": "Regel löschen",
"resources.overrides.moveUp": "Regel nach oben (höhere Priorität)",
"resources.overrides.moveDown": "Regel nach unten (tiefere Priorität)",
"resources.overrides.enabled": "Regel aktiv",
"resources.overrides.condition": "Bedingung",
"resources.overrides.condType": "Kriterium",
"resources.overrides.condType.layer": "Ebene (Name/Code)",
"resources.overrides.condType.object": "Objektname",
"resources.overrides.operator": "Operator",
"resources.overrides.op.equals": "ist gleich",
"resources.overrides.op.contains": "enthält",
"resources.overrides.op.startsWith": "beginnt mit",
"resources.overrides.op.notEquals": "ist ungleich",
"resources.overrides.value": "Wert",
"resources.overrides.value.placeholder": "z. B. Wände oder 20",
"resources.overrides.actions": "Aktionen",
"resources.overrides.action.color": "Farbe",
"resources.overrides.action.lineweight": "Strichstärke (mm)",
"resources.overrides.action.linetype": "Linienstil",
"resources.overrides.linetype.none": "— (unverändert)",
"resources.wallStyles.hint": "resources.wallStyles.hint":
"Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.", "Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
@@ -517,6 +605,75 @@ export const de = {
"resources.ceilingStyles.empty": "Noch keine Deckentypen.", "resources.ceilingStyles.empty": "Noch keine Deckentypen.",
"resources.wallStyles.placeholder": "Wandtyp", "resources.wallStyles.placeholder": "Wandtyp",
"resources.ceilingStyles.placeholder": "Deckentyp", "resources.ceilingStyles.placeholder": "Deckentyp",
// Bauteil-Typen (Tür/Fenster/Treppe) — Formular-Bibliotheken.
"resources.doorTypes.hint":
"Wiederverwendbare Türtypen mit Bauart, Blattausführung und Standardmaßen für neu platzierte Türen.",
"resources.doorTypes.empty": "Noch keine Türtypen.",
"resources.doorTypes.placeholder": "Türtyp",
"resources.windowTypes.hint":
"Wiederverwendbare Fenstertypen mit Öffnungsart, Verglasung und Standardmaßen für neu platzierte Fenster.",
"resources.windowTypes.empty": "Noch keine Fenstertypen.",
"resources.windowTypes.placeholder": "Fenstertyp",
"resources.stairTypes.hint":
"Wiederverwendbare Treppentypen mit Tragart, Stufenausbildung und Geländer für neu platzierte Treppen.",
"resources.stairTypes.empty": "Noch keine Treppentypen.",
"resources.stairTypes.placeholder": "Treppentyp",
"resources.delete.doorType": "Türtyp „{name}\" löschen",
"resources.delete.windowType": "Fenstertyp „{name}\" löschen",
"resources.delete.stairType": "Treppentyp „{name}\" löschen",
"resources.field.doorKind": "Bauart",
"resources.field.leafCount": "Flügelzahl",
"resources.field.leafStyle": "Blattausführung",
"resources.field.glazingRatio": "Glasanteil",
"resources.field.frameThickness": "Zargenstärke (m)",
"resources.field.frameDepth": "Zargentiefe (m)",
"resources.field.defaultWidth": "Lichtbreite (m)",
"resources.field.defaultHeight": "Lichthöhe (m)",
"resources.field.threshold": "Bodenschwelle",
"resources.field.windowKind": "Öffnungsart",
"resources.field.wingCount": "Flügelzahl",
"resources.field.glazing": "Verglasung",
"resources.field.defaultSillHeight": "Brüstungshöhe (m)",
"resources.field.sillBoard": "Fensterbank",
"resources.field.structure": "Tragart",
"resources.field.closedRisers": "Setzstufen geschlossen",
"resources.field.treadThickness": "Trittstufendicke (m)",
"resources.field.nosing": "Trittkanten-Überstand (m)",
"resources.field.railing": "Geländer",
"resources.field.stairWidth": "Laufbreite (m)",
"resources.doorKind.dreh": "Drehflügel",
"resources.doorKind.schiebe": "Schiebe",
"resources.doorKind.wandoeffnung": "Wandöffnung",
"resources.leafCount.1": "1-flügelig",
"resources.leafCount.2": "2-flügelig",
"resources.leafStyle.glatt": "Glatt",
"resources.leafStyle.kassette": "Kassette",
"resources.leafStyle.glas": "Glas",
"resources.windowKind.dreh": "Dreh",
"resources.windowKind.kipp": "Kipp",
"resources.windowKind.drehkipp": "Dreh-Kipp",
"resources.windowKind.fest": "Fest",
"resources.windowKind.schiebe": "Schiebe",
"resources.glazing.einfach": "Einfach",
"resources.glazing.zweifach": "Zweifach",
"resources.glazing.dreifach": "Dreifach",
"resources.sillBoard.keine": "Keine",
"resources.sillBoard.innen": "Innen",
"resources.sillBoard.aussen": "Außen",
"resources.sillBoard.beide": "Beide",
"resources.structure.massiv": "Massiv",
"resources.structure.beton": "Betontreppe (Laufplatte)",
"resources.structure.wange": "Wange",
"resources.structure.aufgesattelt": "Aufgesattelt",
"resources.structure.spindel": "Spindel",
"resources.railing.keine": "Keine",
"resources.railing.links": "Links",
"resources.railing.rechts": "Rechts",
"resources.railing.beide": "Beide",
"resources.col.layer": "Schicht", "resources.col.layer": "Schicht",
"resources.col.component": "Bauteil", "resources.col.component": "Bauteil",
"resources.col.thickness": "Dicke (mm)", "resources.col.thickness": "Dicke (mm)",
@@ -698,6 +855,9 @@ export const de = {
"cmd.opening.dir": "Richtung", "cmd.opening.dir": "Richtung",
"cmd.opening.sillField": "Brüstung", "cmd.opening.sillField": "Brüstung",
"cmd.opening.swingField": "Winkel", "cmd.opening.swingField": "Winkel",
"cmd.column.label": "Stütze",
"cmd.column.place": "Stütze setzen:",
"cmd.column.profile": "Profil",
"cmd.stair.label": "Treppe", "cmd.stair.label": "Treppe",
"cmd.stair.start": "Startpunkt der Treppe:", "cmd.stair.start": "Startpunkt der Treppe:",
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):", "cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
@@ -732,11 +892,23 @@ export const de = {
"cmd.arc.center": "Mittelpunkt:", "cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):", "cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):", "cmd.arc.end": "Endpunkt (Endwinkel):",
"cmd.text.label": "Text",
"cmd.text.point": "Ankerpunkt:",
"cmd.text.enter": "Text eingeben:",
"cmd.textbox.label": "Textspalte",
"cmd.textbox.point": "Ankerpunkt:",
"cmd.textbox.width": "Spaltenbreite (zweiter Punkt):",
"cmd.textbox.enter": "Text eingeben:",
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl. // Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
"cmd.move.label": "Verschieben", "cmd.move.label": "Verschieben",
"cmd.move.base": "Basispunkt:", "cmd.move.base": "Basispunkt:",
"cmd.move.target": "Zielpunkt:", "cmd.move.target": "Zielpunkt:",
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).", "cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.rotate.label": "Drehen",
"cmd.measure.label": "Messen",
"cmd.measure.start": "Messen von:",
"cmd.measure.to": "bis (Distanz · Winkel):",
"transform.needSelection": "Erst Objekte wählen, dann drehen.",
"cmd.mirror.label": "Spiegeln", "cmd.mirror.label": "Spiegeln",
"cmd.mirror.first": "Erster Punkt der Spiegelachse:", "cmd.mirror.first": "Erster Punkt der Spiegelachse:",
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:", "cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
@@ -749,6 +921,10 @@ export const de = {
"cmd.offset.label": "Versatz", "cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:", "cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:", "cmd.offset.side": "Seite oder Distanz:",
"cmd.extrude.label": "Extrudieren",
"cmd.extrude.pick": "Geschlossenes Profil wählen:",
"cmd.extrude.height": "Höhe:",
"cmd.extrude.taper": "Verjüngung (01, 1=Spitze):",
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend. // Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
"cmd.trim.label": "Stutzen", "cmd.trim.label": "Stutzen",
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):", "cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
@@ -863,11 +1039,43 @@ export const de = {
"file.exportPdf": "Als PDF exportieren…", "file.exportPdf": "Als PDF exportieren…",
"file.exportDxf": "Als DXF exportieren…", "file.exportDxf": "Als DXF exportieren…",
"file.exportSchedule": "Bauteilliste (CSV)…", "file.exportSchedule": "Bauteilliste (CSV)…",
"file.exportIfc": "IFC exportieren",
"file.exportObj": "OBJ exportieren (3D-Mesh)",
"file.exportStl": "STL exportieren (3D-Mesh)",
"file.export": "Exportieren",
"file.openFailed": "Projekt-Datei konnte nicht gelesen werden.",
// ── Lock-Konflikt (Projektdatei bereits in anderer Instanz geöffnet) ────
"lock.title": "Projekt bereits geöffnet",
"lock.message":
"Diese Projektdatei ist bereits in einer anderen Instanz geöffnet.",
"lock.messageInfo": "Geöffnet auf {host} (Prozess {pid}).",
"lock.openReadOnly": "Schreibgeschützt öffnen",
"lock.forceOpen": "Trotzdem erzwingen",
"lock.forceOpenWarning":
"Achtung: Öffnet zum Schreiben, obwohl eine andere Instanz die Datei hält — Änderungen können sich gegenseitig überschreiben.",
"exportSave.title": "Exportieren",
"exportSave.format": "Format",
"exportSave.filename": "Dateiname",
"exportSave.locationNote": "Wird in den Standard-Download-Ordner gespeichert.",
"exportSave.fmt.csv": "Bauteilliste (CSV)",
"exportSave.fmt.ifc": "IFC (BIM)",
"exportSave.fmt.obj": "OBJ (3D-Mesh)",
"exportSave.fmt.stl": "STL (3D-Mesh)",
"about.title": "Über dossier",
"about.version": "Version",
"about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.",
"about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "Lizenz: AGPL-3.0-or-later",
"about.suite": "Teil der openbureau-Suite",
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
"about.close": "Schließen",
// ── Einstellungs-Fenster ────────────────────────────────────────────────── // ── Einstellungs-Fenster ──────────────────────────────────────────────────
"topbar.settings": "Einstellungen", "topbar.settings": "Einstellungen",
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)", "topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
"settings.title": "Einstellungen", "settings.title": "Einstellungen",
"settings.section.workspace": "Arbeitsumgebung",
"settings.section.display": "Darstellung", "settings.section.display": "Darstellung",
"settings.marqueeColor": "Auswahlrahmen-Farbe", "settings.marqueeColor": "Auswahlrahmen-Farbe",
"settings.marqueeColor.hint": "settings.marqueeColor.hint":
@@ -907,6 +1115,9 @@ export const de = {
"ctxImport.src.osmRoads": "OSM-Strassen", "ctxImport.src.osmRoads": "OSM-Strassen",
"ctxImport.src.osmWater": "OSM-Wasser", "ctxImport.src.osmWater": "OSM-Wasser",
"ctxImport.src.osmGreen": "OSM-Grün", "ctxImport.src.osmGreen": "OSM-Grün",
"ctxImport.src.osmParking": "OSM-Parkplätze",
"ctxImport.src.osmRailway": "OSM-Bahn",
"ctxImport.src.osmForest": "OSM-Wald",
"ctxImport.cancel": "Abbrechen", "ctxImport.cancel": "Abbrechen",
"ctxImport.confirm": "Importieren", "ctxImport.confirm": "Importieren",
"ctxImport.importing": "Importiere…", "ctxImport.importing": "Importiere…",
@@ -950,6 +1161,9 @@ export const de = {
"roomStamp.showFloorArea": "Bodenfläche anzeigen", "roomStamp.showFloorArea": "Bodenfläche anzeigen",
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche", "roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
"roomStamp.showUsage": "Nutzung anzeigen", "roomStamp.showUsage": "Nutzung anzeigen",
"roomStamp.roundingStep": "Flächen-Rundung",
"roomStamp.roundingStepDefault": "Standard (2 Nachkommastellen)",
"roomStamp.occupancy": "Personenzahl",
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text", "text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
// ── Rich-Text-Editor ──────────────────────────────────────────────────────── // ── Rich-Text-Editor ────────────────────────────────────────────────────────
@@ -958,6 +1172,8 @@ export const de = {
"rt.underline": "Unterstrichen", "rt.underline": "Unterstrichen",
"rt.strike": "Durchgestrichen", "rt.strike": "Durchgestrichen",
"rt.strikethrough": "Durchgestrichen", "rt.strikethrough": "Durchgestrichen",
"rt.super": "Hochgestellt",
"rt.sub": "Tiefgestellt",
"rt.fontSize": "Schriftgrösse", "rt.fontSize": "Schriftgrösse",
"rt.color": "Farbe", "rt.color": "Farbe",
"rt.preset": "Vorlage", "rt.preset": "Vorlage",
@@ -969,6 +1185,148 @@ export const de = {
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.", "WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.",
"viewport3d.grid.show": "Bodenraster einblenden", "viewport3d.grid.show": "Bodenraster einblenden",
"viewport3d.grid.hide": "Bodenraster ausblenden", "viewport3d.grid.hide": "Bodenraster ausblenden",
"viewport3d.section.label": "Schnittebene",
"viewport3d.section.show": "Schnittebene ein",
"viewport3d.section.hide": "Schnittebene aus",
"viewport3d.grip.vertex": "Wandende ziehen",
"viewport3d.grip.height": "Höhe ziehen",
"viewport3d.grip.move": "Verschieben",
// ── Element-Baum (Elemente-Panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elemente",
"elements.search": "Elemente suchen…",
"elements.empty": "Keine Bauteile im Projekt.",
"elements.noMatch": "Keine Treffer.",
"elements.kind.wall": "Wand",
"elements.kind.ceiling": "Decke",
"elements.kind.window": "Fenster",
"elements.kind.opening": "Öffnung",
"elements.kind.stair": "Treppe",
"elements.kind.extrusion": "Extrusion",
// Ausschnitte / View-Snapshots (DOSSIER A2).
"viewsnap.title": "Ausschnitte",
"viewsnap.saveCurrent": " Aktuellen Ausschnitt speichern …",
"viewsnap.savePrompt": "Ausschnitt speichern als:",
"viewsnap.renamePrompt": "Ausschnitt umbenennen:",
"viewsnap.rename": "Umbenennen",
"viewsnap.delete": "Löschen",
"viewsnap.deleteConfirm": "Ausschnitt „{name}\" löschen?",
"viewsnap.empty": "Noch keine Ausschnitte. Ansicht einrichten und speichern.",
"viewsnap.applyHint": "Ausschnitt „{name}\" wiederherstellen",
"viewsnap.add.folder": "Ordner erstellen",
"viewsnap.add.snapshot": "Ausschnitt speichern",
"viewsnap.folder.defaultName": "Neuer Ordner",
"viewsnap.folder.rename": "Ordner umbenennen",
"viewsnap.folder.delete": "Ordner löschen",
"viewsnap.folder.empty": "Leerer Ordner",
"viewsnap.tree.empty": "Noch keine Ordner oder Ausschnitte. Über „+\" anlegen.",
"viewsnap.footer.scale": "Massstab",
"viewsnap.footer.layerCombo": "Ebenenkombi",
"viewsnap.footer.drawingCombo": "Zeichnungskombi",
"viewsnap.footer.overrides": "Overrides",
"viewsnap.footer.none": "—",
// Layout-Blätter mit Masterlayout (DOSSIER A3).
"layouts.panelTitle": "Mappe",
"layouts.title": "Layouts",
"layouts.new": "Anlegen",
"layouts.namePlaceholder": "Blattname …",
"layouts.defaultName": "Neues Layout",
"layouts.rename": "Umbenennen",
"layouts.delete": "Löschen",
"layouts.renamePrompt": "Umbenennen:",
"layouts.deleteConfirm": "„{name}\" löschen?",
"layouts.openHint": "Layout „{name}\" öffnen",
"layouts.empty": "Noch keine Layouts. Blatt anlegen und öffnen.",
"layouts.paper": "Papierformat",
"layouts.orientation": "Ausrichtung",
"layouts.portrait": "Hoch",
"layouts.landscape": "Quer",
"layouts.border": "Rahmen",
"layouts.masters": "Masterlayouts",
"layouts.newMaster": " Master",
"layouts.newMasterPrompt": "Neues Masterlayout:",
"layouts.defaultMasterName": "Master",
"layouts.noMasters": "Noch keine Masterlayouts.",
"layouts.master": "Master",
"layouts.noMaster": "Kein Master",
"layouts.tb.projectName": "Projekt",
"layouts.tb.sheetName": "Blattname",
"layouts.tb.scale": "Massstab",
"layouts.tb.date": "Datum",
"layouts.tb.author": "Bearbeiter",
"layouts.tb.autoPlaceholder": "(automatisch)",
// Editor
"layouts.editorTitle": "Layout-Editor",
"layouts.fit": "Einpassen",
"layouts.addViewport": "Viewport hinzufügen",
"layouts.pickSnapshot": "Ausschnitt wählen …",
"layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.",
"layouts.viewportProps": "Viewport-Eigenschaften",
"layouts.noViewports": "Noch keine Viewports. Ausschnitt wählen und hinzufügen.",
"layouts.selectViewport": "Viewport auf dem Blatt anklicken.",
"layouts.missingSnapshot": "Ausschnitt fehlt",
"layouts.xMm": "X (mm)",
"layouts.yMm": "Y (mm)",
"layouts.widthMm": "Breite (mm)",
"layouts.heightMm": "Höhe (mm)",
"layouts.scale": "Massstab",
"layouts.scaleReset": "Massstab vom Ausschnitt",
"layouts.removeViewport": "Viewport entfernen",
"layouts.backToModel": "Zurück zum Modell",
"layouts.placeViewport": "Viewport aufziehen",
"layouts.placeHint": "Rechteck auf dem Blatt aufziehen, dann Ausschnitt binden.",
"layouts.bindSnapshot": "Ausschnitt binden",
"layouts.emptyViewports":
"Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.",
// Annotationen (Linie/Rechteck/Text) direkt auf dem Blatt
"layouts.annotLine": "Linie zeichnen",
"layouts.annotRect": "Rechteck zeichnen",
"layouts.annotText": "Text setzen",
"layouts.annotTextTitle": "Text",
"layouts.annotTextLabel": "Textinhalt:",
"layouts.annotEditText": "Text ändern",
"layouts.annotColor": "Farbe",
"layouts.annotWeight": "Strichstärke (mm)",
"layouts.annotRemove": "Annotation entfernen",
"layouts.annot.line": "Linie",
"layouts.annot.rect": "Rechteck",
"layouts.annot.text": "Text",
// Baum / „+"-Menü / Ordner
"layouts.add": "Neu …",
"layouts.addFolder": "Ordner erstellen",
"layouts.addLayout": "Layout erstellen",
"layouts.addMaster": "Masterlayout erstellen",
"layouts.add.folder": "Ordner erstellen",
"layouts.add.layout": "Layout erstellen",
"layouts.add.master": "Masterlayout erstellen",
"layouts.folder.defaultName": "Neuer Ordner",
"layouts.folder.rename": "Ordner umbenennen",
"layouts.folder.delete": "Ordner löschen",
"layouts.folder.export": "Ordner als PDF exportieren",
"layouts.folder.empty": "Leerer Ordner",
"layouts.folder.exportEmpty": "Ordner enthält keine Layouts.",
"layouts.tree.empty": "Noch keine Ordner oder Layouts. Über „+\" anlegen.",
// Dialog „Neues Layout"
"layouts.newLayout.title": "Neues Layout",
"layouts.newLayout.source": "Vorlage",
"layouts.newLayout.fromMaster": "Masterlayout als Vorlage",
"layouts.newLayout.freeSize": "Freie Grösse",
"layouts.newLayout.pickMaster": "Masterlayout wählen …",
"layouts.newLayout.noMasters": "Keine Masterlayouts vorhanden.",
// Dialog „Neues Masterlayout"
"layouts.newMaster.title": "Neues Masterlayout",
// Gemeinsame Grössen-Auswahl (beide Dialoge)
"layouts.size.format": "Format",
"layouts.size.custom": "Freie Grösse",
"layouts.size.a4": "A4",
"layouts.size.a3": "A3",
"layouts.paperGroup.a": "A-Reihe",
"layouts.paperGroup.b": "B-Reihe",
"layouts.size.widthMm": "Breite (mm)",
"layouts.size.heightMm": "Höhe (mm)",
"layouts.size.create": "Erstellen",
} as const; } as const;
export type TranslationKey = keyof typeof de; export type TranslationKey = keyof typeof de;
+362 -5
View File
@@ -16,14 +16,26 @@ export const en: Record<TranslationKey, string> = {
"view3d.group": "3D view", "view3d.group": "3D view",
"view3d.front": "Front", "view3d.front": "Front",
"view3d.front.hint": "Front view (frontal)", "view3d.front.hint": "Front view (frontal)",
"view3d.back": "Back",
"view3d.back.hint": "Back view (rear)",
"view3d.top": "Top", "view3d.top": "Top",
"view3d.top.hint": "Top view (straight down)", "view3d.top.hint": "Top view (straight down)",
"view3d.side": "Side", "view3d.side": "Right",
"view3d.side.hint": "Side view (90° to front)", "view3d.side.hint": "Right side view (90° to the front)",
"view3d.left": "Left",
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
"view3d.perspective": "Perspective", "view3d.perspective": "Perspective",
"view3d.perspective.hint": "Free perspective (3/4 view)", "view3d.perspective.hint": "Free perspective (3/4 view)",
"view3d.iso": "Isometric", "view3d.iso": "Isometric",
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)", "view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
"view3d.isoFrontLeft": "Front Left",
"view3d.isoFrontLeft.hint": "Isometric view (equiangular from front-top-left)",
"view3d.isoBackRight": "Back Right",
"view3d.isoBackRight.hint": "Isometric view (equiangular from back-top-right)",
"view3d.isoBackLeft": "Back Left",
"view3d.isoBackLeft.hint": "Isometric view (equiangular from back-top-left)",
"view3d.iso.menu": "More isometric views",
"view3d.iso.menu.hint": "Pick one of the four upper isometric octants",
"view3d.camera": "Camera", "view3d.camera": "Camera",
"view3d.camera.hint": "Camera settings (field of view)", "view3d.camera.hint": "Camera settings (field of view)",
"view3d.fov": "Field of view", "view3d.fov": "Field of view",
@@ -85,12 +97,12 @@ export const en: Record<TranslationKey, string> = {
"topbar.resources.hint": "Show/hide the resources window", "topbar.resources.hint": "Show/hide the resources window",
// Layout menu. // Layout menu.
"layout.title": "Layout", "layout.title": "Workspace",
"layout.placeholder": "Layout …", "layout.placeholder": "Workspace …",
"layout.saveCurrent": " Save current …", "layout.saveCurrent": " Save current …",
"layout.loadGroup": "Load", "layout.loadGroup": "Load",
"layout.deleteGroup": "Delete", "layout.deleteGroup": "Delete",
"layout.savePrompt": "Save layout as:", "layout.savePrompt": "Save workspace as:",
// Visibility combinations (topbar). // Visibility combinations (topbar).
"combo.layers": "Layer comb.", "combo.layers": "Layer comb.",
@@ -119,6 +131,10 @@ export const en: Record<TranslationKey, string> = {
"tool.circle.hint": "Circle: set center, then radius", "tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc", "tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)", "tool.arc.hint": "Arc: center, start point, then end point (CCW)",
"tool.text": "Text",
"tool.text.hint": "Text: set anchor point, then type the text",
"tool.textbox": "Text column",
"tool.textbox.hint": "Text column: anchor, drag column width, then type the text",
// Ribbon top bar (tabs + groups) // Ribbon top bar (tabs + groups)
"ribbon.tab.2d": "2D", "ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D", "ribbon.tab.3d": "3D",
@@ -233,6 +249,7 @@ export const en: Record<TranslationKey, string> = {
"attr.fill": "Fill", "attr.fill": "Fill",
"attr.fillColor": "Fill color", "attr.fillColor": "Fill color",
"attr.clearFill": "Remove fill color", "attr.clearFill": "Remove fill color",
"attr.pickColor": "Choose color",
"attr.foreground": "Foreground", "attr.foreground": "Foreground",
"attr.background": "Background", "attr.background": "Background",
"attr.bySystem": "By system", "attr.bySystem": "By system",
@@ -265,12 +282,29 @@ export const en: Record<TranslationKey, string> = {
"objinfo.kind.opening": "Opening", "objinfo.kind.opening": "Opening",
"objinfo.kind.stair": "Stair", "objinfo.kind.stair": "Stair",
"objinfo.kind.room": "Room", "objinfo.kind.room": "Room",
"objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Column",
// ── Column attributes (object info panel) ────────────────────────────────
"objinfo.column.section": "Column",
"objinfo.column.profile": "Profile",
"objinfo.column.profile.rect": "Rectangle",
"objinfo.column.profile.round": "Round",
"objinfo.column.width": "Width",
"objinfo.column.depth": "Depth",
"objinfo.column.radius": "Radius",
"objinfo.column.height": "Height",
"objinfo.column.rotation": "Rotation (°)",
"objinfo.column.floor": "Floor",
// ── Wall attributes (object info panel) ────────────────────────────────── // ── Wall attributes (object info panel) ──────────────────────────────────
"objinfo.wall.section": "Wall", "objinfo.wall.section": "Wall",
"objinfo.wall.refLine": "Reference line", "objinfo.wall.refLine": "Reference line",
"objinfo.wall.refLine.left": "Exterior (left)", "objinfo.wall.refLine.left": "Exterior (left)",
"objinfo.wall.refLine.center": "Center", "objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)", "objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.sliceTermination": "End at ceiling",
"objinfo.wall.sliceTermination.both": "Off",
"objinfo.wall.sliceTermination.below": "Below",
"objinfo.wall.sliceTermination.above": "Above",
"objinfo.wall.buildup": "Build-up", "objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Solid", "objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Multi layer", "objinfo.wall.multi": "Multi layer",
@@ -283,6 +317,11 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.top": "Top (OK)", "objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound", "objinfo.wall.anchorFloor": "Bound",
"objinfo.wall.anchorCustom": "Custom", "objinfo.wall.anchorCustom": "Custom",
"objinfo.anchor.refFloor": "Reference storey",
"objinfo.anchor.customHeight": "Custom height",
"objinfo.wall.length": "Length",
"objinfo.wall.volumeGross": "Gross",
"objinfo.volume": "Volume",
"objinfo.wall.height": "Height", "objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above", "objinfo.wall.floorAboveNone": "No floor above",
// ── Ceiling attributes (object info panel) ─────────────────────────────── // ── Ceiling attributes (object info panel) ───────────────────────────────
@@ -299,6 +338,9 @@ export const en: Record<TranslationKey, string> = {
"objinfo.ceiling.anchorCustom": "Custom", "objinfo.ceiling.anchorCustom": "Custom",
// ── Opening attributes (object info panel) ────────────────────────────── // ── Opening attributes (object info panel) ──────────────────────────────
"objinfo.opening.section": "Opening", "objinfo.opening.section": "Opening",
"objinfo.opening.type": "Type",
"objinfo.stair.type": "Type",
"objinfo.type.none": "(no type)",
"objinfo.opening.kind": "Type", "objinfo.opening.kind": "Type",
"objinfo.opening.kind.window": "Window", "objinfo.opening.kind.window": "Window",
"objinfo.opening.kind.door": "Door", "objinfo.opening.kind.door": "Door",
@@ -356,6 +398,15 @@ export const en: Record<TranslationKey, string> = {
"objinfo.room.perimeter": "Perimeter", "objinfo.room.perimeter": "Perimeter",
"objinfo.room.refFloor": "Reference floor", "objinfo.room.refFloor": "Reference floor",
"objinfo.room.editStamp": "Edit stamp …", "objinfo.room.editStamp": "Edit stamp …",
// ── Extruded solid attributes (truck integration, object info panel) ────
"objinfo.extrudedSolid.section": "Extrusion",
"objinfo.extrudedSolid.height": "Height",
"objinfo.extrudedSolid.taper": "Taper",
"objinfo.extrudedSolid.area": "Footprint area",
"objinfo.extrudedSolid.floor": "Floor",
"objinfo.rotate": "Rotate",
"objinfo.drawing.length": "Length",
"objinfo.drawing.radius": "Radius",
"levels.addFloor": "+ Floor", "levels.addFloor": "+ Floor",
"levels.addSection": "+ Section", "levels.addSection": "+ Section",
"levels.addElevation": "+ Elevation", "levels.addElevation": "+ Elevation",
@@ -484,6 +535,9 @@ export const en: Record<TranslationKey, string> = {
"default.ceilingTypeSingle": "Ceiling {thickness} m", "default.ceilingTypeSingle": "Ceiling {thickness} m",
"default.wallTypeName": "New wall type", "default.wallTypeName": "New wall type",
"default.ceilingTypeName": "New ceiling type", "default.ceilingTypeName": "New ceiling type",
"default.doorTypeName": "New door type",
"default.windowTypeName": "New window type",
"default.stairTypeName": "New stair type",
// Alerts. // Alerts.
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.", "alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
@@ -492,6 +546,9 @@ export const en: Record<TranslationKey, string> = {
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.", "alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
"alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.", "alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.",
"alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.", "alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.",
"alert.doorTypeInUse": "Door type “{name}” is used by a door and cannot be deleted.",
"alert.windowTypeInUse": "Window type “{name}” is used by a window and cannot be deleted.",
"alert.stairTypeInUse": "Stair type “{name}” is used by a stair and cannot be deleted.",
// Resource manager. // Resource manager.
"resources.title": "Resources", "resources.title": "Resources",
@@ -502,7 +559,38 @@ export const en: Record<TranslationKey, string> = {
"resources.tab.lines": "Lines", "resources.tab.lines": "Lines",
"resources.tab.wallStyles": "Wall styles", "resources.tab.wallStyles": "Wall styles",
"resources.tab.ceilingStyles": "Ceiling styles", "resources.tab.ceilingStyles": "Ceiling styles",
"resources.tab.doorStyles": "Door types",
"resources.tab.windowStyles": "Window types",
"resources.tab.stairStyles": "Stair types",
"resources.tab.materials": "Materials", "resources.tab.materials": "Materials",
"resources.tab.overrides": "Overrides",
// Graphic overrides (rule engine).
"resources.overrides.hint":
"Rules override color/lineweight/line style at render time (element data stays untouched). Order = priority: per field, the topmost active rule wins.",
"resources.overrides.empty": "No override rules yet.",
"resources.overrides.placeholder": "Rule",
"resources.overrides.defaultName": "New rule",
"resources.overrides.delete": "Delete rule",
"resources.overrides.moveUp": "Move rule up (higher priority)",
"resources.overrides.moveDown": "Move rule down (lower priority)",
"resources.overrides.enabled": "Rule active",
"resources.overrides.condition": "Condition",
"resources.overrides.condType": "Criterion",
"resources.overrides.condType.layer": "Layer (name/code)",
"resources.overrides.condType.object": "Object name",
"resources.overrides.operator": "Operator",
"resources.overrides.op.equals": "equals",
"resources.overrides.op.contains": "contains",
"resources.overrides.op.startsWith": "starts with",
"resources.overrides.op.notEquals": "not equal to",
"resources.overrides.value": "Value",
"resources.overrides.value.placeholder": "e.g. Walls or 20",
"resources.overrides.actions": "Actions",
"resources.overrides.action.color": "Color",
"resources.overrides.action.lineweight": "Lineweight (mm)",
"resources.overrides.action.linetype": "Line style",
"resources.overrides.linetype.none": "— (unchanged)",
"resources.wallStyles.hint": "resources.wallStyles.hint":
"Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.", "Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.",
@@ -512,6 +600,75 @@ export const en: Record<TranslationKey, string> = {
"resources.ceilingStyles.empty": "No ceiling types yet.", "resources.ceilingStyles.empty": "No ceiling types yet.",
"resources.wallStyles.placeholder": "Wall type", "resources.wallStyles.placeholder": "Wall type",
"resources.ceilingStyles.placeholder": "Ceiling type", "resources.ceilingStyles.placeholder": "Ceiling type",
// Component types (door/window/stair) — form-based libraries.
"resources.doorTypes.hint":
"Reusable door types with construction, leaf style and default dimensions for newly placed doors.",
"resources.doorTypes.empty": "No door types yet.",
"resources.doorTypes.placeholder": "Door type",
"resources.windowTypes.hint":
"Reusable window types with opening style, glazing and default dimensions for newly placed windows.",
"resources.windowTypes.empty": "No window types yet.",
"resources.windowTypes.placeholder": "Window type",
"resources.stairTypes.hint":
"Reusable stair types with structure, tread style and railing for newly placed stairs.",
"resources.stairTypes.empty": "No stair types yet.",
"resources.stairTypes.placeholder": "Stair type",
"resources.delete.doorType": "Delete door type “{name}”",
"resources.delete.windowType": "Delete window type “{name}”",
"resources.delete.stairType": "Delete stair type “{name}”",
"resources.field.doorKind": "Construction",
"resources.field.leafCount": "Leaf count",
"resources.field.leafStyle": "Leaf style",
"resources.field.glazingRatio": "Glazing ratio",
"resources.field.frameThickness": "Frame thickness (m)",
"resources.field.frameDepth": "Frame depth (m)",
"resources.field.defaultWidth": "Clear width (m)",
"resources.field.defaultHeight": "Clear height (m)",
"resources.field.threshold": "Threshold",
"resources.field.windowKind": "Opening style",
"resources.field.wingCount": "Wing count",
"resources.field.glazing": "Glazing",
"resources.field.defaultSillHeight": "Sill height (m)",
"resources.field.sillBoard": "Sill board",
"resources.field.structure": "Structure",
"resources.field.closedRisers": "Closed risers",
"resources.field.treadThickness": "Tread thickness (m)",
"resources.field.nosing": "Nosing (m)",
"resources.field.railing": "Railing",
"resources.field.stairWidth": "Run width (m)",
"resources.doorKind.dreh": "Hinged",
"resources.doorKind.schiebe": "Sliding",
"resources.doorKind.wandoeffnung": "Wall opening",
"resources.leafCount.1": "Single-leaf",
"resources.leafCount.2": "Double-leaf",
"resources.leafStyle.glatt": "Flush",
"resources.leafStyle.kassette": "Panelled",
"resources.leafStyle.glas": "Glazed",
"resources.windowKind.dreh": "Turn",
"resources.windowKind.kipp": "Tilt",
"resources.windowKind.drehkipp": "Turn-tilt",
"resources.windowKind.fest": "Fixed",
"resources.windowKind.schiebe": "Sliding",
"resources.glazing.einfach": "Single",
"resources.glazing.zweifach": "Double",
"resources.glazing.dreifach": "Triple",
"resources.sillBoard.keine": "None",
"resources.sillBoard.innen": "Inside",
"resources.sillBoard.aussen": "Outside",
"resources.sillBoard.beide": "Both",
"resources.structure.massiv": "Solid",
"resources.structure.beton": "Concrete (waist slab)",
"resources.structure.wange": "String",
"resources.structure.aufgesattelt": "Saddled",
"resources.structure.spindel": "Spiral",
"resources.railing.keine": "None",
"resources.railing.links": "Left",
"resources.railing.rechts": "Right",
"resources.railing.beide": "Both",
"resources.col.layer": "Layer", "resources.col.layer": "Layer",
"resources.col.component": "Component", "resources.col.component": "Component",
"resources.col.thickness": "Thickness (mm)", "resources.col.thickness": "Thickness (mm)",
@@ -688,6 +845,9 @@ export const en: Record<TranslationKey, string> = {
"cmd.opening.dir": "Direction", "cmd.opening.dir": "Direction",
"cmd.opening.sillField": "Sill", "cmd.opening.sillField": "Sill",
"cmd.opening.swingField": "Angle", "cmd.opening.swingField": "Angle",
"cmd.column.label": "Column",
"cmd.column.place": "Place column:",
"cmd.column.profile": "Profile",
"cmd.stair.label": "Stair", "cmd.stair.label": "Stair",
"cmd.stair.start": "Start point of the stair:", "cmd.stair.start": "Start point of the stair:",
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):", "cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
@@ -722,10 +882,22 @@ export const en: Record<TranslationKey, string> = {
"cmd.arc.center": "Center:", "cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):", "cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):", "cmd.arc.end": "End point (end angle):",
"cmd.text.label": "Text",
"cmd.text.point": "Anchor point:",
"cmd.text.enter": "Enter text:",
"cmd.textbox.label": "Text column",
"cmd.textbox.point": "Anchor point:",
"cmd.textbox.width": "Column width (second point):",
"cmd.textbox.enter": "Enter text:",
"cmd.move.label": "Move", "cmd.move.label": "Move",
"cmd.move.base": "Base point:", "cmd.move.base": "Base point:",
"cmd.move.target": "Target point:", "cmd.move.target": "Target point:",
"cmd.move.empty": "Nothing selected (select objects first).", "cmd.move.empty": "Nothing selected (select objects first).",
"cmd.rotate.label": "Rotate",
"cmd.measure.label": "Measure",
"cmd.measure.start": "Measure from:",
"cmd.measure.to": "to (distance · angle):",
"transform.needSelection": "Select objects first, then rotate.",
"cmd.mirror.label": "Mirror", "cmd.mirror.label": "Mirror",
"cmd.mirror.first": "First point of mirror axis:", "cmd.mirror.first": "First point of mirror axis:",
"cmd.mirror.second": "Second point of mirror axis:", "cmd.mirror.second": "Second point of mirror axis:",
@@ -738,6 +910,10 @@ export const en: Record<TranslationKey, string> = {
"cmd.offset.label": "Offset", "cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:", "cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:", "cmd.offset.side": "Side or distance:",
"cmd.extrude.label": "Extrude",
"cmd.extrude.pick": "Select closed profile:",
"cmd.extrude.height": "Height:",
"cmd.extrude.taper": "Taper (01, 1=point):",
// Trim (quick-trim): click the part to cut away; repeats until Esc. // Trim (quick-trim): click the part to cut away; repeats until Esc.
"cmd.trim.label": "Trim", "cmd.trim.label": "Trim",
"cmd.trim.pick": "Click the part to trim away (Esc to finish):", "cmd.trim.pick": "Click the part to trim away (Esc to finish):",
@@ -852,11 +1028,42 @@ export const en: Record<TranslationKey, string> = {
"file.exportPdf": "Export as PDF…", "file.exportPdf": "Export as PDF…",
"file.exportDxf": "Export as DXF…", "file.exportDxf": "Export as DXF…",
"file.exportSchedule": "Component schedule (CSV)…", "file.exportSchedule": "Component schedule (CSV)…",
"file.exportIfc": "Export IFC",
"file.exportObj": "Export OBJ (3D mesh)",
"file.exportStl": "Export STL (3D mesh)",
"file.export": "Export",
"file.openFailed": "Could not read the project file.",
// Lock conflict (project file already open in another instance).
"lock.title": "Project already open",
"lock.message": "This project file is already open in another instance.",
"lock.messageInfo": "Opened on {host} (process {pid}).",
"lock.openReadOnly": "Open read-only",
"lock.forceOpen": "Force open anyway",
"lock.forceOpenWarning":
"Warning: opens for writing even though another instance holds the file — changes may overwrite each other.",
"exportSave.title": "Export",
"exportSave.format": "Format",
"exportSave.filename": "File name",
"exportSave.locationNote": "Saved to the default downloads folder.",
"exportSave.fmt.csv": "Component schedule (CSV)",
"exportSave.fmt.ifc": "IFC (BIM)",
"exportSave.fmt.obj": "OBJ (3D mesh)",
"exportSave.fmt.stl": "STL (3D mesh)",
"about.title": "About dossier",
"about.version": "Version",
"about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.",
"about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "License: AGPL-3.0-or-later",
"about.suite": "Part of the openbureau suite",
"about.licenses": "Open-source licenses (third-party)",
"about.close": "Close",
// Settings dialog. // Settings dialog.
"topbar.settings": "Settings", "topbar.settings": "Settings",
"topbar.settings.hint": "Open settings (display colors, project)", "topbar.settings.hint": "Open settings (display colors, project)",
"settings.title": "Settings", "settings.title": "Settings",
"settings.section.workspace": "Workspace",
"settings.section.display": "Display", "settings.section.display": "Display",
"settings.marqueeColor": "Selection box color", "settings.marqueeColor": "Selection box color",
"settings.marqueeColor.hint": "settings.marqueeColor.hint":
@@ -896,6 +1103,9 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.src.osmRoads": "OSM roads", "ctxImport.src.osmRoads": "OSM roads",
"ctxImport.src.osmWater": "OSM water", "ctxImport.src.osmWater": "OSM water",
"ctxImport.src.osmGreen": "OSM greenery", "ctxImport.src.osmGreen": "OSM greenery",
"ctxImport.src.osmParking": "OSM parking",
"ctxImport.src.osmRailway": "OSM railway",
"ctxImport.src.osmForest": "OSM forest",
"ctxImport.cancel": "Cancel", "ctxImport.cancel": "Cancel",
"ctxImport.confirm": "Import", "ctxImport.confirm": "Import",
"ctxImport.importing": "Importing…", "ctxImport.importing": "Importing…",
@@ -940,6 +1150,9 @@ export const en: Record<TranslationKey, string> = {
"roomStamp.showFloorArea": "Show floor area", "roomStamp.showFloorArea": "Show floor area",
"roomStamp.floorAreaPrefix": "Floor area prefix", "roomStamp.floorAreaPrefix": "Floor area prefix",
"roomStamp.showUsage": "Show usage", "roomStamp.showUsage": "Show usage",
"roomStamp.roundingStep": "Area rounding",
"roomStamp.roundingStepDefault": "Default (2 decimal places)",
"roomStamp.occupancy": "Occupancy",
"text.selectedHint": "Formatting applies to the selected text", "text.selectedHint": "Formatting applies to the selected text",
"rt.bold": "Bold", "rt.bold": "Bold",
@@ -947,6 +1160,8 @@ export const en: Record<TranslationKey, string> = {
"rt.underline": "Underline", "rt.underline": "Underline",
"rt.strike": "Strikethrough", "rt.strike": "Strikethrough",
"rt.strikethrough": "Strikethrough", "rt.strikethrough": "Strikethrough",
"rt.super": "Superscript",
"rt.sub": "Subscript",
"rt.fontSize": "Font size", "rt.fontSize": "Font size",
"rt.color": "Color", "rt.color": "Color",
"rt.preset": "Preset", "rt.preset": "Preset",
@@ -958,4 +1173,146 @@ export const en: Record<TranslationKey, string> = {
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.", "WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.",
"viewport3d.grid.show": "Show ground grid", "viewport3d.grid.show": "Show ground grid",
"viewport3d.grid.hide": "Hide ground grid", "viewport3d.grid.hide": "Hide ground grid",
"viewport3d.section.label": "Section plane",
"viewport3d.section.show": "Section plane on",
"viewport3d.section.hide": "Section plane off",
"viewport3d.grip.vertex": "Drag wall endpoint",
"viewport3d.grip.height": "Drag height",
"viewport3d.grip.move": "Move",
// ── Element tree (Elements panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elements",
"elements.search": "Search elements…",
"elements.empty": "No components in the project.",
"elements.noMatch": "No matches.",
"elements.kind.wall": "Wall",
"elements.kind.ceiling": "Ceiling",
"elements.kind.window": "Window",
"elements.kind.opening": "Opening",
"elements.kind.stair": "Stair",
"elements.kind.extrusion": "Extrusion",
// Views / view snapshots (DOSSIER A2).
"viewsnap.title": "Views",
"viewsnap.saveCurrent": " Save current view …",
"viewsnap.savePrompt": "Save view as:",
"viewsnap.renamePrompt": "Rename view:",
"viewsnap.rename": "Rename",
"viewsnap.delete": "Delete",
"viewsnap.deleteConfirm": "Delete view “{name}”?",
"viewsnap.empty": "No views yet. Set up a view and save it.",
"viewsnap.applyHint": "Restore view “{name}”",
"viewsnap.add.folder": "Create folder",
"viewsnap.add.snapshot": "Save view",
"viewsnap.folder.defaultName": "New folder",
"viewsnap.folder.rename": "Rename folder",
"viewsnap.folder.delete": "Delete folder",
"viewsnap.folder.empty": "Empty folder",
"viewsnap.tree.empty": "No folders or views yet. Add via “+”.",
"viewsnap.footer.scale": "Scale",
"viewsnap.footer.layerCombo": "Layer combo",
"viewsnap.footer.drawingCombo": "Drawing combo",
"viewsnap.footer.overrides": "Overrides",
"viewsnap.footer.none": "—",
// Layout sheets with master layout (DOSSIER A3).
"layouts.panelTitle": "Portfolio",
"layouts.title": "Layouts",
"layouts.new": "Create",
"layouts.namePlaceholder": "Sheet name …",
"layouts.defaultName": "New layout",
"layouts.rename": "Rename",
"layouts.delete": "Delete",
"layouts.renamePrompt": "Rename:",
"layouts.deleteConfirm": "Delete “{name}”?",
"layouts.openHint": "Open layout “{name}”",
"layouts.empty": "No layouts yet. Create a sheet and open it.",
"layouts.paper": "Paper size",
"layouts.orientation": "Orientation",
"layouts.portrait": "Portrait",
"layouts.landscape": "Landscape",
"layouts.border": "Border",
"layouts.masters": "Master layouts",
"layouts.newMaster": " Master",
"layouts.newMasterPrompt": "New master layout:",
"layouts.defaultMasterName": "Master",
"layouts.noMasters": "No master layouts yet.",
"layouts.master": "Master",
"layouts.noMaster": "No master",
"layouts.tb.projectName": "Project",
"layouts.tb.sheetName": "Sheet name",
"layouts.tb.scale": "Scale",
"layouts.tb.date": "Date",
"layouts.tb.author": "Author",
"layouts.tb.autoPlaceholder": "(automatic)",
// Editor
"layouts.editorTitle": "Layout editor",
"layouts.fit": "Fit",
"layouts.addViewport": "Add viewport",
"layouts.pickSnapshot": "Pick a view …",
"layouts.noSnapshots": "No views available. Save a view first.",
"layouts.viewportProps": "Viewport properties",
"layouts.noViewports": "No viewports yet. Pick a view and add it.",
"layouts.selectViewport": "Click a viewport on the sheet.",
"layouts.missingSnapshot": "View missing",
"layouts.xMm": "X (mm)",
"layouts.yMm": "Y (mm)",
"layouts.widthMm": "Width (mm)",
"layouts.heightMm": "Height (mm)",
"layouts.scale": "Scale",
"layouts.scaleReset": "Scale from view",
"layouts.removeViewport": "Remove viewport",
"layouts.backToModel": "Back to model",
"layouts.placeViewport": "Draw viewport",
"layouts.placeHint": "Drag a rectangle on the sheet, then bind a snapshot.",
"layouts.bindSnapshot": "Bind snapshot",
"layouts.emptyViewports":
"No viewports yet. Choose “Draw viewport” and drag a rectangle on the sheet.",
// Annotations (line/rect/text) drawn directly on the sheet
"layouts.annotLine": "Draw line",
"layouts.annotRect": "Draw rectangle",
"layouts.annotText": "Place text",
"layouts.annotTextTitle": "Text",
"layouts.annotTextLabel": "Text content:",
"layouts.annotEditText": "Edit text",
"layouts.annotColor": "Color",
"layouts.annotWeight": "Line weight (mm)",
"layouts.annotRemove": "Remove annotation",
"layouts.annot.line": "Line",
"layouts.annot.rect": "Rectangle",
"layouts.annot.text": "Text",
// Tree / “+” menu / folders
"layouts.add": "New …",
"layouts.addFolder": "Create folder",
"layouts.addLayout": "Create layout",
"layouts.addMaster": "Create master layout",
"layouts.add.folder": "Create folder",
"layouts.add.layout": "Create layout",
"layouts.add.master": "Create master layout",
"layouts.folder.defaultName": "New folder",
"layouts.folder.rename": "Rename folder",
"layouts.folder.delete": "Delete folder",
"layouts.folder.export": "Export folder as PDF",
"layouts.folder.empty": "Empty folder",
"layouts.folder.exportEmpty": "Folder contains no layouts.",
"layouts.tree.empty": "No folders or layouts yet. Add one via “+”.",
// “New layout” dialog
"layouts.newLayout.title": "New layout",
"layouts.newLayout.source": "Template",
"layouts.newLayout.fromMaster": "Master layout as template",
"layouts.newLayout.freeSize": "Free size",
"layouts.newLayout.pickMaster": "Pick a master layout …",
"layouts.newLayout.noMasters": "No master layouts available.",
// “New master layout” dialog
"layouts.newMaster.title": "New master layout",
// Shared size picker (both dialogs)
"layouts.size.format": "Format",
"layouts.size.custom": "Free size",
"layouts.size.a4": "A4",
"layouts.size.a3": "A3",
"layouts.paperGroup.a": "A series",
"layouts.paperGroup.b": "B series",
"layouts.size.widthMm": "Width (mm)",
"layouts.size.heightMm": "Height (mm)",
"layouts.size.create": "Create",
}; };
+15 -2
View File
@@ -2,7 +2,7 @@
// //
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in // Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts), // LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in // kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün/Parkplatz/Bahn/Wald). Die UI wandelt diese in
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`. // `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
// //
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
@@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95";
import type { ContextObject, Contour, ImportedMesh } from "../model/types"; import type { ContextObject, Contour, ImportedMesh } from "../model/types";
/** Fachliche Kategorie einer importierten Kontext-Linie. */ /** Fachliche Kategorie einer importierten Kontext-Linie. */
export type GeoCategory = "building" | "road" | "water" | "green"; export type GeoCategory =
| "building"
| "road"
| "water"
| "green"
| "parking"
| "railway"
| "forest";
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */ /** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
export interface GeoFeature { export interface GeoFeature {
@@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record<GeoCategory, string> = {
road: "Strassen", road: "Strassen",
water: "Gewässer", water: "Gewässer",
green: "Grünflächen", green: "Grünflächen",
parking: "Parkplätze",
railway: "Bahn",
forest: "Wald",
}; };
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */ /** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
@@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record<GeoCategory, string> = {
road: "context-roads", road: "context-roads",
water: "context-water", water: "context-water",
green: "context-green", green: "context-green",
parking: "context-parking",
railway: "context-railway",
forest: "context-forest",
}; };
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */ /** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
+28 -8
View File
@@ -1,7 +1,8 @@
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import. // OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
// //
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt // Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe // Bahnlinien / Wald und wandelt sie in Polylinien in LOKALEN Modell-Metern (über
// LV95 + Origin, siehe
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher // lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern // läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen // `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
@@ -24,6 +25,9 @@ export interface OsmSelection {
roads: boolean; roads: boolean;
water: boolean; water: boolean;
green: boolean; green: boolean;
parking: boolean;
railway: boolean;
forest: boolean;
} }
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */ /** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
@@ -55,7 +59,12 @@ function buildQuery(
} }
if (sel.green) { if (sel.green) {
parts.push(`way["leisure"="park"](${b});`); parts.push(`way["leisure"="park"](${b});`);
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`); parts.push(`way["landuse"~"grass|meadow|recreation_ground"](${b});`);
}
if (sel.parking) parts.push(`way["amenity"="parking"](${b});`);
if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`);
if (sel.forest) {
parts.push(`way["landuse"="forest"](${b});`);
parts.push(`way["natural"="wood"](${b});`); parts.push(`way["natural"="wood"](${b});`);
} }
return `[out:json][timeout:40];(${parts.join("")});out geom;`; return `[out:json][timeout:40];(${parts.join("")});out geom;`;
@@ -69,20 +78,23 @@ function categorize(
if (!tags) return null; if (!tags) return null;
if (sel.buildings && tags.building) return "building"; if (sel.buildings && tags.building) return "building";
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water"; if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest"))
return "forest";
if ( if (
sel.green && sel.green &&
(tags.leisure === "park" || (tags.leisure === "park" ||
tags.natural === "wood" || /grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
) )
return "green"; return "green";
if (sel.parking && tags.amenity === "parking") return "parking";
if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway";
if (sel.roads && tags.highway) return "road"; if (sel.roads && tags.highway) return "road";
return null; return null;
} }
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */ /** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
function isClosedCategory(cat: GeoCategory): boolean { function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road"; return cat !== "road" && cat !== "railway";
} }
/** /**
@@ -118,7 +130,15 @@ export async function fetchOsm(
originIn?: GeoOrigin, originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> { ): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center); const origin = originIn ?? makeOrigin(center);
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) { if (
!sel.buildings &&
!sel.roads &&
!sel.water &&
!sel.green &&
!sel.parking &&
!sel.railway &&
!sel.forest
) {
return { origin, features: [] }; return { origin, features: [] };
} }
+161
View File
@@ -0,0 +1,161 @@
// Eigenes Projektdatei-Format (Endung `.obp`, "openbureau project") + Lock-Datei
// gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei Instanzen.
//
// Der Dateiinhalt ist unverändert das bisherige Projekt-JSON — nur Endung/
// Dialog-Filter sind neu. Alte `.json`-Projekte bleiben ladbar (Filter erlaubt
// beide, s. PROJECT_DIALOG_FILTERS).
//
// Der Lock ist ein OS-Advisory-Lock im Rust-Kern (src-tauri/src/lock.rs, Crate
// `fs4`): eine Sidecar-Datei `foo.obp.lock` wird exklusiv gelockt, solange die
// Instanz die Datei offen hat. Der OS-Lock fällt beim Prozessende (auch bei
// Absturz) automatisch weg — hier nur "best effort" für sauberes Timing beim
// normalen Schliessen. Nur unter Tauri relevant: im reinen Browser gibt es
// keinen echten Dateipfad (Blob-Download), daher entfällt der Lock dort.
import { saveTextFile } from "./saveFile";
/** Datei-Endung für Projektdateien (ohne Punkt). Einzige Änderungsstelle. */
export const PROJECT_EXT = "obp";
/** Dialog-Filter für den nativen Speichern-/Öffnen-Dialog: neue Endung + Alt-JSON. */
export const PROJECT_DIALOG_FILTERS = [
{ name: "openbureau-Projekt", extensions: [PROJECT_EXT] },
{ name: "openbureau-Projekt (alt, JSON)", extensions: ["json"] },
];
/** Erkennt Tauri wie an anderen Stellen (saveFile.ts, nativeSync.ts). */
export function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** Ergebnis eines erfolgreichen nativen Öffnen-Vorgangs. */
export interface OpenedProjectFile {
path: string;
content: string;
}
/**
* Öffnet eine Projektdatei über den NATIVEN Öffnen-Dialog (Filter: `.obp` +
* `.json`). Nur unter Tauri sinnvoll liefert `null` im Browser, dort greift
* weiterhin der bestehende `<input type=file>`-Fluss (FileReader).
*
* @returns `null` bei Abbruch/ohne Tauri, sonst Pfad + Textinhalt.
*/
export async function openProjectFileNative(): Promise<OpenedProjectFile | null> {
if (!isTauriRuntime()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: false,
directory: false,
filters: PROJECT_DIALOG_FILTERS,
});
if (!selected || Array.isArray(selected)) return null; // Abbruch (oder Mehrfachauswahl, sollte hier nicht vorkommen)
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const content = await readTextFile(selected);
return { path: selected, content };
}
/**
* Speichert das Projekt-JSON in eine `.obp`-Datei. Unter Tauri nativer
* Speichern-Dialog mit Projekt-Filter, im Browser Blob-Download beides via
* {@link saveTextFile} (dort steckt die WKWebView-Härtung).
*
* @param json Fertig serialisiertes Projekt (JSON.stringify des ProjectSlice).
* @param projectName `project.name` für den Standard-Dateinamen (Fallback "projekt").
*/
export async function saveProjectFile(json: string, projectName: string): Promise<boolean> {
const filename = `${projectName || "projekt"}.${PROJECT_EXT}`;
return saveTextFile(json, filename, "application/json", PROJECT_DIALOG_FILTERS);
}
// ── Lock gegen Doppelöffnung (nur Tauri) ────────────────────────────────────
/** Info-Block aus der Lock-Sidecar-Datei — für die Konflikt-Meldung an den Nutzer. */
export interface LockInfo {
pid: number;
hostname: string;
timestamp: string;
}
export type LockResult = { ok: true } | { ok: false; info: LockInfo | null };
/**
* Holt (ungeguardet) die Tauri-`invoke`-Funktion, oder `null` ohne Tauri-Core.
* Anders als das Muster in compute/index.ts bzw. plan/nativeSync.ts schluckt
* dieser Helfer KEINE Fehler aus dem eigentlichen Aufruf ein `Err(...)` vom
* Rust-Command (hier: schon gelockt", inkl. {@link LockInfo}-Payload) muss bis
* zum Aufrufer durchgereicht werden.
*/
async function tauriInvokeFn(): Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null
> {
try {
// @ts-ignore optionales Paket
const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null);
if (!mod || typeof (mod as any).invoke !== "function") return null;
return (mod as any).invoke;
} catch {
return null;
}
}
/**
* Fordert den exklusiven Lock für `path` an (Rust-Command `acquire_project_lock`,
* legt `path + ".lock"` an und hält einen fs4-OS-Lock darauf). Im Browser (kein
* Tauri) sofort `{ok:true}` dort gibt es keinen echten Pfad, also keinen Lock.
*/
export async function acquireProjectLock(path: string): Promise<LockResult> {
if (!isTauriRuntime()) return { ok: true };
const invoke = await tauriInvokeFn();
if (!invoke) return { ok: true }; // Tauri-Core nicht verfügbar -> nicht blockieren
try {
await invoke("acquire_project_lock", { path });
return { ok: true };
} catch (err) {
return { ok: false, info: isLockInfo(err) ? err : null };
}
}
/** Gibt den Lock für `path` frei (Rust-Command `release_project_lock`). No-op ohne Tauri. */
export async function releaseProjectLock(path: string): Promise<void> {
if (!isTauriRuntime()) return;
const invoke = await tauriInvokeFn();
if (!invoke) return;
try {
await invoke("release_project_lock", { path });
} catch {
// Freigabe ist best effort — der OS-Lock fällt beim Prozessende ohnehin weg.
}
}
function isLockInfo(v: unknown): v is LockInfo {
return (
typeof v === "object" &&
v !== null &&
typeof (v as LockInfo).pid === "number" &&
typeof (v as LockInfo).hostname === "string"
);
}
/**
* Best-effort Freigabe beim Schliessen des Fensters (sauberes Timing): der
* OS-Lock fällt beim Prozessende/Absturz ohnehin automatisch weg (fs4-Handle
* schliesst mit dem Prozess) dieser Hook sorgt nur dafür, dass ein normales
* Schliessen die Lock-Datei nicht unnötig bis zum nächsten GC/Poll blockiert.
* No-op ohne Tauri.
*/
export function registerLockAutoRelease(getPath: () => string | null): void {
if (!isTauriRuntime()) return;
void (async () => {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
await win.onCloseRequested(() => {
const path = getPath();
if (path) void releaseProjectLock(path);
});
} catch {
// Tauri-window-API nicht verfügbar -> ignorieren, OS-Lock greift ohnehin.
}
})();
}
+51
View File
@@ -0,0 +1,51 @@
// Tests fuer saveTextFile. Vitest laeuft hier im "node"-Env (kein jsdom), daher
// stubben wir eine minimale DOM-/URL-Umgebung, um den Browser-Fallback-Pfad zu
// pruefen — ohne echten Tauri (kein `__TAURI_INTERNALS__`).
import { afterEach, describe, expect, it, vi } from "vitest";
import { saveTextFile } from "./saveFile";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Stubt Blob, URL und ein rudimentaeres document fuer den Anchor-Download. */
function stubBrowserEnv() {
const clicked: string[] = [];
const anchor: Record<string, unknown> = {
click: () => clicked.push("click"),
};
vi.stubGlobal("URL", {
createObjectURL: () => "blob:stub",
revokeObjectURL: () => {},
});
vi.stubGlobal("document", {
createElement: () => anchor,
body: { appendChild: () => {}, removeChild: () => {} },
});
return { clicked, anchor };
}
describe("saveTextFile", () => {
it("nimmt im Nicht-Tauri-Umfeld den Browser-Download und liefert true", async () => {
// Kein __TAURI_INTERNALS__ → Browser-Pfad.
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) vi.stubGlobal("window", {});
const { clicked, anchor } = stubBrowserEnv();
const ok = await saveTextFile("hallo", "modell.txt", "text/plain");
expect(ok).toBe(true);
expect(clicked).toContain("click");
expect(anchor.download).toBe("modell.txt");
});
it("nutzt den Standard-MIME-Typ ohne Angabe, ohne zu crashen", async () => {
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) vi.stubGlobal("window", {});
stubBrowserEnv();
await expect(saveTextFile("x", "a.bin")).resolves.toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
// Datei-Speicherung mit nativem „Speichern unter"-Dialog im Tauri-Fenster,
// im reinen Browser Fallback auf den klassischen Blob-/Anchor-Download.
//
// Wichtig: Die Tauri-Plugins werden NUR dynamisch importiert. So scheitert der
// reine Browser-Build/vitest nicht an fehlenden Tauri-Modulen und der Code laeuft
// auch ohne Tauri-Laufzeit.
/** Erkennt, ob wir im Tauri-Webview laufen (dann steht `__TAURI_INTERNALS__`). */
function isTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
let warnedFallback = false;
/** Datei-Endungsfilter fuer den nativen Speichern-/Oeffnen-Dialog (Tauri). */
export interface FileDialogFilter {
name: string;
extensions: string[];
}
/**
* Speichert Text-Inhalt in eine Datei.
* - Unter Tauri: nativer Save-Dialog (Ordner + Name) `writeTextFile`.
* - Im Browser: Blob + Anchor-Download in den Download-Ordner.
*
* @param filters Optionale Datei-Endungsfilter fuer den nativen Dialog (z. B.
* Projekt-Endung). Additiv/optional, damit bestehende Aufrufer ohne
* Filter unveraendert funktionieren (Default-Verhalten: irgendeine Endung).
* @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch (Dialog
* ohne Pfad). Bei einem Tauri-Fehler wird auf den Browser-Fallback
* zurueckgefallen.
*/
export async function saveTextFile(
content: string,
defaultName: string,
mime = "application/octet-stream",
filters?: FileDialogFilter[],
): Promise<boolean> {
if (isTauri()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: defaultName, filters });
if (!path) return false; // Nutzer hat abgebrochen
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, content);
return true;
} catch (err) {
if (!warnedFallback) {
warnedFallback = true;
console.warn(
"Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.",
err,
);
}
// Weiter zum Browser-Fallback unten.
}
}
return browserDownload(content, defaultName, mime);
}
/**
* Speichert Binaer-Inhalt (z. B. ein PDF) in eine Datei gleiches Tauri-/
* Browser-Muster wie {@link saveTextFile}, nur mit `writeFile` (Bytes) statt
* `writeTextFile`.
* - Unter Tauri: nativer Save-Dialog `writeFile(path, bytes)`.
* - Im Browser: Blob + Anchor-Download.
*
* @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch.
*/
export async function saveBinaryFile(
bytes: Uint8Array,
defaultName: string,
mime = "application/octet-stream",
): Promise<boolean> {
if (isTauri()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: defaultName });
if (!path) return false; // Nutzer hat abgebrochen
const { writeFile } = await import("@tauri-apps/plugin-fs");
await writeFile(path, bytes);
return true;
} catch (err) {
if (!warnedFallback) {
warnedFallback = true;
console.warn(
"Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.",
err,
);
}
// Weiter zum Browser-Fallback unten.
}
}
return browserDownloadBlob(new Blob([bytes as BlobPart], { type: mime }), defaultName);
}
/** Klassischer Blob-/Anchor-Download (gehaertet wie der bisherige Helfer). */
function browserDownload(content: string, defaultName: string, mime: string): boolean {
return browserDownloadBlob(new Blob([content], { type: mime }), defaultName);
}
/** Anchor-Download eines fertigen Blobs (Text wie Binaer). */
function browserDownloadBlob(blob: Blob, defaultName: string): boolean {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultName;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 4000);
return true;
}
+49
View File
@@ -4,6 +4,7 @@ import {
roomStampToDoc, roomStampToDoc,
roomStampExtraLines, roomStampExtraLines,
roomDisplayName, roomDisplayName,
formatStampArea,
} from "./roomStamp"; } from "./roomStamp";
import type { Room } from "./types"; import type { Room } from "./types";
@@ -82,6 +83,54 @@ describe("roomStampExtraLines", () => {
); );
expect(lines).toHaveLength(0); expect(lines).toHaveLength(0);
}); });
it("rundet die Fläche auf roundingStep, falls gesetzt", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: true, showUsage: false, roundingStep: 0.5 },
{ netArea: 12.34, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines[0].text).toBe("12.5 m²");
});
it("nutzt 2 Nachkommastellen ohne roundingStep (Alt-Verhalten)", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: true, showUsage: false },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines[0].text).toBe("12.35 m²");
});
it("zeigt die Personenzahl als eigene Zeile, wenn gesetzt", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: false, showUsage: false, occupancy: 4 },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines).toHaveLength(1);
expect(lines[0].text).toBe("4 Pers.");
expect(lines[0].align).toBe("center");
});
it("blendet die Personenzahl-Zeile aus, wenn occupancy fehlt", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: false, showUsage: false },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines).toHaveLength(0);
});
});
describe("formatStampArea", () => {
it("2 Nachkommastellen ohne roundingStep", () => {
expect(formatStampArea(12.345)).toBe("12.35");
expect(formatStampArea(12)).toBe("12.00");
});
it("rundet auf das nächste Vielfache von roundingStep", () => {
expect(formatStampArea(12.24, 0.5)).toBe("12");
expect(formatStampArea(12.26, 0.5)).toBe("12.5");
expect(formatStampArea(12.3, 0.1)).toBe("12.3");
expect(formatStampArea(12.34, 1)).toBe("12");
});
}); });
describe("roomDisplayName", () => { describe("roomDisplayName", () => {
+24 -3
View File
@@ -67,17 +67,32 @@ export interface RoomStampLine {
align: Align; align: Align;
} }
/**
* Formatiert die Bodenfläche für die Stempel-Anzeige: ohne `roundingStep` wie
* bisher auf 2 Nachkommastellen; mit `roundingStep` auf das nächste Vielfache
* davon gerundet (z. B. 0.5 halbe m²), ohne Nachkommastellen-Rauschen.
*/
export function formatStampArea(area: number, roundingStep?: number): string {
if (roundingStep && roundingStep > 0) {
const rounded = Math.round(area / roundingStep) * roundingStep;
// Rundungsfehler aus der Fliesskomma-Division kappen (max. 2 Nachkommastellen).
return `${Math.round(rounded * 100) / 100}`;
}
return area.toFixed(2);
}
/** /**
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea, * Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. Die Ausrichtung folgt den * Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage, Personenzahl wenn gesetzt.
* Stempel-Feldern; fehlt sie, gilt zentriert" (Alt-Verhalten). * Die Ausrichtung folgt den Stempel-Feldern; fehlt sie, gilt zentriert"
* (Alt-Verhalten).
*/ */
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] { export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] {
const lines: RoomStampLine[] = []; const lines: RoomStampLine[] = [];
if (stamp.showFloorArea) { if (stamp.showFloorArea) {
const prefix = stamp.floorAreaPrefix ?? ""; const prefix = stamp.floorAreaPrefix ?? "";
lines.push({ lines.push({
text: `${prefix}${ctx.netArea.toFixed(2)}`, text: `${prefix}${formatStampArea(ctx.netArea, stamp.roundingStep)}`,
align: stamp.floorAreaAlign ?? "center", align: stamp.floorAreaAlign ?? "center",
}); });
} }
@@ -87,6 +102,12 @@ export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): Ro
align: stamp.usageAlign ?? "center", align: stamp.usageAlign ?? "center",
}); });
} }
if (stamp.occupancy != null) {
lines.push({
text: `${stamp.occupancy} Pers.`,
align: "center",
});
}
return lines; return lines;
} }
+121 -4
View File
@@ -1,5 +1,15 @@
import type { Project } from "./types"; import type { Project } from "./types";
import { recomputeFloorElevations } from "./types"; import { recomputeFloorElevations } from "./types";
import { MATERIAL_LIBRARY } from "../materials/library";
import { materialFromAsset } from "../materials/runtime";
// Bibliotheks-Materialien für die Basis-Components ab Werk (PBR-Texturen,
// s. `src/materials/library.ts`). Zuordnung nur, wo ein Bibliotheks-Asset
// sinnvoll passt — Wärmedämmung und Zementestrich bleiben bewusst ohne
// Zuweisung (kein passendes Asset in der Bibliothek), statt zu raten.
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")!;
// Demo-Haus mit zwei Geschossen: // Demo-Haus mit zwei Geschossen:
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand. // EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
@@ -111,14 +121,44 @@ export const sampleProject: Project = {
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe; // Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße). // joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
components: [ components: [
{ id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }, {
id: "render-ext",
name: "Aussenputz",
color: "#d8d2c7",
hatchId: "none",
joinPriority: 10,
material: materialFromAsset(PLASTER_ASSET),
},
// Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand). // Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand).
// Kein passendes Bibliotheks-Asset (Dämmung) → bewusst ohne `material`.
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 }, { id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 },
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "sia-brick", joinPriority: 50 }, {
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 }, id: "brick",
name: "Backstein",
color: "#8c5544",
hatchId: "sia-brick",
joinPriority: 50,
material: materialFromAsset(BRICK_ASSET),
},
{
id: "render-int",
name: "Innenputz",
color: "#efece6",
hatchId: "none",
joinPriority: 10,
material: materialFromAsset(PLASTER_ASSET),
},
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität). // Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
{ id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "sia-concrete", joinPriority: 100 }, {
id: "concrete",
name: "Beton",
color: "#9aa0a6",
hatchId: "sia-concrete",
joinPriority: 100,
material: materialFromAsset(CONCRETE_ASSET),
},
// Zementestrich — oberste Schicht des mehrschichtigen Deckenaufbaus (Decken-Stile). // Zementestrich — oberste Schicht des mehrschichtigen Deckenaufbaus (Decken-Stile).
// Kein passendes Bibliotheks-Asset (Estrich) → bewusst ohne `material`.
{ id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 }, { id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 },
], ],
wallTypes: [ wallTypes: [
@@ -162,6 +202,80 @@ export const sampleProject: Project = {
], ],
}, },
], ],
// Bauteil-Typen (Bibliothek) — Tür/Fenster/Treppe, analog Wand-/Deckenstile.
// Elemente referenzieren sie per `typeId`; ohne Referenz gilt Inline-Verhalten.
doorTypes: [
{
id: "door-standard",
name: "Zimmertür 0.9 m",
kind: "dreh",
leafCount: 1,
leafStyle: "glatt",
frameThickness: 0.05,
defaultWidth: 0.9,
defaultHeight: 2.0,
},
],
windowTypes: [
{
id: "window-standard",
name: "Fenster 1.2 × 1.4 m",
kind: "drehkipp",
wingCount: 1,
glazing: "zweifach",
frameThickness: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.2,
defaultHeight: 1.4,
},
],
stairTypes: [
{
// Standard: normale Ortbeton-Laufplatte (schräge, offene Untersicht).
id: "stair-standard",
name: "Betontreppe 1.0 m",
structure: "beton",
closedRisers: true,
treadThickness: 0.05,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
},
{
id: "stair-massiv",
name: "Massivtreppe (auf Sockel)",
structure: "massiv",
closedRisers: true,
treadThickness: 0.04,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
},
{
// Offene Wangentreppe: schwebende Tritte (unten offen), zum direkten
// Vergleich mit der Massivtreppe — im ObjectInfoPanel der Treppe zuweisen.
id: "stair-wange-holz",
name: "Wangentreppe Holz (offen)",
structure: "wange",
closedRisers: false,
treadThickness: 0.05,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
},
{
// Aufgesattelte Treppe mit geschlossenen Setzstufen (Tritt + Setzstufe,
// aber unten offen — der Raum darunter bleibt frei).
id: "stair-aufgesattelt",
name: "Aufgesattelt (Setzstufen)",
structure: "aufgesattelt",
closedRisers: true,
treadThickness: 0.05,
nosing: 0.025,
railing: "keine",
defaultWidth: 1.0,
},
],
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung. // Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6. // baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
drawingLevels: recomputeFloorElevations([ drawingLevels: recomputeFloorElevations([
@@ -257,6 +371,7 @@ export const sampleProject: Project = {
hostWallId: "W1", hostWallId: "W1",
categoryCode: "21", categoryCode: "21",
kind: "window", kind: "window",
typeId: "window-standard",
position: 3.0, // Startkante 3.0 m → Fenster x 3.04.0, rechts von W9 mit Massiv-Abstand position: 3.0, // Startkante 3.0 m → Fenster x 3.04.0, rechts von W9 mit Massiv-Abstand
width: 1.0, width: 1.0,
height: 1.2, height: 1.2,
@@ -268,6 +383,7 @@ export const sampleProject: Project = {
hostWallId: "W3", hostWallId: "W3",
categoryCode: "21", categoryCode: "21",
kind: "window", kind: "window",
typeId: "window-standard",
// Startkante 0.6 m ab W3-Start (x=5) → Fenster x 3.04.4, rechts von W9 (x=2.4) // Startkante 0.6 m ab W3-Start (x=5) → Fenster x 3.04.4, rechts von W9 (x=2.4)
// mit sichtbarer Massiv-Wand dazwischen (die Querwand stösst auf Mauerwerk). // mit sichtbarer Massiv-Wand dazwischen (die Querwand stösst auf Mauerwerk).
position: 0.6, position: 0.6,
@@ -306,6 +422,7 @@ export const sampleProject: Project = {
floorId: "eg", floorId: "eg",
categoryCode: "40", categoryCode: "40",
shape: "straight", shape: "straight",
typeId: "stair-standard",
start: { x: 3.7, y: 0.4 }, start: { x: 3.7, y: 0.4 },
dir: { x: 0, y: 1 }, dir: { x: 0, y: 1 },
runLength: 3.2, runLength: 3.2,
+693 -2
View File
@@ -18,6 +18,10 @@ export type { SiaCategory } from "../geometry/roomArea";
// Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil). // Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil).
import type { RichTextDoc, Align } from "../text/richText"; import type { RichTextDoc, Align } from "../text/richText";
export type { RichTextDoc } from "../text/richText"; export type { RichTextDoc } from "../text/richText";
// Ansichts-Enums für Ausschnitte/View-Snapshots (Ansichtstyp, Kamera-Preset,
// Detailgrad). Rein type-only importiert — zur Laufzeit erased, kein Zyklus
// (TopBar importiert seinerseits nur `Project` als Typ). Wie SiaCategory oben.
import type { DetailLevel, View3d, ViewType } from "../ui/TopBar";
// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ──────────────────── // ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ────────────────────
// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette: // Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette:
@@ -272,6 +276,166 @@ export interface CeilingType {
layers: Layer[]; layers: Layer[];
} }
// ── Bauteil-Typen: Tür / Fenster / Treppe ──────────────────────────────────
// Wiederverwendbare Stile (Presets) für Türen, Fenster und Treppen — analog
// `WallType`/`CeilingType` (Bibliothek im Projekt, referenziert per `typeId`).
// Getrennte Typen (kein gemeinsames „OpeningType"), damit türspezifische und
// fensterspezifische Parameter sauber je Gattung wohnen. Ein Element ohne
// `typeId` behält sein heutiges Inline-Verhalten (rückwärtskompatibel).
// Detailstufe der Bauteil-Darstellung (Grundriss + 3D) nutzt das bestehende
// {@link DetailLevel}-Vokabular ("grob" | "mittel" | "fein", aus ui/TopBar) —
// bewusst KEIN zweites Detail-Enum, damit Ansicht und Bauteil dieselbe Skala
// teilen. Ausgewertet an der Symbol-/Mesh-Erzeugung (Phase 2).
/**
* Ein Türtyp (Türstil) wiederverwendbares Preset für {@link Opening} mit
* `kind: "door"`. Bündelt Bauart, Blattausführung und Standardmaße. Werte am
* einzelnen Element (Breite/Höhe/Anschlag) übersteuern die Typ-Defaults.
*/
export interface DoorType {
id: string;
name: string;
/**
* Bauart: normaler Drehflügel, reine Wandöffnung (kein Blatt/Schwenk),
* Schiebetür. Ersetzt/ergänzt das Inline-Feld `Opening.doorType`.
*/
kind: "dreh" | "schiebe" | "wandoeffnung";
/** Anzahl Türblätter (1 = einflügelig, 2 = zweiflügelig). */
leafCount: 1 | 2;
/** Blatt-Ausführung (3D/Detail): glatt, Kassette, Glasfüllung. */
leafStyle: "glatt" | "kassette" | "glas";
/** Glasanteil des Blatts (0..1); nur bei `leafStyle: "glas"` relevant. */
glazingRatio?: number;
/** Zargen-/Rahmenstärke quer zur Wand (Meter). */
frameThickness: number;
/** Rahmen-/Zargen-Tiefe in Wandrichtung (Meter); fehlt ⇒ aus Wanddicke. */
frameDepth?: number;
/** Default-Lichtbreite (Meter) für neu platzierte Türen dieses Typs. */
defaultWidth: number;
/** Default-Lichthöhe (Meter). */
defaultHeight: number;
/** Bodenschwelle/Anschlag zeichnen (3D/Detail). */
threshold?: boolean;
}
/**
* Ein Fenstertyp (Fensterstil) wiederverwendbares Preset für {@link Opening}
* mit `kind: "window"`. Bündelt Öffnungsart, Flügelzahl, Verglasung und
* Standardmaße inkl. Brüstungshöhe.
*/
export interface WindowType {
id: string;
name: string;
/** Öffnungsart: Dreh, Kipp, Dreh-Kipp, fest verglast, Schiebe. */
kind: "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe";
/** Anzahl Flügel (14) → Mittelpfosten = wingCount 1 (vgl. `Opening.wingCount`). */
wingCount: number;
/** Verglasung: Einfach/Zweifach/Dreifach (3D-Scheibenzahl). */
glazing: "einfach" | "zweifach" | "dreifach";
/** Rahmenstärke quer zur Wand (Meter). */
frameThickness: number;
/** Rahmen-Tiefe in Wandrichtung (Meter); fehlt ⇒ aus Wanddicke. */
frameDepth?: number;
/** Default-Brüstungshöhe über Wand-UK (Meter). */
defaultSillHeight: number;
/** Default-Lichtbreite (Meter). */
defaultWidth: number;
/** Default-Lichthöhe (Meter). */
defaultHeight: number;
/** Fensterbank zeichnen (Detail): keine / innen / aussen / beide. */
sillBoard?: "keine" | "innen" | "aussen" | "beide";
}
/**
* Ein Treppentyp (Treppenstil) wiederverwendbares Preset für {@link Stair}.
* Bündelt Tragart, Stufenausbildung und Geländer. Geometrische Grundform
* (gerade/L/Wendel) + Lauflänge/Stufenzahl bleiben am einzelnen Element.
*/
export interface StairType {
id: string;
name: string;
/**
* Tragart:
* "massiv" Vollblock bis zur Treppen-UK (Treppe auf Erdreich/Sockel).
* "beton" Ortbeton-Laufplatte mit SCHRÄGER, offener Untersicht
* (die klassische normale Betontreppe" zwischen zwei Geschossen).
* "wange" Wangentreppe (schwebende Tritte, offen).
* "aufgesattelt" aufgesattelte Tritte (mit Setzstufen, offen).
* "spindel" Spindel-/Wendeltreppe (offen).
*/
structure: "massiv" | "beton" | "wange" | "aufgesattelt" | "spindel";
/** Setzstufen geschlossen (true) oder offen (false, durchsichtige Tritte). */
closedRisers: boolean;
/** Trittstufendicke (Meter). */
treadThickness: number;
/** Trittkanten-Überstand / Nase (Meter); fehlt ⇒ 0. */
nosing?: number;
/** Handlauf/Geländer: keine / links / rechts / beide (in Laufrichtung). */
railing?: "keine" | "links" | "rechts" | "beide";
/** Default-Laufbreite (Meter) für neu platzierte Treppen dieses Typs. */
defaultWidth: number;
}
// ── Grafische Overrides (Regel-Engine) ─────────────────────────────────────
// ArchiCAD-/Vectorworks-Stil: benannte Regeln `condition → actions`, die beim
// RENDERN als oberste Schicht über die By-Layer/By-Object-Attribut-Auflösung
// gelegt werden (reines Rendering-Overlay — die Elementdaten bleiben
// unverändert, jederzeit reversibel). Regeln werden additiv angewendet: pro
// Aktions-Feld gewinnt die OBERSTE (erste) aktive Regel, die es setzt.
// Auswertung: `src/overrides/engine.ts`; Einhängepunkt: `plan/generatePlan.ts`.
/**
* Bedingungs-Typ einer Override-Regel wogegen der Wert verglichen wird:
* "layer_name" Name ODER Code der Ebene (LayerCategory) des Elements.
* "object_name" Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name,
* Öffnungs-Label, Drawing2D-Formname).
* Ein `user_string`-Tag (DOSSIER-Rhino) existiert am Element noch nicht und
* ist deshalb bewusst NICHT enthalten (kein Schein-Feature).
*/
export type OverrideConditionType = "layer_name" | "object_name";
/** Vergleichs-Operator einer Override-Bedingung (case-insensitiv). */
export type OverrideOperator =
| "equals"
| "contains"
| "starts_with"
| "not_equals";
/** Bedingung einer Override-Regel. */
export interface OverrideCondition {
type: OverrideConditionType;
operator: OverrideOperator;
/** Vergleichswert (Freitext, case-insensitiv verglichen). */
value: string;
}
/**
* Aktionen einer Override-Regel jede optional (Teilaktionen sind erlaubt;
* fehlende Felder lassen die reguläre Auflösung unberührt).
*/
export interface OverrideActions {
/** Strich-/Umrandungsfarbe (hex). */
color?: string;
/** Strichstärke in mm Papier. */
lineweight?: number;
/** Linienstil (Line Manager) — wirkt, wo Elemente einen LineStyle tragen (Drawing2D). */
linetypeId?: string;
}
/**
* Eine grafische Override-Regel. Lebt in `Project.overrideRules` (Reihenfolge
* = Priorität, oben gewinnt) und ist einzeln aktivier-/deaktivierbar.
*/
export interface OverrideRule {
id: string;
name: string;
/** Deaktivierte Regeln werden bei der Auswertung übersprungen. */
enabled: boolean;
condition: OverrideCondition;
actions: OverrideActions;
}
// ── Parametrische Wände (Parametric Walls) ──────────────────────────────── // ── Parametrische Wände (Parametric Walls) ────────────────────────────────
// Parametrische Wand-Regeln generieren automatisch Wall[]-Arrays — analog zu // Parametrische Wand-Regeln generieren automatisch Wall[]-Arrays — analog zu
// FreeCAD BIM. Sie leben in Project.resources.parametricWalls[] und werden // FreeCAD BIM. Sie leben in Project.resources.parametricWalls[] und werden
@@ -619,8 +783,26 @@ export interface Wall {
* Z-Wert; floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss. * Z-Wert; floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss.
*/ */
top?: VerticalAnchor; top?: VerticalAnchor;
/**
* TERMINIERUNGS-Regel am horizontalen Deckenanschluss (Zuschnitt, NICHT
* Priorität bewusst getrennt von `Component.joinPriority`/der Schnitt-
* Boolean-Dominanz gehalten). Steuert, ob ein Wandband, das eine dominante
* Deckenschicht durchstösst, oberhalb der Decke wieder auftaucht":
* `undefined`/"both" heutiges Verhalten: die dominante Deckenschicht
* stanzt nur ihr z-Band aus, das Wandband bleibt oben UND unten erhalten.
* "below" die Wand ENDET an der Decke: nur der Teil UNTER dem höchst-
* gelegenen dominanten Cut bleibt (Regelfall, Wand steigt von unten in die
* Decke). Der oberhalb der Decke stehende Rest wird verworfen.
* "above" spiegelbildlich: nur der Teil ÜBER dem tiefsten dominanten Cut
* bleibt (Brüstung/Attika, die von oben an die Decke stösst).
* Additiv; Alt-Projekte laden unverändert (Default = "both").
*/
sliceTermination?: SliceTermination;
} }
/** Terminierungs-Regel einer Wand am Deckenanschluss (siehe {@link Wall.sliceTermination}). */
export type SliceTermination = "both" | "below" | "above";
/** /**
* Eine Decke (Slab) ein geschossgebundenes, mehrschichtiges Flächenbauteil, * Eine Decke (Slab) ein geschossgebundenes, mehrschichtiges Flächenbauteil,
* definiert über einen GESCHLOSSENEN Umriss (Polygon) im Grundriss und eine * definiert über einen GESCHLOSSENEN Umriss (Polygon) im Grundriss und eine
@@ -631,7 +813,9 @@ export interface Wall {
* Vertikale Lage: die OBERKANTE (OK) der Decke. Fehlt `top`, liegt die OK an der * Vertikale Lage: die OBERKANTE (OK) der Decke. Fehlt `top`, liegt die OK an der
* Oberkante des Geschosses (baseElevation + floorHeight also bündig mit dem * Oberkante des Geschosses (baseElevation + floorHeight also bündig mit dem
* Wandkopf); die Decke wächst um `thickness` nach UNTEN. `top: custom` setzt eine * Wandkopf); die Decke wächst um `thickness` nach UNTEN. `top: custom` setzt eine
* absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss. * absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss. Optional übersteuert
* `bottom` die UNTERKANTE (UK) direkt (analog zur Wand); fehlt `bottom`, bleibt es
* beim heutigen Verhalten UK = OK `thickness`.
*/ */
export interface Ceiling { export interface Ceiling {
id: string; id: string;
@@ -699,6 +883,12 @@ export interface Ceiling {
* Oberkante des Geschosses (baseElevation + floorHeight). * Oberkante des Geschosses (baseElevation + floorHeight).
*/ */
top?: VerticalAnchor; top?: VerticalAnchor;
/**
* Vertikale Bindung der UNTERKANTE (UK). Fehlt sie, ergibt sich die UK aus
* OK `thickness` (= heutiges Verhalten). custom" setzt einen absoluten
* Z-Wert; floor" bindet die UK an ein Geschoss.
*/
bottom?: VerticalAnchor;
} }
/** Schwenkrichtung der Tür relativ zur Wandachse. */ /** Schwenkrichtung der Tür relativ zur Wandachse. */
@@ -772,6 +962,18 @@ export interface Opening {
* "beide" beide Linien (Default wenn nicht gesetzt) * "beide" beide Linien (Default wenn nicht gesetzt)
*/ */
lintelLines?: "keine" | "innen" | "aussen" | "beide"; lintelLines?: "keine" | "innen" | "aussen" | "beide";
/**
* Referenz auf einen Bauteil-Typ (Bibliothek): {@link DoorType} bei
* `kind: "door"`, {@link WindowType} bei `kind: "window"`. Fehlt sie, gilt
* das heutige Inline-Verhalten (rückwärtskompatibel). Typ-Defaults liefern
* Bauart/Rahmen/Verglasung; Element-Felder (Breite/Höhe/) übersteuern.
*/
typeId?: string;
/**
* Optionale per-Element-Übersteuerung der Detailstufe (grob/normal/
* detailliert); fehlt sie, gilt die Ansichts-/Projekt-Detailstufe.
*/
detailLevel?: DetailLevel;
/** /**
* Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die * Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
* Kategorie-Farbe. * Kategorie-Farbe.
@@ -862,6 +1064,17 @@ export interface Stair {
* `dir` (Default). false kehrt Auf-/Abpfeil um (Treppe steigt zum Start hin). * `dir` (Default). false kehrt Auf-/Abpfeil um (Treppe steigt zum Start hin).
*/ */
up?: boolean; up?: boolean;
/**
* Referenz auf einen {@link StairType} (Bibliothek). Fehlt sie, gilt das
* heutige Inline-Verhalten (rückwärtskompatibel). Typ-Defaults liefern
* Tragart/Stufenausbildung/Geländer; Element-Felder übersteuern.
*/
typeId?: string;
/**
* Optionale per-Element-Übersteuerung der Detailstufe (grob/normal/
* detailliert); fehlt sie, gilt die Ansichts-/Projekt-Detailstufe.
*/
detailLevel?: DetailLevel;
/** /**
* Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die * Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die
* Kategorie-Farbe. * Kategorie-Farbe.
@@ -948,6 +1161,17 @@ export interface RoomStamp {
floorAreaAlign?: Align; floorAreaAlign?: Align;
/** Ausrichtung der Nutzungs-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */ /** Ausrichtung der Nutzungs-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */
usageAlign?: Align; usageAlign?: Align;
/**
* Personenzahl (Live-Zeile), z. B. für Nutzungsauflagen. Fehlt/undefined:
* keine Personenzahl-Zeile (Alt-Verhalten).
*/
occupancy?: number;
/**
* Rundungsschritt der angezeigten Bodenfläche in m² (z. B. 0.5 auf halbe
* m² gerundet). Fehlt er, gilt das Alt-Verhalten: 2 Nachkommastellen ohne
* Schrittrundung.
*/
roundingStep?: number;
} }
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */ /** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
@@ -980,7 +1204,19 @@ export type Drawing2DGeom =
| { shape: "rect"; min: Vec2; max: Vec2 } | { shape: "rect"; min: Vec2; max: Vec2 }
| { shape: "circle"; center: Vec2; r: number } | { shape: "circle"; center: Vec2; r: number }
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number } | { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
| { shape: "text"; at: Vec2; text: string; height: number; angle: number }; | {
shape: "text";
at: Vec2;
text: string;
height: number;
angle: number;
/**
* Optionale Spaltenbreite in Metern (Textspalte/Absatztext). Ist sie gesetzt,
* wird der Text beim Rendern wortweise auf diese Breite umgebrochen; fehlt
* sie, bleibt es einzeiliger Text (heutiges Verhalten).
*/
width?: number;
};
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */ /** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
export interface Drawing2D { export interface Drawing2D {
@@ -1067,6 +1303,96 @@ export interface EdgeGrip {
export type Element = Wall | Ceiling | Opening | Door | Stair | Room | Drawing2D; export type Element = Wall | Ceiling | Opening | Door | Stair | Room | Drawing2D;
// ── Extrudierter Körper (truck-Integration, docs/design/truck-plan.md) ─────
// Ein per `extrude`-Befehl aus einem geschlossenen 2D-Profil (Polylinie-Ring/
// Rechteck) erzeugter 3D-Körper. Die eigentliche B-Rep-Extrusion (truck-WASM,
// `engine/truckSolid.ts`) läuft NICHT hier, sondern asynchron in
// `toWalls3d.ts` (emitExtrudedSolids) — dieser Typ hält nur die Rohdaten.
/** Ein extrudierter Körper: geschlossenes Profil (Modell-Meter) + Höhe. */
export interface ExtrudedSolid {
id: string;
type: "extrudedSolid";
/** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */
levelId: string;
/**
* Geschlossenes Profil in der XY-Ebene (Grundriss), 3 Punkte. Bei einem
* Kreis-Profil (`circle` gesetzt) eine Tessellierung des Kreises (48-Eck)
* für 2D-Footprint/Auswahl/bbox/Verschieben; die 3D-Extrusion nutzt in dem
* Fall stattdessen `circle` (echte runde truck-Extrusion, `extrudeCircle`).
*/
points: Vec2[];
/** Extrusionshöhe in Metern (> 0), nach +Z ab der Geschoss-UK. */
height: number;
/**
* Gesetzt, wenn das Profil ein Kreis ist (aus einem `Drawing2D` mit
* `shape:"circle"`) `points` bleibt die Tessellierung, `toWalls3d.ts`
* (emitExtrudedSolids) nutzt `circle` für die runde 3D-Extrusion.
*/
circle?: { center: Vec2; r: number };
/**
* Verjüngung 0 (Prisma, Default/fehlend) 1 (Spitze Kegel bei Kreis-,
* Pyramide bei Polygon-Profil). Linear zum Profil-Schwerpunkt skaliert.
*/
taper?: number;
}
// ── Stütze (Column, Tragwerk) ──────────────────────────────────────────────
// Eine Stütze ist im Kern eine PLATZIERTE PROFIL-EXTRUSION: ein an `position`
// gesetztes 2D-Profil (Rechteck oder Kreis), um `rotation` gedreht, über `height`
// nach oben extrudiert. Anders als der generische `ExtrudedSolid` trägt sie
// vollständige BIM-Attribute (Geschoss, Kategorie, optionales Bauteil, UK/OK-
// Anker) — sie lebt daher in `Project.columns` mit eigenem Selektionskanal.
/**
* Querschnitt einer Stütze im Grundriss (Modell-Meter), lokal um `position`.
* "rect" Rechteck `width` (X, quer) × `depth` (Y, längs), vor Rotation.
* "round" Kreis mit `radius`.
*/
export type ColumnProfile =
| { kind: "rect"; width: number; depth: number }
| { kind: "round"; radius: number };
/**
* Eine Stütze (Column) ein geschossgebundenes Tragwerk-Bauteil (Kategorie
* 50"), definiert über einen Einfügepunkt `position`, ein Profil, eine Drehung
* und eine Höhe. Vertikale Lage analog Wand (UK/OK-Anker, sonst Geschoss-UK +
* `height`).
*/
export interface Column {
id: string;
type: "column";
/** Zugehörige Zeichnungsebene (Geschoss). */
floorId: string;
/** Grafik-Kategorie (Ebene), z. B. "50" für Tragwerk. */
categoryCode: string;
/** Einfügepunkt (Profil-Mitte) im Grundriss (Meter). */
position: Vec2;
/** Querschnitt (Rechteck oder Kreis). */
profile: ColumnProfile;
/** Drehung des Profils um `position` (Radiant, CCW; Default 0). */
rotation: number;
/** Höhe in Metern (> 0), nach +Z ab der UK. */
height: number;
/** Optionales tragendes Bauteil (Component) für Poché/3D-Farbe/Schraffur. */
componentId?: string;
/**
* Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der
* baseElevation des zugehörigen Geschosses (= Wand-Default).
*/
bottom?: VerticalAnchor;
/**
* Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus
* UK + `height`.
*/
top?: VerticalAnchor;
/**
* Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die
* Kategorie-Farbe.
*/
color?: string;
}
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ─────────── // ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres // Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
// Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen // Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen
@@ -1160,6 +1486,278 @@ export interface TerrainMesh {
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh; export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
/** Das gesamte Projekt. */ /** Das gesamte Projekt. */
/**
* Ein Ausschnitt (View-Snapshot) eine benannte, gespeicherte Ansicht, die den
* kompletten Darstellungszustand einfängt und per Klick wiederherstellt
* (DOSSIER A2, ROADMAP §2c/§11). KEIN neuer Zustand, sondern ein Container, der
* bereits vorhandene Bausteine KOMPONIERT + benennt: Ansicht (Typ/Kamera/
* Massstab/Detail), Sichtbarkeit (Ebenen-/Zeichnungskombination, gleiches
* Payload-Format wie {@link https LayerCombo}/`DrawingCombo`), aktive Overrides
* und das aktive Geschoss. Lebt im DOKUMENT (`Project.viewSnapshots`), nicht im
* localStorage Ausschnitte gehören zum Projekt (Export/Teilen).
*/
export interface ViewSnapshot {
id: string;
name: string;
/**
* LEGACY: name-basierte Flach-Gruppierung (Phase 1). Wird von der neuen
* Ordner-/Baum-UI nicht mehr genutzt, bleibt aber für Alt-Projekte gültig.
* Neue Ausschnitte tragen stattdessen `folderId` (echte Ordner-Referenz).
*/
folder?: string;
/**
* Ordner (Baumstruktur) des Ausschnitte-Panels, in dem der Ausschnitt liegt
* ({@link ViewSnapshotFolder}). Fehlt er (oder verweist er ins Leere), liegt
* der Ausschnitt auf der Wurzelebene. Additiv Alt-Projekte laden unverändert.
*/
folderId?: string;
// ── Ansicht ──────────────────────────────────────────────────────────────
/** Ansichtstyp (Grundriss / Perspektive). */
viewType: ViewType;
/** Kanonischer 3D-Blickwinkel (Front/Top/Iso/… bzw. freie Perspektive). */
view3d: View3d;
/** Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad. Optional (nur 3D relevant). */
fov?: number;
/** Papier-Massstab „1:N" (der Nenner). */
scaleDenominator: number;
/** Detailgrad der Darstellung (grob/mittel/fein). */
detail: DetailLevel;
// ── Zustand ──────────────────────────────────────────────────────────────
/** Aktives Geschoss/Zeichnungsebene beim Erfassen. */
activeLevelId: string;
/** Ebenen-Sichtbarkeit: Kategorie-`code` → sichtbar (wie `LayerCombo.codes`). */
layerVisibility: Record<string, boolean>;
/** Zeichnungsebenen-Sichtbarkeit: DrawingLevel-`id` → sichtbar (wie `DrawingCombo.ids`). */
drawingVisibility: Record<string, boolean>;
/**
* Ids der beim Erfassen AKTIVEN (`enabled`) Override-Regeln. Beim
* Wiederherstellen wird jede Regel des Projekts auf `enabled = ids.includes(id)`
* gesetzt. Optional, damit hand-/altangelegte Snapshots ohne Feld gültig sind.
*/
enabledOverrideRuleIds?: string[];
}
/**
* Ein Ordner im Ausschnitte-Baum (DOSSIER A2). Ausschnitte verweisen per
* `folderId` auf ihren Ordner; `parentId` erlaubt Verschachtelung (Ordner in
* Ordner). Fehlt `parentId`, liegt der Ordner auf der Wurzelebene. Additiv
* Alt-Projekte laden unverändert (spiegelbildlich zu {@link LayoutFolder}).
*/
export interface ViewSnapshotFolder {
id: string;
name: string;
/** Übergeordneter Ordner; ohne → Wurzelebene. */
parentId?: string;
}
// ── Layout-Blätter mit Masterlayout (DOSSIER A3, ROADMAP §11) ───────────────
//
// Ein „Layout" ist ein Druck-/Plan-Blatt (A4/A3), auf dem mehrere Viewports
// platziert werden; jeder Viewport ist an einen Ausschnitt ({@link ViewSnapshot})
// gebunden und rendert dessen Plan im gewählten Massstab. Ein Masterlayout
// liefert die gemeinsamen Blatt-Elemente (Titelblock/Rahmen), die die Layouts
// erben. Alles lebt im DOKUMENT (`Project.layouts`/`Project.masterLayouts`),
// nicht im localStorage — Layouts gehören zum Projekt. Additiv: bestehende
// Projekte ohne diese Felder bleiben unverändert gültig.
/**
* Papierformat eines Layout-Blatts (Blattmasse in mm identisch zum PDF-Export).
* ISO-216-Reihen A0A6 und B0B6; Alt-Projekte (nur A4/A3) laden unverändert.
*/
export type LayoutPaperFormat =
| "a0"
| "a1"
| "a2"
| "a3"
| "a4"
| "a5"
| "a6"
| "b0"
| "b1"
| "b2"
| "b3"
| "b4"
| "b5"
| "b6";
/** Ausrichtung eines Layout-Blatts. */
export type LayoutOrientation = "portrait" | "landscape";
/**
* Einfache Textfelder des Titelblocks (Master). Alle optional leere Felder
* werden beim Auflösen aus dem Projekt/Layout mit sinnvollen Defaults gefüllt
* (Projektname `Project.name`, Blattname `Layout.name`, Datum heute).
*/
export interface LayoutTitleBlock {
projectName?: string;
sheetName?: string;
scale?: string;
date?: string;
author?: string;
}
/**
* Masterlayout gemeinsame Blatt-Elemente (Titelblock + optionaler Rahmen), die
* einzelne {@link Layout}s per `masterId` erben. Eine Änderung am Master schlägt
* auf alle Layouts durch, die ihn referenzieren (InDesign-/ArchiCAD-Muster).
*/
export interface MasterLayout {
id: string;
name: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
/** Titelblock-Textfelder (Vorlage; leere Felder werden aufgelöst/aufgefüllt). */
titleBlock: LayoutTitleBlock;
/**
* Ordner (Master-Baum), in dem das Masterlayout liegt ({@link LayoutFolder} mit
* `kind:"master"`). Fehlt er (oder verweist auf einen gelöschten/Layout-Ordner),
* liegt der Master auf der Wurzelebene. Additiv Alt-Projekte laden unverändert.
*/
folderId?: string;
/** Blattrahmen zeichnen (Randlinie). Optional, Default `true`. */
border?: boolean;
/**
* Freie Blattbreite in mm. Ist sie (zusammen mit `customHeightMm`) gesetzt,
* überschreibt sie `paper`/A4·A3 (die effektive Blattgrösse ist dann
* `customWidthMm`×`customHeightMm`, `orientation` wird ignoriert). Fehlt sie,
* gilt `paper`+`orientation` wie bisher. Additiv Alt-Projekte laden unverändert.
*/
customWidthMm?: number;
/** Freie Blatthöhe in mm (siehe {@link MasterLayout.customWidthMm}). */
customHeightMm?: number;
}
/**
* Ein platzierter Viewport auf einem Layout-Blatt Position/Grösse in Papier-mm,
* gebunden an einen Ausschnitt (`snapshotId`). `scaleDenominator` übersteuert
* optional den Massstab des Ausschnitts (Massstab pro Viewport).
*/
export interface LayoutViewport {
id: string;
/** Id des gebundenen Ausschnitts ({@link ViewSnapshot}). */
snapshotId: string;
/** Linke Kante auf dem Blatt (mm, von links). */
xMm: number;
/** Obere Kante auf dem Blatt (mm, von oben). */
yMm: number;
/** Breite des Viewport-Rahmens auf dem Blatt (mm). */
widthMm: number;
/** Höhe des Viewport-Rahmens auf dem Blatt (mm). */
heightMm: number;
/** Optionaler Massstab-Nenner (1:N); übersteuert `snapshot.scaleDenominator`. */
scaleDenominator?: number;
}
/**
* Eine 2D-Annotation (Linie/Rechteck/Text), direkt auf dem Layout-Blatt in
* Papier-mm-Koordinaten gezeichnet eine vom Viewport-Baum unabhängige
* Zusatzschicht (Markup). `color`/`weightMm` sind optional (Editor-Defaults:
* `#111111` / 0.25 mm); Text trägt statt `weightMm` seine Zeilenhöhe.
*/
export type LayoutAnnotation =
| {
id: string;
kind: "line";
x1Mm: number;
y1Mm: number;
x2Mm: number;
y2Mm: number;
color?: string;
weightMm?: number;
}
| {
id: string;
kind: "rect";
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
color?: string;
weightMm?: number;
}
| {
id: string;
kind: "text";
xMm: number;
yMm: number;
text: string;
heightMm: number;
color?: string;
};
/**
* Flaches Patch-Objekt für {@link LayoutAnnotation} alle Felder ALLER Arten
* optional (statt eines pro Art unterscheidenden Unions), damit CRUD/Handler
* unabhängig von der konkreten `kind` bleiben (der Aufrufer setzt ohnehin nur
* die zur gewählten Annotation passenden Felder).
*/
export type LayoutAnnotationPatch = Partial<{
x1Mm: number;
y1Mm: number;
x2Mm: number;
y2Mm: number;
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
text: string;
color: string;
weightMm: number;
}>;
/**
* Ein Layout-Blatt Papierformat/Ausrichtung, optionaler Master (`masterId`)
* und die platzierten Viewports.
*/
export interface Layout {
id: string;
name: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
/** Referenz auf ein {@link MasterLayout}; ohne → kein Master (blanko Blatt). */
masterId?: string;
/**
* Ordner (Baumstruktur), in dem das Layout liegt ({@link LayoutFolder}). Fehlt
* er (oder verweist auf einen gelöschten Ordner), liegt das Layout auf der
* Wurzelebene. Additiv Alt-Projekte laden unverändert.
*/
folderId?: string;
/**
* Freie Blattbreite in mm überschreibt `paper`/A4·A3, siehe
* {@link MasterLayout.customWidthMm}. Bei gesetztem `masterId` erbt das Blatt
* ohnehin die Master-Grösse; eigene Custom-Werte gelten nur ohne Master.
*/
customWidthMm?: number;
/** Freie Blatthöhe in mm (siehe {@link Layout.customWidthMm}). */
customHeightMm?: number;
viewports: LayoutViewport[];
/**
* 2D-Markups direkt auf dem Blatt (Linie/Rechteck/Text), siehe
* {@link LayoutAnnotation}. Additiv Alt-Layouts ohne dieses Feld laden
* unverändert (Editor/CRUD behandeln ein fehlendes Array wie ein leeres).
*/
annotations?: LayoutAnnotation[];
}
/**
* Ein Ordner im Layouts-Baum (DOSSIER A3). Layouts verweisen per `folderId` auf
* ihren Ordner; `parentId` erlaubt Verschachtelung (Ordner in Ordner). Fehlt
* `parentId`, liegt der Ordner auf der Wurzelebene. Masterlayouts liegen NICHT
* in Ordnern (sie sind Vorlagen). Additiv Alt-Projekte laden unverändert.
*/
export interface LayoutFolder {
id: string;
name: string;
/** Übergeordneter Ordner; ohne → Wurzelebene. */
parentId?: string;
/**
* Baum-Zugehörigkeit: `"layout"`-Ordner tragen Layouts, `"master"`-Ordner
* tragen Masterlayouts. Die beiden Bäume bleiben getrennt (ein Master-Ordner
* erscheint nie im Layout-Baum und umgekehrt). Fehlt das Feld, gilt `"layout"`
* (rückwärtskompatibel Alt-Projekte ohne `kind` bleiben Layout-Ordner).
*/
kind?: "layout" | "master";
}
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
@@ -1177,6 +1775,20 @@ export interface Project {
* `Ceiling.wallTypeId` gegen `wallTypes` auf (siehe `getCeilingType`). * `Ceiling.wallTypeId` gegen `wallTypes` auf (siehe `getCeilingType`).
*/ */
ceilingTypes?: CeilingType[]; ceilingTypes?: CeilingType[];
/**
* Türtypen-Bibliothek (Türstile), analog `wallTypes`. Optional, damit
* bestehende Projekte/Tests ohne `doorTypes` gültig bleiben; Türen ohne
* `typeId` verhalten sich wie bisher (Inline-Felder).
*/
doorTypes?: DoorType[];
/**
* Fenstertypen-Bibliothek (Fensterstile). Optional (siehe `doorTypes`).
*/
windowTypes?: WindowType[];
/**
* Treppentypen-Bibliothek (Treppenstile). Optional (siehe `doorTypes`).
*/
stairTypes?: StairType[];
/** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */ /** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */
drawingLevels: DrawingLevel[]; drawingLevels: DrawingLevel[];
/** Grafik-Kategorie-Baum (geschossübergreifend). */ /** Grafik-Kategorie-Baum (geschossübergreifend). */
@@ -1213,6 +1825,18 @@ export interface Project {
* Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln). * Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln).
*/ */
context?: ContextObject[]; context?: ContextObject[];
/**
* Extrudierte Körper (truck-Integration) geschlossene 2D-Profile mit Höhe,
* siehe {@link ExtrudedSolid}. Optional, damit bestehende Projekte/Tests ohne
* `extrudedSolids` gültig bleiben (Default: leer behandeln).
*/
extrudedSolids?: ExtrudedSolid[];
/**
* Stützen (Columns) geschossgebundene Tragwerk-Bauteile (platzierte
* Profil-Extrusionen), siehe {@link Column}. Optional, damit bestehende
* Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln).
*/
columns?: Column[];
/** /**
* Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende * Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende
* Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln). * Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln).
@@ -1220,6 +1844,45 @@ export interface Project {
* aufgelöst generiert Wall[]-Objekte bei Bedarf. * aufgelöst generiert Wall[]-Objekte bei Bedarf.
*/ */
parametricWalls?: ParametricWall[]; parametricWalls?: ParametricWall[];
/**
* Grafische Override-Regeln (Regel-Engine, Reihenfolge = Priorität, oben
* gewinnt) reines Rendering-Overlay, siehe {@link OverrideRule}. Optional,
* damit bestehende Projekte/Tests ohne `overrideRules` gültig bleiben
* (Default: leer behandeln).
*/
overrideRules?: OverrideRule[];
/**
* Ausschnitte / View-Snapshots (DOSSIER A2) benannte, wiederherstellbare
* Ansichten (siehe {@link ViewSnapshot}). Optional, damit bestehende
* Projekte/Tests ohne `viewSnapshots` unverändert gültig bleiben (Default:
* leer behandeln). Gehört ins Dokument (nicht localStorage).
*/
viewSnapshots?: ViewSnapshot[];
/**
* Ordner des Ausschnitte-Baums (DOSSIER A2) Gruppierung der
* {@link ViewSnapshot}s per `folderId`. Optional, damit bestehende
* Projekte/Tests ohne `viewSnapshotFolders` gültig bleiben (Default: leer
* alle Ausschnitte auf Wurzelebene).
*/
viewSnapshotFolders?: ViewSnapshotFolder[];
/**
* Layout-Blätter (DOSSIER A3) Druck-/Plan-Blätter mit platzierten Viewports
* (siehe {@link Layout}). Optional, damit bestehende Projekte/Tests ohne
* `layouts` gültig bleiben (Default: leer behandeln). Gehört ins Dokument.
*/
layouts?: Layout[];
/**
* Masterlayouts (DOSSIER A3) gemeinsame Blatt-Elemente (Titelblock/Rahmen),
* die einzelne {@link Layout}s erben (siehe {@link MasterLayout}). Optional,
* damit bestehende Projekte/Tests ohne `masterLayouts` gültig bleiben.
*/
masterLayouts?: MasterLayout[];
/**
* Ordner des Layouts-Baums (DOSSIER A3) Gruppierung der {@link Layout}s per
* `folderId`. Optional, damit bestehende Projekte/Tests ohne `layoutFolders`
* gültig bleiben (Default: leer alle Layouts auf Wurzelebene).
*/
layoutFolders?: LayoutFolder[];
/** /**
* Referenzhöhe des Erdgeschosses in Metern über Meer (m ü. M.), editierbar * Referenzhöhe des Erdgeschosses in Metern über Meer (m ü. M.), editierbar
* im Einstellungs-Fenster. Optional, damit bestehende Projekte ohne diesen * im Einstellungs-Fenster. Optional, damit bestehende Projekte ohne diesen
@@ -1270,6 +1933,22 @@ export const ceilingThickness = (project: Project, ceiling: Ceiling): number =>
} }
}; };
/**
* Löst den {@link DoorType} einer Tür-Öffnung auf, oder `undefined` (keine
* `typeId` bzw. unbekannt Inline-Verhalten). Bewusst NICHT werfend der
* Typ ist eine optionale Übersteuerung, kein Pflichtbezug wie beim Wandtyp.
*/
export const getDoorType = (project: Project, opening: Opening): DoorType | undefined =>
opening.typeId ? (project.doorTypes ?? []).find((t) => t.id === opening.typeId) : undefined;
/** Löst den {@link WindowType} einer Fenster-Öffnung auf, oder `undefined`. */
export const getWindowType = (project: Project, opening: Opening): WindowType | undefined =>
opening.typeId ? (project.windowTypes ?? []).find((t) => t.id === opening.typeId) : undefined;
/** Löst den {@link StairType} einer Treppe auf, oder `undefined`. */
export const getStairType = (project: Project, stair: Stair): StairType | undefined =>
stair.typeId ? (project.stairTypes ?? []).find((t) => t.id === stair.typeId) : undefined;
/** Alle Öffnungen einer Wand (leere Liste, wenn keine oder `openings` fehlt). */ /** Alle Öffnungen einer Wand (leere Liste, wenn keine oder `openings` fehlt). */
export const openingsOfWall = (project: Project, wallId: string): Opening[] => export const openingsOfWall = (project: Project, wallId: string): Opening[] =>
(project.openings ?? []).filter((o) => o.hostWallId === wallId); (project.openings ?? []).filter((o) => o.hostWallId === wallId);
@@ -1291,6 +1970,18 @@ export const stairLabel = (s: Stair): string => {
return `${shape} ${s.stepCount} STG`; return `${shape} ${s.stepCount} STG`;
}; };
/** Alle Stützen eines Geschosses (leere Liste, wenn keine oder `columns` fehlt). */
export const columnsOfFloor = (project: Project, floorId: string): Column[] =>
(project.columns ?? []).filter((c) => c.floorId === floorId);
/** Menschenlesbarer Standardname einer Stütze (Profil + Masse in cm). */
export const columnLabel = (c: Column): string => {
if (c.profile.kind === "round") {
return `Stütze Ø${(c.profile.radius * 200).toFixed(0)}`;
}
return `Stütze ${(c.profile.width * 100).toFixed(0)}×${(c.profile.depth * 100).toFixed(0)}`;
};
/** Alle Räume eines Geschosses (leere Liste, wenn keine oder `rooms` fehlt). */ /** Alle Räume eines Geschosses (leere Liste, wenn keine oder `rooms` fehlt). */
export const roomsOfFloor = (project: Project, floorId: string): Room[] => export const roomsOfFloor = (project: Project, floorId: string): Room[] =>
(project.rooms ?? []).filter((r) => r.floorId === floorId); (project.rooms ?? []).filter((r) => r.floorId === floorId);
+115
View File
@@ -0,0 +1,115 @@
// Unit-Tests für die Wand-/Decken-Resolver (wallVerticalExtent /
// ceilingVerticalExtent): reine Datenschicht, kein React/Store.
import { describe, it, expect } from "vitest";
import { ceilingVerticalExtent } from "./wall";
import type { Ceiling, Project } from "./types";
/** Minimalprojekt mit einem Geschoss (baseElevation 0, floorHeight 2.6) und
* einem einschichtigen Wandtyp (0.2 m), den die Decke als Aufbau nutzt. */
function makeProject(overrides?: Partial<Project>): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [
{ id: "dt", name: "Decke", layers: [{ componentId: "c", thickness: 0.2 }] },
],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
{
id: "og",
name: "OG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 2.8,
},
],
layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
...overrides,
};
}
function makeCeiling(overrides?: Partial<Ceiling>): Ceiling {
return {
id: "cl1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
...overrides,
};
}
describe("ceilingVerticalExtent", () => {
it("ohne top/bottom: OK = Geschoss-Oberkante, UK = OK Dicke (heutiges Verhalten)", () => {
const project = makeProject();
const ceiling = makeCeiling();
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(2.6); // baseElevation(0) + floorHeight(2.6)
expect(zBottom).toBeCloseTo(2.4); // zTop thickness(0.2)
});
it("mit top: custom übersteuert die Oberkante; UK bleibt OK Dicke", () => {
const project = makeProject();
const ceiling = makeCeiling({ top: { mode: "custom", z: 3.0 } });
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(3.0);
expect(zBottom).toBeCloseTo(2.8);
});
it("mit bottom: custom übersteuert die Unterkante — gewinnt über OK Dicke", () => {
const project = makeProject();
const ceiling = makeCeiling({ bottom: { mode: "custom", z: 2.2 } });
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(2.6); // OK unverändert (Geschoss-Default)
expect(zBottom).toBeCloseTo(2.2); // UK-Override statt OK Dicke (2.4)
});
it("mit top UND bottom: beide Overrides wirken unabhängig (Dicke wird ignoriert)", () => {
const project = makeProject();
const ceiling = makeCeiling({
top: { mode: "custom", z: 5.0 },
bottom: { mode: "custom", z: 4.5 },
});
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(5.0);
expect(zBottom).toBeCloseTo(4.5);
});
it("bottom: floor bindet die Unterkante an ein Geschoss (+ optionalem offset)", () => {
const project = makeProject();
const ceiling = makeCeiling({ bottom: { mode: "floor", floorId: "og", offset: 0.1 } });
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(2.6);
expect(zBottom).toBeCloseTo(2.9); // baseElevation(og)=2.8 + offset(0.1)
});
});
+29 -3
View File
@@ -5,7 +5,7 @@
// //
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). // Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types"; import type { Ceiling, Column, Project, Stair, VerticalAnchor, Wall } from "./types";
import { ceilingThickness, getFloor } from "./types"; import { ceilingThickness, getFloor } from "./types";
/** /**
@@ -74,7 +74,9 @@ export function wallVerticalExtent(
* Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern). * Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern).
* zTop: aus `ceiling.top`, sonst Oberkante des Geschosses * zTop: aus `ceiling.top`, sonst Oberkante des Geschosses
* (baseElevation + floorHeight) also bündig mit dem Wandkopf. * (baseElevation + floorHeight) also bündig mit dem Wandkopf.
* zBottom: zTop Deckendicke (die Decke wächst nach UNTEN). * zBottom: aus `ceiling.bottom`, sonst zTop Deckendicke (die Decke
* wächst nach UNTEN heutiges Verhalten). Die `thickness` bleibt bei
* gesetztem `bottom` rein informativ (dient nicht mehr der UK-Ableitung).
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante. * Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante.
*/ */
export function ceilingVerticalExtent( export function ceilingVerticalExtent(
@@ -89,7 +91,9 @@ export function ceilingVerticalExtent(
floorTop = 0; floorTop = 0;
} }
const zTop = resolveAnchor(project, ceiling.top) ?? floorTop; const zTop = resolveAnchor(project, ceiling.top) ?? floorTop;
const zBottom = zTop - ceilingThickness(project, ceiling); const zBottom =
resolveAnchor(project, ceiling.bottom) ??
zTop - ceilingThickness(project, ceiling);
return { zBottom, zTop }; return { zBottom, zTop };
} }
@@ -124,6 +128,28 @@ export function stairVerticalExtent(
return { zBottom: base, zTop: base + rise }; return { zBottom: base, zTop: base + rise };
} }
/**
* Vertikale Ausdehnung einer Stütze (absolute Z-Werte in Metern) dieselbe
* Regel wie {@link wallVerticalExtent}:
* zBottom: aus `column.bottom`, sonst baseElevation des Geschosses.
* zTop: aus `column.top`, sonst `zBottom + column.height`.
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante.
*/
export function columnVerticalExtent(
project: Project,
column: Column,
): { zBottom: number; zTop: number } {
let floorBase = 0;
try {
floorBase = getFloor(project, column.floorId).baseElevation ?? 0;
} catch {
floorBase = 0;
}
const zBottom = resolveAnchor(project, column.bottom) ?? floorBase;
const zTop = resolveAnchor(project, column.top) ?? zBottom + column.height;
return { zBottom, zTop };
}
/** /**
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der * Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt. * "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
+189
View File
@@ -0,0 +1,189 @@
// Unit-Tests der Override-Regel-Engine (reine Auswertung, src/overrides/engine.ts):
// Operatoren, Reihenfolge (oberste Regel gewinnt pro Feld), additive
// Teilaktionen, deaktivierte Regeln, layer_name gegen Name UND Code.
import { describe, it, expect } from "vitest";
import type { OverrideRule } from "../model/types";
import {
EMPTY_OVERRIDES,
effectiveOverrides,
hasOverrides,
matchesCondition,
} from "./engine";
/** Kurzform: Regel mit Defaults. */
function rule(partial: Partial<OverrideRule> & Pick<OverrideRule, "condition" | "actions">): OverrideRule {
return {
id: partial.id ?? "r",
name: partial.name ?? "Regel",
enabled: partial.enabled ?? true,
condition: partial.condition,
actions: partial.actions,
};
}
const WALL_SUBJECT = { layerName: "Wände", layerCode: "20", objectName: "AW 24" };
describe("overrides/engine — matchesCondition", () => {
it("equals: case-insensitiv, trimmt den Regelwert", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: " wände " },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: "Decken" },
WALL_SUBJECT,
),
).toBe(false);
});
it("layer_name matcht Namen ODER Code der Ebene", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: "20" },
WALL_SUBJECT,
),
).toBe(true);
});
it("contains und starts_with auf dem Objektnamen", () => {
expect(
matchesCondition(
{ type: "object_name", operator: "contains", value: "24" },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "object_name", operator: "starts_with", value: "aw" },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "object_name", operator: "starts_with", value: "24" },
WALL_SUBJECT,
),
).toBe(false);
});
it("not_equals trifft nur, wenn WEDER Name noch Code gleich sind", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "20" },
WALL_SUBJECT,
),
).toBe(false);
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "Wände" },
WALL_SUBJECT,
),
).toBe(false);
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "Decken" },
WALL_SUBJECT,
),
).toBe(true);
});
it("fehlende Subjekt-Eigenschaft: positive Operatoren treffen nicht, not_equals schon", () => {
expect(
matchesCondition(
{ type: "object_name", operator: "contains", value: "x" },
{ layerName: "Wände", layerCode: "20" },
),
).toBe(false);
expect(
matchesCondition(
{ type: "object_name", operator: "not_equals", value: "x" },
{ layerName: "Wände", layerCode: "20" },
),
).toBe(true);
});
});
describe("overrides/engine — effectiveOverrides", () => {
it("ohne Regeln (oder undefined) kommt EMPTY_OVERRIDES zurück", () => {
expect(effectiveOverrides(undefined, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
expect(effectiveOverrides([], WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
expect(hasOverrides(EMPTY_OVERRIDES)).toBe(false);
});
it("oberste Regel gewinnt pro Aktions-Feld", () => {
const rules = [
rule({
id: "top",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "below",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#00ff00" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#ff0000");
});
it("additive Teilaktionen: untere Regeln füllen offene Felder", () => {
const rules = [
rule({
id: "color-only",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "weight-only",
condition: { type: "object_name", operator: "starts_with", value: "AW" },
actions: { lineweight: 0.7, color: "#0000ff" },
}),
];
const eff = effectiveOverrides(rules, WALL_SUBJECT);
// Farbe aus der obersten Regel, Strichstärke additiv aus der zweiten.
expect(eff).toEqual({ color: "#ff0000", lineweight: 0.7 });
});
it("deaktivierte Regeln werden übersprungen", () => {
const rules = [
rule({
id: "off",
enabled: false,
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "on",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#00ff00" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#00ff00");
});
it("nicht matchende Regeln tragen nichts bei", () => {
const rules = [
rule({
id: "other-layer",
condition: { type: "layer_name", operator: "equals", value: "30" },
actions: { color: "#ff0000" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
});
it("linetypeId wird wie die anderen Felder aufgelöst", () => {
const rules = [
rule({
id: "lt",
condition: { type: "layer_name", operator: "contains", value: "wän" },
actions: { linetypeId: "dashed" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).linetypeId).toBe("dashed");
});
});
+131
View File
@@ -0,0 +1,131 @@
// Grafische Overrides — reine Auswertungs-Engine (Regel-Engine, DOSSIER A1).
//
// Wertet die Regelliste `Project.overrideRules` für ein Element aus und
// liefert die effektiven Aktionen (Farbe/Strichstärke/Linienstil). Die Regeln
// werden ADDITIV angewendet: pro Aktions-Feld gewinnt die OBERSTE (erste)
// aktive Regel, deren Bedingung matcht; spätere Regeln füllen nur noch die
// verbleibenden Felder. Deaktivierte Regeln werden übersprungen.
//
// Reines Rendering-Overlay: die Engine kennt weder Project noch Renderer —
// der Aufrufer (plan/generatePlan.ts) baut das Subjekt aus Ebene + Element
// und legt das Ergebnis als oberste Schicht ÜBER die reguläre
// By-Layer/By-Object-Attribut-Auflösung. Elementdaten bleiben unverändert.
import type {
OverrideActions,
OverrideCondition,
OverrideRule,
} from "../model/types";
/**
* Das Subjekt einer Auswertung die vergleichbaren Eigenschaften EINES
* Elements. `layer_name`-Bedingungen matchen gegen Namen ODER Code der Ebene
* (LayerCategory), `object_name`-Bedingungen gegen den Element-/Typ-Namen.
*/
export interface OverrideSubject {
/** Name der Ebene (LayerCategory.name) des Elements. */
layerName?: string;
/** Code der Ebene (LayerCategory.code / Element.categoryCode). */
layerCode?: string;
/** Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name, Öffnungs-Label). */
objectName?: string;
}
/** Leere Aktionen (kein Override) — geteilte Konstante für den Schnellpfad. */
export const EMPTY_OVERRIDES: OverrideActions = Object.freeze({});
/** Case-insensitiver Feld-Vergleich eines Operators. */
function matchValue(
operator: OverrideCondition["operator"],
candidate: string,
value: string,
): boolean {
switch (operator) {
case "equals":
return candidate === value;
case "contains":
return candidate.includes(value);
case "starts_with":
return candidate.startsWith(value);
case "not_equals":
return candidate !== value;
}
}
/**
* Prüft, ob eine Bedingung auf ein Subjekt zutrifft (case-insensitiv,
* Wert getrimmt). `layer_name` vergleicht gegen Ebenen-NAME und -CODE:
* positive Operatoren (equals/contains/starts_with) treffen, wenn EINER der
* beiden passt; `not_equals` trifft nur, wenn KEINER der beiden gleich ist
* (sonst würde der Code fast jede Namens-Ungleichheit erfüllen).
*/
export function matchesCondition(
condition: OverrideCondition,
subject: OverrideSubject,
): boolean {
const value = condition.value.trim().toLowerCase();
const candidates: string[] = [];
if (condition.type === "layer_name") {
if (subject.layerName != null) candidates.push(subject.layerName.trim().toLowerCase());
if (subject.layerCode != null) candidates.push(subject.layerCode.trim().toLowerCase());
} else {
if (subject.objectName != null) candidates.push(subject.objectName.trim().toLowerCase());
}
if (candidates.length === 0) {
// Subjekt trägt die Eigenschaft nicht: positive Operatoren können nicht
// treffen; „ungleich" ist trivial erfüllt.
return condition.operator === "not_equals";
}
if (condition.operator === "not_equals") {
return candidates.every((c) => c !== value);
}
return candidates.some((c) => matchValue(condition.operator, c, value));
}
/**
* Kollabiert die Regelliste für ein Subjekt zu den effektiven Aktionen.
* Additiv, oberste Regel gewinnt PRO FELD: die erste aktive, matchende Regel,
* die ein Feld setzt, bestimmt es; weitere Regeln füllen nur offene Felder.
* Ohne Treffer (oder ohne Regeln) kommt {@link EMPTY_OVERRIDES} zurück.
*/
export function effectiveOverrides(
rules: readonly OverrideRule[] | undefined,
subject: OverrideSubject,
): OverrideActions {
if (!rules || rules.length === 0) return EMPTY_OVERRIDES;
let out: OverrideActions | null = null;
for (const rule of rules) {
if (!rule.enabled) continue;
if (!matchesCondition(rule.condition, subject)) continue;
const a = rule.actions;
if (a.color === undefined && a.lineweight === undefined && a.linetypeId === undefined) {
continue;
}
if (!out) out = {};
if (out.color === undefined && a.color !== undefined) out.color = a.color;
if (out.lineweight === undefined && a.lineweight !== undefined) {
out.lineweight = a.lineweight;
}
if (out.linetypeId === undefined && a.linetypeId !== undefined) {
out.linetypeId = a.linetypeId;
}
// Alle Felder belegt → keine weitere Regel kann noch etwas beitragen.
if (
out.color !== undefined &&
out.lineweight !== undefined &&
out.linetypeId !== undefined
) {
break;
}
}
return out ?? EMPTY_OVERRIDES;
}
/** True, wenn mindestens eine Aktion gesetzt ist (Schnelltest für Aufrufer). */
export function hasOverrides(actions: OverrideActions): boolean {
return (
actions.color !== undefined ||
actions.lineweight !== undefined ||
actions.linetypeId !== undefined
);
}
+4
View File
@@ -39,6 +39,8 @@ import {
OpeningSection, OpeningSection,
StairSection, StairSection,
RoomSection, RoomSection,
ExtrudedSolidSection,
ColumnSection,
} from "./ObjectInfoPanel"; } from "./ObjectInfoPanel";
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld: /** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
@@ -315,6 +317,8 @@ export function AttributesPanel() {
{sel.opening && <OpeningSection opening={sel.opening} host={host} />} {sel.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />} {sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />} {sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
{sel.extrudedSolid && <ExtrudedSolidSection solid={sel.extrudedSolid} host={host} />}
{sel.column && <ColumnSection column={sel.column} host={host} />}
</div> </div>
); );
} }
+287
View File
@@ -0,0 +1,287 @@
// Inhalts-Panel „Elemente" — BIM-Baum Geschoss → Bauteilklasse → Element
// (ROADMAP §11 „Element-Übersicht", Phase 1).
//
// Datenquelle: `scheduleRows()` (export/exportSchedule.ts) — dieselbe
// Auswertung wie die Bauteilliste (CSV-Export), hier aber live zu einem
// klick-/zoombaren Baum gruppiert statt exportiert. Legacy-„Tür"-Datensätze
// (project.doors) werden ausgeblendet: dafür existiert aktuell KEIN
// Auswahl-Kanal (nichts legt sie mehr an, siehe evalDoor-Kommentar) — echte
// Türen laufen als Öffnung (`Opening.kind === "door"`) und erscheinen hier
// als Bauteilklasse „Öffnung".
//
// Klick selektiert das Element (leert alle anderen Auswahl-Kanäle, wechselt
// bei Bedarf das Geschoss); Shift-Klick oder Doppelklick selektiert UND passt
// die Plan-Ansicht ein. Auf-/zuklappbare Geschoss- und Klassen-Knoten, ein
// Textfilter oben durchsucht Name/Typ/Klasse.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ScheduleKind, ScheduleRow } from "./host";
import { scheduleRows } from "../export/exportSchedule";
import { t } from "../i18n";
/** Bauteilklassen, die im Baum erscheinen (Reihenfolge = Anzeigereihenfolge). */
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
"Wand",
"Decke",
"Fenster",
"Öffnung",
"Treppe",
"Extrusion",
];
/** i18n-Key des Klassen-Labels je Bauteilklasse. */
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
Wand: "elements.kind.wall",
Decke: "elements.kind.ceiling",
Fenster: "elements.kind.window",
Öffnung: "elements.kind.opening",
Treppe: "elements.kind.stair",
Extrusion: "elements.kind.extrusion",
};
/** Kurzform einer ID für die Anzeige (voller Wert bleibt im Tooltip). */
function shortId(id: string): string {
return id.length <= 14 ? id : `${id.slice(0, 4)}${id.slice(-6)}`;
}
/** Ein Geschoss-Knoten des Baums (auch für Elemente ohne bekanntes Geschoss). */
interface FloorGroup {
/** Gruppierungsschlüssel: die Geschoss-ID, oder ein Namens-Fallback. */
key: string;
/** Anzeigename (Geschoss-Name bzw. bereits aufgelöster Fallback aus row.floor). */
name: string;
rows: ScheduleRow[];
}
/** Gruppiert Zeilen nach Geschoss, in der Reihenfolge der Zeichnungsebenen (oben→unten). */
function buildFloorGroups(
rows: ScheduleRow[],
levels: { id: string; name: string }[],
): FloorGroup[] {
const byKey = new Map<string, ScheduleRow[]>();
for (const r of rows) {
const key = r.floorId ?? `name:${r.floor}`;
const list = byKey.get(key);
if (list) list.push(r);
else byKey.set(key, [r]);
}
// Oberstes Geschoss zuoberst, wie im Zeichnungsebenen-Panel.
const levelsTopDown = [...levels].reverse();
const groups: FloorGroup[] = [];
const used = new Set<string>();
for (const lvl of levelsTopDown) {
const list = byKey.get(lvl.id);
if (list && list.length) {
groups.push({ key: lvl.id, name: lvl.name, rows: list });
used.add(lvl.id);
}
}
// Rest (Elemente ohne bekannte Zeichnungsebene, z. B. verwaiste Referenz).
for (const [key, list] of byKey) {
if (used.has(key)) continue;
groups.push({ key, name: list[0].floor, rows: list });
}
return groups;
}
/** Ob eine Zeile zum Suchtext passt (Name/Typ/Klasse, case-insensitive). */
function matchesQuery(row: ScheduleRow, classLabel: string, query: string): boolean {
const haystack = `${row.typeName} ${classLabel} ${row.id}`.toLowerCase();
return haystack.includes(query);
}
export function ElementTreePanel() {
const host = usePanelHost();
const [query, setQuery] = useState("");
const [collapsedFloors, setCollapsedFloors] = useState<Set<string>>(new Set());
const [collapsedClasses, setCollapsedClasses] = useState<Set<string>>(new Set());
const allRows = useMemo(
() => scheduleRows(host.project).filter((r) => r.kind !== "Tür"),
[host.project],
);
const q = query.trim().toLowerCase();
const searching = q.length > 0;
const visibleRows = useMemo(() => {
if (!searching) return allRows;
return allRows.filter((r) =>
matchesQuery(r, t(CLASS_LABEL_KEY[r.kind as Exclude<ScheduleKind, "Tür">]), q),
);
}, [allRows, q, searching]);
const floorGroups = useMemo(
() => buildFloorGroups(visibleRows, host.project.drawingLevels),
[visibleRows, host.project.drawingLevels],
);
const toggleFloor = (key: string) =>
setCollapsedFloors((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleClass = (key: string) =>
setCollapsedClasses((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const onRowSelect = (row: ScheduleRow, zoom: boolean) => {
host.onSelectScheduleRow(row.kind, row.id, row.floorId, { zoom });
};
if (allRows.length === 0) {
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.empty")}
</div>
</section>
);
}
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "0 6px 6px" }}>
<input
type="text"
value={query}
placeholder={t("elements.search")}
onChange={(e) => setQuery(e.target.value)}
style={{
width: "100%",
boxSizing: "border-box",
background: "var(--input)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--label)",
fontFamily: "inherit",
fontSize: 11,
padding: "4px 8px",
}}
/>
</div>
{floorGroups.length === 0 ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.noMatch")}
</div>
) : (
floorGroups.map((floor) => {
const floorOpen = searching || !collapsedFloors.has(floor.key);
return (
<div className="level-cat" key={floor.key}>
<header
className="level-cat-head"
onClick={() => toggleFloor(floor.key)}
>
<span
className={`level-cat-chevron${floorOpen ? " open" : ""}`}
aria-hidden="true"
>
</span>
<span className="level-cat-title">{floor.name}</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{floor.rows.length}
</span>
</header>
{floorOpen && (
<div className="nav-list">
{CLASS_ORDER.map((kind) => {
const rowsOfKind = floor.rows.filter((r) => r.kind === kind);
if (rowsOfKind.length === 0) return null;
const classKey = `${floor.key}::${kind}`;
const classOpen = searching || !collapsedClasses.has(classKey);
return (
<div key={classKey}>
<header
className="level-cat-head"
style={{ paddingLeft: 20, minHeight: 20 }}
onClick={() => toggleClass(classKey)}
>
<span
className={`level-cat-chevron${classOpen ? " open" : ""}`}
aria-hidden="true"
>
</span>
<span className="level-cat-title" style={{ fontSize: 9 }}>
{t(CLASS_LABEL_KEY[kind])}
</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{rowsOfKind.length}
</span>
</header>
{classOpen && (
<div className="nav-list">
{rowsOfKind.map((row) => (
<div
key={row.id}
className="nav-row"
style={{ paddingLeft: 34 }}
title={row.id}
onClick={(e) => onRowSelect(row, e.shiftKey)}
onDoubleClick={() => onRowSelect(row, true)}
>
<span className="nav-label">{row.typeName}</span>
<span className="nav-elev">{shortId(row.id)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})
)}
</section>
);
}
/** Tab-Symbol „Elemente" — kleiner Baum (Ast + drei Blätter). */
export function ElementsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<line x1="3" y1="2.5" x2="3" y2="13.5" />
<line x1="3" y1="5" x2="8" y2="5" />
<line x1="3" y1="8" x2="8" y2="8" />
<line x1="3" y1="11" x2="8" y2="11" />
<circle cx="11" cy="5" r="1.6" />
<circle cx="11" cy="8" r="1.6" />
<circle cx="11" cy="11" r="1.6" />
</svg>
);
}
+912
View File
@@ -0,0 +1,912 @@
// Inhalts-Panel „Layouts" — Druck-/Plan-Blätter als ORDNER-/BAUM-Struktur
// (DOSSIER A3, ROADMAP §11). Ordner enthalten Layouts (Verschachtelung möglich);
// Masterlayouts bilden einen eigenen Abschnitt (Vorlagen, kein Ordner-Mitglied).
//
// Ein einziges „+"-Menü oben legt Ordner/Layout/Masterlayout an; Layout/Master
// öffnen zuvor einen Grössen-Dialog. Neu angelegte Elemente landen im aktiven
// Ordner (Klick auf eine Ordnerzeile) und werden SOFORT inline umbenennbar
// (autofocus, Enter bestätigt — KEIN window.prompt, im Tauri-WKWebView deaktiviert).
// Ein Ordner lässt sich als EIN Mehrseiten-PDF exportieren.
//
// Reine Verdrahtung über den Host-Context (usePanelHost); Mutationslogik in
// App.tsx (setProject), pure CRUD/Baum-Aufbau in panels/layoutModel.ts.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { usePanelHost } from "./host";
import type {
Layout,
LayoutOrientation,
LayoutPaperFormat,
MasterLayout,
} from "../model/types";
import {
buildLayoutTree,
buildMasterTree,
PAPER_FORMAT_GROUPS,
type FolderNode,
type MasterFolderNode,
} from "./layoutModel";
import {
NewLayoutDialog,
NewMasterLayoutDialog,
type LayoutSizeStart,
type NewLayoutStart,
} from "../ui/LayoutCreateDialogs";
import { t } from "../i18n";
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
function TreeIcon({ children }: { children: React.ReactNode }) {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto" }}
>
{children}
</svg>
);
}
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
function FolderIcon({ open }: { open: boolean }) {
return (
<TreeIcon>
{open ? (
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
) : (
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
)}
</TreeIcon>
);
}
/** Layout-Blatt — Papier-Rechteck mit ein paar Inhaltslinien. */
function LayoutSheetIcon() {
return (
<TreeIcon>
<rect x="3.5" y="2" width="9" height="12" rx="1" />
<line x1="5.5" y1="5" x2="10.5" y2="5" />
<line x1="5.5" y1="7.5" x2="10.5" y2="7.5" />
<line x1="5.5" y1="10" x2="8.5" y2="10" />
</TreeIcon>
);
}
/** Masterlayout — Papier mit Stern-Akzent (Vorlage), hebt sich vom Blatt ab. */
function MasterSheetIcon() {
return (
<TreeIcon>
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M8 5l0.9 1.9 2 0.2-1.5 1.4 0.5 2L8 9.6 6.1 10.5l0.5-2L5.1 7.1l2-0.2z" />
</TreeIcon>
);
}
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
function FolderPlusIcon() {
return (
<TreeIcon>
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
<path d="M2 4.2v9.3h6.5" />
<line x1="11.5" y1="9" x2="11.5" y2="14" />
<line x1="9" y1="11.5" x2="14" y2="11.5" />
</TreeIcon>
);
}
/** „Erstellen" — reines Plus (Layout/Masterlayout). */
function PlusIcon() {
return (
<TreeIcon>
<line x1="8" y1="3" x2="8" y2="13" />
<line x1="3" y1="8" x2="13" y2="8" />
</TreeIcon>
);
}
/** Welche Zeilenart gerade inline umbenannt wird (Commit-Handler unterscheidet sich). */
type RenameKind = "folder" | "layout" | "master";
interface Renaming {
id: string;
kind: RenameKind;
}
/** Ein Inline-Rename-Input (Muster wie ViewSnapshotsPanel: autoFocus/Enter/Escape/Blur). */
function RenameInput({
value,
onChange,
onCommit,
onCancel,
}: {
value: string;
onChange: (v: string) => void;
onCommit: () => void;
onCancel: () => void;
}) {
return (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={value}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit();
else if (e.key === "Escape") onCancel();
}}
onBlur={onCommit}
/>
);
}
/** Kompakte Paper/Orientation-Auswahl (Master-Editor). Setzt Custom zurück. */
function PaperControls({
paper,
orientation,
onPaper,
onOrientation,
}: {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
onPaper: (p: LayoutPaperFormat) => void;
onOrientation: (o: LayoutOrientation) => void;
}) {
return (
<>
<select
className="res-select"
value={paper}
title={t("layouts.paper")}
onChange={(e) => onPaper(e.target.value as LayoutPaperFormat)}
>
{PAPER_FORMAT_GROUPS.map((g) => (
<optgroup
key={g.label}
label={g.label === "A" ? t("layouts.paperGroup.a") : t("layouts.paperGroup.b")}
>
{g.formats.map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
</option>
))}
</optgroup>
))}
</select>
<select
className="res-select"
value={orientation}
title={t("layouts.orientation")}
onChange={(e) => onOrientation(e.target.value as LayoutOrientation)}
>
<option value="portrait">{t("layouts.portrait")}</option>
<option value="landscape">{t("layouts.landscape")}</option>
</select>
</>
);
}
function MasterRow({
master,
depth,
renaming,
renameDraft,
setRenameDraft,
startRename,
commitRename,
cancelRename,
}: {
master: MasterLayout;
depth: number;
renaming: boolean;
renameDraft: string;
setRenameDraft: (v: string) => void;
startRename: (e: React.MouseEvent) => void;
commitRename: () => void;
cancelRename: () => void;
}) {
const host = usePanelHost();
const [open, setOpen] = useState(false);
const tb = master.titleBlock;
const onDelete = (e: React.MouseEvent) => {
e.stopPropagation();
host.onDeleteMasterLayout(master.id);
};
const field = (key: keyof typeof tb, label: string) => (
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 11,
color: "var(--muted)",
}}
>
<span style={{ flex: "0 0 82px" }}>{label}</span>
<input
type="text"
className="res-input"
style={{ flex: 1 }}
value={tb[key] ?? ""}
placeholder={t("layouts.tb.autoPlaceholder")}
onChange={(e) =>
host.onPatchMasterLayout(master.id, {
titleBlock: { ...tb, [key]: e.target.value },
})
}
/>
</label>
);
return (
<div>
<div
className="nav-row"
onClick={() => setOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
paddingLeft: 10 + depth * 14,
}}
>
{renaming ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<span style={{ flex: "0 0 auto" }}>{open ? "▾" : "▸"}</span>
<MasterSheetIcon />
{master.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.rename")}
onClick={startRename}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.delete")}
onClick={onDelete}
>
</button>
</div>
{open && (
<div
style={{ display: "flex", flexDirection: "column", gap: 5, padding: "6px 10px 10px" }}
>
<div style={{ display: "flex", gap: 6 }}>
<PaperControls
paper={master.paper}
orientation={master.orientation}
onPaper={(p) =>
host.onPatchMasterLayout(master.id, {
paper: p,
customWidthMm: undefined,
customHeightMm: undefined,
})
}
onOrientation={(o) =>
host.onPatchMasterLayout(master.id, {
orientation: o,
customWidthMm: undefined,
customHeightMm: undefined,
})
}
/>
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 11,
color: "var(--muted)",
}}
>
<input
type="checkbox"
checked={master.border ?? true}
onChange={(e) =>
host.onPatchMasterLayout(master.id, { border: e.target.checked })
}
/>
{t("layouts.border")}
</label>
</div>
{field("projectName", t("layouts.tb.projectName"))}
{field("sheetName", t("layouts.tb.sheetName"))}
{field("scale", t("layouts.tb.scale"))}
{field("date", t("layouts.tb.date"))}
{field("author", t("layouts.tb.author"))}
</div>
)}
</div>
);
}
export function LayoutsPanel() {
const host = usePanelHost();
const layouts = host.project.layouts ?? [];
const masters = host.project.masterLayouts ?? [];
const folders = host.project.layoutFolders ?? [];
// Getrennte Bäume je `kind` — Layout- und Master-Ordner werden NIE vermischt.
const layoutFolders = folders.filter((f) => f.kind !== "master");
const masterFolders = folders.filter((f) => f.kind === "master");
const tree = buildLayoutTree(layoutFolders, layouts);
const masterTree = buildMasterTree(masterFolders, masters);
// Aktiver Ordner = Ziel neuer Elemente (Klick auf eine Ordnerzeile). Null = Wurzel.
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
// Aktiver Master-Ordner (eigener Zustand — Ziel neuer Master/Master-Ordner).
const [activeMasterFolderId, setActiveMasterFolderId] = useState<string | null>(null);
// Auf-/zugeklappte Ordner.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Inline-Rename (welche Zeile + Entwurf).
const [renaming, setRenaming] = useState<Renaming | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Offener Erstell-Dialog.
const [dialog, setDialog] = useState<null | "layout" | "master">(null);
// Drag&Drop: gezogenes Element + gerade überfahrenes Drop-Ziel (Ordner-Id
// oder "root" für die Wurzel-Ablagefläche).
const [dragItem, setDragItem] = useState<{
kind: "layout" | "folder";
id: string;
} | null>(null);
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
const endDrag = () => {
setDragItem(null);
setDragOverId(null);
};
const dropOnFolder = (targetFolderId: string) => {
if (!dragItem) return;
if (dragItem.kind === "layout") {
host.onMoveLayoutToFolder(dragItem.id, targetFolderId);
} else if (dragItem.id !== targetFolderId) {
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
host.onMoveFolderToFolder(dragItem.id, targetFolderId);
}
expandFolder(targetFolderId);
endDrag();
};
const dropOnRoot = () => {
if (!dragItem) return;
if (dragItem.kind === "layout") host.onMoveLayoutToFolder(dragItem.id, null);
else host.onMoveFolderToFolder(dragItem.id, null);
endDrag();
};
const isExpanded = (id: string) => !collapsed.has(id);
const toggleFolder = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandFolder = (id: string) =>
setCollapsed((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
setRenaming({ id, kind });
setRenameDraft(currentName);
};
const commitRename = () => {
if (!renaming) return;
const name = renameDraft.trim();
if (name) {
if (renaming.kind === "folder") host.onRenameLayoutFolder(renaming.id, name);
else if (renaming.kind === "layout") host.onRenameLayout(renaming.id, name);
else host.onRenameMasterLayout(renaming.id, name);
}
setRenaming(null);
};
const cancelRename = () => setRenaming(null);
const renamingRow = (id: string, kind: RenameKind) =>
renaming?.id === id && renaming.kind === kind;
// ── Anlegen (zwei getrennte Buttons je Abschnitt) ─────────────────────────
const addFolder = () => {
const id = host.onAddLayoutFolder(activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
beginRename(id, "folder", t("layouts.folder.defaultName"));
};
const addMasterFolder = () => {
const id = host.onAddMasterFolder(activeMasterFolderId ?? undefined);
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
beginRename(id, "folder", t("layouts.folder.defaultName"));
};
const createLayoutFromDialog = (start: NewLayoutStart) => {
const id = host.onCreateLayout({
folderId: activeFolderId ?? undefined,
masterId: start.masterId,
paper: start.paper,
orientation: start.orientation,
customWidthMm: start.customWidthMm,
customHeightMm: start.customHeightMm,
});
if (activeFolderId) expandFolder(activeFolderId);
setDialog(null);
beginRename(id, "layout", t("layouts.defaultName"));
};
const createMasterFromDialog = (start: LayoutSizeStart) => {
const id = host.onCreateMasterLayout({
folderId: activeMasterFolderId ?? undefined,
paper: start.paper,
orientation: start.orientation,
customWidthMm: start.customWidthMm,
customHeightMm: start.customHeightMm,
});
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
setDialog(null);
beginRename(id, "master", t("layouts.defaultMasterName"));
};
// ── Baum-Zeilen ─────────────────────────────────────────────────────────
const LayoutRow = (layout: Layout, depth: number) => (
<div
key={layout.id}
className="nav-row"
title={t("layouts.openHint", { name: layout.name })}
draggable={!renamingRow(layout.id, "layout")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "layout", id: layout.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onClick={() =>
renamingRow(layout.id, "layout") ? undefined : host.onOpenLayout(layout.id)
}
onDoubleClick={() => host.onOpenLayout(layout.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
{renamingRow(layout.id, "layout") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<LayoutSheetIcon />
{layout.name}
</span>
)}
<span style={{ fontSize: 9, color: "var(--muted)" }}>
{(layout.customWidthMm && layout.customHeightMm
? `${Math.round(layout.customWidthMm)}×${Math.round(layout.customHeightMm)}`
: layout.paper.toUpperCase())}{" "}
· {layout.viewports.length}
</span>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(layout.id, "layout", layout.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.delete")}
onClick={(e) => {
e.stopPropagation();
host.onDeleteLayout(layout.id);
}}
>
</button>
</div>
);
const FolderRowTree = (node: FolderNode, depth: number): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeFolderId === folder.id;
const empty = node.layouts.length === 0 && node.subfolders.length === 0;
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
const dragOver = dragOverId === folder.id && !isDragged;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
title={folder.name}
draggable={!renamingRow(folder.id, "folder")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "folder", id: folder.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (!dragItem) return;
e.stopPropagation();
if (isDragged) {
if (dragOverId !== null) setDragOverId(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== folder.id) setDragOverId(folder.id);
}}
onDragLeave={() => {
if (dragOverId === folder.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
dropOnFolder(folder.id);
}}
onClick={() => setActiveFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.export")}
onClick={(e) => {
e.stopPropagation();
host.onExportFolderPdf(folder.id);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeFolderId === folder.id) setActiveFolderId(null);
host.onDeleteLayoutFolder(folder.id);
}}
>
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
{node.layouts.map((l) => LayoutRow(l, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("layouts.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
// ── Master-Baum-Zeilen (eigener Baum; kein Drag&Drop, kein PDF-Export) ─────
const renderMasterRow = (m: MasterLayout, depth: number) => (
<MasterRow
key={m.id}
master={m}
depth={depth}
renaming={renamingRow(m.id, "master")}
renameDraft={renameDraft}
setRenameDraft={setRenameDraft}
startRename={(e) => {
e.stopPropagation();
beginRename(m.id, "master", m.name);
}}
commitRename={commitRename}
cancelRename={cancelRename}
/>
);
const MasterFolderRowTree = (node: MasterFolderNode, depth: number): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeMasterFolderId === folder.id;
const empty = node.masters.length === 0 && node.subfolders.length === 0;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}`}
title={folder.name}
onClick={() => setActiveMasterFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeMasterFolderId === folder.id) setActiveMasterFolderId(null);
host.onDeleteMasterFolder(folder.id);
}}
>
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => MasterFolderRowTree(sub, depth + 1))}
{node.masters.map((m) => renderMasterRow(m, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("layouts.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
const treeEmpty = tree.rootFolders.length === 0 && tree.rootLayouts.length === 0;
const masterTreeEmpty =
masterTree.rootFolders.length === 0 && masterTree.rootMasters.length === 0;
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("layouts.title")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.folder")}
onClick={addFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.layout")}
onClick={() => setDialog("layout")}
>
<PlusIcon />
</button>
</span>
</header>
{treeEmpty ? (
<div style={{ padding: "8px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("layouts.tree.empty")}
</div>
) : (
<div
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
onDragOver={(e) => {
if (!dragItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== "root") setDragOverId("root");
}}
onDragLeave={(e) => {
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dragOverId === "root") setDragOverId(null);
}
}}
onDrop={(e) => {
e.preventDefault();
dropOnRoot();
}}
>
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
{tree.rootLayouts.map((l) => LayoutRow(l, 0))}
</div>
)}
{/* Master-Sektion — eigener Ordner-Baum (Master-Ordner, kind:"master"). */}
<header className="nav-group-head" style={{ marginTop: 6 }}>
<span className="nav-group-title">{t("layouts.masters")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.folder")}
onClick={addMasterFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.master")}
onClick={() => setDialog("master")}
>
<PlusIcon />
</button>
</span>
</header>
{masterTreeEmpty ? (
<div style={{ padding: "4px 10px 8px", fontSize: 12, color: "var(--muted)" }}>
{t("layouts.noMasters")}
</div>
) : (
<div className="nav-list">
{masterTree.rootFolders.map((node) => MasterFolderRowTree(node, 0))}
{masterTree.rootMasters.map((m) => renderMasterRow(m, 0))}
</div>
)}
{dialog === "layout" && (
<NewLayoutDialog
masters={masters}
onCreate={createLayoutFromDialog}
onClose={() => setDialog(null)}
/>
)}
{dialog === "master" && (
<NewMasterLayoutDialog
onCreate={createMasterFromDialog}
onClose={() => setDialog(null)}
/>
)}
</section>
);
}
/** Tab-Symbol „Layouts" — gestapelte Blätter. */
export function LayoutsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="4.5" y="2.5" width="9" height="11" rx="1" />
<path d="M2.5 4.5v9h8" />
</svg>
);
}
+382 -63
View File
@@ -19,6 +19,7 @@ import { t } from "../i18n";
import { formatM } from "../model/types"; import { formatM } from "../model/types";
import type { import type {
SiaCategory, SiaCategory,
SliceTermination,
StairShape, StairShape,
VerticalAnchor, VerticalAnchor,
WallReferenceLine, WallReferenceLine,
@@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import type { import type {
CeilingInfo, CeilingInfo,
ColumnInfo,
ExtrudedSolidInfo,
OpeningInfo, OpeningInfo,
RoomInfo, RoomInfo,
StairInfo, StairInfo,
@@ -68,11 +71,24 @@ export function ObjectInfoPanel() {
const width = bbox.maxX - bbox.minX; const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY; const height = bbox.maxY - bbox.minY;
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt // X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket. // verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die
// eingegebene Koordinate wandert (Delta = neu aktuell → generischer Move
// über den Host). Bei Elementarten, die der Transform-Kern nicht kennt
// (Decke/Öffnung/Treppe/Raum), ist der Host-Aufruf ein No-op — das Feld
// zeigt danach wieder den unveränderten Wert (wie bei Breite/Höhe oben).
const px = bbox.minX + anchor.fx * width; const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height; const py = bbox.minY + anchor.fy * height;
// Rohe Drawing2D-Geometrie der Selektion (nur bei kind==="drawing2d") — für
// die Linien-Länge/Kreis-Radius-Felder unten; die Selection-Sicht trägt
// keine Geometrieform, daher direkter (Lese-)Zugriff aufs Projekt.
const rawDrawing =
sel.kind === "drawing2d"
? host.project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const drawingShape = rawDrawing?.geom.shape;
// ── Maß-Commit ────────────────────────────────────────────────────────────── // ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven // Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize). // Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
@@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
<div className="objinfo-sep" /> <div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */} {/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
rechts daneben (Nutzer-Wunsch). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div> <div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}> <div className="objinfo-refrow">
{FRACTIONS.map((fy) => <div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
FRACTIONS.map((fx) => { {FRACTIONS.map((fy) =>
const active = anchor.fx === fx && anchor.fy === fy; FRACTIONS.map((fx) => {
return ( const active = anchor.fx === fx && anchor.fy === fy;
<button return (
key={anchorKey(fx, fy)} <button
type="button" key={anchorKey(fx, fy)}
className={"objinfo-anchor" + (active ? " active" : "")} type="button"
title={t("objinfo.setRefpoint")} className={"objinfo-anchor" + (active ? " active" : "")}
aria-pressed={active} title={t("objinfo.setRefpoint")}
onClick={() => setAnchor({ fx, fy })} aria-pressed={active}
> onClick={() => setAnchor({ fx, fy })}
<span className="objinfo-dot" /> >
</button> <span className="objinfo-dot" />
); </button>
}), );
)} }),
</div> )}
</div>
{/* X / Y des gewählten Bezugspunkts (read-only). */} {/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
<div className="objinfo-field"> Dreh-Feld, als Spalte. */}
<span className="objinfo-flabel">{t("objinfo.x")}</span> <div className="objinfo-xyzcol">
<span className="objinfo-fval">{formatM(px)}</span> <DimensionField
</div> label={t("objinfo.x")}
<div className="objinfo-field"> value={px}
<span className="objinfo-flabel">{t("objinfo.y")}</span> min={-Infinity}
<span className="objinfo-fval">{formatM(py)}</span> onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
</div> />
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element. <DimensionField
Daher " statt einer gefakten 0, mit kurzem Hinweis im Titel. */} label={t("objinfo.y")}
<div className="objinfo-field"> value={py}
<span className="objinfo-flabel">{t("objinfo.z")}</span> min={-Infinity}
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}> onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
/>
</span> {/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher " statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
</span>
</div>
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt kein
persistenter Zustand, das Feld zeigt nach dem Commit wieder 0". */}
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
</div>
</div> </div>
<div className="objinfo-sep" /> <div className="objinfo-sep" />
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() {
value={height} value={height}
onCommit={(h) => commitSize(width, h)} onCommit={(h) => commitSize(width, h)}
/> />
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
Linie/ein Kreis ist das Modell gibt das direkt her, andere Formen
bleiben ohne Zusatzfeld). */}
{drawingShape === "line" && (
<DimensionField
label={t("objinfo.drawing.length")}
value={Math.hypot(width, height)}
onCommit={(v) => host.onSetDrawingLineLength(v)}
/>
)}
{drawingShape === "circle" && (
<DimensionField
label={t("objinfo.drawing.radius")}
value={width / 2}
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
)}
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) {/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */} die exportierten *Section-Komponenten). */}
@@ -228,6 +274,153 @@ export function RoomSection({
); );
} }
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
export function ExtrudedSolidSection({
solid,
host,
}: {
solid: ExtrudedSolidInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.extrudedSolid.section")}</div>
{/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.height")}</span>
<input
type="number"
className="objinfo-text"
step={0.01}
min={0.01}
value={solid.height}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v);
}}
title={t("objinfo.extrudedSolid.height")}
/>
</div>
{/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.taper")}</span>
<input
type="number"
className="objinfo-text"
step={0.05}
min={0}
max={1}
value={solid.taper}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v);
}}
title={t("objinfo.extrudedSolid.taper")}
/>
</div>
{/* Grundfläche (read-only, abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.area")}</span>
<span className="objinfo-fval">{solid.area.toFixed(2)} m²</span>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.floor")}</span>
<span className="objinfo-fval">{solid.floorName}</span>
</div>
</>
);
}
// ── Stützen-Attribut-Abschnitt (Tragwerk) ───────────────────────────────────
// Profil (Rechteck/Kreis) + Masse, Höhe, Drehung editierbar; Geschoss + UK/OK
// read-only.
export function ColumnSection({
column,
host,
}: {
column: ColumnInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.column.section")}</div>
{/* Profil-Dropdown (Rechteck/Kreis). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.profile")}</span>
<Dropdown
value={column.profile.kind}
onChange={(v) => host.onSetColumnProfileKind(v as "rect" | "round")}
options={[
{ value: "rect", label: t("objinfo.column.profile.rect") },
{ value: "round", label: t("objinfo.column.profile.round") },
]}
title={t("objinfo.column.profile")}
width={130}
/>
</div>
{/* Editierbare Masse je Profil. */}
{column.profile.kind === "round" ? (
<DimensionField
label={t("objinfo.column.radius")}
value={column.profile.radius}
onCommit={(v) => host.onSetColumnRadius(v)}
/>
) : (
<>
<DimensionField
label={t("objinfo.column.width")}
value={column.profile.kind === "rect" ? column.profile.width : 0}
onCommit={(v) => host.onSetColumnWidth(v)}
/>
<DimensionField
label={t("objinfo.column.depth")}
value={column.profile.kind === "rect" ? column.profile.depth : 0}
onCommit={(v) => host.onSetColumnDepth(v)}
/>
</>
)}
<DimensionField
label={t("objinfo.column.height")}
value={column.height}
onCommit={(v) => host.onSetColumnHeight(v)}
/>
{/* Drehung (Grad). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.rotation")}</span>
<input
type="number"
className="objinfo-text"
step={1}
value={Number(column.rotationDeg.toFixed(1))}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetColumnRotation(v);
}}
title={t("objinfo.column.rotation")}
/>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.floor")}</span>
<span className="objinfo-fval">{column.floorName}</span>
</div>
</>
);
}
// ── Treppen-Attribut-Abschnitt ─────────────────────────────────────────────── // ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete // Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only. // Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
@@ -243,6 +436,24 @@ export function StairSection({
<div className="objinfo-sep" /> <div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div> <div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
{/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.type")}</span>
<Dropdown
value={stair.typeId ?? ""}
onChange={(v) => host.onSetStairType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(host.project.stairTypes ?? []).map((st) => ({
value: st.id,
label: st.name,
})),
]}
title={t("objinfo.stair.type")}
width={130}
/>
</div>
{/* Grundform-Dropdown. */} {/* Grundform-Dropdown. */}
<div className="objinfo-field"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span> <span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
@@ -360,6 +571,24 @@ export function OpeningSection({
<div className="objinfo-sep" /> <div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div> <div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
<Dropdown
value={opening.typeId ?? ""}
onChange={(v) => host.onSetOpeningType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(isDoor
? host.project.doorTypes ?? []
: host.project.windowTypes ?? []
).map((ty) => ({ value: ty.id, label: ty.name })),
]}
title={t("objinfo.opening.type")}
width={130}
/>
</div>
{/* Art-Segment Fenster/Tür. */} {/* Art-Segment Fenster/Tür. */}
<div className="objinfo-field"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span> <span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
@@ -596,11 +825,15 @@ export function CeilingSection({
<span className="objinfo-fval">{ceiling.floorName}</span> <span className="objinfo-fval">{ceiling.floorName}</span>
</div> </div>
{/* Fläche (read-only). */} {/* Fläche + Volumen (read-only). */}
<div className="objinfo-field"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span> <span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span> <span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
</div> </div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span className="objinfo-fval">{ceiling.volume.toFixed(2)} m³</span>
</div>
<div className="objinfo-sep" /> <div className="objinfo-sep" />
@@ -614,11 +847,16 @@ export function CeilingSection({
onChange={(a) => host.onSetCeilingTop(a)} onChange={(a) => host.onSetCeilingTop(a)}
/> />
{/* UK = OK Dicke (abgeleitet, read-only). */} {/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override
<div className="objinfo-field"> bleibt es beim heutigen Verhalten UK = OK Dicke. */}
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span> <VerticalAnchorRow
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span> label={t("objinfo.ceiling.bottom")}
</div> anchor={ceiling.bottom}
defaultZ={ceiling.zBottom}
floors={ceiling.floors}
defaultFloorId={ceiling.floorId}
onChange={(a) => host.onSetCeilingBottom?.(a)}
/>
<div className="objinfo-field"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span> <span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span> <span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
@@ -678,6 +916,23 @@ export function WallSection({
/> />
</div> </div>
{/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand
an der Decke endet oder oberhalb wieder auftaucht. Default "both". */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.sliceTermination")}</span>
<Dropdown
value={wall.sliceTermination}
onChange={(v) => host.onSetWallSliceTermination(v as SliceTermination)}
options={[
{ value: "both", label: t("objinfo.wall.sliceTermination.both") },
{ value: "below", label: t("objinfo.wall.sliceTermination.below") },
{ value: "above", label: t("objinfo.wall.sliceTermination.above") },
]}
title={t("objinfo.wall.sliceTermination")}
width={120}
/>
</div>
{/* Aufbau-Typ-Segment. */} {/* Aufbau-Typ-Segment. */}
<div className="objinfo-field"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span> <span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
@@ -730,6 +985,26 @@ export function WallSection({
<span className="objinfo-fval">{wall.floorName}</span> <span className="objinfo-fval">{wall.floorName}</span>
</div> </div>
{/* Länge + Volumen (read-only). Volumen ist NETTO Fenster/Türen
abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.length")}</span>
<span className="objinfo-fval">{formatM(wall.length)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span
className="objinfo-fval"
title={
wall.openingVolume > 0
? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} ${wall.openingVolume.toFixed(2)}`
: undefined
}
>
{wall.netVolume.toFixed(2)} m³
</span>
</div>
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */} {/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
<label className="objinfo-field objinfo-check-field"> <label className="objinfo-field objinfo-check-field">
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span> <span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
@@ -755,15 +1030,8 @@ export function WallSection({
<div className="objinfo-sep" /> <div className="objinfo-sep" />
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */} {/* OK & UK getrennt: je an Geschoss gebunden" ODER eigene Höhe". OK
<VerticalAnchorRow (oben) steht oben, UK (unten) darunter räumlich passend (Nutzer). */}
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
<VerticalAnchorRow <VerticalAnchorRow
label={t("objinfo.wall.top")} label={t("objinfo.wall.top")}
anchor={wall.top} anchor={wall.top}
@@ -772,6 +1040,14 @@ export function WallSection({
defaultFloorId={wall.floorAbove?.id ?? wall.floorId} defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)} onChange={(a) => host.onSetWallTop(a)}
/> />
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* Höhe = OK UK (abgeleitet, read-only). */} {/* Höhe = OK UK (abgeleitet, read-only). */}
<div className="objinfo-field"> <div className="objinfo-field">
@@ -836,25 +1112,27 @@ function VerticalAnchorRow({
</button> </button>
</div> </div>
</div> </div>
{/* Unterzeile in normaler Label-/Wert-Struktur: Label links, Control in
der Wert-Spalte (Nutzer-Wunsch vorher hing das Control ohne Label
im Einzug der linken Spalte). */}
{mode === "floor" ? ( {mode === "floor" ? (
<div className="objinfo-field objinfo-subfield"> <div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.anchor.refFloor")}</span>
<Dropdown <Dropdown
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId} value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
onChange={(id) => onChange({ mode: "floor", floorId: id })} onChange={(id) => onChange({ mode: "floor", floorId: id })}
options={floors.map((f) => ({ value: f.id, label: f.name }))} options={floors.map((f) => ({ value: f.id, label: f.name }))}
title={label} title={t("objinfo.anchor.refFloor")}
width={150} width={150}
/> />
</div> </div>
) : ( ) : (
<div className="objinfo-subfield"> <DimensionField
<DimensionField label={t("objinfo.anchor.customHeight")}
label={t("objinfo.z")} value={anchor?.mode === "custom" ? anchor.z : defaultZ}
value={anchor?.mode === "custom" ? anchor.z : defaultZ} min={-Infinity}
min={-Infinity} onCommit={(z) => onChange({ mode: "custom", z })}
onCommit={(z) => onChange({ mode: "custom", z })} />
/>
</div>
)} )}
</div> </div>
); );
@@ -915,3 +1193,44 @@ function DimensionField({
</label> </label>
); );
} }
// ── Dreh-Feld (Delta-Winkel in Grad) ─────────────────────────────────────────
// Anders als DimensionField zeigt dieses Feld KEINEN persistenten Host-Wert —
// eine „aktuelle Rotation" gibt es für die Selektion generell nicht (Wand ist
// nur eine Achse, Drawing2D/Extrusion tragen keinen Rotationswinkel). Das Feld
// ist daher ein Delta: Eingabe = Drehung ab jetzt, danach zeigt es wieder „0".
function RotateField({ onCommit }: { onCommit: (deg: number) => void }) {
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? "0";
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v !== 0) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.rotate")}</span>
<input
className="objinfo-input"
type="number"
step={1}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(null);
(e.target as HTMLInputElement).blur();
}
}}
/>
<span className="objinfo-unit">°</span>
</label>
);
}
+34
View File
@@ -183,6 +183,25 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/> />
</label> </label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.roundingStep")}</span>
<select
className="roomstamp-input"
value={draft.roundingStep ?? ""}
disabled={!draft.showFloorArea}
onChange={(e) => {
const v = e.target.value;
patch({ roundingStep: v === "" ? undefined : Number(v) });
}}
>
<option value="">{t("roomStamp.roundingStepDefault")}</option>
<option value="0.01">0.01 m²</option>
<option value="0.1">0.1 m²</option>
<option value="0.5">0.5 m²</option>
<option value="1">1 m²</option>
</select>
</label>
<div className="roomstamp-row"> <div className="roomstamp-row">
<label className="roomstamp-check"> <label className="roomstamp-check">
<input <input
@@ -200,6 +219,21 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
onChange={(usageAlign) => patch({ usageAlign })} onChange={(usageAlign) => patch({ usageAlign })}
/> />
</div> </div>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.occupancy")}</span>
<input
type="number"
min={0}
step={1}
className="roomstamp-input"
value={draft.occupancy ?? ""}
onChange={(e) => {
const v = e.target.value;
patch({ occupancy: v === "" ? undefined : Number(v) });
}}
/>
</label>
</div> </div>
<div className="texteditor-foot"> <div className="texteditor-foot">
+5 -3
View File
@@ -18,6 +18,7 @@ import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types"; import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog"; import { ContextImportDialog } from "../ui/ContextImportDialog";
import { Dropdown } from "../ui/Dropdown"; import { Dropdown } from "../ui/Dropdown";
import { useStore } from "../state/appStore";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */ /** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey { function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -33,6 +34,7 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
export function SitePanel() { export function SitePanel() {
const host = usePanelHost(); const host = usePanelHost();
const notify = useStore((s) => s.notify);
const objects = host.contextObjects; const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet"); const contourSets = objects.filter((o) => o.type === "contourSet");
@@ -50,9 +52,9 @@ export function SitePanel() {
if (!effectiveSet) return; if (!effectiveSet) return;
const id = host.onGenerateTerrain(effectiveSet); const id = host.onGenerateTerrain(effectiveSet);
if (id === null) { if (id === null) {
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox). // Kein sinnvolles TIN — ruhiger Hinweis als Toast (keine harte Fehlerbox;
// eslint-disable-next-line no-alert // window.alert wäre im Tauri-WKWebView ohnehin wirkungslos).
alert(t("site.terrainFailed")); notify(t("site.terrainFailed"), "error");
} }
}; };
+22 -1
View File
@@ -124,6 +124,24 @@ export function ToolIcon({ id }: { id: ToolId }) {
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" /> <path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
</svg> </svg>
); );
case "text":
// Serifen-„A" als Text-Symbol.
return (
<svg {...common}>
<path d="M 4 13 L 8 3 L 12 13" fill="none" />
<path d="M 5.5 9.5 L 10.5 9.5" fill="none" />
</svg>
);
case "textbox":
// Spalten-Rahmen mit drei Textzeilen (Absatztext).
return (
<svg {...common}>
<rect x="2.5" y="3" width="11" height="10" fill="none" />
<line x1="4.5" y1="6" x2="11.5" y2="6" />
<line x1="4.5" y1="8.5" x2="11.5" y2="8.5" />
<line x1="4.5" y1="11" x2="9" y2="11" />
</svg>
);
default: default:
return null; return null;
} }
@@ -140,7 +158,10 @@ export function ToolsPanel() {
{TOOL_ORDER.map((id) => { {TOOL_ORDER.map((id) => {
const tool = getTool(id); const tool = getTool(id);
const active = host.activeTool === id; const active = host.activeTool === id;
const disabled = id !== "select" && !host.toolsEnabled; // 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 const title = disabled
? t("tool.floorOnlyDisabled") ? t("tool.floorOnlyDisabled")
: t(tool.hintKey(tool.init())); : t(tool.hintKey(tool.init()));
+667
View File
@@ -0,0 +1,667 @@
// Inhalts-Panel „Ausschnitte" — benannte, gespeicherte Ansichten (View-Snapshots,
// DOSSIER A2, ROADMAP §2c/§11) als ORDNER-/BAUM-Struktur (spiegelbildlich zum
// Layouts-Panel).
//
// Ein Ausschnitt fängt den kompletten Darstellungszustand ein (Ansichtstyp,
// Kamera-Preset, Massstab, Detailgrad, Ebenen-/Zeichnungssichtbarkeit, aktive
// Overrides, aktives Geschoss) und stellt ihn per Klick wieder her. Das Panel
// zeigt Ordner (auf-/zuklappbar) mit Ausschnitten als Blättern; zwei Header-
// Buttons legen einen Ordner bzw. einen neuen Ausschnitt im AKTIVEN Ordner an
// (Klick auf eine Ordnerzeile = aktiver Ordner; ohne Auswahl = Wurzel). Neue
// Ordner werden sofort inline umbenannt (autofocus/Enter — KEIN window.prompt,
// im Tauri-WKWebView deaktiviert). Ausschnitte/Ordner lassen sich per HTML5-
// Drag&Drop zwischen Ordnern verschieben (Zyklus-Schutz im Host).
//
// Die Footer-Bar (Massstab/Kombis/Overrides/Umbenennen bei angewähltem
// Ausschnitt) und die Anwahl-Persistenz (`selectedViewSnapshotId`) bleiben
// unverändert erhalten — sie hängen unter dem Baum.
//
// Reine Verdrahtung über den Host-Context (usePanelHost); die Erfassungs-/
// Anwendungslogik lebt in App.tsx (Setter/rAF) bzw. state/viewSnapshots.ts, die
// pure Baum-/Ordner-Logik in state/viewSnapshotFolders.ts.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ViewSnapshot } from "../model/types";
import {
buildViewSnapshotTree,
type ViewSnapshotFolderNode,
} from "../state/viewSnapshotFolders";
import { t } from "../i18n";
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
function TreeIcon({ children }: { children: React.ReactNode }) {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto" }}
>
{children}
</svg>
);
}
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
function FolderIcon({ open }: { open: boolean }) {
return (
<TreeIcon>
{open ? (
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
) : (
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
)}
</TreeIcon>
);
}
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
function FolderPlusIcon() {
return (
<TreeIcon>
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
<path d="M2 4.2v9.3h6.5" />
<line x1="11.5" y1="9" x2="11.5" y2="14" />
<line x1="9" y1="11.5" x2="14" y2="11.5" />
</TreeIcon>
);
}
/** „Erstellen" — reines Plus (neuer Ausschnitt). */
function PlusIcon() {
return (
<TreeIcon>
<line x1="8" y1="3" x2="8" y2="13" />
<line x1="3" y1="8" x2="13" y2="8" />
</TreeIcon>
);
}
/** Ausschnitt-Blatt — Kamera-Sucherrahmen (passt zum Tab-Symbol). */
function SnapshotIcon() {
return (
<TreeIcon>
<rect x="2.5" y="4" width="11" height="8" rx="1" />
<circle cx="8" cy="8" r="2" />
<line x1="5" y1="4" x2="5.8" y2="2.8" />
</TreeIcon>
);
}
/** Ein Inline-Rename-Input (autoFocus/Enter/Escape/Blur). */
function RenameInput({
value,
onChange,
onCommit,
onCancel,
}: {
value: string;
onChange: (v: string) => void;
onCommit: () => void;
onCancel: () => void;
}) {
return (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={value}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit();
else if (e.key === "Escape") onCancel();
}}
onBlur={onCommit}
/>
);
}
/** Deep-Equal zweier `Record<string, boolean>`-Maps (gleiche Keys UND Werte). */
function boolMapEqual(
a: Record<string, boolean>,
b: Record<string, boolean>,
): boolean {
const ka = Object.keys(a);
if (ka.length !== Object.keys(b).length) return false;
for (const k of ka) {
if (a[k] !== b[k]) return false;
}
return true;
}
/**
* Sucht unter den gespeicherten Kombinationen (`names` + `load`) diejenige,
* deren Map EXAKT `target` entspricht liefert den Namen oder `null`. Grundlage
* der Footer-Anzeige welche Ebenen-/Zeichnungskombi war aktiv".
*/
function matchingComboName(
names: string[],
load: (name: string) => Record<string, boolean> | null,
target: Record<string, boolean>,
): string | null {
for (const name of names) {
const map = load(name);
if (map && boolMapEqual(map, target)) return name;
}
return null;
}
/** Welche Zeilenart gerade inline umbenannt wird (Commit unterscheidet sich). */
type RenameKind = "folder" | "snapshot";
interface Renaming {
id: string;
kind: RenameKind;
}
export function ViewSnapshotsPanel() {
const host = usePanelHost();
const snaps = host.project.viewSnapshots ?? [];
const folders = host.project.viewSnapshotFolders ?? [];
const tree = useMemo(
() => buildViewSnapshotTree(folders, snaps),
[folders, snaps],
);
// In-App-Eingabe statt window.prompt (im Tauri-WKWebView deaktiviert → gäbe
// null zurück, nichts würde gespeichert). `naming` = neuer Ausschnitt.
const [naming, setNaming] = useState(false);
const [draft, setDraft] = useState("");
// Inline-Rename einer Baumzeile (Ordner ODER Ausschnitt) + Entwurf.
const [renaming, setRenaming] = useState<Renaming | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Inline-Umbenennen DIREKT in der Footer-Bar (eigener Draft, damit Liste und
// Footer sich nicht ins Gehege kommen). `footerRenaming` = Bearbeitung aktiv.
const [footerRenaming, setFooterRenaming] = useState(false);
const [footerDraft, setFooterDraft] = useState("");
// Aktiver Ordner = Ziel neuer Ausschnitte/Ordner (Klick auf Ordnerzeile). Null = Wurzel.
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
// Auf-/zugeklappte Ordner.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Drag&Drop: gezogenes Element + überfahrenes Drop-Ziel (Ordner-Id oder "root").
const [dragItem, setDragItem] = useState<{
kind: "snapshot" | "folder";
id: string;
} | null>(null);
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
// Der aktuell „angewählte" Ausschnitt (bleibt markiert, bis App bei einer
// Abweichung `selectedViewSnapshotId` auf null setzt). Footer-Bar nur sichtbar,
// solange dieser existiert.
const selectedId = host.selectedViewSnapshotId;
const selectedSnap = useMemo(
() => (selectedId ? snaps.find((s) => s.id === selectedId) ?? null : null),
[selectedId, snaps],
);
// Footer-Auflösung: Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal auf
// die gespeicherte Map) und die Namen der aktiven Override-Regeln.
const footer = useMemo(() => {
if (!selectedSnap) return null;
const layerCombo = matchingComboName(
host.listLayerCombos(),
host.loadLayerCombo,
selectedSnap.layerVisibility,
);
const drawingCombo = matchingComboName(
host.listDrawingCombos(),
host.loadDrawingCombo,
selectedSnap.drawingVisibility,
);
const ids = new Set(selectedSnap.enabledOverrideRuleIds ?? []);
const overrideNames = (host.project.overrideRules ?? [])
.filter((r) => ids.has(r.id))
.map((r) => r.name);
return { layerCombo, drawingCombo, overrideNames };
}, [selectedSnap, host]);
// ── Auf-/Zuklappen ────────────────────────────────────────────────────────
const isExpanded = (id: string) => !collapsed.has(id);
const toggleFolder = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandFolder = (id: string) =>
setCollapsed((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// ── Inline-Rename (Baumzeilen) ──────────────────────────────────────────────
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
setRenaming({ id, kind });
setRenameDraft(currentName);
};
const commitRename = () => {
if (!renaming) return;
const name = renameDraft.trim();
if (name) {
if (renaming.kind === "folder")
host.onRenameViewSnapshotFolder(renaming.id, name);
else host.onRenameViewSnapshot(renaming.id, name);
}
setRenaming(null);
};
const cancelRename = () => setRenaming(null);
const renamingRow = (id: string, kind: RenameKind) =>
renaming?.id === id && renaming.kind === kind;
// ── Anlegen (zwei Header-Buttons) ──────────────────────────────────────────
const addFolder = () => {
const id = host.onAddViewSnapshotFolder(activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
beginRename(id, "folder", t("viewsnap.folder.defaultName"));
};
const commitSave = () => {
const name = draft.trim();
if (name) host.onCaptureViewSnapshot(name, activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
setNaming(false);
setDraft("");
};
// ── Drag&Drop ──────────────────────────────────────────────────────────────
const endDrag = () => {
setDragItem(null);
setDragOverId(null);
};
const dropOnFolder = (targetFolderId: string) => {
if (!dragItem) return;
if (dragItem.kind === "snapshot") {
host.onMoveViewSnapshotToFolder(dragItem.id, targetFolderId);
} else if (dragItem.id !== targetFolderId) {
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
host.onMoveViewSnapshotFolder(dragItem.id, targetFolderId);
}
expandFolder(targetFolderId);
endDrag();
};
const dropOnRoot = () => {
if (!dragItem) return;
if (dragItem.kind === "snapshot")
host.onMoveViewSnapshotToFolder(dragItem.id, null);
else host.onMoveViewSnapshotFolder(dragItem.id, null);
endDrag();
};
// ── Footer-Rename ──────────────────────────────────────────────────────────
const startFooterRename = () => {
if (!selectedSnap) return;
setFooterRenaming(true);
setFooterDraft(selectedSnap.name);
};
const commitFooterRename = () => {
if (!selectedSnap) return;
const name = footerDraft.trim();
if (name && name !== selectedSnap.name)
host.onRenameViewSnapshot(selectedSnap.id, name);
setFooterRenaming(false);
};
// ── Baum-Zeilen ────────────────────────────────────────────────────────────
const SnapshotRow = (snap: ViewSnapshot, depth: number) => (
<div
key={snap.id}
className={"nav-row" + (snap.id === selectedId ? " active" : "")}
title={t("viewsnap.applyHint", { name: snap.name })}
draggable={!renamingRow(snap.id, "snapshot")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "snapshot", id: snap.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onClick={() =>
renamingRow(snap.id, "snapshot")
? undefined
: host.onApplyViewSnapshot(snap.id)
}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
{renamingRow(snap.id, "snapshot") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<SnapshotIcon />
{snap.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(snap.id, "snapshot", snap.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.delete")}
onClick={(e) => {
e.stopPropagation();
host.onDeleteViewSnapshot(snap.id);
}}
>
</button>
</div>
);
const FolderRowTree = (
node: ViewSnapshotFolderNode,
depth: number,
): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeFolderId === folder.id;
const empty = node.snapshots.length === 0 && node.subfolders.length === 0;
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
const dragOver = dragOverId === folder.id && !isDragged;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
title={folder.name}
draggable={!renamingRow(folder.id, "folder")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "folder", id: folder.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (!dragItem) return;
e.stopPropagation();
if (isDragged) {
if (dragOverId !== null) setDragOverId(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== folder.id) setDragOverId(folder.id);
}}
onDragLeave={() => {
if (dragOverId === folder.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
dropOnFolder(folder.id);
}}
onClick={() => setActiveFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeFolderId === folder.id) setActiveFolderId(null);
host.onDeleteViewSnapshotFolder(folder.id);
}}
>
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
{node.snapshots.map((s) => SnapshotRow(s, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("viewsnap.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
const treeEmpty =
tree.rootFolders.length === 0 && tree.rootSnapshots.length === 0;
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("viewsnap.title")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("viewsnap.add.folder")}
onClick={addFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("viewsnap.add.snapshot")}
onClick={() => {
setNaming(true);
setDraft("");
}}
>
<PlusIcon />
</button>
</span>
</header>
{naming && (
<div className="nav-row" style={{ display: "flex", gap: 4, padding: "4px 8px" }}>
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
placeholder={t("viewsnap.savePrompt")}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitSave();
else if (e.key === "Escape") {
setNaming(false);
setDraft("");
}
}}
/>
<button className="nav-add" onClick={commitSave} title={t("viewsnap.add.snapshot")}>
</button>
</div>
)}
{treeEmpty ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("viewsnap.empty")}
</div>
) : (
<div
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
onDragOver={(e) => {
if (!dragItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== "root") setDragOverId("root");
}}
onDragLeave={(e) => {
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dragOverId === "root") setDragOverId(null);
}
}}
onDrop={(e) => {
e.preventDefault();
dropOnRoot();
}}
>
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
{tree.rootSnapshots.map((s) => SnapshotRow(s, 0))}
</div>
)}
{/* Footer-Bar: sichtbar, sobald ein Ausschnitt angewählt ist. Zeigt den
erfassten Massstab, die passende Ebenen-/Zeichnungskombi und die
aktiven Overrides und erlaubt das Umbenennen direkt hier. */}
{selectedSnap && footer && (
<div className="viewsnap-footer">
<div className="viewsnap-footer-name">
{footerRenaming ? (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={footerDraft}
onChange={(e) => setFooterDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitFooterRename();
else if (e.key === "Escape") setFooterRenaming(false);
}}
onBlur={commitFooterRename}
/>
) : (
<>
<span className="viewsnap-footer-title">{selectedSnap.name}</span>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.rename")}
onClick={startFooterRename}
>
</button>
</>
)}
</div>
<dl className="viewsnap-footer-grid">
<dt>{t("viewsnap.footer.scale")}</dt>
<dd>1:{selectedSnap.scaleDenominator}</dd>
<dt>{t("viewsnap.footer.layerCombo")}</dt>
<dd>{footer.layerCombo ?? t("viewsnap.footer.none")}</dd>
<dt>{t("viewsnap.footer.drawingCombo")}</dt>
<dd>{footer.drawingCombo ?? t("viewsnap.footer.none")}</dd>
<dt>{t("viewsnap.footer.overrides")}</dt>
<dd>
{footer.overrideNames.length
? footer.overrideNames.join(", ")
: t("viewsnap.footer.none")}
</dd>
</dl>
</div>
)}
</section>
);
}
/** Tab-Symbol „Ausschnitte" — Kamera-Blende / Sucherrahmen. */
export function ViewSnapshotsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
<circle cx="8" cy="8" r="2.4" />
<line x1="4.5" y1="3.5" x2="5.5" y2="2" />
<line x1="11.5" y1="3.5" x2="10.5" y2="2" />
</svg>
);
}
+35
View File
@@ -22,6 +22,9 @@ import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel"; import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel"; import { SitePanel } from "./SitePanel";
import { RoomBalancePanel } from "./RoomBalancePanel"; import { RoomBalancePanel } from "./RoomBalancePanel";
import { ElementTreePanel, ElementsTabIcon } from "./ElementTreePanel";
import { ViewSnapshotsPanel, ViewSnapshotsTabIcon } from "./ViewSnapshotsPanel";
import { LayoutsPanel, LayoutsTabIcon } from "./LayoutsPanel";
import { import {
ToolsTabIcon, ToolsTabIcon,
AttributesTabIcon, AttributesTabIcon,
@@ -41,6 +44,9 @@ export const BUILTIN_PANEL_IDS = {
objectInfo: "object-info", objectInfo: "object-info",
site: "site", site: "site",
roomBalance: "room-balance", roomBalance: "room-balance",
elements: "elements",
viewSnapshots: "view-snapshots",
layouts: "layouts",
} as const; } as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil). // Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
@@ -105,3 +111,32 @@ registerPanel({
hasDisplayMode: false, hasDisplayMode: false,
render: () => <RoomBalancePanel />, render: () => <RoomBalancePanel />,
}); });
// Element-Baum (ROADMAP §11) — Geschoss → Bauteilklasse → Element, Suche + Klick-Selektion.
registerPanel({
id: BUILTIN_PANEL_IDS.elements,
title: "elements.title",
icon: <ElementsTabIcon />,
hasDisplayMode: false,
render: () => <ElementTreePanel />,
});
// Ausschnitte / View-Snapshots (DOSSIER A2, ROADMAP §2c/§11) — benannte,
// wiederherstellbare Ansichten (Darstellungszustand als Preset).
registerPanel({
id: BUILTIN_PANEL_IDS.viewSnapshots,
title: "viewsnap.title",
icon: <ViewSnapshotsTabIcon />,
hasDisplayMode: false,
render: () => <ViewSnapshotsPanel />,
});
// Layouts / Masterlayouts (DOSSIER A3, ROADMAP §11) — Druck-/Plan-Blätter mit
// platzierten Viewports (an Ausschnitte gebunden), Editor als schwebendes Fenster.
registerPanel({
id: BUILTIN_PANEL_IDS.layouts,
title: "layouts.panelTitle",
icon: <LayoutsTabIcon />,
hasDisplayMode: false,
render: () => <LayoutsPanel />,
});
+224
View File
@@ -22,9 +22,13 @@ import type {
DrawingLevel, DrawingLevel,
HatchStyle, HatchStyle,
LayerCategory, LayerCategory,
LayoutOrientation,
LayoutPaperFormat,
LineStyle, LineStyle,
MasterLayout,
Project, Project,
SiaCategory, SiaCategory,
SliceTermination,
StairShape, StairShape,
VerticalAnchor, VerticalAnchor,
WallReferenceLine, WallReferenceLine,
@@ -32,6 +36,7 @@ import type {
} from "../model/types"; } from "../model/types";
import type { SnapSettings, ToolId } from "../tools/types"; import type { SnapSettings, ToolId } from "../tools/types";
import type { Selection } from "../state/selectionInfo"; import type { Selection } from "../state/selectionInfo";
import type { ScheduleKind } from "../export/exportSchedule";
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ──────────────────────────── // ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
@@ -196,10 +201,38 @@ export interface PanelHostValue {
h: number, h: number,
anchor: { fx: number; fy: number }, anchor: { fx: number; fy: number },
) => void; ) => void;
/**
* Verschiebt die Selektion um (dx,dy) Meter generischer Move über das
* Transform-System (analog dem Move-Werkzeug/-Befehl), deckt Wand/Drawing2D/
* Extrusionskörper ab. Bei Decke/Öffnung/Treppe/Raum (Transform-Kern kennt
* diese Elementarten nicht) ein No-op.
*/
onMoveSelectionBy: (dx: number, dy: number) => void;
/**
* Dreht die Selektion um `deg` Grad um den Weltpunkt (cx,cy) generischer
* Rotate über das Transform-System; deckt dieselben Selektionsarten ab wie
* {@link onMoveSelectionBy}.
*/
onRotateSelectionAround: (cx: number, cy: number, deg: number) => void;
/**
* Setzt die Länge einer Linien-Drawing2D (Endpunkt entlang der Richtung neu
* skaliert, Startpunkt bleibt fix). No-op, wenn die Selektion keine Linie ist.
*/
onSetDrawingLineLength: (length: number) => void;
/**
* Setzt den Radius einer Kreis-Drawing2D (Mittelpunkt bleibt fix). No-op,
* wenn die Selektion kein Kreis ist.
*/
onSetDrawingCircleRadius: (radius: number) => void;
// ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─ // ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─
/** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */ /** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */
onSetWallReferenceLine: (ref: WallReferenceLine) => void; onSetWallReferenceLine: (ref: WallReferenceLine) => void;
/**
* Setzt die Terminierungs-Regel am Deckenanschluss (Zuschnitt, NICHT Priorität):
* "both" = heutiges Verhalten, "below"/"above" = die Wand endet an der Decke.
*/
onSetWallSliceTermination: (termination: SliceTermination) => void;
/** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */ /** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */
onSetWallType: (wallTypeId: string) => void; onSetWallType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */ /** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
@@ -216,6 +249,12 @@ export interface PanelHostValue {
onSetCeilingThickness: (thickness: number) => void; onSetCeilingThickness: (thickness: number) => void;
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */ /** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
onSetCeilingTop: (anchor: VerticalAnchor | null) => void; onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
/**
* Setzt/entfernt die UK-Bindung der Decke (`null` = OK Dicke). Optional,
* da bestehende Hosts (App.tsx) diesen Setter ggf. noch nicht verdrahtet
* haben additive Erweiterung analog `onSetWallBottom`.
*/
onSetCeilingBottom?: (anchor: VerticalAnchor | null) => void;
// ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─ // ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─
/** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */ /** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */
@@ -242,6 +281,8 @@ export interface PanelHostValue {
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void; onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
/** Setzt die Sturzlinien-Darstellung der Tür. */ /** Setzt die Sturzlinien-Darstellung der Tür. */
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void; onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
/** Weist der Öffnung einen Tür-/Fenstertyp (Bibliothek) zu; "" löst die Zuweisung. */
onSetOpeningType: (typeId: string) => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ─── // ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */ /** Setzt die Grundform (gerade/L/Wendel). */
@@ -256,6 +297,8 @@ export interface PanelHostValue {
onSetStairUp: (up: boolean) => void; onSetStairUp: (up: boolean) => void;
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */ /** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void; onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
onSetStairType: (typeId: string) => void;
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ──────── // ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
/** Setzt den Raum-Namen. */ /** Setzt den Raum-Namen. */
@@ -265,6 +308,26 @@ export interface PanelHostValue {
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */ /** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
onEditRoomStamp: (roomId: string) => void; onEditRoomStamp: (roomId: string) => void;
// ── Extrusions-Attribute (truck-Integration; nur der selektierte Körper) ──
/** Setzt die Extrusionshöhe (Meter, > 0) — löst eine Re-Extrusion aus. */
onSetExtrudedSolidHeight: (height: number) => void;
/** Setzt die Verjüngung (0..1) — löst eine Re-Extrusion aus. */
onSetExtrudedSolidTaper: (taper: number) => void;
// ── Stützen-Attribute (Tragwerk; nur die selektierte Stütze) ────────────
/** Wechselt das Profil (Rechteck/Kreis); leitet ein sinnvolles Mass ab. */
onSetColumnProfileKind: (kind: "rect" | "round") => void;
/** Setzt die Breite (X) eines Rechteckprofils (Meter). */
onSetColumnWidth: (width: number) => void;
/** Setzt die Tiefe (Y) eines Rechteckprofils (Meter). */
onSetColumnDepth: (depth: number) => void;
/** Setzt den Radius eines Kreisprofils (Meter). */
onSetColumnRadius: (radius: number) => void;
/** Setzt die Höhe der Stütze (Meter, > 0). */
onSetColumnHeight: (height: number) => void;
/** Setzt die Drehung der Stütze (Grad; intern in Radiant gespeichert). */
onSetColumnRotation: (deg: number) => void;
// ── Site-/Kontext-Schicht (SitePanel) ─────────────────────────────────── // ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */ /** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[]; contextObjects: ContextObject[];
@@ -279,6 +342,166 @@ export interface PanelHostValue {
onGenerateTerrain: (contourSetId: string) => string | null; onGenerateTerrain: (contourSetId: string) => string | null;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void; onImportDxf: () => void;
// ── Element-Baum (Elemente-Panel) ───────────────────────────────────────
/**
* Selektiert EIN Bauteil-Vorkommen aus der Elementliste (`scheduleRows`) im
* Baum: leert alle Auswahl-Kanäle und setzt genau dieses Element im
* passenden Kanal (Wand/Decke/Fenster+Öffnung/Treppe/Extrusion). Liegt das
* Element auf einem anderen Geschoss, wechselt zuerst die aktive
* Zeichnungsebene. Für Bauteilklassen ohne Auswahl-Kanal (aktuell nur die
* Legacy-Tür"-Datensätze aus `project.doors`, die kein Werkzeug mehr
* anlegt) ein No-op. `opts.zoom` (Shift-Klick/Doppelklick in der Baumzeile)
* passt die Plan-Ansicht zusätzlich ein.
*/
onSelectScheduleRow: (
kind: ScheduleKind,
id: string,
floorId: string | undefined,
opts?: { zoom?: boolean },
) => void;
// ── Ausschnitte / View-Snapshots (Ausschnitte-Panel, DOSSIER A2) ─────────
// Die Liste selbst liest das Panel über `project.viewSnapshots`. Die
// Erfassungs-/Anwendungslogik (Setter/rAF) lebt in App.tsx; hier nur die
// immutablen Handler.
/**
* Erfasst den aktuellen Darstellungszustand als neuen, benannten Ausschnitt.
* `folderId` legt ihn direkt im angegebenen Ordner ab (sonst Wurzelebene)
* additiv, Alt-Aufrufe ohne Ordner-Argument bleiben gültig.
*/
onCaptureViewSnapshot: (name: string, folderId?: string) => void;
/** Stellt alle Felder eines Ausschnitts wieder her (Geschoss/Sichtbarkeit/Overrides/Ansicht). */
onApplyViewSnapshot: (id: string) => void;
/** Benennt einen Ausschnitt um. */
onRenameViewSnapshot: (id: string, name: string) => void;
/** Löscht einen Ausschnitt. */
onDeleteViewSnapshot: (id: string) => void;
// ── Ausschnitte-Baum (Ordner, DOSSIER A2) ───────────────────────────────
// Die Listen liest das Panel über `project.viewSnapshots`/
// `project.viewSnapshotFolders`; pure CRUD in state/viewSnapshotFolders.ts.
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
onAddViewSnapshotFolder: (parentId?: string) => string;
/** Benennt einen Ausschnitte-Ordner um. */
onRenameViewSnapshotFolder: (id: string, name: string) => void;
/**
* Löscht einen Ausschnitte-Ordner (enthaltene Ausschnitte + Unterordner wandern
* auf die Elternebene, kein Datenverlust).
*/
onDeleteViewSnapshotFolder: (id: string) => void;
/**
* Verschiebt einen Ausschnitt per Drag&Drop in einen Ordner (`folderId`) bzw.
* auf die Wurzelebene (`null`).
*/
onMoveViewSnapshotToFolder: (snapshotId: string, folderId: string | null) => void;
/**
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
* werden verworfen (No-op).
*/
onMoveViewSnapshotFolder: (folderId: string, parentId: string | null) => void;
/**
* Id des aktuell angewählten" Ausschnitts (Footer-Bar sichtbar) oder `null`.
* Bleibt gesetzt, solange der Live-Zustand exakt dem Ausschnitt entspricht;
* App löscht ihn, sobald der Nutzer eine erfasste Grösse ändert.
*/
selectedViewSnapshotId: string | null;
/** Namen aller gespeicherten Ebenen-Kombinationen (localStorage). */
listLayerCombos: () => string[];
/** Lädt die `codes`-Map einer Ebenen-Kombination (`null`, wenn unbekannt). */
loadLayerCombo: (name: string) => Record<string, boolean> | null;
/** Namen aller gespeicherten Zeichnungs-Kombinationen (localStorage). */
listDrawingCombos: () => string[];
/** Lädt die `ids`-Map einer Zeichnungs-Kombination (`null`, wenn unbekannt). */
loadDrawingCombo: (name: string) => Record<string, boolean> | null;
// ── Layouts / Masterlayouts (Layouts-Panel, DOSSIER A3) ─────────────────
// Die Listen liest das Panel über `project.layouts`/`project.masterLayouts`.
// Die Mutationslogik lebt in App.tsx (setProject); pure CRUD in
// panels/layoutModel.ts.
/** Legt ein neues, leeres Layout an und öffnet den Editor. */
onAddLayout: (
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
) => void;
/** Benennt ein Layout um. */
onRenameLayout: (id: string, name: string) => void;
/** Löscht ein Layout (schliesst ggf. den offenen Editor). */
onDeleteLayout: (id: string) => void;
/** Öffnet ein Layout im schwebenden Layout-Editor. */
onOpenLayout: (id: string) => void;
/** Legt ein neues Masterlayout an. */
onAddMasterLayout: (name: string) => void;
/** Benennt ein Masterlayout um. */
onRenameMasterLayout: (id: string, name: string) => void;
/** Löscht ein Masterlayout (löst Bindungen der Layouts). */
onDeleteMasterLayout: (id: string) => void;
/** Ändert Felder eines Masterlayouts (Titelblock/Papier/Rahmen). */
onPatchMasterLayout: (id: string, patch: Partial<MasterLayout>) => void;
// ── Layouts-Baum (Ordner) + Erstell-Dialoge (DOSSIER A3) ────────────────
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
onAddLayoutFolder: (parentId?: string) => string;
/**
* Legt einen neuen MASTER-Ordner (`kind:"master"`) an (optional in `parentId`);
* liefert die neue Id. Analog {@link onAddLayoutFolder}, aber im Master-Baum.
*/
onAddMasterFolder: (parentId?: string) => string;
/** Benennt einen Ordner um (Layout- wie Master-Ordner, per Id). */
onRenameLayoutFolder: (id: string, name: string) => void;
/** Löscht einen Ordner (Inhalt wandert auf die Elternebene, kein Datenverlust). */
onDeleteLayoutFolder: (id: string) => void;
/**
* Löscht einen Master-Ordner (enthaltene Masterlayouts + Unterordner wandern
* auf die Elternebene, kein Datenverlust). Analog {@link onDeleteLayoutFolder}.
*/
onDeleteMasterFolder: (id: string) => void;
/**
* Legt ein Layout mit expliziten Startwerten (Master-Vorlage ODER freie
* Grösse) an und liefert dessen Id (das Panel versetzt es in Inline-Rename).
* Öffnet das Blatt NICHT automatisch (anders als {@link onAddLayout}).
*/
onCreateLayout: (opts: CreateLayoutOptions) => string;
/** Legt ein Masterlayout mit Grösse an und liefert dessen Id. */
onCreateMasterLayout: (opts: CreateMasterLayoutOptions) => string;
/** Exportiert alle Layouts eines Ordners als EIN Mehrseiten-PDF. */
onExportFolderPdf: (folderId: string) => void;
/**
* Verschiebt ein Layout per Drag&Drop in einen Ordner (`folderId`) bzw. auf
* die Wurzelebene (`null`).
*/
onMoveLayoutToFolder: (layoutId: string, folderId: string | null) => void;
/**
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
* werden verworfen (No-op).
*/
onMoveFolderToFolder: (folderId: string, parentId: string | null) => void;
}
/** Startwerte eines neuen Layouts (aus dem „Neues Layout"-Dialog). */
export interface CreateLayoutOptions {
/** Zielordner; ohne → Wurzelebene. */
folderId?: string;
/** Gebundenes Masterlayout (erbt dessen Grösse); ohne → freie Grösse. */
masterId?: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
/** Startwerte eines neuen Masterlayouts (aus dem „Neues Masterlayout"-Dialog). */
export interface CreateMasterLayoutOptions {
/** Ziel-Master-Ordner; ohne → Wurzelebene des Master-Baums. */
folderId?: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
} }
// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so // Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so
@@ -286,6 +509,7 @@ export interface PanelHostValue {
export type { DrawingLevel, LayerCategory }; export type { DrawingLevel, LayerCategory };
export type { SnapSettings, ToolId }; export type { SnapSettings, ToolId };
export type { Selection } from "../state/selectionInfo"; export type { Selection } from "../state/selectionInfo";
export type { ScheduleKind, ScheduleRow } from "../export/exportSchedule";
// ── Hook ─────────────────────────────────────────────────────────────────── // ── Hook ───────────────────────────────────────────────────────────────────
+8 -2
View File
@@ -34,7 +34,13 @@ import type {
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) → // v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen // Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
// (Attribute volle linke Höhe statt Werkzeug-Palette oben). // (Attribute volle linke Höhe statt Werkzeug-Palette oben).
export const LAYOUT_VERSION = 8; // v9: Element-Baum ("elements", ROADMAP Paragraf 11) als weiterer Tab in der
// rechten unteren Gruppe (neben Zeichnungsebenen/Ebenen/Umgebung).
// v10: Ausschnitte-Panel ("view-snapshots", DOSSIER A2 / ROADMAP Paragraf 2c)
// 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;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */ /** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout"; const CURRENT_KEY = "cad.layout";
@@ -68,7 +74,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [ const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 }, { tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
{ {
tabs: ["drawing-levels", "layers", "site"], tabs: ["drawing-levels", "layers", "site", "elements", "view-snapshots", "layouts"],
activeTab: "drawing-levels", activeTab: "drawing-levels",
weight: 1.5, weight: 1.5,
}, },
+654
View File
@@ -0,0 +1,654 @@
// Tests der reinen Layout-Logik (DOSSIER A3): Blatt-Geometrie, Fabriken,
// immutables Viewport-CRUD, Master-Vererbung der Titelblock-Werte,
// Viewport→Ausschnitt-Auflösung + Massstab, Sichtbarkeits-Ableitung.
import { describe, expect, it } from "vitest";
import type {
Layout,
LayoutFolder,
MasterLayout,
Project,
ViewSnapshot,
} from "../model/types";
import {
addAnnotation,
addViewport,
buildLayoutTree,
buildMasterTree,
collectFolderLayouts,
createAnnotation,
createLayout,
createLayoutFolder,
createMasterLayout,
createViewport,
deleteFolder,
deleteMasterFolder,
effectiveSheetSizeMm,
findMaster,
findSnapshot,
folderPdfPages,
isDescendant,
moveFolderToFolder,
moveLayoutToFolder,
PAPER_FORMATS,
patchAnnotation,
patchViewport,
removeAnnotation,
removeViewport,
resolveTitleBlock,
resolveViewportScale,
sheetSizeMm,
visibleCodesFromSnapshot,
} from "./layoutModel";
/** Minimaler Ausschnitt (nur die im Layout-System genutzten Felder tragen Werte). */
function snap(id: string, patch?: Partial<ViewSnapshot>): ViewSnapshot {
return {
id,
name: `Snap ${id}`,
viewType: "grundriss",
view3d: "top",
scaleDenominator: 100,
detail: "mittel",
activeLevelId: "floor-0",
layerVisibility: { A: true, B: false, C: true },
drawingVisibility: {},
...patch,
};
}
/** Minimales Projekt-Gerüst mit optionalen Layout-Feldern. */
function project(patch?: Partial<Project>): Project {
return {
id: "p1",
name: "Villa Test",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
drawings2d: [],
...patch,
};
}
describe("sheetSizeMm", () => {
it("liefert A4/A3 in Hoch- und Querformat", () => {
expect(sheetSizeMm("a4", "portrait")).toEqual({ w: 210, h: 297 });
expect(sheetSizeMm("a4", "landscape")).toEqual({ w: 297, h: 210 });
expect(sheetSizeMm("a3", "portrait")).toEqual({ w: 297, h: 420 });
expect(sheetSizeMm("a3", "landscape")).toEqual({ w: 420, h: 297 });
});
});
describe("Fabriken", () => {
it("createLayout: leer, ohne Master", () => {
const l = createLayout("l1", "Blatt 1", "a3", "landscape");
expect(l).toEqual({
id: "l1",
name: "Blatt 1",
paper: "a3",
orientation: "landscape",
viewports: [],
});
expect(l.masterId).toBeUndefined();
});
it("createMasterLayout: leerer Titelblock + Rahmen an", () => {
const m = createMasterLayout("m1", "Master", "a4", "portrait");
expect(m.titleBlock).toEqual({});
expect(m.border).toBe(true);
});
it("createViewport: Defaults + optionaler Massstab", () => {
const v = createViewport("v1", "s1");
expect(v).toMatchObject({ id: "v1", snapshotId: "s1", xMm: 20, yMm: 20, widthMm: 120, heightMm: 90 });
expect(v.scaleDenominator).toBeUndefined();
const v2 = createViewport("v2", "s1", { xMm: 5, scaleDenominator: 50 });
expect(v2.xMm).toBe(5);
expect(v2.scaleDenominator).toBe(50);
});
});
describe("Viewport-CRUD (immutabel)", () => {
const base = addViewport(createLayout("l1", "L", "a4", "portrait"), createViewport("v1", "s1"));
it("addViewport verändert das Original nicht", () => {
const before = createLayout("l1", "L", "a4", "portrait");
const after = addViewport(before, createViewport("v1", "s1"));
expect(before.viewports).toHaveLength(0);
expect(after.viewports).toHaveLength(1);
expect(after).not.toBe(before);
});
it("removeViewport entfernt per Id", () => {
const two = addViewport(base, createViewport("v2", "s2"));
const one = removeViewport(two, "v1");
expect(one.viewports.map((v) => v.id)).toEqual(["v2"]);
});
it("patchViewport ändert nur das Ziel, immutabel", () => {
const patched = patchViewport(base, "v1", { xMm: 42, scaleDenominator: 50 });
expect(patched.viewports[0]).toMatchObject({ xMm: 42, scaleDenominator: 50 });
expect(base.viewports[0].xMm).toBe(20);
// Unbekannte Id → No-op (gleiche Werte, neue Liste).
const noop = patchViewport(base, "nope", { xMm: 1 });
expect(noop.viewports[0].xMm).toBe(20);
});
});
// ── Annotationen (Linie/Rechteck/Text) ───────────────────────────────────────
describe("createAnnotation — Defaults je Art", () => {
it("line: Default-Geometrie ohne Farbe/Strichstärke", () => {
const a = createAnnotation("a1", "line");
expect(a).toEqual({ id: "a1", kind: "line", x1Mm: 20, y1Mm: 20, x2Mm: 80, y2Mm: 20 });
});
it("line: init überschreibt einzelne Felder + optionale Farbe/Strichstärke", () => {
const a = createAnnotation("a1", "line", { x1Mm: 5, color: "#f00", weightMm: 0.5 });
expect(a).toMatchObject({ x1Mm: 5, y1Mm: 20, color: "#f00", weightMm: 0.5 });
});
it("rect: Default-Grösse", () => {
const a = createAnnotation("a2", "rect");
expect(a).toEqual({ id: "a2", kind: "rect", xMm: 20, yMm: 20, widthMm: 40, heightMm: 30 });
});
it("text: Default-Zeilenhöhe 3.5mm, leerer Text", () => {
const a = createAnnotation("a3", "text");
expect(a).toEqual({ id: "a3", kind: "text", xMm: 20, yMm: 20, text: "", heightMm: 3.5 });
});
it("text: init setzt Inhalt + Höhe", () => {
const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 });
expect(a).toMatchObject({ text: "EG", heightMm: 5 });
});
});
describe("Annotations-CRUD (immutabel)", () => {
const base = createLayout("l1", "L", "a4", "portrait");
it("addAnnotation verändert das Original nicht", () => {
const after = addAnnotation(base, createAnnotation("a1", "line"));
expect(base.annotations).toBeUndefined();
expect(after.annotations).toHaveLength(1);
expect(after).not.toBe(base);
});
it("addAnnotation baut auf einer fehlenden `annotations`-Liste auf (Alt-Layout)", () => {
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
const after = addAnnotation(legacy, createAnnotation("a1", "rect"));
expect(after.annotations).toHaveLength(1);
expect(legacy.annotations).toBeUndefined();
});
it("removeAnnotation entfernt per Id", () => {
let l = addAnnotation(base, createAnnotation("a1", "line"));
l = addAnnotation(l, createAnnotation("a2", "rect"));
const removed = removeAnnotation(l, "a1");
expect(removed.annotations!.map((a) => a.id)).toEqual(["a2"]);
});
it("removeAnnotation auf Alt-Layout ohne `annotations` bleibt heil (No-op)", () => {
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
const r = removeAnnotation(legacy, "nope");
expect(r.annotations).toEqual([]);
});
it("patchAnnotation ändert nur das Ziel, immutabel", () => {
const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" }));
const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" });
expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" });
expect(l.annotations![0].color).toBe("#111");
});
it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => {
const l = addAnnotation(base, createAnnotation("a1", "line"));
const noop = patchAnnotation(l, "nope", { x1Mm: 1 });
expect((noop.annotations![0] as { x1Mm: number }).x1Mm).toBe(20);
});
it("patchAnnotation auf einer Text-Annotation ändert den Inhalt", () => {
const l = addAnnotation(base, createAnnotation("t1", "text", { text: "EG" }));
const patched = patchAnnotation(l, "t1", { text: "1. OG" });
expect(patched.annotations![0]).toMatchObject({ text: "1. OG" });
});
});
describe("resolveViewportScale", () => {
const s = snap("s1", { scaleDenominator: 200 });
it("Viewport-Override gewinnt", () => {
expect(resolveViewportScale(createViewport("v", "s1", { scaleDenominator: 50 }), s)).toBe(50);
});
it("sonst Ausschnitt-Massstab", () => {
expect(resolveViewportScale(createViewport("v", "s1"), s)).toBe(200);
});
it("Fallback ohne beides", () => {
expect(resolveViewportScale(createViewport("v", "s1"), undefined, 100)).toBe(100);
});
});
describe("findSnapshot / findMaster", () => {
const p = project({
viewSnapshots: [snap("s1"), snap("s2")],
masterLayouts: [createMasterLayout("m1", "M1", "a4", "portrait")],
});
it("findSnapshot", () => {
expect(findSnapshot(p, "s2")?.id).toBe("s2");
expect(findSnapshot(p, "x")).toBeUndefined();
});
it("findMaster nur bei gesetzter masterId", () => {
const bound: Layout = { ...createLayout("l", "L", "a4", "portrait"), masterId: "m1" };
expect(findMaster(p, bound)?.id).toBe("m1");
expect(findMaster(p, createLayout("l2", "L2", "a4", "portrait"))).toBeUndefined();
// Dangling masterId → undefined.
expect(findMaster(p, { ...bound, masterId: "gone" })).toBeUndefined();
});
});
describe("resolveTitleBlock — Master-Vererbung + Defaults", () => {
const p = project({ name: "Villa Test", viewSnapshots: [snap("s1", { scaleDenominator: 50 })] });
it("ohne Master: Defaults aus Projekt/Layout + Massstab aus erstem Viewport", () => {
let l = createLayout("l1", "EG Grundriss", "a4", "portrait");
l = addViewport(l, createViewport("v1", "s1"));
const tb = resolveTitleBlock(p, l, undefined, "2026-07-07");
expect(tb.projectName).toBe("Villa Test");
expect(tb.sheetName).toBe("EG Grundriss");
expect(tb.scale).toBe("1:50");
expect(tb.date).toBe("2026-07-07");
expect(tb.author).toBe("");
});
it("Master-Werte übersteuern die Defaults", () => {
const master: MasterLayout = {
id: "m1",
name: "M",
paper: "a4",
orientation: "portrait",
titleBlock: { projectName: "Kundenprojekt", author: "GV", scale: "1:100" },
};
const l = createLayout("l1", "EG", "a4", "portrait");
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
expect(tb.projectName).toBe("Kundenprojekt");
expect(tb.author).toBe("GV");
expect(tb.scale).toBe("1:100"); // Master-Wert schlägt den Viewport-Default
expect(tb.sheetName).toBe("EG"); // leer im Master → Layout-Name
});
it("leere/whitespace Master-Felder fallen auf Defaults zurück", () => {
const master: MasterLayout = {
id: "m1",
name: "M",
paper: "a4",
orientation: "portrait",
titleBlock: { projectName: " ", sheetName: "" },
};
const l = createLayout("l1", "OG", "a4", "portrait");
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
expect(tb.projectName).toBe("Villa Test");
expect(tb.sheetName).toBe("OG");
expect(tb.scale).toBe("—"); // kein Viewport → kein Massstab
});
});
describe("visibleCodesFromSnapshot", () => {
it("nur sichtbare Codes landen im Set", () => {
const codes = visibleCodesFromSnapshot(snap("s1"));
expect([...codes].sort()).toEqual(["A", "C"]);
expect(codes.has("B")).toBe(false);
});
});
// ── Freie Grösse (custom übersteuert Papier) ──────────────────────────────────
describe("sheetSizeMm / effectiveSheetSizeMm — freie Grösse", () => {
it("custom übersteuert paper/orientation, wenn beide positiv gesetzt", () => {
expect(sheetSizeMm("a4", "portrait", 500, 300)).toEqual({ w: 500, h: 300 });
});
it("fällt auf paper zurück, wenn custom fehlt oder nicht positiv ist", () => {
expect(sheetSizeMm("a4", "portrait", undefined, 300)).toEqual({ w: 210, h: 297 });
expect(sheetSizeMm("a3", "landscape", 0, 300)).toEqual({ w: 420, h: 297 });
});
it("effectiveSheetSizeMm liest die Felder eines Layouts/Masters", () => {
expect(
effectiveSheetSizeMm({ paper: "a4", orientation: "portrait" }),
).toEqual({ w: 210, h: 297 });
expect(
effectiveSheetSizeMm({
paper: "a4",
orientation: "portrait",
customWidthMm: 250,
customHeightMm: 250,
}),
).toEqual({ w: 250, h: 250 });
});
});
describe("createMasterLayout — freie Grösse", () => {
it("übernimmt custom Breite/Höhe, wenn übergeben", () => {
const m = createMasterLayout("m", "M", "a4", "portrait", {
widthMm: 400,
heightMm: 250,
});
expect(m.customWidthMm).toBe(400);
expect(m.customHeightMm).toBe(250);
});
});
// ── Ordner-Baum (CRUD + Aufbau) ───────────────────────────────────────────────
function lay(id: string, folderId?: string): Layout {
return {
id,
name: id,
paper: "a4",
orientation: "portrait",
viewports: [],
...(folderId ? { folderId } : {}),
};
}
describe("createLayoutFolder", () => {
it("Wurzelordner ohne parentId", () => {
expect(createLayoutFolder("f1", "Ordner")).toEqual({ id: "f1", name: "Ordner" });
});
it("Unterordner mit parentId", () => {
expect(createLayoutFolder("f2", "Sub", "f1")).toEqual({
id: "f2",
name: "Sub",
parentId: "f1",
});
});
it("Master-Ordner trägt kind:\"master\"", () => {
expect(createLayoutFolder("m1", "Master-Ordner", undefined, "master")).toEqual({
id: "m1",
name: "Master-Ordner",
kind: "master",
});
});
it("kind \"layout\"/undefined wird NICHT gesetzt (rückwärtskompatibel)", () => {
expect(createLayoutFolder("l1", "Layout-Ordner", undefined, "layout")).toEqual({
id: "l1",
name: "Layout-Ordner",
});
expect(createLayoutFolder("l2", "Layout-Ordner", "p", undefined)).toEqual({
id: "l2",
name: "Layout-Ordner",
parentId: "p",
});
});
});
describe("buildLayoutTree", () => {
it("gruppiert Layouts in Ordner + Wurzel; Unterordner verschachtelt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("root1"), lay("inA", "A"), lay("inB", "B")];
const tree = buildLayoutTree(folders, layouts);
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["root1"]);
expect(tree.rootFolders).toHaveLength(1);
const a = tree.rootFolders[0];
expect(a.folder.id).toBe("A");
expect(a.layouts.map((l) => l.id)).toEqual(["inA"]);
expect(a.subfolders).toHaveLength(1);
expect(a.subfolders[0].folder.id).toBe("B");
expect(a.subfolders[0].layouts.map((l) => l.id)).toEqual(["inB"]);
});
it("verwaiste Referenzen landen auf der Wurzel", () => {
const tree = buildLayoutTree(
[createLayoutFolder("orphan", "O", "missing")],
[lay("ghost", "gone")],
);
// Ordner mit unbekanntem parent → Wurzel; Layout mit unbekanntem folder → Wurzel.
expect(tree.rootFolders.map((f) => f.folder.id)).toEqual(["orphan"]);
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["ghost"]);
});
});
// ── Master-Baum + Layout/Master-Trennung (kind) ──────────────────────────────
function mas(id: string, folderId?: string): MasterLayout {
return {
id,
name: id,
paper: "a4",
orientation: "portrait",
titleBlock: {},
...(folderId ? { folderId } : {}),
};
}
describe("buildMasterTree", () => {
it("gruppiert Master in Master-Ordner + Wurzel; Unterordner verschachtelt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A", undefined, "master"),
createLayoutFolder("B", "B", "A", "master"),
];
const masters = [mas("root1"), mas("inA", "A"), mas("inB", "B")];
const tree = buildMasterTree(folders, masters);
expect(tree.rootMasters.map((m) => m.id)).toEqual(["root1"]);
expect(tree.rootFolders).toHaveLength(1);
const a = tree.rootFolders[0];
expect(a.folder.id).toBe("A");
expect(a.masters.map((m) => m.id)).toEqual(["inA"]);
expect(a.subfolders[0].masters.map((m) => m.id)).toEqual(["inB"]);
});
});
describe("Layout-/Master-Ordner bleiben getrennt (kind-Filter)", () => {
it("Master-Ordner erscheint NICHT im Layout-Baum und umgekehrt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("L", "Layout-Ordner"), // kind fehlt → Layout
createLayoutFolder("M", "Master-Ordner", undefined, "master"),
];
const layouts = [lay("l1", "L"), lay("lm", "M")]; // lm zeigt fälschlich auf Master-Ordner
const masters = [mas("m1", "M"), mas("ml", "L")]; // ml zeigt fälschlich auf Layout-Ordner
// Wie das Panel filtert: Layout-Baum nur kind!=="master", Master-Baum nur kind==="master".
const layoutTree = buildLayoutTree(
folders.filter((f) => f.kind !== "master"),
layouts,
);
const masterTree = buildMasterTree(
folders.filter((f) => f.kind === "master"),
masters,
);
// Layout-Baum kennt nur den Layout-Ordner; das auf M zeigende Layout fällt auf die Wurzel.
expect(layoutTree.rootFolders.map((f) => f.folder.id)).toEqual(["L"]);
expect(layoutTree.rootLayouts.map((l) => l.id)).toEqual(["lm"]);
// Master-Baum kennt nur den Master-Ordner; der auf L zeigende Master fällt auf die Wurzel.
expect(masterTree.rootFolders.map((f) => f.folder.id)).toEqual(["M"]);
expect(masterTree.rootMasters.map((m) => m.id)).toEqual(["ml"]);
expect(masterTree.rootFolders[0].masters.map((m) => m.id)).toEqual(["m1"]);
});
});
describe("deleteMasterFolder", () => {
it("zieht Master + Unterordner auf die Elternebene und erhält kind:\"master\"", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A", undefined, "master"),
createLayoutFolder("B", "B", "A", "master"),
];
const masters = [mas("a1", "A"), mas("b1", "B")];
const r = deleteMasterFolder("A", folders, masters);
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
// B auf Wurzel hochgezogen, aber weiterhin Master-Ordner.
expect(r.folders[0].parentId).toBeUndefined();
expect(r.folders[0].kind).toBe("master");
expect(r.masters.find((m) => m.id === "a1")!.folderId).toBeUndefined();
expect(r.masters.find((m) => m.id === "b1")!.folderId).toBe("B");
});
});
describe("collectFolderLayouts", () => {
it("liefert direkte Blätter zuerst, dann rekursiv Unterordner (PDF-Reihenfolge)", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("root1"), lay("a1", "A"), lay("a2", "A"), lay("b1", "B")];
expect(collectFolderLayouts("A", folders, layouts).map((l) => l.id)).toEqual([
"a1",
"a2",
"b1",
]);
expect(collectFolderLayouts("B", folders, layouts).map((l) => l.id)).toEqual([
"b1",
]);
});
it("unbekannter Ordner → leer", () => {
expect(collectFolderLayouts("nope", [], [lay("x")])).toEqual([]);
});
});
describe("deleteFolder", () => {
it("zieht Layouts + Unterordner auf die Elternebene (kein Datenverlust)", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("a1", "A"), lay("b1", "B")];
// A (Wurzel) löschen → a1 auf Wurzel, B verliert parent → Wurzel.
const r = deleteFolder("A", folders, layouts);
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
expect(r.folders[0].parentId).toBeUndefined();
expect(r.layouts.find((l) => l.id === "a1")!.folderId).toBeUndefined();
expect(r.layouts.find((l) => l.id === "b1")!.folderId).toBe("B");
});
it("Unterordner löschen zieht Kinder zum Grosselter", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("b1", "B")];
const r = deleteFolder("B", folders, layouts);
expect(r.folders.map((f) => f.id)).toEqual(["A"]);
expect(r.layouts[0].folderId).toBe("A");
});
});
describe("folderPdfPages", () => {
it("plant Seiten in Baum-Reihenfolge mit effektiver Blattgrösse", () => {
const master: MasterLayout = createMasterLayout("m", "M", "a3", "landscape");
const layouts: Layout[] = [
{ ...lay("p1", "F"), masterId: "m" }, // erbt A3 quer vom Master
{ ...lay("p2", "F"), customWidthMm: 500, customHeightMm: 200 }, // freie Grösse
lay("p3", "F"), // A4 hoch (Default)
];
const p = project({
layoutFolders: [createLayoutFolder("F", "F")],
layouts,
masterLayouts: [master],
});
const pages = folderPdfPages(p, "F");
expect(pages.map((x) => x.layout.id)).toEqual(["p1", "p2", "p3"]);
expect(pages[0]).toMatchObject({ widthMm: 420, heightMm: 297 }); // Master A3 quer
expect(pages[1]).toMatchObject({ widthMm: 500, heightMm: 200 }); // custom
expect(pages[2]).toMatchObject({ widthMm: 210, heightMm: 297 }); // A4 hoch
});
it("leerer/unbekannter Ordner → keine Seiten", () => {
expect(folderPdfPages(project(), "nope")).toEqual([]);
});
});
describe("PAGE_MM / Standard-Formate A0A6 + B0B6", () => {
it("PAPER_FORMATS listet 14 Formate (A-Reihe, dann B-Reihe)", () => {
expect(PAPER_FORMATS).toEqual([
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
]);
});
it("liefert offizielle ISO-216-Masse (Hochformat, mm)", () => {
expect(sheetSizeMm("a0", "portrait")).toEqual({ w: 841, h: 1189 });
expect(sheetSizeMm("a1", "portrait")).toEqual({ w: 594, h: 841 });
expect(sheetSizeMm("a2", "portrait")).toEqual({ w: 420, h: 594 });
expect(sheetSizeMm("a5", "portrait")).toEqual({ w: 148, h: 210 });
expect(sheetSizeMm("a6", "portrait")).toEqual({ w: 105, h: 148 });
expect(sheetSizeMm("b0", "portrait")).toEqual({ w: 1000, h: 1414 });
expect(sheetSizeMm("b3", "portrait")).toEqual({ w: 353, h: 500 });
expect(sheetSizeMm("b6", "portrait")).toEqual({ w: 125, h: 176 });
});
it("Querformat dreht die Basis (Beispiel A0)", () => {
expect(sheetSizeMm("a0", "landscape")).toEqual({ w: 1189, h: 841 });
});
it("jedes Format hat eine definierte Grösse", () => {
for (const f of PAPER_FORMATS) {
const s = sheetSizeMm(f, "portrait");
expect(s.w).toBeGreaterThan(0);
expect(s.h).toBeGreaterThan(0);
}
});
});
describe("isDescendant — Zyklus-Schutz", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
createLayoutFolder("C", "C", "B"),
createLayoutFolder("X", "X"),
];
it("ein Ordner ist sein eigener Nachfahre (self)", () => {
expect(isDescendant(folders, "A", "A")).toBe(true);
});
it("erkennt direkte + tiefe Nachfahren", () => {
expect(isDescendant(folders, "B", "A")).toBe(true);
expect(isDescendant(folders, "C", "A")).toBe(true);
});
it("nicht-verwandte Ordner sind keine Nachfahren", () => {
expect(isDescendant(folders, "X", "A")).toBe(false);
expect(isDescendant(folders, "A", "B")).toBe(false);
});
});
describe("moveLayoutToFolder", () => {
const layouts = [lay("l1", "A"), lay("l2")];
it("setzt folderId auf den Zielordner", () => {
const r = moveLayoutToFolder(layouts, "l2", "A");
expect(r.find((l) => l.id === "l2")!.folderId).toBe("A");
});
it("null → zurück auf Wurzel (folderId entfernt)", () => {
const r = moveLayoutToFolder(layouts, "l1", null);
expect(r.find((l) => l.id === "l1")!.folderId).toBeUndefined();
expect("folderId" in r.find((l) => l.id === "l1")!).toBe(false);
});
it("unbekannte Id → unverändert", () => {
expect(moveLayoutToFolder(layouts, "nope", "A")).toEqual(layouts);
});
});
describe("moveFolderToFolder", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
createLayoutFolder("X", "X"),
];
it("setzt parentId auf den Zielordner", () => {
const r = moveFolderToFolder(folders, "X", "A");
expect(r.find((f) => f.id === "X")!.parentId).toBe("A");
});
it("null → zurück auf Wurzel (parentId entfernt)", () => {
const r = moveFolderToFolder(folders, "B", null);
expect(r.find((f) => f.id === "B")!.parentId).toBeUndefined();
});
it("kind bleibt beim Wurzel-Verschieben erhalten (Master-Ordner)", () => {
const withMaster: LayoutFolder[] = [
createLayoutFolder("P", "P", undefined, "master"),
createLayoutFolder("Q", "Q", "P", "master"),
];
const r = moveFolderToFolder(withMaster, "Q", null);
const q = r.find((f) => f.id === "Q")!;
expect(q.parentId).toBeUndefined();
expect(q.kind).toBe("master");
});
it("Zyklus (in eigenen Nachfahren) → No-op, Original zurück", () => {
expect(moveFolderToFolder(folders, "A", "B")).toEqual(folders);
});
it("in sich selbst → No-op", () => {
expect(moveFolderToFolder(folders, "A", "A")).toEqual(folders);
});
});
+673
View File
@@ -0,0 +1,673 @@
// Layout-Blätter mit Masterlayout (DOSSIER A3) — reine, testbare Logik.
//
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf Layouts/Master/
// Viewports, die Master-Vererbung der Titelblock-Werte, die Auflösung eines
// Viewports auf seinen gebundenen Ausschnitt und die Blatt-Geometrie (Papier-mm).
// Das UI-Wiring (App.tsx setProject, LayoutEditor-Rendering) baut hierauf auf.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
Layout,
LayoutAnnotation,
LayoutAnnotationPatch,
LayoutFolder,
LayoutOrientation,
LayoutPaperFormat,
LayoutTitleBlock,
LayoutViewport,
MasterLayout,
Project,
ViewSnapshot,
} from "../model/types";
// ── Blatt-Geometrie ──────────────────────────────────────────────────────────
/**
* Blattmasse je Format im Hochformat (mm) offizielle ISO-216-Masse (auf ganze
* mm gerundet). A-Reihe A0A6 und B-Reihe B0B6.
*/
const PAGE_MM: Record<LayoutPaperFormat, { w: number; h: number }> = {
a0: { w: 841, h: 1189 },
a1: { w: 594, h: 841 },
a2: { w: 420, h: 594 },
a3: { w: 297, h: 420 },
a4: { w: 210, h: 297 },
a5: { w: 148, h: 210 },
a6: { w: 105, h: 148 },
b0: { w: 1000, h: 1414 },
b1: { w: 707, h: 1000 },
b2: { w: 500, h: 707 },
b3: { w: 353, h: 500 },
b4: { w: 250, h: 353 },
b5: { w: 176, h: 250 },
b6: { w: 125, h: 176 },
};
/**
* Auswahlreihenfolge der Standard-Formate für Dropdowns (A-Reihe, dann B-Reihe).
* Gruppierbar über {@link PAPER_FORMAT_GROUPS} (A/B) für optgroups.
*/
export const PAPER_FORMATS: LayoutPaperFormat[] = [
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
];
/** Format-Liste je Reihe (A/B) — für gruppierte Dropdown-Anzeige (optgroup). */
export const PAPER_FORMAT_GROUPS: { label: string; formats: LayoutPaperFormat[] }[] = [
{ label: "A", formats: ["a0", "a1", "a2", "a3", "a4", "a5", "a6"] },
{ label: "B", formats: ["b0", "b1", "b2", "b3", "b4", "b5", "b6"] },
];
/**
* Blattmasse (mm) nach Format + Ausrichtung (Quer = Basis gedreht). Sind
* `customWidthMm`/`customHeightMm` (freie Grösse) gesetzt und positiv, gewinnen
* sie und `paper`/`orientation` werden ignoriert (additiv Alt-Aufrufe ohne
* Custom-Argumente verhalten sich unverändert).
*/
export function sheetSizeMm(
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
customWidthMm?: number,
customHeightMm?: number,
): { w: number; h: number } {
if (
customWidthMm !== undefined &&
customHeightMm !== undefined &&
customWidthMm > 0 &&
customHeightMm > 0
) {
return { w: customWidthMm, h: customHeightMm };
}
const base = PAGE_MM[paper];
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
}
/**
* Effektive Blattgrösse eines Layouts/Masters (mm) respektiert die freie
* Grösse (`customWidthMm`/`customHeightMm`), sonst `paper`+`orientation`. EIN
* Ort für die Custom übersteuert Papier"-Regel (Panel/PDF/Editor teilen ihn).
*/
export function effectiveSheetSizeMm(item: {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}): { w: number; h: number } {
return sheetSizeMm(
item.paper,
item.orientation,
item.customWidthMm,
item.customHeightMm,
);
}
// ── Fabriken ─────────────────────────────────────────────────────────────────
/** Neues, leeres Layout (ohne Viewports, ohne Master). */
export function createLayout(
id: string,
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
): Layout {
return { id, name, paper, orientation, viewports: [] };
}
/** Neues Masterlayout mit leerem Titelblock + gezeichnetem Rahmen. */
export function createMasterLayout(
id: string,
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
custom?: { widthMm: number; heightMm: number },
): MasterLayout {
return {
id,
name,
paper,
orientation,
titleBlock: {},
border: true,
...(custom ? { customWidthMm: custom.widthMm, customHeightMm: custom.heightMm } : {}),
};
}
/**
* Neuer, leerer Ordner für den Layouts- ODER Master-Baum. `kind:"master"` markiert
* einen Master-Ordner; `"layout"`/`undefined` bleibt ein (rückwärtskompatibler)
* Layout-Ordner das Feld wird dann NICHT gesetzt (Alt-Projekte unverändert).
*/
export function createLayoutFolder(
id: string,
name: string,
parentId?: string,
kind?: "layout" | "master",
): LayoutFolder {
return {
id,
name,
...(parentId ? { parentId } : {}),
...(kind === "master" ? { kind } : {}),
};
}
// ── Layouts-Baum (Ordner + Blätter) ──────────────────────────────────────────
/** Ein Ordner-Knoten im Baum: der Ordner, seine Unterordner und direkten Blätter. */
export interface FolderNode {
folder: LayoutFolder;
subfolders: FolderNode[];
layouts: Layout[];
}
/** Der aufgebaute Baum: Wurzel-Blätter (ohne Ordner) + Wurzel-Ordner. */
export interface LayoutTree {
/** Layouts ohne (gültigen) `folderId` — auf Wurzelebene. */
rootLayouts: Layout[];
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
rootFolders: FolderNode[];
}
/**
* Baut aus der flachen Ordner-/Layout-Liste den verschachtelten Baum. Robust
* gegen verwaiste Referenzen: ein Layout mit unbekanntem `folderId` bzw. ein
* Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben
* Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten.
*/
export function buildLayoutTree(
folders: LayoutFolder[],
layouts: Layout[],
): LayoutTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, FolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], layouts: [] });
}
const rootFolders: FolderNode[] = [];
for (const f of folders) {
const node = nodes.get(f.id)!;
const parent =
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
if (parent) parent.subfolders.push(node);
else rootFolders.push(node);
}
const rootLayouts: Layout[] = [];
for (const l of layouts) {
const node =
l.folderId && folderIds.has(l.folderId) ? nodes.get(l.folderId) : undefined;
if (node) node.layouts.push(l);
else rootLayouts.push(l);
}
return { rootLayouts, rootFolders };
}
// ── Master-Baum (Ordner + Masterlayouts) ─────────────────────────────────────
// Eigener Baum NEBEN dem Layout-Baum: Master-Ordner (`kind:"master"`) tragen
// Masterlayouts als Blätter. Bewusst KEINE gemeinsame Struktur mit dem Layout-
// Baum, damit Layout- und Master-Ordner nie vermischt werden.
/** Ein Master-Ordner-Knoten: der Ordner, seine Unterordner und direkten Master. */
export interface MasterFolderNode {
folder: LayoutFolder;
subfolders: MasterFolderNode[];
masters: MasterLayout[];
}
/** Der Master-Baum: Wurzel-Master (ohne Ordner) + Wurzel-Ordner. */
export interface MasterTree {
/** Masterlayouts ohne (gültigen) `folderId` — auf Wurzelebene. */
rootMasters: MasterLayout[];
/** Master-Ordner ohne (gültigen) `parentId` — je mit Unterbaum. */
rootFolders: MasterFolderNode[];
}
/**
* Baut aus der flachen Master-Ordner-/Master-Liste den verschachtelten Baum
* analog {@link buildLayoutTree}, aber mit Masterlayouts als Blättern. Der
* Aufrufer übergibt bereits die NUR-Master-Ordner (kind:"master"); verwaiste
* Referenzen landen robust auf der Wurzelebene.
*/
export function buildMasterTree(
folders: LayoutFolder[],
masters: MasterLayout[],
): MasterTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, MasterFolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], masters: [] });
}
const rootFolders: MasterFolderNode[] = [];
for (const f of folders) {
const node = nodes.get(f.id)!;
const parent =
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
if (parent) parent.subfolders.push(node);
else rootFolders.push(node);
}
const rootMasters: MasterLayout[] = [];
for (const m of masters) {
const node =
m.folderId && folderIds.has(m.folderId) ? nodes.get(m.folderId) : undefined;
if (node) node.masters.push(m);
else rootMasters.push(m);
}
return { rootMasters, rootFolders };
}
/**
* Alle Layouts eines Ordners in Baum-Reihenfolge direkte Blätter zuerst, dann
* (rekursiv) die Blätter der Unterordner. Für den OrdnerMehrseiten-PDF-Export
* (jedes Layout = eine Seite, deterministische Reihenfolge).
*/
export function collectFolderLayouts(
folderId: string,
folders: LayoutFolder[],
layouts: Layout[],
): Layout[] {
const tree = buildLayoutTree(folders, layouts);
const find = (nodes: FolderNode[]): FolderNode | undefined => {
for (const n of nodes) {
if (n.folder.id === folderId) return n;
const deep = find(n.subfolders);
if (deep) return deep;
}
return undefined;
};
const node = find(tree.rootFolders);
if (!node) return [];
const out: Layout[] = [];
const walk = (n: FolderNode) => {
out.push(...n.layouts);
for (const sub of n.subfolders) walk(sub);
};
walk(node);
return out;
}
/** Eine geplante Ordner-PDF-Seite: das Layout + seine effektive Blattgrösse (mm). */
export interface FolderPdfPage {
layout: Layout;
widthMm: number;
heightMm: number;
}
/**
* Reine Seiten-Planung des OrdnerMehrseiten-PDF (ohne DOM/jsPDF): welche
* Layouts in welcher Reihenfolge mit welcher Blattgrösse. Reihenfolge =
* Baum-Reihenfolge (siehe {@link collectFolderLayouts}). Die effektive Grösse
* respektiert die freie Grösse (Custom übersteuert Papier); ein gebundener
* Master erzwingt die Blattgrösse (das Blatt erbt Papier/Ausrichtung des Masters).
*/
export function folderPdfPages(
project: Project,
folderId: string,
): FolderPdfPage[] {
const folders = project.layoutFolders ?? [];
const layouts = project.layouts ?? [];
return collectFolderLayouts(folderId, folders, layouts).map((layout) => {
const master = findMaster(project, layout);
const { w, h } = effectiveSheetSizeMm(master ?? layout);
return { layout, widthMm: w, heightMm: h };
});
}
/**
* Löscht einen Ordner (immutabel). Unterordner und Layouts des Ordners werden
* auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen, statt
* mitgelöscht zu werden es gehen keine Blätter verloren. Liefert die neuen
* `folders`/`layouts`-Listen.
*/
export function deleteFolder(
folderId: string,
folders: LayoutFolder[],
layouts: Layout[],
): { folders: LayoutFolder[]; layouts: Layout[] } {
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
const nextLayouts = layouts.map((l) =>
l.folderId === folderId
? grandParentId
? { ...l, folderId: grandParentId }
: { ...l, folderId: undefined }
: l,
);
return { folders: nextFolders, layouts: nextLayouts };
}
/**
* Löscht einen Master-Ordner (immutabel) analog {@link deleteFolder}, aber die
* enthaltenen Masterlayouts (und Unterordner) werden auf die Elternebene
* hochgezogen statt mitgelöscht. Liefert die neuen `folders`/`masters`-Listen.
*/
export function deleteMasterFolder(
folderId: string,
folders: LayoutFolder[],
masters: MasterLayout[],
): { folders: LayoutFolder[]; masters: MasterLayout[] } {
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
const nextMasters = masters.map((m) =>
m.folderId === folderId
? grandParentId
? { ...m, folderId: grandParentId }
: { ...m, folderId: undefined }
: m,
);
return { folders: nextFolders, masters: nextMasters };
}
/**
* Entfernt einen Ordner aus der flachen Liste und zieht dessen direkte
* Unterordner auf die Elternebene (`grandParentId`). Das `kind`-Feld bleibt dabei
* ERHALTEN (Spread statt Neu-Bau), damit ein hochgezogener Master-Ordner nicht
* versehentlich zum Layout-Ordner wird. Liefert die neue Liste + `grandParentId`
* (den die Blatt-Reparentierung der Aufrufer nutzt).
*/
function foldersAfterDelete(
folderId: string,
folders: LayoutFolder[],
): { folders: LayoutFolder[]; grandParentId?: string } {
const target = folders.find((f) => f.id === folderId);
const grandParentId = target?.parentId;
const nextFolders = folders
.filter((f) => f.id !== folderId)
.map((f) =>
f.parentId === folderId
? grandParentId
? { ...f, parentId: grandParentId }
: stripParentId(f)
: f,
);
return { folders: nextFolders, grandParentId };
}
/** Ordner auf die Wurzelebene setzen (parentId entfernen, `kind` bleibt erhalten). */
function stripParentId(folder: LayoutFolder): LayoutFolder {
const next = { ...folder };
delete next.parentId;
return next;
}
// ── Baum-Umsortieren (Drag & Drop) ───────────────────────────────────────────
/**
* Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den
* Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich
* selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum).
* Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab).
*/
export function isDescendant(
folders: LayoutFolder[],
candidateId: string,
rootId: string,
): boolean {
if (candidateId === rootId) return true;
const byId = new Map(folders.map((f) => [f.id, f]));
const seen = new Set<string>();
let cur = byId.get(candidateId);
while (cur && cur.parentId && !seen.has(cur.id)) {
if (cur.parentId === rootId) return true;
seen.add(cur.id);
cur = byId.get(cur.parentId);
}
return false;
}
/**
* Verschiebt ein Layout in einen anderen Ordner (immutabel). `folderId = null`
* zurück auf die Wurzelebene. No-op, wenn die Layout-Id fehlt.
*/
export function moveLayoutToFolder(
layouts: Layout[],
layoutId: string,
folderId: string | null,
): Layout[] {
return layouts.map((l) => {
if (l.id !== layoutId) return l;
if (folderId) return { ...l, folderId };
const next = { ...l };
delete next.folderId;
return next;
});
}
/**
* Verschiebt einen Ordner unter einen neuen Elternordner (immutabel).
* `parentId = null` Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in
* sich selbst oder einen seiner Nachfahren wandern (dann No-op Original zurück).
*/
export function moveFolderToFolder(
folders: LayoutFolder[],
folderId: string,
parentId: string | null,
): LayoutFolder[] {
if (parentId && isDescendant(folders, parentId, folderId)) return folders;
return folders.map((f) => {
if (f.id !== folderId) return f;
if (parentId) return { ...f, parentId };
return stripParentId(f);
});
}
/**
* Neuer Viewport (Standardgrösse/-position). `snapshotId` bindet ihn an einen
* Ausschnitt; Position/Grösse in Papier-mm setzt der Aufrufer per {@link patchViewport}.
*/
export function createViewport(
id: string,
snapshotId: string,
init?: Partial<Omit<LayoutViewport, "id" | "snapshotId">>,
): LayoutViewport {
return {
id,
snapshotId,
xMm: init?.xMm ?? 20,
yMm: init?.yMm ?? 20,
widthMm: init?.widthMm ?? 120,
heightMm: init?.heightMm ?? 90,
...(init?.scaleDenominator !== undefined
? { scaleDenominator: init.scaleDenominator }
: {}),
};
}
// ── Immutable CRUD (Viewports auf einem Layout) ──────────────────────────────
/** Fügt einen Viewport an (immutabel). */
export function addViewport(layout: Layout, viewport: LayoutViewport): Layout {
return { ...layout, viewports: [...layout.viewports, viewport] };
}
/** Entfernt einen Viewport per Id (immutabel). */
export function removeViewport(layout: Layout, viewportId: string): Layout {
return {
...layout,
viewports: layout.viewports.filter((v) => v.id !== viewportId),
};
}
/** Ändert Felder eines Viewports (immutabel). No-op, wenn die Id fehlt. */
export function patchViewport(
layout: Layout,
viewportId: string,
patch: Partial<Omit<LayoutViewport, "id">>,
): Layout {
return {
...layout,
viewports: layout.viewports.map((v) =>
v.id === viewportId ? { ...v, ...patch } : v,
),
};
}
// ── Fabrik + immutable CRUD (Annotationen auf einem Layout) ─────────────────
// Analog zu Viewports: Linie/Rechteck/Text als vom Viewport-Baum unabhängige
// Markup-Schicht in Papier-mm. `annotations ?? []` ist überall die Basis, damit
// Alt-Layouts (ohne das Feld) unverändert bleiben (additiv).
/**
* Neue Annotation mit Default-Geometrie je Art (Linie/Rechteck: sichtbare
* Mindestgrösse; Text: 3.5 mm Zeilenhöhe, wie im Attribute-Editor üblich).
* `init` überschreibt einzelne Felder der Aufrufer (Editor) übergibt i. d. R.
* die vollständige, aus dem Zeichnen gewonnene Geometrie.
*/
export function createAnnotation(
id: string,
kind: LayoutAnnotation["kind"],
init: LayoutAnnotationPatch = {},
): LayoutAnnotation {
const colorPatch = init.color !== undefined ? { color: init.color } : {};
if (kind === "line") {
return {
id,
kind: "line",
x1Mm: init.x1Mm ?? 20,
y1Mm: init.y1Mm ?? 20,
x2Mm: init.x2Mm ?? 80,
y2Mm: init.y2Mm ?? 20,
...colorPatch,
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
if (kind === "rect") {
return {
id,
kind: "rect",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
widthMm: init.widthMm ?? 40,
heightMm: init.heightMm ?? 30,
...colorPatch,
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
return {
id,
kind: "text",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
text: init.text ?? "",
heightMm: init.heightMm ?? 3.5,
...colorPatch,
};
}
/** Fügt eine Annotation an (immutabel). */
export function addAnnotation(layout: Layout, annotation: LayoutAnnotation): Layout {
return { ...layout, annotations: [...(layout.annotations ?? []), annotation] };
}
/** Entfernt eine Annotation per Id (immutabel). */
export function removeAnnotation(layout: Layout, annotationId: string): Layout {
return {
...layout,
annotations: (layout.annotations ?? []).filter((a) => a.id !== annotationId),
};
}
/**
* Ändert Felder einer Annotation (immutabel). No-op, wenn die Id fehlt. Der
* Patch ist bewusst art-unabhängig ({@link LayoutAnnotationPatch}) es werden
* nur die tatsächlich übergebenen Felder gemergt.
*/
export function patchAnnotation(
layout: Layout,
annotationId: string,
patch: LayoutAnnotationPatch,
): Layout {
return {
...layout,
annotations: (layout.annotations ?? []).map((a) =>
a.id === annotationId ? ({ ...a, ...patch } as LayoutAnnotation) : a,
),
};
}
// ── Auflösungen ──────────────────────────────────────────────────────────────
/** Findet einen Ausschnitt (View-Snapshot) im Projekt. */
export function findSnapshot(
project: Project,
snapshotId: string,
): ViewSnapshot | undefined {
return (project.viewSnapshots ?? []).find((s) => s.id === snapshotId);
}
/** Findet das Masterlayout eines Layouts (falls gebunden). */
export function findMaster(
project: Project,
layout: Layout,
): MasterLayout | undefined {
if (!layout.masterId) return undefined;
return (project.masterLayouts ?? []).find((m) => m.id === layout.masterId);
}
/**
* Effektiver Massstab-Nenner eines Viewports: sein `scaleDenominator` übersteuert
* den des gebundenen Ausschnitts. Fällt auf `fallback` zurück, wenn weder
* Viewport noch Ausschnitt einen Wert tragen (defensiv, sollte nicht auftreten).
*/
export function resolveViewportScale(
viewport: LayoutViewport,
snapshot: ViewSnapshot | undefined,
fallback = 100,
): number {
return viewport.scaleDenominator ?? snapshot?.scaleDenominator ?? fallback;
}
/** Aufgelöste Titelblock-Werte (alle Felder gefüllt) für die Darstellung. */
export interface ResolvedTitleBlock {
projectName: string;
sheetName: string;
scale: string;
date: string;
author: string;
}
/**
* Master-Vererbung der Titelblock-Werte: der Master liefert die Vorlagen-Texte;
* leere Felder werden aus Projekt/Layout mit sinnvollen Defaults gefüllt
* (Projektname `Project.name`, Blattname `Layout.name`, Datum `today`).
* Ohne Master bleibt der Titelblock trotzdem sinnvoll befüllt (nur Defaults).
* Der `scale`-Default ergibt sich aus dem ERSTEN Viewport des Layouts (falls
* vorhanden), damit ein Ein-Viewport-Blatt gleich seinen Massstab trägt.
*/
export function resolveTitleBlock(
project: Project,
layout: Layout,
master: MasterLayout | undefined,
today: string = new Date().toISOString().slice(0, 10),
): ResolvedTitleBlock {
const tb: LayoutTitleBlock = master?.titleBlock ?? {};
const firstVp = layout.viewports[0];
const firstSnap = firstVp ? findSnapshot(project, firstVp.snapshotId) : undefined;
const scaleN = firstVp
? resolveViewportScale(firstVp, firstSnap)
: undefined;
const nonEmpty = (s: string | undefined): string | undefined =>
s && s.trim() ? s.trim() : undefined;
return {
projectName: nonEmpty(tb.projectName) ?? project.name,
sheetName: nonEmpty(tb.sheetName) ?? layout.name,
scale: nonEmpty(tb.scale) ?? (scaleN ? `1:${scaleN}` : "—"),
date: nonEmpty(tb.date) ?? today,
author: nonEmpty(tb.author) ?? "",
};
}
/**
* Sichtbare Kategorie-Codes eines Ausschnitts als Set (für `generatePlan`). Aus
* der gespeicherten `layerVisibility`-Map (Code sichtbar) abgeleitet.
*/
export function visibleCodesFromSnapshot(snap: ViewSnapshot): Set<string> {
const out = new Set<string>();
for (const [code, visible] of Object.entries(snap.layerVisibility)) {
if (visible) out.add(code);
}
return out;
}
+217 -13
View File
@@ -133,6 +133,12 @@ export interface PlanViewHandle {
pxPerMeter: () => number; pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */ /** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2; viewCenterModel: () => Vec2;
/**
* Auf eine beliebige Punktmenge (Modell-Meter) einpassen, z. B. die Bounding-
* Box neu importierter Kontext-Objekte (Auto-Zoom nach Geo-/DXF-Import).
* Leere Punktmenge No-op (kein Fit, kein Crash).
*/
fitBounds: (pts: Vec2[]) => void;
} }
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */ /** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -175,6 +181,17 @@ export interface PlanSelection {
* sonst `null`. * sonst `null`.
*/ */
roomId: string | null; roomId: string | null;
/**
* ID des getroffenen extrudierten Körpers (truck-Integration, Footprint-
* Umriss), wenn der Klick ihn traf und KEIN Element höherer Priorität
* (Wand/2D/Decke/Öffnung/Treppe); sonst `null`.
*/
extrudedSolidId: string | null;
/**
* ID der getroffenen Stütze (Column, Grundriss-Querschnitt), wenn der Klick sie
* traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`.
*/
columnId: string | null;
/** /**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die * War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu * Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
@@ -263,6 +280,17 @@ export interface PlanViewProps {
* Fläche JEDES dieser Räume mit einer Akzentkontur. * Fläche JEDES dieser Räume mit einer Akzentkontur.
*/ */
selectedRoomIds?: string[]; selectedRoomIds?: string[];
/**
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
* einer Akzentkontur.
*/
selectedExtrudedSolidIds?: string[];
/**
* Aktuell ausgewählte Stützen-IDs (Tragwerk; kontrolliert von App). Die Ansicht
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
*/
selectedColumnIds?: string[];
/** /**
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID. * Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick. * PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
@@ -273,6 +301,8 @@ export interface PlanViewProps {
onStampMove?: (roomId: string, delta: Vec2) => void; onStampMove?: (roomId: string, delta: Vec2) => void;
/** Doppelklick auf einen Raum-Stempel: Editor öffnen. */ /** Doppelklick auf einen Raum-Stempel: Editor öffnen. */
onStampEdit?: (roomId: string) => void; onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/** /**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach * Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional. * der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
@@ -348,7 +378,11 @@ export interface GripHandlers {
* wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt * wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt
* `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität). * `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität).
*/ */
onMoveBody(delta: Vec2, target?: { wallId?: string; drawingId?: string }): void; onMoveBody(
delta: Vec2,
target?: { wallId?: string; drawingId?: string },
mods?: ToolMods,
): void;
/** /**
* Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht * Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht
* (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe * (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe
@@ -374,9 +408,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedOpeningIds, selectedOpeningIds,
selectedStairIds, selectedStairIds,
selectedRoomIds, selectedRoomIds,
selectedExtrudedSolidIds,
selectedColumnIds,
roomStamp, roomStamp,
onStampMove, onStampMove,
onStampEdit, onStampEdit,
onEditDrawingText,
activeTool, activeTool,
toolInputActive, toolInputActive,
toolHandlers, toolHandlers,
@@ -638,6 +675,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const v = viewRef.current; const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M }; return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
}, },
fitBounds: (pts) => {
if (pts.length === 0) return; // leerer Import → kein Fit, kein Crash
setView(fitBoxFor(pts, toScreenRef.current));
},
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[], [],
@@ -841,7 +882,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
bestId = p.drawingId; bestId = p.drawingId;
} }
} }
return bestId; if (bestId) return bestId;
// Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik).
return pickDrawingText(clientX, clientY);
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
const pickDrawingText = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (const p of plan.primitives) {
if (p.kind !== "drawingText" || !p.drawingId) continue;
const singleW = Math.max(1, p.text.length) * p.heightM * 0.6;
// Textspalte: Breite = wrapWidth, Höhe = geschätzte Zeilenzahl · Zeilenhöhe.
const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW;
const lines =
p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1;
const pad = p.heightM * 0.6;
const minX = p.at.x - pad;
const maxX = p.at.x + w + pad;
// Baseline am Anker; Text läuft nach unten (Zeilen) und etwas nach oben (Oberlängen).
const minY = p.at.y - lines * p.heightM * 1.25 - pad;
const maxY = p.at.y + p.heightM * 0.4 + pad;
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) {
return p.drawingId;
}
}
return null;
}; };
// Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit // Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit
@@ -917,6 +985,35 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null; 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).
const pickExtrudedSolid = (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.extrudedSolidId && pointInPolygon(m, p.pts)) {
return p.extrudedSolidId;
}
}
return null;
};
// Getroffene Stütze (Tragwerk): ihr Querschnitt ist eine gefüllte Poché
// (Polygon mit columnId) — per Punkt-in-Polygon als FLÄCHE anklickbar.
const pickColumn = (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.columnId && pointInPolygon(m, p.pts)) {
return p.columnId;
}
}
return null;
};
// Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw. // Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw.
// 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das // 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das
// Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER // Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER
@@ -1188,7 +1285,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (md && md.pointerId === p.pointerId) { if (md && md.pointerId === p.pointerId) {
const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y }; const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y };
if (delta.x !== 0 || delta.y !== 0) { if (delta.x !== 0 || delta.y !== 0) {
gripHandlers!.onMoveBody(delta, md.target); gripHandlers!.onMoveBody(delta, md.target, mods);
md.last = raw; md.last = raw;
} }
return; return;
@@ -1402,28 +1499,46 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const openingId = pickOpening(e.clientX, e.clientY); const openingId = pickOpening(e.clientX, e.clientY);
const wallId = const wallId =
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null; openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
// Traf der Klick KEINE Wand/Öffnung: zuerst eine getroffene gefüllte 2D-Fläche // Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché —
// (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe. // ein direkter Klick auf sie trifft zuerst die Stütze (hit.columnId), sonst
// die Fläche per Punkt-in-Polygon. Priorität direkt nach Öffnung/Wand.
const polyColumnId =
hit && hit.kind === "polygon" ? hit.columnId ?? null : null;
const columnId =
openingId || wallId
? null
: polyColumnId ?? pickColumn(e.clientX, e.clientY);
// Traf der Klick KEINE Wand/Öffnung/Stütze: zuerst eine getroffene gefüllte
// 2D-Fläche (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
const polyDrawingId = const polyDrawingId =
hit && hit.kind === "polygon" ? hit.drawingId ?? null : null; hit && hit.kind === "polygon" ? hit.drawingId ?? null : null;
const drawingId = const drawingId =
openingId || wallId openingId || wallId || columnId
? null ? null
: polyDrawingId ?? pickDrawing(e.clientX, e.clientY); : polyDrawingId ?? pickDrawing(e.clientX, e.clientY);
// Extrudierter Körper (truck-Integration): sein Footprint-Umriss ersetzt
// die frühere Drawing2D-Fläche, also gleiche Priorität wie drawingId.
const polyExtrudedSolidId =
hit && hit.kind === "polygon" ? hit.extrudedSolidId ?? null : null;
const extrudedSolidId =
openingId || wallId || columnId || drawingId
? null
: polyExtrudedSolidId ?? pickExtrudedSolid(e.clientX, e.clientY);
// Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die // Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die
// Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick // Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick
// auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke. // auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke.
const hitCeilingId = const hitCeilingId =
hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null; hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null;
const ceilingId = openingId || wallId || drawingId ? null : hitCeilingId; const ceilingId =
openingId || wallId || columnId || drawingId || extrudedSolidId ? null : hitCeilingId;
// Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei). // Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei).
const stairId = const stairId =
openingId || wallId || drawingId || ceilingId openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
? null ? null
: pickStair(e.clientX, e.clientY); : pickStair(e.clientX, e.clientY);
// Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen. // Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen.
const roomId = const roomId =
openingId || wallId || drawingId || ceilingId || stairId openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
? null ? null
: pickRoom(e.clientX, e.clientY); : pickRoom(e.clientX, e.clientY);
onSelect({ onSelect({
@@ -1435,6 +1550,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
openingId, openingId,
stairId, stairId,
roomId, roomId,
extrudedSolidId,
columnId,
shift: e.shiftKey, shift: e.shiftKey,
}); });
} }
@@ -1490,6 +1607,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
toolHandlers!.onToolCommit(); toolHandlers!.onToolCommit();
return; return;
} }
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
if (onEditDrawingText) {
const tid = pickDrawingText(e.clientX, e.clientY);
if (tid) {
onEditDrawingText(tid);
return;
}
}
// Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor. // Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor.
if (onStampEdit) { if (onStampEdit) {
const rid = const rid =
@@ -1614,6 +1739,33 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
); );
}, [plan, selectedRoomIds]); }, [plan, selectedRoomIds]);
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
// — analog zur Raum-Auswahl.
const highlightExtrudedSolidPolys = useMemo(() => {
const ids =
selectedExtrudedSolidIds && selectedExtrudedSolidIds.length
? new Set(selectedExtrudedSolidIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.extrudedSolidId != null && ids.has(p.extrudedSolidId),
);
}, [plan, selectedExtrudedSolidIds]);
// Hervorhebung der selektierten Stützen (Tragwerk): der Querschnitt-Umriss
// (Polygon mit columnId) mit Akzentkontur überzeichnen — analog Extrusion/Raum.
const highlightColumnPolys = useMemo(() => {
const ids =
selectedColumnIds && selectedColumnIds.length ? new Set(selectedColumnIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.columnId != null && ids.has(p.columnId),
);
}, [plan, selectedColumnIds]);
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen // Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können — // bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die // so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
@@ -1819,6 +1971,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none" pointerEvents="none"
/> />
))} ))}
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
{highlightExtrudedSolidPolys.map((poly, i) => (
<polygon
key={`selex-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Stützen (Tragwerk). */}
{highlightColumnPolys.map((poly, i) => (
<polygon
key={`selcol-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */} {/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */}
{highlightOpeningPrims.map((p, i) => {highlightOpeningPrims.map((p, i) =>
p.kind === "line" ? ( p.kind === "line" ? (
@@ -2024,7 +2200,7 @@ function DraftOverlay({
snapColor: string; snapColor: string;
}) { }) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich) const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 4.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (gleich groß wie Stützpunkt) const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar)
return ( return (
<g className="tool-overlay" pointerEvents="none"> <g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */} {/* Vorschau-Formen. */}
@@ -3081,12 +3257,32 @@ function renderPrimitive(
); );
} }
case "drawingText": { case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox- // Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern →
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). // viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
const c = toScreen(p.at); const c = toScreen(p.at);
const fontSize = p.heightM * PX_PER_M; const fontSize = p.heightM * PX_PER_M;
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg. // Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
const deg = -(p.angle * 180) / Math.PI; const deg = -(p.angle * 180) / Math.PI;
// Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite
// umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile.
const lineH = fontSize * 1.25;
const lines: string[] = [];
if (p.wrapWidth && p.wrapWidth > 0) {
const maxPx = p.wrapWidth * PX_PER_M;
const charPx = fontSize * 0.55;
const maxChars = Math.max(1, Math.floor(maxPx / charPx));
for (const paragraph of p.text.split("\n")) {
let line = "";
for (const word of paragraph.split(/\s+/)) {
if (line === "") line = word;
else if ((line.length + 1 + word.length) <= maxChars) line += " " + word;
else { lines.push(line); line = word; }
}
lines.push(line);
}
} else {
lines.push(p.text);
}
return ( return (
<text <text
x={c.x} x={c.x}
@@ -3099,7 +3295,15 @@ function renderPrimitive(
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined} transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }} style={{ pointerEvents: "none", userSelect: "none" }}
> >
{p.text} {lines.length <= 1 ? (
p.text
) : (
lines.map((ln, li) => (
<tspan key={li} x={c.x} dy={li === 0 ? 0 : lineH}>
{ln === "" ? "" : ln}
</tspan>
))
)}
</text> </text>
); );
} }
+162
View File
@@ -0,0 +1,162 @@
/**
* Unit-Tests für das Stützen-Bauteil (Column) DOSSIER-Audit A4 Tragwerk:
* 2D-Grundriss (generatePlan): eine Stütze erzeugt ein Poché-Polygon mit
* columnId an der richtigen Position; die Rotation dreht den Rechteck-
* Querschnitt.
* 3D (projectToModel3d/emitColumns): eine Stütze wird zu einem Prisma-Mesh
* (Rechteck-Box bzw. Kreis-Prisma) mit der erwarteten vertikalen Ausdehnung.
* Vertikale Lage (columnVerticalExtent): Geschoss-Default sowie UK/OK-Anker.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import { projectToModel3d } from "./toWalls3d";
import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import type { Column, Project } from "../model/types";
/** Minimalprojekt (zwei Geschosse gestapelt) mit einer konfigurierbaren Stütze. */
function projectWithColumn(column: Column): Project {
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#c8c8cc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 3 },
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "50", name: "Tragwerk", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
columns: [column],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20", "50"]);
/** Basis-Stütze: Rechteck 0.4×0.2 an (2,3), Höhe 3, keine Drehung. */
const rectColumn: Column = {
id: "C1",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 2, y: 3 },
profile: { kind: "rect", width: 0.4, depth: 0.2 },
rotation: 0,
height: 3,
};
describe("Stütze — 2D-Grundriss (generatePlan)", () => {
it("erzeugt ein Poché-Polygon mit columnId am Rechteck-Querschnitt", () => {
const plan = generatePlan(projectWithColumn(rectColumn), "eg", visible);
const polys = plan.primitives.filter(
(p) => p.kind === "polygon" && p.columnId === "C1",
);
expect(polys.length).toBe(1);
const poly = polys[0];
if (poly.kind !== "polygon") throw new Error("kein Polygon");
// Rechteck 0.4×0.2 um (2,3): Ecken bei x∈{1.8,2.2}, y∈{2.9,3.1}.
const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b);
const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b);
expect(poly.pts.length).toBe(4);
expect(xs[0]).toBeCloseTo(1.8, 6);
expect(xs[3]).toBeCloseTo(2.2, 6);
expect(ys[0]).toBeCloseTo(2.9, 6);
expect(ys[3]).toBeCloseTo(3.1, 6);
});
it("dreht den Querschnitt um 90° (Breite/Tiefe vertauscht)", () => {
const rotated: Column = { ...rectColumn, rotation: Math.PI / 2 };
const plan = generatePlan(projectWithColumn(rotated), "eg", visible);
const poly = plan.primitives.find(
(p) => p.kind === "polygon" && p.columnId === "C1",
);
if (!poly || poly.kind !== "polygon") throw new Error("kein Polygon");
// Nach 90°-Drehung: X-Ausdehnung = ±depth/2 = ±0.1, Y = ±width/2 = ±0.2.
const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b);
const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b);
expect(xs[0]).toBeCloseTo(1.9, 6);
expect(xs[3]).toBeCloseTo(2.1, 6);
expect(ys[0]).toBeCloseTo(2.8, 6);
expect(ys[3]).toBeCloseTo(3.2, 6);
});
it("blendet die Stütze aus, wenn Kategorie 50 unsichtbar ist", () => {
const plan = generatePlan(projectWithColumn(rectColumn), "eg", new Set(["20"]));
expect(plan.primitives.some((p) => p.kind === "polygon" && p.columnId)).toBe(false);
});
});
describe("Stütze — 3D (projectToModel3d / emitColumns)", () => {
it("erzeugt eine Rechteck-Box (8 Ecken) über die volle Höhe", () => {
const model = projectToModel3d(projectWithColumn(rectColumn));
const meshes = model.meshes.filter((m) => m.kind === "extrusion");
expect(meshes.length).toBe(1);
const mesh = meshes[0];
// 4 Footprint-Punkte × (unten + oben) = 8 Vertices.
expect(mesh.positions.length).toBe(8 * 3);
// z-Werte (jedes 3. Element) liegen bei 0 (UK) und 3 (OK).
const zs = mesh.positions.filter((_, i) => i % 3 === 2);
expect(Math.min(...zs)).toBeCloseTo(0, 6);
expect(Math.max(...zs)).toBeCloseTo(3, 6);
});
it("erzeugt ein tesselliertes Kreis-Prisma (64 Ecken)", () => {
const round: Column = {
...rectColumn,
profile: { kind: "round", radius: 0.15 },
};
const model = projectToModel3d(projectWithColumn(round));
const mesh = model.meshes.find((m) => m.kind === "extrusion");
if (!mesh) throw new Error("kein Prisma-Mesh");
// 32-Eck × (unten + oben) = 64 Vertices.
expect(mesh.positions.length).toBe(64 * 3);
});
});
describe("Stütze — vertikale Lage (columnVerticalExtent)", () => {
it("nutzt Geschoss-UK + Höhe als Default", () => {
const { zBottom, zTop } = columnVerticalExtent(
projectWithColumn(rectColumn),
rectColumn,
);
expect(zBottom).toBeCloseTo(0, 6);
expect(zTop).toBeCloseTo(3, 6);
});
it("bindet UK/OK an Anker (custom + floor)", () => {
const anchored: Column = {
...rectColumn,
bottom: { mode: "custom", z: 0.5 },
top: { mode: "floor", floorId: "og" }, // baseElevation OG = 3
};
const { zBottom, zTop } = columnVerticalExtent(projectWithColumn(anchored), anchored);
expect(zBottom).toBeCloseTo(0.5, 6);
expect(zTop).toBeCloseTo(3, 6);
});
});
describe("Stütze — Footprint-Helfer (columnFootprint)", () => {
it("tesselliert ein rundes Profil als 32-Eck um die Position", () => {
const round: Column = { ...rectColumn, profile: { kind: "round", radius: 0.15 } };
const pts = columnFootprint(round);
expect(pts.length).toBe(32);
// Alle Punkte liegen im Radius 0.15 um (2,3).
for (const p of pts) {
const d = Math.hypot(p.x - 2, p.y - 3);
expect(d).toBeCloseTo(0.15, 6);
}
});
});
+181
View File
@@ -0,0 +1,181 @@
// Integrations-Tests: der Grundriss-Pfad (generatePlan) wendet die grafischen
// Override-Regeln (Project.overrideRules) als OBERSTE Schicht über der
// By-Layer/By-Object-Auflösung an — als reines Rendering-Overlay (die
// Elementdaten bleiben unverändert).
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { OverrideRule, Project, Wall } from "../model/types";
/** Minimalprojekt: eine Wand (Ebene „Wände"/20) + eine 2D-Linie (Ebene 60). */
function project(overrideRules?: OverrideRule[]): Project {
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 4, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
return {
id: "t",
name: "T",
lineStyles: [
{ id: "dashed", name: "Gestrichelt", weight: 0.13, color: "#111", dash: [2, 1] },
],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW Beton", layers: [{ componentId: "a", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "60", name: "Zeichnung", color: "#0a0a0a", lw: 0.25, visible: true, locked: false },
],
walls: [wall],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [
{
id: "D1",
type: "drawing2d",
levelId: "eg",
categoryCode: "60",
geom: { shape: "line", a: { x: 0, y: 1 }, b: { x: 2, y: 1 } },
},
],
context: [],
overrideRules,
};
}
const visible = new Set(["20", "60"]);
/** Alle Polygone der Wand W1 (grob = eine Sammelfläche mit Umriss). */
function wallPolys(p: ReturnType<typeof generatePlan>) {
return p.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "polygon" }> =>
pr.kind === "polygon" && pr.wallId === "W1",
);
}
/** Die Linien-Primitive des 2D-Elements D1. */
function drawingLines(p: ReturnType<typeof generatePlan>) {
return p.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "line" }> =>
pr.kind === "line" && pr.drawingId === "D1",
);
}
describe("generatePlan — grafische Overrides (Regel-Engine)", () => {
it("layer_name-Regel färbt die Wand-Umrisslinie um (Farbe-Aktion)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "grob");
const polys = wallPolys(plan);
expect(polys.length).toBeGreaterThan(0);
expect(polys.every((p) => p.stroke === "#ff0000")).toBe(true);
});
it("Lineweight-Regel wirkt identisch zum expliziten Attribut-Override", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände dick",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { lineweight: 0.7 },
},
];
const withRule = generatePlan(project(rules), "eg", visible, undefined, "grob");
// Referenz: dieselbe Strichstärke direkt als Element-Attribut gesetzt.
const ref = project();
ref.walls[0].strokeWeight = 0.7;
const withAttr = generatePlan(ref, "eg", visible, undefined, "grob");
expect(wallPolys(withRule).map((p) => p.strokeWidthMm)).toEqual(
wallPolys(withAttr).map((p) => p.strokeWidthMm),
);
// Und unterscheidet sich vom Plan ohne Regel (Regel wirkt tatsächlich).
const base = generatePlan(project(), "eg", visible, undefined, "grob");
expect(wallPolys(withRule)[0].strokeWidthMm).not.toBe(
wallPolys(base)[0].strokeWidthMm,
);
});
it("deaktivierte Regel wirkt nicht (Standard-Strich bleibt)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot (aus)",
enabled: false,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "grob");
expect(wallPolys(plan).some((p) => p.stroke === "#ff0000")).toBe(false);
});
it("Regel-Override gewinnt über den Per-Instanz-Farbwert (oberste Schicht)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const p = project(rules);
p.walls[0].color = "#00ff00"; // Instanz-Farbe — die Regel liegt darüber.
const plan = generatePlan(p, "eg", visible, undefined, "grob");
expect(wallPolys(plan).every((pr) => pr.stroke === "#ff0000")).toBe(true);
});
it("object_name-Regel + Linienstil-Aktion: 2D-Linie erhält das Strichmuster", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Linien gestrichelt",
enabled: true,
condition: { type: "object_name", operator: "equals", value: "line" },
actions: { linetypeId: "dashed", color: "#ff00ff" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "mittel");
const lines = drawingLines(plan);
expect(lines.length).toBeGreaterThan(0);
expect(lines.every((l) => l.color === "#ff00ff")).toBe(true);
expect(lines.every((l) => Array.isArray(l.dash) && l.dash.length === 2)).toBe(true);
});
it("reines Rendering-Overlay: die Projektdaten bleiben unverändert", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Alles rot",
enabled: true,
condition: { type: "layer_name", operator: "contains", value: "" },
actions: { color: "#ff0000", lineweight: 1.0 },
},
];
const p = project(rules);
generatePlan(p, "eg", visible, undefined, "grob");
expect(p.walls[0].color).toBeUndefined();
expect(p.walls[0].strokeWeight).toBeUndefined();
expect(p.drawings2d[0].lineStyleId).toBeUndefined();
});
});
+261 -29
View File
@@ -8,11 +8,14 @@
import type { import type {
AttributeSource, AttributeSource,
Ceiling, Ceiling,
Column,
Component, Component,
Drawing2D, Drawing2D,
ExtrudedSolid,
HatchPattern, HatchPattern,
LayerCategory, LayerCategory,
Opening, Opening,
OverrideActions,
Project, Project,
Room, Room,
Stair, Stair,
@@ -20,6 +23,8 @@ import type {
Wall, Wall,
} from "../model/types"; } from "../model/types";
import { import {
columnLabel,
columnsOfFloor,
flattenCategories, flattenCategories,
getCeilingType, getCeilingType,
getComponent, getComponent,
@@ -27,11 +32,15 @@ import {
getLayerCategory, getLayerCategory,
getLineStyle, getLineStyle,
getWallType, getWallType,
openingLabel,
openingsOfWall, openingsOfWall,
roomsOfFloor, roomsOfFloor,
stairLabel,
stairsOfFloor, stairsOfFloor,
wallTypeThickness, wallTypeThickness,
} from "../model/types"; } from "../model/types";
import { columnFootprint } from "../geometry/column";
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
import { evaluateRoom, siaLabel } from "../geometry/roomArea"; import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText"; import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText"; import { docFromText } from "../text/richText";
@@ -42,6 +51,7 @@ import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import type { SpiralOutlineSegments } from "../geometry/stair"; import type { SpiralOutlineSegments } from "../geometry/stair";
import { import {
doorSymbol, doorSymbol,
openingGapQuad,
openingInterval, openingInterval,
openingJambs, openingJambs,
wallAxisFrame, wallAxisFrame,
@@ -224,6 +234,17 @@ export type Primitive =
stairId?: string; stairId?: string;
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */ /** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
roomId?: string; roomId?: string;
/**
* ID des extrudierten Körpers (truck-Integration), falls dieses Polygon der
* Grundriss-Footprint einer Extrusion ist (für eine spätere Links-Klick-
* Auswahl heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
*/
extrudedSolidId?: 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).
*/
columnId?: string;
} }
| { | {
kind: "line"; kind: "line";
@@ -312,6 +333,12 @@ export type Primitive =
/** Drehung in RADIANT (CCW im Modell). */ /** Drehung in RADIANT (CCW im Modell). */
angle: number; angle: number;
color: string; color: string;
/**
* Optionale Spaltenbreite in Modell-Metern (Textspalte): ist sie gesetzt,
* bricht der Renderer den Text wortweise auf diese Breite um. Fehlt sie,
* bleibt es einzeiliger Text.
*/
wrapWidth?: number;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */ /** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
drawingId?: string; drawingId?: string;
greyed?: boolean; greyed?: boolean;
@@ -597,15 +624,6 @@ export function generatePlan(
const primitives: Primitive[] = []; const primitives: Primitive[] = [];
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen). // Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
const floor = project.drawingLevels.find((z) => z.id === floorId); const floor = project.drawingLevels.find((z) => z.id === floorId);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls.filter(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
);
// Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem // Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem
// Modell (Ebene) stammt statt aus einer Magie-Konstante. // Modell (Ebene) stammt statt aus einer Magie-Konstante.
const lwByCode = categoryLwMap(project.layers); const lwByCode = categoryLwMap(project.layers);
@@ -614,26 +632,92 @@ export function generatePlan(
// "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur). // "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur).
const catByCode = categoryMap(project.layers); const catByCode = categoryMap(project.layers);
// ── Grafische Overrides (Regel-Engine, src/overrides/engine.ts) ───────────
// Effektive Aktionen je Element als OBERSTE Schicht ÜBER der regulären
// By-Layer/By-Object-Auflösung: die Elemente werden hier — rein zur
// Renderzeit — mit den Regel-Ergebnissen dekoriert (flache Kopien; das
// Projekt bleibt unverändert, jederzeit reversibel). `layer_name` matcht
// Ebenen-Name ODER -Code, `object_name` den Element-/Typ-Namen.
const overrideFor = (
categoryCode: string,
objectName?: string,
): OverrideActions =>
effectiveOverrides(project.overrideRules, {
layerName: catByCode.get(categoryCode)?.name,
layerCode: categoryCode,
objectName,
});
const wallTypeNameById = new Map(project.wallTypes.map((t) => [t.id, t.name]));
const ceilingTypeNameById = new Map(
(project.ceilingTypes ?? []).map((t) => [t.id, t.name]),
);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls
.filter(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
)
.map((w) => {
// Regel-Override gewinnt über den Per-Instanz-Wert (oberste Schicht):
// Farbe → Umriss-/Fugenfarbe (Wall.color), Strichstärke → strokeWeight
// (der topmost-Wert der bestehenden resolveStrokeWeight-Kette).
const ov = overrideFor(w.categoryCode, wallTypeNameById.get(w.wallTypeId));
if (!hasOverrides(ov)) return w;
return {
...w,
color: ov.color ?? w.color,
strokeWeight: ov.lineweight ?? w.strokeWeight,
};
});
// Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren // Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren
// Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché // Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
// gezeichnet (am Ende angehängt). // gezeichnet (am Ende angehängt).
const drawings = project.drawings2d.filter( const drawings = project.drawings2d
(d) => .filter(
d.levelId === floorId && (d) =>
visibleCodes.has(d.categoryCode) && d.levelId === floorId &&
categoryDisplay(d.categoryCode).render, visibleCodes.has(d.categoryCode) &&
); categoryDisplay(d.categoryCode).render,
)
.map((d) => {
// `object_name` matcht bei freier 2D-Geometrie den Formnamen
// (line/polyline/rect/circle/arc/text). Linienstil-Override nur hier —
// Drawing2D ist das einzige Element mit LineStyle-Referenz.
const ov = overrideFor(d.categoryCode, d.geom.shape);
if (!hasOverrides(ov)) return d;
return {
...d,
color: ov.color ?? d.color,
weightMm: ov.lineweight ?? d.weightMm,
lineStyleId: ov.linetypeId ?? d.lineStyleId,
};
});
// Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien + // Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der // Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt, // Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben). // UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
const ceilings = (project.ceilings ?? []).filter( const ceilings = (project.ceilings ?? [])
(c) => .filter(
c.floorId === floorId && (c) =>
visibleCodes.has(c.categoryCode) && c.floorId === floorId &&
categoryDisplay(c.categoryCode).render, visibleCodes.has(c.categoryCode) &&
); categoryDisplay(c.categoryCode).render,
)
.map((c) => {
// Decken: nur der Farb-Override wirkt (der Umriss ist eine feste
// Überkopf-Haarlinie, siehe addCeilingPoche — keine Strichstärke).
const typeName =
(c.ceilingTypeId ? ceilingTypeNameById.get(c.ceilingTypeId) : undefined) ??
wallTypeNameById.get(c.wallTypeId);
const ov = overrideFor(c.categoryCode, typeName);
return ov.color !== undefined ? { ...c, color: ov.color } : c;
});
// Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse: // Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
// die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort, // die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
// wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig, // wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
@@ -650,10 +734,17 @@ export function generatePlan(
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel // Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände // (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob". // lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
const rooms = roomsOfFloor(project, floorId).filter( const rooms = roomsOfFloor(project, floorId)
(r) => .filter(
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render, (r) =>
); visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
)
.map((r) => {
// Räume: `object_name` matcht den Raum-Namen; der Farb-Override färbt
// Fläche + Stempel (Room.color) um.
const ov = overrideFor(r.categoryCode, r.name);
return ov.color !== undefined ? { ...r, color: ov.color } : r;
});
for (const room of rooms) { for (const room of rooms) {
const greyed = categoryDisplay(room.categoryCode).greyed; const greyed = categoryDisplay(room.categoryCode).greyed;
addRoomArea(primitives, room, greyed, detail); addRoomArea(primitives, room, greyed, detail);
@@ -701,8 +792,13 @@ export function generatePlan(
for (const o of openings) { for (const o of openings) {
const d = categoryDisplay(o.categoryCode); const d = categoryDisplay(o.categoryCode);
if (visibleCodes.has(o.categoryCode) && d.render) { if (visibleCodes.has(o.categoryCode) && d.render) {
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM; // Override-Regeln: `object_name` matcht das Öffnungs-Label
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm); // („Tür 90×210" / „Fenster 120×140").
const ov = overrideFor(o.categoryCode, openingLabel(o));
const oLwMm =
ov.lineweight ?? lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
const effO = ov.color !== undefined ? { ...o, color: ov.color } : o;
addOpeningSymbol(primitives, project, wall, effO, d.greyed, detail, oLwMm);
} }
} }
} }
@@ -717,8 +813,28 @@ export function generatePlan(
); );
for (const stair of stairs) { for (const stair of stairs) {
const greyed = categoryDisplay(stair.categoryCode).greyed; const greyed = categoryDisplay(stair.categoryCode).greyed;
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM; // Override-Regeln: `object_name` matcht das Treppen-Label („Gerade 16 STG").
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight); const ov = overrideFor(stair.categoryCode, stairLabel(stair));
const lwMm =
ov.lineweight ?? lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
const effStair = ov.color !== undefined ? { ...stair, color: ov.color } : stair;
addStairSymbol(primitives, project, effStair, greyed, lwMm, cutHeight);
}
// Stützen (Tragwerk) dieses Geschosses: Querschnitt-Poché (Rechteck/Kreis),
// um `rotation` gedreht, auf Kategorie „50". Über der Wand-Poché gezeichnet.
const columns = columnsOfFloor(project, floorId).filter(
(col) =>
visibleCodes.has(col.categoryCode) && categoryDisplay(col.categoryCode).render,
);
for (const column of columns) {
const greyed = categoryDisplay(column.categoryCode).greyed;
const category = catByCode.get(column.categoryCode);
const ov = overrideFor(column.categoryCode, columnLabel(column));
const lwMm =
ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM);
const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
} }
// 2D-Zeichengeometrie zuletzt (über der Poché). // 2D-Zeichengeometrie zuletzt (über der Poché).
@@ -727,6 +843,14 @@ export function generatePlan(
addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode, catByCode); addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode, catByCode);
} }
// Extrudierte Körper (truck-Integration): das Quell-Profil wurde beim
// Extrudieren aus `drawings2d` entfernt (s. `extrude.ts` appendExtrudedSolid)
// — im Grundriss erscheint stattdessen der Footprint-Umriss des Körpers
// selbst (Haarlinie, kein Poché/Schraffur; Farbe wie das 3D-Orange).
for (const solid of project.extrudedSolids ?? []) {
if (solid.levelId === floorId) addExtrudedSolidOutline(primitives, solid);
}
// Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der // Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der
// Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist // Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist
// die Reihenfolge unkritisch; wir hängen sie als Referenz an. // die Reihenfolge unkritisch; wir hängen sie als Referenz an.
@@ -956,6 +1080,79 @@ function categoryMap(layers: LayerCategory[]): Map<string, LayerCategory> {
return map; return map;
} }
/** Footprint-Farbe der truck-Extrusionen im Grundriss (identisch zum 3D-Orange EXTRUSION_RGB in toWalls3d.ts). */
const EXTRUSION_STROKE = "#d98c40";
/**
* Footprint-Umriss eines extrudierten Körpers (truck-Integration) im Grundriss:
* reine Haarlinie ohne Füllung/Schraffur analog zur Ansichtslinie einer Wand
* unter der Schnitthöhe ({@link addWallPoche} `viewOnly`), da der Körper (noch)
* keine Schnitt-Poché/Kategorie-Zuordnung hat.
*/
function addExtrudedSolidOutline(out: Primitive[], solid: ExtrudedSolid): void {
out.push({
kind: "polygon",
pts: solid.points,
fill: "none",
stroke: EXTRUSION_STROKE,
strokeWidthMm: SYMBOL_HAIRLINE_MM,
hatch: NO_HATCH,
extrudedSolidId: solid.id,
});
}
/** Fallback-Poché-Füllung einer Stütze ohne (auflösbares) Bauteil: neutrale Tinte. */
const COLUMN_FALLBACK_FILL = "#3a3f47";
/**
* Grundriss-Querschnitt einer Stütze (Column) als Schnitt-Poché: gefülltes
* Polygon (Rechteck bzw. tesselliertes Kreisprofil), an `position` platziert und
* um `rotation` gedreht. Trägt sofern ein Bauteil (`componentId`) auflösbar ist
* dessen Füllung + Schraffur (dieselbe Auflösung wie Wand/Decke, {@link
* resolveWallSectionStyle}); sonst eine neutrale Vollton-Poché. `columnId` macht
* die Fläche links-klick-selektierbar (analog Extrusion/Raum).
*/
function addColumnPoche(
out: Primitive[],
project: Project,
column: Column,
greyed: boolean,
lwMm: number,
category: LayerCategory | undefined,
): void {
const pts = columnFootprint(column);
const stroke = column.color ?? category?.color ?? MONO_INK;
let fill = column.color ?? category?.color ?? COLUMN_FALLBACK_FILL;
let hatch: HatchRender = NO_HATCH;
if (column.componentId) {
try {
const comp = getComponent(project, column.componentId);
const hatchId =
resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId;
const resolved = resolveHatch(
project,
hatchId,
undefined,
resolveForeground(comp, undefined, category, undefined),
);
hatch = resolved;
fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER;
} catch {
// Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
}
}
out.push({
kind: "polygon",
pts,
fill,
stroke,
strokeWidthMm: lwMm,
hatch,
greyed,
columnId: column.id,
});
}
/** /**
* Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md * Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md
* §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der * §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
@@ -1091,6 +1288,7 @@ function addDrawing2D(
heightM: g.height, heightM: g.height,
angle: g.angle, angle: g.angle,
color, color,
...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}),
greyed, greyed,
drawingId: d.id, drawingId: d.id,
}); });
@@ -1691,6 +1889,24 @@ function addOpeningSymbol(
if (o.kind === "door") { if (o.kind === "door") {
const sym = doorSymbol(wall, o); const sym = doorSymbol(wall, o);
if (!sym) return; if (!sym) return;
// Unsichtbares Pick-Polygon über der Öffnungslücke (Laibung × Wanddicke) —
// damit die Tür wie ein Fenster per Punkt-in-Polygon über die ganze
// Öffnungsfläche selektierbar ist (Türsymbol besteht sonst nur aus dünnem
// Blatt + Bogen, die man exakt treffen müsste). fill/stroke "none" ⇒ zeichnet
// nichts; `pickOpening` (PlanView) testet nur die `pts`.
const gap = openingGapQuad(project, wall, o);
if (gap) {
out.push({
kind: "polygon",
pts: gap,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
// Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN // Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN
// Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der // Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
// ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet). // ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
@@ -1795,6 +2011,22 @@ function addOpeningSymbol(
const glassCount = detail === "fein" ? 2 : 1; const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount); const sym = windowSymbol(project, wall, o, glassCount);
if (!sym) return; if (!sym) return;
// Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über
// die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst
// schwer zu treffen). Siehe Tür-Zweig oben.
const wgap = openingGapQuad(project, wall, o);
if (wgap) {
out.push({
kind: "polygon",
pts: wgap,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
if (detail !== "grob") { if (detail !== "grob") {
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung). // Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
out.push({ out.push({
+14 -1
View File
@@ -12,7 +12,7 @@
import type { Plan } from "./generatePlan"; import type { Plan } from "./generatePlan";
import type { Project } from "../model/types"; import type { Project } from "../model/types";
import { planToRenderScene } from "./toRenderScene"; import { planToRenderScene } from "./toRenderScene";
import { projectToModel3d } from "./toWalls3d"; import { projectToModel3d, TRUCK_MESH_READY_EVENT } from "./toWalls3d";
/** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */ /** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */
const DEBOUNCE_MS = 200; const DEBOUNCE_MS = 200;
@@ -56,8 +56,21 @@ export function pushNativeScene(plan: Plan): void {
*/ */
export function pushNativeWalls(project: Project): void { export function pushNativeWalls(project: Project): void {
if (!isTauri()) return; if (!isTauri()) return;
lastProject = project;
clearTimeout(wallsTimer); clearTimeout(wallsTimer);
wallsTimer = setTimeout(() => { wallsTimer = setTimeout(() => {
void invokeNative("push_native_walls", { walls: projectToModel3d(project) }); void invokeNative("push_native_walls", { walls: projectToModel3d(project) });
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
} }
/** Letztes an {@link pushNativeWalls} übergebenes Projekt (für den Re-Push unten). */
let lastProject: Project | null = null;
// truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids) laden WASM asynchron;
// ohne diesen Re-Push nach Abschluss bliebe eine fertige Extrusion im nativen
// Fenster unsichtbar, weil `pushNativeWalls` nur bei Projektänderungen läuft.
if (typeof window !== "undefined") {
window.addEventListener(TRUCK_MESH_READY_EVENT, () => {
if (lastProject) pushNativeWalls(lastProject);
});
}
+561 -72
View File
@@ -9,6 +9,7 @@ import { describe, it, expect } from "vitest";
import type { Project } from "../model/types"; import type { Project } from "../model/types";
import { sampleProject } from "../model/sampleProject"; import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d"; import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
import { resolveHatch } from "./generatePlan";
const RGB: [number, number, number] = [1, 0.5, 0.1]; const RGB: [number, number, number] = [1, 0.5, 0.1];
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b] const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
@@ -25,14 +26,19 @@ describe("selectionHighlightLines", () => {
expect(lines.length).toBe(0); expect(lines.length).toBe(0);
}); });
it("eine Wand ohne Öffnungen -> je Materiallage ein Quader (4·12 Kanten)", () => { it("eine Wand ohne Öffnungen -> je Materiallage ein Quader, Kern-Band unter der Decke gespalten (5·12 Kanten)", () => {
// W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3 // W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3
// haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp // haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp
// "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die // "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die
// TS-seitig je als eigene Box gestapelt werden (Option B) -> 4 Quader = // TS-seitig je als eigene Box gestapelt werden (Option B).
// 4·12 Kanten. (Früher: 1 Vollkörper = 12 Kanten.) // NEU (per-Schicht-Decken-Dominanz): W2 steht unter der Geschossdecke C1
// (dg-massiv: screed 30 / insulation 20 / concrete 100). Der Backstein (50)
// wird NUR von der Beton-Schicht (100) im z-Band [2.3,2.5] gekappt und läuft
// darüber (bis Wandkopf 2.6) durch -> ZWEI Backstein-Boxen [0,2.3] + [2.5,2.6].
// Die anderen Bänder bleiben je 1 Box (Aussenputz/Dämmung aussen: ungeschnitten;
// Innenputz 10 < alle Deckenschichten: bis 2.3). Also 5 Quader = 5·12 Kanten.
const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB); const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(lines.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); expect(lines.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
}); });
it("eine Wand mit Farbwerten in jedem Vertex", () => { it("eine Wand mit Farbwerten in jedem Vertex", () => {
@@ -43,11 +49,14 @@ describe("selectionHighlightLines", () => {
expect(lines[5]).toBeCloseTo(RGB[2], 6); expect(lines[5]).toBeCloseTo(RGB[2], 6);
}); });
it("eine Decke (Slab) -> 3·n Kanten (n = Umriss-Eckenzahl)", () => { it("eine Decke (Slab) -> je Schicht 3·n Kanten (geschichteter Bodenaufbau)", () => {
// C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten. // C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten JE SCHICHT.
// Deckentyp "dg-massiv" hat 3 Schichten (Estrich/Dämmung/Beton) -> je ein
// eigener Slab, damit der 3D-Schnitt den Aufbau geschichtet zeigt (wie 2D).
const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB); const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB);
const n = 4; const n = 4;
expect(lines.length).toBe(3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); const layers = 3; // dg-massiv: screed + insulation + concrete
expect(lines.length).toBe(layers * 3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
}); });
it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => { it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => {
@@ -63,16 +72,21 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2); expect(two.length).toBe(one.length * 2);
}); });
it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => { it("Öffnungen allein multiplizieren die Bänder NICHT (ein Körper je Schicht-Band mit Löchern)", () => {
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird // 3D-Pfad (layered=true): eine Wand mit Öffnungen wird NICHT in Pfeiler/
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro // Brüstung/Sturz zerlegt, sondern als EIN Körper pro Schicht-Band mit echten
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher // Löchern emittiert — die Löcher fügen im Highlight keine Kanten hinzu.
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele // Isoliert von Verschneidungen getestet: OHNE die Querwand W9 (deren T-Stoss
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches // sonst die innerste Putzschicht von W1 span-cutout-splittet, s. eigener
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.) // Test unten) hat W1 (Tür D1 + Fenster O1) dieselbe Bänder-Zahl wie die
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB); // öffnungslose Wand W2 desselben Typs "aw" — die Löcher fügen keine Körper
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB); // hinzu. Beide stehen unter der Geschossdecke C1, deren Beton-Schicht (100)
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX); // den Backstein (50) im z-Band [2.3,2.5] spaltet -> 5 Körper (per-Schicht-
// Decken-Dominanz, s. Backstein-Boxen [0,2.3] + [2.5,2.6]).
const noW9: Project = { ...sampleProject, walls: sampleProject.walls.filter((w) => w.id !== "W9") };
const w1 = selectionHighlightLines(noW9, ["W1"], [], RGB);
const w2 = selectionHighlightLines(noW9, ["W2"], [], RGB);
expect(w1.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(w1.length).toBe(w2.length); expect(w1.length).toBe(w2.length);
}); });
}); });
@@ -114,23 +128,37 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
], ],
}; };
it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => { it("layered=true: BEIDE Fenster als holes je Band, Loch-Positionen bleiben nach Join-Rebase korrekt", () => {
const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1"); const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1");
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen. // W1 ist Durchgangswand für die Querwand W9 (T-Stoss): die innerste Putz-
expect(w1.length).toBe(4); // schicht wird durch den Span-Cutout entlang der Achse in ZWEI Teilstücke
for (const body of w1) { // gebrochen. NEU (per-Schicht-Decken-Dominanz): W1 steht unter der Decke C1,
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper). // deren Beton (100) den Backstein (50) im z-Band [2.3,2.5] spaltet -> der
// Backstein kommt als ZWEI Boxen [0,2.3] + [2.5,2.6]. Die obere Backstein-Box
// liegt ÜBER beiden Fenstern (z ≤ 2.1) und trägt daher KEINE Löcher.
// Körper gesamt: render-ext(1) + insulation(1) + brick[0,2.3](1) +
// brick[2.5,2.6](1, ohne Loch) + render-int-Span(2) = 6.
expect(w1.length).toBe(6);
// Nur die Körper, die die Fenster-z-Bänder schneiden, tragen Löcher (5 Stück);
// die obere Backstein-Box (über den Fenstern) hat keine.
const withHoles = w1.filter((b) => b.holes && b.holes.length > 0);
expect(withHoles.length).toBe(5);
// W1-Achse: (0,0)->(5,0), u=(1,0) -> Box-Achsenstart = box.start[0]. Die Loch-
// Achsenkoordinaten sind je Körper auf DESSEN Box-Start rebased; die ABSOLUTE
// Position (Box-Start + from) muss wieder exakt auf den Öffnungs-Intervallen liegen.
for (const body of withHoles) {
expect(body.holes).toBeDefined(); expect(body.holes).toBeDefined();
expect(body.holes!.length).toBe(2); expect(body.holes!.length).toBe(2);
const axis0 = body.start[0];
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from); const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
// Fenster A: u=[1,2], z=[0.9,2.1]. // Fenster A: u=[1,2], z=[0.9,2.1] (absolut rekonstruiert).
expect(byFrom[0].from).toBeCloseTo(1.0, 6); expect(axis0 + byFrom[0].from).toBeCloseTo(1.0, 6);
expect(byFrom[0].to).toBeCloseTo(2.0, 6); expect(axis0 + byFrom[0].to).toBeCloseTo(2.0, 6);
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6); expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6);
expect(byFrom[0].zTop).toBeCloseTo(2.1, 6); expect(byFrom[0].zTop).toBeCloseTo(2.1, 6);
// Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer. // Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer.
expect(byFrom[1].from).toBeCloseTo(1.5, 6); expect(axis0 + byFrom[1].from).toBeCloseTo(1.5, 6);
expect(byFrom[1].to).toBeCloseTo(2.5, 6); expect(axis0 + byFrom[1].to).toBeCloseTo(2.5, 6);
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6); expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6);
expect(byFrom[1].zTop).toBeCloseTo(0.7, 6); expect(byFrom[1].zTop).toBeCloseTo(0.7, 6);
} }
@@ -149,7 +177,12 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
// zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung. // zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung.
const p: Project = { ...sampleProject, openings: [] }; const p: Project = { ...sampleProject, openings: [] };
const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1"); const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(w1.length).toBe(4); // W1 ist Durchgangswand für W9 (T-Stoss) -> innerste Putzschicht span-cutout-
// gebrochen. Zusätzlich spaltet die Decke C1 (Beton 100) den Backstein (50) im
// z-Band [2.3,2.5] -> obere Backstein-Box [2.5,2.6] ohne Loch. Körper: render-ext
// + insulation + brick[0,2.3] + brick[2.5,2.6] + render-int-Span(2) = 6.
expect(w1.length).toBe(6);
// Der erste Körper (Aussenputz-Band, volle Achse) trägt die Tür als Loch bis UK.
const door = w1[0].holes?.find((h) => h.zBottom < 1e-6); const door = w1[0].holes?.find((h) => h.zBottom < 1e-6);
expect(door).toBeDefined(); expect(door).toBeDefined();
expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK. expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK.
@@ -161,10 +194,13 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
// Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben: // Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben:
// render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff, // render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff,
// brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m). // brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m).
// W2 ist öffnungslos -> genau ein Achsen-Teilquader, also 4 Schicht-Boxen. // W2 ist öffnungslos, steht aber unter der Geschossdecke C1: die Beton-Schicht
// (100) spaltet den Backstein (50) im z-Band [2.3,2.5] -> der Backstein kommt
// als ZWEI Boxen [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz), die drei
// übrigen Bänder je 1 Box = 5 Boxen. Die Farbzuordnung je Schicht bleibt.
const walls = projectToWalls3d(sampleProject); const walls = projectToWalls3d(sampleProject);
const w2 = walls.filter((w) => w.wallId === "W2"); const w2 = walls.filter((w) => w.wallId === "W2");
expect(w2.length).toBe(4); expect(w2.length).toBe(5);
// Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand) // Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand)
// und sind Vollkörper (layers leer -> render3d zeichnet in `color`). // und sind Vollkörper (layers leer -> render3d zeichnet in `color`).
@@ -173,14 +209,18 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
expect(seg.layers.length).toBe(0); expect(seg.layers.length).toBe(0);
} }
// Die vier Materialfarben kommen genau einmal vor. // Die vier Materialfarben kommen vor; der Backstein (#8c5544) zweimal (gespalten).
const hex = (c: [number, number, number]) => const hex = (c: [number, number, number]) =>
c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join(""); c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
const colors = w2.map((s) => hex(s.color)).sort(); const colors = w2.map((s) => hex(s.color)).sort();
expect(colors).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]); expect(colors).toEqual(["8c5544", "8c5544", "d8d2c7", "efece6", "ffffff"]);
expect([...new Set(colors)].sort()).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]);
// Die Schichtdicken summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps). // Die Schichtdicken (je EINDEUTIGEM Querversatz, damit die gespaltene Backstein-
const totalT = w2.reduce((s, seg) => s + seg.thickness, 0); // Box nur EINMAL zählt) summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps).
const byOffset = new Map<number, number>();
for (const seg of w2) byOffset.set(Math.round((seg.start[0] - 5) * 1e6) / 1e6, seg.thickness);
const totalT = [...byOffset.values()].reduce((s, t) => s + t, 0);
expect(totalT).toBeCloseTo(0.345, 6); expect(totalT).toBeCloseTo(0.345, 6);
}); });
@@ -189,9 +229,14 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
// wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach // wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach
// Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel // Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel
// symmetrisch um die Achse liegen: min = -T/2, max = +T/2. // symmetrisch um die Achse liegen: min = -T/2, max = +T/2.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); // Nach EINDEUTIGEM Querversatz zusammenfassen: der unter der Decke gespaltene
const bands = w2 // Backstein ([0,2.3] + [2.5,2.6]) liegt zweimal am SELBEN Versatz vor und darf
.map((s) => ({ offset: s.start[0] - 5, t: s.thickness })) // die Dicken-Bilanz nicht doppelt zählen (per-Schicht-Decken-Dominanz).
const w2raw = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
const byOffset = new Map<number, number>();
for (const s of w2raw) byOffset.set(Math.round((s.start[0] - 5) * 1e6) / 1e6, s.thickness);
const bands = [...byOffset.entries()]
.map(([offset, t]) => ({ offset, t }))
.sort((a, b) => a.offset - b.offset); .sort((a, b) => a.offset - b.offset);
const T = bands.reduce((s, b) => s + b.t, 0); const T = bands.reduce((s, b) => s + b.t, 0);
expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6); expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6);
@@ -216,9 +261,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
context: [], context: [],
}; };
const { meshes } = projectToModel3d(p); const { meshes } = projectToModel3d(p);
// `meshes` enthält jetzt auch die glatte Betontreppen-Laufplatte (ST1, Mesh);
// die Glasscheiben tragen die bläuliche Glasfarbe → danach filtern.
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
// Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe). // Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe).
expect(meshes.length).toBe(1); expect(glassMeshes.length).toBe(1);
const glass = meshes[0]; const glass = glassMeshes[0];
// Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas. // Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas.
expect(glass.positions.length).toBe(24); expect(glass.positions.length).toBe(24);
expect(glass.indices.length).toBe(36); expect(glass.indices.length).toBe(36);
@@ -234,32 +284,73 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => { it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer. // Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer.
// Neben den Glasscheiben liegt jetzt auch das Betontreppen-Mesh in `meshes`
// → nach Glasfarbe filtern: genau 2 Scheiben (O1, O2), Tür ohne Scheibe.
const { meshes } = projectToModel3d(sampleProject); const { meshes } = projectToModel3d(sampleProject);
expect(meshes.length).toBe(2); const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
expect(glassMeshes.length).toBe(2);
}); });
}); });
describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings)", () => { describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw", dominante Schicht "brick" // Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
// joinPriority 50) tragen exakt Wandhöhe 2.6 m = Geschosshöhe -> zTop 2.6. // brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
// Decke C1 (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" // (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" joinPriority 100)
// joinPriority 100) hat OK bündig mit dem Wandkopf (zTop 2.6) und // hat OK bündig mit dem Wandkopf (zTop 2.6), Gesamtdicke 0.3 m -> zBottom 2.3.
// Gesamtdicke 0.3 m (screed .06 + insulation .04 + concrete .2) -> zBottom //
// 2.3. Vor dem Trim überlappten sich Wand- und Deckenvolumen exakt über // ENTSCHEIDEND (Nutzer-Bug „Kranz durch die Dämmung"): C1.outline ist der
// [2.3..2.6] im ganzen Wand-Footprint (Z-Fighting). Nach dem Trim endet die // Raumgrundriss auf den WAND-ACHSEN ((0,0)-(5,0)-(5,4)-(0,4)) — die Decke liegt
// Wand an der Deckenunterkante, weil die Decke die höhere joinPriority hat. // also nur auf dem INNEREN Halbquerschnitt jeder Aussenwand. Für W2 (Achse x=5)
it("Decke mit höherer joinPriority kappt die Wand an ihrer Unterkante", () => { // liegen die Bandmittellinien bei x = 5 offset:
// render-ext x=5.1625 (AUSSEN, x>5) -> NICHT überdeckt -> läuft durch
// insulation x=5.0725 (AUSSEN) -> NICHT überdeckt -> läuft durch
// brick x=4.9175 (INNEN, x<5) -> überdeckt -> Decken-z-Schnitt
// render-int x=4.8350 (INNEN) -> überdeckt -> Decken-z-Schnitt
// Die Decke schneidet ein Band also nur dort, wo ihre Outline die Footprint-
// Mittellinie DIESES Bandes überdeckt (geometrischer Überlapp wie
// toSection.ts::subtractDominantBands) — NICHT mehr per wandweitem BBox-Test.
// (Frühere Tests erwarteten hier „ALLE Schichten geschnitten"; das kodierte das
// falsche BBox-Verhalten und ist die Ursache des 3D-Kranzes. Korrigiert.)
it("Decke schneidet nur die von ihrer Outline überdeckten (inneren) Bänder; Dämmung/Aussenputz laufen durch; Beton spaltet den Backstein PER SCHICHT", () => {
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2"); const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0); // PER-SCHICHT-Decken-Dominanz (identisch zu toSection.ts::subtractDominantBands):
for (const seg of w2) { // jede Deckenschicht (screed 30 / insulation 20 / concrete 100) schneidet nur
expect(seg.baseElevation + seg.height).toBeCloseTo(2.3, 6); // Wandbänder mit STRIKT niedrigerer Priorität in IHREM z-Band. Der Backstein (50)
} // wird also NUR von der Beton-Schicht (100, z-Band [2.3,2.5]) gekappt und läuft
// darüber bis zum Wandkopf (2.6) durch -> ZWEI Boxen. So schneidet er im Schnitt
// die schwimmenden Deckenschichten Estrich/Dämmung über sich weg (Nutzer-Bug).
const zBoxes = (t: number) =>
w2.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
// brick: [0,2.3] (unter Beton) + [2.5,2.6] (über Beton, bis Wandkopf).
const brick = zBoxes(0.15);
expect(brick.length).toBe(2);
expect(brick[0][1]).toBeCloseTo(2.3, 6); // endet an Beton-UK
expect(brick[1][0]).toBeCloseTo(2.5, 6); // beginnt an Beton-OK
expect(brick[1][1]).toBeCloseTo(2.6, 6); // läuft bis Wandkopf durch
// render-int (10 < allen Deckenschichten): über [2.3,2.6] komplett gekappt -> [0,2.3].
const renderInt = zBoxes(0.015);
expect(renderInt.length).toBe(1);
expect(renderInt[0][1]).toBeCloseTo(2.3, 6);
// AUSSEN liegende Schichten (Mittellinie ausserhalb der Decken-Outline): KEIN
// Schnitt -> volle Höhe (behobener „Kranz"-Bug, wie im 2D-Schnitt).
expect(zBoxes(0.16)).toEqual([[0, 2.6]]); // insulation läuft durch
expect(zBoxes(0.02)).toEqual([[0, 2.6]]); // render-ext (Aussenputz) läuft durch
expect(w2.length).toBe(5); // render-ext + insulation + brick(2) + render-int
}); });
it("kein Trim, wenn die Wand-Priorität >= Decken-Priorität ist", () => { it("PER-SCHICHT: nur überdeckte Bänder mit < Decken-Priorität werden geschnitten (Kern >= Prio läuft durch)", () => {
// Deckentyp-Priorität (concrete) auf 40 senken -> unter der Wand-Priorität // Deckentyp-Priorität (concrete) auf 40 senken. Von den ÜBERDECKTEN (inneren)
// (brick 50) -> die Decke gewinnt nicht mehr, die Wand bleibt unverändert // Bändern läuft brick (50 > 40) jetzt bis zum Wandkopf (2.6) durch; render-int
// auf voller Höhe (2.6 m, wie ohne Trim-Logik). // (10 < 40) wird im Decken-z-Band [2.3,2.6] geschnitten -> endet bei 2.3. Die
// AUSSEN liegenden Bänder (insulation, render-ext) sind ohnehin nicht überdeckt
// und laufen unabhängig von der Priorität durch (2.6). Priorität ALLEIN genügt
// also nicht — es braucht zusätzlich den geometrischen Überlapp (wie die
// „gestaffelte Kette" in toSection.boolean.test.ts, wo Beton die Dämmung trotz
// höherer Prio NICHT schneidet, weil sie sich nicht überlappen).
const p: Project = { const p: Project = {
...sampleProject, ...sampleProject,
components: sampleProject.components.map((c) => components: sampleProject.components.map((c) =>
@@ -267,13 +358,155 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings
), ),
}; };
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2"); const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0); expect(w2.length).toBe(4); // je 1 Box pro Band (Wand ragt nicht über die Decke)
for (const seg of w2) { const top = (t: number) => {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.6, 6); const b = w2.find((s) => Math.abs(s.thickness - t) < 1e-6)!;
} return b.baseElevation + b.height;
};
expect(top(0.15)).toBeCloseTo(2.6, 6); // brick (überdeckt, 50 > 40) läuft durch
expect(top(0.015)).toBeCloseTo(2.3, 6); // render-int (überdeckt, 10 < 40) geschnitten
expect(top(0.16)).toBeCloseTo(2.6, 6); // insulation (nicht überdeckt) läuft durch
expect(top(0.02)).toBeCloseTo(2.6, 6); // render-ext (nicht überdeckt) läuft durch
}); });
it("kein Trim, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => { it("Wand ragt über die Decke: überdecktes dominiertes Band spaltet vertikal, Aussenschichten bleiben ungeteilt", () => {
// W2 auf 3.0 m erhöhen (über Decken-OK 2.6) und concrete-Priorität auf 25
// senken. Von den ÜBERDECKTEN Bändern spaltet render-int (10 < 25) vertikal in
// ZWEI Boxen [0,2.3] + [2.6,3.0] (Decken-z-Band [2.3,2.6] ausgespart); brick
// (50 > 25) läuft als EINE Box [0,3.0] durch. Die NICHT überdeckten Aussen-
// schichten (insulation, render-ext) bleiben je EINE volle Box [0,3.0] — sie
// werden NICHT vertikal aufgespalten (kein Kranz).
const p: Project = {
...sampleProject,
walls: sampleProject.walls.map((w) => (w.id === "W2" ? { ...w, height: 3.0 } : w)),
components: sampleProject.components.map((c) =>
c.id === "concrete" ? { ...c, joinPriority: 25 } : c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
// render-int 2 Boxen + brick/insulation/render-ext je 1 Box = 5 Boxen.
expect(w2.length).toBe(5);
const zRanges = (t: number) =>
w2
.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
// brick (überdeckt, 50 > 25): EINE durchgehende Box.
const brick = zRanges(0.15);
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(3.0, 6);
// render-int (überdeckt, 10 < 25): ZWEI Boxen unter/über der Decke.
const renderInt = zRanges(0.015);
expect(renderInt.length).toBe(2);
expect(renderInt[0][0]).toBeCloseTo(0, 6);
expect(renderInt[0][1]).toBeCloseTo(2.3, 6);
expect(renderInt[1][0]).toBeCloseTo(2.6, 6);
expect(renderInt[1][1]).toBeCloseTo(3.0, 6);
// insulation (nicht überdeckt): EINE volle Box, KEIN vertikaler Split.
const insul = zRanges(0.16);
expect(insul.length).toBe(1);
expect(insul[0][0]).toBeCloseTo(0, 6);
expect(insul[0][1]).toBeCloseTo(3.0, 6);
// render-ext (nicht überdeckt): ebenfalls EINE volle Box.
const renderExt = zRanges(0.02);
expect(renderExt.length).toBe(1);
expect(renderExt[0][0]).toBeCloseTo(0, 6);
expect(renderExt[0][1]).toBeCloseTo(3.0, 6);
});
it("Decke überdeckt die VOLLE Wanddicke -> jede Schicht PER SCHICHT gegen die Deckenschichten verschnitten", () => {
// Gegenprobe: verbreitert man C1 so, dass die Outline die ganze W2 überdeckt
// (Aussenkante x=5.1725 < 5.5), liegen ALLE Bandmittellinien innerhalb der Decke.
// PER-SCHICHT (statt pauschal): Deckenschichten screed(30, z[2.54,2.6]),
// insulation(20, z[2.5,2.54]), concrete(100, z[2.3,2.5]).
// • render-ext/render-int (Prio 10 < allen): über [2.3,2.6] komplett gekappt -> [0,2.3].
// • brick (50): nur von concrete(100) gekappt -> [0,2.3] + [2.5,2.6].
// • insulation-Wand (20): von concrete(100) und screed(30) gekappt, aber NICHT
// von der gleichrangigen Decken-insulation(20) -> [0,2.3] + [2.5,2.54] (Sliver,
// wo gleichrangiges Material koexistiert — exakt wie „gleiche Prio koexistiert").
const p: Project = {
...sampleProject,
ceilings: (sampleProject.ceilings ?? []).map((c) =>
c.id === "C1"
? {
...c,
outline: [
{ x: 0, y: -0.5 },
{ x: 5.5, y: -0.5 },
{ x: 5.5, y: 4.5 },
{ x: 0, y: 4.5 },
],
}
: c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(6);
const zBoxes = (t: number) =>
w2.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
expect(zBoxes(0.02)).toEqual([[0, 2.3]]); // render-ext (10) voll gekappt
expect(zBoxes(0.015)).toEqual([[0, 2.3]]); // render-int (10) voll gekappt
const brick = zBoxes(0.15); // brick (50): nur Beton kappt
expect(brick.length).toBe(2);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
expect(brick[1][0]).toBeCloseTo(2.5, 6);
expect(brick[1][1]).toBeCloseTo(2.6, 6);
const insul = zBoxes(0.16); // insulation-Wand (20): Sliver [2.5,2.54] bleibt
expect(insul.length).toBe(2);
expect(insul[0][1]).toBeCloseTo(2.3, 6);
expect(insul[1][0]).toBeCloseTo(2.5, 6);
expect(insul[1][1]).toBeCloseTo(2.54, 6);
});
it("Decke überdeckt nur EINEN TEIL der Wandlänge -> überdecktes Kern-Band spaltet ENTLANG DER ACHSE", () => {
// C1.outline auf die halbe Länge kürzen (y in [0,2] statt [0,4]): die Decke
// überdeckt die inneren W2-Bänder nur auf dem Achsenstück y in [0,2]. Das
// überdeckte Kern-Band (brick) wird dort z-geschnitten und läuft im Rest der
// Wandlänge (y > 2) über die volle Höhe durch — die Überdeckungsgrenze (y=2)
// wird aus der Bandmittellinien-Abtastung per Bisektion scharf ermittelt. Die
// Aussenschichten (nicht überdeckt) bleiben ganz. So wird auch eine Decke
// korrekt behandelt, die nur einen Teil der Wandlänge trägt.
const p: Project = {
...sampleProject,
ceilings: (sampleProject.ceilings ?? []).map((c) =>
c.id === "C1"
? { ...c, outline: [{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 5, y: 2 }, { x: 0, y: 2 }] }
: c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
// Per-Schicht-Decken-Dominanz + Achsen-Überdeckung: der Backstein (50) wird nur
// von der Beton-Schicht (100) UND nur auf dem überdeckten Achsenstück y∈[~0.08,2]
// gekappt. Dort entstehen ZWEI z-Boxen [0,2.3] + [2.5,2.6]; das unbedeckte Stück
// y∈[2,~3.92] läuft als EINE volle Box [0,2.6] durch -> 3 Backstein-Boxen.
// render-int (10 < Beton) spaltet in [0,2.3] (überdeckt) + [0,2.6] (unbedeckt) = 2.
// Aussenschichten (insulation, render-ext) nicht überdeckt -> je 1 volle Box.
expect(w2.length).toBe(7);
const brick = w2
.filter((s) => Math.abs(s.thickness - 0.15) < 1e-6)
.map((s) => ({ sy: s.start[1], zb: s.baseElevation, zt: s.baseElevation + s.height }))
.sort((a, b) => a.zb - b.zb || a.sy - b.sy);
expect(brick.length).toBe(3);
// Überdecktes Stück y∈[~0.08,2]: untere Box [0,2.3] + obere Box [2.5,2.6].
const covered = brick.filter((b) => b.sy < 1.0);
expect(covered.length).toBe(2);
expect(covered[0].zt).toBeCloseTo(2.3, 6); // unter Beton
expect(covered[1].zb).toBeCloseTo(2.5, 6); // über Beton
expect(covered[1].zt).toBeCloseTo(2.6, 6);
// Unbedecktes Stück y∈[2,~3.92]: EINE volle Box [0,2.6].
const uncovered = brick.filter((b) => b.sy > 1.5);
expect(uncovered.length).toBe(1);
expect(uncovered[0].zt).toBeCloseTo(2.6, 6);
// insulation (aussen) bleibt EINE ungeteilte volle Box.
const insul = w2.filter((s) => Math.abs(s.thickness - 0.16) < 1e-6);
expect(insul.length).toBe(1);
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
});
it("keine Subtraktion, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => {
// W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) -> // W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) ->
// unverändertes Verhalten (volle wall.height). // unverändertes Verhalten (volle wall.height).
const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6"); const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6");
@@ -284,15 +517,271 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings
}); });
}); });
describe("Bauteil-/Materialfarbe Decke", () => { describe("Geschichteter Bodenaufbau Decke", () => {
it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => { it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => {
// Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 -> // Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff),
// dickste Schicht = concrete (#9aa0a6). // concrete 0.2 (#9aa0a6) — Reihenfolge OK->UK. C1: zTop 2.6, Gesamt 0.3 ->
// zBottom 2.3. Proportional: Estrich 2.54..2.6, Dämmung 2.50..2.54,
// Beton 2.30..2.50. Der 3D-Schnitt zeigt so den Aufbau geschichtet (wie 2D).
const { slabs } = projectToModel3d(sampleProject); const { slabs } = projectToModel3d(sampleProject);
const c1 = slabs.find((s) => s.ceilingId === "C1"); const c1 = slabs
expect(c1).toBeDefined(); .filter((s) => s.ceilingId === "C1")
expect(c1!.color[0]).toBeCloseTo(0x9a / 255, 6); .sort((a, b) => b.zTop - a.zTop); // oben zuerst
expect(c1!.color[1]).toBeCloseTo(0xa0 / 255, 6); expect(c1.length).toBe(3);
expect(c1!.color[2]).toBeCloseTo(0xa6 / 255, 6);
// Oberste Schicht = Estrich (#c9c2b3), bündig mit OK der Decke (2.6).
expect(c1[0].zTop).toBeCloseTo(2.6, 6);
expect(c1[0].color[0]).toBeCloseTo(0xc9 / 255, 6);
expect(c1[0].color[1]).toBeCloseTo(0xc2 / 255, 6);
expect(c1[0].color[2]).toBeCloseTo(0xb3 / 255, 6);
// Mittlere Schicht = Dämmung (#ffffff).
expect(c1[1].color[0]).toBeCloseTo(1, 6);
expect(c1[1].color[1]).toBeCloseTo(1, 6);
expect(c1[1].color[2]).toBeCloseTo(1, 6);
// Unterste Schicht = Betondecke (#9aa0a6), bündig mit UK der Decke (2.3).
expect(c1[2].zBottom).toBeCloseTo(2.3, 6);
expect(c1[2].color[0]).toBeCloseTo(0x9a / 255, 6);
expect(c1[2].color[1]).toBeCloseTo(0xa0 / 255, 6);
expect(c1[2].color[2]).toBeCloseTo(0xa6 / 255, 6);
// Schichten schließen lückenlos aneinander (UK Schicht k == OK Schicht k+1).
expect(c1[0].zBottom).toBeCloseTo(c1[1].zTop, 6);
expect(c1[1].zBottom).toBeCloseTo(c1[2].zTop, 6);
});
});
describe("sliceTermination: Wand endet an der Decke (Zuschnitt, nicht Priorität)", () => {
// W2 (EG, aw) steht unter der Decke C1 (dg-massiv). Ohne Terminierung ("both")
// spaltet der Beton (100) den Backstein (50) in [0,2.3] + [2.5,2.6] (heutiges,
// prioritäts-korrektes Verhalten). "below" clippt die Wand an der Decken-UK →
// nur die untere Backstein-Box [0,2.3] bleibt; "above" spiegelbildlich [2.5,2.6].
const withTermination = (mode: "both" | "below" | "above") => ({
...sampleProject,
walls: sampleProject.walls.map((w) =>
w.id === "W2" ? { ...w, sliceTermination: mode === "both" ? undefined : mode } : w,
),
});
const brickBoxes = (mode: "both" | "below" | "above") =>
projectToWalls3d(withTermination(mode))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.15) < 1e-6)
.map((w) => [w.baseElevation, w.baseElevation + w.height] as const)
.sort((a, b) => a[0] - b[0]);
it('Default/"both": Backstein bleibt als ZWEI Boxen [0,2.3] + [2.5,2.6] (heutiges Verhalten)', () => {
const brick = brickBoxes("both");
expect(brick.length).toBe(2);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
expect(brick[1][0]).toBeCloseTo(2.5, 6);
expect(brick[1][1]).toBeCloseTo(2.6, 6);
});
it('"below": NUR die untere Backstein-Box [0,2.3] (Wand endet an der Decke, kein Rest oben)', () => {
const brick = brickBoxes("below");
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
// Auch der Innenputz (render-int, überdeckt) endet einheitlich an der Decken-UK 2.3.
const renderInt = projectToWalls3d(withTermination("below"))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.015) < 1e-6);
expect(renderInt.length).toBe(1);
expect(renderInt[0].baseElevation + renderInt[0].height).toBeCloseTo(2.3, 6);
// Aussenschichten (nicht überdeckt) laufen weiter voll durch (2.6).
const insul = projectToWalls3d(withTermination("below"))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.16) < 1e-6);
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
});
it('"above": NUR die obere Backstein-Box [2.5,2.6] (Brüstung/Attika von oben)', () => {
const brick = brickBoxes("above");
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(2.5, 6);
expect(brick[0][1]).toBeCloseTo(2.6, 6);
});
});
describe("Schnitt-Schraffur-Orientierung: relativeToWall dreht bei Wand, nicht bei Decke (2D-Parität)", () => {
// 2D-Referenz (verbindlich): splitWallLayers ruft resolveHatch(…, 90, …) (Wand
// steht vertikal, relativeToWall-Muster drehen mit), splitSlabLayers ruft
// resolveHatch(…, undefined, …) (Decke liegt, absoluter Winkel). Der 3D-Pfad muss
// EXAKT diesen Endwinkel je Kontext liefern (statt des rohen Hatch-Winkels) —
// sonst läuft z. B. die Boden-Dämmung im 3D-Schnitt horizontal statt vertikal.
const wallInsul = () =>
projectToModel3d(sampleProject).walls.find(
(w) => w.cut?.componentId === "insulation" && Math.abs(w.thickness - 0.16) < 1e-6,
)!;
const slabInsul = () =>
projectToModel3d(sampleProject).slabs.find((s) => s.cut?.componentId === "insulation")!;
it("WAND-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, 90)", () => {
const expected = resolveHatch(sampleProject, "sia-insulation", 90).angle;
expect(wallInsul().hatch?.angle).toBeCloseTo(expected, 6);
});
it("DECKEN-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, undefined) (absolut)", () => {
const expected = resolveHatch(sampleProject, "sia-insulation", undefined).angle;
expect(slabInsul().hatch?.angle).toBeCloseTo(expected, 6);
});
it("Wand- und Decken-Dämmung unterscheiden sich um den Wandachsenwinkel (relativeToWall dreht 90°)", () => {
// sia-insulation ist relativeToWall -> Wand-Winkel = roh 90, Decke = roh.
// Die Differenz ist exakt 90° (der Boden-Aufbau läuft dadurch quer zur Wand).
const diff = (slabInsul().hatch!.angle - wallInsul().hatch!.angle + 360) % 360;
expect(diff).toBeCloseTo(90, 6);
});
it("WAND-Backstein (relativeToWall): 3D-Hatch-Winkel == resolveHatch(sia-brick, 90)", () => {
const brick = projectToModel3d(sampleProject).walls.find(
(w) => w.cut?.componentId === "brick" && Math.abs(w.thickness - 0.15) < 1e-6,
)!;
expect(brick.hatch?.angle).toBeCloseTo(resolveHatch(sampleProject, "sia-brick", 90).angle, 6);
});
it("Musterlinien-Stärke kommt aus dem LineStyle des Hatch durch (2D-Parität)", () => {
// sia-insulation nutzt lineStyleId "hatch-hair" (0.02 mm). Der 3D-Hatch muss die
// resolveHatch-lineWeight (aus dem LineStyle) tragen — für Wand UND Decke gleich.
const expected = resolveHatch(sampleProject, "sia-insulation", 90).lineWeight;
expect(wallInsul().hatch?.lineWeight).toBeCloseTo(expected, 6);
expect(slabInsul().hatch?.lineWeight).toBeCloseTo(expected, 6);
expect(expected).toBeCloseTo(0.02, 6); // hatch-hair
});
});
describe("Wand-Joins im 3D (L-/T-Stösse, identisch zum 2D-Grundriss/Schnitt)", () => {
// Die per-Schicht-Cut-Distanzen aus computeJoins (model/joins.ts) werden im
// layered-3D-Pfad auf die Band-Achsenlängen angewandt — dieselben Zahlen, die
// generatePlan.ts über clippedBand auf die 2D-Polygone anwendet (Band-Mittellinie
// an der Gehrungs-/Anschluss-Linie geschnitten). Alle Erwartungswerte unten sind
// aus der Join-Geometrie des Sample-Projekts von Hand hergeleitet.
it("(a) L-Ecke gleicher Wandtyp: jede Schicht individuell gehrt (aussen länger, Kern kürzer)", () => {
// W2 (5,0)->(5,4), Typ "aw", KEINE Öffnung/Querwand -> genau 4 Schicht-Bänder.
// Beide Enden sind rechtwinklige L-Ecken (mit W1 bei (5,0), W3 bei (5,4)):
// 45°-Gehrung x+y=5 (Start) bzw. y=x-1 (Ende). Die Mittellinie einer Schicht
// bei Querversatz `offset` trifft die Gehrung bei Achsenmeter `offset` (Start)
// bzw. `4-offset` (Ende). Aussenschichten (negativer offset) laufen ÜBER die
// Ecke hinaus (wrappen), Innenschichten (positiver offset) enden kürzer.
// Achse u=(0,1) -> Box-Achsenanfang/-ende = start[1]/end[1].
// W2 steht unter der Decke C1: die Beton-Schicht (100) spaltet den Backstein (50)
// z-mässig in [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz) -> 5 Boxen. Die
// Gehrung ist z-unabhängig, beide Backstein-Boxen tragen denselben Achsenschnitt.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(5);
const band = (t: number) => w2.find((s) => Math.abs(s.thickness - t) < 1e-9)!;
// offset je Schicht (aw, center): -0.1625 / -0.0725 / 0.0825 / 0.165.
const cases: Array<[number, number]> = [
[0.02, -0.1625], // render-ext (aussen) -> from=-0.1625, to=4.1625
[0.16, -0.0725], // insulation
[0.15, 0.0825], // brick (Kern)
[0.015, 0.165], // render-int (innen) -> from=0.165, to=3.835 (am kürzesten)
];
for (const [t, offset] of cases) {
const b = band(t);
expect(b.start[1]).toBeCloseTo(offset, 6); // Achsenanfang = offset
expect(b.end[1]).toBeCloseTo(4 - offset, 6); // Achsenende = 4-offset
}
// Der Kern (brick) ist kürzer als die Aussenschicht (render-ext): die
// per-Schicht-Gehrung ist also wirklich individuell (keine einheitliche Länge).
const len = (t: number) => band(t).end[1] - band(t).start[1];
expect(len(0.15)).toBeLessThan(len(0.02));
expect(len(0.15)).toBeCloseTo(3.835, 6); // 3.9175 - 0.0825
expect(len(0.02)).toBeCloseTo(4.325, 6); // 4.1625 - (-0.1625)
});
it("(b) T-Stoss: Abzweig-Kern läuft bis zur Rückgrat-Nahfläche durch, Putz-Band stoppt an der Wandfläche", () => {
// Querwand W9 (2.4,0)->(2.4,4), Typ "iw" (render-int 0.015 / brick 0.12 /
// render-int 0.015), stösst mittig auf W1 (unten) und W3 (oben) -> zwei
// Mittelspannen-T-Stösse. W9-brick == W1/W3-Rückgrat (Komponente "brick",
// Prio 50) -> MERGE: der brick-Kern sticht durch den 0.015-Nah-Putz der
// Durchgangswand bis zu deren Rückgrat-Nahfläche, die render-int-Putzschichten
// stoppen an der Wandoberfläche. Achse u=(0,1) -> Achsenmeter = start[1]/end[1].
// W9 (iw) steht ebenfalls unter der Decke C1: die Beton-Schicht (100) spaltet den
// Kern-Backstein (50) z-mässig in [0,2.3] + [2.5,2.6] -> 2 Kern-Boxen + 2 Putz = 4.
// Die Gehrung/T-Stoss-Achse ist z-unabhängig, beide Kern-Boxen identisch.
const w9 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W9");
expect(w9.length).toBe(4);
const brick = w9.find((s) => Math.abs(s.thickness - 0.12) < 1e-9)!;
const renders = w9.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9);
expect(renders.length).toBe(2);
// Putz stoppt an W1-Oberfläche (y=0.1725) bzw. W3-Oberfläche (y=3.8275).
for (const r of renders) {
expect(r.start[1]).toBeCloseTo(0.1725, 6);
expect(r.end[1]).toBeCloseTo(3.8275, 6);
}
// Kern läuft 0.015 tiefer bis zur Rückgrat-Nahfläche (y=0.1575 bzw. 3.8425).
expect(brick.start[1]).toBeCloseTo(0.1575, 6);
expect(brick.end[1]).toBeCloseTo(3.8425, 6);
expect(brick.start[1]).toBeLessThan(renders[0].start[1]); // Kern durchgesteckt
});
it("(b) T-Stoss Durchgangswand: Nah-Putz wird über die Kernbreite aufgebrochen (Span-Cutout splittet das Band)", () => {
// W1 (Durchgangswand) verliert den Nah-Putz (innerstes render-int-Band) über
// die Achsen-Breite des durchstechenden W9-Kerns: das Band zerfällt entlang
// der Achse in zwei Teilstücke [0.165..2.34] und [2.46..4.835] (Lücke = die
// 0.12 m Kernbreite um x=2.4). W1-Achse u=(1,0) -> Achsenmeter = start[0]/end[0].
const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1");
const innerPlaster = w1
.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9)
.map((s) => [s.start[0], s.end[0]] as const)
.sort((a, b) => a[0] - b[0]);
expect(innerPlaster.length).toBe(2); // in zwei Teilstücke gebrochen
// Lücke = Kernbreite-Projektion [2.34, 2.46] um die Querwand-Achse x=2.4.
expect(innerPlaster[0][1]).toBeCloseTo(2.34, 6); // Ende Teilstück 1
expect(innerPlaster[1][0]).toBeCloseTo(2.46, 6); // Anfang Teilstück 2
});
it("(c) Join-verschobenes Band: Öffnungs-Löcher bleiben absolut korrekt positioniert (rebased)", () => {
// W1 trägt Fenster O1 (Achsenintervall [3.0,4.0]). Das Aussenputz-Band ist an
// den L-Ecken über die Achse hinaus verlängert (Achsenanfang < 0) — die Loch-
// `from`/`to` sind auf DIESEN Box-Start umgerechnet. Rekonstruiert man die
// absolute Achsenposition (Box-Achsenanfang + `from`), liegt das Fenster wieder
// exakt auf [3.0,4.0], unabhängig von der Join-Verschiebung. W1-Achse u=(1,0).
const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1");
const outer = w1.find((s) => Math.abs(s.thickness - 0.02) < 1e-9)!;
expect(outer.start[0]).toBeLessThan(0); // Band über die Ecke hinaus verlängert
const window = (outer.holes ?? [])
.map((h) => [outer.start[0] + h.from, outer.start[0] + h.to] as const)
.sort((a, b) => a[0] - b[0])
.find(([a]) => a > 2); // das Fenster O1 (das andere Loch ist die Tür D1)
expect(window).toBeDefined();
expect(window![0]).toBeCloseTo(3.0, 6);
expect(window![1]).toBeCloseTo(4.0, 6);
});
it("Joins wirken NUR im layered-3D-Pfad, nicht im Schnitt-Pfad (layered=false unverändert)", () => {
// Der Schnitt-Pfad (computeSection, layeredWalls:false) darf KEINE Join-Cuts
// bekommen: W2 bleibt eine Voll-Box über die ganze Dicke, ungehrt (Achse
// 0..4), damit toSection.ts::subtractDominantBands seine eigene Dominanz auf
// Rohgeometrie anwendet.
const w2 = projectToWalls3d(sampleProject, false).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(1); // Voll-Box, keine Schicht-Bänder
expect(w2[0].start[1]).toBeCloseTo(0, 6); // ungehrt: Achse 0..4
expect(w2[0].end[1]).toBeCloseTo(4, 6);
});
});
describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => {
it("gerade Betontreppe -> Mesh mit 8 Ecken je Tritt, glatte schräge Untersicht", () => {
// Sample-Treppe ST1: Typ "stair-standard" (Tragart "beton"), gerade,
// stepCount 15 -> ein konvexes Prisma je Tritt = 8 Ecken. Das Mesh landet in
// model.meshes (kind "extrusion").
const model = projectToModel3d(sampleProject);
const stairMesh = model.meshes.find((m) => m.positions.length / 3 === 8 * 15);
expect(stairMesh).toBeTruthy();
const m = stairMesh!;
// Indizes bilden Dreiecke (6 Quads * 2 Tris * 3 = 36 Indizes je Tritt).
expect(m.indices.length).toBe(15 * 36);
expect(m.indices.length % 3).toBe(0);
// Keine NaN/Inf in der Geometrie.
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
// Höhen (jede 3. Komponente) liegen zwischen Treppen-UK (~0) und -OK (~2.6).
const heights = m.positions.filter((_, i) => i % 3 === 2);
expect(Math.min(...heights)).toBeGreaterThanOrEqual(-1e-6);
expect(Math.max(...heights)).toBeLessThanOrEqual(2.6 + 1e-6);
// Alle Index-Werte referenzieren existierende Vertices.
const vcount = m.positions.length / 3;
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
}); });
}); });
+1243 -124
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
// Unit-Tests für den gemeinsamen Wand-Loch-Ausschnitt-Generator (wallMeshCut.ts,
// TS-Port von render3d/src/mesh.rs::extrude_layer_segment_with_holes). Prüft die
// geometrische Zerlegung (gleiche Rechteck-Anzahl wie die Rust-Referenz), nicht
// nur „läuft".
import { describe, it, expect } from "vitest";
import { isWatertight, solidSubrects, wallCutMesh } from "./wallMeshCut";
import type { CutMesh } from "./wallMeshCut";
import type { RWall } from "./toWalls3d";
/**
* Geschlossene-Fläche-Kennzahlen: Vektorfläche n dA (Σ (v1v0)×(v2v0)) und
* 6× signiertes Volumen (Σ v0·(v1×v2)). Dienen den Watertight-Tests direkt (statt
* eines naiven Kanten-Manifold-Zählers, der an den legitimen T-Stössen der vollen
* Deckel/Boden-Streifen scheitern würde siehe isWatertight-Doc in wallMeshCut.ts).
*/
function surfaceIntegrals(m: CutMesh): { areaLen: number; vol6: number } {
const p = m.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < m.indices.length; t += 3) {
const i0 = m.indices[t] * 3;
const i1 = m.indices[t + 1] * 3;
const i2 = m.indices[t + 2] * 3;
const e1 = [p[i1] - p[i0], p[i1 + 1] - p[i0 + 1], p[i1 + 2] - p[i0 + 2]];
const e2 = [p[i2] - p[i0], p[i2 + 1] - p[i0 + 1], p[i2 + 2] - p[i0 + 2]];
const cx = e1[1] * e2[2] - e1[2] * e2[1];
const cy = e1[2] * e2[0] - e1[0] * e2[2];
const cz = e1[0] * e2[1] - e1[1] * e2[0];
ax += cx;
ay += cy;
az += cz;
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return { areaLen: Math.hypot(ax, ay, az), vol6 };
}
/** Ein RWall-Band mit optionalen Löchern (frei stehend, Achse entlang +x). */
function band(holes?: RWall["holes"]): RWall {
return {
start: [0, 0],
end: [5, 0],
thickness: 0.4,
height: 2.6,
baseElevation: 0,
color: [0.8, 0.8, 0.8],
layers: [],
wallId: "W1",
...(holes ? { holes } : {}),
};
}
describe("solidSubrects — Koordinaten-Kompression (Rust-Parität)", () => {
it("ohne Loch: genau ein Vollrechteck", () => {
expect(solidSubrects(0, 5, 0, 2.6, [])).toEqual([[0, 5, 0, 2.6]]);
});
it("ein voll im Inneren liegendes Loch → 3×3-Gitter minus 1 = 8 solide Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.6, [[2, 3, 0.9, 2.4]]);
expect(rects).toHaveLength(8);
// Keiner der Teilrechteck-Mittelpunkte liegt im Loch.
for (const [ua, ub, za, zb] of rects) {
const cu = (ua + ub) / 2;
const cz = (za + zb) / 2;
expect(cu > 2 && cu < 3 && cz > 0.9 && cz < 2.4).toBe(false);
}
});
it("randständiges Loch (Tür am Boden) → 3×2-Gitter minus 1 = 5 Teilrechtecke", () => {
expect(solidSubrects(0, 5, 0, 2.6, [[2, 3, 0, 2.1]])).toHaveLength(5);
});
it("zwei versetzt übereinanderliegende Löcher: keine überlappenden/entarteten Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.8, [
[1, 2, 0.9, 1.4],
[3, 4, 1.6, 2.2],
]);
// Alle Teilrechtecke haben echte Ausdehnung und liegen ausserhalb beider Löcher.
for (const [ua, ub, za, zb] of rects) {
expect(ub - ua).toBeGreaterThan(0);
expect(zb - za).toBeGreaterThan(0);
}
expect(rects.length).toBeGreaterThan(0);
});
});
describe("wallCutMesh — Loch-Ausschnitt-Mesh", () => {
it("ohne Loch: 12 Dreiecke (volle Box-Äquivalenz)", () => {
const m = wallCutMesh(band());
expect(m.indices.length / 3).toBe(12);
});
it("Fenster im Inneren: 48 Dreiecke, 4 Laibungen, Loch-Rand-Vertices vorhanden", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
expect(m.indices.length / 3).toBe(48);
// Vertices an allen vier Loch-Rändern (Brüstung/Sturz/links/rechts).
const has = (axis: number, val: number) => {
for (let i = 0; i < m.positions.length; i += 3) {
if (Math.abs(m.positions[i + axis] - val) < 1e-6) return true;
}
return false;
};
expect(has(1, 0.9)).toBe(true); // y = Brüstungs-OK
expect(has(1, 2.4)).toBe(true); // y = Sturz-UK
expect(has(0, 2)).toBe(true); // x = linke Kante
expect(has(0, 3)).toBe(true); // x = rechte Kante
// KEIN Vertex liegt strikt im offenen Loch-Rechteck (die Fläche ist wirklich weg).
for (let i = 0; i < m.positions.length; i += 3) {
const x = m.positions[i];
const y = m.positions[i + 1];
const strictlyInside = x > 2 + 1e-6 && x < 3 - 1e-6 && y > 0.9 + 1e-6 && y < 2.4 - 1e-6;
expect(strictlyInside).toBe(false);
}
});
it("Tür am Boden: 36 Dreiecke (untere Laibung entfällt, Boden bekommt die Lücke)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
expect(m.indices.length / 3).toBe(36);
});
it("alle Dreiecks-Indizes referenzieren existierende Vertices", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const vcount = m.positions.length / 3;
for (const idx of m.indices) {
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(vcount);
}
});
});
describe("wallCutMesh — Wasserdichtigkeit + Aussen-Orientierung (der hohl/offen-Bug)", () => {
it("volle Wand OHNE Loch: geschlossen (∮ n dA = 0), Normalen aussen (Volumen = L·T·H)", () => {
const m = wallCutMesh(band());
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6); // ∮ n dA = 0 ⇒ kein Loch, kein invertiertes Dreieck
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6, 6); // > 0 ⇒ aussen orientiert
expect(isWatertight(m)).toBe(true);
});
it("Wand mit Fenster: trotz T-Stösse geschlossen + aussen orientiert (Volumen = Wand Fensterquader)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * (2.4 - 0.9), 6);
expect(isWatertight(m)).toBe(true);
});
it("eine EINZELNE invertierte Kappe würde erkannt (Regressionswächter gegen falsche Wicklung)", () => {
// Bau die volle Wand, kehre die Wicklung EINES Dreiecks um → nicht mehr dicht.
const m = wallCutMesh(band());
const broken: CutMesh = { positions: m.positions.slice(), indices: m.indices.slice() };
[broken.indices[1], broken.indices[2]] = [broken.indices[2], broken.indices[1]];
expect(isWatertight(broken)).toBe(false);
});
it("Türwand ist ein GESCHLOSSENER Prisma-Körper (Querschnitt = П-Polygon, Türkerbe umlaufend)", () => {
// Die Türkerbe ist eine Einbuchtung der UNTEREN Umriss-Kante (kein Loch): der
// Querschnitt bleibt EIN einfaches, geschlossenes Polygon (П-Form), das über
// die Dicke extrudiert wieder einen geschlossenen Körper ergibt. Die fehlende
// untere Laibung und die Boden-Lücke heben sich in ∮ n dA exakt auf.
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * 2.1, 6);
expect(isWatertight(m)).toBe(true);
});
});
+370
View File
@@ -0,0 +1,370 @@
// Gemeinsamer Wand-Mesh-Generator MIT echtem Loch-Ausschnitt (Fenster/Türen).
//
// Portierung der Rust-Referenz `render3d/src/mesh.rs::extrude_layer_segment_
// with_holes` nach TS: aus EINEM RWall-Band (Achse `start→end`, `thickness`,
// `baseElevation`, `height` + rechteckige `holes`) wird das Dreiecks-Mesh
// erzeugt, das die Öffnungen ECHT ausschneidet (statt der vollen Box). Damit
// sehen die Datei-Exporte (STL/OBJ/IFC) genauso aus wie die 3D-Ansicht: die
// Löcher sind sichtbar ausgestanzt, inklusive der Laibungs-/Jamb-Flächen.
//
// WICHTIG — WARUM KEINE GEHRUNG HIER: Die RWall-Bänder aus
// `toWalls3d.ts::pickGeometry` sind bereits an ihren Achsenden auf die
// Gehrungs-/Anschluss-Schnittlinie GEKÜRZT (die Box endet aber achsparallel,
// keine gekippte Stirnfläche — siehe `bandCutDistance` in toWalls3d.ts). Die
// Ecken-Verschneidung (Joins) steckt also schon in `start`/`end` jedes Bandes.
// Deshalb entfällt hier die `start_cut`/`end_cut`-Gehrungslogik der Rust-Funktion
// (im Rust-Pfad wird sie nur gebraucht, weil dort EIN ungekürztes Band mit
// Gehrungslinien extrudiert wird). Das Ergebnis: dieselbe Rechteck-Zerlegung der
// Langseiten + dieselben 4 Laibungsquads je Loch wie in Rust, nur mit rechten
// (ungekippten) Stirnkappen — exakt die Geometrie, die der Viewer schon zeigt.
//
// ACHSEN: Modell (x, y) → Welt (x, Höhe, y), Y-UP — identisch zu exportMesh.ts /
// selectionHighlightLines / dem 3D-Viewer. Ein Weltvertex (vx,vy,vz) ist also
// (Modell-x, Meter-Höhe, Modell-y).
import type { RWall } from "./toWalls3d";
/** Rundungs-/Entartungsschwelle (1e-4 m), 1:1 wie `openings::MIN_SPAN` in Rust. */
const MIN_SPAN = 1e-4;
/** Ein Weltpunkt (x, Höhe, y). */
type V3 = [number, number, number];
/**
* Ein Dreiecks-Mesh in LOKALEN (0-basierten) Indizes: `positions` flach
* (x,y,z, ) in Weltmetern (Y-up), `indices` je 3 = ein Dreieck.
*/
export interface CutMesh {
positions: number[];
indices: number[];
}
/**
* Aussen-Normale eines planaren, CCW-von-aussen georderten Quads über die ersten
* drei Ecken: `normalize(cross(b-a, d-a))` 1:1 wie `mesh.rs::quad_normal`.
* Nulllange Flächen liefern den Nullvektor (Aufrufer nutzt sie dann nicht).
*/
function quadNormal(a: V3, b: V3, d: V3): V3 {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ad: V3 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
const c: V3 = [
ab[1] * ad[2] - ab[2] * ad[1],
ab[2] * ad[0] - ab[0] * ad[2],
ab[0] * ad[1] - ab[1] * ad[0],
];
const len = Math.max(Math.hypot(c[0], c[1], c[2]), 1e-9);
return [c[0] / len, c[1] / len, c[2] / len];
}
/**
* Hängt ein Dreieck an `mesh` an, dessen Winding an `wantNormal` ausgerichtet
* wird (robust gegen die Eck-Reihenfolge) 1:1 wie `mesh.rs::push_tri_oriented`.
*/
function pushTriOriented(mesh: CutMesh, a: V3, b: V3, c: V3, wantNormal: V3): void {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ac: V3 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const gn: V3 = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
const dot = gn[0] * wantNormal[0] + gn[1] * wantNormal[1] + gn[2] * wantNormal[2];
const [v0, v1, v2] = dot < 0 ? [a, c, b] : [a, b, c];
const base = mesh.positions.length / 3;
mesh.positions.push(v0[0], v0[1], v0[2], v1[0], v1[1], v1[2], v2[0], v2[1], v2[2]);
mesh.indices.push(base, base + 1, base + 2);
}
/** Hängt ein Quad (vier Ecken als Ring) als zwei orientierte Dreiecke an. */
function pushQuadOriented(mesh: CutMesh, a: V3, b: V3, c: V3, d: V3, wantNormal: V3): void {
pushTriOriented(mesh, a, b, c, wantNormal);
pushTriOriented(mesh, a, c, d, wantNormal);
}
/**
* Zerlegt das Rechteck `[uLo,uHi]×[zLo,zHi]` MINUS `holes` (`[u0,u1,z0,z1]`) in
* achsparallele, disjunkte SOLIDE Teilrechtecke Koordinaten-Kompression, 1:1
* wie `mesh.rs::solid_subrects` (jede Gitterzelle, deren Mittelpunkt in keinem
* Loch liegt, ist ein solides Teilrechteck). Exportiert für die Zerlegungs-Tests
* (gleiche Rechteck-Anzahl wie die Rust-Zerlegung).
*/
export function solidSubrects(
uLo: number,
uHi: number,
zLo: number,
zHi: number,
holes: Array<[number, number, number, number]>,
): Array<[number, number, number, number]> {
const sortDedup = (v: number[]): number[] => {
const s = [...v].sort((a, b) => a - b);
const out: number[] = [];
for (const x of s) {
if (out.length === 0 || Math.abs(x - out[out.length - 1]) > MIN_SPAN) out.push(x);
}
return out;
};
const usRaw = [uLo, uHi];
const zsRaw = [zLo, zHi];
for (const [a0, a1, hb, ht] of holes) {
usRaw.push(a0, a1);
zsRaw.push(hb, ht);
}
const us = sortDedup(usRaw);
const zs = sortDedup(zsRaw);
const out: Array<[number, number, number, number]> = [];
for (let i = 0; i < us.length - 1; i++) {
const ua = us[i];
const ub = us[i + 1];
if (ub - ua <= MIN_SPAN) continue;
for (let j = 0; j < zs.length - 1; j++) {
const za = zs[j];
const zb = zs[j + 1];
if (zb - za <= MIN_SPAN) continue;
const cu = (ua + ub) * 0.5;
const cz = (za + zb) * 0.5;
const inHole = holes.some(([a0, a1, hb, ht]) => cu > a0 && cu < a1 && cz > hb && cz < ht);
if (!inHole) out.push([ua, ub, za, zb]);
}
}
return out;
}
/**
* Zieht die (ggf. überlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
* die verbleibenden, sortierten, disjunkten Teilintervalle 1:1 wie
* `mesh.rs::subtract_intervals` (für Deckel-/Boden-/Kappen-Lücken randständiger
* Löcher).
*/
function subtractIntervals(lo: number, hi: number, gaps: Array<[number, number]>): Array<[number, number]> {
const gs = gaps
.map(([a, b]): [number, number] => [Math.max(a, lo), Math.min(b, hi)])
.filter(([a, b]) => b - a > MIN_SPAN)
.sort((p, q) => p[0] - q[0]);
const out: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of gs) {
if (a > cursor + MIN_SPAN) out.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (hi > cursor + MIN_SPAN) out.push([cursor, hi]);
return out;
}
/**
* Prüft, ob ein Mesh eine GESCHLOSSENE, KONSISTENT NACH AUSSEN gewundene Fläche
* ist (bounds ein Volumen ohne Löcher) über das Gauss-/Divergenz-Kriterium
* statt eines Kanten-Manifold-Zählers:
* n dA = 0 Σ (v1v0)×(v2v0) = 0 über alle Dreiecke (jede offene
* Lücke ODER jedes falsch gewundene/fehlende Dreieck verletzt die Summe).
* signiertes Volumen Σ v0·(v1×v2)/6 > 0 Normalen zeigen NACH AUSSEN.
*
* BEWUSST NICHT der naive jede ungerichtete Kante genau 2×"-Manifold-Check:
* dieses Mesh nutzt (wie die Rust-Referenz) VOLLE Deckel/Boden-Streifen über die
* ganze Breite, während die Langseiten an den Loch-Spalten unterteilt sind das
* erzeugt legitime T-Stösse (eine lange Kante trifft mehrere kurze), die KEINE
* echten Lücken sind. Der naive Kantenzähler würde diese korrekte, wasserdichte
* Geometrie fälschlich als offen" melden; das Gauss-Kriterium ist T-Stoss-robust
* und misst genau die Eigenschaft, auf die es ankommt (dichtes Volumen + Aussen-
* Orientierung die Ursache des hohl/offen"-Bugs war eine invertierte Wicklung
* im IFC-Export, nicht eine fehlende Fläche).
*/
export function isWatertight(mesh: CutMesh, eps = 1e-6): boolean {
const p = mesh.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < mesh.indices.length; t += 3) {
const i0 = mesh.indices[t] * 3;
const i1 = mesh.indices[t + 1] * 3;
const i2 = mesh.indices[t + 2] * 3;
const e1x = p[i1] - p[i0];
const e1y = p[i1 + 1] - p[i0 + 1];
const e1z = p[i1 + 2] - p[i0 + 2];
const e2x = p[i2] - p[i0];
const e2y = p[i2 + 1] - p[i0 + 1];
const e2z = p[i2 + 2] - p[i0 + 2];
const cx = e1y * e2z - e1z * e2y;
const cy = e1z * e2x - e1x * e2z;
const cz = e1x * e2y - e1y * e2x;
ax += cx;
ay += cy;
az += cz;
// v0 · (v1 × v2) = v0 · (e1 × e2) (die reinen v0-Terme heben sich auf).
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return Math.hypot(ax, ay, az) < eps && vol6 > eps;
}
/**
* Erzeugt das Wand-Mesh EINES RWall-Bandes mit ECHTEN Loch-Ausschnitten der
* TS-Port von `mesh.rs::extrude_layer_segment_with_holes` (ohne Gehrung, siehe
* Moduldoc). Aufbau:
* Langseiten (+n / n): Flächen-Rechteck MINUS der Löcher, per {@link
* solidSubrects} in Teilrechtecke zerlegt (je 2 Dreiecke).
* Deckel (y1) / Boden (y0): voller Streifen minus der u-Intervalle rand-
* ständiger Löcher (z. B. eine Tür am Boden).
* Stirnkappen (a=0 / a=length): volle Höhe minus z-Intervalle randständiger
* Löcher.
* Laibungen je Loch: bis zu vier Quads (links/rechts/unten/oben), nur für die
* im Wand-Inneren liegenden Seiten.
*
* Ohne `holes` (undefined/leer) liefert das die volle Box (Regressionsfall);
* Aufrufer, die für lochlose Bänder die klassische 8-Ecken-Box wollen, prüfen
* das selbst (siehe exportMesh.ts).
*/
export function wallCutMesh(w: RWall): CutMesh {
const mesh: CutMesh = { positions: [], indices: [] };
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dy = ey - sy;
const length = Math.hypot(dx, dy);
if (length < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return mesh;
const ux = dx / length;
const uy = dy / length;
// Links-Normale n = (uy, ux) — dieselbe Konvention wie mesh.rs::left_normal.
const n: [number, number] = [-uy, ux];
const y0 = w.baseElevation;
const y1 = w.baseElevation + w.height;
const offA = w.thickness / 2;
const offB = -w.thickness / 2;
const off = (p: [number, number], s: number): [number, number] => [p[0] + n[0] * s, p[1] + n[1] * s];
const axisPoint = (a: number): [number, number] => [sx + ux * a, sy + uy * a];
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv` (keine Gehrung).
const gAt = (a: number, offv: number): [number, number] => off(axisPoint(a), offv);
const wpt = (g: [number, number], y: number): V3 => [g[0], y, g[1]];
// Löcher auf [0,length]×[y0,y1] klemmen, entartete verwerfen.
const hs: Array<[number, number, number, number]> = [];
for (const h of w.holes ?? []) {
const a0 = Math.max(h.from, 0);
const a1 = Math.min(h.to, length);
const zb = Math.max(h.zBottom, y0);
const zt = Math.min(h.zTop, y1);
if (a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN) hs.push([a0, a1, zb, zt]);
}
// ── Langseiten (+n / n): Rechteck-Gitter minus Löcher. ────────────────────
const np: V3 = [n[0], 0, n[1]];
const nn: V3 = [-n[0], 0, -n[1]];
for (const [ua, ub, za, zb] of solidSubrects(0, length, y0, y1, hs)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), za),
wpt(gAt(ub, offA), za),
wpt(gAt(ub, offA), zb),
wpt(gAt(ua, offA), zb),
np,
);
pushQuadOriented(
mesh,
wpt(gAt(ua, offB), za),
wpt(gAt(ub, offB), za),
wpt(gAt(ub, offB), zb),
wpt(gAt(ua, offB), zb),
nn,
);
}
// ── Deckel (y1) / Boden (y0): voller Streifen minus randständiger Löcher. ──
const topGaps = hs.filter(([, , , zt]) => zt >= y1 - MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, topGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y1),
wpt(gAt(ub, offA), y1),
wpt(gAt(ub, offB), y1),
wpt(gAt(ua, offB), y1),
[0, 1, 0],
);
}
const botGaps = hs.filter(([, , zb]) => zb <= y0 + MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, botGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y0),
wpt(gAt(ub, offA), y0),
wpt(gAt(ub, offB), y0),
wpt(gAt(ua, offB), y0),
[0, -1, 0],
);
}
// ── Stirnkappen (a=0 / a=length): volle Höhe minus randständiger Löcher. ───
const startNormal = quadNormal(wpt(gAt(0, offB), y0), wpt(gAt(0, offA), y0), wpt(gAt(0, offB), y1));
const startGaps = hs.filter(([a0]) => a0 <= MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, startGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(0, offB), za),
wpt(gAt(0, offA), za),
wpt(gAt(0, offA), zb),
wpt(gAt(0, offB), zb),
startNormal,
);
}
const endNormal = quadNormal(wpt(gAt(length, offA), y0), wpt(gAt(length, offB), y0), wpt(gAt(length, offA), y1));
const endGaps = hs.filter(([, a1]) => a1 >= length - MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, endGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(length, offA), za),
wpt(gAt(length, offB), za),
wpt(gAt(length, offB), zb),
wpt(gAt(length, offA), zb),
endNormal,
);
}
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
for (const [a0, a1, zb, zt] of hs) {
// Linke Laibung (bei a0), Normale +u (in die Öffnung).
if (a0 > MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a0, offB), zb),
wpt(gAt(a0, offB), zt),
wpt(gAt(a0, offA), zt),
[ux, 0, uy],
);
}
// Rechte Laibung (bei a1), Normale u.
if (a1 < length - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a1, offB), zt),
wpt(gAt(a1, offA), zt),
[-ux, 0, -uy],
);
}
// Untere Laibung (bei zb = Brüstungsoberkante), Normale +Y.
if (zb > y0 + MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a0, offB), zb),
[0, 1, 0],
);
}
// Obere Laibung (bei zt = Sturzunterkante), Normale Y.
if (zt < y1 - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zt),
wpt(gAt(a1, offA), zt),
wpt(gAt(a1, offB), zt),
wpt(gAt(a0, offB), zt),
[0, -1, 0],
);
}
}
return mesh;
}
+11 -3
View File
@@ -19,6 +19,8 @@ import { createLayoutSlice } from "./layoutSlice";
import type { LayoutSlice } from "./layoutSlice"; import type { LayoutSlice } from "./layoutSlice";
import { createSiteSlice } from "./siteSlice"; import { createSiteSlice } from "./siteSlice";
import type { SiteSlice } from "./siteSlice"; import type { SiteSlice } from "./siteSlice";
import { createNotifySlice } from "./notifySlice";
import type { NotifySlice } from "./notifySlice";
/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */ /** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */
export type RootState = ProjectSlice & export type RootState = ProjectSlice &
@@ -26,17 +28,23 @@ export type RootState = ProjectSlice &
SelectionSlice & SelectionSlice &
ViewSlice & ViewSlice &
LayoutSlice & LayoutSlice &
SiteSlice; SiteSlice &
NotifySlice;
const store = createStore<RootState>((api) => ({ const store = createStore<RootState>((api) => ({
// Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur // Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur
// ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil // ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil
// `set/get` immer über den Gesamt-RootState laufen. ProjectSlice bringt die // `set/get` immer über den Gesamt-RootState laufen. ProjectSlice bringt die
// History (undo/redo) gleich mit (setProject ist der einzige Durchlauf- // History (undo/redo) gleich mit (setProject ist der einzige Durchlauf-
// punkt jeder Projekt-Mutation, siehe projectSlice.ts). // punkt jeder Projekt-Mutation, siehe projectSlice.ts) und braucht `notify`
// (Cross-Slice) für die Lösch-Schutz-Warnungen (statt window.alert, siehe
// notifySlice.ts).
...createProjectSlice( ...createProjectSlice(
api as unknown as StoreApi<ProjectSlice & HistorySlice & { activeLevelId: string }>, api as unknown as StoreApi<
ProjectSlice & HistorySlice & { activeLevelId: string } & Pick<NotifySlice, "notify">
>,
), ),
...createNotifySlice(api as unknown as StoreApi<NotifySlice>),
...createSelectionSlice(api as unknown as StoreApi<SelectionSlice>), ...createSelectionSlice(api as unknown as StoreApi<SelectionSlice>),
...createViewSlice(api as unknown as StoreApi<ViewSlice>), ...createViewSlice(api as unknown as StoreApi<ViewSlice>),
...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>), ...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>),
+61
View File
@@ -0,0 +1,61 @@
/**
* Notify-Slice (`src/state/notifySlice.ts`) nicht-blockierende
* Kurzmeldungen (Ersatz für `window.alert`, das im Tauri-WKWebView deaktiviert
* ist). Getestet wird nur der reine State (Hinzufügen/Entfernen); der
* Auto-Dismiss-Timer lebt bewusst in der Toast-Komponente (src/ui/Toast.tsx),
* nicht in der Slice, und ist hier daher kein Thema.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { getState, setState } from "./appStore";
beforeEach(() => {
setState({ notifications: [] });
});
describe("notifySlice", () => {
it("notify fügt eine Meldung hinzu (Default-Art: info)", () => {
getState().notify("Hallo");
const notes = getState().notifications;
expect(notes.length).toBe(1);
expect(notes[0].message).toBe("Hallo");
expect(notes[0].kind).toBe("info");
expect(notes[0].id).toBeTruthy();
});
it("notify übernimmt die übergebene Art", () => {
getState().notify("Achtung", "warning");
expect(getState().notifications[0].kind).toBe("warning");
});
it("mehrere notify()-Aufrufe erzeugen unterschiedliche ids, in Reihenfolge angehängt", () => {
getState().notify("A");
getState().notify("B");
const notes = getState().notifications;
expect(notes.map((n) => n.message)).toEqual(["A", "B"]);
expect(new Set(notes.map((n) => n.id)).size).toBe(2);
});
it("dismissNotify entfernt genau die Meldung mit der gegebenen id", () => {
getState().notify("A");
getState().notify("B");
const [first, second] = getState().notifications;
getState().dismissNotify(first.id);
const remaining = getState().notifications;
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
it("dismissNotify bei unbekannter id ist ein No-Op", () => {
getState().notify("A");
const before = getState().notifications;
getState().dismissNotify("unbekannte-id");
expect(getState().notifications).toEqual(before);
});
});
+58
View File
@@ -0,0 +1,58 @@
// Notify-Slice: nicht-blockierende Kurzmeldungen (Toasts). Ersetzt
// `window.alert`, das im Tauri-WKWebView deaktiviert ist (tut nichts) und
// Nutzer-Warnungen (z. B. „X wird noch verwendet") lautlos verschluckte.
//
// Reiner Zustand + zwei Actions — das Rendern (gestapelter Container) UND der
// Auto-Dismiss-Timer leben bewusst in der Toast-Komponente
// (src/ui/Toast.tsx), nicht hier: ein Timer pro GEMOUNTETER Meldung ist
// robuster als einer pro `notify()`-Aufruf (kein doppeltes Aufräumen bei
// Store-Reload/Tests, kein Timer, der eine Komponente überlebt, die es nie zu
// sehen bekam). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { StoreApi } from "./store";
/** Art der Meldung — steuert nur den Farbakzent im Toast. */
export type NotifyKind = "info" | "warning" | "error";
/** Eine einzelne Toast-Meldung. */
export interface Notification {
id: string;
message: string;
kind: NotifyKind;
}
/** Felder/Actions, die diese Slice in den RootState beisteuert. */
export interface NotifySlice {
notifications: Notification[];
/** Fügt eine Meldung mit eindeutiger id hinzu (Default-Art: "info"). */
notify: (message: string, kind?: NotifyKind) => void;
/** Entfernt eine Meldung per id (No-op bei unbekannter id). */
dismissNotify: (id: string) => void;
}
// Monoton wachsender Zähler zusätzlich zum Zeitstempel: verhindert doppelte
// ids, falls mehrere `notify()`-Aufrufe innerhalb derselben Millisekunde
// passieren (z. B. mehrere Löschversuche in einer Schleife).
let counter = 0;
function nextId(): string {
counter += 1;
return `notify-${Date.now()}-${counter}`;
}
export function createNotifySlice(api: StoreApi<NotifySlice>): NotifySlice {
const { set } = api;
return {
notifications: [],
notify: (message, kind = "info") =>
set((s) => ({
notifications: [...s.notifications, { id: nextId(), message, kind }],
})),
dismissNotify: (id) =>
set((s) => ({
notifications: s.notifications.filter((n) => n.id !== id),
})),
};
}
+12 -4
View File
@@ -30,6 +30,7 @@ import { t } from "../i18n";
import type { StoreApi } from "./store"; import type { StoreApi } from "./store";
import { createHistorySlice, pushHistoryPatch } from "./historySlice"; import { createHistorySlice, pushHistoryPatch } from "./historySlice";
import type { HistorySlice } from "./historySlice"; import type { HistorySlice } from "./historySlice";
import type { NotifyKind } from "./notifySlice";
/** Felder, die diese Slice in den RootState beisteuert. */ /** Felder, die diese Slice in den RootState beisteuert. */
export interface ProjectSlice { export interface ProjectSlice {
@@ -286,6 +287,9 @@ export interface ProjectSlice {
*/ */
type ProjectSliceDeps = { type ProjectSliceDeps = {
activeLevelId: string; activeLevelId: string;
/** Nicht-blockierende Kurzmeldung (Toast) Ersatz für `window.alert`, das
* im Tauri-WKWebView deaktiviert ist (siehe notifySlice.ts). */
notify: (message: string, kind?: NotifyKind) => void;
} & HistorySlice; } & HistorySlice;
export function createProjectSlice( export function createProjectSlice(
@@ -579,7 +583,7 @@ export function createProjectSlice(
codes.has(o.categoryCode), codes.has(o.categoryCode),
); );
if (usedByWall || usedByDoor || usedByOpening) { if (usedByWall || usedByDoor || usedByOpening) {
window.alert(t("alert.layerInUse", { code: src.code, name: src.name })); get().notify(t("alert.layerInUse", { code: src.code, name: src.name }), "warning");
return p; return p;
} }
return { ...p, layers: removeByCode(p.layers, code) }; return { ...p, layers: removeByCode(p.layers, code) };
@@ -664,7 +668,7 @@ export function createProjectSlice(
); );
if (used) { if (used) {
const name = p.components.find((c) => c.id === id)?.name ?? id; const name = p.components.find((c) => c.id === id)?.name ?? id;
window.alert(t("alert.componentInUse", { name })); get().notify(t("alert.componentInUse", { name }), "warning");
return p; return p;
} }
return { ...p, components: p.components.filter((c) => c.id !== id) }; return { ...p, components: p.components.filter((c) => c.id !== id) };
@@ -695,7 +699,7 @@ export function createProjectSlice(
const used = p.components.some((c) => c.hatchId === id); const used = p.components.some((c) => c.hatchId === id);
if (used) { if (used) {
const name = p.hatches.find((h) => h.id === id)?.name ?? id; const name = p.hatches.find((h) => h.id === id)?.name ?? id;
window.alert(t("alert.hatchInUse", { name })); get().notify(t("alert.hatchInUse", { name }), "warning");
return p; return p;
} }
return { ...p, hatches: p.hatches.filter((h) => h.id !== id) }; return { ...p, hatches: p.hatches.filter((h) => h.id !== id) };
@@ -726,7 +730,7 @@ export function createProjectSlice(
const used = p.hatches.some((h) => h.lineStyleId === id); const used = p.hatches.some((h) => h.lineStyleId === id);
if (used) { if (used) {
const name = p.lineStyles.find((l) => l.id === id)?.name ?? id; const name = p.lineStyles.find((l) => l.id === id)?.name ?? id;
window.alert(t("alert.lineStyleInUse", { name })); get().notify(t("alert.lineStyleInUse", { name }), "warning");
return p; return p;
} }
return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) }; return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) };
@@ -1373,6 +1377,8 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y }, { x: g.min.x, y: g.max.y },
]; ];
} }
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
if (g.shape === "text") return [g.at];
return []; return [];
} }
@@ -1412,6 +1418,8 @@ function moveGrip(
const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) }; const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) };
return { ...d, geom: { ...g, min, max } }; return { ...d, geom: { ...g, min, max } };
} }
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
return d; return d;
}), }),
}; };
+59
View File
@@ -0,0 +1,59 @@
// Unit-Tests für projectStore.ts — Guard-Verhalten ohne IndexedDB.
//
// vitest läuft mit environment: "node" (siehe vitest.config.ts); dort
// existiert `indexedDB` nicht, und `fake-indexeddb` ist keine devDependency
// dieses Repos. Getestet wird daher primär, dass die API auch ohne
// IndexedDB sauber degradiert (siehe CONVENTIONS/Auftrag): `loadProject`
// liefert `null`, `listProjects` liefert `[]`, `saveProject`/`deleteProject`
// werfen nicht.
import { describe, it, expect } from "vitest";
import { saveProject, loadProject, listProjects, deleteProject } from "./projectStore";
import type { Project } from "../model/types";
/** Minimalprojekt (analog model/wall.test.ts) — genügt der Project-Form. */
function makeProject(overrides?: Partial<Project>): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
...overrides,
} as Project;
}
describe("projectStore — Guard ohne IndexedDB", () => {
it("loadProject liefert null", async () => {
await expect(loadProject("irgendein-projekt")).resolves.toBeNull();
});
it("listProjects liefert leere Liste", async () => {
await expect(listProjects()).resolves.toEqual([]);
});
it("saveProject wirft nicht (no-op)", async () => {
await expect(saveProject("p1", makeProject())).resolves.toBeUndefined();
});
it("deleteProject wirft nicht (no-op)", async () => {
await expect(deleteProject("p1")).resolves.toBeUndefined();
});
it("loadProject mit leerem Namen liefert null, ohne indexedDB zu berühren", async () => {
await expect(loadProject("")).resolves.toBeNull();
});
it("saveProject mit leerem Namen ist ein No-op", async () => {
await expect(saveProject("", makeProject())).resolves.toBeUndefined();
});
it("deleteProject mit leerem Namen ist ein No-op", async () => {
await expect(deleteProject("")).resolves.toBeUndefined();
});
});
+184
View File
@@ -0,0 +1,184 @@
// Projekt-Persistenz — IndexedDB (Browser-nativer Speicher-Kern).
//
// Speichert vollständige Projekte (inkl. Meshes/grosser JSON-Nutzlast) lokal
// im Browser. Analog zu visibilitySets.ts: reine Speicher-Helfer OHNE React-
// Abhängigkeit, sämtlicher Zugriff defensiv (SSR/Tests/Privatmodus) — hier
// mit `indexedDB` statt `localStorage`, da localStorage-Kontingente für
// Projekt-JSON (Wände, Meshes, …) zu klein sind.
//
// Hinweis: Dies ist nur der Speicher-KERN (CRUD-API). UI-Verdrahtung
// (Speichern/Öffnen-Menü, zuletzt-geöffnet-Liste, Auto-Save) ist NICHT Teil
// dieses Moduls — folgt als separates Item.
import type { Project } from "../model/types";
/** Name der IndexedDB-Datenbank. */
const DB_NAME = "dossier";
/** Name des ObjectStores (keyPath „name"). */
const STORE_NAME = "projects";
/** DB-Schemaversion. */
const DB_VERSION = 1;
/** Ein gespeicherter Datensatz je Projekt. */
interface ProjectRecord {
name: string;
savedAt: number;
project: Project;
}
/** `true`, sobald die Fehlmeldung „IndexedDB nicht verfügbar" geloggt wurde (nur einmal). */
let warnedUnavailable = false;
/** Warnt einmalig auf der Konsole, dass IndexedDB fehlt (Tests/SSR/Privatmodus). */
function warnUnavailableOnce(): void {
if (warnedUnavailable) return;
warnedUnavailable = true;
try {
// eslint-disable-next-line no-console
console.warn(
"[projectStore] IndexedDB nicht verfügbar — Projekt-Persistenz deaktiviert.",
);
} catch {
// Konsole kann in exotischen Umgebungen fehlen — ignorieren.
}
}
/** Liefert `true`, wenn `indexedDB` in dieser Umgebung nutzbar ist. */
function isAvailable(): boolean {
try {
return typeof indexedDB !== "undefined";
} catch {
// Zugriff kann werfen (z. B. strikte SSR-Sandboxen).
return false;
}
}
/** Gecachtes Öffnen-Promise, damit `indexedDB.open` nur einmal läuft. */
let dbPromise: Promise<IDBDatabase> | null = null;
/** Öffnet (bzw. erstellt) die Datenbank und liefert die geöffnete Verbindung. */
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(DB_NAME, DB_VERSION);
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "name" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexedDB.open fehlgeschlagen"));
});
// Bei Fehlschlag den Cache zurücksetzen, damit ein späterer Aufruf erneut
// versuchen kann (statt dauerhaft am fehlgeschlagenen Promise zu hängen).
dbPromise.catch(() => {
dbPromise = null;
});
return dbPromise;
}
/**
* Speichert ein Projekt unter `name` (überschreibt einen vorhandenen
* Datensatz gleichen Namens). Ohne IndexedDB: no-op (wirft nicht).
*/
export async function saveProject(name: string, project: Project): Promise<void> {
if (!name) return;
if (!isAvailable()) {
warnUnavailableOnce();
return;
}
try {
const db = await openDb();
const record: ProjectRecord = { name, savedAt: Date.now(), project };
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen"));
tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen"));
});
} catch {
// Kontingent überschritten, Zugriff verweigert o. Ä. — still ignorieren
// (analog writeJson in visibilitySets.ts).
}
}
/**
* Lädt ein Projekt nach Namen. Liefert `null`, wenn es nicht existiert, die
* Umgebung IndexedDB nicht unterstützt oder der Zugriff fehlschlägt.
*/
export async function loadProject(name: string): Promise<Project | null> {
if (!name) return null;
if (!isAvailable()) {
warnUnavailableOnce();
return null;
}
try {
const db = await openDb();
const record = await new Promise<ProjectRecord | undefined>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const request = tx.objectStore(STORE_NAME).get(name);
request.onsuccess = () => resolve(request.result as ProjectRecord | undefined);
request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen"));
});
return record?.project ?? null;
} catch {
return null;
}
}
/**
* Listet alle gespeicherten Projekte (Name + Speicherzeitpunkt), alphabetisch
* nach Name sortiert. Ohne IndexedDB oder bei Fehler: leere Liste.
*/
export async function listProjects(): Promise<{ name: string; savedAt: number }[]> {
if (!isAvailable()) {
warnUnavailableOnce();
return [];
}
try {
const db = await openDb();
const records = await new Promise<ProjectRecord[]>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const request = tx.objectStore(STORE_NAME).getAll();
request.onsuccess = () => resolve((request.result as ProjectRecord[]) ?? []);
request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen"));
});
return records
.map((r) => ({ name: r.name, savedAt: r.savedAt }))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
/**
* Löscht ein gespeichertes Projekt. Ohne IndexedDB oder bei Fehler: no-op
* (wirft nicht).
*/
export async function deleteProject(name: string): Promise<void> {
if (!name) return;
if (!isAvailable()) {
warnUnavailableOnce();
return;
}
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(name);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen"));
tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen"));
});
} catch {
// still ignorieren — analog removeKey in visibilitySets.ts.
}
}
+180 -2
View File
@@ -21,20 +21,26 @@ import {
import type { import type {
AttributeSource, AttributeSource,
Ceiling, Ceiling,
Column,
ColumnProfile,
Drawing2D, Drawing2D,
Drawing2DGeom, Drawing2DGeom,
ExtrudedSolid,
LayerCategory, LayerCategory,
Opening, Opening,
Project, Project,
Room, Room,
SiaCategory, SiaCategory,
SliceTermination,
Stair, Stair,
StairShape, StairShape,
VerticalAnchor, VerticalAnchor,
Wall, Wall,
WallReferenceLine, WallReferenceLine,
} from "../model/types"; } from "../model/types";
import { evaluateRoom } from "../geometry/roomArea"; import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
import { import {
ceilingVerticalExtent, ceilingVerticalExtent,
nextFloorAbove, nextFloorAbove,
@@ -70,6 +76,8 @@ export interface FloorChoice {
*/ */
export interface WallInfo { export interface WallInfo {
referenceLine: WallReferenceLine; referenceLine: WallReferenceLine;
/** Terminierungs-Regel am Deckenanschluss (Default "both" = heutiges Verhalten). */
sliceTermination: SliceTermination;
wallTypeId: string; wallTypeId: string;
/** Aktuelle Gesamtdicke (Meter). */ /** Aktuelle Gesamtdicke (Meter). */
thickness: number; thickness: number;
@@ -91,6 +99,14 @@ export interface WallInfo {
/** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */ /** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */
zBottom: number; zBottom: number;
zTop: number; zTop: number;
/** Achslänge der Wand (Meter). */
length: number;
/** Bruttovolumen = Länge × Dicke × Höhe (m³, ohne Öffnungsabzug). */
grossVolume: number;
/** Summe der Öffnungsvolumina (Fenster/Türen) dieser Wand (m³). */
openingVolume: number;
/** Nettovolumen = Brutto Öffnungen (m³, ≥ 0). */
netVolume: number;
} }
/** /**
@@ -106,6 +122,8 @@ export interface CeilingInfo {
singleLayer: boolean; singleLayer: boolean;
/** Grundfläche der Decke (m²). */ /** Grundfläche der Decke (m²). */
area: number; area: number;
/** Volumen = Fläche × Dicke (m³). */
volume: number;
/** Verfügbare Deckentyp-Presets (dediziert, `project.ceilingTypes`). */ /** Verfügbare Deckentyp-Presets (dediziert, `project.ceilingTypes`). */
ceilingTypes: WallTypeChoice[]; ceilingTypes: WallTypeChoice[];
/** Geschoss, dem die Decke zugeordnet ist. */ /** Geschoss, dem die Decke zugeordnet ist. */
@@ -115,6 +133,8 @@ export interface CeilingInfo {
floors: FloorChoice[]; floors: FloorChoice[];
/** OK-Bindung (undefined = Geschoss-Oberkante). */ /** OK-Bindung (undefined = Geschoss-Oberkante). */
top?: VerticalAnchor; top?: VerticalAnchor;
/** UK-Bindung (undefined = OK Dicke). */
bottom?: VerticalAnchor;
/** Aufgelöste absolute UK/OK (Meter). */ /** Aufgelöste absolute UK/OK (Meter). */
zBottom: number; zBottom: number;
zTop: number; zTop: number;
@@ -146,6 +166,8 @@ export interface OpeningInfo {
doorType?: "normal" | "wandoeffnung"; doorType?: "normal" | "wandoeffnung";
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */ /** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
lintelLines?: "keine" | "innen" | "aussen" | "beide"; lintelLines?: "keine" | "innen" | "aussen" | "beide";
/** Zugewiesener Tür-/Fenstertyp (Bibliothek, `Opening.typeId`) oder undefined. */
typeId?: string;
/** Aufgelöste absolute UK/OK (Meter). */ /** Aufgelöste absolute UK/OK (Meter). */
zBottom: number; zBottom: number;
zTop: number; zTop: number;
@@ -171,6 +193,8 @@ export interface StairInfo {
up: boolean; up: boolean;
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */ /** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
referenz?: "links" | "mitte" | "rechts"; referenz?: "links" | "mitte" | "rechts";
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
typeId?: string;
/** Geschoss, dem die Treppe zugeordnet ist. */ /** Geschoss, dem die Treppe zugeordnet ist. */
floorId: string; floorId: string;
floorName: string; floorName: string;
@@ -198,6 +222,41 @@ export interface RoomInfo {
floorName: string; floorName: string;
} }
/**
* Extrusions-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl
* genau eines extrudierten Körpers, truck-Integration, gesetzt).
*/
export interface ExtrudedSolidInfo {
/** Extrusionshöhe in Metern (editierbar). */
height: number;
/** Verjüngung 0 (Prisma) … 1 (Spitze), editierbar. */
taper: number;
/** Grundfläche des Profils (m²). */
area: number;
/** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */
floorId: string;
floorName: string;
}
/**
* Stützen-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
* einer Stütze gesetzt). Anzeigefertige Werte für die Panel-Felder.
*/
export interface ColumnInfo {
/** Profil (Rechteck/Kreis) — bestimmt, welche Masse editierbar sind. */
profile: ColumnProfile;
/** Höhe (Meter, editierbar). */
height: number;
/** Drehung (Grad, editierbar) — intern in Radiant gespeichert. */
rotationDeg: number;
/** Geschoss, dem die Stütze zugeordnet ist. */
floorId: string;
floorName: string;
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
}
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */ /** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
export interface SelectionBBox { export interface SelectionBBox {
minX: number; minX: number;
@@ -212,7 +271,16 @@ export interface SelectionBBox {
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll. * gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
*/ */
export interface Selection { export interface Selection {
kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door"; kind:
| "wall"
| "ceiling"
| "opening"
| "stair"
| "room"
| "drawing2d"
| "door"
| "extrudedSolid"
| "column";
id: string; id: string;
/** Grafik-Kategorie (Ebene) des Elements. */ /** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string; categoryCode: string;
@@ -276,6 +344,10 @@ export interface Selection {
stair?: StairInfo; stair?: StairInfo;
/** Raum-Attribute (nur bei `kind === "room"`). */ /** Raum-Attribute (nur bei `kind === "room"`). */
room?: RoomInfo; room?: RoomInfo;
/** Extrusions-Attribute (nur bei `kind === "extrudedSolid"`). */
extrudedSolid?: ExtrudedSolidInfo;
/** Stützen-Attribute (nur bei `kind === "column"`). */
column?: ColumnInfo;
} }
const WALL_FALLBACK_MM = 0.18; const WALL_FALLBACK_MM = 0.18;
@@ -354,8 +426,18 @@ function wallSelection(project: Project, wall: Wall): Selection {
const floor = project.drawingLevels.find((z) => z.id === wall.floorId); const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
const { zBottom, zTop } = wallVerticalExtent(project, wall); const { zBottom, zTop } = wallVerticalExtent(project, wall);
const above = nextFloorAbove(project, wall); const above = nextFloorAbove(project, wall);
// Volumen aus Achslänge × Gesamtdicke × Höhe, abzüglich der Öffnungen (jede
// Öffnung durchbricht die volle Wanddicke: Breite × Höhe × Dicke).
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
const wallHeight = Math.max(0, zTop - zBottom);
const grossVolume = length * thickness * wallHeight;
const openingVolume = (project.openings ?? [])
.filter((o) => o.hostWallId === wall.id)
.reduce((sum, o) => sum + o.width * o.height * thickness, 0);
const netVolume = Math.max(0, grossVolume - openingVolume);
const wallInfo: WallInfo = { const wallInfo: WallInfo = {
referenceLine: wall.referenceLine ?? "center", referenceLine: wall.referenceLine ?? "center",
sliceTermination: wall.sliceTermination ?? "both",
wallTypeId: wall.wallTypeId, wallTypeId: wall.wallTypeId,
thickness, thickness,
singleLayer: wt.layers.length <= 1, singleLayer: wt.layers.length <= 1,
@@ -376,6 +458,10 @@ function wallSelection(project: Project, wall: Wall): Selection {
top: wall.top, top: wall.top,
zBottom, zBottom,
zTop, zTop,
length,
grossVolume,
openingVolume,
netVolume,
}; };
// bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt, // bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt,
// damit die Box der Achs-Geometrie folgt (genügt für die Paletten). // damit die Box der Achs-Geometrie folgt (genügt für die Paletten).
@@ -415,6 +501,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
thickness, thickness,
singleLayer: wt.layers.length <= 1, singleLayer: wt.layers.length <= 1,
area: ceilingArea(ceiling.outline), area: ceilingArea(ceiling.outline),
volume: ceilingArea(ceiling.outline) * thickness,
ceilingTypes: (project.ceilingTypes ?? []).map((t) => ({ ceilingTypes: (project.ceilingTypes ?? []).map((t) => ({
id: t.id, id: t.id,
name: t.name, name: t.name,
@@ -428,6 +515,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
.filter((z) => z.kind === "floor") .filter((z) => z.kind === "floor")
.map((z) => ({ id: z.id, name: z.name })), .map((z) => ({ id: z.id, name: z.name })),
top: ceiling.top, top: ceiling.top,
bottom: ceiling.bottom,
zBottom, zBottom,
zTop, zTop,
}; };
@@ -494,6 +582,7 @@ function openingSelection(project: Project, o: Opening): Selection {
wingCount: o.wingCount, wingCount: o.wingCount,
doorType: o.doorType, doorType: o.doorType,
lintelLines: o.lintelLines, lintelLines: o.lintelLines,
typeId: o.typeId,
zBottom: ext.zBottom, zBottom: ext.zBottom,
zTop: ext.zTop, zTop: ext.zTop,
}; };
@@ -530,6 +619,7 @@ function stairSelection(project: Project, s: Stair): Selection {
treadDepth: geo.treadDepth, treadDepth: geo.treadDepth,
up: s.up !== false, up: s.up !== false,
referenz: s.referenz, referenz: s.referenz,
typeId: s.typeId,
floorId: s.floorId, floorId: s.floorId,
floorName: floor?.name ?? s.floorId, floorName: floor?.name ?? s.floorId,
zBottom, zBottom,
@@ -589,6 +679,84 @@ function roomSelection(project: Project, r: Room): Selection {
}; };
} }
/** Auswahl-Farbe der Extrusions-Footprints (identisch zum Grundriss-Umriss/3D-Orange). */
const EXTRUSION_COLOR = "#d98c40";
/** Selektion für einen extrudierten Körper ableiten (truck-Integration). */
function extrudedSolidSelection(project: Project, s: ExtrudedSolid): Selection {
const floor = project.drawingLevels.find((z) => z.id === s.levelId);
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of s.points) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
const info: ExtrudedSolidInfo = {
height: s.height,
taper: s.taper ?? 0,
area: polygonArea(s.points),
floorId: s.levelId,
floorName: floor?.name ?? s.levelId,
};
return {
kind: "extrudedSolid",
id: s.id,
categoryCode: "",
color: EXTRUSION_COLOR,
weightMm: WALL_FALLBACK_MM,
fillHatchId: null,
fillColor: null,
closed: true,
bbox: { minX, minY, maxX, maxY },
extrudedSolid: info,
};
}
/** Umriss-/Auswahlfarbe der Stützen-Poché (neutrales Tragwerks-Grau). */
const COLUMN_COLOR = "#3a3f47";
/** Selektion für eine Stütze (Column) ableiten. */
function columnSelection(project: Project, col: Column): Selection {
const floor = project.drawingLevels.find((z) => z.id === col.floorId);
const category = findCategory(project.layers, col.categoryCode);
const pts = columnFootprint(col);
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
const { zBottom, zTop } = columnVerticalExtent(project, col);
const info: ColumnInfo = {
profile: col.profile,
height: col.height,
rotationDeg: ((col.rotation ?? 0) * 180) / Math.PI,
floorId: col.floorId,
floorName: floor?.name ?? col.floorId,
zBottom,
zTop,
};
return {
kind: "column",
id: col.id,
categoryCode: col.categoryCode,
color: col.color ?? category?.color ?? COLUMN_COLOR,
weightMm: WALL_FALLBACK_MM,
bbox: { minX, minY, maxX, maxY },
column: info,
};
}
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */ /** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
function drawingSelection(project: Project, d: Drawing2D): Selection { function drawingSelection(project: Project, d: Drawing2D): Selection {
const ls = d.lineStyleId const ls = d.lineStyleId
@@ -640,11 +808,21 @@ export function deriveSelection(
selectedOpeningId: string | null = null, selectedOpeningId: string | null = null,
selectedStairId: string | null = null, selectedStairId: string | null = null,
selectedRoomId: string | null = null, selectedRoomId: string | null = null,
selectedExtrudedSolidId: string | null = null,
selectedColumnId: string | null = null,
): Selection | null { ): Selection | null {
if (selectedDrawingId) { if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === selectedDrawingId); const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
return d ? drawingSelection(project, d) : null; return d ? drawingSelection(project, d) : null;
} }
if (selectedExtrudedSolidId) {
const s = (project.extrudedSolids ?? []).find((x) => x.id === selectedExtrudedSolidId);
return s ? extrudedSolidSelection(project, s) : null;
}
if (selectedColumnId) {
const c = (project.columns ?? []).find((x) => x.id === selectedColumnId);
return c ? columnSelection(project, c) : null;
}
if (selectedOpeningId) { if (selectedOpeningId) {
const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId); const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId);
if (o) return openingSelection(project, o); if (o) return openingSelection(project, o);
+12
View File
@@ -19,6 +19,10 @@ export interface SelectionSlice {
selectedStairIds: string[]; selectedStairIds: string[];
// Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen. // Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedRoomIds: string[]; selectedRoomIds: string[];
// Gewählte extrudierte Körper (truck-Integration) als MENGE von IDs — getrennt.
selectedExtrudedSolidIds: string[];
// Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedColumnIds: string[];
setSelectedWallIds: (ids: string[]) => void; setSelectedWallIds: (ids: string[]) => void;
setSelectedDrawingIds: (ids: string[]) => void; setSelectedDrawingIds: (ids: string[]) => void;
@@ -26,6 +30,8 @@ export interface SelectionSlice {
setSelectedOpeningIds: (ids: string[]) => void; setSelectedOpeningIds: (ids: string[]) => void;
setSelectedStairIds: (ids: string[]) => void; setSelectedStairIds: (ids: string[]) => void;
setSelectedRoomIds: (ids: string[]) => void; setSelectedRoomIds: (ids: string[]) => void;
setSelectedExtrudedSolidIds: (ids: string[]) => void;
setSelectedColumnIds: (ids: string[]) => void;
/** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */ /** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */
clearSelection: () => void; clearSelection: () => void;
} }
@@ -41,12 +47,16 @@ export function createSelectionSlice(
selectedOpeningIds: [], selectedOpeningIds: [],
selectedStairIds: [], selectedStairIds: [],
selectedRoomIds: [], selectedRoomIds: [],
selectedExtrudedSolidIds: [],
selectedColumnIds: [],
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }), setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }), setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }), setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }), setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }),
setSelectedStairIds: (ids) => set({ selectedStairIds: ids }), setSelectedStairIds: (ids) => set({ selectedStairIds: ids }),
setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }), setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }),
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
clearSelection: () => clearSelection: () =>
set({ set({
selectedWallIds: [], selectedWallIds: [],
@@ -55,6 +65,8 @@ export function createSelectionSlice(
selectedOpeningIds: [], selectedOpeningIds: [],
selectedStairIds: [], selectedStairIds: [],
selectedRoomIds: [], selectedRoomIds: [],
selectedExtrudedSolidIds: [],
selectedColumnIds: [],
}), }),
}; };
} }
+158
View File
@@ -0,0 +1,158 @@
// Pure Tests für den Ausschnitte-Baum (state/viewSnapshotFolders.ts):
// Aufbau, Reparent, Zyklus-Schutz, verwaiste Referenzen, Löschen ohne
// Datenverlust. Kein React/DOM.
import { describe, it, expect } from "vitest";
import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types";
import {
buildViewSnapshotTree,
createViewSnapshotFolder,
deleteFolder,
isDescendant,
moveFolder,
moveSnapshotToFolder,
} from "./viewSnapshotFolders";
/** Minimaler ViewSnapshot-Stub (nur id/name/folderId sind hier relevant). */
function snap(id: string, folderId?: string): ViewSnapshot {
return {
id,
name: id,
...(folderId ? { folderId } : {}),
viewType: "grundriss",
view3d: "top",
scaleDenominator: 100,
detail: "mittel",
activeLevelId: "L0",
layerVisibility: {},
drawingVisibility: {},
} as ViewSnapshot;
}
function folder(id: string, parentId?: string): ViewSnapshotFolder {
return createViewSnapshotFolder(id, id, parentId);
}
describe("createViewSnapshotFolder", () => {
it("lässt parentId weg, wenn nicht gesetzt (Wurzelebene)", () => {
const f = createViewSnapshotFolder("f1", "Ordner");
expect(f).toEqual({ id: "f1", name: "Ordner" });
expect("parentId" in f).toBe(false);
});
it("setzt parentId, wenn gegeben", () => {
expect(createViewSnapshotFolder("f2", "Sub", "f1").parentId).toBe("f1");
});
});
describe("buildViewSnapshotTree", () => {
it("verschachtelt Ordner + Ausschnitte", () => {
const folders = [folder("f1"), folder("f2", "f1")];
const snaps = [snap("s0"), snap("s1", "f1"), snap("s2", "f2")];
const tree = buildViewSnapshotTree(folders, snaps);
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s0"]);
expect(tree.rootFolders).toHaveLength(1);
const f1 = tree.rootFolders[0];
expect(f1.folder.id).toBe("f1");
expect(f1.snapshots.map((s) => s.id)).toEqual(["s1"]);
expect(f1.subfolders).toHaveLength(1);
expect(f1.subfolders[0].folder.id).toBe("f2");
expect(f1.subfolders[0].snapshots.map((s) => s.id)).toEqual(["s2"]);
});
it("hebt Ausschnitt mit unbekanntem folderId auf die Wurzelebene (verwaist)", () => {
const tree = buildViewSnapshotTree([folder("f1")], [snap("s1", "ghost")]);
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s1"]);
expect(tree.rootFolders[0].snapshots).toHaveLength(0);
});
it("hebt Ordner mit unbekanntem parentId auf die Wurzelebene (verwaist)", () => {
const tree = buildViewSnapshotTree([folder("f2", "ghost")], []);
expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["f2"]);
});
it("bewahrt die Eingabe-Reihenfolge je Ebene", () => {
const folders = [folder("b"), folder("a")];
const snaps = [snap("s2"), snap("s1")];
const tree = buildViewSnapshotTree(folders, snaps);
expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["b", "a"]);
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s2", "s1"]);
});
});
describe("isDescendant", () => {
const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")];
it("erkennt sich selbst", () => {
expect(isDescendant(folders, "f1", "f1")).toBe(true);
});
it("erkennt echten Nachfahren (transitiv)", () => {
expect(isDescendant(folders, "f3", "f1")).toBe(true);
});
it("verneint Nicht-Nachfahren", () => {
expect(isDescendant(folders, "f1", "f3")).toBe(false);
});
it("bricht bei kaputter parentId-Kette ab (kein Absturz)", () => {
expect(isDescendant([folder("x", "ghost")], "x", "f1")).toBe(false);
});
});
describe("moveSnapshotToFolder", () => {
it("setzt folderId", () => {
const out = moveSnapshotToFolder([snap("s1")], "s1", "f1");
expect(out[0].folderId).toBe("f1");
});
it("entfernt folderId bei null (Wurzelebene)", () => {
const out = moveSnapshotToFolder([snap("s1", "f1")], "s1", null);
expect("folderId" in out[0]).toBe(false);
});
it("no-op bei unbekannter Id", () => {
const input = [snap("s1", "f1")];
expect(moveSnapshotToFolder(input, "ghost", null)).toEqual(input);
});
});
describe("moveFolder", () => {
const folders = [folder("f1"), folder("f2", "f1"), folder("f3")];
it("hängt Ordner unter neuen Elternordner", () => {
const out = moveFolder(folders, "f3", "f1");
expect(out.find((f) => f.id === "f3")!.parentId).toBe("f1");
});
it("verschiebt auf Wurzelebene bei null", () => {
const out = moveFolder(folders, "f2", null);
expect("parentId" in out.find((f) => f.id === "f2")!).toBe(false);
});
it("verhindert Zyklus: Ordner in eigenen Nachfahren (No-op)", () => {
expect(moveFolder(folders, "f1", "f2")).toEqual(folders);
});
it("verhindert Ordner in sich selbst (No-op)", () => {
expect(moveFolder(folders, "f1", "f1")).toEqual(folders);
});
});
describe("deleteFolder", () => {
it("zieht Unterordner + Ausschnitte auf die Elternebene (kein Datenverlust)", () => {
// f1 > f2 > f3; s1 in f2. f2 löschen → f3 & s1 hängen an f1.
const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")];
const snaps = [snap("s1", "f2")];
const out = deleteFolder("f2", folders, snaps);
expect(out.folders.map((f) => f.id).sort()).toEqual(["f1", "f3"]);
expect(out.folders.find((f) => f.id === "f3")!.parentId).toBe("f1");
expect(out.snapshots[0].folderId).toBe("f1");
});
it("zieht auf die Wurzelebene, wenn der gelöschte Ordner an der Wurzel lag", () => {
const folders = [folder("f1"), folder("f2", "f1")];
const snaps = [snap("s1", "f1")];
const out = deleteFolder("f1", folders, snaps);
expect(out.folders).toHaveLength(1);
expect("parentId" in out.folders[0]).toBe(false); // f2 an Wurzel
expect("folderId" in out.snapshots[0]).toBe(false); // s1 an Wurzel
});
it("no-op bei unbekannter Ordner-Id", () => {
const folders = [folder("f1")];
const snaps = [snap("s1", "f1")];
const out = deleteFolder("ghost", folders, snaps);
expect(out.folders).toEqual(folders);
expect(out.snapshots).toEqual(snaps);
});
});
+183
View File
@@ -0,0 +1,183 @@
// Ausschnitte-Baum (Ordner + Ausschnitte) — reine, testbare Logik (DOSSIER A2).
//
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf ViewSnapshot-
// Ordnern und die Auflösung der flachen Listen zum verschachtelten Baum. Das
// UI-Wiring (App.tsx setProject, ViewSnapshotsPanel) baut hierauf auf.
//
// Spiegelbildlich zu `panels/layoutModel.ts` (Layouts-Baum), aber bewusst OHNE
// Import aus dort — Ausschnitte-spezifisch und entkoppelt gehalten.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types";
// ── Fabrik ───────────────────────────────────────────────────────────────────
/** Neuer, leerer Ausschnitte-Ordner (optional in `parentId`). */
export function createViewSnapshotFolder(
id: string,
name: string,
parentId?: string,
): ViewSnapshotFolder {
return { id, name, ...(parentId ? { parentId } : {}) };
}
// ── Baum-Aufbau ──────────────────────────────────────────────────────────────
/** Ein Ordner-Knoten: der Ordner, seine Unterordner und direkten Ausschnitte. */
export interface ViewSnapshotFolderNode {
folder: ViewSnapshotFolder;
subfolders: ViewSnapshotFolderNode[];
snapshots: ViewSnapshot[];
}
/** Der aufgebaute Baum: Wurzel-Ausschnitte (ohne Ordner) + Wurzel-Ordner. */
export interface ViewSnapshotTree {
/** Ausschnitte ohne (gültigen) `folderId` — auf Wurzelebene. */
rootSnapshots: ViewSnapshot[];
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
rootFolders: ViewSnapshotFolderNode[];
}
/**
* Baut aus der flachen Ordner-/Ausschnitt-Liste den verschachtelten Baum. Robust
* gegen verwaiste Referenzen: ein Ausschnitt mit unbekanntem `folderId` bzw. ein
* Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben
* Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten.
*/
export function buildViewSnapshotTree(
folders: ViewSnapshotFolder[],
snapshots: ViewSnapshot[],
): ViewSnapshotTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, ViewSnapshotFolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], snapshots: [] });
}
const rootFolders: ViewSnapshotFolderNode[] = [];
for (const f of folders) {
const node = nodes.get(f.id)!;
const parent =
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
if (parent) parent.subfolders.push(node);
else rootFolders.push(node);
}
const rootSnapshots: ViewSnapshot[] = [];
for (const s of snapshots) {
const node =
s.folderId && folderIds.has(s.folderId) ? nodes.get(s.folderId) : undefined;
if (node) node.snapshots.push(s);
else rootSnapshots.push(s);
}
return { rootSnapshots, rootFolders };
}
// ── Baum-Umsortieren (Drag & Drop) + Löschen ────────────────────────────────
/**
* Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den
* Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich
* selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum).
* Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab).
*/
export function isDescendant(
folders: ViewSnapshotFolder[],
candidateId: string,
rootId: string,
): boolean {
if (candidateId === rootId) return true;
const byId = new Map(folders.map((f) => [f.id, f]));
const seen = new Set<string>();
let cur = byId.get(candidateId);
while (cur && cur.parentId && !seen.has(cur.id)) {
if (cur.parentId === rootId) return true;
seen.add(cur.id);
cur = byId.get(cur.parentId);
}
return false;
}
/**
* Verschiebt einen Ausschnitt in einen anderen Ordner (immutabel). `folderId =
* null` → zurück auf die Wurzelebene. No-op, wenn die Ausschnitt-Id fehlt.
*/
export function moveSnapshotToFolder(
snapshots: ViewSnapshot[],
snapshotId: string,
folderId: string | null,
): ViewSnapshot[] {
return snapshots.map((s) => {
if (s.id !== snapshotId) return s;
if (folderId) return { ...s, folderId };
const next = { ...s };
delete next.folderId;
return next;
});
}
/**
* Verschiebt einen Ordner unter einen neuen Elternordner (immutabel).
* `parentId = null` Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in
* sich selbst oder einen seiner Nachfahren wandern (dann No-op Original zurück).
*/
export function moveFolder(
folders: ViewSnapshotFolder[],
folderId: string,
parentId: string | null,
): ViewSnapshotFolder[] {
if (parentId && isDescendant(folders, parentId, folderId)) return folders;
return folders.map((f) => {
if (f.id !== folderId) return f;
if (parentId) return { ...f, parentId };
return stripParentId(f);
});
}
/**
* Löscht einen Ordner (immutabel). Unterordner und Ausschnitte des Ordners
* werden auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen,
* statt mitgelöscht zu werden es gehen keine Ausschnitte verloren. Liefert die
* neuen `folders`/`snapshots`-Listen.
*/
export function deleteFolder(
folderId: string,
folders: ViewSnapshotFolder[],
snapshots: ViewSnapshot[],
): { folders: ViewSnapshotFolder[]; snapshots: ViewSnapshot[] } {
const target = folders.find((f) => f.id === folderId);
const grandParentId = target?.parentId;
const nextFolders = folders
.filter((f) => f.id !== folderId)
.map((f) =>
f.parentId === folderId
? grandParentId
? { ...f, parentId: grandParentId }
: stripParentId(f)
: f,
);
const nextSnapshots = snapshots.map((s) =>
s.folderId === folderId
? grandParentId
? { ...s, folderId: grandParentId }
: stripFolderId(s)
: s,
);
return { folders: nextFolders, snapshots: nextSnapshots };
}
/** Ordner auf die Wurzelebene setzen (parentId entfernen). */
function stripParentId(folder: ViewSnapshotFolder): ViewSnapshotFolder {
const next = { ...folder };
delete next.parentId;
return next;
}
/** Ausschnitt auf die Wurzelebene setzen (folderId entfernen). */
function stripFolderId(snap: ViewSnapshot): ViewSnapshot {
const next = { ...snap };
delete next.folderId;
return next;
}

Some files were not shown because too many files have changed in this diff Show More