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:
+86
@@ -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 1–4 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)
|
||||
|
||||
Ü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
@@ -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: ~4–6 Wochen. Mit Nutzer Scope/MVP klären vor Start.
|
||||
|
||||
## ⛔ Blocker (zuerst klären)
|
||||
|
||||
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
||||
|
||||
## 🔧 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: ~4–6 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.0–0.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:
|
||||
- [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 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
|
||||
- [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.
|
||||
- **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)
|
||||
|
||||
- [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] ~~**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).
|
||||
@@ -75,12 +95,53 @@
|
||||
- **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.
|
||||
- [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`).
|
||||
- [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 A0–A6+B0–B6 (`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,my−base)`) 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).
|
||||
- [x] ~~**Linienstile aufräumen**~~ — **erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line`→`thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive`→`thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
|
||||
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **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.
|
||||
- **Aufwand:** gross (2–4 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
|
||||
|
||||
- [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)
|
||||
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). ✅ **Erster Durchstich erledigt** (`0ca3b1d`, Schachbrett-Textur auf Wänden, group-1-Bind-Group, `MESH_TEXTURED_WGSL`, planare Meter-UVs). **Verbleibende Lücken bis „richtig gut" (Grobschätzung ~18–29 PT):** Bild-Datei-Laden (`image`-Crate, PNG/JPG→GPU) ~1–2 · Material→Textur-Zuordnung (Wandtyp/Layer→Material→Textur-Set, Cache/Atlas oder Bind-Group je Material) ~3–5 · Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~5–8 · Mipmaps (Blit-Pass) + anisotropes Filtern ~1–2 · Web-Pfad (`web.rs`/WebGPU, Bild-Upload via ImageBitmap) ~3–4 · UI zum Zuweisen + Persistenz im Dokumentmodell ~5–8.
|
||||
- [x] ~~**Wand-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 (~15–25 PT):** Normal-/Roughness-/Metallic-Maps + Cook-Torrance-BRDF + Tangentenraum ~5–8 · Mipmaps (Blit-Pass) + anisotropes Filtern ~1–2 · Bild-Datei-Laden serverseitig statt Browser-Dekodierung ~1–2 · nativer Tauri-Textur-Push ~2–3 · UI/Persistenz-Feinschliff ~5–8. **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.
|
||||
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
||||
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||
- [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.
|
||||
- [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)
|
||||
|
||||
@@ -111,6 +178,20 @@
|
||||
|
||||
_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 `zTop−thickness` 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 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.
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# DOSSIER Standalone
|
||||
|
||||
Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus
|
||||
semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
|
||||
ohne Revit.
|
||||
Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
|
||||
aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
|
||||
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
|
||||
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
|
||||
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
|
||||
React/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz.
|
||||
Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine
|
||||
(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview
|
||||
kleinster gemeinsamer Nenner.
|
||||
React-App mit eigener Rendering-Engine (Rust/WASM/WebGPU) statt als Rhino-Aufsatz.
|
||||
Als Desktop-App (Tauri) läuft sie im eigenen Fenster mit voller Engine-Leistung;
|
||||
browserseitig ist derselbe Kern zugänglich, die App ist die vollständige Fassung.
|
||||
|
||||
## 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
|
||||
- [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,
|
||||
Swisstopo-Geodaten) sind als Differenzierer eingeplant.
|
||||
Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten)
|
||||
sind als Differenzierer eingeplant.
|
||||
|
||||
Generated
+20
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"delaunator": "^5.1.0",
|
||||
"dxf-parser": "^1.1.2",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -1842,6 +1844,24 @@
|
||||
"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": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
|
||||
+13
-1
@@ -3,6 +3,8 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Karim Gabriele Varano",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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: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: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": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"delaunator": "^5.1.0",
|
||||
"dxf-parser": "^1.1.2",
|
||||
"jspdf": "^4.2.1",
|
||||
@@ -48,5 +53,12 @@
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^4.1.9",
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+173
-3
@@ -384,6 +384,7 @@ dependencies = [
|
||||
name = "cad-tauri"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fs4",
|
||||
"geometry",
|
||||
"pollster",
|
||||
"render2d",
|
||||
@@ -392,6 +393,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"wgpu",
|
||||
"winit",
|
||||
]
|
||||
@@ -982,7 +985,7 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading 0.7.4",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1304,6 +1307,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@@ -1690,7 +1703,7 @@ dependencies = [
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2892,6 +2905,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -3705,6 +3719,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
@@ -4538,6 +4576,64 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -5946,6 +6042,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -5979,13 +6084,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 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_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 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]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -6025,6 +6147,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -6037,6 +6165,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -6049,12 +6183,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -6067,6 +6213,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -6079,6 +6231,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -6091,6 +6249,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -6103,6 +6267,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
|
||||
@@ -8,7 +8,7 @@ members = ["."]
|
||||
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
|
||||
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
|
||||
# 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]
|
||||
name = "cad-tauri"
|
||||
@@ -37,9 +37,15 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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") -----------------------------------
|
||||
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2470,6 +2470,223 @@ pub fn room_from_point_inside_faces(
|
||||
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) ----------------------------
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
// --- 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
}],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
|
||||
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
|
||||
@@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
@@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
||||
z_bottom: -0.2,
|
||||
z_top: 0.0,
|
||||
color: [0.86, 0.86, 0.88],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}];
|
||||
(walls, slabs)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
// Raum 6 x 4 m.
|
||||
vec![
|
||||
|
||||
+278
-27
@@ -15,12 +15,17 @@ use wgpu::util::DeviceExt;
|
||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||
use crate::math::{view_projection, Mat4};
|
||||
use crate::mesh::{build_scene_mesh, build_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_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::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/
|
||||
@@ -142,6 +147,10 @@ struct MeshBuffers {
|
||||
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
|
||||
struct TexturedMeshBuffers {
|
||||
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,
|
||||
index_count: u32,
|
||||
}
|
||||
@@ -225,21 +234,39 @@ pub struct Renderer {
|
||||
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
||||
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
||||
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,
|
||||
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
|
||||
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
|
||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||
/// Shader/Layout unterscheiden sich.
|
||||
textured_pipeline: wgpu::RenderPipeline,
|
||||
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
|
||||
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
|
||||
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
|
||||
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
|
||||
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
|
||||
texture_bind_group: wgpu::BindGroup,
|
||||
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
|
||||
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
|
||||
/// Queue bekommt.
|
||||
/// Das Textur-Array selbst. Start: 1 Ebene (Schachbrett). `set_material_textures`
|
||||
/// ersetzt es durch ein Array `[Schachbrett, Material0, Material1, …]`.
|
||||
texture: wgpu::Texture,
|
||||
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
|
||||
/// ersten `render` einmalig gesetzt (lazy Upload).
|
||||
/// Bind-Group-Layout (group 1), festgehalten fuer den Neubau der Bind-Group
|
||||
/// 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,
|
||||
bind_group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
@@ -264,6 +291,10 @@ pub struct Renderer {
|
||||
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
||||
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
||||
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 —
|
||||
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
|
||||
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
|
||||
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
|
||||
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
|
||||
@@ -478,10 +555,14 @@ impl Renderer {
|
||||
label: Some("cap.wgsl"),
|
||||
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 {
|
||||
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
|
||||
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 {
|
||||
label: Some("cap.pipeline"),
|
||||
@@ -547,7 +628,7 @@ impl Renderer {
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
@@ -579,6 +660,14 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
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,
|
||||
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
|
||||
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
|
||||
@@ -590,7 +679,7 @@ impl Renderer {
|
||||
vertex: wgpu::VertexState {
|
||||
module: &textured_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[textured_vertex_layout],
|
||||
buffers: &[textured_vertex_layout, textured_layer_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
@@ -632,8 +721,10 @@ impl Renderer {
|
||||
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
|
||||
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
|
||||
// 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 {
|
||||
label: Some("checker.texture"),
|
||||
label: Some("materials.texture_array"),
|
||||
size: wgpu::Extent3d {
|
||||
width: TEXTURE_SIZE,
|
||||
height: TEXTURE_SIZE,
|
||||
@@ -646,10 +737,13 @@ impl Renderer {
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
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).
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("checker.sampler"),
|
||||
let texture_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("materials.sampler"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
address_mode_w: wgpu::AddressMode::Repeat,
|
||||
@@ -659,7 +753,7 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("texture.bind"),
|
||||
label: Some("materials.bind"),
|
||||
layout: &texture_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
@@ -668,7 +762,7 @@ impl Renderer {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
resource: wgpu::BindingResource::Sampler(&texture_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -694,9 +788,13 @@ impl Renderer {
|
||||
grid_pipeline,
|
||||
highlight_pipeline,
|
||||
cap_pipeline,
|
||||
cut_line_pipeline,
|
||||
textured_pipeline,
|
||||
texture_bind_group,
|
||||
texture,
|
||||
texture_bind_group_layout,
|
||||
texture_sampler,
|
||||
material_layer_count: 1,
|
||||
texture_uploaded: false,
|
||||
bind_group,
|
||||
uniform,
|
||||
@@ -706,6 +804,7 @@ impl Renderer {
|
||||
grid: None,
|
||||
highlight: None,
|
||||
caps: None,
|
||||
cut_lines: None,
|
||||
grid_visible: false,
|
||||
style: RenderStyle::Shaded,
|
||||
depth: None,
|
||||
@@ -724,7 +823,7 @@ impl Renderer {
|
||||
|
||||
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
|
||||
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
|
||||
@@ -736,13 +835,17 @@ impl Renderer {
|
||||
slabs: &[SlabInput],
|
||||
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
|
||||
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
||||
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
|
||||
if mesh.indices.is_empty() {
|
||||
self.mesh = None;
|
||||
self.textured_mesh = None;
|
||||
@@ -782,11 +885,10 @@ impl Renderer {
|
||||
index_count: mesh.indices.len() as u32,
|
||||
});
|
||||
|
||||
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
|
||||
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
|
||||
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
|
||||
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
|
||||
let tex_mesh = textured_from_mesh(&mesh);
|
||||
// Texturiertes Pendant (planare Welt-UV je Vertex + Material-Ebenen-Puffer,
|
||||
// siehe `build_scene_mesh_textured`) in zwei Vertex-Puffer hochladen. Die
|
||||
// Geometrie bleibt bitgleich zum Alt-Pfad; der Stil `Textured` hat ohne
|
||||
// Re-Meshing seine Puffer bereit.
|
||||
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.textured.vbo"),
|
||||
contents: bytemuck::cast_slice(&tex_mesh.verts),
|
||||
@@ -797,8 +899,22 @@ impl Renderer {
|
||||
contents: bytemuck::cast_slice(&tex_mesh.indices),
|
||||
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 {
|
||||
vbo: tvbo,
|
||||
layer_vbo: tlvbo,
|
||||
ibo: tibo,
|
||||
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)
|
||||
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
|
||||
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
|
||||
@@ -936,11 +1073,110 @@ impl Renderer {
|
||||
let plane = SectionPlane::new(point, normal);
|
||||
let caps = build_cut_caps(&plane, walls, slabs);
|
||||
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 {
|
||||
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.
|
||||
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);
|
||||
@@ -1019,6 +1255,8 @@ impl Renderer {
|
||||
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
|
||||
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
|
||||
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));
|
||||
|
||||
// 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(1, &self.texture_bind_group, &[]);
|
||||
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.draw_indexed(0..t.index_count, 0, 0..1);
|
||||
}
|
||||
@@ -1128,6 +1367,18 @@ impl Renderer {
|
||||
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).
|
||||
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
|
||||
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod math;
|
||||
pub mod mesh;
|
||||
pub(crate) mod openings;
|
||||
pub mod section;
|
||||
pub mod section_boolean;
|
||||
pub mod section_fill;
|
||||
pub mod shaders;
|
||||
pub mod types;
|
||||
@@ -72,6 +73,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +175,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
|
||||
@@ -250,6 +257,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
extrude_wall(&mut mesh, &w);
|
||||
@@ -272,6 +282,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mesh = build_walls_mesh(&[w]);
|
||||
assert_eq!(mesh.vertex_count(), 0);
|
||||
@@ -486,6 +499,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let b = WallInput {
|
||||
start: [0.0, 0.0],
|
||||
@@ -497,6 +513,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mesh = build_walls_mesh(&[a, b]);
|
||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
|
||||
@@ -557,6 +576,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0]),
|
||||
@@ -586,6 +608,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let walls = vec![
|
||||
mk([0.0, 0.0], [3.0, 0.0], 0.0),
|
||||
@@ -675,6 +700,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: layers(),
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.end = [0.0, 3.0];
|
||||
@@ -705,6 +733,8 @@ mod tests {
|
||||
z_bottom,
|
||||
z_top,
|
||||
color: [0.86, 0.86, 0.88],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,6 +791,8 @@ mod tests {
|
||||
z_bottom: 0.0,
|
||||
z_top: 0.3,
|
||||
color: [0.8, 0.8, 0.8],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
let mut mesh = Mesh::default();
|
||||
extrude_slab(&mut mesh, &slab);
|
||||
@@ -789,7 +821,7 @@ mod tests {
|
||||
fn entarteter_slab_erzeugt_nichts() {
|
||||
let mut mesh = Mesh::default();
|
||||
// <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);
|
||||
// Nullhoehe.
|
||||
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");
|
||||
}
|
||||
|
||||
#[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
|
||||
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
|
||||
/// sonst nicht mitgebaut wird (Muster: render2d).
|
||||
|
||||
@@ -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`
|
||||
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
|
||||
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
|
||||
/// ist perspektivisch.
|
||||
/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
|
||||
/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
|
||||
/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
|
||||
/// 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 {
|
||||
let mut cam = Camera {
|
||||
target,
|
||||
..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 {
|
||||
// Blick entlang -Z (von vorne), orthografisch.
|
||||
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.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
|
||||
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
|
||||
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.ortho_half_height = dist * 0.5;
|
||||
}
|
||||
// Blick entlang -X (von der Seite), orthografisch.
|
||||
// Blick entlang -X (von der rechten Seite), orthografisch.
|
||||
CameraPreset::Side => {
|
||||
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 (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
|
||||
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
|
||||
CameraPreset::Iso => {
|
||||
cam.projection = Projection::Orthographic;
|
||||
let d = dist / 3.0_f32.sqrt();
|
||||
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
|
||||
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 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];
|
||||
// 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;
|
||||
}
|
||||
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
|
||||
|
||||
@@ -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
|
||||
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
|
||||
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`
|
||||
@@ -980,9 +983,62 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
|
||||
TexturedMesh {
|
||||
verts,
|
||||
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
|
||||
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
||||
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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).
|
||||
const EPS: f32 = 1e-4;
|
||||
@@ -268,8 +268,19 @@ pub struct ComponentRef {
|
||||
pub struct CutPolygon {
|
||||
pub component: ComponentRef,
|
||||
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).
|
||||
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.
|
||||
@@ -314,9 +325,16 @@ struct Prism {
|
||||
z0: f32,
|
||||
z1: f32,
|
||||
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
|
||||
/// Decken (Slabs kennen keine Oeffnungen).
|
||||
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-
|
||||
@@ -399,12 +417,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
|
||||
z0: wall.base_elevation,
|
||||
z1: wall.base_elevation + wall.height,
|
||||
color: wall.color,
|
||||
hatch: wall.hatch,
|
||||
wall_axis: Some(WallAxis {
|
||||
start: wall.start,
|
||||
dir,
|
||||
length,
|
||||
openings: wall.openings.clone(),
|
||||
}),
|
||||
cut: wall.cut.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -425,7 +445,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
|
||||
z0,
|
||||
z1,
|
||||
color: slab.color,
|
||||
hatch: slab.hatch,
|
||||
wall_axis: None,
|
||||
cut: slab.cut.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -974,7 +996,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
|
||||
index: prism.index,
|
||||
},
|
||||
color: prism.color,
|
||||
hatch: prism.hatch,
|
||||
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
|
||||
cut: prism.cut.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1017,6 +1041,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
WallInput {
|
||||
start: [0.0, 3.0],
|
||||
@@ -1028,6 +1055,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
},
|
||||
];
|
||||
let slabs = vec![SlabInput {
|
||||
@@ -1035,6 +1065,8 @@ mod tests {
|
||||
z_bottom: -0.2,
|
||||
z_top: 0.0,
|
||||
color: [0.86, 0.86, 0.88],
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}];
|
||||
(walls, slabs)
|
||||
}
|
||||
@@ -1285,6 +1317,9 @@ mod tests {
|
||||
}],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1371,6 +1406,9 @@ mod tests {
|
||||
openings: vec![],
|
||||
layers: None,
|
||||
holes: vec![],
|
||||
material_index: None,
|
||||
hatch: None,
|
||||
cut: None,
|
||||
};
|
||||
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
|
||||
let plane = SectionPlane::looking_plus_y(-2.0);
|
||||
|
||||
@@ -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 0–4 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -21,19 +21,68 @@
|
||||
//!
|
||||
//! ## Ausgabeformat
|
||||
//!
|
||||
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
|
||||
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
|
||||
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
|
||||
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
|
||||
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
|
||||
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
|
||||
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
|
||||
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
|
||||
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
|
||||
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
|
||||
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
|
||||
//! 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};
|
||||
|
||||
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
|
||||
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
|
||||
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
|
||||
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
|
||||
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
|
||||
/// `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
|
||||
/// 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
|
||||
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
|
||||
/// 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(
|
||||
plane: &SectionPlane,
|
||||
walls: &[WallInput],
|
||||
slabs: &[SlabInput],
|
||||
) -> Vec<f32> {
|
||||
let out = cut_section(plane, walls, slabs);
|
||||
let polys = cut_cap_polys(plane, walls, slabs);
|
||||
let point = plane.point;
|
||||
let u_axis = plane.u_axis();
|
||||
// (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();
|
||||
for poly in &out.cut_polygons {
|
||||
for poly in &polys {
|
||||
let pts = &poly.pts;
|
||||
if pts.len() < 3 {
|
||||
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 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).
|
||||
for i in 1..pts.len() - 1 {
|
||||
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,15 +144,20 @@ struct Globals {
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||
|
||||
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
|
||||
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
|
||||
@group(1) @binding(0) var tex : texture_2d<f32>;
|
||||
// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
|
||||
// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
|
||||
// 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;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) normal : vec3<f32>,
|
||||
@location(2) uv : vec2<f32>,
|
||||
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
|
||||
@location(3) layer : f32,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@@ -160,6 +165,7 @@ struct VsOut {
|
||||
@location(0) world_normal : vec3<f32>,
|
||||
@location(1) uv : vec2<f32>,
|
||||
@location(2) world_pos : vec3<f32>,
|
||||
@location(3) @interpolate(flat) layer : f32,
|
||||
};
|
||||
|
||||
@vertex
|
||||
@@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut {
|
||||
out.world_normal = in.normal;
|
||||
out.uv = in.uv;
|
||||
out.world_pos = in.position;
|
||||
out.layer = in.layer;
|
||||
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
|
||||
* vec3<f32>(0.24, 0.27, 0.30);
|
||||
|
||||
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
|
||||
let albedo = textureSample(tex, samp, in.uv).rgb;
|
||||
// Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
|
||||
// `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);
|
||||
|
||||
@@ -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).
|
||||
/// 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
|
||||
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale
|
||||
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern —
|
||||
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention.
|
||||
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
|
||||
/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
|
||||
/// 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#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
@@ -281,11 +298,18 @@ struct Globals {
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<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 {
|
||||
@builtin(position) clip_pos : vec4<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
|
||||
@@ -293,27 +317,85 @@ fn vs_main(in : VsIn) -> VsOut {
|
||||
var out : VsOut;
|
||||
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||
out.uv = in.uv;
|
||||
out.hatch = in.hatch;
|
||||
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
|
||||
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 paper = vec3<f32>(0.94, 0.94, 0.94);
|
||||
|
||||
let s = (in.uv.x + in.uv.y) / spacing;
|
||||
let frac = fract(s);
|
||||
let dist = min(frac, 1.0 - frac); // 0 auf der Linie
|
||||
let half_s = half / spacing;
|
||||
let aa = max(fwidth(s), 1e-5);
|
||||
let line = 1.0 - smoothstep(half_s, half_s + aa, dist);
|
||||
let color = mix(paper, ink, line);
|
||||
let pattern = i32(round(in.hatch.x));
|
||||
let angle = in.hatch.y;
|
||||
let scale = max(in.hatch.z, 0.05);
|
||||
|
||||
// none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
|
||||
if (pattern == 0) {
|
||||
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);
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -20,6 +20,79 @@ pub type Point2 = [f32; 2];
|
||||
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
|
||||
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,
|
||||
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -162,6 +259,13 @@ pub struct SlabInput {
|
||||
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
|
||||
#[serde(default = "default_slab_color")]
|
||||
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 {
|
||||
@@ -179,6 +283,9 @@ pub enum MeshKind {
|
||||
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
|
||||
#[default]
|
||||
Imported,
|
||||
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
|
||||
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
|
||||
Extrusion,
|
||||
}
|
||||
|
||||
impl MeshKind {
|
||||
@@ -191,6 +298,8 @@ impl MeshKind {
|
||||
MeshKind::Terrain => [0.663, 0.690, 0.635],
|
||||
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
|
||||
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
|
||||
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
|
||||
/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
|
||||
/// 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)]
|
||||
pub enum CameraPreset {
|
||||
Front,
|
||||
Back,
|
||||
Top,
|
||||
Side,
|
||||
Left,
|
||||
Iso,
|
||||
IsoFrontLeft,
|
||||
IsoBackRight,
|
||||
IsoBackLeft,
|
||||
Persp,
|
||||
}
|
||||
|
||||
@@ -345,6 +464,14 @@ pub struct TexturedMesh {
|
||||
pub verts: Vec<f32>,
|
||||
/// Dreiecks-Indizes (0-basiert auf die Vertices).
|
||||
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.
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
|
||||
// (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.
|
||||
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete
|
||||
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/
|
||||
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte
|
||||
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung
|
||||
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die
|
||||
// weitere Navigation frei ueber `set_camera` (kein Lock).
|
||||
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
|
||||
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
|
||||
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
|
||||
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
|
||||
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
|
||||
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
|
||||
// `set_camera` (kein Lock).
|
||||
//
|
||||
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
|
||||
// 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
|
||||
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf
|
||||
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend
|
||||
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch,
|
||||
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
|
||||
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
|
||||
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
|
||||
/// 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
|
||||
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
|
||||
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
|
||||
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
|
||||
let preset = match preset {
|
||||
"front" => CameraPreset::Front,
|
||||
"back" => CameraPreset::Back,
|
||||
"top" => CameraPreset::Top,
|
||||
"side" => CameraPreset::Side,
|
||||
"left" => CameraPreset::Left,
|
||||
"iso" => CameraPreset::Iso,
|
||||
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
|
||||
"isoBackRight" => CameraPreset::IsoBackRight,
|
||||
"isoBackLeft" => CameraPreset::IsoBackLeft,
|
||||
"perspective" => CameraPreset::Persp,
|
||||
other => {
|
||||
return Err(JsValue::from_str(&format!(
|
||||
@@ -377,8 +384,8 @@ impl WebModelRenderer {
|
||||
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
|
||||
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
|
||||
/// (identisch zum bisherigen `set_render_mode_white(true)`).
|
||||
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"`
|
||||
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht.
|
||||
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
|
||||
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
|
||||
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
|
||||
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
|
||||
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
|
||||
@@ -431,6 +438,20 @@ impl WebModelRenderer {
|
||||
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).
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
let (w, h) = (width.max(1), height.max(1));
|
||||
|
||||
+43
-3
@@ -6,6 +6,32 @@
|
||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||
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.
|
||||
#[tauri::command]
|
||||
async fn compute_joins(
|
||||
@@ -40,7 +66,15 @@ fn push_native_walls(walls: serde_json::Value) {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
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
|
||||
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
|
||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||
@@ -52,10 +86,16 @@ pub fn run() {
|
||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||
compute_joins,
|
||||
push_native_scene,
|
||||
push_native_walls
|
||||
push_native_walls,
|
||||
acquire_project_lock,
|
||||
release_project_lock
|
||||
]);
|
||||
#[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
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -344,6 +344,12 @@ fn demo_walls() -> Vec<WallInput> {
|
||||
color: grey,
|
||||
openings: vec![],
|
||||
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![
|
||||
mk([0.0, 0.0], [6.0, 0.0]),
|
||||
|
||||
Generated
+1488
File diff suppressed because it is too large
Load Diff
@@ -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.16–0.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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
};
|
||||
@@ -42,10 +42,20 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
|
||||
}
|
||||
|
||||
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 {
|
||||
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
||||
return {
|
||||
wallIds: sel.wallIds,
|
||||
drawingId: sel.drawingId,
|
||||
extrudedSolidId: sel.extrudedSolidId,
|
||||
columnId: sel.columnId,
|
||||
};
|
||||
}
|
||||
|
||||
interface CopyBase extends CommandState {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -32,12 +32,22 @@ const EPS = 1e-6;
|
||||
|
||||
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
|
||||
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). */
|
||||
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).
|
||||
|
||||
@@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
|
||||
|
||||
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
|
||||
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). */
|
||||
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).
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
|
||||
|
||||
@@ -10,18 +10,23 @@ import { wallCommand } from "./cmds/wall";
|
||||
import { ceilingCommand } from "./cmds/ceiling";
|
||||
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
|
||||
import { stairCommand } from "./cmds/stair";
|
||||
import { columnCommand } from "./cmds/column";
|
||||
import { roomCommand } from "./cmds/room";
|
||||
import { rectCommand } from "./cmds/rect";
|
||||
import { circleCommand } from "./cmds/circle";
|
||||
import { arcCommand } from "./cmds/arc";
|
||||
import { textCommand } from "./cmds/text";
|
||||
import { textboxCommand } from "./cmds/textbox";
|
||||
import { moveCommand } from "./cmds/move";
|
||||
import { mirrorCommand } from "./cmds/mirror";
|
||||
import { joinCommand } from "./cmds/join";
|
||||
import { copyCommand } from "./cmds/copy";
|
||||
import { offsetCommand } from "./cmds/offset";
|
||||
import { extrudeCommand } from "./cmds/extrude";
|
||||
import { trimCommand } from "./cmds/trim";
|
||||
import { importCommand } from "./cmds/import";
|
||||
import { terrainCommand } from "./cmds/terrain";
|
||||
import { measureCommand } from "./cmds/measure";
|
||||
|
||||
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||
export const COMMANDS: Record<string, Command> = {
|
||||
@@ -31,20 +36,25 @@ export const COMMANDS: Record<string, Command> = {
|
||||
fenster: fensterCommand,
|
||||
tuer: tuerCommand,
|
||||
stair: stairCommand,
|
||||
column: columnCommand,
|
||||
room: roomCommand,
|
||||
line: lineCommand,
|
||||
polyline: polylineCommand,
|
||||
rect: rectCommand,
|
||||
circle: circleCommand,
|
||||
arc: arcCommand,
|
||||
text: textCommand,
|
||||
textbox: textboxCommand,
|
||||
move: moveCommand,
|
||||
mirror: mirrorCommand,
|
||||
join: joinCommand,
|
||||
copy: copyCommand,
|
||||
offset: offsetCommand,
|
||||
extrude: extrudeCommand,
|
||||
trim: trimCommand,
|
||||
import: importCommand,
|
||||
terrain: terrainCommand,
|
||||
measure: measureCommand,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -63,6 +73,8 @@ export const ALIASES: Record<string, string> = {
|
||||
oe: "opening",
|
||||
treppe: "stair",
|
||||
tp: "stair",
|
||||
stuetze: "column",
|
||||
stütze: "column",
|
||||
raum: "room",
|
||||
rm: "room",
|
||||
l: "line",
|
||||
@@ -71,6 +83,9 @@ export const ALIASES: Record<string, string> = {
|
||||
c: "circle",
|
||||
a: "arc",
|
||||
bogen: "arc",
|
||||
tx: "text",
|
||||
txt: "text",
|
||||
beschriftung: "text",
|
||||
m: "move",
|
||||
s: "mirror",
|
||||
spiegeln: "mirror",
|
||||
@@ -78,6 +93,7 @@ export const ALIASES: Record<string, string> = {
|
||||
verbinden: "join",
|
||||
cp: "copy",
|
||||
o: "offset",
|
||||
ex: "extrude",
|
||||
tr: "trim",
|
||||
imp: "import",
|
||||
ter: "terrain",
|
||||
|
||||
@@ -19,7 +19,7 @@ export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
|
||||
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 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
|
||||
@@ -67,6 +67,10 @@ export interface CommandSelection {
|
||||
* `drawingId` (falls gesetzt) als einziges Element.
|
||||
*/
|
||||
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). */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,6 +28,10 @@ const LAYER_HATCH = "HATCH";
|
||||
const LAYER_SYMBOLS = "SYMBOLS";
|
||||
const LAYER_CONTEXT = "CONTEXT";
|
||||
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).
|
||||
@@ -60,6 +64,8 @@ export function buildPlanDxf(
|
||||
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_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
|
||||
// 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);
|
||||
const codeByDrawing = new Map<string, string>();
|
||||
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 => {
|
||||
if (p.kind === "polygon") {
|
||||
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.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT;
|
||||
if (p.extrudedSolidId) return LAYER_EXTRUSION;
|
||||
return LAYER_DEFAULT;
|
||||
}
|
||||
if (p.kind === "line") {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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·4−4.
|
||||
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·5−4 = 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6).
|
||||
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
|
||||
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2).
|
||||
// • 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.
|
||||
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
|
||||
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
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 {
|
||||
const walls: Wall[] = [
|
||||
{
|
||||
@@ -48,6 +52,61 @@ function fixtureProject(): Project {
|
||||
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 {
|
||||
id: "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 }],
|
||||
walls,
|
||||
doors: [],
|
||||
openings: [],
|
||||
doors,
|
||||
openings,
|
||||
ceilings,
|
||||
stairs: [],
|
||||
stairs,
|
||||
extrudedSolids,
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
@@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
||||
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
|
||||
const lines = csv.split("\r\n");
|
||||
|
||||
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
|
||||
expect(lines).toHaveLength(4);
|
||||
// Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion).
|
||||
expect(lines).toHaveLength(8);
|
||||
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.
|
||||
@@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
||||
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.
|
||||
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", () => {
|
||||
const csv = exportScheduleCsv(fixtureProject());
|
||||
const lines = csv.split("\r\n");
|
||||
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
|
||||
expect(lines).toHaveLength(8);
|
||||
expect(lines[4]).toBe("");
|
||||
expect(lines[5]).toContain("Zusammenfassung");
|
||||
// Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16.
|
||||
expect(lines).toHaveLength(16);
|
||||
expect(lines[8]).toBe("");
|
||||
expect(lines[9]).toContain("Zusammenfassung");
|
||||
// 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.
|
||||
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", () => {
|
||||
@@ -114,6 +190,10 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
||||
const proj = fixtureProject();
|
||||
proj.walls = [];
|
||||
proj.ceilings = [];
|
||||
proj.doors = [];
|
||||
proj.openings = [];
|
||||
proj.stairs = [];
|
||||
proj.extrudedSolids = [];
|
||||
const csv = exportScheduleCsv(proj);
|
||||
expect(csv.split("\r\n")).toHaveLength(1);
|
||||
expect(scheduleRows(proj)).toHaveLength(0);
|
||||
|
||||
Binary file not shown.
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
stairGeometry,
|
||||
type StairGeometry as TSStairGeometry,
|
||||
} from "./stair";
|
||||
import type { Stair } from "../model/types";
|
||||
import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
|
||||
import {
|
||||
detectRooms,
|
||||
pointInPolygon as rbPointInPolygon,
|
||||
@@ -69,6 +69,19 @@ import {
|
||||
type WallSegment,
|
||||
type WallFace,
|
||||
} 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 };
|
||||
|
||||
@@ -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)", () => {
|
||||
it("parallele/kollineare Strecken → null (beide)", () => {
|
||||
const qs = [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ export function centroid(pts: Vec2[]): Vec2 {
|
||||
//
|
||||
// GF Geschossfläche
|
||||
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
|
||||
// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …)
|
||||
// └── NGF Nettogeschossfläche
|
||||
// ├── NF Nutzfläche
|
||||
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
|
||||
@@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 {
|
||||
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
|
||||
//
|
||||
// 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. */
|
||||
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
|
||||
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF";
|
||||
|
||||
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
|
||||
export type SiaAggregate = "NF" | "NGF" | "GF";
|
||||
@@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
|
||||
"VF",
|
||||
"FF",
|
||||
"KGF",
|
||||
"AGF",
|
||||
] as const;
|
||||
|
||||
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
|
||||
@@ -134,6 +138,7 @@ const SIA_LABELS: Record<SiaKey, string> = {
|
||||
VF: "Verkehrsfläche",
|
||||
FF: "Funktionsfläche",
|
||||
KGF: "Konstruktionsfläche",
|
||||
AGF: "Aussengeschossfläche",
|
||||
NF: "Nutzfläche",
|
||||
NGF: "Nettogeschossfläche",
|
||||
GF: "Geschossfläche",
|
||||
@@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string {
|
||||
/**
|
||||
* 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.
|
||||
* 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[]> = {
|
||||
HNF: ["NF", "NGF", "GF"],
|
||||
@@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
|
||||
VF: ["NGF", "GF"],
|
||||
FF: ["NGF", "GF"],
|
||||
KGF: ["GF"],
|
||||
AGF: ["GF"],
|
||||
};
|
||||
|
||||
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
|
||||
@@ -242,14 +249,14 @@ export interface Balance {
|
||||
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function balance(
|
||||
rooms: { category: SiaCategory; area: number }[],
|
||||
): Balance {
|
||||
const leaf: 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 };
|
||||
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, AGF: 0 };
|
||||
const agg: 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,
|
||||
FF: leaf.FF,
|
||||
KGF: leaf.KGF,
|
||||
AGF: leaf.AGF,
|
||||
NF: agg.NF,
|
||||
NGF: agg.NGF,
|
||||
GF: agg.GF,
|
||||
@@ -297,6 +305,7 @@ export function balance(
|
||||
mkLeaf("FF"),
|
||||
mkAgg("NGF"),
|
||||
mkLeaf("KGF"),
|
||||
mkLeaf("AGF"),
|
||||
mkAgg("GF"),
|
||||
];
|
||||
|
||||
|
||||
+365
-7
@@ -15,16 +15,28 @@ export const de = {
|
||||
"topbar.perspektive": "Perspektive",
|
||||
|
||||
"view3d.group": "Ansicht 3D",
|
||||
"view3d.front": "Front",
|
||||
"view3d.front": "Vorne",
|
||||
"view3d.front.hint": "Frontalansicht von vorne",
|
||||
"view3d.back": "Hinten",
|
||||
"view3d.back.hint": "Rückansicht von hinten",
|
||||
"view3d.top": "Oben",
|
||||
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
||||
"view3d.side": "Seite",
|
||||
"view3d.side.hint": "Seitenansicht (90° zur Front)",
|
||||
"view3d.side": "Rechts",
|
||||
"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.hint": "Freie Perspektive (3/4-Blick)",
|
||||
"view3d.iso": "Isometrie",
|
||||
"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.hint": "Kamera-Einstellungen (Sichtwinkel)",
|
||||
"view3d.fov": "Sichtwinkel",
|
||||
@@ -86,12 +98,12 @@ export const de = {
|
||||
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
|
||||
|
||||
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
||||
"layout.title": "Layout",
|
||||
"layout.placeholder": "Layout …",
|
||||
"layout.saveCurrent": "+ Aktuelles speichern …",
|
||||
"layout.title": "Arbeitsumgebung",
|
||||
"layout.placeholder": "Arbeitsumgebung …",
|
||||
"layout.saveCurrent": "+ Aktuelle speichern …",
|
||||
"layout.loadGroup": "Laden",
|
||||
"layout.deleteGroup": "Löschen",
|
||||
"layout.savePrompt": "Layout speichern als:",
|
||||
"layout.savePrompt": "Arbeitsumgebung speichern als:",
|
||||
|
||||
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
|
||||
"combo.layers": "Ebenen-Komb.",
|
||||
@@ -120,6 +132,10 @@ export const de = {
|
||||
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
||||
"tool.arc": "Bogen",
|
||||
"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.tab.2d": "2D",
|
||||
"ribbon.tab.3d": "3D",
|
||||
@@ -234,6 +250,7 @@ export const de = {
|
||||
"attr.fill": "Füllung",
|
||||
"attr.fillColor": "Füllfarbe",
|
||||
"attr.clearFill": "Füllfarbe entfernen",
|
||||
"attr.pickColor": "Farbe wählen",
|
||||
"attr.foreground": "Vordergrund",
|
||||
"attr.background": "Hintergrund",
|
||||
"attr.bySystem": "Nach System",
|
||||
@@ -266,12 +283,29 @@ export const de = {
|
||||
"objinfo.kind.opening": "Öffnung",
|
||||
"objinfo.kind.stair": "Treppe",
|
||||
"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) ──────────────────────────────────
|
||||
"objinfo.wall.section": "Wand",
|
||||
"objinfo.wall.refLine": "Referenzlinie",
|
||||
"objinfo.wall.refLine.left": "Außen (links)",
|
||||
"objinfo.wall.refLine.center": "Mitte",
|
||||
"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.single": "Solid",
|
||||
"objinfo.wall.multi": "Mehrschichtig",
|
||||
@@ -284,6 +318,11 @@ export const de = {
|
||||
"objinfo.wall.top": "Oberkante (OK)",
|
||||
"objinfo.wall.anchorFloor": "Gebunden",
|
||||
"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",
|
||||
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
||||
"objinfo.ceiling.section": "Decke",
|
||||
@@ -299,6 +338,9 @@ export const de = {
|
||||
"objinfo.ceiling.anchorCustom": "Eigene",
|
||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Öffnung",
|
||||
"objinfo.opening.type": "Typ",
|
||||
"objinfo.stair.type": "Typ",
|
||||
"objinfo.type.none": "(kein Typ)",
|
||||
"objinfo.opening.kind": "Art",
|
||||
"objinfo.opening.kind.window": "Fenster",
|
||||
"objinfo.opening.kind.door": "Tür",
|
||||
@@ -359,6 +401,15 @@ export const de = {
|
||||
"objinfo.room.perimeter": "Umfang",
|
||||
"objinfo.room.refFloor": "Referenzgeschoss",
|
||||
"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",
|
||||
"levels.addFloor": "+ Geschoss",
|
||||
"levels.addSection": "+ Schnitt",
|
||||
@@ -489,6 +540,9 @@ export const de = {
|
||||
"default.ceilingTypeSingle": "Decke {thickness} m",
|
||||
"default.wallTypeName": "Neuer Wandtyp",
|
||||
"default.ceilingTypeName": "Neuer Deckentyp",
|
||||
"default.doorTypeName": "Neuer Türtyp",
|
||||
"default.windowTypeName": "Neuer Fenstertyp",
|
||||
"default.stairTypeName": "Neuer Treppentyp",
|
||||
|
||||
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
|
||||
"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.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.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 ───────────────────────────────────────────────────
|
||||
"resources.title": "Ressourcen",
|
||||
@@ -507,7 +564,38 @@ export const de = {
|
||||
"resources.tab.lines": "Linien",
|
||||
"resources.tab.wallStyles": "Wandstile",
|
||||
"resources.tab.ceilingStyles": "Deckenstile",
|
||||
"resources.tab.doorStyles": "Türtypen",
|
||||
"resources.tab.windowStyles": "Fenstertypen",
|
||||
"resources.tab.stairStyles": "Treppentypen",
|
||||
"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":
|
||||
"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.wallStyles.placeholder": "Wandtyp",
|
||||
"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.component": "Bauteil",
|
||||
"resources.col.thickness": "Dicke (mm)",
|
||||
@@ -698,6 +855,9 @@ export const de = {
|
||||
"cmd.opening.dir": "Richtung",
|
||||
"cmd.opening.sillField": "Brüstung",
|
||||
"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.start": "Startpunkt der Treppe:",
|
||||
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
|
||||
@@ -732,11 +892,23 @@ export const de = {
|
||||
"cmd.arc.center": "Mittelpunkt:",
|
||||
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
||||
"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.
|
||||
"cmd.move.label": "Verschieben",
|
||||
"cmd.move.base": "Basispunkt:",
|
||||
"cmd.move.target": "Zielpunkt:",
|
||||
"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.first": "Erster Punkt der Spiegelachse:",
|
||||
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
|
||||
@@ -749,6 +921,10 @@ export const de = {
|
||||
"cmd.offset.label": "Versatz",
|
||||
"cmd.offset.pick": "Kurve wählen:",
|
||||
"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 (0–1, 1=Spitze):",
|
||||
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
||||
"cmd.trim.label": "Stutzen",
|
||||
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
||||
@@ -863,11 +1039,43 @@ export const de = {
|
||||
"file.exportPdf": "Als PDF exportieren…",
|
||||
"file.exportDxf": "Als DXF exportieren…",
|
||||
"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 ──────────────────────────────────────────────────
|
||||
"topbar.settings": "Einstellungen",
|
||||
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
|
||||
"settings.title": "Einstellungen",
|
||||
"settings.section.workspace": "Arbeitsumgebung",
|
||||
"settings.section.display": "Darstellung",
|
||||
"settings.marqueeColor": "Auswahlrahmen-Farbe",
|
||||
"settings.marqueeColor.hint":
|
||||
@@ -907,6 +1115,9 @@ export const de = {
|
||||
"ctxImport.src.osmRoads": "OSM-Strassen",
|
||||
"ctxImport.src.osmWater": "OSM-Wasser",
|
||||
"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.confirm": "Importieren",
|
||||
"ctxImport.importing": "Importiere…",
|
||||
@@ -950,6 +1161,9 @@ export const de = {
|
||||
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
|
||||
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
|
||||
"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",
|
||||
|
||||
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
|
||||
@@ -958,6 +1172,8 @@ export const de = {
|
||||
"rt.underline": "Unterstrichen",
|
||||
"rt.strike": "Durchgestrichen",
|
||||
"rt.strikethrough": "Durchgestrichen",
|
||||
"rt.super": "Hochgestellt",
|
||||
"rt.sub": "Tiefgestellt",
|
||||
"rt.fontSize": "Schriftgrösse",
|
||||
"rt.color": "Farbe",
|
||||
"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.",
|
||||
"viewport3d.grid.show": "Bodenraster einblenden",
|
||||
"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;
|
||||
|
||||
export type TranslationKey = keyof typeof de;
|
||||
|
||||
+362
-5
@@ -16,14 +16,26 @@ export const en: Record<TranslationKey, string> = {
|
||||
"view3d.group": "3D view",
|
||||
"view3d.front": "Front",
|
||||
"view3d.front.hint": "Front view (frontal)",
|
||||
"view3d.back": "Back",
|
||||
"view3d.back.hint": "Back view (rear)",
|
||||
"view3d.top": "Top",
|
||||
"view3d.top.hint": "Top view (straight down)",
|
||||
"view3d.side": "Side",
|
||||
"view3d.side.hint": "Side view (90° to front)",
|
||||
"view3d.side": "Right",
|
||||
"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.hint": "Free perspective (3/4 view)",
|
||||
"view3d.iso": "Isometric",
|
||||
"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.hint": "Camera settings (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",
|
||||
|
||||
// Layout menu.
|
||||
"layout.title": "Layout",
|
||||
"layout.placeholder": "Layout …",
|
||||
"layout.title": "Workspace",
|
||||
"layout.placeholder": "Workspace …",
|
||||
"layout.saveCurrent": "+ Save current …",
|
||||
"layout.loadGroup": "Load",
|
||||
"layout.deleteGroup": "Delete",
|
||||
"layout.savePrompt": "Save layout as:",
|
||||
"layout.savePrompt": "Save workspace as:",
|
||||
|
||||
// Visibility combinations (topbar).
|
||||
"combo.layers": "Layer comb.",
|
||||
@@ -119,6 +131,10 @@ export const en: Record<TranslationKey, string> = {
|
||||
"tool.circle.hint": "Circle: set center, then radius",
|
||||
"tool.arc": "Arc",
|
||||
"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.tab.2d": "2D",
|
||||
"ribbon.tab.3d": "3D",
|
||||
@@ -233,6 +249,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"attr.fill": "Fill",
|
||||
"attr.fillColor": "Fill color",
|
||||
"attr.clearFill": "Remove fill color",
|
||||
"attr.pickColor": "Choose color",
|
||||
"attr.foreground": "Foreground",
|
||||
"attr.background": "Background",
|
||||
"attr.bySystem": "By system",
|
||||
@@ -265,12 +282,29 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.kind.opening": "Opening",
|
||||
"objinfo.kind.stair": "Stair",
|
||||
"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) ──────────────────────────────────
|
||||
"objinfo.wall.section": "Wall",
|
||||
"objinfo.wall.refLine": "Reference line",
|
||||
"objinfo.wall.refLine.left": "Exterior (left)",
|
||||
"objinfo.wall.refLine.center": "Center",
|
||||
"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.single": "Solid",
|
||||
"objinfo.wall.multi": "Multi layer",
|
||||
@@ -283,6 +317,11 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.wall.top": "Top (OK)",
|
||||
"objinfo.wall.anchorFloor": "Bound",
|
||||
"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.floorAboveNone": "No floor above",
|
||||
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
||||
@@ -299,6 +338,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.ceiling.anchorCustom": "Custom",
|
||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Opening",
|
||||
"objinfo.opening.type": "Type",
|
||||
"objinfo.stair.type": "Type",
|
||||
"objinfo.type.none": "(no type)",
|
||||
"objinfo.opening.kind": "Type",
|
||||
"objinfo.opening.kind.window": "Window",
|
||||
"objinfo.opening.kind.door": "Door",
|
||||
@@ -356,6 +398,15 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.room.perimeter": "Perimeter",
|
||||
"objinfo.room.refFloor": "Reference floor",
|
||||
"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.addSection": "+ Section",
|
||||
"levels.addElevation": "+ Elevation",
|
||||
@@ -484,6 +535,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"default.ceilingTypeSingle": "Ceiling {thickness} m",
|
||||
"default.wallTypeName": "New wall type",
|
||||
"default.ceilingTypeName": "New ceiling type",
|
||||
"default.doorTypeName": "New door type",
|
||||
"default.windowTypeName": "New window type",
|
||||
"default.stairTypeName": "New stair type",
|
||||
|
||||
// Alerts.
|
||||
"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.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.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.
|
||||
"resources.title": "Resources",
|
||||
@@ -502,7 +559,38 @@ export const en: Record<TranslationKey, string> = {
|
||||
"resources.tab.lines": "Lines",
|
||||
"resources.tab.wallStyles": "Wall 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.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":
|
||||
"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.wallStyles.placeholder": "Wall 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.component": "Component",
|
||||
"resources.col.thickness": "Thickness (mm)",
|
||||
@@ -688,6 +845,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.opening.dir": "Direction",
|
||||
"cmd.opening.sillField": "Sill",
|
||||
"cmd.opening.swingField": "Angle",
|
||||
"cmd.column.label": "Column",
|
||||
"cmd.column.place": "Place column:",
|
||||
"cmd.column.profile": "Profile",
|
||||
"cmd.stair.label": "Stair",
|
||||
"cmd.stair.start": "Start point of the stair:",
|
||||
"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.start": "Start point (radius + start 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.base": "Base point:",
|
||||
"cmd.move.target": "Target point:",
|
||||
"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.first": "First 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.pick": "Select curve:",
|
||||
"cmd.offset.side": "Side or distance:",
|
||||
"cmd.extrude.label": "Extrude",
|
||||
"cmd.extrude.pick": "Select closed profile:",
|
||||
"cmd.extrude.height": "Height:",
|
||||
"cmd.extrude.taper": "Taper (0–1, 1=point):",
|
||||
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
||||
"cmd.trim.label": "Trim",
|
||||
"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.exportDxf": "Export as DXF…",
|
||||
"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.
|
||||
"topbar.settings": "Settings",
|
||||
"topbar.settings.hint": "Open settings (display colors, project)",
|
||||
"settings.title": "Settings",
|
||||
"settings.section.workspace": "Workspace",
|
||||
"settings.section.display": "Display",
|
||||
"settings.marqueeColor": "Selection box color",
|
||||
"settings.marqueeColor.hint":
|
||||
@@ -896,6 +1103,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"ctxImport.src.osmRoads": "OSM roads",
|
||||
"ctxImport.src.osmWater": "OSM water",
|
||||
"ctxImport.src.osmGreen": "OSM greenery",
|
||||
"ctxImport.src.osmParking": "OSM parking",
|
||||
"ctxImport.src.osmRailway": "OSM railway",
|
||||
"ctxImport.src.osmForest": "OSM forest",
|
||||
"ctxImport.cancel": "Cancel",
|
||||
"ctxImport.confirm": "Import",
|
||||
"ctxImport.importing": "Importing…",
|
||||
@@ -940,6 +1150,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"roomStamp.showFloorArea": "Show floor area",
|
||||
"roomStamp.floorAreaPrefix": "Floor area prefix",
|
||||
"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",
|
||||
|
||||
"rt.bold": "Bold",
|
||||
@@ -947,6 +1160,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"rt.underline": "Underline",
|
||||
"rt.strike": "Strikethrough",
|
||||
"rt.strikethrough": "Strikethrough",
|
||||
"rt.super": "Superscript",
|
||||
"rt.sub": "Subscript",
|
||||
"rt.fontSize": "Font size",
|
||||
"rt.color": "Color",
|
||||
"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.",
|
||||
"viewport3d.grid.show": "Show 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
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
|
||||
// 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`.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
@@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95";
|
||||
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
|
||||
|
||||
/** 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. */
|
||||
export interface GeoFeature {
|
||||
@@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record<GeoCategory, string> = {
|
||||
road: "Strassen",
|
||||
water: "Gewässer",
|
||||
green: "Grünflächen",
|
||||
parking: "Parkplätze",
|
||||
railway: "Bahn",
|
||||
forest: "Wald",
|
||||
};
|
||||
|
||||
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
|
||||
@@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record<GeoCategory, string> = {
|
||||
road: "context-roads",
|
||||
water: "context-water",
|
||||
green: "context-green",
|
||||
parking: "context-parking",
|
||||
railway: "context-railway",
|
||||
forest: "context-forest",
|
||||
};
|
||||
|
||||
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
|
||||
|
||||
+28
-8
@@ -1,7 +1,8 @@
|
||||
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
|
||||
//
|
||||
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
|
||||
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
|
||||
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
|
||||
// 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
|
||||
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
|
||||
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
|
||||
@@ -24,6 +25,9 @@ export interface OsmSelection {
|
||||
roads: boolean;
|
||||
water: boolean;
|
||||
green: boolean;
|
||||
parking: boolean;
|
||||
railway: boolean;
|
||||
forest: boolean;
|
||||
}
|
||||
|
||||
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
|
||||
@@ -55,7 +59,12 @@ function buildQuery(
|
||||
}
|
||||
if (sel.green) {
|
||||
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});`);
|
||||
}
|
||||
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
|
||||
@@ -69,20 +78,23 @@ function categorize(
|
||||
if (!tags) return null;
|
||||
if (sel.buildings && tags.building) return "building";
|
||||
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
|
||||
if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest"))
|
||||
return "forest";
|
||||
if (
|
||||
sel.green &&
|
||||
(tags.leisure === "park" ||
|
||||
tags.natural === "wood" ||
|
||||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
|
||||
/grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
|
||||
)
|
||||
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";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
|
||||
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
|
||||
function isClosedCategory(cat: GeoCategory): boolean {
|
||||
return cat !== "road";
|
||||
return cat !== "road" && cat !== "railway";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +130,15 @@ export async function fetchOsm(
|
||||
originIn?: GeoOrigin,
|
||||
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
||||
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: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
roomStampToDoc,
|
||||
roomStampExtraLines,
|
||||
roomDisplayName,
|
||||
formatStampArea,
|
||||
} from "./roomStamp";
|
||||
import type { Room } from "./types";
|
||||
|
||||
@@ -82,6 +83,54 @@ describe("roomStampExtraLines", () => {
|
||||
);
|
||||
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", () => {
|
||||
|
||||
+24
-3
@@ -67,17 +67,32 @@ export interface RoomStampLine {
|
||||
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,
|
||||
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. Die Ausrichtung folgt den
|
||||
* Stempel-Feldern; fehlt sie, gilt „zentriert" (Alt-Verhalten).
|
||||
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage, Personenzahl wenn gesetzt.
|
||||
* Die Ausrichtung folgt den Stempel-Feldern; fehlt sie, gilt „zentriert"
|
||||
* (Alt-Verhalten).
|
||||
*/
|
||||
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] {
|
||||
const lines: RoomStampLine[] = [];
|
||||
if (stamp.showFloorArea) {
|
||||
const prefix = stamp.floorAreaPrefix ?? "";
|
||||
lines.push({
|
||||
text: `${prefix}${ctx.netArea.toFixed(2)} m²`,
|
||||
text: `${prefix}${formatStampArea(ctx.netArea, stamp.roundingStep)} m²`,
|
||||
align: stamp.floorAreaAlign ?? "center",
|
||||
});
|
||||
}
|
||||
@@ -87,6 +102,12 @@ export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): Ro
|
||||
align: stamp.usageAlign ?? "center",
|
||||
});
|
||||
}
|
||||
if (stamp.occupancy != null) {
|
||||
lines.push({
|
||||
text: `${stamp.occupancy} Pers.`,
|
||||
align: "center",
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
+121
-4
@@ -1,5 +1,15 @@
|
||||
import type { Project } 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:
|
||||
// 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;
|
||||
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
|
||||
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).
|
||||
// Kein passendes Bibliotheks-Asset (Dämmung) → bewusst ohne `material`.
|
||||
{ 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).
|
||||
{ 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).
|
||||
// Kein passendes Bibliotheks-Asset (Estrich) → bewusst ohne `material`.
|
||||
{ id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 },
|
||||
],
|
||||
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.
|
||||
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
|
||||
drawingLevels: recomputeFloorElevations([
|
||||
@@ -257,6 +371,7 @@ export const sampleProject: Project = {
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
typeId: "window-standard",
|
||||
position: 3.0, // Startkante 3.0 m → Fenster x 3.0–4.0, rechts von W9 mit Massiv-Abstand
|
||||
width: 1.0,
|
||||
height: 1.2,
|
||||
@@ -268,6 +383,7 @@ export const sampleProject: Project = {
|
||||
hostWallId: "W3",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
typeId: "window-standard",
|
||||
// Startkante 0.6 m ab W3-Start (x=5) → Fenster x 3.0–4.4, rechts von W9 (x=2.4)
|
||||
// mit sichtbarer Massiv-Wand dazwischen (die Querwand stösst auf Mauerwerk).
|
||||
position: 0.6,
|
||||
@@ -306,6 +422,7 @@ export const sampleProject: Project = {
|
||||
floorId: "eg",
|
||||
categoryCode: "40",
|
||||
shape: "straight",
|
||||
typeId: "stair-standard",
|
||||
start: { x: 3.7, y: 0.4 },
|
||||
dir: { x: 0, y: 1 },
|
||||
runLength: 3.2,
|
||||
|
||||
+693
-2
@@ -18,6 +18,10 @@ export type { SiaCategory } from "../geometry/roomArea";
|
||||
// Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil).
|
||||
import type { RichTextDoc, Align } 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) ────────────────────
|
||||
// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette:
|
||||
@@ -272,6 +276,166 @@ export interface CeilingType {
|
||||
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 (1–4) → 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 Wand-Regeln generieren automatisch Wall[]-Arrays — analog zu
|
||||
// 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.
|
||||
*/
|
||||
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,
|
||||
* 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
|
||||
* Oberkante des Geschosses (baseElevation + floorHeight — also bündig mit dem
|
||||
* 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 {
|
||||
id: string;
|
||||
@@ -699,6 +883,12 @@ export interface Ceiling {
|
||||
* Oberkante des Geschosses (baseElevation + floorHeight).
|
||||
*/
|
||||
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. */
|
||||
@@ -772,6 +962,18 @@ export interface Opening {
|
||||
* "beide" → beide Linien (Default wenn nicht gesetzt)
|
||||
*/
|
||||
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
|
||||
* Kategorie-Farbe.
|
||||
@@ -862,6 +1064,17 @@ export interface Stair {
|
||||
* `dir` (Default). false kehrt Auf-/Abpfeil um (Treppe steigt zum Start hin).
|
||||
*/
|
||||
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
|
||||
* Kategorie-Farbe.
|
||||
@@ -948,6 +1161,17 @@ export interface RoomStamp {
|
||||
floorAreaAlign?: Align;
|
||||
/** Ausrichtung der Nutzungs-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */
|
||||
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. */
|
||||
@@ -980,7 +1204,19 @@ export type Drawing2DGeom =
|
||||
| { shape: "rect"; min: Vec2; max: Vec2 }
|
||||
| { shape: "circle"; center: Vec2; r: 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. */
|
||||
export interface Drawing2D {
|
||||
@@ -1067,6 +1303,96 @@ export interface EdgeGrip {
|
||||
|
||||
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) ───────────
|
||||
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
|
||||
// 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;
|
||||
|
||||
/** 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 A0–A6 und B0–B6; 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1177,6 +1775,20 @@ export interface Project {
|
||||
* `Ceiling.wallTypeId` gegen `wallTypes` auf (siehe `getCeilingType`).
|
||||
*/
|
||||
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. */
|
||||
drawingLevels: DrawingLevel[];
|
||||
/** Grafik-Kategorie-Baum (geschossübergreifend). */
|
||||
@@ -1213,6 +1825,18 @@ export interface Project {
|
||||
* Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln).
|
||||
*/
|
||||
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
|
||||
* Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln).
|
||||
@@ -1220,6 +1844,45 @@ export interface Project {
|
||||
* aufgelöst — generiert Wall[]-Objekte bei Bedarf.
|
||||
*/
|
||||
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
|
||||
* 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). */
|
||||
export const openingsOfWall = (project: Project, wallId: string): Opening[] =>
|
||||
(project.openings ?? []).filter((o) => o.hostWallId === wallId);
|
||||
@@ -1291,6 +1970,18 @@ export const stairLabel = (s: Stair): string => {
|
||||
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). */
|
||||
export const roomsOfFloor = (project: Project, floorId: string): Room[] =>
|
||||
(project.rooms ?? []).filter((r) => r.floorId === floorId);
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// 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";
|
||||
|
||||
/**
|
||||
@@ -74,7 +74,9 @@ export function wallVerticalExtent(
|
||||
* Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern).
|
||||
* • zTop: aus `ceiling.top`, sonst Oberkante des Geschosses
|
||||
* (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.
|
||||
*/
|
||||
export function ceilingVerticalExtent(
|
||||
@@ -89,7 +91,9 @@ export function ceilingVerticalExtent(
|
||||
floorTop = 0;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -124,6 +128,28 @@ export function stairVerticalExtent(
|
||||
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
|
||||
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
OpeningSection,
|
||||
StairSection,
|
||||
RoomSection,
|
||||
ExtrudedSolidSection,
|
||||
ColumnSection,
|
||||
} from "./ObjectInfoPanel";
|
||||
|
||||
/** 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.stair && <StairSection stair={sel.stair} 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -19,6 +19,7 @@ import { t } from "../i18n";
|
||||
import { formatM } from "../model/types";
|
||||
import type {
|
||||
SiaCategory,
|
||||
SliceTermination,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
WallReferenceLine,
|
||||
@@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown";
|
||||
import { usePanelHost } from "./host";
|
||||
import type {
|
||||
CeilingInfo,
|
||||
ColumnInfo,
|
||||
ExtrudedSolidInfo,
|
||||
OpeningInfo,
|
||||
RoomInfo,
|
||||
StairInfo,
|
||||
@@ -68,11 +71,24 @@ export function ObjectInfoPanel() {
|
||||
const width = bbox.maxX - bbox.minX;
|
||||
const height = bbox.maxY - bbox.minY;
|
||||
|
||||
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
|
||||
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
|
||||
// X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit
|
||||
// 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 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 ──────────────────────────────────────────────────────────────
|
||||
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
|
||||
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
|
||||
@@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
|
||||
|
||||
<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-cube" role="group" aria-label={t("objinfo.refpoint")}>
|
||||
{FRACTIONS.map((fy) =>
|
||||
FRACTIONS.map((fx) => {
|
||||
const active = anchor.fx === fx && anchor.fy === fy;
|
||||
return (
|
||||
<button
|
||||
key={anchorKey(fx, fy)}
|
||||
type="button"
|
||||
className={"objinfo-anchor" + (active ? " active" : "")}
|
||||
title={t("objinfo.setRefpoint")}
|
||||
aria-pressed={active}
|
||||
onClick={() => setAnchor({ fx, fy })}
|
||||
>
|
||||
<span className="objinfo-dot" />
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<div className="objinfo-refrow">
|
||||
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
|
||||
{FRACTIONS.map((fy) =>
|
||||
FRACTIONS.map((fx) => {
|
||||
const active = anchor.fx === fx && anchor.fy === fy;
|
||||
return (
|
||||
<button
|
||||
key={anchorKey(fx, fy)}
|
||||
type="button"
|
||||
className={"objinfo-anchor" + (active ? " active" : "")}
|
||||
title={t("objinfo.setRefpoint")}
|
||||
aria-pressed={active}
|
||||
onClick={() => setAnchor({ fx, fy })}
|
||||
>
|
||||
<span className="objinfo-dot" />
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* X / Y des gewählten Bezugspunkts (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.x")}</span>
|
||||
<span className="objinfo-fval">{formatM(px)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.y")}</span>
|
||||
<span className="objinfo-fval">{formatM(py)}</span>
|
||||
</div>
|
||||
{/* 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>
|
||||
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
|
||||
Dreh-Feld, als Spalte. */}
|
||||
<div className="objinfo-xyzcol">
|
||||
<DimensionField
|
||||
label={t("objinfo.x")}
|
||||
value={px}
|
||||
min={-Infinity}
|
||||
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.y")}
|
||||
value={py}
|
||||
min={-Infinity}
|
||||
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
|
||||
/>
|
||||
{/* 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 className="objinfo-sep" />
|
||||
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() {
|
||||
value={height}
|
||||
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)
|
||||
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
|
||||
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 ───────────────────────────────────────────────
|
||||
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
||||
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
||||
@@ -243,6 +436,24 @@ export function StairSection({
|
||||
<div className="objinfo-sep" />
|
||||
<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. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
|
||||
@@ -360,6 +571,24 @@ export function OpeningSection({
|
||||
<div className="objinfo-sep" />
|
||||
<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. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
|
||||
@@ -596,11 +825,15 @@ export function CeilingSection({
|
||||
<span className="objinfo-fval">{ceiling.floorName}</span>
|
||||
</div>
|
||||
|
||||
{/* Fläche (read-only). */}
|
||||
{/* Fläche + Volumen (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
|
||||
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
|
||||
</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" />
|
||||
|
||||
@@ -614,11 +847,16 @@ export function CeilingSection({
|
||||
onChange={(a) => host.onSetCeilingTop(a)}
|
||||
/>
|
||||
|
||||
{/* UK = OK − Dicke (abgeleitet, read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span>
|
||||
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span>
|
||||
</div>
|
||||
{/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override
|
||||
bleibt es beim heutigen Verhalten UK = OK − Dicke. */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.ceiling.bottom")}
|
||||
anchor={ceiling.bottom}
|
||||
defaultZ={ceiling.zBottom}
|
||||
floors={ceiling.floors}
|
||||
defaultFloorId={ceiling.floorId}
|
||||
onChange={(a) => host.onSetCeilingBottom?.(a)}
|
||||
/>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
|
||||
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
|
||||
@@ -678,6 +916,23 @@ export function WallSection({
|
||||
/>
|
||||
</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. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
|
||||
@@ -730,6 +985,26 @@ export function WallSection({
|
||||
<span className="objinfo-fval">{wall.floorName}</span>
|
||||
</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)} m³ − ${wall.openingVolume.toFixed(2)} m³`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{wall.netVolume.toFixed(2)} m³
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
|
||||
<label className="objinfo-field objinfo-check-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
|
||||
@@ -755,15 +1030,8 @@ export function WallSection({
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.wall.bottom")}
|
||||
anchor={wall.bottom}
|
||||
defaultZ={wall.zBottom}
|
||||
floors={wall.floors}
|
||||
defaultFloorId={wall.floorId}
|
||||
onChange={(a) => host.onSetWallBottom(a)}
|
||||
/>
|
||||
{/* OK & UK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". OK
|
||||
(oben) steht oben, UK (unten) darunter — räumlich passend (Nutzer). */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.wall.top")}
|
||||
anchor={wall.top}
|
||||
@@ -772,6 +1040,14 @@ export function WallSection({
|
||||
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
|
||||
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). */}
|
||||
<div className="objinfo-field">
|
||||
@@ -836,25 +1112,27 @@ function VerticalAnchorRow({
|
||||
</button>
|
||||
</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" ? (
|
||||
<div className="objinfo-field objinfo-subfield">
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.anchor.refFloor")}</span>
|
||||
<Dropdown
|
||||
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
|
||||
onChange={(id) => onChange({ mode: "floor", floorId: id })}
|
||||
options={floors.map((f) => ({ value: f.id, label: f.name }))}
|
||||
title={label}
|
||||
title={t("objinfo.anchor.refFloor")}
|
||||
width={150}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="objinfo-subfield">
|
||||
<DimensionField
|
||||
label={t("objinfo.z")}
|
||||
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
|
||||
min={-Infinity}
|
||||
onCommit={(z) => onChange({ mode: "custom", z })}
|
||||
/>
|
||||
</div>
|
||||
<DimensionField
|
||||
label={t("objinfo.anchor.customHeight")}
|
||||
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
|
||||
min={-Infinity}
|
||||
onCommit={(z) => onChange({ mode: "custom", z })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -915,3 +1193,44 @@ function DimensionField({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,25 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
|
||||
/>
|
||||
</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">
|
||||
<label className="roomstamp-check">
|
||||
<input
|
||||
@@ -200,6 +219,21 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
|
||||
onChange={(usageAlign) => patch({ usageAlign })}
|
||||
/>
|
||||
</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 className="texteditor-foot">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { usePanelHost } from "./host";
|
||||
import type { ContextObject } from "../model/types";
|
||||
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { useStore } from "../state/appStore";
|
||||
|
||||
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
|
||||
function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
@@ -33,6 +34,7 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
|
||||
export function SitePanel() {
|
||||
const host = usePanelHost();
|
||||
const notify = useStore((s) => s.notify);
|
||||
const objects = host.contextObjects;
|
||||
const contourSets = objects.filter((o) => o.type === "contourSet");
|
||||
|
||||
@@ -50,9 +52,9 @@ export function SitePanel() {
|
||||
if (!effectiveSet) return;
|
||||
const id = host.onGenerateTerrain(effectiveSet);
|
||||
if (id === null) {
|
||||
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox).
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(t("site.terrainFailed"));
|
||||
// Kein sinnvolles TIN — ruhiger Hinweis als Toast (keine harte Fehlerbox;
|
||||
// window.alert wäre im Tauri-WKWebView ohnehin wirkungslos).
|
||||
notify(t("site.terrainFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
</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:
|
||||
return null;
|
||||
}
|
||||
@@ -140,7 +158,10 @@ export function ToolsPanel() {
|
||||
{TOOL_ORDER.map((id) => {
|
||||
const tool = getTool(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
|
||||
? t("tool.floorOnlyDisabled")
|
||||
: t(tool.hintKey(tool.init()));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import { AttributesPanel } from "./AttributesPanel";
|
||||
import { ObjectInfoPanel } from "./ObjectInfoPanel";
|
||||
import { SitePanel } from "./SitePanel";
|
||||
import { RoomBalancePanel } from "./RoomBalancePanel";
|
||||
import { ElementTreePanel, ElementsTabIcon } from "./ElementTreePanel";
|
||||
import { ViewSnapshotsPanel, ViewSnapshotsTabIcon } from "./ViewSnapshotsPanel";
|
||||
import { LayoutsPanel, LayoutsTabIcon } from "./LayoutsPanel";
|
||||
import {
|
||||
ToolsTabIcon,
|
||||
AttributesTabIcon,
|
||||
@@ -41,6 +44,9 @@ export const BUILTIN_PANEL_IDS = {
|
||||
objectInfo: "object-info",
|
||||
site: "site",
|
||||
roomBalance: "room-balance",
|
||||
elements: "elements",
|
||||
viewSnapshots: "view-snapshots",
|
||||
layouts: "layouts",
|
||||
} as const;
|
||||
|
||||
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
|
||||
@@ -105,3 +111,32 @@ registerPanel({
|
||||
hasDisplayMode: false,
|
||||
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 />,
|
||||
});
|
||||
|
||||
@@ -22,9 +22,13 @@ import type {
|
||||
DrawingLevel,
|
||||
HatchStyle,
|
||||
LayerCategory,
|
||||
LayoutOrientation,
|
||||
LayoutPaperFormat,
|
||||
LineStyle,
|
||||
MasterLayout,
|
||||
Project,
|
||||
SiaCategory,
|
||||
SliceTermination,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
WallReferenceLine,
|
||||
@@ -32,6 +36,7 @@ import type {
|
||||
} from "../model/types";
|
||||
import type { SnapSettings, ToolId } from "../tools/types";
|
||||
import type { Selection } from "../state/selectionInfo";
|
||||
import type { ScheduleKind } from "../export/exportSchedule";
|
||||
|
||||
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
|
||||
|
||||
@@ -196,10 +201,38 @@ export interface PanelHostValue {
|
||||
h: number,
|
||||
anchor: { fx: number; fy: number },
|
||||
) => 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) ─
|
||||
/** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */
|
||||
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. */
|
||||
onSetWallType: (wallTypeId: string) => void;
|
||||
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
|
||||
@@ -216,6 +249,12 @@ export interface PanelHostValue {
|
||||
onSetCeilingThickness: (thickness: number) => void;
|
||||
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
|
||||
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) ─
|
||||
/** 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;
|
||||
/** Setzt die Sturzlinien-Darstellung der Tür. */
|
||||
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) ───
|
||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||
@@ -256,6 +297,8 @@ export interface PanelHostValue {
|
||||
onSetStairUp: (up: boolean) => void;
|
||||
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
|
||||
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
||||
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
|
||||
onSetStairType: (typeId: string) => void;
|
||||
|
||||
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
|
||||
/** Setzt den Raum-Namen. */
|
||||
@@ -265,6 +308,26 @@ export interface PanelHostValue {
|
||||
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
|
||||
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) ───────────────────────────────────
|
||||
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
|
||||
contextObjects: ContextObject[];
|
||||
@@ -279,6 +342,166 @@ export interface PanelHostValue {
|
||||
onGenerateTerrain: (contourSetId: string) => string | null;
|
||||
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
|
||||
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
|
||||
@@ -286,6 +509,7 @@ export interface PanelHostValue {
|
||||
export type { DrawingLevel, LayerCategory };
|
||||
export type { SnapSettings, ToolId };
|
||||
export type { Selection } from "../state/selectionInfo";
|
||||
export type { ScheduleKind, ScheduleRow } from "../export/exportSchedule";
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -34,7 +34,13 @@ import type {
|
||||
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
|
||||
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
|
||||
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
|
||||
export const LAYOUT_VERSION = 8;
|
||||
// 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. */
|
||||
const CURRENT_KEY = "cad.layout";
|
||||
@@ -68,7 +74,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||
{ 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",
|
||||
weight: 1.5,
|
||||
},
|
||||
|
||||
@@ -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 A0–A6 + B0–B6", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 A0–A6 und B-Reihe B0–B6.
|
||||
*/
|
||||
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 Ordner→Mehrseiten-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 Ordner→Mehrseiten-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
@@ -133,6 +133,12 @@ export interface PlanViewHandle {
|
||||
pxPerMeter: () => number;
|
||||
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
|
||||
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. */
|
||||
@@ -175,6 +181,17 @@ export interface PlanSelection {
|
||||
* sonst `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
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
|
||||
@@ -273,6 +301,8 @@ export interface PlanViewProps {
|
||||
onStampMove?: (roomId: string, delta: Vec2) => void;
|
||||
/** Doppelklick auf einen Raum-Stempel: Editor öffnen. */
|
||||
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
|
||||
* 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
|
||||
* `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
|
||||
* (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe
|
||||
@@ -374,9 +408,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
selectedOpeningIds,
|
||||
selectedStairIds,
|
||||
selectedRoomIds,
|
||||
selectedExtrudedSolidIds,
|
||||
selectedColumnIds,
|
||||
roomStamp,
|
||||
onStampMove,
|
||||
onStampEdit,
|
||||
onEditDrawingText,
|
||||
activeTool,
|
||||
toolInputActive,
|
||||
toolHandlers,
|
||||
@@ -638,6 +675,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
const v = viewRef.current;
|
||||
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
|
||||
[],
|
||||
@@ -841,7 +882,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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
|
||||
@@ -917,6 +985,35 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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.
|
||||
// 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das
|
||||
// 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) {
|
||||
const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y };
|
||||
if (delta.x !== 0 || delta.y !== 0) {
|
||||
gripHandlers!.onMoveBody(delta, md.target);
|
||||
gripHandlers!.onMoveBody(delta, md.target, mods);
|
||||
md.last = raw;
|
||||
}
|
||||
return;
|
||||
@@ -1402,28 +1499,46 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
const openingId = pickOpening(e.clientX, e.clientY);
|
||||
const wallId =
|
||||
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
|
||||
// Traf der Klick KEINE Wand/Öffnung: zuerst eine getroffene gefüllte 2D-Fläche
|
||||
// (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
|
||||
// Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché —
|
||||
// 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 =
|
||||
hit && hit.kind === "polygon" ? hit.drawingId ?? null : null;
|
||||
const drawingId =
|
||||
openingId || wallId
|
||||
openingId || wallId || columnId
|
||||
? null
|
||||
: 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
|
||||
// Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick
|
||||
// auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke.
|
||||
const hitCeilingId =
|
||||
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).
|
||||
const stairId =
|
||||
openingId || wallId || drawingId || ceilingId
|
||||
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
|
||||
? null
|
||||
: pickStair(e.clientX, e.clientY);
|
||||
// Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen.
|
||||
const roomId =
|
||||
openingId || wallId || drawingId || ceilingId || stairId
|
||||
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
|
||||
? null
|
||||
: pickRoom(e.clientX, e.clientY);
|
||||
onSelect({
|
||||
@@ -1435,6 +1550,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
openingId,
|
||||
stairId,
|
||||
roomId,
|
||||
extrudedSolidId,
|
||||
columnId,
|
||||
shift: e.shiftKey,
|
||||
});
|
||||
}
|
||||
@@ -1490,6 +1607,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
toolHandlers!.onToolCommit();
|
||||
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.
|
||||
if (onStampEdit) {
|
||||
const rid =
|
||||
@@ -1614,6 +1739,33 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
);
|
||||
}, [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
|
||||
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
|
||||
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
|
||||
@@ -1819,6 +1971,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
|
||||
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). */}
|
||||
{highlightOpeningPrims.map((p, i) =>
|
||||
p.kind === "line" ? (
|
||||
@@ -2024,7 +2200,7 @@ function DraftOverlay({
|
||||
snapColor: string;
|
||||
}) {
|
||||
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 (
|
||||
<g className="tool-overlay" pointerEvents="none">
|
||||
{/* Vorschau-Formen. */}
|
||||
@@ -3081,12 +3257,32 @@ function renderPrimitive(
|
||||
);
|
||||
}
|
||||
case "drawingText": {
|
||||
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
|
||||
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
|
||||
// Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern →
|
||||
// viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
|
||||
const c = toScreen(p.at);
|
||||
const fontSize = p.heightM * PX_PER_M;
|
||||
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
|
||||
const deg = -(p.angle * 180) / Math.PI;
|
||||
// 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 (
|
||||
<text
|
||||
x={c.x}
|
||||
@@ -3099,7 +3295,15 @@ function renderPrimitive(
|
||||
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -8,11 +8,14 @@
|
||||
import type {
|
||||
AttributeSource,
|
||||
Ceiling,
|
||||
Column,
|
||||
Component,
|
||||
Drawing2D,
|
||||
ExtrudedSolid,
|
||||
HatchPattern,
|
||||
LayerCategory,
|
||||
Opening,
|
||||
OverrideActions,
|
||||
Project,
|
||||
Room,
|
||||
Stair,
|
||||
@@ -20,6 +23,8 @@ import type {
|
||||
Wall,
|
||||
} from "../model/types";
|
||||
import {
|
||||
columnLabel,
|
||||
columnsOfFloor,
|
||||
flattenCategories,
|
||||
getCeilingType,
|
||||
getComponent,
|
||||
@@ -27,11 +32,15 @@ import {
|
||||
getLayerCategory,
|
||||
getLineStyle,
|
||||
getWallType,
|
||||
openingLabel,
|
||||
openingsOfWall,
|
||||
roomsOfFloor,
|
||||
stairLabel,
|
||||
stairsOfFloor,
|
||||
wallTypeThickness,
|
||||
} from "../model/types";
|
||||
import { columnFootprint } from "../geometry/column";
|
||||
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
|
||||
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
|
||||
import type { RichTextDoc } 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 {
|
||||
doorSymbol,
|
||||
openingGapQuad,
|
||||
openingInterval,
|
||||
openingJambs,
|
||||
wallAxisFrame,
|
||||
@@ -224,6 +234,17 @@ export type Primitive =
|
||||
stairId?: string;
|
||||
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
|
||||
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";
|
||||
@@ -312,6 +333,12 @@ export type Primitive =
|
||||
/** Drehung in RADIANT (CCW im Modell). */
|
||||
angle: number;
|
||||
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). */
|
||||
drawingId?: string;
|
||||
greyed?: boolean;
|
||||
@@ -597,15 +624,6 @@ export function generatePlan(
|
||||
const primitives: Primitive[] = [];
|
||||
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
|
||||
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
|
||||
// Modell (Ebene) stammt statt aus einer Magie-Konstante.
|
||||
const lwByCode = categoryLwMap(project.layers);
|
||||
@@ -614,26 +632,92 @@ export function generatePlan(
|
||||
// "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur).
|
||||
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
|
||||
// Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
|
||||
// gezeichnet (am Ende angehängt).
|
||||
const drawings = project.drawings2d.filter(
|
||||
(d) =>
|
||||
d.levelId === floorId &&
|
||||
visibleCodes.has(d.categoryCode) &&
|
||||
categoryDisplay(d.categoryCode).render,
|
||||
);
|
||||
const drawings = project.drawings2d
|
||||
.filter(
|
||||
(d) =>
|
||||
d.levelId === floorId &&
|
||||
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 +
|
||||
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
|
||||
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
|
||||
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
|
||||
const ceilings = (project.ceilings ?? []).filter(
|
||||
(c) =>
|
||||
c.floorId === floorId &&
|
||||
visibleCodes.has(c.categoryCode) &&
|
||||
categoryDisplay(c.categoryCode).render,
|
||||
);
|
||||
const ceilings = (project.ceilings ?? [])
|
||||
.filter(
|
||||
(c) =>
|
||||
c.floorId === floorId &&
|
||||
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:
|
||||
// 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,
|
||||
@@ -650,10 +734,17 @@ export function generatePlan(
|
||||
// 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
|
||||
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
|
||||
const rooms = roomsOfFloor(project, floorId).filter(
|
||||
(r) =>
|
||||
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
|
||||
);
|
||||
const rooms = roomsOfFloor(project, floorId)
|
||||
.filter(
|
||||
(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) {
|
||||
const greyed = categoryDisplay(room.categoryCode).greyed;
|
||||
addRoomArea(primitives, room, greyed, detail);
|
||||
@@ -701,8 +792,13 @@ export function generatePlan(
|
||||
for (const o of openings) {
|
||||
const d = categoryDisplay(o.categoryCode);
|
||||
if (visibleCodes.has(o.categoryCode) && d.render) {
|
||||
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
|
||||
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm);
|
||||
// Override-Regeln: `object_name` matcht das Öffnungs-Label
|
||||
// („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) {
|
||||
const greyed = categoryDisplay(stair.categoryCode).greyed;
|
||||
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
|
||||
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight);
|
||||
// Override-Regeln: `object_name` matcht das Treppen-Label („Gerade 16 STG").
|
||||
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é).
|
||||
@@ -727,6 +843,14 @@ export function generatePlan(
|
||||
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
|
||||
// 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.
|
||||
@@ -956,6 +1080,79 @@ function categoryMap(layers: LayerCategory[]): Map<string, LayerCategory> {
|
||||
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
|
||||
* §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
|
||||
@@ -1091,6 +1288,7 @@ function addDrawing2D(
|
||||
heightM: g.height,
|
||||
angle: g.angle,
|
||||
color,
|
||||
...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}),
|
||||
greyed,
|
||||
drawingId: d.id,
|
||||
});
|
||||
@@ -1691,6 +1889,24 @@ function addOpeningSymbol(
|
||||
if (o.kind === "door") {
|
||||
const sym = doorSymbol(wall, o);
|
||||
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
|
||||
// Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
|
||||
// ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
|
||||
@@ -1795,6 +2011,22 @@ function addOpeningSymbol(
|
||||
const glassCount = detail === "fein" ? 2 : 1;
|
||||
const sym = windowSymbol(project, wall, o, glassCount);
|
||||
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") {
|
||||
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
|
||||
out.push({
|
||||
|
||||
+14
-1
@@ -12,7 +12,7 @@
|
||||
import type { Plan } from "./generatePlan";
|
||||
import type { Project } from "../model/types";
|
||||
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). */
|
||||
const DEBOUNCE_MS = 200;
|
||||
@@ -56,8 +56,21 @@ export function pushNativeScene(plan: Plan): void {
|
||||
*/
|
||||
export function pushNativeWalls(project: Project): void {
|
||||
if (!isTauri()) return;
|
||||
lastProject = project;
|
||||
clearTimeout(wallsTimer);
|
||||
wallsTimer = setTimeout(() => {
|
||||
void invokeNative("push_native_walls", { walls: projectToModel3d(project) });
|
||||
}, 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
@@ -9,6 +9,7 @@ import { describe, it, expect } from "vitest";
|
||||
import type { Project } from "../model/types";
|
||||
import { sampleProject } from "../model/sampleProject";
|
||||
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
|
||||
import { resolveHatch } from "./generatePlan";
|
||||
|
||||
const RGB: [number, number, number] = [1, 0.5, 0.1];
|
||||
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
|
||||
@@ -25,14 +26,19 @@ describe("selectionHighlightLines", () => {
|
||||
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
|
||||
// haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp
|
||||
// "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die
|
||||
// TS-seitig je als eigene Box gestapelt werden (Option B) -> 4 Quader =
|
||||
// 4·12 Kanten. (Früher: 1 Vollkörper = 12 Kanten.)
|
||||
// TS-seitig je als eigene Box gestapelt werden (Option B).
|
||||
// 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);
|
||||
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", () => {
|
||||
@@ -43,11 +49,14 @@ describe("selectionHighlightLines", () => {
|
||||
expect(lines[5]).toBeCloseTo(RGB[2], 6);
|
||||
});
|
||||
|
||||
it("eine Decke (Slab) -> 3·n Kanten (n = Umriss-Eckenzahl)", () => {
|
||||
// C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten.
|
||||
it("eine Decke (Slab) -> je Schicht 3·n Kanten (geschichteter Bodenaufbau)", () => {
|
||||
// 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 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", () => {
|
||||
@@ -63,16 +72,21 @@ describe("selectionHighlightLines", () => {
|
||||
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)", () => {
|
||||
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
|
||||
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
|
||||
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
|
||||
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
|
||||
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches
|
||||
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.)
|
||||
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
|
||||
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
|
||||
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
|
||||
it("Öffnungen allein multiplizieren die Bänder NICHT (ein Körper je Schicht-Band mit Löchern)", () => {
|
||||
// 3D-Pfad (layered=true): eine Wand mit Öffnungen wird NICHT in Pfeiler/
|
||||
// Brüstung/Sturz zerlegt, sondern als EIN Körper pro Schicht-Band mit echten
|
||||
// Löchern emittiert — die Löcher fügen im Highlight keine Kanten hinzu.
|
||||
// Isoliert von Verschneidungen getestet: OHNE die Querwand W9 (deren T-Stoss
|
||||
// sonst die innerste Putzschicht von W1 span-cutout-splittet, s. eigener
|
||||
// Test unten) hat W1 (Tür D1 + Fenster O1) dieselbe Bänder-Zahl wie die
|
||||
// öffnungslose Wand W2 desselben Typs "aw" — die Löcher fügen keine Körper
|
||||
// hinzu. Beide stehen unter der Geschossdecke C1, deren Beton-Schicht (100)
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen.
|
||||
expect(w1.length).toBe(4);
|
||||
for (const body of w1) {
|
||||
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper).
|
||||
// W1 ist Durchgangswand für die Querwand W9 (T-Stoss): die innerste Putz-
|
||||
// schicht wird durch den Span-Cutout entlang der Achse in ZWEI Teilstücke
|
||||
// gebrochen. NEU (per-Schicht-Decken-Dominanz): W1 steht unter der Decke C1,
|
||||
// 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!.length).toBe(2);
|
||||
const axis0 = body.start[0];
|
||||
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
|
||||
// Fenster A: u=[1,2], z=[0.9,2.1].
|
||||
expect(byFrom[0].from).toBeCloseTo(1.0, 6);
|
||||
expect(byFrom[0].to).toBeCloseTo(2.0, 6);
|
||||
// Fenster A: u=[1,2], z=[0.9,2.1] (absolut rekonstruiert).
|
||||
expect(axis0 + byFrom[0].from).toBeCloseTo(1.0, 6);
|
||||
expect(axis0 + byFrom[0].to).toBeCloseTo(2.0, 6);
|
||||
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 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.
|
||||
expect(byFrom[1].from).toBeCloseTo(1.5, 6);
|
||||
expect(byFrom[1].to).toBeCloseTo(2.5, 6);
|
||||
expect(axis0 + byFrom[1].from).toBeCloseTo(1.5, 6);
|
||||
expect(axis0 + byFrom[1].to).toBeCloseTo(2.5, 6);
|
||||
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 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.
|
||||
const p: Project = { ...sampleProject, openings: [] };
|
||||
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);
|
||||
expect(door).toBeDefined();
|
||||
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:
|
||||
// render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff,
|
||||
// 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 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)
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Die vier Materialfarben kommen genau einmal vor.
|
||||
// Die vier Materialfarben kommen vor; der Backstein (#8c5544) zweimal (gespalten).
|
||||
const hex = (c: [number, number, number]) =>
|
||||
c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
|
||||
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).
|
||||
const totalT = w2.reduce((s, seg) => s + seg.thickness, 0);
|
||||
// Die Schichtdicken (je EINDEUTIGEM Querversatz, damit die gespaltene Backstein-
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel
|
||||
// symmetrisch um die Achse liegen: min = -T/2, max = +T/2.
|
||||
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
|
||||
const bands = w2
|
||||
.map((s) => ({ offset: s.start[0] - 5, t: s.thickness }))
|
||||
// Nach EINDEUTIGEM Querversatz zusammenfassen: der unter der Decke gespaltene
|
||||
// Backstein ([0,2.3] + [2.5,2.6]) liegt zweimal am SELBEN Versatz vor und darf
|
||||
// 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);
|
||||
const T = bands.reduce((s, b) => s + b.t, 0);
|
||||
expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6);
|
||||
@@ -216,9 +261,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
|
||||
context: [],
|
||||
};
|
||||
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).
|
||||
expect(meshes.length).toBe(1);
|
||||
const glass = meshes[0];
|
||||
expect(glassMeshes.length).toBe(1);
|
||||
const glass = glassMeshes[0];
|
||||
// Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas.
|
||||
expect(glass.positions.length).toBe(24);
|
||||
expect(glass.indices.length).toBe(36);
|
||||
@@ -234,32 +284,73 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
|
||||
|
||||
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
|
||||
// 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);
|
||||
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)", () => {
|
||||
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw", dominante Schicht "brick"
|
||||
// joinPriority 50) tragen exakt Wandhöhe 2.6 m = Geschosshöhe -> zTop 2.6.
|
||||
// Decke C1 (EG, Deckentyp "dg-massiv", dominante Schicht "concrete"
|
||||
// joinPriority 100) hat OK bündig mit dem Wandkopf (zTop 2.6) und
|
||||
// Gesamtdicke 0.3 m (screed .06 + insulation .04 + concrete .2) -> zBottom
|
||||
// 2.3. Vor dem Trim überlappten sich Wand- und Deckenvolumen exakt über
|
||||
// [2.3..2.6] im ganzen Wand-Footprint (Z-Fighting). Nach dem Trim endet die
|
||||
// Wand an der Deckenunterkante, weil die Decke die höhere joinPriority hat.
|
||||
it("Decke mit höherer joinPriority kappt die Wand an ihrer Unterkante", () => {
|
||||
describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
|
||||
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
|
||||
// brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
|
||||
// (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" joinPriority 100)
|
||||
// hat OK bündig mit dem Wandkopf (zTop 2.6), Gesamtdicke 0.3 m -> zBottom 2.3.
|
||||
//
|
||||
// ENTSCHEIDEND (Nutzer-Bug „Kranz durch die Dämmung"): C1.outline ist der
|
||||
// Raumgrundriss auf den WAND-ACHSEN ((0,0)-(5,0)-(5,4)-(0,4)) — die Decke liegt
|
||||
// also nur auf dem INNEREN Halbquerschnitt jeder Aussenwand. Für W2 (Achse x=5)
|
||||
// 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");
|
||||
expect(w2.length).toBeGreaterThan(0);
|
||||
for (const seg of w2) {
|
||||
expect(seg.baseElevation + seg.height).toBeCloseTo(2.3, 6);
|
||||
}
|
||||
// PER-SCHICHT-Decken-Dominanz (identisch zu toSection.ts::subtractDominantBands):
|
||||
// jede Deckenschicht (screed 30 / insulation 20 / concrete 100) schneidet nur
|
||||
// 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", () => {
|
||||
// Deckentyp-Priorität (concrete) auf 40 senken -> unter der Wand-Priorität
|
||||
// (brick 50) -> die Decke gewinnt nicht mehr, die Wand bleibt unverändert
|
||||
// auf voller Höhe (2.6 m, wie ohne Trim-Logik).
|
||||
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. Von den ÜBERDECKTEN (inneren)
|
||||
// Bändern läuft brick (50 > 40) jetzt bis zum Wandkopf (2.6) durch; render-int
|
||||
// (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 = {
|
||||
...sampleProject,
|
||||
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");
|
||||
expect(w2.length).toBeGreaterThan(0);
|
||||
for (const seg of w2) {
|
||||
expect(seg.baseElevation + seg.height).toBeCloseTo(2.6, 6);
|
||||
}
|
||||
expect(w2.length).toBe(4); // je 1 Box pro Band (Wand ragt nicht über die Decke)
|
||||
const top = (t: number) => {
|
||||
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) ->
|
||||
// unverändertes Verhalten (volle wall.height).
|
||||
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", () => {
|
||||
it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => {
|
||||
// Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 ->
|
||||
// dickste Schicht = concrete (#9aa0a6).
|
||||
describe("Geschichteter Bodenaufbau Decke", () => {
|
||||
it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => {
|
||||
// Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff),
|
||||
// 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 c1 = slabs.find((s) => s.ceilingId === "C1");
|
||||
expect(c1).toBeDefined();
|
||||
expect(c1!.color[0]).toBeCloseTo(0x9a / 255, 6);
|
||||
expect(c1!.color[1]).toBeCloseTo(0xa0 / 255, 6);
|
||||
expect(c1!.color[2]).toBeCloseTo(0xa6 / 255, 6);
|
||||
const c1 = slabs
|
||||
.filter((s) => s.ceilingId === "C1")
|
||||
.sort((a, b) => b.zTop - a.zTop); // oben zuerst
|
||||
expect(c1.length).toBe(3);
|
||||
|
||||
// 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
File diff suppressed because it is too large
Load Diff
@@ -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 (Σ (v1−v0)×(v2−v0)) 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);
|
||||
});
|
||||
});
|
||||
@@ -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 ⟺ Σ (v1−v0)×(v2−v0) = 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
@@ -19,6 +19,8 @@ import { createLayoutSlice } from "./layoutSlice";
|
||||
import type { LayoutSlice } from "./layoutSlice";
|
||||
import { createSiteSlice } from "./siteSlice";
|
||||
import type { SiteSlice } from "./siteSlice";
|
||||
import { createNotifySlice } from "./notifySlice";
|
||||
import type { NotifySlice } from "./notifySlice";
|
||||
|
||||
/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */
|
||||
export type RootState = ProjectSlice &
|
||||
@@ -26,17 +28,23 @@ export type RootState = ProjectSlice &
|
||||
SelectionSlice &
|
||||
ViewSlice &
|
||||
LayoutSlice &
|
||||
SiteSlice;
|
||||
SiteSlice &
|
||||
NotifySlice;
|
||||
|
||||
const store = createStore<RootState>((api) => ({
|
||||
// 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
|
||||
// `set/get` immer über den Gesamt-RootState laufen. ProjectSlice bringt die
|
||||
// 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(
|
||||
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>),
|
||||
...createViewSlice(api as unknown as StoreApi<ViewSlice>),
|
||||
...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { t } from "../i18n";
|
||||
import type { StoreApi } from "./store";
|
||||
import { createHistorySlice, pushHistoryPatch } from "./historySlice";
|
||||
import type { HistorySlice } from "./historySlice";
|
||||
import type { NotifyKind } from "./notifySlice";
|
||||
|
||||
/** Felder, die diese Slice in den RootState beisteuert. */
|
||||
export interface ProjectSlice {
|
||||
@@ -286,6 +287,9 @@ export interface ProjectSlice {
|
||||
*/
|
||||
type ProjectSliceDeps = {
|
||||
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;
|
||||
|
||||
export function createProjectSlice(
|
||||
@@ -579,7 +583,7 @@ export function createProjectSlice(
|
||||
codes.has(o.categoryCode),
|
||||
);
|
||||
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, layers: removeByCode(p.layers, code) };
|
||||
@@ -664,7 +668,7 @@ export function createProjectSlice(
|
||||
);
|
||||
if (used) {
|
||||
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, components: p.components.filter((c) => c.id !== id) };
|
||||
@@ -695,7 +699,7 @@ export function createProjectSlice(
|
||||
const used = p.components.some((c) => c.hatchId === id);
|
||||
if (used) {
|
||||
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, hatches: p.hatches.filter((h) => h.id !== id) };
|
||||
@@ -726,7 +730,7 @@ export function createProjectSlice(
|
||||
const used = p.hatches.some((h) => h.lineStyleId === id);
|
||||
if (used) {
|
||||
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, 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 },
|
||||
];
|
||||
}
|
||||
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
|
||||
if (g.shape === "text") return [g.at];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1412,6 +1418,8 @@ function moveGrip(
|
||||
const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) };
|
||||
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;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -21,20 +21,26 @@ import {
|
||||
import type {
|
||||
AttributeSource,
|
||||
Ceiling,
|
||||
Column,
|
||||
ColumnProfile,
|
||||
Drawing2D,
|
||||
Drawing2DGeom,
|
||||
ExtrudedSolid,
|
||||
LayerCategory,
|
||||
Opening,
|
||||
Project,
|
||||
Room,
|
||||
SiaCategory,
|
||||
SliceTermination,
|
||||
Stair,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
Wall,
|
||||
WallReferenceLine,
|
||||
} 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 {
|
||||
ceilingVerticalExtent,
|
||||
nextFloorAbove,
|
||||
@@ -70,6 +76,8 @@ export interface FloorChoice {
|
||||
*/
|
||||
export interface WallInfo {
|
||||
referenceLine: WallReferenceLine;
|
||||
/** Terminierungs-Regel am Deckenanschluss (Default "both" = heutiges Verhalten). */
|
||||
sliceTermination: SliceTermination;
|
||||
wallTypeId: string;
|
||||
/** Aktuelle Gesamtdicke (Meter). */
|
||||
thickness: number;
|
||||
@@ -91,6 +99,14 @@ export interface WallInfo {
|
||||
/** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */
|
||||
zBottom: 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;
|
||||
/** Grundfläche der Decke (m²). */
|
||||
area: number;
|
||||
/** Volumen = Fläche × Dicke (m³). */
|
||||
volume: number;
|
||||
/** Verfügbare Deckentyp-Presets (dediziert, `project.ceilingTypes`). */
|
||||
ceilingTypes: WallTypeChoice[];
|
||||
/** Geschoss, dem die Decke zugeordnet ist. */
|
||||
@@ -115,6 +133,8 @@ export interface CeilingInfo {
|
||||
floors: FloorChoice[];
|
||||
/** OK-Bindung (undefined = Geschoss-Oberkante). */
|
||||
top?: VerticalAnchor;
|
||||
/** UK-Bindung (undefined = OK − Dicke). */
|
||||
bottom?: VerticalAnchor;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
@@ -146,6 +166,8 @@ export interface OpeningInfo {
|
||||
doorType?: "normal" | "wandoeffnung";
|
||||
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
|
||||
lintelLines?: "keine" | "innen" | "aussen" | "beide";
|
||||
/** Zugewiesener Tür-/Fenstertyp (Bibliothek, `Opening.typeId`) oder undefined. */
|
||||
typeId?: string;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
@@ -171,6 +193,8 @@ export interface StairInfo {
|
||||
up: boolean;
|
||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
|
||||
typeId?: string;
|
||||
/** Geschoss, dem die Treppe zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
@@ -198,6 +222,41 @@ export interface RoomInfo {
|
||||
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. */
|
||||
export interface SelectionBBox {
|
||||
minX: number;
|
||||
@@ -212,7 +271,16 @@ export interface SelectionBBox {
|
||||
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
|
||||
*/
|
||||
export interface Selection {
|
||||
kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door";
|
||||
kind:
|
||||
| "wall"
|
||||
| "ceiling"
|
||||
| "opening"
|
||||
| "stair"
|
||||
| "room"
|
||||
| "drawing2d"
|
||||
| "door"
|
||||
| "extrudedSolid"
|
||||
| "column";
|
||||
id: string;
|
||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||
categoryCode: string;
|
||||
@@ -276,6 +344,10 @@ export interface Selection {
|
||||
stair?: StairInfo;
|
||||
/** Raum-Attribute (nur bei `kind === "room"`). */
|
||||
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;
|
||||
@@ -354,8 +426,18 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
||||
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
|
||||
const { zBottom, zTop } = wallVerticalExtent(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 = {
|
||||
referenceLine: wall.referenceLine ?? "center",
|
||||
sliceTermination: wall.sliceTermination ?? "both",
|
||||
wallTypeId: wall.wallTypeId,
|
||||
thickness,
|
||||
singleLayer: wt.layers.length <= 1,
|
||||
@@ -376,6 +458,10 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
||||
top: wall.top,
|
||||
zBottom,
|
||||
zTop,
|
||||
length,
|
||||
grossVolume,
|
||||
openingVolume,
|
||||
netVolume,
|
||||
};
|
||||
// bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt,
|
||||
// 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,
|
||||
singleLayer: wt.layers.length <= 1,
|
||||
area: ceilingArea(ceiling.outline),
|
||||
volume: ceilingArea(ceiling.outline) * thickness,
|
||||
ceilingTypes: (project.ceilingTypes ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
@@ -428,6 +515,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
|
||||
.filter((z) => z.kind === "floor")
|
||||
.map((z) => ({ id: z.id, name: z.name })),
|
||||
top: ceiling.top,
|
||||
bottom: ceiling.bottom,
|
||||
zBottom,
|
||||
zTop,
|
||||
};
|
||||
@@ -494,6 +582,7 @@ function openingSelection(project: Project, o: Opening): Selection {
|
||||
wingCount: o.wingCount,
|
||||
doorType: o.doorType,
|
||||
lintelLines: o.lintelLines,
|
||||
typeId: o.typeId,
|
||||
zBottom: ext.zBottom,
|
||||
zTop: ext.zTop,
|
||||
};
|
||||
@@ -530,6 +619,7 @@ function stairSelection(project: Project, s: Stair): Selection {
|
||||
treadDepth: geo.treadDepth,
|
||||
up: s.up !== false,
|
||||
referenz: s.referenz,
|
||||
typeId: s.typeId,
|
||||
floorId: s.floorId,
|
||||
floorName: floor?.name ?? s.floorId,
|
||||
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). */
|
||||
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||
const ls = d.lineStyleId
|
||||
@@ -640,11 +808,21 @@ export function deriveSelection(
|
||||
selectedOpeningId: string | null = null,
|
||||
selectedStairId: string | null = null,
|
||||
selectedRoomId: string | null = null,
|
||||
selectedExtrudedSolidId: string | null = null,
|
||||
selectedColumnId: string | null = null,
|
||||
): Selection | null {
|
||||
if (selectedDrawingId) {
|
||||
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
||||
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) {
|
||||
const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId);
|
||||
if (o) return openingSelection(project, o);
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface SelectionSlice {
|
||||
selectedStairIds: string[];
|
||||
// Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen.
|
||||
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;
|
||||
setSelectedDrawingIds: (ids: string[]) => void;
|
||||
@@ -26,6 +30,8 @@ export interface SelectionSlice {
|
||||
setSelectedOpeningIds: (ids: string[]) => void;
|
||||
setSelectedStairIds: (ids: string[]) => void;
|
||||
setSelectedRoomIds: (ids: string[]) => void;
|
||||
setSelectedExtrudedSolidIds: (ids: string[]) => void;
|
||||
setSelectedColumnIds: (ids: string[]) => void;
|
||||
/** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */
|
||||
clearSelection: () => void;
|
||||
}
|
||||
@@ -41,12 +47,16 @@ export function createSelectionSlice(
|
||||
selectedOpeningIds: [],
|
||||
selectedStairIds: [],
|
||||
selectedRoomIds: [],
|
||||
selectedExtrudedSolidIds: [],
|
||||
selectedColumnIds: [],
|
||||
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
||||
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
||||
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
||||
setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }),
|
||||
setSelectedStairIds: (ids) => set({ selectedStairIds: ids }),
|
||||
setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }),
|
||||
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
|
||||
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
|
||||
clearSelection: () =>
|
||||
set({
|
||||
selectedWallIds: [],
|
||||
@@ -55,6 +65,8 @@ export function createSelectionSlice(
|
||||
selectedOpeningIds: [],
|
||||
selectedStairIds: [],
|
||||
selectedRoomIds: [],
|
||||
selectedExtrudedSolidIds: [],
|
||||
selectedColumnIds: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user