Compare commits
127 Commits
9133c0961d
...
0ab0361e3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ab0361e3d | |||
| 31b76a2f02 | |||
| 7d368786ba | |||
| a631ddaace | |||
| cc74396854 | |||
| d4575df29f | |||
| e61634c3b9 | |||
| dc241ba63e | |||
| e6a7738170 | |||
| ae47b4f024 | |||
| ee0f1dfb07 | |||
| 9705890fd2 | |||
| 35a6834072 | |||
| 7dc8f0d5c6 | |||
| a84dc7ac73 | |||
| 6b89ad5bdb | |||
| 814e9a8656 | |||
| a51fd212d3 | |||
| bd8612ee74 | |||
| d2758efc8f | |||
| 441e4a319f | |||
| 3e7ad1f4e5 | |||
| fb5ffb457c | |||
| d21af4ac5a | |||
| 4a01754b4d | |||
| ce7454039f | |||
| a5849111e3 | |||
| 3d66dc8967 | |||
| e65a6b7de1 | |||
| 6ac054ea71 | |||
| ed724be481 | |||
| 00bff27b02 | |||
| 61313777da | |||
| 7c3a6f3f35 | |||
| 0240a23a02 | |||
| e309e54af7 | |||
| 13cc6a0d6a | |||
| 3ecb8c45de | |||
| f98c962ba3 | |||
| cdae20acbe | |||
| 1c31e680c1 | |||
| d34e1cb1c1 | |||
| 5f1b38a420 | |||
| 31d7aefcb7 | |||
| bc2778562c | |||
| 06803206e5 | |||
| 38a4d8ca3a | |||
| ee9aeda01e | |||
| a612ae55cd | |||
| c02a02cffe | |||
| 983d061a0c | |||
| 1945fecd11 | |||
| 4c4c9907af | |||
| b96714641a | |||
| 242b850beb | |||
| 7898a0d158 | |||
| 8f85e135ee | |||
| 038054fee6 | |||
| 3980a06a6c | |||
| b86fca2c96 | |||
| fa59c0a9dc | |||
| a6db682e9d | |||
| d43e2c151e | |||
| ef7eb295d9 | |||
| 456ecc5445 | |||
| 975ce3f745 | |||
| 4b0f23d123 | |||
| 61c2a48f52 | |||
| 39bceedc17 | |||
| 3f042eb091 | |||
| a3a6b51db4 | |||
| ce728703f6 | |||
| 340f950f9c | |||
| 0e02c2b20f | |||
| 355c1d4dd1 | |||
| c91b9ab1f2 | |||
| f248e2ae80 | |||
| 9a00c8e2f2 | |||
| 83dcdd0501 | |||
| aee884639e | |||
| cdbef992f3 | |||
| 83abc1d87b | |||
| b029ce0139 | |||
| 37f4fe8947 | |||
| a8a1835b68 | |||
| 897a2dcd43 | |||
| db44317d95 | |||
| c9baff58b0 | |||
| 4319e12e35 | |||
| 3a986ecd2f | |||
| e99bb2413f | |||
| bfb80b363b | |||
| 9a38636bf2 | |||
| d131683304 | |||
| 82d5710ddb | |||
| e3cdc86f46 | |||
| 2e13ec3097 | |||
| c734802555 | |||
| 1bbe814ab1 | |||
| 4ef40a02c4 | |||
| 9b6dd80d39 | |||
| a409e8e6b4 | |||
| 826685ceaf | |||
| 64f61797d8 | |||
| eaf57e2292 | |||
| 7cfdf59c29 | |||
| 8cfe01837e | |||
| 80121a3f9a | |||
| 89112eff9b | |||
| 7b22f8fdf7 | |||
| 38bc36d36d | |||
| 31f2d63362 | |||
| 1195d2a054 | |||
| 89e737b25e | |||
| 4beae72eea | |||
| 586c1c99bf | |||
| 4ac2ecb504 | |||
| 973ac6d04f | |||
| d0b9d22141 | |||
| 938d6421f9 | |||
| 9a65900377 | |||
| 142ba7ec20 | |||
| 492e1f811a | |||
| dbe7d374cd | |||
| fa40429e63 | |||
| 35299307d6 | |||
| 889cbb2c12 |
+158
@@ -1,3 +1,161 @@
|
|||||||
|
# HANDOVER — Stand 2026-07-09 (autonome Session, ALLES committet)
|
||||||
|
|
||||||
|
Lange autonome Session (Nutzer-Auftrag: „arbeite die Pendenzen/Funktionen durch,
|
||||||
|
selbstständig committen, nicht mehr nachfragen"). **Abweichung vom bisherigen
|
||||||
|
Protokoll: in dieser Session wurde committet** (ausdrücklich beauftragt). Keine
|
||||||
|
KI-Spuren, deutsche Commits, keine Co-Authored-By-Trailer.
|
||||||
|
|
||||||
|
## Verifikations-Baseline (zuletzt grün)
|
||||||
|
`npx tsc --noEmit` sauber · `npx vitest run` **620**. (Rust/WASM unverändert →
|
||||||
|
kein Neubau nötig.) Der akkumulierte, über frühere Sessions gewachsene grüne
|
||||||
|
Stand wurde zuerst als EIN Basis-Commit `3529930` gelandet, danach jedes Feature
|
||||||
|
einzeln.
|
||||||
|
|
||||||
|
## In dieser Session gebaut + committet (neueste zuerst)
|
||||||
|
- **Dächer** (`1195d2a` Modell+Geometrie, `31f2d63` Werkzeug+2D+3D): neues
|
||||||
|
Roof-Element + `geometry/roof.ts` (roofGeometry für Flach/Pult/Sattel/Walm/
|
||||||
|
Mansarde/Zelt, auf der Umriss-BBox, First entlang X/Y). Befehl „Dach" (BIM-
|
||||||
|
Ribbon, Alias dach/rf): Rechteck aufziehen, Dachform als Inline-Option wählbar.
|
||||||
|
2D-Plan (Traufe/First/Grat/Knick, generatePlan) + 3D-Flächen (emitRoofs,
|
||||||
|
terrakotta). Demo-Satteldach RF1 im Seed. +19 Tests. **OFFEN (Folge-Increment):
|
||||||
|
Auswahl/Attribut-Editieren/Löschen platzierter Dächer** (Form/Neigung/Überstand
|
||||||
|
im Panel ändern, Klick-Auswahl, Delete) — mirror des Decken-Selektionspfads
|
||||||
|
über ~12 App.tsx-Stellen + PlanView-Pick + selectionInfo + host + Panel.
|
||||||
|
- **Fenster/Tür-Feedback** (`4beae72`, `89e737b`): (a) Fenster wirkten im 3D
|
||||||
|
flach → Rahmen füllt jetzt die VOLLE Wanddicke (tiefe Laibung), Scheibe dünn
|
||||||
|
mittig; Ursache war frameThickness (Profilbreite) fälschlich als Einbautiefe.
|
||||||
|
(b) Detailgrad grob/mittel/fein wirkt jetzt auch im 3D (grob=Loch+Scheibe,
|
||||||
|
mittel=Rahmen, fein=+Sprossen), durchgereicht bis projectToModel3d({detail}).
|
||||||
|
(c) Fenster↔Tür-Umschalter im Objekt-Info entfernt (nur noch Anzeige).
|
||||||
|
(d) **Kernursache „sieht im 3D gar nicht so aus": platzierte Öffnungen bekamen
|
||||||
|
keinen typeId** → resolveOpeningFrame lieferte null → keine Rahmen. appendOpening
|
||||||
|
weist jetzt den ersten Fenster-/Türtyp zu.
|
||||||
|
- **Öffnung ⚙-Knopf** (`586c1c9`): springt in den Tür-/Fenstertyp-Editor.
|
||||||
|
- **Decken-Griffe im 3D** (`973ac6d`): gewählte Decken haben im wgpu-Viewport
|
||||||
|
Eckpunkt- + Kanten-Mittelpunkt-Griffe (rautenförmig) + Verschiebe-Griff →
|
||||||
|
moveCeilingGrip/Edge/By. Prop-Kette Wasm3DViewport←Viewport3D←App. three.js-
|
||||||
|
Sicht unverändert (Default wgpu). **Visuell noch NICHT in Tauri abgenommen.**
|
||||||
|
- **Text-Styling-Leiste** (`d0b9d22`): selektierter Freitext/Textspalte ist jetzt
|
||||||
|
Formatier-Ziel der Oberleiste (Schrift/fett/kursiv/Farbe via Drawing2D-Text.marks;
|
||||||
|
Grösse bleibt über Modell-Höhe, nicht pt). generatePlan/PlanView rendern die Marks.
|
||||||
|
- **Schichttrennlinie als Wand-Referenzlinie** (`938d642`): Wall.referenceOffset
|
||||||
|
(freier Achsversatz) übersteuert left/center/right; Object-Info-Dropdown listet
|
||||||
|
je interne Fuge einen Eintrag. wallReferenceOffset bleibt EINE Quelle (2D/Schnitt/3D).
|
||||||
|
- **Mess-Werkzeug** (`492e1f8`): Polygonzug mit Länge + FLÄCHE (ab 3 Ecken),
|
||||||
|
Live-Werte dauerhaft im Objekt-Info-Panel (ToolDraft.measure→PanelHost.measurement),
|
||||||
|
Rechtsklick beendet Pfad + armiert neuen (mehrere nacheinander).
|
||||||
|
- **Tür/Fenster tief** (`fa40429` Modell/Seeds/RM-Editor, `142ba7e` 2D, `9a65900` 3D):
|
||||||
|
DoorType/WindowType um frameKind (Zarge/Blockrahmen), frameWidth, insetFromFace/
|
||||||
|
insetFace (Schichteinzug), transomHeight (Oberlicht), mullionRows (Kämpfer)
|
||||||
|
erweitert. 2D: opening-frame/-transom-Primitive. 3D: emitOpeningFrames (Rahmen/
|
||||||
|
Sprossen/Kämpfer) + einzugs-/oberlicht-bewusste Scheiben. **3D visuell noch NICHT
|
||||||
|
in Tauri abgenommen.**
|
||||||
|
- **LICENSE** (`dbe7d37`): offizieller AGPL-3.0-Text von gnu.org.
|
||||||
|
- **CSV-Element-Set (D2)** war bereits im Baseline-Stand vorhanden (verifiziert).
|
||||||
|
- **window.prompt/confirm-Sweep**: keine offenen Vorkommen mehr (bereits sauber).
|
||||||
|
|
||||||
|
## Offene Fäden / bewusst NICHT gemacht
|
||||||
|
1. **Tauri-Visualabnahme** der 3D-Änderungen (Tür/Fenster-Rahmen, Decken-Griffe)
|
||||||
|
steht aus — Nutzer testet selbst (render3d/Viewport nicht browserverifizierbar).
|
||||||
|
2. **Rechtsklick=Abbruch generell**: bewusst NICHT global von confirm→cancel
|
||||||
|
umgestellt (würde Polylinien-Abschluss brechen); fürs Mess-Werkzeug via onConfirm
|
||||||
|
gelöst (commit-frei → confirm≙abbruch).
|
||||||
|
3. **Bauteil-Einstellungs-Button** (Discoverability: von der gewählten Öffnung in
|
||||||
|
den RM-Typeditor springen) noch offen — Typeditor selbst ist über das
|
||||||
|
Ressourcen-Fenster (Tür-/Fenstertypen-Tabs) erreichbar.
|
||||||
|
4. Grosse Deferred-Items unverändert: 2D-Schnitt editierbar, Schnitt-Verschneidung
|
||||||
|
(wartet auf `.obp`), Feld-Controller 3D, Schnitt/Ansicht Phase 3, truck-Live-Wiring.
|
||||||
|
5. **Konventionen bleiben:** ab jetzt wieder Standard = NICHT committen, ausser der
|
||||||
|
Nutzer beauftragt es erneut. Keine KI-Spuren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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)
|
# HANDOVER — Stand 2026-07-04 (Schraffur/Linien-Epic + Folgethemen)
|
||||||
|
|
||||||
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
|
Übergabe an eine frische Instanz. Alles unten Gelistete ist committet, sofern nicht anders vermerkt. Ältere Handover-Stände (< 2026-07-04) siehe git-Historie. Zuerst `CONVENTIONS.md` lesen.
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
+164
-12
@@ -18,22 +18,55 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📅 Geplant
|
|
||||||
|
|
||||||
- [ ] **truck-Integration (Profil-Extrusion / B-Rep)** — **Start: 2026-07-07 (Dienstag, nach Reset).** Nutzer zeichnet 2D-Querschnitt (L-Profil, T-Träger, Freiform) → truck-Extrusion → 3D-Körper + Boolean gegen Wand/Decke. Voraussetzung: truck-Booleans stabil + WASM-Integration. Umfang: ~4–6 Wochen. Mit Nutzer Scope/MVP klären vor Start.
|
|
||||||
|
|
||||||
## ⛔ Blocker (zuerst klären)
|
## ⛔ Blocker (zuerst klären)
|
||||||
|
|
||||||
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
||||||
|
|
||||||
## 🔧 In Arbeit
|
## 🔧 In Arbeit
|
||||||
|
|
||||||
|
- [ ] **GEO-BLOCK Folgepunkte (2026-07-12, nach dem swissBUILDINGS3D-DXF-Fix)** — Nutzer-Report, noch NICHT umgesetzt:
|
||||||
|
- ✅ **Georeferenzierung (`ae47b4f` + `e61634c`):** `Project.geoAnchor` (persistenter Modell↔LV95-Referenzpunkt) ergänzt — der ERSTE Import in einem Projekt setzt ihn, ALLE folgenden Importe verwenden denselben Anker statt je eigenständig `makeOrigin(center)` neu zu berechnen. Zusätzlich neuer Befehl **„georef"** (Button „Neuer Bezugspunkt" in SitePanel): verschiebt per Klick das GESAMTE Projekt (`model/geoRebase.ts::translateProject`, reine Translation über alle Bauteile/2D-Geometrie/Kontext-Schicht/Schnittlinien) so, dass der geklickte Punkt zum neuen Modell-Ursprung wird — `geoAnchor` wandert automatisch mit, der LV95-Bezug bleibt exakt erhalten (rekonstruierbar, auch für einen künftigen georeferenzierten Export). Löst das vom Nutzer beschriebene Workflow: Kataster/3D zuerst importieren (landet ggf. weit vom Ursprung), dann selbst einen praktischen Bezugspunkt am Modell wählen. **Bewusst NUR Translation, keine Rotation:** `Roof.ridgeAxis` ist hart auf `"x"|"y"` beschränkt (keine frei gedrehten Dächer im Datenmodell) und mehrere Winkel-Felder (`Column.rotation`, Text-/Bild-Rotation, Türschwenk) müssten bei einer Drehung mitgeführt werden — eine Rotation, damit „orthogonal zur Parzellengrenze" gezeichnet werden kann, ist ein eigener, grösserer und riskanterer Schritt (Datenmodell-Erweiterung nötig), bewusst nicht mitgemacht. +17 Tests (`geoRebase.test.ts`, `georef.test.ts`), Suite 820 grün.
|
||||||
|
- ✅ **Importierte Gebäude/Terrain sind jetzt anwählbare Meshes im 3D-Viewport (`e6a7738`):** Klick im WASM-3D-Viewport pickt Kontext-Meshes per Raycast (`raycast3d.ts`/`pickGeometry` in `toWalls3d.ts`), Auswahl bekommt einen eigenen Kanal (`selectedContextObjectIds`), Bounding-Box-Umriss als Highlight (volles Dreiecks-Wireframe wäre bei grossen Importen zu dicht), Entf-Taste löscht, `SitePanel`-Liste hebt die Auswahl hervor. **Bewusst NICHT umgesetzt:** die „auf einer Ebene"-Zuordnung (kein `categoryCode`/Layer-Feld an `ImportedMesh`/`TerrainMesh`, keine Sichtbarkeits-Kopplung an die Ebenen-Verwaltung) und keine volle Attribut-Bearbeitung (nur Name-Anzeige/Löschen, keine `deriveSelection()`-Integration ins Attribute-Panel — als zu gross für diesen Schritt eingeschätzt). **Ebenen-Zuordnung bleibt offen, ggf. eigener Design-Entscheid nötig** (eine feste „Import"-Ebene vs. frei zuweisbar).
|
||||||
|
- ✅ **Zwei echte Bugs im swissBUILDINGS3D-Pfad behoben (`35a6834`+`9705890`):** (1) Absturz bei riesigen Kacheln (JSZip „Invalid string length" — DXF komprimiert stark, eine Kachel unter dem 150-MB-Limit kann entpackt trotzdem >700 MB Text ergeben; nur `asset.size` aus der STAC-API zu prüfen reichte nicht, jetzt zusätzlich die JSZip-interne Grössenschätzung + genereller Try/Catch, übersprungene Kacheln landen sichtbar in `skippedTiles`/UI-Meldung statt stumm 0 Gebäude). (2) **Der eigentliche „Import funktioniert nicht"-Bug:** die installierte `dxf-parser`-Version liefert Polyface-Mesh-Face-Indizes als vier einzelne Felder (`faceA`/`faceB`/`faceC`/`faceD`, Gruppencodes 71–74) statt als `faces`-Array — unser Code prüfte auf das Array, das nie existierte, jede swissBUILDINGS3D-DXF-Kachel ergab dadurch 0 Dreiecke. Live gegen echte Kacheln verifiziert: jetzt tausende Dreiecke mit realistischen Höhenwerten. **Damit ist der Gebäude-Import technisch lauffähig — die beiden Design-Punkte oben (Georeferenzierung, Ebenen-Zuordnung) bleiben offen.**
|
||||||
|
|
||||||
|
- [ ] **3D-Kanten-Folgepunkte ("Schattiert mit Kanten", 2026-07-12)** — nach dem Fix der falschen Flächendiagonalen (`7dc8f0d`) zeigte der Nutzer drei weitere Beobachtungen, NOCH NICHT behoben:
|
||||||
|
- **Fenster-Rahmenecken überlappen statt sauber zu stossen** (Screenshot: doppelte/kreuzende Linien an den Blendrahmen-Ecken bei „fein"). Vermutlich KEIN Kanten-Rendering-Bug, sondern echte überlappende Geometrie — die Rahmen-/Sprossen-Riegel (`openingAxisBox` in `toWalls3d.ts`) sind separate Boxen ohne Gehrung/Union an den Stössen. **Nutzer-Folgewunsch dazu:** bei „fein" einstellbar machen, ob die Ecke vertikal-dominant, horizontal-dominant oder auf Gehrung gelöst wird.
|
||||||
|
- **Dach ebenfalls kein sauberer Verschnitt am First/Grat** (Screenshot: kleines Störtriangle exakt am First-Apex). Gleiche Kategorie — mehrere Dachflächen (`emitRoofs`) sind separate, nicht verschnittene Meshes.
|
||||||
|
- **Im OG viele vertikale Striche auf den Wandflächen** — noch NICHT diagnostiziert (nicht als Textur-Pipeline-Artefakt bestätigt, evtl. eine mehrschichtige Wandtyp-Verkleidung mit vielen dünnen Einzelelementen). **Braucht weitere Untersuchung**, idealerweise mit Angabe welcher Wandtyp/welche Schicht im betroffenen Projekt verwendet wird.
|
||||||
|
- Alle drei sind vermutlich Fälle für eine echte Boolean-Verschneidung (Kandidat: `csgrs`/`boolean_mesh`, bereits als WASM-Export vorhanden aus der truck-Integration, s. u.) statt nur Kanten-Heuristik — grösserer, eigener Task.
|
||||||
|
|
||||||
|
- [ ] **Warteschlange „für danach" (Nutzer 2026-07-12, noch nicht begonnen):**
|
||||||
|
- Luftbild/SWISSIMAGE wahlweise als Drapierung auf das Terrain-Mesh ODER als eigenständiges „Dokument" einfügbar (Bild/PDF generell als einfügbares Dokument-Objekt — noch keine Anforderungen präzisiert).
|
||||||
|
- Import-Mesh (swissBUILDINGS3D u. Ä.) wahlweise geglättet („gerundet") oder kantig/eckig belassen, wählbar beim Import oder am Objekt.
|
||||||
|
|
||||||
|
- [ ] **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:
|
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
|
||||||
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d` — `a78e7c7`
|
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d` — `a78e7c7`
|
||||||
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
|
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
|
||||||
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
|
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
|
||||||
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
|
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
|
||||||
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert.
|
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. ✅ **Phase 5 komplett (2026-07-07):** `opening` portiert (`wall_axis_length`/`opening_interval`/`wall_axis_frame`/`opening_jambs`/`opening_gap_quad`/`opening_center`/`door_symbol`/`window_symbol` + `along`-Helper, geflachte Signaturen nach PORT_PLAN; `openingVerticalExtent` bleibt bewusst TS — echte Modell-Kopplung an drawingLevels). +7 Parity-Tests (Suite 40 Parity). `cargo test` kernel2d 18/18, `build:kernel2d` sauber, vitest 348/348. Produktive Aufrufer unverändert (Phase 6 weiter offen/Nutzer-Entscheid). **Noch uncommittet.**
|
||||||
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
|
- [ ] Phase 6: TS-Fassade umstellen (alt → `kernel2d.legacy.ts`), Suite grün, `npm run build` + WASM sauber. **Achtung Laufzeit-Entscheid:** macht die Live-App synchron WASM-abhängig (Init + Per-Call-Marshalling) — heute nutzt die App KEINE WASM-Geometrie zur Laufzeit; vor Umstellung mit Nutzer klären.
|
||||||
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
|
- **Join-Durchstich verworfen (2026-07-05, gemessen):** `computeJoins` live auf WASM zu legen lohnt NICHT. Benchmark TS vs. WASM inkl. JSON-Marshalling (Median ms/Aufruf, 200 Iter): 6 W → TS 0.018/WASM 0.022 · 20 → 0.027/0.043 · 50 → 0.082/0.102 · 120 → 0.331/0.246 · 300 → 1.68/0.67. Crossover erst ~100+ Wände; realistische Plan-/Geschossmengen liegen darunter → TS schneller, und selbst 300 Wände sind mit TS 1,7 ms (nicht wahrnehmbar). Marshalling frisst den Rust-Vorteil, plus dauerhafte Rust↔TS-Paritätspflicht. **Fazit:** reines TS behalten; WASM für Joins nicht weiterverfolgen. WASM lohnt erst bei echten Rechen-Hotspots (Booleans/Tessellierung).
|
||||||
|
|
||||||
@@ -43,12 +76,29 @@
|
|||||||
|
|
||||||
## ⏭️ Als Nächstes
|
## ⏭️ Als Nächstes
|
||||||
|
|
||||||
|
- [ ] **BIM-Elemente „wirklich 1:1" (Tür/Fenster/Dach/Decke)** — volle Studie + Priorisierung: **[docs/design/bim-elements-depth-study.md](docs/design/bim-elements-depth-study.md)** (623 Zeilen, Referenzmatrix VW/ArchiCAD/Revit/Allplan gegen IST). ✅ **Bereits erledigt 2026-07-10:** Fenster-Schachtelung Blendrahmen→Flügelrahmen→Glas + fest/öffenbar + Einbaulage 2D (`9a38636`); Mansard-Untertypen Giebel/Walm/Zelt + Knick (`bfb80b3`); Dach-Dicke 3D (`e99bb24`) + **Dach-Schichtlogik RoofType/Layer[]** (`3a986ec`); zweiflügelige Türen leafCount 2D+3D (`4319e12`); getrennter Traufe/Ortgang-Überstand (`c9baff5`); Glas/glazingPanes 2D+3D + Rollladenkasten (`2e13ec3`/`d131683`). **Offen (nach Doc-Priorität):**
|
||||||
|
- [x] ~~**Decken-Aussparungen**~~ — **erledigt 2026-07-10 (`37f4fe8`+`cdbef99`+`aee8846`+`83dcdd0`):** `Ceiling.openings?: Vec2[][]` + **Brücken-Trick** (`mergeHoles` in `src/geometry/polygonHoles.ts`: Löcher über schmale Brücken in den Aussenring → EIN einfacher Ring, paritätskorrekt für SVG/Hatch/Ear-Clipping/Pick — KEIN Loch-Support in den Primitiven nötig, kein Rust-Change). 2D-Poché-Loch (Brückenkanten via noStrokeEdges) + 3D-Slab-Loch; Befehl „Deckenloch" (Aliase deckenloch/aussparung, BIM-Ribbon); Panel listet Aussparungen mit Entfernen. +11 Tests.
|
||||||
|
- [x] ~~**Dach in 2D-Aufsicht + Vertikalschnitt**~~ — **erledigt 2026-07-10 (`9a00c8e`+`f248e2a`):** Grundriss-Aufsicht trägt die Ansichts-Schraffur (viewHatchId) der Eindeckungs-Schicht; Vertikalschnitt via `appendRoofSections` (analytischer TS-Schnitt: Cyrus–Beck gegen die Aufsichts-Polygone, lineare Oberkante, Dicke/cos(Neigung), Schicht-Bänder aussen→innen mit Bauteil-Schraffur). **Offen:** Ansichts-/Silhouettenkanten des Dachs in Elevationen (nur Schnitt-Polygone bisher).
|
||||||
|
- [x] ~~**RoofType-ResourceManager-Tab**~~ — **erledigt `83abc1d`** (RoofStylesTab auf LayeredStylesTab-Rumpf; anlegen/editieren/löschen, Löschen geschützt bei Verwendung).
|
||||||
|
- [ ] **Decken-Randschicht-Override** (`Ceiling.edgeOverride`, Ringzone anderer Aufbau, Tropfkante/Randdämmung) — P1; **Deckentrenn-Werkzeug** für thermisch getrennte Auskragung (Isokorb, UI-only) — P1.
|
||||||
|
- [ ] **Tür 1:1 Rest:** `glazingRatio` (Teilverglasung), Kassettentür-Geometrie, `frameKind` im 3D (Zarge vs. Blockrahmen), echtes Schwellenprofil; Alt-`Door[]`-Pfad in `Opening` konsolidieren (technische Schuld, zwei Datenwege).
|
||||||
|
- [ ] **Fenster 1:1 Rest:** asymmetrische Rahmenbreiten, Bank/Nische, Laibungsverkleidung, Form (Rund/Spitz/Schräg). ~~echtes Sprossengitter~~ ✅ **erledigt 2026-07-12 (`00bff27`):** `mullionCols` (vertikale Sprossen-Spalten) spiegelbildlich zu `mullionRows` — 3D-Rahmen-Riegel, Ansichts-Trennlinien + Glasscheiben als echtes rows×cols-Raster, Eingabefelder in `OpeningEditorDialog`/`ResourceManager`.
|
||||||
|
- [ ] **Dach 1:1 Rest:** Kniestock/Drempel, Krüppelwalm, Kehlen bei L-Grundriss (Straight-Skeleton), Gauben/Dachfenster, Aufschieblinge. ~~Dach im Vertikalschnitt~~ ✅ `f248e2a`.
|
||||||
|
- [ ] **Geneigte Decke** (Rampe/Gefälle), Deckenspiegel (zweite abgehängte Fläche) — P2/P3.
|
||||||
|
- **Nicht bauen** (Doc §6): VW-Massketten-Apparat, volle Sichtbarkeitsmatrix, Eck-Fenster/-Dach generisch, Beschlags-Produktbibliothek, IFC-Void-Semantik nachrüsten.
|
||||||
|
|
||||||
|
- [ ] **`make2D`-Befehl (Sicht → 2D-Zeichnung mit Füllungen)** (Nutzer-Wunsch 2026-07-10). Aus der AKTUELLEN Sicht — egal ob Grundriss, Schnitt oder 3D — eine flache 2D-Zeichnung aus reinen 2D-Geometrien erzeugen (Linien + Füllungen/Schraffuren, „mit allem"). Zwei Ausgaben: (a) als neue `Drawing2D`-Elemente ins Modell einfügen (auf einer Ziel-Ebene), ODER (b) in die Zwischenablage kopieren (SVG/DXF-Fragment) zum Einfügen anderswo. Vorbild: Vectorworks „2D-Darstellung erzeugen" / Rhino `Make2D`. **Bausteine vorhanden:** Grundriss/Schnitt laufen bereits über `generatePlan()`/`generateSectionPlan()` → RScene → SVG (`sceneToPrintSvg`); für 3D braucht es eine Projektion (HLR/Silhouette) der `projectToModel3d`-Meshes auf die Bildebene (neuer Teil). MVP: Grundriss/Schnitt → Drawing2D + Clipboard; 3D-Projektion als zweite Phase. Scope/Format (SVG vs. DXF vs. native Drawing2D) mit Nutzer schärfen.
|
||||||
|
|
||||||
|
- [x] ~~**Dächer: Auswahl + Attribut-Editieren + Löschen**~~ — **erledigt (`80121a3` + Folge-Commits `7cfdf59`/`eaf57e2`/`64f6179`/`826685c`):** Klick-Auswahl (Traufe-Pick-Polygon + pickRoof), `selectedRoofIds`/`updateRoof`/`RoofInfo`/`roofSelection`; RoofSection-Panel voll editierbar (Form, Firstrichtung X/Y, Neigung(en), **Breite/Tiefe**, Überstand, Dicke, **Traufhöhe**) + Firsthöhe/Fläche read-only; **Auswahl-Hervorhebung im 2D UND 3D** (Draht-Umriss); Löschen; Abwählen an ALLEN Reset-Stellen. **Optional Folge:** ~~3D-Griffe zum Ziehen (Traufe/First)~~ ✅ **erledigt `4ef40a0`** (Eckpunkt-Resize + Verschieben + First-Griff für Neigung). **Noch offen:** Dachfenster, Kehlen bei nicht-rechteckigem Grundriss (Straight-Skeleton).
|
||||||
|
|
||||||
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + +-Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
|
- [ ] **Ribbon-UI + modulare Bars** (Nutzer-Vision 2026-07-05, bestätigt: Tab-Schema **2D·3D·BIM·Ansichten**). Voller Plan + datengetriebene Architektur: **[docs/design/ribbon-ui-plan.md](docs/design/ribbon-ui-plan.md)**. Ribbon-Oberleiste mit Tabs ersetzt die Werkzeug-Sidebar; datengetriebene Registry (RibbonItem = tool|command|action) ermöglicht auch eine **modulare Custom-Bar** (gleiche Items, vom Nutzer gewählt). Attribute bekommen volle Höhe, Objektinfo darunter gemergt; XYZ-Box oben rechts bleibt. **Phasen:** ~~(1) Gerüst + 2D/BIM-Tab~~ ✅ (`9d6e86d`), ~~(2) TopBar → Ansichten-Tab mergen~~ ✅ (`85011cb`), ~~(2b) Tabs in die TopBar-Zeile~~ ✅ (`456ebc8`), ~~(2c) OCS-Chrome (kleine Wortmarke + Quick-Access-Icons statt Burger, Zeile 26px)~~ ✅ (`4e3b074`), ~~(2d) Text + Ansichten auf eine Leiste, „Ansichten" als Standard-Tab zuerst~~ ✅ (`fe22cbf`), (3) **teilweise** ✅: Werkzeug-Sidebar aus Default-Layout raus + Attribute volle linke Höhe + Wandtyp-/Deckentyp-Picker ins Attribute-Panel verschoben (Nutzer-Entscheid), `LAYOUT_VERSION`→8; ~~**offen:** Objektinfo unter Attribute mergen~~ ✅ (`3a2cef3`): element-spezifische Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) + Wand-Referenzlinie ins Attribute-Panel verschoben; ObjectInfo trägt nur noch Bezugspunkt + Masse (Nutzer-Wunsch). ~~(4) modulare Custom-Bar~~ ✅ (Tab „Eigene" + +-Picker, localStorage-persistiert). **Offen:** 3D-Tab füllen (aktuell leer; ggf. 3D-spezifisch statt Doppelung mit Ansichten), Band-Höhe/Abstände + 26px-Zeile visuell im Tauri abnehmen, Dauer-Zoom-Anzeige (Statusleiste?) klären. ✅ Vorarbeit: Eigenschaften-Grid im OCS-Stil (`00733d8`), Kreis+Bogen-Werkzeuge (`e454eab`/`bd2b12b`). **Nächster Schritt: im Tauri visuell prüfen (Band-Höhe, Icons, Aktiv-Highlight), dann Phase 2/3.**
|
||||||
|
|
||||||
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~ — **erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
|
- [x] ~~**SPIKE — Bild-Texturen in `render3d`**~~ — **erledigt `0ca3b1d`** (verifiziert 2026-07-05): `RenderStyle::Textured` real, prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), UVs planar in Metern, Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`), `spike3d` per `T` umschaltbar. Alt-Vertexpfad `[pos,normal,color]` bitgleich (Regressionstest). `cargo test` 58 grün (59 mit `--features render`, inkl. naga-Test); `spike3d`-Build sauber; keine neuen Deps, Default-Build unverändert. Auftrag: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md). **Lücken bis „richtig gutes" Texturing → siehe 3D-REST unten.**
|
||||||
|
|
||||||
## 📋 Backlog (Priorität grob absteigend)
|
## 📋 Backlog (Priorität grob absteigend)
|
||||||
|
|
||||||
|
- [x] ~~**Shift-Ortho beim Körper-Verschieben (2D) fehlte**~~ — **erledigt 2026-07-06:** Nutzer-Report — Shift zum H/V-Einrasten wirkte beim Ziehen eines Vertex-Griffs (`onGripMove`) und beim freien Kanten-Zug (`onEdgeMove`), aber NICHT beim Verschieben eines ganzen Elements per Körper-Griff (`onMoveBody` — Wand/2D-Element/Decke/Treppe/Raum als Ganzes): `PlanView.tsx` reichte `mods` an dieser einen Stelle schlicht nicht durch. Fix: `GripHandlers.onMoveBody` bekommt optionales drittes Argument `mods: ToolMods`, `App.tsx` zwingt bei `mods.shift` das Delta auf die dominante Achse (H/V) — dieselbe Regel wie beim freien Kanten-Zug. `tsc`/`vitest` 339/339 grün.
|
||||||
|
- [ ] **Feld-Controller (getippte Länge/Winkel relativ zum Ausgangspunkt) fehlt beim Körper-Verschieben + im 3D.** Nutzer-Wunsch 2026-07-06: „allgemein brauchen wir ein System bei 2D- und 3D-Elementen, dass wenn man einen Punkt anwählt, man ihn verschiebt — mit der Option, Länge und Winkel relativ zur alten Position zu wählen." Bestandsaufnahme: **existiert bereits** für den 2D-Vertex-Griff-Drag (`gripEditRef`/„Feld-Controller", Tab öffnet/zykelt Länge↔Winkel-Sperre, `gripEditPoint` in `App.tsx`) — fehlt aber (a) beim 2D-Körper-Verschieben (`onMoveBody`, ganzes Element ziehen) und (b) komplett im neuen 3D-Griffsystem (`Wasm3DViewport.tsx`, s. „3D-Griffe/Editieren" oben — weder Vertex- noch Verschiebe-Griff haben dort eine Locks/HUD-Eingabe). Für (b) zusätzlich zu klären: wie eine getippte Zahl im 3D-Viewport erfasst wird, ohne mit der freien Maus-Navigation (Orbit/Pan) zu kollidieren — eigenes UI-Element (z. B. ein kleines Eingabefeld neben dem gezogenen Griff-Button) naheliegend, aber nicht mit dem 2D-HUD-Muster identisch übertragbar. Mit Nutzer Scope/Reihenfolge klären (2D-Körper zuerst, da mechanische Erweiterung des bestehenden Feld-Controllers; 3D danach als grössere UI-Frage).
|
||||||
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
|
- [x] ~~**Zeichenwerkzeuge ergänzen: Kreis + Bogen.**~~ — **komplett erledigt (2026-07-05):** beide Werkzeuge + Center-/Quadrant-Snaps + WebGL-Sichtbarkeitsfix (`2c8ad8f`). Details in den Unterpunkten:
|
||||||
- [x] ~~**Kreis-Toolbar** (trivial)~~ — **erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
|
- [x] ~~**Kreis-Toolbar** (trivial)~~ — **erledigt `e454eab` (2026-07-05):** `ToolId`+`"circle"`, Platzhalter `circleTool` (nicht floorOnly), `TOOL_COMMAND`+`TOOL_ORDER`, Kreis-Icon in `ToolsPanel`, i18n `tool.circle`/`tool.circle.hint`. tsc + Suite 331 grün. (Kreise rendern seit `4ac99d3` glatt als `<circle>`.)
|
||||||
- [x] ~~**Bogen-Werkzeug** (mittel)~~ — **erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
|
- [x] ~~**Bogen-Werkzeug** (mittel)~~ — **erledigt (2026-07-05):** `arcCommand` in `src/commands/cmds/arc.ts` (3-Klick: Mittelpunkt → Start/Radius → Endwinkel, CCW; Vorschau via `arcPts`/`circlePts`), registriert in `registry.ts` (Alias `a`/`bogen`), `"arc"` als ToolId + Toolbar-Eintrag (Bogen-Icon) + i18n. tsc + Suite 331 grün. ✅ **Center-/Quadrant-Snaps ergänzt (2026-07-05):** `collectCircles` + Snap-Block in `snapping.ts` — Mittelpunkt + Quadranten (Kreis: alle 4; Bogen: nur im Spannbereich) unter der `center`-Einstellung, Bogen-Endpunkte unter `endpoint`; +3 Tests (Suite 334).
|
||||||
@@ -69,18 +119,60 @@
|
|||||||
|
|
||||||
- [x] ~~**Ebene-Schraffur editierbar**~~ — **gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
|
- [x] ~~**Ebene-Schraffur editierbar**~~ — **gelandet `dcb6ed5`**: Kategorie-Dialog (`App.tsx`, `editor.hatch`-Feld) hat `<select>` auf `cat.hatch` + `HatchSwatch`-Vorschau. Verifiziert vorhanden.
|
||||||
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
|
- [ ] **GEO-BLOCK** (gemeinsame Dateien io/geoContext/swissTopo/terrain/ContextImportDialog/Viewport3D/siteSlice):
|
||||||
|
- ✅ **swissBUILDINGS3D 1:1 + swissALTI3D-Terrain erledigt 2026-07-12 (`ed724be`):** Root Cause war `swissTopo.ts::fetchBuildings` (generalisierter 1:25'000-Kartografie-Layer, nur Grundriss, `DEFAULT_BUILDING_HEIGHT=9`-Pauschalkiste) + grobe `profile.json`-Terrain-Näherung. Fix: neues `stacApi.ts` (gemeinsamer STAC-Client) + `swissBuildings3d.ts` (echte Wände/Dach-Meshes aus swissBUILDINGS3D-DXF-Kacheln, Generation **2.0 (stabil) UND 3.0 (Beta)** wählbar, über den bestehenden `dxfParser.ts` eingelesen — kein neuer Parser nötig) + `swissAlti3d.ts` (echtes swissALTI3D-Höhenraster, **0.5 m/2 m** wählbare Punktdichte statt Näherung). `ContextImportDialog.tsx` entsprechend erweitert (Gebäude-Modus aus/vereinfacht/2.0/3.0, Terrain-Auflösung). Referenz war das Rhino-Vorgänger-Plugin (`git.kgva.ch/karim/DOSSIER`, `rhino/swisstopo.py`). Pipeline nutzt **render3d** (nicht Three.js, Nutzer-Entscheid 2026-07-12 „scheiss auf three.js darstellung render3d ist fokus").
|
||||||
|
- ~~**Nordstern-Geo-Rendering**~~ — **war bereits erledigt** (`35299307d`, 2026-07-09, stand hier fälschlich noch als offen): `emitMeshes` in `toWalls3d.ts` liest `project.context` (`importedMesh`/`terrainMesh`) bereits und speist sie in `projectToModel3d` ein — keine weitere Verdrahtung nötig, war Voraussetzung für den swissBUILDINGS3D-Fix oben und stand schon.
|
||||||
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
|
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
|
||||||
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
||||||
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
|
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
|
||||||
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
|
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D bei manuellem Import — der Mesh-Pfad selbst ist über `dxfParser.ts` schon 3D-fähig, s. o.); Building-Draping; höhere DTM-Auflösung.
|
||||||
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
|
|
||||||
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~ — **bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
|
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~ — **bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
|
||||||
- [ ] **Einstellungs-Fenster (Rest):** Verdrahtung ist **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`). **Offen bleibt nur:** Snap/Endpunkt-Default = ❓„aki" (s.u., derzeit Sora #5FA1C9) — reine Farbentscheidung des Nutzers.
|
- [x] ~~**Einstellungs-Fenster (Rest)**~~ — **erledigt:** Verdrahtung war schon **da** (`viewSlice.snapColor`/`marqueeColor` → PlanView `SnapMarker`/Marquee, Defaults aus `theme/accents.ts`, Projekt-MüM-Feld `referenceElevationMasl`); der einzig offene Punkt (Snap/Endpunkt-Default „aki") ist längst entschieden (2026-07-04: Sora #5FA1C9, s. „❓ Offene Rückfragen"). Zeile war stehen geblieben, obwohl die Frage schon geschlossen war.
|
||||||
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||||
|
- [x] ~~**Tragwerk-Start: Stützen (Column) — MVP end-to-end**~~ (DOSSIER-Audit A4) — **erledigt 2026-07-07:** `Column`/`ColumnProfile` (rect/round) als platzierte Profil-Extrusion, `Project.columns`, `columnFootprint()` (gemeinsame Geometrie 2D/3D/Selektion/Transform), `columnVerticalExtent` (UK/OK-Anker wie Wand). Platzieren via BIM-Ribbon-Befehl (`column`, Alias stütze; Default Rect 0.3×0.3, Höhe=Geschosshöhe, Rechteck/Rund-Toggle + Live-Vorschau), 2D-Poché auf Kat. 50 (`addColumnPoche`, Component/Hatch-Auflösung), 3D-Prisma (`emitColumns`, synchron, Rust unberührt), eigener Selektionskanal (ALLE Reset-Stellen gespiegelt), `ColumnSection` (Profil/Masse/Höhe/Drehung editierbar), Löschen/move/copy/mirror, DXF-Layer, i18n. +8 Tests (2D-Position/Rotation, 3D-Box/Prisma, vertikale Lage). tsc/vitest 422. **Bewusst ausgelassen:** 3D-Pick (wie ExtrudedSolid), Marquee, Schedule-Zeile, eigene ToolId (Extrude-Muster gefolgt). **Noch uncommittet. Nutzer prüft 3D/2D visuell.**
|
||||||
|
- [x] ~~**Schnellexport-Dialog (Format + Dateiname) für Topbar-Export**~~ (Nutzer-Wunsch) — **erledigt 2026-07-07:** Klick auf CSV/IFC/OBJ/STL im Topbar-Export-Menü öffnet jetzt `ExportSaveDialog` (Format-Dropdown + Dateiname-Feld, Endung folgt dem Format, Enter/Speichern), statt sofort mit Default-Namen zu laden. PDF/DXF behalten ihre eigenen Options-Dialoge. `runExport(format, filename)` in App.tsx erzeugt+lädt. +i18n `exportSave.*`. tsc/vitest 434. **Offen (bewusst, Nutzer nannte es selbst als Folge):** echter Speicherort-Picker („wo") braucht den nativen Tauri-Save-Dialog (plugin-dialog/fs, Rust) — aktuell Download-Ordner + frei wählbarer Dateiname; die späteren „Ausschnitte" bekommen vordefinierten Namen+Ort. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Nativer Tauri-Speichern-Dialog für Exporte**~~ (Nutzer „ausnahmsweise JA") — **erledigt 2026-07-07:** `@tauri-apps/plugin-dialog`+`plugin-fs` eingebunden (Cargo/lib.rs/capabilities `dialog:allow-save`+`fs:allow-write-text-file`, `cargo check` grün), Util `src/io/saveFile.ts` (`saveTextFile`: unter Tauri nativer „Speichern unter"-Dialog `save()`+`writeTextFile`, sonst Blob-Fallback mit octet-stream + verzögertem revoke; +2 Tests). App.tsx-`downloadTextFile` ruft jetzt `saveTextFile` (Import ergänzt). **Nutzer testet den nativen Dialog in Tauri.** **Scope-Hinweis:** falls fs-Scope-Fehler, `fs:scope` `$HOME/**` nachrüsten. **Noch uncommittet.**
|
||||||
|
- [x] ~~**Ausschnitte liessen sich nicht speichern (Tauri)**~~ — **erledigt 2026-07-08:** Ursache `window.prompt` ist im Tauri-WKWebView DEAKTIVIERT (→ null, kein Name, nichts gespeichert). `ViewSnapshotsPanel` nutzt jetzt In-App-Inline-Eingaben statt prompt/confirm (Speichern + Umbenennen inline, Löschen ohne confirm). tsc/vitest 465.
|
||||||
|
- [x] ~~**Layouts als Ordner-/Baumstruktur**~~ — **erledigt 2026-07-08:** Modell additiv `LayoutFolder{id,name,parentId?}` + `Layout.folderId?` + `Project.layoutFolders?`; freie Grösse `customWidthMm?`/`customHeightMm?` an Layout UND MasterLayout (übersteuert paper/orientation), zentraler Helfer `effectiveSheetSizeMm()`. Pure Logik `layoutModel.ts`: `buildLayoutTree`, `collectFolderLayouts`, `deleteFolder` (Kinder reparent auf Elternebene), `folderPdfPages`. `LayoutsPanel.tsx` als Baum (auf/zuklappbare Ordner, Master als eigener Vorlagen-Abschnitt), EIN „+"-Dropdown {Ordner·Layout·Masterlayout}, neues Element landet SOFORT im Inline-Rename (ViewSnapshotsPanel-Muster, kein window.prompt). Zwei Erstell-Dialoge (`LayoutCreateDialogs.tsx`): Layout mit Master-Vorlage-Dropdown ODER freie Grösse (A4/A3/Custom-mm); Masterlayout mit Format/freie Grösse+Ausrichtung. **Ordner→Mehrseiten-PDF funktioniert:** `src/export/layoutPdf.ts` (`buildFolderPdf`/`saveFolderPdf`, jsPDF, `addPage` je Layout mit eigener Grösse, Viewports via generatePlan→planToPrintSvg, Master-Titelblock als Vektor), neuer `saveBinaryFile` in `saveFile.ts` (nativer Speichern-Dialog für PDF-Bytes). +14 Tests (Custom-Grösse, Ordner-CRUD, Baumaufbau inkl. verwaister Refs, Reparenting, PDF-Seitenplan). **Nachtrag (Orchestrator):** `LayoutSheet.tsx` (In-Viewport-Editor, vom Agent bewusst nicht angefasst) nutzte noch `sheetSizeMm(layout.paper,...)` ohne Custom-Grösse zu respektieren — auf `effectiveSheetSizeMm(master ?? layout)` umgestellt (identische Regel wie der PDF-Export), damit Blätter mit freier Grösse auch im Viewport korrekt dargestellt werden. tsc/vitest 505. **Noch uncommittet. Nutzer prüft visuell in Tauri** (+-Menü, Ordner-Zuordnung, Dialoge, Mehrseiten-PDF-Reihenfolge/Grössen).
|
||||||
|
- [x] ~~**Icons/A0-A6+B0-B6/aktiver Ordner/Drag&Drop im Layouts-Baum**~~ — **erledigt 2026-07-08:** eigene inline-SVG-Icons (`FolderIcon` auf/zu, `LayoutSheetIcon`, `MasterSheetIcon` mit Stern-Akzent), Muster wie die bestehenden Tab-Icons. `LayoutPaperFormat` volle ISO-216-Reihe 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).
|
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||||
- [x] ~~**Linienstile aufräumen**~~ — **erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line`→`thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive`→`thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
|
- [x] ~~**Linienstile aufräumen**~~ — **erledigt (2026-07-05):** die drei funktional identischen 0.13-Volllinien (`thin`/`hatch-line`/`joint-massive` — alle weight 0.13, `dash:null`, gleiche Farbe) auf EINE kanonische „Volllinie 0.13" (`thin`) zusammengeführt; alle weight-only-Stile klar als „Volllinie X.XX" benannt; Schraffur-Referenzen (`hatch-line`→`thin`, inkl. `ResourceManager.patToHatches`) + Schichtfugen (`joint-massive`→`thin`) remappt; `hatch-dash` (einzige gestrichelte) bleibt. Ids stabil gelassen (geladene Projekte + interne Refs bleiben heil; Rendering-Gewichte unverändert). 7→5 Stile. tsc + Suite 331 grün.
|
||||||
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
- [ ] 2D-Plan z-Anordnen (Kombo-Schraffuren); Bild-Schraffur GL/DXF (heute Fallback).
|
||||||
- [ ] **AUDIT (DOSSIER-Studie):** A1 Override-Regel-Engine; A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
- [ ] **AUSSCHNITTE- + LAYOUT-SYSTEM (Nutzer-Vision 2026-07-07, grosser Hebel).** Zwei zusammenhängende Systeme (DOSSIER A2→A3, ROADMAP §7 Phase 3 + §11 Pläne):
|
||||||
|
- **Ausschnitte / View-Snapshots (A2):** benannte, gespeicherte Ansichten, die einen kompletten Darstellungszustand einfangen und wiederherstellen. **Kern-Einsicht des Nutzers: das muss NICHT neu erfunden werden — die Bausteine existieren schon und werden nur KOMPONIERT:** ein Ausschnitt = Referenz/Snapshot auf {**Ebenenkombination** (`src/state/visibilitySets.ts` `LayerCombo`) + **Zeichnungsebenen-Kombination/-Einstellungen** (`DrawingCombo` ebd.) + aktive **Overrides**-Regelmenge (`Project.overrideRules`, A1) + Kamera/Ansichtstyp/View3d + **Massstab** + **Detailgrad**}. Also im Wesentlichen ein Container, der diese bereits vorhandenen Zustände bündelt, benennt (Ordner/Presets) und per Klick wiederherstellt. Speicherort: Projekt (nicht localStorage — Ausschnitte gehören zum Dokument). Voraussetzung für die halbe Pläne-Tabelle (Layouts, Multi-Page-PDF, Detail-Bindung, Massstab-pro-Viewport).
|
||||||
|
- **Layout-System mit Masterlayout + Layouts (A3):** Druck-/Plan-Blätter (A4/A3-Papierformate), auf denen mehrere **Details/Viewports** platziert werden, jedes an einen Ausschnitt-Snapshot gebunden (bei „Alle aktualisieren" ziehen sie nach). Ein **Masterlayout** (gemeinsame Elemente: Titelblock/Planrahmen/Logo/Plankopf, wie Vorlagen-Master in InDesign/ArchiCAD) wird von den einzelnen **Layouts** geerbt/überlagert — Änderung am Master schlägt auf alle Layouts durch. Pro Layout eigener Massstab pro Viewport (Auto-DPI, Plotweight-/Schraffur-Skalierung, `sceneToPrintSvg.ts` als Basis), PDF-Export pro Blatt bzw. Multi-Page.
|
||||||
|
- **Erweiterte Kamera-Settings als Teil des Ausschnitts (Nutzer 2026-07-07):** mehr einstellbare Kamera-Parameter, die der Ausschnitt MIT speichert — u. a. **Zielhöhe** und **Augenhöhe** (Eye/Target-Z getrennt), FOV, Blickrichtung/Distanz; die Parameter dürfen **pro Projektionsart unterschiedlich** sein (Perspektive vs. Isometrie/Ortho haben je eigene sinnvolle Sets — Iso braucht z. B. orthoHalfHeight statt FOV/Augenhöhe). Heutiger Zustand: nur FOV global (`fov` in App-State, CameraMenu) + Orbit-State intern im `Wasm3DViewport` (`OrbitState` yaw/pitch/dist/target, `orthoHalfHeight`) — nicht als benannte, persistierbare Kamera-Definition nach aussen geführt. Schritt dazu: Kamera-Zustand als serialisierbares Objekt exponieren (get/set am Viewport), UI-Felder (Augenhöhe/Zielhöhe/Distanz) im Kamera-Popover, dann im Ausschnitt-Modell mitführen.
|
||||||
|
- **Phasen-Vorschlag:** (1) Ausschnitt-Datenmodell + Speichern/Wiederherstellen (komponiert die bestehenden Combos/Overrides/View-State) + Ausschnitte-Panel (Liste/Ordner, wie `visibilitySets`-Muster). (2) Masterlayout + Layout-Blatt-Modell + Viewport-Platzierung, Viewport→Ausschnitt-Bindung. (3) Master-Vererbung + „Alle aktualisieren" + Multi-Page-PDF@DPI. Mit Nutzer Detailgrade/Papier-Defaults/Ordnerstruktur festlegen.
|
||||||
|
- [ ] **SCHNITTEBENEN als editierbare, gekoppelte Objekte (2D↔3D) — Nutzer-Vision 2026-07-07 „ein richtig ausgereiftes Tool".** Schnittebenen sollen sichtbare, im Grundriss platzier- und editierbare Objekte sein, die 2D-Schnitt und 3D-Live-Schnitt verbinden. Bausteine der Vision (verbindlich festhalten):
|
||||||
|
- **Eigene Zeichnungs-/Grafik-Ebene „Schnittebenen":** die Schnittlinie des 2D-Schnitts liegt auf einer eigenen Ebene (ein-/ausblendbar wie andere Layer). Auch die 3D-Schnittebene wird im GRUNDRISS als Objekt dargestellt (Linie/Symbol) — vor allem, um die 3D-Schnittebene dort PRÄZISE zu setzen (im 2D positionieren statt im 3D fummeln).
|
||||||
|
- **3D-Schnittebene über Topbar ein/aus** (existiert teilweise: Viewport-Toggle) + **Farbe**; alles HINTER der Ebene (die weggeschnittene Seite) wird mit einer **Misch-/Andeutungsschraffur umhüllt**, damit visuell klar ist, dass dieser Teil abgeschnitten wird — sowohl im 3D als auch als Vorschau im 2D.
|
||||||
|
- **Doppelklick auf eine Schnittebene → springt in die zugehörige Schnitt-Ansicht:** 2D-Schnittebene-Symbol → 2D-Schnittfenster; 3D-Schnittebene-Symbol → 3D-Schnittfenster (Perspektive/Ansicht mit gesetzter Clip-Ebene). Zwei Objektarten, zwei Zielansichten, gleiche Interaktion.
|
||||||
|
- **3D-Schnitt-POV-Parameter:** die 3D-Schnittebene definiert zusätzlich Blickpunkt (POV), **Augenhöhe** + **Zielhöhe** (getrennt) und **Blickwinkel** — dieselben erweiterten Kamera-Settings wie im [Ausschnitte-System oben] (dort pro Projektionsart mitgespeichert). Ein Schnitt ist damit ein Ausschnitt mit Clip-Ebene + Kamera-Definition.
|
||||||
|
- Verknüpfung: baut auf dem bestehenden 2D-Schnitt (`DrawingLevel kind:"section"`, `linePoints`/`directionSign`) + der 3D-Live-Schnittebene (`03f0c40`, Clip-Uniform) auf; koppelt eng mit dem Ausschnitte-/Layout-System (View-Snapshots) und den erweiterten Kamera-Settings. Grosses, phasenweises Feature — mit Nutzer Reihenfolge/Scope festlegen (zuerst: 3D-Schnittebene als 2D-Grundriss-Objekt platzier-/editierbar + Doppelklick-Navigation).
|
||||||
|
- [ ] **AUDIT (DOSSIER-Studie):** ~~A1 Override-Regel-Engine~~ ✅ (2026-07-07, s. Erledigt); A2→A3 View-Snapshots → Print-Layout-Blätter (PDF pro Blatt); A5 reichere Öffnungen; B1 Text-Werkzeug (+ Kreis-Tool → Shortcuts 1&3); A4 Tragwerk; B2 Object-Info numerisch. (A6 Bauteil-Schedule-CSV erledigt; D2 volles Element-Set erledigt 2026-07-07.) Belege in `/tmp/dossier-ref/rhino/*.py`.
|
||||||
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||||
|
|
||||||
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
|
- [ ] **TEAMWORK — kollaboratives Bearbeiten (Supabase self-hosted).** Projekte auf einem self-hosted Supabase-Stack speichern; mehrere Nutzer bearbeiten dasselbe Projekt gleichzeitig. Kernkonzept: **pessimistisches Object-Locking** (wie Revit Worksharing / ArchiCAD Teamwork) — alle Objekte sind zunächst gesperrt; ein Nutzer *reserviert* die Elemente, die er bearbeiten möchte (exklusiver Schreibzugriff), und *gibt sie frei*, sobald er fertig ist. Freigabe → sofort für alle anderen sichtbar via Supabase Realtime (WebSocket-Kanal). Kein Merge-Konflikt nötig, weil niemals zwei Nutzer dasselbe Objekt gleichzeitig schreiben. Grobe Schichten:
|
||||||
@@ -92,11 +184,17 @@
|
|||||||
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
|
- **Umfang/Granularität TBD mit Nutzer:** Object = Wand/Raum/Drawing2D-Element? Geschoss? Layer? Feinere Granularität = mehr Parallelarbeit, aber komplexeres UI.
|
||||||
- **Aufwand:** gross (2–4 Wochen echte Arbeit). Erst sinnvoll, wenn Kern-CAD-Features stabil. Mit Nutzer Granularität + Hosting-Setup klären bevor Implementierung startet.
|
- **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)
|
### 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.
|
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~ — **bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
|
||||||
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
- [x] ~~**Ortho-Ray-Picking**~~ — **erledigt 2026-07-07:** `cameraRay` (`src/viewport/raycast3d.ts`) war IMMER ein perspektivischer Pinhole-Strahl (ein Ursprung `eye`, Richtung variiert je Pixel) — für Front/Top/Side/Iso-Presets (die render3d orthografisch zeichnet) nur eine Näherung. Fix: `cameraRay` nimmt jetzt optional `perspective`/`orthoHalfHeight` (neue optionale Felder auf `RayCamera`, rückwärtskompatibel — fehlt `perspective`, bleibt das Verhalten exakt wie vorher) und baut bei `perspective:false` echte PARALLELE Strahlen (Ursprung wandert lateral mit dem Pixel, Richtung immer `f` — exakte Umkehrung von `worldToScreen`s bereits vorhandenem Ortho-Zweig). Beide Aufrufer in `Wasm3DViewport.tsx` (Klick-Pick + Griff-Drag) reichten schon das volle `orbitCamera(o)`-Objekt (inkl. `perspective`/`orthoHalfHeight`) durch — der Bug sass rein in `cameraRay`, keine Änderung an den Aufrufstellen nötig. +2 Tests (`raycast3d.test.ts`: parallele Strahlen, Rückwärtskompatibilität ohne `perspective`-Feld). `tsc`/`vitest` 341/341 grün.
|
||||||
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
- [x] ~~**3D-Griffe/Editieren im nativen render3d/wasm-Pfad**~~ — **erledigt 2026-07-06:** existierte bereits vollständig im three.js-Fallback (`Viewport3D.tsx`/`drawGrips`), fehlte aber im nativen wasm-Pfad (`Wasm3DViewport.tsx`, seit `WASM_ENGINE_ACTIVE` der Standard-Renderer) — genau wie beim drawings2d-z0-Fund oben eine „existiert, aber auf dem falschen Pfad"-Lücke. Feasibility-Spike (Nutzer-Entscheid „Spike jetzt starten") zuerst nur Wand-Endpunkte, dann auf volle Parität ausgebaut: Wand-Endpunkt-/Höhen-/Verschiebe-Griff + 2D-Zeichnungs-Vertex-/Verschiebe-Griff, dieselben App.tsx-Callbacks (`onEdit3dVertex`/`onEdit3dBody`/`onEdit3dWallTop`) wie die three.js-Sicht. **Wichtige Design-Korrektur unterwegs:** erster Versuch zeichnete Griff-Marker als In-Szene-Geometrie (3-Achsen-Kreuz, dann Drahtgitter-Kugel) über den bestehenden `setHighlightLines`-Kanal — Nutzer-Feedback zweimal `"immernoch kein GUI button sondern geometrie"`: nicht klar genug als klickbarer Punkt erkennbar UND farblich identisch mit dem Auswahl-Umriss auf derselben Ecke. Gelöst durch Umstieg auf ECHTE DOM-Buttons (`position:absolute`-Kreise über dem Canvas, Farben wie drei.js: Vertex orange/Höhe blau/Verschieben grün), deren Bildschirmposition ein `requestAnimationFrame`-Loop aus der aktuellen Kamera nachführt (`worldToScreen`, neue Umkehrfunktion zu `cameraRay` in `raycast3d.ts`) — kein WASM/GPU-Push nötig, nur 2D-Projektionsmathe. Ziehen läuft über `rayPlaneY`/`rayPlane` (neu in `raycast3d.ts`) + dieselben Store-Callbacks. `drawingGripVertices`/`drawingMoveAnchor` nach `src/viewport/drawingGrips.ts` ausgelagert (gemeinsam von beiden Renderern genutzt, sonst zyklischer Import). **Bewusst NICHT gebaut:** Snap/Koinzidenz-Mitführen beim 3D-Ziehen (bleibt three.js-exklusiv). `tsc`/`vitest` 339/339 grün. **Nutzer-bestätigt** (Wand-Endpunkt-Drag funktional in der Tauri-App getestet, dann Marker-Sichtbarkeit korrigiert). **Noch uncommittet.**
|
||||||
|
|
||||||
## ❓ Offene Rückfragen (an den Nutzer)
|
## ❓ Offene Rückfragen (an den Nutzer)
|
||||||
|
|
||||||
@@ -111,6 +209,60 @@
|
|||||||
|
|
||||||
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
||||||
|
|
||||||
|
- [x] 2026-07-12 **swissBUILDINGS3D-Import auf Suchradius zugeschnitten** (`31b76a2`) — Nutzer-Frage „importiert es wirklich georeferenziert, oder landet alles am 0-Punkt?" Live gegen die echte STAC-API geprüft: Georeferenzierung selbst war KORREKT (`geocode`/`makeOrigin`/`shiftMeshToOrigin` liefern echte, nicht-triviale LV95-Koordinaten). Echter Bug daneben gefunden: eine STAC-Kachel liefert ALLE Gebäude der Kachel als EIN Mesh (`parseDxf` sammelt alles in denselben Puffer) — ohne Zuschnitt landete bei jedem Import die KOMPLETTE Kachel im Modell, bis zu mehreren km über den Suchradius hinaus (verifiziert: zwei 1.4 km auseinanderliegende Adressen derselben Kachel lieferten VORHER identische, ungeschnittene 62'532-Dreiecke-Geometrie). Neues `clipMeshToBbox` (`swissBuildings3d.ts`) schneidet + kompaktiert auf den gesuchten Radius. +4 Tests, Suite 827 grün.
|
||||||
|
- [x] 2026-07-12 **Fenster/Tür: 2D+3D setzen "aussen" jetzt auf dieselbe Wandfläche** (`a631dda`) — Nutzer-Report „im 2D innen bündig und im 3D aussen bündig". Root Cause: das 2D-Vorzeichen für `insetFace:"aussen"` (`windowSymbol` in `geometry/opening.ts`) war gegenüber der ECHTEN Aussenschicht der Wand (erste Wandtyp-Schicht, kleinster Achs-Offset in `addWallPoche`/`pushSegment`) invertiert — landete am inneren statt äusseren Wandputz. Dieselbe Verwechslung steckte auch in `addOpeningFrameBand` (Tür-Rahmenband), den Sturzlinien und dem Rollladenkasten (alle in `generatePlan.ts`) — alle vier gefixt. Die 3D-Seite (`openingAxisBox`/`resolveFrameNormalRange` in `toWalls3d.ts`) war bereits korrekt (zwei kompensierende Vorzeichen ergaben zufällig das richtige Ergebnis) und blieb unverändert. +1 Regressionstest, der 2D- und 3D-Rahmenposition direkt gegen die bekannte Aussenschicht der Wand prüft (statt nur relative Deltas). Suite 823 grün.
|
||||||
|
- [x] 2026-07-12 **Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import (Teil-Fix)** (`ae47b4f`) — erster Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Neues `Project.geoAnchor` (`{lv95, model, label?}`) verbindet einmalig „dieser LV95-Punkt = dieser Modellpunkt"; der ERSTE Import in einem Projekt setzt ihn automatisch, alle folgenden Importe (`ContextImportDialog`) verwenden denselben Anker statt je unabhängig `makeOrigin(center)` neu zu berechnen — behebt die Lage-Inkonsistenz zwischen mehreren Importen in einem Projekt. `tsc` sauber, Suite 794 grün. **Bewusst Teil-Fix:** Anker sitzt immer bei Modell-(0,0), es gibt noch kein Werkzeug, um ihn auf einen beliebigen, vom Nutzer gewählten Modellpunkt zu legen — bleibt offen.
|
||||||
|
- [x] 2026-07-12 **Kontext-Meshes (importierte Gebäude/Terrain) im 3D-Viewport anwählbar** (`e6a7738`) — zweiter Design-Punkt aus dem GEO-BLOCK-Report umgesetzt. Raycast/`pickGeometry` (`raycast3d.ts`/`toWalls3d.ts`) um Kontext-Meshes erweitert (rohe Dreiecke aus `project.context`, kaputte Indizes robust übersprungen), neuer Auswahl-Kanal `selectedContextObjectIds` (Store-Slice + `onViewport3dPick`-Zweig, konsistent in ALLE bestehenden Auswahl-Reset-Stellen eingehängt), Highlight als achsenparallele Bounding-Box (volles Dreiecks-Wireframe wäre bei importierten Gebäuden mit tausenden Dreiecken unbrauchbar dicht), Entf-Taste löscht die Auswahl (reused `project.context`-Filter im bestehenden Lösch-Handler), `SitePanel`-Liste hebt die im 3D gewählte Zeile hervor (`host.selectedContextObjectIds`). +9 Tests (`raycast3d.test.ts`, `toWalls3d.test.ts`), Suite 803 grün, `tsc` sauber. **Bewusst nicht umgesetzt:** Ebenen-/Layer-Zuordnung (`categoryCode` an `ImportedMesh`/`TerrainMesh`), volle Attribut-Panel-Integration (`deriveSelection()`) — als eigener, grösserer Schritt eingeschätzt, s. „🔧 In Arbeit" oben.
|
||||||
|
- [x] 2026-07-12 **3D-Ansicht: „Schattiert mit Kanten" (BIM-Look) + Fix falscher Flächendiagonalen** (`a84dc7a`+`7dc8f0d`) — neuer `RenderStyle::ShadedEdges` (render3d): Bauteilfarben + dunkle Modell-Kanten obenauf, wie Revit/ArchiCAD. Direkt danach Nutzer-Report: sichtbare Dreiecks-Diagonalen auf Dach/Fensterglas im neuen Modus. Root Cause: Kontext-Meshes (Dach/Glas/Rahmen, auch swissBUILDINGS3D-Import) werden wegen aktivem Backface-Culling IMMER doppelseitig aufgebaut (`mesh.rs::push_ctx_tri`, Dreieck + gespiegelte Rückseite) — jede Kante bekam dadurch ein exakt entgegengesetztes Normalen-Paar, das die Knick-Erkennung fälschlich als Kante wertete. Fix in `edges.rs::should_draw`: Rückseiten-Duplikate (dot≈-1) werden vor der Rand-/Knick-Entscheidung zusammengeführt. +11 Rust-Tests, 88/88 grün (`--features render`). **Drei Folgepunkte dabei entdeckt, noch offen** (s. „🔧 In Arbeit" oben): Fenster-Rahmenecken/Dach-First ohne sauberen Verschnitt (vermutlich fehlende Boolean-Union, kein Kanten-Bug), OG-Wandflächen mit vielen vertikalen Strichen (nicht diagnostiziert).
|
||||||
|
- [x] 2026-07-12 **swissBUILDINGS3D-Import repariert: Absturz + eigentlicher „funktioniert nicht"-Bug** (`35a6834`+`9705890`) — zwei getrennte, echte Bugs gefunden und behoben. (1) Harter Absturz (JSZip „Invalid string length") bei Kacheln, deren ENTPACKTE Grösse (DXF komprimiert stark) die max. JS-String-Länge sprengt, obwohl die ZIP-Grösse selbst unter dem Limit lag — `downloadAssetText` prüft jetzt zusätzlich die JSZip-interne Grössenschätzung und fängt alle Fehler sicher ab; übersprungene Kacheln landen sichtbar in `skippedTiles`/einer UI-Meldung statt eines stummen Leer-Ergebnisses. (2) **Der eigentliche Grund, warum der Import nie Gebäude lieferte:** die installierte `dxf-parser`-Version liefert Polyface-Mesh-Face-Indizes als vier einzelne Felder (`faceA..faceD`, Gruppencodes 71–74) statt als erwartetes `faces`-Array — `addPolyfaceMesh` prüfte auf ein Array, das nie existierte, jede swissBUILDINGS3D-DXF-Kachel (exakt dieses Format) ergab dadurch 0 Dreiecke. Live gegen echte Kacheln verifiziert (vorher 0 Meshes, jetzt tausende Dreiecke mit realistischen Höhenwerten). +3 Tests mit rohem DXF-Text (deckt auch die zugrundeliegende Bibliothek ab), Suite 794 grün. **Georeferenzierung und „anwählbares Mesh auf Ebene statt Geo-Panel-Eintrag" bleiben als eigene, ungelöste Design-Fragen offen** (s. „🔧 In Arbeit" oben).
|
||||||
|
- [x] 2026-07-12 **Fenster-Grundriss komplett überarbeitet** (`e65a6b7`..`d2758ef`, iterativ über mehrere Live-Prüfungsrunden in Tauri) — Nutzer-Report mit SIA-Referenzbildern deckte mehrere übereinanderliegende Probleme auf, alle einzeln gefixt + verifiziert:
|
||||||
|
- Sims/Anschlag-Kerben nutzten pauschal die volle Wandfläche statt der tatsächlichen (ggf. per `insetFromFace` eingezogenen) Rahmen-Aussenkante.
|
||||||
|
- Stulp-Marken waren kleine, von der Rahmentiefe unabhängige Quadrate statt Profilquerschnitte über die GANZE Rahmentiefe; die Blendrahmen-Querschnittsblöcke an den Laibungs-Enden fehlten komplett — beide jetzt als `meetingMarks` in `windowSymbol` berechnet (inkl. Laibungs-Enden), `generatePlan.ts` nutzt sie direkt statt einer zweiten, abweichenden Neuberechnung.
|
||||||
|
- Zusätzliche `window-mullion`-Trennlinie lag redundant NEBEN dem neuen Rahmenblock (entfernt, für typisierte Fenster reicht der Block; Alt-Pfad ohne Typ behält die Linie).
|
||||||
|
- Glaslinie(n) im Feld (mittel: 1, fein: Doppellinie) komplett entfernt — Nutzer wollte dort keine Haarlinie, unabhängig von Detailgrad/Position.
|
||||||
|
- Pauschale Brüstungslinie (Wandachse, unabhängig von Rahmenposition) UND die „Oberlicht-Andeutung" (gestrichelt bei `transomHeight>0`, ebenfalls immer auf der Wandachse unabhängig davon ob die Schnittebene den Kämpfer trifft) beide entfernt — architektonisch nicht aussagekräftig für einen horizontalen Grundriss-Schnitt.
|
||||||
|
- **Ersatz:** `WindowType.sillLine` — konfigurierbare Auf-/Untersicht-Andeutung (Fläche aussen/innen/beide, Blickrichtung Auf-/Untersicht), an der echten Rahmenkante statt der Wandachse.
|
||||||
|
- **Neu:** `Opening.frameLine`/`sashLine`/`sillLineStyle` — Farbe/Strichstärke je Linien-Kategorie (Blendrahmen+Stulp / Flügelrahmen+Sprossen / Sims) im Objekt-Info-Panel einstellbar, Default = bisheriges Verhalten.
|
||||||
|
- +~30 Tests über mehrere Dateien, Suite 790 grün.
|
||||||
|
- [x] 2026-07-12 **Dach-Wand-Verschneidung (Z-Fighting im 3D behoben)** (`a584911`) — Nutzer-Report mit Screenshot (flackerndes Rausch-Muster wo Wand-Oberkante die Dachschräge kreuzt). Root Cause: Wände bekamen ihre Höhe unabhängig vom Dach, `collectCeilingCutters` kappte nur gegen `project.ceilings`, nie gegen `project.roofs` — wo die geneigte Dachfläche den flachen Wand-Top kreuzte, überlappten sich beide Volumen. Fix: `roofUndersideAt` (`geometry/roof.ts`, Ebenengleichung je Dachfläche + Punkt-in-Polygon) liefert die Dach-Unterkante an einem Grundriss-Punkt; `clipPieceToRoofs` (`toWalls3d.ts`) zerlegt betroffene Wand-Achsenstücke in feine ~15-cm-Schritte und klemmt jeden auf die dort gemessene Unterkante — Treppenstufen-Annäherung an eine echte geneigte Giebelwand-Stirnfläche (render3d-Wandkörper haben nur einen flachen Top; eine echte Schrägfläche bräuchte einen neuen Mesh-Pfad, bewusst nicht gebaut). Ohne Dach im selben Geschoss unverändertes Verhalten. +9 Tests (5 `roofUndersideAt`, 3 Integration inkl. Regressionsfund einer legitim gekappten Putz-Gehrungsspitze im Sample-Projekt), Suite 781 grün. Verwandt: [[design-schicht-verschneidung-3d-schnitt]] (gleiche Kategorie wie „Wand endet an Decke", hier für geneigte statt horizontale Cutter).
|
||||||
|
- [x] 2026-07-12 **Fenster-Grundriss grob/mittel/fein nach SIA 400 klar unterschieden** (`13cc6a0`) — Nutzer-Report „mittel und fein sind genau gleich, grob ist viel zu grob" behoben: `windowSymbol` (`geometry/opening.ts`) staffelt jetzt nach SIA 400 Anhang B.9.1 Fig. 36–38 statt DIN — grob (1:100) nur eine Glaslinie, mittel (1:50) Blendrahmen + Flügel-Trennlinien + 1 Stulp-Quadrat je Flügelstoss, fein (1:20) zusätzlich verschachtelte Flügelrahmen + Glas-Doppellinie (Isolierverglasung) + 2 Stulp-Quadrate. `generatePlan.ts` reicht `detail` durch, rendert die neuen Stulp-Marken (`window-stulp`) nur im typisierten Pfad (kein Fake-Stoss bei reinem `wingCount`-Altfall). Referenzdoku `docs/research/sia400-fenster-tueren.md` (Fig.-Transkription aus der SIA-400-PDF, lokal excluded). +9 Tests, Suite 753 grün. Visuell per Puppeteer verifiziert. ✅ **Folge-Fix `e309e54`:** Öffnungssymbol-Kommentare in `toElevation.ts`/`toWalls3d.ts` waren fälschlich „DIN" benannt (Grundlage ist SIA 400 B.9.1.3) — reine Doku-Korrektur, Symbol-Geometrie selbst war bereits korrekt (SIA-PDF zeigt nur Sinnbild-Namen, keine Pfeilgrafiken, daher keine Geometrieänderung). ✅ **Wand-Poché bei „grob" jetzt immer vollschwarz** (`0240a23`, 2026-07-12) — war zuvor nur schwarz, wenn das dominante Bauteil selbst `pattern:"solid"` hatte (bei mehrschichtigen Wandtypen mit Backstein/Dämmung/Verputz blieb es weiss). Fix (unconditional `HATCH_INK` bei grob) von Hermes/Qwen3 geliefert (zweiter Versuch, erster war die o. g. Fehleinschätzung), von mir verifiziert + um fehlenden Regressionstest + Aufräumen der toten `backbonePocheFill`-Hilfsfunktion ergänzt. +2 Tests, Suite 755 grün.
|
||||||
|
|
||||||
|
- [x] 2026-07-11 **Schnitt/Ansicht auf VW-Niveau (grosser Block)** — Schnitt ENTSPIEGELT (`983d061`, u-Achse = Betrachter-Rechts, Rust+TS koordiniert, Engine neu gebaut); Kanten geschnittener Bauteile gefiltert + verdeckte Kanten opt-in (`4c4c990`); Wand-Terminierung wirkt im Schnitt auch bei Decken-ANSCHLUSS ±5 cm (`a612ae5`); Dämmschraffur-Orientierung Wand/Decke korrekt (Sprossen quer zur Schicht, empirisch verifiziert, `a612ae5`+`38a4d8c`). Ansicht als LINIENZEICHNUNG (`c02a02c`): weisse Flächen + XOR-Silhouette (xorOutlineEdges — Teilbox-Innenkanten heben sich auf), Fenster mit Blendrahmen→Flügelprofil→Glas, Sims mit Tropfkante, DIN-Symbole exakt auf den Glasfeld-Ecken (`ee9aeda`). **3D-Öffnungen VW-fein** (Merge `31d7aef`): vorstehende Flügelrahmen, DIN-Symbole auf dem Glas (openingPlaneBox-Prismen), 3D-Fensterbank + Tropfkante, Zargen-Umgriff; Staffelung grob/mittel/fein. Display/Print-Umschalter in allen 2D-Darstellungen. **Offen:** Tauri-Visualabnahme 3D; Schnitt-Auswahl-UX (Treffer nur auf der Poché).
|
||||||
|
- [x] 2026-07-11 **Lineale + VW-Zeichen-Feedback komplett** (`1945fec`, `8f85e13`, `7898a0d`, `242b850`) — Lineale oben/links in allen 2D-Ansichten (Meter-Ticks, Cursor-Marker); Cursor-HUD im Programm-Look mit Live-Echo getippter Werte (oben+unten synchron), Tab-Feldwechsel, Direkt-Tippen; Δx/Δy beim Rechteck; lange Führungslinien + Winkelbogen mit 0°-Referenz.
|
||||||
|
|
||||||
|
- [x] 2026-07-11 **Schnitt ausgebaut** (`b86fca2`) — Befehl `sectionline`/`viewline` (Aliase schnittlinie/ansichtslinie, BIM-Ribbon „Schnitte"): zwei Klicks setzen die Schnitt-/Ansichtslinie (neue Ebene bei Bedarf). Schnittführungs-Symbol im Grundriss (Strichpunkt, Endmarken, Richtungspfeile, Label) + Pick-Band; Schnittlinie selektierbar mit Attribut-Sektion (Name, Blickrichtung umkehren, Endpunkte, Tiefe, Löschen/Entf). **Editieren IM Schnitt**: Cut-Polygone tragen die Quell-Element-Id (sourceId, durch Schicht-Zerlegung/Terminierung/Dominanz propagiert) → Klick im Schnitt wählt das Bauteil, Panels editieren, Schnitt rechnet neu. Schnitt-Tiefe `DrawingLevel.depth` (Owner-Distanz-Näherung, TODO exakte Kanten-Tiefe bräuchte section.rs). **Offen:** Endpunkt-Drag-Griffe der Schnittlinie im Grundriss.
|
||||||
|
- [x] 2026-07-11 **Ansicht (Elevation) als echte 2D-Darstellung** (`456ecc5`+`a6db682`) — `toElevation.ts`: Painter-Projektion (Fassaden/Decken fern→nah, Rückseiten-Culling, Tiefen-Graustaffelung), Fenster/Türen als Rahmen+Glas, Bodenlinie, **Dächer** (Flächen + Giebel aus roofGeometry) und **Schatten ein/aus** (45°-Schlagschatten auskragender Decken UND Dachflächen, Sutherland–Hodgman-geclippt; Toggle in der Ansichts-Leiste, `DrawingLevel.shadows`). Rein TS/synchron ohne WASM. **Offen:** exakter Hidden-Line statt Painter (dokumentierte Näherung), Material-/viewHatch-Füllungen je Fassade.
|
||||||
|
- [x] 2026-07-11 **VW-Zeichengefühl: Cursor-HUD + Winkelraster** (`975ce3f`) — beim Zeichnen L/W-Kästchen am Cursor (`L: 3.118m W: 60.000°`, VW-Stil), weiches Einrasten auf 15°-Vielfache (±2.5°, konfigurierbar) mit Winkel-Badge + gestrichelter Führungslinie; Vorrang Objekt-Snap > Shift/Ortho > Winkelraster > Raster; Toggle in der Fang-Leiste. Das bis dahin nie gerenderte `ToolDraft.hud` lebt jetzt (line/polyline/wall/rect/circle/arc).
|
||||||
|
- [x] 2026-07-11 **Dach im 3D anwählbar** (`355c1d4`) + **2D-Schnitt = 3D Wand-Terminierung** (`0e02c2b`) — Ray-Dreieck-Pick über Dachflächen/Giebel; applyWallTermination spiegelt terminateOrSubtractSpans im (u,v)-Schnittraum (below/above kappen an der Decken-UK/OK).
|
||||||
|
|
||||||
|
- [x] 2026-07-10 **Fenster-/Tür-Einstellungsdialog + reiche Öffnungs-Parameter** (`1bbe814`+`c734802`+`2e13ec3`, Studie [docs/design/window-editor-vectorworks-study.md](docs/design/window-editor-vectorworks-study.md)) — Nutzer-Feedback „Fenster/Türen mega mager". Das ⚙ der Öffnungs-Sektion öffnet neu einen dedizierten Dialog (`OpeningEditorDialog.tsx`): Kategorie-Sidebar (Basis/Grösse/Rahmen/Flügel/Sonnenschutz bzw. Türblatt), Flügeltabelle (Flügel/Pfosten + Öffnungsart + Anschlag je Flügel), Live-2D-Frontalansicht, Stil-Leiste mit „Als Stil speichern" (klont Typ → neuer benannter WindowType/DoorType). Modell additiv: `SashDef[]`/`shading`/`glazingPanes` + Helfer `sashesOfWindowType`/`glazingPanesOf`. Renderer konsumiert sie: 2D-Flügeltrennlinien aus Flügelbreiten + Öffnungsandeutung, glazingPanes-Glaslinien, gestrichelte Rollladenkasten-Kontur; 3D mehrscheibige Verglasung. +26 Tests, 659/659 grün. **Offen (P1+, Studie §4):** asymmetrische Rahmenbreiten, Oberlicht/Unterlicht als eigene Felder, Sprossengitter, Bank/Nische, Form (Rund/Spitz), echte wgpu-3D-Vorschau im Dialog, 3D-Rollladenkasten-Box.
|
||||||
|
- [x] 2026-07-10 **Dach-3D-Ziehgriffe** (`4ef40a0`) — gewählte Dächer haben im wgpu-Viewport Eckpunkt-Griffe (achsparalleles Resize, Gegenecke fix), einen Verschiebe-Griff und einen First-Griff (vertikal ziehen → Dachneigung, Firstlage bleibt). Store `moveRoofGrip`/`moveRoofBy` (coalescing) + `onEdit3dRoofPitch` (Höhe→Neigung). +3 Tests. Schliesst die „optionale Folge" des Dach-Features (s. u.). **Tauri-Visualabnahme offen.**
|
||||||
|
- [x] 2026-07-10 **Messwert im Objekt-Info** (`9b6dd80`) — Live-Messwerte erschienen nicht im Panel; Ursache war ein eingefrorenes Memo (`baseHost`-Deps ohne `draft`). Fix: `measurement` pro Render live in `hostWithMode` injiziert.
|
||||||
|
|
||||||
|
- [x] 2026-07-09 **Dächer (Grundfeature)** (`1195d2a`+`31f2d63`) — Roof-Element + `geometry/roof.ts` (Flach/Pult/Sattel/Walm/Mansarde/Zelt, BBox-basiert, First X/Y). Befehl „Dach" (BIM-Ribbon, Rechteck aufziehen, Form als Inline-Option), 2D-Plan (Traufe/First/Grat/Knick), 3D (emitRoofs, terrakotta), Demo-Dach RF1. +19 Tests. **Offen:** Auswahl/Attribut-Editieren/Löschen (s. „Als Nächstes").
|
||||||
|
- [x] 2026-07-09 **Fenster/Tür-Feedback** (`4beae72`+`89e737b`) — Fenster im 3D tiefer (Rahmen füllt Wanddicke, dünne Scheibe mittig); Detailgrad grob/mittel/fein wirkt im 3D; Fenster↔Tür-Umschalter entfernt; platzierte Öffnungen bekommen Standard-Typ (sonst kein Rahmen). Kernursache „sieht im 3D nicht so aus" = fehlender typeId behoben.
|
||||||
|
|
||||||
|
- [x] 2026-07-09 **Decken-Griffe im 3D** (`973ac6d`) — gewählte Decken haben im wgpu-Viewport Eckpunkt- + Kanten-Mittelpunkt-Griffe (rautenförmig, eigene Farbe) + Verschiebe-Griff → moveCeilingGrip/moveCeilingEdge/moveCeilingBy (Store-Actions existierten schon fürs 2D). Neu: 3D-Griffgeometrie (computeGrips), Kanten-Drag (GripDrag „edge"), Prop-Kette Wasm3DViewport←Viewport3D←App (editCeilings/onEditEdge, Ziel um ceilingId). three.js-Sicht unverändert. **Tauri-Visualabnahme offen.**
|
||||||
|
- [x] 2026-07-09 **Text-Styling-Leiste wirkt auf Freitext** (`d0b9d22`) — selektierter Freitext/Textspalte (Drawing2D shape „text") ist Formatier-Ziel der Oberleiste: Drawing2D-Text.marks (Schrift/fett/kursiv/Farbe); Grösse bleibt über Modell-Höhe (Meter, nicht pt). App.textTarget adaptiert via docFromText/plainText; generatePlan/PlanView rendern die Marks. +3 Tests.
|
||||||
|
- [x] 2026-07-09 **Schichttrennlinie als Wand-Referenzlinie** (`938d642`) — Wall.referenceOffset (freier Achsversatz) übersteuert left/center/right; Object-Info-Dropdown listet je interne Fuge einen Eintrag (kumulierte Schichtdicken in selectionInfo). wallReferenceOffset bleibt EINE Quelle → 2D/Schnitt/3D erben es. +3 Tests.
|
||||||
|
- [x] 2026-07-09 **Mess-Werkzeug: Polygonzug + Fläche + Objekt-Info** (`492e1f8`) — Länge je Segment + aufsummiert + FLÄCHE (ab 3 Ecken, Gauss); Live-Werte dauerhaft im Objekt-Info-Panel (ToolDraft.measure→PanelHost.measurement→ObjectInfoPanel); Rechtsklick beendet Pfad + armiert neuen (mehrere nacheinander). +4 Tests.
|
||||||
|
- [x] 2026-07-09 **Tür/Fenster tief ausgearbeitet** (`fa40429`/`142ba7e`/`9a65900`) — DoorType/WindowType um frameKind (Zarge/Blockrahmen), frameWidth, insetFromFace/insetFace (Schichteinzug), transomHeight (Oberlicht), mullionRows (Kämpfer). ResourceManager-Typeditor + Seeds (Haustür Blockrahmen/Oberlicht, Fenster 2-flügl.+Oberlicht). 2D: opening-frame/-transom-Primitive. 3D: emitOpeningFrames (Rahmen/Sprossen/Kämpfer) + einzugs-/oberlicht-bewusste Scheiben. +13 Tests. **3D-Visualabnahme offen.**
|
||||||
|
- [x] 2026-07-09 **LICENSE** (`dbe7d37`) — offizieller AGPL-3.0-Text (gnu.org).
|
||||||
|
|
||||||
|
- [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 2: Decke über Schnittebene = gestrichelte Überkopf-Linie** (`39ddd9b`) — freier Decken-Umriss (Balkon-/Vordach-Überstände) jetzt gestrichelte Haarlinie (`OVERHEAD_DASH` in `generatePlan.ts`, `addCeilingPoche`) statt Volllinie; Decken-Fläche war schon Ansicht (viewHatchId). `lwMm`-Param entfernt (feste Haarlinie). +1 Test, Suite 337.
|
||||||
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
|
- [x] 2026-07-05 **Schnitt/Ansicht Phase 1: Wand unter Schnittebene = Ansichtslinie** — `addWallPoche(viewOnly)` in `generatePlan.ts`: `wall.height < floor.cutHeight` (Brüstung/Podestrand) → Umriss-Haarlinie (`fill:"none"`, NO_HATCH) statt Schnitt-Poché; normale Wände unberührt (Segment-/Gehrungs-/Öffnungslogik geteilt). +2 Tests (`generatePlan.viewwall.test.ts`), Suite 336. Erster Baustein des BIM-Standard-Items „Schnitt vs. Ansicht nach Schnitthöhe".
|
||||||
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
|
- [x] 2026-07-05 **Ribbon Phase 4: modulare Custom-Bar** (Tab „Eigene") — datengetrieben auf der bestehenden Registry: neuer `custom`-Tab, `CustomBar` in `RibbonBar.tsx` rendert die vom Nutzer gewählten Items + einen „+ Hinzufügen"-Picker (Dropdown mit Häkchen, listet ALLE Werkzeuge/Befehle aus `ALL_RIBBON_ITEMS`, Klick = an/abwählen). `itemKey`/`itemFromKey` für stabile Persistenz; State in App (`customItems`) via localStorage (`dossier.ribbon.customItems`). Gleiche `RibbonButton`-Aktivierung wie normale Tabs (kein Sonderweg). tsc + Suite 334 grün.
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# DOSSIER Standalone
|
# DOSSIER Standalone
|
||||||
|
|
||||||
Eigenständiges BIM-Werkzeug für Wohnbau. Modelliert ein Wohnhaus aus
|
Open-Source-CAAD (Computer Aided Architecture Design). Modelliert ein Gebäude
|
||||||
semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
|
aus semantischen Bauteilen und zieht daraus saubere, normgerechte 2D-Pläne —
|
||||||
ohne Revit.
|
ohne Revit. Als Desktop-App und im Browser zugänglich; die Desktop-App ist die
|
||||||
|
vollständige Fassung.
|
||||||
|
|
||||||
Das ist die eigenständige Standalone-Variante des Rhino-Plugins
|
Das ist die eigenständige Standalone-Variante des Rhino-Plugins
|
||||||
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
|
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
|
||||||
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
|
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
|
||||||
React/Three.js-App in einer Electron-Desktop-Shell statt als Rhino-Aufsatz.
|
React-App mit eigener Rendering-Engine (Rust/WASM/WebGPU) statt als Rhino-Aufsatz.
|
||||||
Kein Browser-Tab-Produkt: eigenes Fenster, eigene Rendering-Engine
|
Als Desktop-App (Tauri) läuft sie im eigenen Fenster mit voller Engine-Leistung;
|
||||||
(Rust/WASM/WebGPU), gebaut für flüssiges CAD-Arbeiten statt für den Webview
|
browserseitig ist derselbe Kern zugänglich, die App ist die vollständige Fassung.
|
||||||
kleinster gemeinsamer Nenner.
|
|
||||||
|
|
||||||
## Grundgedanke
|
## Grundgedanke
|
||||||
|
|
||||||
@@ -151,7 +151,17 @@ src-tauri/ Rust-Crates (render2d/render3d) — headless per wasm-pack zu WAS
|
|||||||
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
|
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
|
||||||
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
|
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Copyright © 2026 Karim Gabriele Varano. Veröffentlicht unter der
|
||||||
|
**GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)** —
|
||||||
|
siehe [LICENSE](LICENSE). DOSSIER ist Teil der **openbureau**-Suite.
|
||||||
|
|
||||||
|
Die AGPL verlangt, dass auch bei Betrieb als Netzwerk-/Webdienst der (ggf.
|
||||||
|
geänderte) Quellcode für die Nutzer verfügbar gemacht wird. Drittkomponenten
|
||||||
|
behalten ihre jeweiligen Lizenzen (siehe „Über"-Dialog in der App).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen,
|
Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen, Swisstopo-Geodaten)
|
||||||
Swisstopo-Geodaten) sind als Differenzierer eingeplant.
|
sind als Differenzierer eingeplant.
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
# BIM-Elementtiefe — Tür, Fenster, Dach, Decke ("wirklich 1:1")
|
||||||
|
|
||||||
|
## 0. Zweck und Abgrenzung
|
||||||
|
|
||||||
|
DOSSIER modelliert Bauteile heute semantisch (kein reines Zeichenprogramm) und
|
||||||
|
hat für Wand/Decke bereits eine mehrschichtige Aufbaulogik (`Component[]` via
|
||||||
|
`WallType`/`CeilingType`). Türen und Fenster haben seit
|
||||||
|
`docs/design/window-editor-vectorworks-study.md` einen dedizierten,
|
||||||
|
phasierten Ausbauplan (Flügeltabelle, Verglasung, Sonnenschutz). Was fehlt,
|
||||||
|
ist die gleiche Tiefe für **Dach** (keine Schichtlogik, keine Mansard-
|
||||||
|
Untertypen, kein Kehl-/Gaubenmodell) und für die **Decken-Randterminierung**
|
||||||
|
(Deckenrand als reines Polygon ohne Kantendetail).
|
||||||
|
|
||||||
|
Dieses Dokument nimmt die vier Bauteile Tür, Fenster, Dach, Decke und hält
|
||||||
|
sie gegen die reale Tiefe vollständiger BIM-Programme (Vectorworks Architektur,
|
||||||
|
ArchiCAD, Revit, Allplan). Ziel ist NICHT, jedes Feature dieser Programme zu
|
||||||
|
kopieren, sondern zu benennen, welche Lücken einen echten "1:1-Zuwachs" für
|
||||||
|
ein Einfamilienhaus-/Wohnbau-Tool wie DOSSIER bringen — und welche reiner
|
||||||
|
Ballast wären (Abschnitt 6).
|
||||||
|
|
||||||
|
Für Fenster/Tür dupliziert dieses Dokument NICHT die Mapping-Tabelle aus
|
||||||
|
`window-editor-vectorworks-study.md` — es referenziert sie und ergänzt, was
|
||||||
|
dort fehlt (v. a. Tür-Tiefe, die die Studie nur am Rand behandelt, und die
|
||||||
|
architektonischen Grenzen des heutigen Öffnungsmodells). Für Wand-Schichtlogik
|
||||||
|
siehe `docs/design/parametric-walls.md` (Raster/Modul-Regeln, nicht
|
||||||
|
Gegenstand hier) und `docs/design/elements.md` (ältere Gesamtplanung, Stand
|
||||||
|
vor der aktuellen `Component`/`Layer`-Implementierung — dort abweichende
|
||||||
|
Typnamen wie `Slab`/`ProfileDef`, hier durchgängig der IST-Code zitiert).
|
||||||
|
|
||||||
|
Alle IST-Aussagen sind mit Datei:Zeile belegt (verifiziert per Lesen des
|
||||||
|
Codes, Stand dieses Commits). Status-Legende der Lücken-Tabellen: **✓**
|
||||||
|
vorhanden und gerendert · **~** Feld existiert, Renderer liest es nicht oder
|
||||||
|
nur grob · **✗** fehlt vollständig. Priorität P0 (grösster 1:1-Zuwachs, bald)
|
||||||
|
… P3 (Ballast, nur auf Nachfrage). Aufwand grob in Personentagen (PT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gemeinsames Fundament: das Schicht-Muster
|
||||||
|
|
||||||
|
Der zentrale Baustein, den DOSSIER bereits hat und der sich wiederverwenden
|
||||||
|
lässt, ist `Component`/`Layer`:
|
||||||
|
|
||||||
|
- `Component` (`src/model/types.ts:196`) — ein Bauteil-Material: Poché-Farbe
|
||||||
|
(`color`/`foreground`/`background`), Schnitt-Schraffur (`hatchId`) UND
|
||||||
|
Ansichts-Schraffur (`viewHatchId`, für unaufgeschnittene Aufsicht),
|
||||||
|
optionales PBR-Material (`material`), Kürzel (`abbrev`) und ein
|
||||||
|
Verschneidungs-Rang (`joinPriority`, `types.ts:241`) für die Boolean-
|
||||||
|
Dominanz am Stoss.
|
||||||
|
- `Layer` (`types.ts:245`) — eine Schicht: `componentId` + `thickness` +
|
||||||
|
optionaler Fugen-Linienstil (`jointLineStyleId`).
|
||||||
|
- `WallType` (`types.ts:260`) und `CeilingType` (`types.ts:273`) sind BEIDE
|
||||||
|
nur `{ id, name, layers: Layer[] }` — bewusst derselbe Typ, "das
|
||||||
|
horizontale Gegenstück zum WallType" (Kommentar `types.ts:266-271`).
|
||||||
|
|
||||||
|
Das Muster ist also bereits zweimal (Wand, Decke) verifiziert:
|
||||||
|
`ceilingThickness()` (`types.ts:2104`) summiert die Layer-Dicken,
|
||||||
|
`emitSlabs()` (`src/plan/toWalls3d.ts:1352-1419`) stapelt sie im 3D als
|
||||||
|
einzelne `RSlab`-Scheiben proportional in `[zBottom, zTop]`, `addCeilingPoche`
|
||||||
|
(`src/plan/generatePlan.ts:2453`) zeichnet die Aufsicht mit der
|
||||||
|
Ansichts-Schraffur der ERSTEN Schicht, und `toSection.ts:633`
|
||||||
|
(`splitSlabLayers`) zerlegt die Decke im ECHTEN Schnitt in
|
||||||
|
Einzel-Bänder je Schicht (mit `resolveCeilingSectionStyle`,
|
||||||
|
`generatePlan.ts:591`, als Schraffur-/Farbquelle).
|
||||||
|
|
||||||
|
**Der Dach-Vorschlag in Abschnitt 4 ist im Kern: dasselbe Muster ein drittes
|
||||||
|
Mal anwenden.** Das ist der günstigste Weg zu echter Dach-1:1-Tiefe, weil
|
||||||
|
Layer-Resolver, Schraffur-Ketten (`resolveHatch`/`resolveForeground` etc.,
|
||||||
|
`generatePlan.ts:409-536`) und die 3D-Stapel-Logik bereits bestehen und nur
|
||||||
|
auf einen neuen Aufbau-Typ angewendet werden müssen statt neu erfunden.
|
||||||
|
|
||||||
|
Architektonische Randbemerkung: `Project` führt bereits `wallTypes`,
|
||||||
|
`ceilingTypes?`, `doorTypes?`, `windowTypes?`, `stairTypes?`
|
||||||
|
(`types.ts:1936-1958`) als eigene Bibliotheken — aber **kein `roofTypes?`**.
|
||||||
|
`Roof` (`types.ts:1022`) trägt nur ein einzelnes `thickness: number`
|
||||||
|
(`types.ts:1044`), keinen Aufbau-Verweis. Das ist die strukturelle Lücke,
|
||||||
|
die Abschnitt 4 schliesst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Tür (Door)
|
||||||
|
|
||||||
|
### 2.1 Was ein vollständiges BIM-Tool bietet
|
||||||
|
|
||||||
|
| Bereich | Typische Parameter (VW/ArchiCAD/Revit/Allplan) |
|
||||||
|
|---|---|
|
||||||
|
| Bauart | Dreh-, Schiebe- (auf/vor Wand), Falt-, Pendel-, Karusselltür, reiner Durchbruch |
|
||||||
|
| Blattzahl/-teilung | 1-/2-flügelig, Gangflügel + Standflügel (unterschiedliche Breite), Seitenteile links/rechts |
|
||||||
|
| Blattausführung | glatt, kassettiert, Glasfüllung (Anteil/Sprossenbild), Brandschutz-/Schallschutz-Kennwert |
|
||||||
|
| Rahmen/Zarge | Zarge vs. Blockrahmen, Rahmenbreite je Kante, Zargentiefe, Bekleidung/Abdeckleiste, Falz |
|
||||||
|
| Schwelle | ohne, Alu-Flachschwelle, Anschlagdichtung, Bodenanschluss/Gefälle bei Aussentüren |
|
||||||
|
| Sturz/Oberlicht | festverglastes Oberlicht mit eigenem Rahmen, Kämpfer, Sprossenbild |
|
||||||
|
| Seitenteile | fest verglaste Seitenteile links/rechts, eigene Breite |
|
||||||
|
| Form | rechteckig, Rundbogen, Segmentbogen, Stichbogen — bei Aussen-/Haustüren verbreitet |
|
||||||
|
| Beschlag | Drücker/Knauf-Typ, Schild, Schliesszylinder, Bänder sichtbar/verdeckt |
|
||||||
|
| 2D-Darstellung | Blatt + Schwenkbogen (Grundriss), eigene Ansichtssymbolik in Schnitt/Elevation, Sturzlinien |
|
||||||
|
| Material/Schichten | Blatt-Kernaufbau (bei Brand-/Schallschutztüren mehrschichtig, analog Wand) |
|
||||||
|
| IFC-Rolle | `IfcDoor` mit `IfcDoorType` (PredefinedType), `OverallWidth/Height`, `IfcDoorPanelProperties`, Void in der Wirtswand |
|
||||||
|
|
||||||
|
### 2.2 IST in DOSSIER
|
||||||
|
|
||||||
|
`Opening` mit `kind: "door"` (`src/model/types.ts:1068`) referenziert
|
||||||
|
optional einen `DoorType` (`types.ts:296`). Vorhanden am Typ: `kind`
|
||||||
|
("dreh"/"schiebe"/"wandoeffnung", :303), `leafCount` (1|2, :305), `leafStyle`
|
||||||
|
("glatt"/"kassette"/"glas", :307), `glazingRatio` (:309), `frameThickness`/
|
||||||
|
`frameDepth`/`frameKind`("zarge"/"blockrahmen")/`frameWidth`
|
||||||
|
(:311-328), `insetFromFace`/`insetFace` (:335-337), `transomHeight` (:343),
|
||||||
|
`threshold` (:349). Am Element selbst: `swing`/`hinge`/`swingAngle`/
|
||||||
|
`openingDir` (:1104-1108), `lintelLines` (Sturzlinien, :1119) und
|
||||||
|
`doorType: "normal"|"wandoeffnung"` (:1100) — ein zweites, mit `DoorType.kind`
|
||||||
|
teilweise redundantes Feld.
|
||||||
|
|
||||||
|
Renderer-Konsum, real geprüft:
|
||||||
|
|
||||||
|
- **2D-Blatt ist IMMER einflügelig.** `addOpeningSymbol` (Tür-Zweig,
|
||||||
|
`src/plan/generatePlan.ts:2091-2213`) zeichnet genau EINE Blattlinie
|
||||||
|
(`sym.hinge → sym.openEnd`) und EINEN Schwenkbogen — `DoorType.leafCount`
|
||||||
|
wird an keiner Stelle in `generatePlan.ts` gelesen (kein Treffer für
|
||||||
|
`leafCount` im ganzen Plan-Renderer). Eine zweiflügelige Tür sieht im Plan
|
||||||
|
aus wie eine einflügelige.
|
||||||
|
- **3D genauso**: `resolveOpeningFrame` (`src/plan/toWalls3d.ts:1861-1886`)
|
||||||
|
setzt für Türen hart `wingCount: 1`, unabhängig von `leafCount`.
|
||||||
|
- `leafStyle` wirkt NUR binär: `"glas"` schaltet eine volle Verglasung frei
|
||||||
|
(`glazed: dt.leafStyle === "glas"`, `toWalls3d.ts:1883`) — `glazingRatio`
|
||||||
|
(Teilverglasung, z. B. 60 % Glasanteil im oberen Blattbereich) wird an
|
||||||
|
keiner Stelle gelesen. "kassette" (Kassettentür) hat keine eigene Geometrie,
|
||||||
|
fällt auf dieselbe Quader-Darstellung wie "glatt" zurück.
|
||||||
|
`frameKind: "blockrahmen"` wirkt NUR im 2D-Rahmenband
|
||||||
|
(`generatePlan.ts:1996`, `isBlock`), im 3D gibt es keinen Unterschied zur
|
||||||
|
Zarge (`frameMeshesForOpening`, `toWalls3d.ts:1936`, kennt kein
|
||||||
|
`frameKind`).
|
||||||
|
- `threshold` steuert `hasSill` im 3D-Rahmen (`toWalls3d.ts:1880`), was einen
|
||||||
|
einfachen Schwellen-Riegel zeichnet — kein eigenes Schwellenprofil,
|
||||||
|
keine Gefälle-/Dichtungsdarstellung.
|
||||||
|
- Es gibt eine ZWEITE, ältere Tür-Repräsentation: `Door`
|
||||||
|
(`types.ts:1333`, `project.doors: Door[]`) mit eigenem Symbol-Renderer
|
||||||
|
`addDoorSymbol` (`generatePlan.ts:1840-1899`) — strukturell identisch zum
|
||||||
|
`Opening`-Pfad, aber ohne jeden Typ-Bezug. Zwei parallele Datenwege für
|
||||||
|
dieselbe Bauteilart sind selbst technische Schuld, nicht nur ein
|
||||||
|
BIM-Feature-Gap.
|
||||||
|
- Form (Rundbogen etc.) existiert nicht: `Opening.width`/`height` sind ein
|
||||||
|
reines Rechteck, `wallGaps`/`buildWallFootprints`
|
||||||
|
(`generatePlan.ts:1363-1457`) schneiden nur rechteckige Bänder aus der
|
||||||
|
Wand-Poché.
|
||||||
|
- IFC-Export: `IfcDoor` wird erzeugt (`src/export/exportIfc.ts:30-31`), aber
|
||||||
|
als reine Box-Geometrie ohne `IfcDoorType`/`PredefinedType` und ohne
|
||||||
|
`IfcOpeningElement`-Void (bewusste Design-Entscheidung, siehe Kommentar
|
||||||
|
`exportIfc.ts:18-31`: das Loch steckt bereits im geschnittenen Wand-Mesh).
|
||||||
|
|
||||||
|
### 2.3 Lücken (Tür)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Zweiflügelige Tür rendert 2 Blätter (2D+3D) | ✗ (`leafCount` ungelesen) | **P0** | 2 PT |
|
||||||
|
| Teilverglasung nach `glazingRatio` (2D-Linie + 3D-Split) | ✗ | P1 | 1.5 PT |
|
||||||
|
| Kassettentür eigene Blattgeometrie (Füllungsfelder) | ✗ | P2 | 2 PT |
|
||||||
|
| `frameKind` (Blockrahmen) auch im 3D wirksam | ~ | P1 | 1 PT |
|
||||||
|
| Seitenteile (feste Verglasung links/rechts der Tür) | ✗ | P1 | 2 PT |
|
||||||
|
| Rundbogen-/Segmentbogen-Türform | ✗ | P2 | 4 PT (braucht gekrümmten Wandausschnitt, s. §5.3) |
|
||||||
|
| Schwellenprofil (Alu-Flachschwelle, Dichtung) statt Riegel | ✗ | P2 | 1 PT |
|
||||||
|
| `Door`/`Opening`-Doppelpfad konsolidieren | technische Schuld | P1 | 3 PT (Migration) |
|
||||||
|
| Beschlag (Drücker/Knauf) als Mesh + 2D-Symbol | ✗ | P2 | 1.5 PT |
|
||||||
|
| IFC `IfcDoorType`/PredefinedType/OverallWidth-Height-Properties | ~ | P2 | 1 PT |
|
||||||
|
|
||||||
|
### 2.4 Umsetzungsvorschlag
|
||||||
|
|
||||||
|
**Modell**: `DoorType.leafs?: { width: number; hingeSide: "left"|"right";
|
||||||
|
fixed?: boolean }[]` analog `WindowType.sashes` (`SashDef`, `types.ts:367`) —
|
||||||
|
bewusst dieselbe Struktur, damit `resolveSashSpans` (`toWalls3d.ts:2019`) UND
|
||||||
|
die 2D-Pfostenlinien-Logik direkt wiederverwendet werden können, statt eine
|
||||||
|
Tür-eigene Variante zu bauen. Ein `fixed: true`-Leaf ist das Seitenteil.
|
||||||
|
`glazingRatio` wandert vom Skalar zu einer klaren Geometrie: Kämpferhöhe
|
||||||
|
innerhalb des Blatts, gerendert wie das bestehende Oberlicht
|
||||||
|
(`transomHeight`), nur INNERHALB des Blattrahmens statt darüber.
|
||||||
|
|
||||||
|
**2D**: `addOpeningSymbol` (Tür-Zweig) über die Leaf-Liste iterieren statt
|
||||||
|
einer festen Blattlinie; pro Leaf ein eigenes `hinge`/`swing` (Default:
|
||||||
|
alternierend wie bei Fenstern, `sashesOfWindowType`, `types.ts:2132`).
|
||||||
|
|
||||||
|
**3D**: `resolveOpeningFrame` liefert `wingCount = leafs.length` statt hart 1;
|
||||||
|
`frameMeshesForOpening` (bereits generisch über `params.sashes`) übernimmt
|
||||||
|
die Mehrflügel-Darstellung ohne Änderung — das ist der Vorteil der
|
||||||
|
Struktur-Wiederverwendung.
|
||||||
|
|
||||||
|
**UI**: Im Tür-Editor (sofern nach dem Muster von
|
||||||
|
`window-editor-vectorworks-study.md` gebaut) eine Flügeltabelle wie bei
|
||||||
|
Fenstern, nur mit Tür-Vokabular (Gangflügel/Standflügel statt Flügel 1/2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fenster (Window)
|
||||||
|
|
||||||
|
Für Fenster existiert bereits eine vollständige Referenzmatrix in
|
||||||
|
`docs/design/window-editor-vectorworks-study.md` §2 (Basiseinstellungen,
|
||||||
|
Grösse, Brüstung, Rahmen, Flügeltabelle, Laibung/Form/Ober-Unterlicht,
|
||||||
|
Sonnenschutz/Beschlag, Attribute/IFC) mit eigener P0–P3-Phasierung. Diese
|
||||||
|
Studie ist der massgebliche Bezugspunkt; hier nur die Delta-Punkte, die dort
|
||||||
|
fehlen oder seither vom IST abweichen.
|
||||||
|
|
||||||
|
### 3.1 IST-Ergänzung (was die Studie nicht/knapp behandelt)
|
||||||
|
|
||||||
|
- `WindowType.glazing` (einfach/zweifach/dreifach) ist bis heute NICHT
|
||||||
|
renderwirksam — bestätigt weiterhin: `glazingPanesOf()` (`types.ts:2149`)
|
||||||
|
wird von `glassPanesForOpening` in `toWalls3d.ts` konsumiert, aber die
|
||||||
|
Studie selbst vermerkt (Zeile 61-65 dort), dass der 2D-Pfad die
|
||||||
|
Scheibenzahl weiterhin allein aus `DetailLevel` ableitet, nicht aus
|
||||||
|
`glazingPanes`. Das ist über ein Jahr nach der Studie noch offen — ein
|
||||||
|
Hinweis, dass P0/P1-Posten aus Fenster-Studien real liegen bleiben, wenn
|
||||||
|
niemand sie explizit nachzieht.
|
||||||
|
- `sillBoard` (Fensterbank keine/innen/aussen/beide, `types.ts:447`) ist
|
||||||
|
weiterhin ungenutzt (kein Treffer für `sillBoard` in `toWalls3d.ts` oder
|
||||||
|
`generatePlan.ts` ausserhalb der Typ-Definition und des Editors) —
|
||||||
|
entspricht dem in der Studie als P1 markierten "Fensterbank erstellen".
|
||||||
|
|
||||||
|
### 3.2 Architektonische Grenze: gekrümmte Öffnungen
|
||||||
|
|
||||||
|
Sowohl die Fenster-Studie (§2.6, "Form Eckig/Schräg/Spitz/Rund", P2, 4 PT)
|
||||||
|
als auch dieser Auftrag nennen Rundbogen-/Spitzbogenfenster. Das ist teurer,
|
||||||
|
als die Aufwandschätzung suggeriert, weil das gesamte Öffnungsmodell auf
|
||||||
|
GERADEN Bändern basiert: `buildWallFootprints`/`wallGaps`
|
||||||
|
(`generatePlan.ts:1363-1457`) schneiden ein rechteckiges Intervall `[from,to]`
|
||||||
|
entlang der Wandachse aus der Poché, `openingAxisBox`
|
||||||
|
(`toWalls3d.ts:1739`) baut im 3D ebenso einen achsparallelen Quader. Eine
|
||||||
|
Bogenform braucht entweder (a) eine gekrümmte Zusatzkontur, die die
|
||||||
|
Rechteck-Aussparung oben kappt (2D: zusätzliche Polygon-Boolean gegen die
|
||||||
|
Poché; 3D: gekrümmte Deckfläche statt ebenem Sturz) oder (b) ein komplett
|
||||||
|
neues, polygonbasiertes Öffnungsmodell. Vorschlag: (a) zuerst — ein
|
||||||
|
`headShape: "eckig"|"segment"|"spitz"|"rund"` mit Zusatzparametern
|
||||||
|
(Stichhöhe/Radius), das NUR die obere Kante der bestehenden Rechteck-Öffnung
|
||||||
|
ersetzt, während Pfosten/Sohlbank rechteckig bleiben. Deckt die reale
|
||||||
|
Mehrheit der Fälle (Haustür mit Rundbogen, Dachflächenfenster-Giebel) ohne
|
||||||
|
das Kernmodell umzubauen.
|
||||||
|
|
||||||
|
### 3.3 Lücken (Fenster, Delta zur Studie)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `glazing`/`glazingPanes` 2D-wirksam (Scheibenzahl statt nur DetailLevel) | ~ (weiter offen seit Studie) | **P0** | 1 PT |
|
||||||
|
| `sillBoard` gerendert (2D-Kontur + 3D-Box) | ✗ | P1 | 2 PT |
|
||||||
|
| Kopfform (Rundbogen/Spitzbogen/Schräge) über Zusatzkontur | ✗ | P2 | 5 PT (s. §3.2) |
|
||||||
|
| Echtes Sprossengitter (Glasteilung UNABHÄNGIG von Flügelrahmen) | ✗ | P1 | 2 PT |
|
||||||
|
| Alle übrigen Fenster-Lücken | siehe window-editor-vectorworks-study.md §2/§4 | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Dach (Roof)
|
||||||
|
|
||||||
|
### 4.1 Was ein vollständiges BIM-Tool bietet
|
||||||
|
|
||||||
|
| Bereich | Typische Parameter |
|
||||||
|
|---|---|
|
||||||
|
| Grundform | Pult, Sattel, Walm, Krüppelwalm, Zeltdach, Mansarde (mit Untertyp Giebel-/Walm-/Zeltmansarde), Flach/Terrassendach, Sheddach, Tonnendach, freie Neigungsflächen je Kante |
|
||||||
|
| Grundriss | beliebiges Polygon (nicht nur Rechteck), automatische Kehlen/Grate über Straight-Skeleton bei L-/T-/U-Grundrissen |
|
||||||
|
| Neigung | je Dachfläche einzeln editierbar, unterschiedliche Neigungen je Seite |
|
||||||
|
| Schichtaufbau | Eindeckung (Ziegel/Blech/Bitumen), Lattung, Konterlattung, Unterdach/-spannbahn, Sparren/Dämmung zwischen Sparren, Dampfbremse, Innenverkleidung — analog Wandaufbau, mit Deckenanschluss |
|
||||||
|
| Überstand | Traufe und Ortgang UNABHÄNGIG editierbar (Betrag + Ausbildung), Aufschiebling (Neigungsknick am Traufende), Ortganddetail (Windbrett, Blech) |
|
||||||
|
| Kniestock/Drempel | vertikale Wandaufkantung zwischen Deckenoberkante und Dach-Traufpunkt, definiert First-/Trauf-Geometrie mit |
|
||||||
|
| Öffnungen im Dach | Dachflächenfenster (schräg, in der Dachebene), Dachgauben (Schlepp-, Sattel-, Walm-, Spitzgaube, Fledermausgaube) als eigenständige Sekundärdächer mit eigenem First |
|
||||||
|
| First/Grat/Kehl/Ortgang | eigene Linientypen in 2D-Ansicht (Dachaufsicht) UND im Schnitt sichtbar (Sparrenlage, Dämmstärke) |
|
||||||
|
| Material/Poché | Dachfläche in der Aufsicht mit Eindeckungs-Symbol/-Schraffur (Ziegel-Textur o. Ä.), im Schnitt Vollschichten wie eine geneigte Wand |
|
||||||
|
| IFC-Rolle | `IfcRoof` (aggregiert `IfcRoofType`), Dachflächen selbst oft als `IfcSlab`-artige Elemente mit `IfcMaterialLayerSetUsage`, Gauben als eigene `IfcRoof`/`IfcBuildingElementProxy`-Unterobjekte |
|
||||||
|
|
||||||
|
### 4.2 IST in DOSSIER
|
||||||
|
|
||||||
|
`Roof` (`src/model/types.ts:1022-1047`) rechnet ausschliesslich auf der
|
||||||
|
**Bounding-Box** des Umrisses (Kommentar `types.ts:1016-1020`: "First entlang
|
||||||
|
einer Hauptachse ... die gängige, intuitive Vereinfachung"). Geometrie kommt
|
||||||
|
aus `roofGeometry()`/`computeCanonical()` (`src/geometry/roof.ts:80-267`),
|
||||||
|
explizit **"ohne Straight-Skeleton"** (Kommentar `roof.ts:5`). Unterstützte
|
||||||
|
`RoofShape` (`types.ts:1013`): flach/pult/sattel/walm/mansarde/zelt — je EINE
|
||||||
|
feste Berechnung, keine Untertypen. Mansarde hat eine feste, nicht editierbare
|
||||||
|
Knick-Geometrie (`d1 = halfD * 0.4`, `roof.ts:181` — 40 % der Tiefe von der
|
||||||
|
Traufe, hart codiert, keine Möglichkeit den Umbruchpunkt zu verschieben).
|
||||||
|
|
||||||
|
Konkrete, verifizierte Lücken:
|
||||||
|
|
||||||
|
- **Kein Schichtaufbau.** `Roof.thickness` (`types.ts:1044`) ist ein
|
||||||
|
einzelner Skalar. Er wird an KEINER Stelle im Code gelesen (`grep
|
||||||
|
"roof.thickness"` über `toWalls3d.ts`/`generatePlan.ts`/`roof.ts` liefert
|
||||||
|
null Treffer) — das Feld existiert im Typ, ist aber komplett tot. Die
|
||||||
|
3D-Dachfläche ist eine unendlich dünne, einfarbige Fläche
|
||||||
|
(`emitRoofs()`, `src/plan/toWalls3d.ts:1670-1684`: nur `plane.pts`/
|
||||||
|
`gable`-Fans, EINE Farbe `ROOF_RGB` bzw. `roof.color`, keine Dicke, keine
|
||||||
|
Materialschichten).
|
||||||
|
- **Kein `RoofType`.** `Project` hat `wallTypes`, `ceilingTypes?`,
|
||||||
|
`doorTypes?`, `windowTypes?`, `stairTypes?` (`types.ts:1936-1958`), aber
|
||||||
|
kein `roofTypes?`. Es gibt keinen Bauteil-Bibliothekseintrag für Dächer.
|
||||||
|
- **2D-Grundriss zeigt keine Poché.** `addRoof()`
|
||||||
|
(`src/plan/generatePlan.ts:2549-2605`) zeichnet AUSSCHLIESSLICH Linien
|
||||||
|
(Traufe/First/Grat/Knick, je feste Strichstärke `ROOF_EAVES_MM`/
|
||||||
|
`ROOF_RIDGE_MM`/`ROOF_HIP_MM`) — keine Fläche, keine Schraffur, keine
|
||||||
|
Materialkennzeichnung. Deckungsgleich mit Wand/Decke, die BEIDE eine
|
||||||
|
Poché-Füllung mit Schraffur haben (`addWallPoche`, `addCeilingPoche`),
|
||||||
|
bleibt das Dach in der Aufsicht ein reines Liniendiagramm.
|
||||||
|
- **Kein Schnitt.** `src/plan/toSection.ts` hat KEINE Roof-Behandlung (kein
|
||||||
|
Treffer für `roof`/`Roof` im gesamten Datei-Grep). Ein Vertikalschnitt
|
||||||
|
durch ein Gebäude mit Satteldach zeigt heute keine Dachlinie, keine
|
||||||
|
Sparrenlage, keine Firstprojektion — ein Kernstück der BIM-1:1-Erwartung
|
||||||
|
fehlt vollständig.
|
||||||
|
- **Kein IFC.** `exportIfc.ts` erzeugt `IfcWall`, `IfcSlab`, `IfcDoor`/
|
||||||
|
`IfcWindow`, `IfcStair`, `IfcBuildingElementProxy` (Kommentar
|
||||||
|
`exportIfc.ts:18-36`) — `Roof`/`IfcRoof` ist in der Abbildungsliste NICHT
|
||||||
|
aufgeführt und wird beim Export komplett übersprungen.
|
||||||
|
- **Kein Straight-Skeleton, kein L-Grundriss mit Kehle.** Ein L-förmiger
|
||||||
|
Baukörper mit durchgehendem Satteldach (Kehle an der Innenecke) lässt sich
|
||||||
|
nicht abbilden — die BBox-Rechnung würde ein Rechteck über die ganze
|
||||||
|
L-Ausdehnung legen.
|
||||||
|
- **Keine Gauben, keine Dachfenster.** Kein Treffer für Gaube/Dormer im
|
||||||
|
gesamten `src`-Baum (verifiziert per Suche).
|
||||||
|
- **Kein Kniestock als Bauteilbeziehung.** `baseElevation` (`types.ts:1042`)
|
||||||
|
erlaubt zwar, die Traufhöhe manuell über die Geschoss-Oberkante zu heben
|
||||||
|
(ein Kommentar in `ObjectInfoPanel.tsx:549` erwähnt das explizit als
|
||||||
|
Nutzungsmuster), aber es gibt keine Wand-Dach-Kopplung, die einen
|
||||||
|
Drempel/Kniestock als eigenes, vermasstes Bauteil führt — der Nutzer muss
|
||||||
|
die Zahl manuell abstimmen.
|
||||||
|
- **Traufe/Ortgang nicht unabhängig.** `overhang` (`types.ts:1038`) ist EIN
|
||||||
|
Wert "ringsum" — Traufüberstand und Ortgangüberstand (oft unterschiedlich,
|
||||||
|
z. B. 0.5 m Traufe / 0.3 m Ortgang) sind nicht trennbar.
|
||||||
|
- **UI**: `ObjectInfoPanel.tsx:479-561` bietet volle Instanz-Bearbeitung
|
||||||
|
(shape/ridgeAxis/pitch/pitchUpper/width/depth/overhang/thickness/
|
||||||
|
baseElevation), aber keine Typ-/Stil-Verwaltung (kein `ResourceManager`-
|
||||||
|
Eintrag für Dächer, anders als Wand/Decke/Tür/Fenster/Treppe).
|
||||||
|
|
||||||
|
### 4.3 Dach-Schichtlogik — konkreter Vorschlag (Kernthema dieses Dokuments)
|
||||||
|
|
||||||
|
Der Vorschlag überträgt exakt das `WallType`/`CeilingType`-Muster:
|
||||||
|
|
||||||
|
```
|
||||||
|
export interface RoofType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Aussen (Eindeckung) → innen (Verkleidung), analog WallType.layers. */
|
||||||
|
layers: Layer[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein neuer Layer-Typ nötig — `Layer` (`types.ts:245`) ist bereits
|
||||||
|
"Bauteil + Dicke + optionaler Fugen-Linienstil", unabhängig davon ob sie
|
||||||
|
horizontal (Decke), vertikal (Wand) oder GENEIGT (Dach) gestapelt wird, weil
|
||||||
|
die Stapel-Richtung beim jeweiligen Renderer entschieden wird, nicht im
|
||||||
|
Datentyp. Typische Schichtfolge eines Steildachs (Eindeckung → Konterlattung
|
||||||
|
→ Lattung → Unterdach/Unterdeckbahn → Sparren+Dämmung → Dampfbremse →
|
||||||
|
Innenverkleidung/GKB) bildet sich 1:1 auf `Layer[]` ab, jede Schicht bekommt
|
||||||
|
ein `Component` mit eigener Schraffur/Farbe/Material wie bei Wand/Decke.
|
||||||
|
|
||||||
|
`Roof.thickness: number` wird zu `Roof.roofTypeId?: string` (Verweis, analog
|
||||||
|
`Ceiling.ceilingTypeId`) mit optionaler `thicknessOverride?: number` — exakt
|
||||||
|
das Muster aus `Ceiling.thickness?` (`types.ts:952-953`, "Optionale
|
||||||
|
Übersteuerung der Gesamtdicke ... sonst Typ-Dicke"). `Project.roofTypes?:
|
||||||
|
RoofType[]` ergänzt die Bibliothek.
|
||||||
|
|
||||||
|
**3D-Konsum**: `emitRoofs()` (`toWalls3d.ts:1670`) baut heute EINE
|
||||||
|
Dreiecksfläche je Dachfläche+Giebel. Mit Layern wird daraus — analog
|
||||||
|
`emitSlabs()` (`toWalls3d.ts:1352-1419`, das bereits genau diese
|
||||||
|
Proportional-Stapel-Logik für Decken hat) — ein Stapel PARALLEL versetzter
|
||||||
|
Flächen entlang der Flächennormalen (nicht entlang Z wie bei der Decke,
|
||||||
|
sondern entlang der Dachflächen-Normalen `n`, siehe `RoofGeometry.planes`
|
||||||
|
in `roof.ts:19-21`). Jede Schicht wird zum eigenen `RMesh` mit eigener Farbe/
|
||||||
|
Schraffur-Metadaten (`RCutMeta`, wie bei `emitSlabs`). Der Versatz macht
|
||||||
|
zugleich die Dachdicke sichtbar (heute unendlich dünn) — ein Nebengewinn ohne
|
||||||
|
Mehraufwand.
|
||||||
|
|
||||||
|
**2D-Konsum (Grundriss/Aufsicht)**: `addRoof()` bekommt eine Poché-Fläche
|
||||||
|
analog `addCeilingPoche` — Füllung mit der `viewHatchId` der obersten Schicht
|
||||||
|
(Eindeckungssymbol), gerahmt von den bestehenden Traufe/First/Grat/Knick-
|
||||||
|
Linien (die bleiben unverändert, sie sind flächen-unabhängig).
|
||||||
|
|
||||||
|
**2D-Konsum (Schnitt, der grössere Umbau)**: `toSection.ts` braucht einen
|
||||||
|
neuen Roof-Zweig. Ansatz: die Dachebene mit der Schnittebene schneiden
|
||||||
|
(Ebene-Ebene-Schnitt, da `RoofPlane` eben ist), daraus ein Liniensegment je
|
||||||
|
betroffener Dachfläche gewinnen, dann `Layer[]` senkrecht ZUR
|
||||||
|
Dachneigung als Bandsequenz auftragen (wie `resolveWallBands`,
|
||||||
|
`toWalls3d.ts:521`, aber gedreht um den Neigungswinkel `pitchDeg`) und mit
|
||||||
|
`splitSlabLayers`-Logik (`toSection.ts:633`) füllen. Das ist der aufwendigste
|
||||||
|
Einzelposten dieses Dokuments (siehe Prioritätsliste), aber ohne ihn bleibt
|
||||||
|
"Dach im Schnitt" eine reine Lücke.
|
||||||
|
|
||||||
|
**UI**: Ein `RoofType`-Eintrag im `ResourceManager`
|
||||||
|
(`src/ui/ResourceManager.tsx`), identisch zum bestehenden Ceiling-Typ-Editor
|
||||||
|
(Schicht-Liste, Dicke, Bauteil-Zuweisung) — kein neues UI-Paradigma.
|
||||||
|
|
||||||
|
### 4.4 Dach 1:1 — Formen, Kehlen, Gauben (konkreter Vorschlag)
|
||||||
|
|
||||||
|
Reihenfolge nach Aufwand/Nutzen, NICHT alles auf einmal:
|
||||||
|
|
||||||
|
1. **Traufe/Ortgang trennen**: `overhang` → `{ eaves: number; gable: number
|
||||||
|
}`. Kleine, lokale Änderung in `roof.ts` (zwei statt einer Offset-Variable
|
||||||
|
je nach Kantentyp), grosser optischer Gewinn (das ist die häufigste
|
||||||
|
Rückmeldung "sieht nicht echt aus" bei Steildächern mit gleich langem
|
||||||
|
Überstand allseitig).
|
||||||
|
2. **Mansard-Untertyp** (`mansardVariant: "walm"|"giebel"|"zelt"`, wie in der
|
||||||
|
älteren Planung `elements.md:298` bereits vorgesehen, aber nie gebaut):
|
||||||
|
steuert nur, wie die STIRNSEITE der Mansarde behandelt wird (heute IMMER
|
||||||
|
`gables` = vertikale Giebelfläche, `roof.ts:200-203`) — bei "walm" wird
|
||||||
|
daraus eine geneigte Fläche wie beim Walmdach. Mittlerer Aufwand, da die
|
||||||
|
Mansard-Berechnung (`roof.ts:176-206`) bereits alle Eckpunkte hat, nur die
|
||||||
|
Gable-Erzeugung muss konditional werden.
|
||||||
|
3. **Editierbarer Mansard-Knickpunkt** (`kinkDepthRatio?: number` statt hart
|
||||||
|
`0.4`, `roof.ts:181`) — 0.5 PT, reine Parametrisierung einer bestehenden
|
||||||
|
Konstante.
|
||||||
|
4. **Krüppelwalm** (`hipTruncation?: number`, 0 = voller Walm, 1 = voller
|
||||||
|
Giebel/Sattel): der First bleibt voll lang, nur ein kleines Walmstück am
|
||||||
|
First-Ende — technisch eine Variation der bestehenden `walm`-Berechnung
|
||||||
|
(`roof.ts:147-174`, der First-Verkürzungs-Faktor `halfD` wird
|
||||||
|
parametrisiert statt fix).
|
||||||
|
5. **Dachflächenfenster** (kein neues Bauteil — ein `Opening`-ähnliches
|
||||||
|
Element, das in eine Dachfläche statt eine Wand einschneidet): neuer
|
||||||
|
`hostRoofId`-Pfad, eigenständiger Vorschlag; lohnt sich erst NACH der
|
||||||
|
Schichtlogik, weil das Fenster sonst nicht "in die Dämmebene" passt.
|
||||||
|
6. **Gauben** (Schlepp-/Sattelgaube als eigenständiges Sekundär-`Roof` mit
|
||||||
|
eigenem `outline`/`baseElevation`, das in die Hauptdachfläche einschneidet):
|
||||||
|
grösster Einzelposten, weil er eine echte Boolean-Verschneidung zwischen
|
||||||
|
zwei Dachkörpern braucht (ähnlich der bestehenden Wand-Boolean-Dominanz
|
||||||
|
über `joinPriority`, aber räumlich in 3D). Realistisch erst nach einem
|
||||||
|
Mesh-Boolean-Werkzeug (die begonnene truck-Integration,
|
||||||
|
`src-tauri/trucksolid/`, ist ein Kandidat dafür).
|
||||||
|
7. **L-/T-Grundriss mit Kehle (Straight-Skeleton)**: bewusst NICHT vor 6,
|
||||||
|
weil es die Bounding-Box-Vereinfachung komplett ersetzt (neue
|
||||||
|
Geometrie-Engine, kein inkrementeller Ausbau von `roof.ts`) — separates,
|
||||||
|
grosses Vorhaben, siehe Prioritätsliste.
|
||||||
|
|
||||||
|
### 4.5 Lücken (Dach)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `RoofType`/`Layer[]`-Schichtaufbau (Modell) | ✗ (`thickness` toter Skalar) | **P0** | 3 PT |
|
||||||
|
| 3D-Schichten (gestapelte Flächen entlang Normalen) | ✗ | **P0** | 3 PT |
|
||||||
|
| 2D-Aufsicht-Poché (Eindeckungssymbol) | ✗ | P1 | 1.5 PT |
|
||||||
|
| Dach im Vertikalschnitt (Ebene-Ebene-Schnitt + Bänder) | ✗ | **P0** | 5 PT |
|
||||||
|
| Traufe/Ortgang getrennter Überstand | ✗ | **P0** | 1 PT |
|
||||||
|
| Mansard-Untertyp (Walm/Giebel/Zelt-Stirn) | ✗ | P1 | 2 PT |
|
||||||
|
| Editierbarer Mansard-Knick | ✗ | P2 | 0.5 PT |
|
||||||
|
| Krüppelwalm | ✗ | P2 | 1.5 PT |
|
||||||
|
| Dachflächenfenster | ✗ | P2 | 4 PT |
|
||||||
|
| Gauben (Boolean-Einschnitt) | ✗ | P3 | 8+ PT |
|
||||||
|
| L-/T-Grundriss, Straight-Skeleton-Kehle | ✗ | P3 | 10+ PT |
|
||||||
|
| `RoofType`-Ressourcen-UI | ✗ | P1 (folgt aus RoofType) | 1 PT |
|
||||||
|
| IFC `IfcRoof`-Export | ✗ | P2 | 1.5 PT |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Decke (Ceiling / Slab)
|
||||||
|
|
||||||
|
### 5.1 Was ein vollständiges BIM-Tool bietet
|
||||||
|
|
||||||
|
| Bereich | Typische Parameter |
|
||||||
|
|---|---|
|
||||||
|
| Grundfläche | beliebiges Polygon inkl. Aussparungen (Treppenauge, Schacht, Kamindurchbruch) |
|
||||||
|
| Schichtaufbau | Rohdecke, Trittschalldämmung, Estrich, Bodenbelag — analog Wand, mit Deckenspiegel/Untersicht (abgehängte Decke, Akustikplatten) als eigene Schicht(en) |
|
||||||
|
| Randausbildung | gerader Rand, auskragender Balkon-/Vordachrand mit thermischer Trennung (Isokorb/Randdämmstreifen), Randschalung/Abschalungsprofil, Attika-Anschluss, Tropfkante |
|
||||||
|
| Deckenspiegel | abgehängte Untersicht mit eigener Höhe/Raster (Akustik-/Gipskarton-Decke), UNABHÄNGIG von der tragenden Rohdecke |
|
||||||
|
| Öffnungen | Deckenaussparungen als eigene, editierbare Polygone (nicht nur Gesamtumriss) |
|
||||||
|
| Neigung | geneigte Decke (Garagenrampe, Terrasse mit Gefälle) — nicht nur horizontal |
|
||||||
|
| 2D-Darstellung | Aufsicht mit Ansichts-Poché (unaufgeschnitten), Schnitt mit Vollschichten je Lage, Deckenspiegel-Plan (reflected ceiling plan) als eigene Zeichnungsart |
|
||||||
|
| IFC-Rolle | `IfcSlab` (PredefinedType FLOOR/ROOF/BASESLAB), `IfcMaterialLayerSetUsage` für den Schichtaufbau, `IfcCovering` für abgehängte Decken |
|
||||||
|
|
||||||
|
### 5.2 IST in DOSSIER
|
||||||
|
|
||||||
|
`Ceiling` (`types.ts:928-1000`) hat bereits die stärkste Tiefe der vier
|
||||||
|
Bauteile in diesem Dokument: geschlossenes Umriss-Polygon (`outline`),
|
||||||
|
`ceilingTypeId` mit Legacy-Fallback auf `wallTypeId` (`getCeilingType`,
|
||||||
|
`types.ts:2089`), volle Attribut-Override-Kette (`foreground`/`background`/
|
||||||
|
`strokeWeight`/`hatchId` + `*Source`, wie bei `Wall`) und unabhängige
|
||||||
|
vertikale Bindung von OK/UK über `VerticalAnchor` (`top?`/`bottom?`,
|
||||||
|
`types.ts:990-999` — "floor"-gebunden oder "custom"-Z, exakt wie bei
|
||||||
|
`Wall.top`/`Wall.bottom`, `types.ts:887-893`). 3D-Schichtstapel ist
|
||||||
|
implementiert (`emitSlabs`, `toWalls3d.ts:1352-1419`), Schnitt-Schichtsplit
|
||||||
|
ebenfalls (`splitSlabLayers`, `toSection.ts:633`).
|
||||||
|
|
||||||
|
Verifizierte Lücken:
|
||||||
|
|
||||||
|
- **Keine Aussparungen.** `Ceiling.outline: Vec2[]` ist EIN geschlossenes
|
||||||
|
Polygon (`types.ts:936-939`) — kein `openings?: Vec2[][]` für
|
||||||
|
Treppenauge/Schacht/Kamin. Ein Treppenloch in der Decke muss heute über
|
||||||
|
die Aussenkontur der Decke "herumgeschnitten" werden (Decke als
|
||||||
|
komplexes, nicht-konvexes Polygon), nicht als saubere Innenaussparung.
|
||||||
|
(`elements.md:217` sah dieses Feld in der älteren Planung explizit vor,
|
||||||
|
es wurde nie in `types.ts` übernommen.)
|
||||||
|
- **Kein Randdetail.** Die Decke ist über die gesamte `outline` exakt
|
||||||
|
`thickness` dick, EINHEITLICH. Es gibt kein Feld für eine abweichende
|
||||||
|
Randausbildung (Aufkantung, Randdämmstreifen, Tropfkante, andere Dicke am
|
||||||
|
Balkonrand). Ein auskragender Balkon lässt sich zwar über eine erweiterte
|
||||||
|
`outline` modellieren, bekommt aber zwangsläufig denselben Vollschicht-
|
||||||
|
Aufbau wie die Innendecke — eine thermisch getrennte Balkonplatte
|
||||||
|
(Isokorb) ist nicht abbildbar.
|
||||||
|
- **Kein Deckenspiegel.** Abgehängte Untersicht (Akustik-/GKB-Decke mit
|
||||||
|
eigener, tieferer Kote) existiert nicht als eigenes Konzept — nur der
|
||||||
|
tragende Aufbau über `ceilingTypeId`.
|
||||||
|
Ein "Deckenspiegel-Plan" (reflected ceiling plan) fehlt als Zeichnungsart
|
||||||
|
komplett (`DrawingLevelKind`, `types.ts:722`, kennt nur "floor"/"section"/
|
||||||
|
"elevation"/"drawing").
|
||||||
|
(Randbemerkung: Beleuchtungsplanung/Deckenspiegel ist ein Elektro-Thema und
|
||||||
|
damit bewusst ausserhalb des DOSSIER-Kernscopes — siehe "nicht bauen",
|
||||||
|
Abschnitt 6. Die reine Geometrie einer zweiten, tiefer liegenden Fläche
|
||||||
|
bleibt aber ein legitimer BIM-1:1-Punkt.)
|
||||||
|
- **Keine Neigung.** `top`/`bottom` sind je EIN `VerticalAnchor` (ein
|
||||||
|
Z-Wert), keine Neigungsebene — eine geneigte Garagen-/Terrassendecke ist
|
||||||
|
nicht modellierbar, nur über mehrere ebene Teildecken behelfsweise
|
||||||
|
annäherbar.
|
||||||
|
- **2D-Aufsicht nutzt nur die erste Schicht.** `addCeilingPoche`
|
||||||
|
(`generatePlan.ts:2453-2536`) liest `wt.layers[0]` (Kommentar/Code
|
||||||
|
`generatePlan.ts:2465`) für die Ansichts-Schraffur — bei einer
|
||||||
|
mehrschichtigen Decke (z. B. Beton unten, Dämmung oben) zeigt die Aufsicht
|
||||||
|
immer nur die OBERSTE (erste) Schicht, was für eine unaufgeschnittene
|
||||||
|
Draufsicht baupraktisch korrekt ist (man sieht von unten die
|
||||||
|
Untersicht/erste Lage), aber nicht konfigurierbar ist, welche Lage als
|
||||||
|
"sichtbare" gilt, falls der Deckenaufbau umgekehrt sortiert wäre.
|
||||||
|
- IFC: `IfcSlab` wird erzeugt (`exportIfc.ts:28`), aber laut Kopfkommentar
|
||||||
|
(`exportIfc.ts:38-41`) bewusst OHNE `IfcMaterialLayerSet`/-`Usage` — der
|
||||||
|
Export verliert den Schichtaufbau, den DOSSIER intern bereits hat.
|
||||||
|
|
||||||
|
### 5.3 Deckenrand/Randterminierung — vertieft
|
||||||
|
|
||||||
|
Der Nutzer nennt explizit "Deckenränder, Stirnabschlüsse, Anschluss an Wand/
|
||||||
|
Aussenkante, auskragende Ränder, Randabschalung" als Schwerpunkt. IST-Bild:
|
||||||
|
keines davon existiert als eigenes Konzept — die Decke ist ein reines
|
||||||
|
Extrusions-Polygon. Konkreter Vorschlag, dreistufig nach Aufwand:
|
||||||
|
|
||||||
|
1. **Aussparungen** (`Ceiling.openings?: Vec2[][]`) — niedrigster Aufwand,
|
||||||
|
grösster praktischer Nutzen (Treppenauge ist im Wohnbau der Regelfall,
|
||||||
|
nicht die Ausnahme). 2D: zusätzliche Ausschnitts-Polygone in
|
||||||
|
`addCeilingPoche` (Loch im Fill, zusätzliche Randlinien). 3D: `emitSlabs`
|
||||||
|
bekommt Löcher im Extrusions-Profil (analog dem bereits vorhandenen
|
||||||
|
Loch-Schnitt bei Wand-Öffnungen, `wallMeshCut.ts`, als Vorlage).
|
||||||
|
2. **Randschicht-Override** (`Ceiling.edgeOverride?: { ringOffset: number;
|
||||||
|
ceilingTypeId: string }` — ein schmaler Innenring der Decke entlang des
|
||||||
|
Randes bekommt einen ANDEREN Layer-Aufbau, z. B. mit zusätzlicher
|
||||||
|
Randdämmschicht oder reduzierter Dicke für eine Tropfkante). Technisch:
|
||||||
|
`emitSlabs` erzeugt für den Ringbereich einen zweiten Layer-Stapel mit dem
|
||||||
|
Override-Typ, geometrisch als Offset-Polygon-Differenz (`outline` minus
|
||||||
|
`outline.offset(-ringOffset)`), eine Operation, die für Wandbänder
|
||||||
|
bereits ähnlich existiert (`buildWallFootprints`).
|
||||||
|
3. **Thermisch getrennte Auskragung (Isokorb-Fall)**: ein eigenes,
|
||||||
|
sekundäres `Ceiling`-Objekt für den auskragenden Teil mit eigenem
|
||||||
|
`ceilingTypeId` (dünnerer/anderer Aufbau) UND eigener `top`/`bottom`-
|
||||||
|
Bindung, das an die Hauptdecke stösst — kein neues Feld nötig, nur eine
|
||||||
|
UI-Erleichterung ("Deckenrand abtrennen"-Werkzeug, das die Decke entlang
|
||||||
|
einer gewählten Kante in zwei `Ceiling`-Objekte teilt). Niedrigster
|
||||||
|
Modell-Aufwand, weil er das bestehende Mehrfach-Decken-Prinzip nutzt statt
|
||||||
|
ein neues Konzept einzuführen.
|
||||||
|
|
||||||
|
### 5.4 Lücken (Decke)
|
||||||
|
|
||||||
|
| Feature | Status | Priorität | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Aussparungen (`openings?: Vec2[][]`) in 2D+3D | ✗ | **P0** | 3 PT |
|
||||||
|
| Randschicht-Override (Ringzone anderer Aufbau) | ✗ | P1 | 3 PT |
|
||||||
|
| Deckentrenn-Werkzeug für Isokorb-Fall (UI, kein neues Modellfeld) | ✗ | P1 | 1.5 PT |
|
||||||
|
| Geneigte Decke (Rampe/Gefälle) | ✗ | P2 | 3 PT |
|
||||||
|
| Deckenspiegel (zweite, abgehängte Fläche) | ✗ | P3 | 2 PT (reine Geometrie) |
|
||||||
|
| IFC `IfcMaterialLayerSetUsage` für Decke (UND Wand) | ~ (bewusst ausgelassen) | P2 | 2 PT |
|
||||||
|
| Konfigurierbare "oberste Schicht" für Aufsicht-Poché | ✓ implizit (Layer-Reihenfolge = Sortierung) | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Konsolidierte Priorisierung (über alle vier Bauteile)
|
||||||
|
|
||||||
|
Sortiert nach "1:1-Zuwachs pro Aufwand", nicht nach Aufwand allein:
|
||||||
|
|
||||||
|
1. **Dach-Schichtaufbau (Modell + 3D-Stapel)** — schliesst die grösste
|
||||||
|
strukturelle Lücke (totes `thickness`-Feld, kein `RoofType`) und liefert
|
||||||
|
sofort sichtbaren Tiefengewinn im 3D (§4.3, ~6 PT gesamt).
|
||||||
|
2. **Traufe/Ortgang getrennter Überstand** — 1 PT, sofortiger optischer
|
||||||
|
Sprung bei jedem Steildach-Projekt (§4.4 Punkt 1).
|
||||||
|
3. **Zweiflügelige Tür rendert wirklich 2 Blätter** — das `leafCount`-Feld
|
||||||
|
existiert seit der Türtyp-Einführung, wird aber komplett ignoriert; sehr
|
||||||
|
sichtbarer Bug-artiger Gap (§2.3, 2 PT).
|
||||||
|
4. **Deckenaussparungen** — Treppenauge ist der Wohnbau-Regelfall, heute nur
|
||||||
|
über Umweg (nicht-konvexes Aussenpolygon) lösbar (§5.3 Punkt 1, 3 PT).
|
||||||
|
5. **Fenster-Glazing 2D-wirksam** — seit über einem Jahr als P0 in der
|
||||||
|
Fenster-Studie dokumentiert und weiterhin offen; niedriger Aufwand,
|
||||||
|
sollte nicht liegen bleiben (§3.1, 1 PT).
|
||||||
|
6. **Dach im Vertikalschnitt** — grösster Einzelposten (5 PT), aber ohne ihn
|
||||||
|
bleibt jeder Gebäudeschnitt mit Steildach unvollständig; das ist die
|
||||||
|
Art Lücke, die bei einer Bemusterung/Baueingabe sofort auffällt.
|
||||||
|
7. **Deckenrand-Override / Isokorb-Trennwerkzeug** — folgt danach, weil er
|
||||||
|
auf demselben Mehrfach-Decken-Prinzip aufbaut wie Punkt 4.
|
||||||
|
8. **Mansard-Untertyp + editierbarer Knick** — mittlere Priorität, weil
|
||||||
|
Mansarde in der Schweiz/Süddeutschland baupraktisch häufig ist und die
|
||||||
|
heutige starre 40 %-Konstante sichtbar unrealistisch wirkt.
|
||||||
|
9. **Tür/Fenster-Rahmentiefe** (asymmetrische Rahmenbreiten, Beschlag,
|
||||||
|
Kopfform) — bewusst NACH den strukturellen Lücken, weil sie additive
|
||||||
|
Detailverbesserungen an einem bereits funktionierenden Pfad sind, während
|
||||||
|
1–7 fehlende oder falsch dargestellte Kernfunktionen betreffen.
|
||||||
|
10. **Gauben, L-Grundriss/Kehle, Dachflächenfenster** — grösste Einzel-
|
||||||
|
Aufwände (8–10+ PT), architektonisch am voraussetzungsreichsten (Boolean-
|
||||||
|
Werkzeug bzw. neue Geometrie-Engine); erst nach 1–9 angehen.
|
||||||
|
|
||||||
|
### 6.1 NICHT bauen (VW/Revit-Ballast ohne Nutzen für DOSSIER)
|
||||||
|
|
||||||
|
- **Vollständiger Massketten-/Bezugsapparat** (VW B1..B5/H1..H7,
|
||||||
|
Roh-/Fertigmass-Umschaltung) — DOSSIER arbeitet mit lichten Massen, das
|
||||||
|
genügt für ein Schweizer Wohnbau-/Kleinprojekt-Tool (bereits so in
|
||||||
|
`window-editor-vectorworks-study.md` §5 entschieden, hier bestätigt für
|
||||||
|
Dach/Decke: keine Dach-Rohmass-/Fertigmass-Unterscheidung).
|
||||||
|
- **Sichtbarkeitsmatrix 3D-Objekte × Ansichten** (Augen-Tabelle je
|
||||||
|
Kategorie×Ansicht×Detailstufe) — DOSSIERs Layer-Sichtbarkeit +
|
||||||
|
`DetailLevel` deckt den praktischen Bedarf; eine volle Matrix ist
|
||||||
|
Verwaltungsaufwand ohne Mehrwert für Einzelprojekte.
|
||||||
|
- **Eckfenster/Eckdach als generischer Sonderfall über zwei Wirtsbauteile**
|
||||||
|
— seltene Geometrie, hoher Modellierungsaufwand (zwei Hosts, ein Element);
|
||||||
|
bei Bedarf als manueller Workaround (zwei separate Öffnungen) lösbar.
|
||||||
|
Ebenso: freie Neigungsflächen je Dachkante (VW erlaubt jede Kante einzeln
|
||||||
|
zu kippen) — für die abgedeckten Standardformen (Pult/Sattel/Walm/
|
||||||
|
Mansarde/Zelt/Krüppelwalm) nicht nötig; wer eine Freiform-Dachlandschaft
|
||||||
|
braucht, ist besser mit den `ExtrudedSolid`/truck-Werkzeugen bedient.
|
||||||
|
- **Vollständige Beschlags-/Baubeschlag-Bibliothek** (Marken-Beschlagsätze,
|
||||||
|
Schliessplan) — Beschlag als generisches Griff-Mesh (§2.4) genügt für die
|
||||||
|
visuelle 1:1-Wirkung; eine Beschlags-PRODUKTBIBLIOTHEK ist Kataloggeschäft,
|
||||||
|
kein CAD-Kernfeature.
|
||||||
|
- **Deckenspiegel als vollwertige Beleuchtungsplanung** (Leuchtenraster,
|
||||||
|
Lichtberechnung) — reine Geometrie einer zweiten Fläche ist ok (P3), die
|
||||||
|
Elektro-/Lichtplanungslogik selbst liegt ausserhalb des Tool-Zwecks.
|
||||||
|
- **IFC `IfcOpeningElement`/Void-Semantik nachrüsten** — bewusste
|
||||||
|
Design-Entscheidung im bestehenden Export (`exportIfc.ts:22-27`), NICHT
|
||||||
|
revidieren: die heutige "Loch steckt im Mesh"-Lösung liefert visuelle
|
||||||
|
Parität in jedem Viewer ohne Boolean-Pflicht beim Empfänger; der reine
|
||||||
|
IFC4-Purismus (Wand als parametrische Extrusion + Void) würde
|
||||||
|
bestehende, bewusst getroffene Trade-offs zunichtemachen.
|
||||||
|
- **Straight-Skeleton/Kehlen und Gauben SOFORT** — nicht "nicht bauen", aber
|
||||||
|
bewusst zurückgestellt (§6, Punkt 10): ohne die Schichtlogik (Punkt 1)
|
||||||
|
vorher zu bauen, würde jede Kehlen-/Gauben-Lösung auf dem unendlich dünnen,
|
||||||
|
ungeschichteten Dach aufsetzen und müsste bei Einführung der Schichten
|
||||||
|
ohnehin neu gefasst werden.
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# Fenster-/Tür-Editor — Studie & Designdokument (Referenz: Vectorworks „Fenster bearbeiten")
|
||||||
|
|
||||||
|
Status: Studie/Entwurf (KEIN Code). Ziel: den heute als „mega mager" empfundenen
|
||||||
|
Fenster-/Tür-Editor zu einem eigenständigen, reichen **Einstellungs-Dialog** mit
|
||||||
|
Kategorie-Sidebar, Live-Vorschau (2D + 3D) und **„Als Stil speichern"** ausbauen.
|
||||||
|
Diese Datei ordnet die Vectorworks-Referenz dem bestehenden DOSSIER-Modell zu und
|
||||||
|
schlägt einen realistischen, phasierten Plan vor.
|
||||||
|
|
||||||
|
Konvention: Bezeichner englisch, UI-Text/Kommentare deutsch. Meter als Grundmaß.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
**Was ein guter DOSSIER-Fenster-/Tür-Editor sein sollte.** Ein eigener modaler
|
||||||
|
Dialog „Fenster-/Tür-Einstellungen" — nicht die heutige, in den Ressourcen-Manager
|
||||||
|
eingebettete Formularspalte (`WindowStylesTab`/`DoorStylesTab` in
|
||||||
|
`src/ui/ResourceManager.tsx`), die pro Feld nur eine `FieldRow` zeigt und keinerlei
|
||||||
|
Vorschau bietet. Der neue Dialog hat drei Zonen (wie Vectorworks):
|
||||||
|
|
||||||
|
1. **Kategorie-Sidebar** links (Basis, Größe/Position, Rahmen, Flügel/Sprossen,
|
||||||
|
Oberlicht/Unterlicht, Laibung/Bank, Sonnenschutz/Rollladen, Attribute/Darstellung,
|
||||||
|
Detaillierung).
|
||||||
|
2. **Parameter-Panel** in der Mitte (die Controls der gewählten Kategorie).
|
||||||
|
3. **Live-Vorschau** rechts: eine 2D-Plan-Vorschau (aus `generatePlan()`) und eine
|
||||||
|
3D-Ansicht/Elevation (aus `projectToModel3d()`), beide sofort aktualisiert.
|
||||||
|
|
||||||
|
**Die eine wichtigste strukturelle Änderung.** Der Editier-Primärort wandert vom
|
||||||
|
Ressourcen-Tab in einen **dedizierten `OpeningEditorDialog`**, der TYP-Parameter
|
||||||
|
(wiederverwendbarer Stil = `WindowType`/`DoorType`) und INSTANZ-Parameter (dieses
|
||||||
|
`Opening`) im selben Fenster editiert und oben eine Stil-Leiste trägt:
|
||||||
|
`Stil: [Dropdown]` · `Fenster speichern…` (aktuelle Konfiguration als neuen
|
||||||
|
benannten `WindowType`/`DoorType` ablegen) · `Einstellungen zurücksetzen…`
|
||||||
|
(auf den Stil zurückfallen). Das ⚙ in `ObjectInfoPanel.OpeningSection`
|
||||||
|
(`src/panels/ObjectInfoPanel.tsx:731`) öffnet künftig DIESEN Dialog statt den
|
||||||
|
Ressourcen-Manager-Tab.
|
||||||
|
|
||||||
|
**Begriffsklärung.** Der Nutzer-Ausdruck „als **Wandstil** speichern" ist ein
|
||||||
|
Versprecher — gemeint ist „als **Fensterstil/Türstil** (Bauteilstil) speichern",
|
||||||
|
also ein neuer Eintrag in `project.windowTypes` bzw. `project.doorTypes`, analog
|
||||||
|
`WallType`/`CeilingType`/`StairType`. Es entsteht KEIN neuer Wandtyp.
|
||||||
|
|
||||||
|
**Warum das der Hebel ist.** Alle geplante Tiefe (mehrflügelig, Sprossenraster,
|
||||||
|
Rollladen, Bank/Nische, Ober-/Unterlicht, Verglasungsanzahl) braucht (a) mehr
|
||||||
|
Felder auf `WindowType`/`DoorType` und (b) eine UI, die sie ohne Formularwust
|
||||||
|
zeigt und deren Wirkung sofort sichtbar macht. Ohne Live-Vorschau bleibt ein
|
||||||
|
reicher Parametersatz unbenutzbar. Erst der Dialog macht die Tiefe zugänglich; die
|
||||||
|
Modellfelder allein (Abschnitt 4) reichen nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Vollständige Mapping-Tabelle (VW-Referenz → DOSSIER)
|
||||||
|
|
||||||
|
Legende — Status: **✓** vorhanden · **~** teilweise (Feld existiert, Renderer liest
|
||||||
|
es nicht ODER nur grob) · **✗** fehlt. Ebene: **T** = Typ (Stil, wiederverwendbar,
|
||||||
|
auf `WindowType`/`DoorType`) · **I** = Instanz (auf `Opening`). Priorität P0
|
||||||
|
(erste reiche Scheibe) … P3 (VW-Ballast). Aufwand grob in Personentagen (PT).
|
||||||
|
|
||||||
|
Wichtiger Ist-Befund aus dem Code (Renderer-Konsum-Lücken):
|
||||||
|
|
||||||
|
- `WindowType.glazing` (einfach/zweifach/dreifach) ist editierbar, wird aber von
|
||||||
|
KEINEM Renderer gelesen. 3D zeichnet stets EINE Scheibe (`glassPanesForOpening`
|
||||||
|
in `src/plan/toWalls3d.ts:1965` ignoriert `glazing`); 2D leitet die Glaslinien-
|
||||||
|
Anzahl allein aus `DetailLevel` ab (`addOpeningSymbol` in
|
||||||
|
`src/plan/generatePlan.ts:2214`, `glassCount = detail==="fein" ? 2 : 1`).
|
||||||
|
- `WindowType.kind`/`DoorType.kind` (dreh/kipp/drehkipp/fest/schiebe …) schlagen
|
||||||
|
sich NICHT in 2D/3D-Geometrie nieder.
|
||||||
|
- `DoorType.leafCount` (1/2), `leafStyle`/`glazingRatio` (außer `leafStyle==="glas"`
|
||||||
|
→ 3D-Verglasung an) sind ungenutzt.
|
||||||
|
- `WindowType.sillBoard` ungenutzt; `DoorType.threshold` → `hasSill` genutzt.
|
||||||
|
- 3D-Mittelpfosten/Kämpfer und Sprossen werden nur bei `detail==="fein"` emittiert
|
||||||
|
(`frameMeshesForOpening` in `src/plan/toWalls3d.ts:1902`, ab :1938).
|
||||||
|
|
||||||
|
### 2.1 Basiseinstellungen
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld (Vorschlag) | 2D-Wirkung | 3D-Wirkung | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Fensterart („Für normale Wand") | ✗ | `WindowType.wallKind?: "normal"\|"eck"` (später) | — | — | T | P3 | 0.5 |
|
||||||
|
| Öffnungsart (Dreh/Kipp/…) | ~ | `WindowType.kind` existiert, kein Render | Öffnungssymbol/Öffnungslinien | Öffnungslinien 3D | T | P1 | 2 |
|
||||||
|
| Einfügepunkt längs (Mitte/…) | ✓ | `Opening.position` + Bezugspunkt in `ObjectInfoPanel` | Position im Plan | Position | I | — | — |
|
||||||
|
| Einfügepunkt quer (Fensterseite außen/…) | ~ | `insetFromFace`/`insetFace` (T) | Band-Lage | Rahmen-Normalenlage (`resolveFrameNormalRange` :1803) | T | P1 | 0.5 |
|
||||||
|
| Versatz im Fassadenmodul | ✗ | — (Fassadensystem fehlt in DOSSIER) | — | — | — | P3 | — |
|
||||||
|
| Laibung (wählen) | ~ | `insetFromFace` deckt Teil ab | Laibungsstriche (fein) | Laibungstiefe | T | P2 | 1 |
|
||||||
|
| Klasse | ~ | `Opening.categoryCode` (LayerCategory) | Farbe/Strich | — | I | — | — |
|
||||||
|
| Darstellung höchste Detaillierung | ✓ | `Opening.detailLevel` + Ansichts-DetailLevel | Symbolstufe | Meshstufe | I | — | — |
|
||||||
|
| Eigenes Symbol verwenden | ✗ | `WindowType.symbolId?` (Drawing2D-Ref) | Symbol-Override | — | T | P3 | 3 |
|
||||||
|
|
||||||
|
### 2.2 Fenstergröße & Bemaßung
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Fensterbreite (Wert) | ✓ | `Opening.width` (+ `defaultWidth` T) | ✓ | ✓ | I/T | — | — |
|
||||||
|
| Fensterhöhe (Wert) | ✓ | `Opening.height` (+ `defaultHeight` T) | ✓ | ✓ | I/T | — | — |
|
||||||
|
| Bezug B1..B5 / H1..H7 (Roh-/Fertigmaß) | ✗ | `WindowType.dimRef?: {...}` | Bemaßungsschema | — | T | P3 | 3+ |
|
||||||
|
| „Bemaßung automatisch" (außen/innen) | ✗ | — (DOSSIER hat noch keine parametrische Öffnungs-Bemaßung) | Maßketten | — | T | P3 | 5+ |
|
||||||
|
|
||||||
|
Der ganze VW-Maßketten-/Bezugsapparat (B1..B5, H1..H7, Roh-/Fertigmaß-Umschaltung)
|
||||||
|
ist **P3/„nicht bauen"** — siehe Abschnitt 5. DOSSIER trägt lichte Maße
|
||||||
|
(`width`/`height`), das genügt.
|
||||||
|
|
||||||
|
### 2.3 Höhe Brüstung/Sturz
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Position definieren durch (Brüstungshöhe/…) | ✓ | `Opening.sillHeight` (+ `defaultSillHeight` T) | — | vertikale Lage (`openingVerticalExtent`) | I | — | — |
|
||||||
|
| Abstand Brüstungshöhe (Wert) | ✓ | `Opening.sillHeight` | — | ✓ | I | — | — |
|
||||||
|
| „bezieht sich auf" (Wandaußenseite/…) | ✗ | — (immer Wand-UK-relativ) | — | — | — | P3 | — |
|
||||||
|
| „auf Ebenenbasishöhe" | ~ | Wand-UK ergibt sich aus Geschoss (`baseElevation`) | — | ✓ | — | — | — |
|
||||||
|
|
||||||
|
### 2.4 Rahmenwerte
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Rahmenbreiten (4 Kanten, asymmetrisch) | ~ | heute nur `frameWidth` (eine Zahl) → `WindowType.frameWidths?: {left,right,top,bottom}` | Rahmenkontur (`windowSymbol`/`addOpeningFrameBand`) | Rahmen-Boxen (`frameMeshesForOpening`) | T | P2 | 2 |
|
||||||
|
| Symmetrisch-Toggle | ✗ | `WindowType.frameSymmetric?: boolean` | — | — | T | P2 | 0.5 |
|
||||||
|
| Flügel mittig / Versatz | ✗ | `WindowType.sashOffset?: number` | Aufschlag-Linie | Flügel-Box-Lage | T | P2 | 1 |
|
||||||
|
| Rahmenstärke quer zur Wand | ✓ | `frameThickness` / `frameDepth` (→ `resolveAcrossWallDepth` :1780) | — | Rahmentiefe | T | — | — |
|
||||||
|
| Schnitt-/Außenansicht-Rahmenbreiten | ~ | von `frameWidth` mitgezeichnet | Bandbreite | — | T | P2 | 1 |
|
||||||
|
|
||||||
|
### 2.5 Flügeleinteilung (KERN-Tiefe — höchster Wert)
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Flügeltabelle (Nr/Typ/Breite/Anschlag/…) | ~ | `wingCount` (Zahl) → **neu** `WindowType.sashes: SashDef[]` (siehe §4) | Pfostenlinien (`mullionLines`) heute nur gleichmäßig | Pfosten-Boxen nur gleichmäßig | T | **P0** | 4 |
|
||||||
|
| Typ Flügel \| Pfosten | ✗ | `SashDef.kind: "fluegel"\|"pfosten"` | Pfostenlage frei | Pfosten frei | T | P0 | (inkl.) |
|
||||||
|
| Aut. Breite / Flügelbreite | ~ | `SashDef.autoWidth`/`width` (heute nur gleichmäßig) | Teilungslage | Teilungslage | T | P1 | 1 |
|
||||||
|
| Pfostenbreite / „alle gleich" | ~ | `SashDef.postWidth`, `WindowType.uniformPosts` | Pfostendicke | Pfostenbox-Breite | T | P1 | 1 |
|
||||||
|
| Anschlag (Drehbar links/Drehkipp rechts) | ✗ | `SashDef.opening: OpeningKind`, `SashDef.hingeSide: "left"\|"right"` | Öffnungslinien/Pfeil je Flügel | Öffnungslinien 3D | T | **P0** | 2 |
|
||||||
|
| Aufschlag (Abstand zu Rahmen) | ✗ | `SashDef.rebate?: number` | Aufschlag-Linie | — | T | P2 | 0.5 |
|
||||||
|
| Winkel / 3D-Öffnung | ✗ | `SashDef.openAngle?: number` | — | Flügel gekippt/offen | T | P2 | 1.5 |
|
||||||
|
| Griffart | ✗ | `SashDef.handle?: HandleKind` | — | Griff-Mesh (§2.7) | T | P2 | 1 |
|
||||||
|
| Kämpfer-Zeilen (horizontale Teilung) | ~ | `mullionRows` existiert | Querlinien | Kämpfer-Boxen (fein) | T | P1 | — |
|
||||||
|
|
||||||
|
Dies ist die **wertvollste Lücke**: VW modelliert je Flügel Typ, Breite,
|
||||||
|
Öffnungsrichtung, Anschlag, Griff. DOSSIER hat nur eine gleichmäßige `wingCount`.
|
||||||
|
Die `SashDef[]`-Tabelle (§4, Phase P0/P1) ist der zentrale Ausbau.
|
||||||
|
|
||||||
|
### 2.6 Laibungsverkleidung · Form · Ober-/Unterlicht · Nische/Bank
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Laibungsverkleidung erstellen | ✗ | `WindowType.reveal?: {create, depth, thickness}` | Laibungsband | Laibungs-Boxen | T | P2 | 1.5 |
|
||||||
|
| Form (Eckig/Schräg/Spitz/Rund) | ✗ | `WindowType.headShape?: HeadShape` + Eckmaße | Öffnungsumriss-Form | Bogen-/Schräg-Mesh | T | P2 | 4 |
|
||||||
|
| Oberlicht erstellen + Höhe/Rahmen/Sprossen | ~ | `transomHeight` existiert (feste Scheibe) → `WindowType.transom?: {height, frame, grid}` | Kämpferlinie + Feld | zweite Scheibe (`glassPanesForOpening` :1988) | T | P1 | 2 |
|
||||||
|
| Unterlicht (unteres festes Feld) | ✗ | `WindowType.underlight?: {height, frame, grid}` | Feld | Scheibe unten | T | P2 | 1.5 |
|
||||||
|
| Sprossen (horiz./vert. im Feld) | ~ | `mullionRows` grob → `WindowType.muntins?: {rows, cols}` je Feld | Sprossengitter | Sprossen-Boxen (fein) | T | P1 | 2 |
|
||||||
|
| Nische aussparen (außen/innen) | ✗ | `WindowType.niche?: {aussen, innen, ...}` | Nischenkontur | Nischen-Aussparung | T | P2 | 2 |
|
||||||
|
| Fensterbank erstellen (außen/innen) | ~ | `sillBoard` existiert, ungenutzt → `WindowType.sill?: {aussen, innen, typ, masse}` | Bankkontur | Bank-Box | T | P1 | 2 |
|
||||||
|
| Banktyp/Winkel/ΔZ/Endverkröpfung | ✗ | Unterfelder von `sill` | Detail | Detail-Mesh | T | P3 | 2 |
|
||||||
|
|
||||||
|
### 2.7 Sonnenschutz · Beschlag · Geländer · Heizkörper · Eckfenster
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Sonnenschutz/Rollladen erstellen + Typ | ✗ | `WindowType.shading?: {create, kind: "raffstore"\|"rollladen"\|…, box, projection}` | Kasten-/Kastenlinien im Grundriss | Kastenbox + Auskragung | T | **P0/P1** | 2.5 |
|
||||||
|
| Rollladenkasten-Maße | ✗ | `shading.box: {w,h,d}` | Rechteck | Box-Mesh | T | P0 | (inkl.) |
|
||||||
|
| „Nische erzeugen"/„Kasten symm."/Lamellenwinkel | ✗ | `shading`-Unterfelder | — | Detail | T | P2 | 1 |
|
||||||
|
| Beschlag (Griff/Knauf) Geometrie/Maße | ✗ | `SashDef.handle` + `WindowType.handleGeom?` | — | Griff-/Knauf-Mesh | T | P2 | 2 |
|
||||||
|
| Geländer (Position/Höhe/Bauteile) | ✗ | `WindowType.railing?: {...}` | Geländerlinien | Geländer-Mesh | T | P3 | 3 |
|
||||||
|
| Heizkörper | ✗ | — (eigenes Bauteil, nicht Fenster) | — | — | — | P3 | — |
|
||||||
|
| Eckfenster | ✗ | eigener Sonderfall (zwei Wände) | — | — | T | P3 | 5+ |
|
||||||
|
|
||||||
|
### 2.8 Attribute · Schnitte/Ansichten · Detaillierung · IFC/Energos
|
||||||
|
|
||||||
|
| VW-Control | Status | DOSSIER-Feld | 2D | 3D | Ebene | Prio | Aufwand |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Attribut-Tabelle je Bestandteil (Klasse/Material/Stift/Linie/…) | ~ | DOSSIER hat By-Layer/By-Object-Attribute (`foreground`/`background`/`hatchId`/`strokeWeight` an Wand/Decke, NICHT an `Opening`) → `Opening.foreground?` etc. + evtl. je Bestandteil | Farbe/Strich der Bestandteile | Material | I/T | P2 | 3 |
|
||||||
|
| „Klassenattribute zuweisen/entfernen" | ~ | `AttributeSource` („layer"/„object") existiert für Wand/Decke | — | — | I | P2 | 1 |
|
||||||
|
| Automatische 2D-Darstellungen (Horizontal/Ansicht/Querschnitt) | ~ | `generatePlan` erzeugt Grundriss; Schnitt/Ansicht sind Platzhalter (`DrawingLevelKind`) | — | — | — | P3 | — |
|
||||||
|
| Sichtbarkeitsmatrix 3D-Objekte × Ansichten (Oben/Unten/…/Querschnitt) | ✗ | **nicht bauen** | — | — | — | P3 | — |
|
||||||
|
| Detaillierung: Option × Kategorie × Detailstufe (Augen-Tabelle) | ~ | `DetailLevel` (grob/mittel/fein) fließt in 2D + 3D | Sichtbarkeit je Stufe | Meshstufe | I | P2 | 2 |
|
||||||
|
| „Für 3D die Tiefe/Mittlere/Hohe Detaillierung verwenden" | ✓ | `Model3dOptions.detail` (`toWalls3d.ts:2145`) | — | Meshstufe | — | — | — |
|
||||||
|
| Beschriftung | ✗ | eigenes Text/Tag-System | Label | — | — | P3 | — |
|
||||||
|
| Infos/IFC-Daten/Infopalette/Energos | ✗ | **nicht bauen** | — | — | — | P3 | — |
|
||||||
|
| „Mehrere ändern" | ~ | Multi-Selektion patcht bereits gemeinsame Felder | — | — | I | P2 | 1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Der dedizierte Dialog
|
||||||
|
|
||||||
|
### 3.1 Komponente & Einbettung
|
||||||
|
|
||||||
|
Neue Komponente **`src/ui/OpeningEditorDialog.tsx`** (Muster: die bestehenden
|
||||||
|
`*Dialog.tsx` in `src/ui/`, z. B. `SettingsDialog.tsx`, `TextEditorDialog.tsx`,
|
||||||
|
und das Overlay-Muster `res-overlay` + `role="dialog"` aus
|
||||||
|
`ResourceManager.tsx:371`). Props (über `usePanelHost`/App-State geliefert):
|
||||||
|
|
||||||
|
```
|
||||||
|
open: boolean
|
||||||
|
openingId: string // die editierte Instanz
|
||||||
|
kind: "window" | "door" // steuert Sidebar-Sätze + welche Type-Liste
|
||||||
|
project: Project // read (Typ-Listen, Bauteile, LayerCategories)
|
||||||
|
onPatchOpening(patch) // Instanz-Felder
|
||||||
|
onPatchType(typeId, patch) // Typ-Felder (aktueller Stil)
|
||||||
|
onSaveAsStyle(name, snapshot)// „Fenster/Tür speichern…" → neuer WindowType/DoorType
|
||||||
|
onResetToStyle() // „zurücksetzen…"
|
||||||
|
onClose()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Öffnen aus dem ⚙.** `ObjectInfoPanel.OpeningSection`
|
||||||
|
(`src/panels/ObjectInfoPanel.tsx:731`, `onClick → host.onEditOpeningType(...)`)
|
||||||
|
ruft künftig einen neuen Host-Handler `host.onOpenOpeningEditor(openingId)` statt
|
||||||
|
`setResourcesTab(...) + setResourcesOpen(true)` (heutige Verdrahtung in
|
||||||
|
`src/App.tsx:3130`). App hält `openingEditorId: string | null` als State und
|
||||||
|
rendert `<OpeningEditorDialog open={openingEditorId!=null} .../>`. Der bisherige
|
||||||
|
Ressourcen-Tab bleibt als Bibliotheks-/Massenpflege bestehen, ist aber nicht mehr
|
||||||
|
der Primärort — das ⚙ landet direkt im reichen Dialog.
|
||||||
|
|
||||||
|
### 3.2 Kategorie-Sidebar (realistischer Teilmenge)
|
||||||
|
|
||||||
|
Reihenfolge und Sichtbarkeit je `kind`:
|
||||||
|
|
||||||
|
1. **Basis** — Öffnungsart (`kind`), Einfügepunkt quer (`insetFace`/`insetFromFace`),
|
||||||
|
Klasse (`categoryCode`), Detaillierung (`detailLevel`).
|
||||||
|
2. **Größe/Position** — `width`, `height`, `sillHeight`, `position` (I),
|
||||||
|
`defaultWidth/Height/SillHeight` (T).
|
||||||
|
3. **Rahmen** — `frameThickness`, `frameDepth`, `frameWidth`/`frameWidths`,
|
||||||
|
`frameKind` (Tür), `sashOffset`.
|
||||||
|
4. **Flügel/Sprossen** — die `SashDef[]`-Tabelle (Flügel/Pfosten einfügen,
|
||||||
|
Öffnungsrichtung/Anschlag je Flügel), Kämpfer-Zeilen, Sprossengitter.
|
||||||
|
5. **Oberlicht/Unterlicht** — `transom`, `underlight` (je Feld: Höhe, Rahmen, Gitter).
|
||||||
|
6. **Laibung/Bank** — `reveal`, `sill` (außen/innen), `niche`.
|
||||||
|
7. **Sonnenschutz/Rollladen** — `shading` (Typ, Kastenmaße, Auskragung).
|
||||||
|
8. **Attribute/Darstellung** — Bestandteil-Attribute (Farbe/Strich/Material).
|
||||||
|
9. **Detaillierung** — welche Bestandteile bei grob/mittel/fein sichtbar sind.
|
||||||
|
|
||||||
|
Sidebar-Sätze: bei `kind==="door"` entfallen Oberlicht/Unterlicht-Gitter-Details,
|
||||||
|
Sonnenschutz und Bank; dafür Türblatt (`leafCount`, `leafStyle`, `threshold`,
|
||||||
|
`swing`/`hinge`/`openingDir`/`swingAngle`).
|
||||||
|
|
||||||
|
### 3.3 Live-Vorschau
|
||||||
|
|
||||||
|
**2D (der billige, sofort machbare Teil).** DOSSIER hat den kompletten
|
||||||
|
Plan→SVG-Pfad bereits: `generatePlan()` (`src/plan/generatePlan.ts:632`) →
|
||||||
|
`toRenderScene.ts` (RScene) → `sceneToPrintSvg()` (`src/export/sceneToPrintSvg.ts:109`)
|
||||||
|
bzw. `planToPrintSvg()` (`src/export/planToPrintSvg.ts:155`). Für die Vorschau ein
|
||||||
|
**Mini-Projekt** bauen (eine kurze Wand + das editierte `Opening` mit den aktuellen
|
||||||
|
Dialog-Werten), durch `generatePlan()` schicken und als SVG in ein Vorschau-`<div>`
|
||||||
|
rendern — Grundriss oben, optional eine Elevations-Skizze. Reagiert auf jeden
|
||||||
|
Patch reaktiv (React-State → neu generieren; günstig, da nur ein Element).
|
||||||
|
|
||||||
|
**3D/Elevation.** Zwei Optionen, in aufsteigendem Aufwand:
|
||||||
|
|
||||||
|
- **(A) günstig, empfohlen für den ersten Wurf:** eine reine 2D-**Ansicht/Elevation**
|
||||||
|
aus denselben Plan-Primitiven — VW's „Vorschau, schattiert" ist für uns nicht
|
||||||
|
nötig; eine saubere Frontalansicht (Rahmen/Flügel/Sprossen/Glas als SVG) zeigt
|
||||||
|
die Flügeleinteilung am aussagekräftigsten und nutzt exakt die neuen Felder.
|
||||||
|
- **(B) echte 3D-Vorschau:** `projectToModel3d()` (`src/plan/toWalls3d.ts:2149`)
|
||||||
|
auf das Mini-Projekt anwenden und im wgpu-Viewport (`Wasm3DViewport.tsx`)
|
||||||
|
darstellen. Teurer (eigener Render-Kontext im Modal, WASM-Instanz) und laut
|
||||||
|
Projekt-Memo NICHT per Browser/Puppeteer verifizierbar — der Nutzer testet 3D
|
||||||
|
selbst in der Tauri-Dev-App. Deshalb 3D-Vorschau erst NACH der 2D-Vorschau, als
|
||||||
|
eigene Phase.
|
||||||
|
|
||||||
|
Empfehlung: erste Scheibe nur **2D-Grundriss + 2D-Elevation**; echte wgpu-Vorschau
|
||||||
|
später.
|
||||||
|
|
||||||
|
### 3.4 „Als Stil speichern"-Flow
|
||||||
|
|
||||||
|
Drei Aktionen in der Stil-Leiste:
|
||||||
|
|
||||||
|
- **Fenster/Tür speichern… (`onSaveAsStyle`)** — nimmt einen Schnappschuss der
|
||||||
|
aktuellen (Typ-relevanten) Dialogwerte, fragt einen Namen ab (`PromptDialog.tsx`)
|
||||||
|
und legt einen NEUEN `WindowType`/`DoorType` in `project.windowTypes`/`doorTypes`
|
||||||
|
an (CRUD-Factories existieren: `addWindowType`/`addDoorType` in `src/App.tsx`
|
||||||
|
ab :2273/:2304). Das editierte `Opening.typeId` zeigt danach auf den neuen Stil.
|
||||||
|
- **Update dieses Stils** — `patchWindowType`/`patchDoorType` (App :2319/:2287) auf
|
||||||
|
den aktuell referenzierten `typeId` anwenden (wirkt auf alle Instanzen des Stils).
|
||||||
|
- **Einstellungen zurücksetzen… (`onResetToStyle`)** — die instanzseitigen
|
||||||
|
Overrides verwerfen und wieder die Typ-Defaults ziehen (Instanz-Felder auf
|
||||||
|
`undefined` setzen, sodass die Resolver `getWindowType`/`getDoorType`
|
||||||
|
(`src/model/types.ts:2060`/:2064) greifen).
|
||||||
|
|
||||||
|
TYP-vs-INSTANZ-Regel im Dialog sichtbar machen: Typ-Felder tragen eine dezente
|
||||||
|
„Stil"-Markierung; ein instanzseitig übersteuertes Feld zeigt einen „abweichend
|
||||||
|
vom Stil"-Indikator + „zurücksetzen". Das ist genau VW's Stil-/Override-Logik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modell-Erweiterungsplan (phasiert)
|
||||||
|
|
||||||
|
Alle Felder additiv/optional (Alt-Projekte laden unverändert — die bestehende
|
||||||
|
Konvention der Datei, vgl. `wingCount?`, `sliceTermination` Default). Neue Felder
|
||||||
|
auf `WindowType`/`DoorType` in `src/model/types.ts`.
|
||||||
|
|
||||||
|
### P0 — die erste reiche Scheibe (Kern-Tiefe)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Ein Flügel- oder Pfosten-Eintrag der Flügeleinteilung.
|
||||||
|
type OpeningKind = "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe";
|
||||||
|
interface SashDef {
|
||||||
|
kind: "fluegel" | "pfosten";
|
||||||
|
autoWidth?: boolean; // gleichmäßig aufteilen (Default true)
|
||||||
|
width?: number; // feste Flügelbreite (m), wenn !autoWidth
|
||||||
|
postWidth?: number; // Pfostenbreite (m), nur kind="pfosten"
|
||||||
|
opening?: OpeningKind; // Öffnungsart DIESES Flügels
|
||||||
|
hingeSide?: "left" | "right"; // Anschlag
|
||||||
|
}
|
||||||
|
interface WindowType {
|
||||||
|
// … bestehend …
|
||||||
|
sashes?: SashDef[]; // ersetzt/erweitert wingCount; fehlt ⇒ aus wingCount abgeleitet
|
||||||
|
shading?: { // Rollladen/Sonnenschutz-Kasten (P0-Minimalfassung)
|
||||||
|
create: boolean;
|
||||||
|
kind: "rollladen" | "raffstore" | "markise";
|
||||||
|
box: { width: number; height: number; depth: number };
|
||||||
|
};
|
||||||
|
glazingPanes?: 1 | 2 | 3; // Verglasung endlich RENDER-wirksam (heute: glazing unbenutzt)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale/Render-Freischaltung:
|
||||||
|
- `sashes` — schaltet echte, ungleichmäßige Flügel/Pfosten + Öffnungsrichtung je
|
||||||
|
Flügel frei; treibt neue Pfostenlinien in `windowSymbol`/`mullionLines`
|
||||||
|
(`src/geometry/opening.ts`) und Pfosten-Boxen in `frameMeshesForOpening`
|
||||||
|
(`src/plan/toWalls3d.ts:1938`). Höchster sichtbarer Mehrwert.
|
||||||
|
- `shading` — der Rollladenkasten ist ein einzelnes Kästchen: eine 2D-Rechteckkontur
|
||||||
|
über der Öffnung (neuer Zeichenzweig in `addOpeningSymbol`) + eine Box in
|
||||||
|
`emitOpeningFrames`/eigener Emitter. Wenig Code, große „Reichhaltigkeit".
|
||||||
|
- `glazingPanes` — `glassPanesForOpening` (:1965) liest die Scheibenzahl statt
|
||||||
|
konstant EINE Scheibe zu zeichnen; 2D-Glaslinien-Anzahl analog. Schließt die
|
||||||
|
auffälligste Konsum-Lücke.
|
||||||
|
|
||||||
|
### P1 — Tiefe der Flügel/Felder
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface SashDef { /* + */ rebate?: number; openAngle?: number; handle?: "drueck"|"knauf"|"none"; }
|
||||||
|
interface WindowType {
|
||||||
|
frameWidths?: { left: number; right: number; top: number; bottom: number };
|
||||||
|
frameSymmetric?: boolean;
|
||||||
|
uniformPosts?: boolean;
|
||||||
|
transom?: { height: number; frame?: number; muntins?: { rows: number; cols: number } };
|
||||||
|
sill?: { aussen?: SillDef; innen?: SillDef };
|
||||||
|
muntins?: { rows: number; cols: number }; // Sprossen im Hauptfeld
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: asymmetrische Rahmenbreiten, richtiges Oberlicht (statt nur feste Scheibe
|
||||||
|
via `transomHeight`), Fensterbank (heute `sillBoard` ungenutzt), Sprossengitter.
|
||||||
|
Alles hängt an vorhandenen Zeichen-/Mesh-Funktionen; nur Parameterausbau.
|
||||||
|
|
||||||
|
### P2 — Detailbestandteile
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface WindowType {
|
||||||
|
reveal?: { create: boolean; depth: number; thickness: number }; // Laibungsverkleidung
|
||||||
|
underlight?: { height: number; frame?: number }; // Unterlicht
|
||||||
|
niche?: { aussen?: boolean; innen?: boolean; depth: number }; // Nische
|
||||||
|
headShape?: "eckig" | "schraeg_links" | "schraeg_rechts" | "spitz" | "rund";
|
||||||
|
handleGeom?: { kind: "quader" | "rund"; masse: Record<string, number> };
|
||||||
|
}
|
||||||
|
interface Opening { foreground?: string; background?: string; strokeWeight?: number; } // Bestandteil-Attribute
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale: Form (Rundbogen/Schräge → neue Öffnungsumriss-Geometrie), Laibung/Nische,
|
||||||
|
Beschlag-Geometrie, per-Instanz-Attribute (analog Wand/Decke, die es schon haben).
|
||||||
|
|
||||||
|
### P3 — VW-Ballast (nur wenn je nachgefragt)
|
||||||
|
|
||||||
|
Bemaßungs-Bezugsschemata (B1..B5/H1..H7), Sichtbarkeitsmatrix 3D×Ansichten,
|
||||||
|
IFC/Energos, Geländer, Heizkörper, Eckfenster, eigenes Symbol, Fassadenmodul-Versatz.
|
||||||
|
Siehe Abschnitt 5.
|
||||||
|
|
||||||
|
**Höchster-Wert-Reihenfolge (verdichtet):** (1) `sashes` inkl. Öffnungsrichtung je
|
||||||
|
Flügel, (2) mehrscheibige Verglasung, (3) Rollladenkasten, (4) Bank/Nische,
|
||||||
|
(5) Sprossengitter, (6) Form (Rund/Spitz/Schräg), (7) Ober-/Unterlicht als eigene
|
||||||
|
Felder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Was NICHT bauen / Risiken
|
||||||
|
|
||||||
|
**Nicht bauen (VW-Komplexität ohne DOSSIER-Nutzen):**
|
||||||
|
|
||||||
|
- **Sichtbarkeitsmatrix „3D-Objekte × 9 Ansichten"** (Oben/Unten/Horizontalschnitt/
|
||||||
|
Vorne/Hinten/Frontalschnitt/Links/Rechts/Querschnitt). DOSSIER rendert heute
|
||||||
|
primär Grundriss; Schnitt/Ansicht sind `DrawingLevelKind`-Platzhalter. Eine
|
||||||
|
Matrix mit ~13×9 An/Aus-Zellen ist Pflegehölle ohne Zielrenderer. `DetailLevel`
|
||||||
|
(grob/mittel/fein) genügt als Sichtbarkeitsachse.
|
||||||
|
- **Bemaßungs-Bezugssystem (Roh-/Fertigmaß, B1..B5/H1..H7).** DOSSIER trägt lichte
|
||||||
|
Maße; eine parametrische Öffnungs-Maßkette existiert nicht. Sehr teuer, geringer
|
||||||
|
Nutzen für ein CAAD-Werkzeug dieser Reife.
|
||||||
|
- **IFC-Datenmapping-Tiefe, Energos, Infopalette.** Kein IFC-Export-Pfad vorhanden;
|
||||||
|
bewusst weglassen (kein Schein-Feature, vgl. die Modell-Doku-Konvention).
|
||||||
|
- **Heizkörper, Geländer, Eckfenster** als Fenster-Unterobjekte — das sind eigene
|
||||||
|
Bauteile bzw. topologische Sonderfälle (zwei Wände). Nicht ins Fenster packen.
|
||||||
|
- **Eigenes Symbol verwenden** (Drawing2D-Override je Stil) — erst wenn ein
|
||||||
|
Symbol-/Blocksystem existiert.
|
||||||
|
|
||||||
|
**Risiken:**
|
||||||
|
|
||||||
|
- **3D-Vorschau nicht als „korrekt" verkaufen.** Laut Projekt-Memo werden
|
||||||
|
`render3d`/`Wasm3DViewport`-Änderungen NICHT per Puppeteer/Browser verifiziert;
|
||||||
|
der Nutzer testet 3D selbst in der Tauri-Dev-App. Der Dialog darf 3D anzeigen,
|
||||||
|
aber diese Studie/Implementierung behauptet keine visuelle Korrektheit der
|
||||||
|
3D-Vorschau — nur der 2D-Pfad (`generatePlan`→SVG) ist headless prüfbar.
|
||||||
|
- **Typ-vs-Instanz-Verwirrung.** Ohne klaren „vom Stil abweichend"-Indikator wird
|
||||||
|
unklar, ob eine Änderung den Stil (alle Instanzen) oder nur dieses Fenster trifft.
|
||||||
|
Muss im UI explizit sein (§3.4).
|
||||||
|
- **`sashes` vs. `wingCount` Migration.** `wingCount` bleibt als Fallback; `sashes`
|
||||||
|
hat Vorrang, wenn gesetzt. Resolver in `resolveOpeningFrame`
|
||||||
|
(`toWalls3d.ts:1845`) und `windowSymbol` müssen beide Wege beherrschen, sonst
|
||||||
|
brechen Alt-Projekte oder die `ObjectInfoPanel`-Flügelanzahl-Eingabe.
|
||||||
|
- **Modal-Render-Kosten.** Live-Vorschau bei jedem Tastendruck neu generieren ist
|
||||||
|
ok für ein Mini-Projekt, aber Patches sollten (leicht) entprellt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empfohlene erste Scheibe (P0, konkret)
|
||||||
|
|
||||||
|
Kleinstes End-to-End-Inkrement, das sich schon „reich" anfühlt:
|
||||||
|
|
||||||
|
**Umfang:** Dialog-Shell + Live-2D-Vorschau + Flügeleinteilung (n Flügel mit
|
||||||
|
Öffnungsrichtung je Flügel) + mehrscheibige Verglasung + Rollladenkasten +
|
||||||
|
„Als Stil speichern".
|
||||||
|
|
||||||
|
**Build-Reihenfolge:**
|
||||||
|
|
||||||
|
1. **Modell (P0-Felder).** `SashDef`, `WindowType.sashes?`, `WindowType.shading?`,
|
||||||
|
`WindowType.glazingPanes?` in `src/model/types.ts` ergänzen (additiv). Resolver
|
||||||
|
`getWindowType` bleibt; `wingCount`→`sashes`-Ableitung als Helper.
|
||||||
|
2. **Renderer-Konsum (2D zuerst, headless prüfbar).**
|
||||||
|
- `windowSymbol`/`mullionLines` (`src/geometry/opening.ts`) auf `sashes` umstellen
|
||||||
|
(ungleichmäßige Pfostenlagen + Öffnungsrichtung je Flügel als Öffnungslinien).
|
||||||
|
- `glassPanesForOpening`/2D-Glaslinien auf `glazingPanes` (statt konstant 1/detail).
|
||||||
|
- neuer Zeichenzweig in `addOpeningSymbol` (`src/plan/generatePlan.ts:2214`) für
|
||||||
|
die Rollladenkasten-Kontur; 3D-Box später.
|
||||||
|
- Unit-Tests gegen `generatePlan()`-Output (Primitive zählen) — das ist der
|
||||||
|
verifizierbare Beweis, nicht nur „grüne Typecheck".
|
||||||
|
3. **Dialog-Shell.** `src/ui/OpeningEditorDialog.tsx` (Overlay `res-overlay` +
|
||||||
|
`role="dialog"`), Sidebar mit den Kategorien Basis / Größe/Position / Rahmen /
|
||||||
|
Flügel-Sprossen / Sonnenschutz. Nur diese fünf für P0.
|
||||||
|
4. **Live-2D-Vorschau.** Mini-Projekt (kurze Wand + editiertes `Opening`) →
|
||||||
|
`generatePlan()` → `sceneToPrintSvg()`/`planToPrintSvg()` → `<div>`. Grundriss +
|
||||||
|
Elevations-SVG. Reaktiv auf Patches (leicht entprellt).
|
||||||
|
5. **Flügel-Tabelle.** UI-Tabelle mit „Flügel einfügen / Pfosten einfügen / Löschen"
|
||||||
|
und je Zeile Öffnungsart + Anschlag (`SashDef`). Schreibt `WindowType.sashes`.
|
||||||
|
6. **„Als Stil speichern".** Stil-Leiste + `onSaveAsStyle` → `addWindowType`
|
||||||
|
(`src/App.tsx:2304`) mit `PromptDialog`-Namensabfrage; „zurücksetzen" verwirft
|
||||||
|
Instanz-Overrides.
|
||||||
|
7. **⚙-Verdrahtung.** `ObjectInfoPanel.OpeningSection` (:731) → neuer Host-Handler
|
||||||
|
`onOpenOpeningEditor(openingId)`; App-State `openingEditorId`. Ressourcen-Tab
|
||||||
|
bleibt als Bibliothek erhalten.
|
||||||
|
|
||||||
|
**Definition of Done (P0):** Aus dem ⚙ öffnet sich der Dialog; man setzt 3 Flügel,
|
||||||
|
davon einer Dreh-links / einer Dreh-rechts / einer fest, wählt Dreifachverglasung
|
||||||
|
und einen Rollladenkasten; die 2D-Vorschau zeigt die Pfosten/Öffnungslinien/Kasten
|
||||||
|
sofort; „Speichern…" legt einen benannten Fensterstil an, der im Typ-Dropdown der
|
||||||
|
`OpeningSection` erscheint. Verifikation über `generatePlan()`-Primitiv-Tests
|
||||||
|
(2D) — 3D-Box erst danach, vom Nutzer in der Tauri-App geprüft.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# SIA 400 — Fenster-/Türdarstellung (Grundriss, Schnitt, Ansicht)
|
||||||
|
|
||||||
|
> Quelle: SIA 400:2000 „Planbearbeitung im Hochbau", Anhang B.9 (Darstellung von
|
||||||
|
> Bauteilen), Figuren 36–42. Das PDF liegt im Projektwurzel (lokal excluded,
|
||||||
|
> NICHT committen — urheberrechtlich geschützt). Dies ist die verbindliche
|
||||||
|
> Darstellungsnorm (Schweiz) — NICHT DIN. Massstab = Detailgrad:
|
||||||
|
> **1:100 = grob, 1:50 = mittel, 1:20 = fein.**
|
||||||
|
|
||||||
|
## B.9.1.1 Fenster im Grundriss (Figuren 36/37/38)
|
||||||
|
|
||||||
|
Regel (Zitat): „Die Darstellung der Fensterkonstruktionen erfolgt nach denselben
|
||||||
|
Regeln, unabhängig davon ob es sich um Holz-, Holz-Metall-, Metall- oder
|
||||||
|
Kunststoff-Fenster handelt."
|
||||||
|
|
||||||
|
### Figur 36 — Massstab 1:100 (= grob)
|
||||||
|
- Wand **voll schwarz** (Poché massiv), keine Schraffur.
|
||||||
|
- Fenster = **eine dünne Rahmen-/Glasband-Andeutung** über die Öffnung, sehr
|
||||||
|
schematisch — im Wesentlichen EIN schmales Rechteck/Band mit wenigen Marken,
|
||||||
|
KEINE Flügel-/Anschlagdetails, KEINE Laibungsmarken.
|
||||||
|
|
||||||
|
### Figur 37 — Massstab 1:50 (= mittel)
|
||||||
|
- Wand **materialschraffiert** (Bänder: Hinterlüftung/Dämmung senkrecht
|
||||||
|
gestrichelt, Backstein/Struktur diagonal). Drei Wandaufbauten gezeigt
|
||||||
|
(Backstein+Aussendämmung hinterlüftet / Holzelementbau / Backstein verputzt).
|
||||||
|
- Fenster: **Blendrahmen als schmales Band** nahe der Aussenfläche, dünne
|
||||||
|
Kontur; **Glaslinie**; **ein kleines Quadrat mittig** (Stulp/Flügelstoss).
|
||||||
|
Laibung/Öffnungstiefe sichtbar. Einfach, aber als Fenster lesbar.
|
||||||
|
|
||||||
|
### Figur 38 — Massstab 1:20 (= fein)
|
||||||
|
- Wand voll schraffiert (Kreuzschraffur Backstein, Diagonalschraffur Dämmung).
|
||||||
|
- Fenster: **volles Rahmenprofil** — Blendrahmen + Flügelrahmen als mehrere
|
||||||
|
parallele Linien (Profiltiefe); **Glas als Doppellinie** (Isolierverglasung
|
||||||
|
IV, zwei enge Parallelen); **zwei Stulp-Quadrate mittig** (Flügelstoss der
|
||||||
|
zwei Flügel); **Anschlag/Laibung** mit Rahmen-Wand-Detail (Falz/Rebate),
|
||||||
|
gestrichelte Anschlagmarken an den Ecken; Anschlagwinkel an den Laibungen.
|
||||||
|
|
||||||
|
## B.9.1.2 Fenster im Schnitt (Figuren 39/40, M 1:50)
|
||||||
|
- Rahmen im Schnitt mit Brüstung/Sturz, Bemassung (z. B. Brüstung +0.90,
|
||||||
|
Sturzhöhe). Fenstertüren (Figur 40): Rahmen bis Boden, Schwelle.
|
||||||
|
|
||||||
|
## B.9.1.3 Sinnbilder Fenster (Öffnungsart in der ANSICHT)
|
||||||
|
Liste der Öffnungsarten (SIA-Benennung):
|
||||||
|
- Fest im Rahmen verglast (kein Symbol)
|
||||||
|
- Drehflügel einflüglig mit Verschluss, **Band rechts/links** (Dreieck, Spitze zur BANDSEITE)
|
||||||
|
- Drehflügel fest mit Band und Plattenverschraubung
|
||||||
|
- Zweiflüglig mit **Öffnungsreihenfolge** (1 = erstöffnend, 2 = zweitöffnend)
|
||||||
|
- Kippflügel mit Verschluss (Dreieck)
|
||||||
|
- Kippflügel fest, für Reinigung bedienbar
|
||||||
|
- Klappflügel mit Verschluss
|
||||||
|
- Drehkippflügel, Band rechts
|
||||||
|
- Schwingflügelfenster
|
||||||
|
- Wendeflügelfenster, Achse in der Mitte
|
||||||
|
- Vertikales Schiebefenster (nach oben schiebbar, oberer Flügel fest)
|
||||||
|
|
||||||
|
Konvention: Dreieck-/Winkelsinnbild, **Spitze zeigt zur Bandseite (Anschlag)**,
|
||||||
|
Basis zur Griffseite. (Genaues Sinnbild-Blatt S. 38 rechts.)
|
||||||
|
|
||||||
|
## B.9.1.4 Kurzzeichen Fenster/Sonnenschutz
|
||||||
|
KL Klappladen · SL Schiebeladen · ROL Rolladen · LAM Lamellenstoren ·
|
||||||
|
RAF Rafflamellenstoren · FAL Faltrolladen · K Kurbel · DV Doppelverglasung ·
|
||||||
|
IV Isolierverglasung · IV3 Dreifachisolierverglasung · BFB Beton-Fensterbank ·
|
||||||
|
MFB Metall-Fensterbank · FFB Faserzement-Fensterbank.
|
||||||
|
|
||||||
|
## B.9.2 Türen im Grundriss (Figuren 41/42) + Sinnbilder (B.9.2.2)
|
||||||
|
- **Türblatt als Linie ab Band + Viertelkreisbogen** (Schwingbahn). Bandseite =
|
||||||
|
Bogenmittelpunkt. Zweiflüglig/Doppeltüre = zwei Bögen.
|
||||||
|
- Zargenarten: Futterrahmen/Zarge, Blockrahmen/Profil, Blendrahmen — je mit
|
||||||
|
Anschlag-/Schwellenmark. Bei Niveaudifferenz Schwellenstrich.
|
||||||
|
- Weitere Sinnbilder: Pendeltüre (Bogen beidseitig gestrichelt), Falttüre
|
||||||
|
(Zickzack), Faltschiebetor, Drehtüre (Kreis mit X), Kipptor, Schiebetüre
|
||||||
|
(ausserhalb/in der Wand, Pfeil), Harmonikatüre.
|
||||||
|
|
||||||
|
## Umsetzung im Code (Soll)
|
||||||
|
- `generatePlan.ts` Fenster-Zweig + `geometry/opening.ts::windowSymbol`:
|
||||||
|
drei DEUTLICH getrennte Detailstufen statt heute (mittel≈fein):
|
||||||
|
- **grob:** 1 Rahmen-/Glasband (Rechteck), sonst nichts.
|
||||||
|
- **mittel:** Blendrahmen-Rechteck + Glaslinie + 1 Stulp-Quadrat mittig +
|
||||||
|
Flügel-Trennlinien; keine verschachtelten Flügelrahmen, kein Anschlag.
|
||||||
|
- **fein:** verschachtelte Blend-+Flügelrahmen (mehrlinig) + Glas-Doppellinie
|
||||||
|
(IV) + Stulp-Quadrate + Anschlag/Laibungsmarken.
|
||||||
|
- Öffnungssymbole (Ansicht) auf SIA umstellen (Spitze zur Bandseite), Türbögen
|
||||||
|
bleiben (schon SIA-konform).
|
||||||
Generated
+20
@@ -10,6 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@@ -1842,6 +1844,24 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||||
|
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-fs": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tweenjs/tween.js": {
|
"node_modules/@tweenjs/tween.js": {
|
||||||
"version": "23.1.3",
|
"version": "23.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
|||||||
+13
-1
@@ -3,6 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "Karim Gabriele Varano",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
@@ -19,11 +21,14 @@
|
|||||||
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
"build:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
||||||
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
|
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
|
||||||
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
||||||
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web"
|
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web",
|
||||||
|
"build:truck": "wasm-pack build src-tauri/trucksolid --release --target web --out-dir ../../src/engine/pkgTruck --out-name trucksolid --no-default-features --features web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@@ -48,5 +53,12 @@
|
|||||||
"vite": "^5.4.8",
|
"vite": "^5.4.8",
|
||||||
"vitest": "^4.1.9",
|
"vitest": "^4.1.9",
|
||||||
"wasm-pack": "^0.15.0"
|
"wasm-pack": "^0.15.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"core-js@3.49.0": true,
|
||||||
|
"esbuild@0.21.5": true,
|
||||||
|
"fsevents@2.3.3": true,
|
||||||
|
"fsevents@2.3.2": true,
|
||||||
|
"wasm-pack@0.15.0": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+173
-3
@@ -384,6 +384,7 @@ dependencies = [
|
|||||||
name = "cad-tauri"
|
name = "cad-tauri"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"fs4",
|
||||||
"geometry",
|
"geometry",
|
||||||
"pollster",
|
"pollster",
|
||||||
"render2d",
|
"render2d",
|
||||||
@@ -392,6 +393,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
]
|
]
|
||||||
@@ -982,7 +985,7 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libloading 0.7.4",
|
"libloading 0.8.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1304,6 +1307,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs4"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de"
|
||||||
|
dependencies = [
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1690,7 +1703,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"presser",
|
"presser",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows 0.61.3",
|
"windows 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2892,6 +2905,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -3705,6 +3719,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2 0.6.2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
@@ -4538,6 +4576,64 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 1.1.2+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -5946,6 +6042,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.53.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -5979,13 +6084,30 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.53.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
|
"windows_aarch64_msvc 0.53.1",
|
||||||
|
"windows_i686_gnu 0.53.1",
|
||||||
|
"windows_i686_gnullvm 0.53.1",
|
||||||
|
"windows_i686_msvc 0.53.1",
|
||||||
|
"windows_x86_64_gnu 0.53.1",
|
||||||
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
|
"windows_x86_64_msvc 0.53.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-threading"
|
name = "windows-threading"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -6025,6 +6147,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6037,6 +6165,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6049,12 +6183,24 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6067,6 +6213,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6079,6 +6231,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6091,6 +6249,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
@@ -6103,6 +6267,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.53.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winit"
|
name = "winit"
|
||||||
version = "0.30.13"
|
version = "0.30.13"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ members = ["."]
|
|||||||
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
|
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
|
||||||
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
|
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
|
||||||
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
|
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
|
||||||
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"]
|
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport", "trucksolid"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "cad-tauri"
|
name = "cad-tauri"
|
||||||
@@ -37,9 +37,15 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
geometry = { path = "geometry" }
|
geometry = { path = "geometry" }
|
||||||
|
# OS-Advisory-Lock (gepflegter fs2-Nachfolger) fuer die Projektdatei-Lock-Datei
|
||||||
|
# (src/lock.rs): exklusiver Lock auf einer Sidecar-Datei, faellt automatisch
|
||||||
|
# beim Prozessende/Absturz weg (kein manuelles Aufraeumen noetig).
|
||||||
|
fs4 = { version = "0.9", default-features = false, features = ["sync"] }
|
||||||
|
|
||||||
# --- M2-Spike (nur mit Feature "native2d") -----------------------------------
|
# --- M2-Spike (nur mit Feature "native2d") -----------------------------------
|
||||||
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
|
# render2d ist eine eigenstaendige Crate (eigener leerer [workspace]); wir binden
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-start-dragging"
|
"core:window:allow-start-dragging",
|
||||||
|
"dialog:allow-save",
|
||||||
|
"fs:allow-write-text-file"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2470,6 +2470,223 @@ pub fn room_from_point_inside_faces(
|
|||||||
best
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Oeffnungs-Geometrie (Port von src/geometry/opening.ts, Slice 4) --------
|
||||||
|
// `Wall`/`Opening`/`Project`-Kopplung durch Vec2/number-Parameter ersetzt
|
||||||
|
// (PORT_PLAN §1 "Teilweise im Scope"): `thickness`/`refOffset` werden hier als
|
||||||
|
// bereits aufgeloeste Zahlen uebergeben (TS: `wallTypeThickness(getWallType(...))`
|
||||||
|
// bzw. `wallReferenceOffset(wall, total)` gegen ein `Project`). Nur
|
||||||
|
// `openingVerticalExtent` bleibt TS (haengt an `Project.drawingLevels` via
|
||||||
|
// `wallVerticalExtent`) und wird hier NICHT portiert.
|
||||||
|
//
|
||||||
|
// `DEG` (Grad→Radiant, Port des lokalen `DEG` in opening.ts) ist bereits als
|
||||||
|
// modul-weite Konstante aus dem stair-Slice vorhanden (identischer Wert).
|
||||||
|
|
||||||
|
/// Punkt entlang der Strecke p1→p2 im Abstand d vom Start (Port von `along`
|
||||||
|
/// aus src/model/geometry.ts).
|
||||||
|
pub fn along(p1: Vec2, p2: Vec2, d: f64) -> Vec2 {
|
||||||
|
add(p1, scale(normalize(sub(p2, p1)), d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Achslaenge einer Wand (Port von `wallAxisLength`; Wand → start/end).
|
||||||
|
pub fn wall_axis_length(start: Vec2, end: Vec2) -> f64 {
|
||||||
|
(end.x - start.x).hypot(end.y - start.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oeffnungs-Intervall entlang der Achse (Port von `openingInterval`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Interval {
|
||||||
|
pub from: f64,
|
||||||
|
pub to: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn opening_interval(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Interval> {
|
||||||
|
let axis = wall_axis_length(start, end);
|
||||||
|
let from = position.min(axis).max(0.0);
|
||||||
|
let to = (position + width).min(axis).max(from);
|
||||||
|
if to - from < 1e-4 {
|
||||||
|
return None; // entartet: Breite <= 0 oder vollstaendig ausserhalb der Achse
|
||||||
|
}
|
||||||
|
Some(Interval { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse (Port von
|
||||||
|
/// `wallAxisFrame`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||||
|
pub struct AxisFrame {
|
||||||
|
pub u: Vec2,
|
||||||
|
pub n: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wall_axis_frame(start: Vec2, end: Vec2) -> AxisFrame {
|
||||||
|
let u = normalize(sub(end, start));
|
||||||
|
AxisFrame { u, n: left_normal(u) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die beiden Pfosten-Punkte einer Oeffnung im Grundriss (Port von `openingJambs`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Jambs {
|
||||||
|
pub jamb_start: Vec2,
|
||||||
|
pub jamb_end: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn opening_jambs(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Jambs> {
|
||||||
|
let iv = opening_interval(start, end, position, width)?;
|
||||||
|
Some(Jambs {
|
||||||
|
jamb_start: along(start, end, iv.from),
|
||||||
|
jamb_end: along(start, end, iv.to),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Luecken-Rechteck-Ecken ueber die volle Wanddicke (Port von `openingGapQuad`).
|
||||||
|
/// Reihenfolge: jambStart(+n/2) → jambEnd(+n/2) → jambEnd(−n/2) → jambStart(−n/2).
|
||||||
|
pub fn opening_gap_quad(
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
thickness: f64,
|
||||||
|
ref_offset: f64,
|
||||||
|
) -> Option<Vec<Vec2>> {
|
||||||
|
let jambs = opening_jambs(start, end, position, width)?;
|
||||||
|
let n = wall_axis_frame(start, end).n;
|
||||||
|
let outer = ref_offset + thickness / 2.0;
|
||||||
|
let inner = ref_offset - thickness / 2.0;
|
||||||
|
Some(vec![
|
||||||
|
add(jambs.jamb_start, scale(n, outer)),
|
||||||
|
add(jambs.jamb_end, scale(n, outer)),
|
||||||
|
add(jambs.jamb_end, scale(n, inner)),
|
||||||
|
add(jambs.jamb_start, scale(n, inner)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mittelpunkt einer Oeffnung im Grundriss (Port von `openingCenter`).
|
||||||
|
pub fn opening_center(start: Vec2, end: Vec2, position: f64, width: f64) -> Option<Vec2> {
|
||||||
|
let iv = opening_interval(start, end, position, width)?;
|
||||||
|
Some(along(start, end, (iv.from + iv.to) / 2.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aufgeloeste Tuer-Symbol-Geometrie im Grundriss (Port von `DoorSymbol`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DoorSymbol {
|
||||||
|
pub hinge: Vec2,
|
||||||
|
pub open_end: Vec2,
|
||||||
|
pub closed_end: Vec2,
|
||||||
|
pub radius: f64,
|
||||||
|
pub jamb_start: Vec2,
|
||||||
|
pub jamb_end: Vec2,
|
||||||
|
pub normal: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port von `doorSymbol`. `swing`/`opening_dir`/`hinge_side` folgen der TS-
|
||||||
|
/// `??`-Fallback-Semantik (`None` → TS-Default); `swing_angle` `None` → 90°.
|
||||||
|
pub fn door_symbol(
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
swing: Option<&str>,
|
||||||
|
opening_dir: Option<&str>,
|
||||||
|
hinge_side: Option<&str>,
|
||||||
|
swing_angle: Option<f64>,
|
||||||
|
) -> Option<DoorSymbol> {
|
||||||
|
let jambs = opening_jambs(start, end, position, width)?;
|
||||||
|
let frame = wall_axis_frame(start, end);
|
||||||
|
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
|
||||||
|
// TS berechnet die Breite NEU aus den geklemmten Pfosten (nicht das rohe
|
||||||
|
// `o.width`) — bewusst repliziert.
|
||||||
|
let w = (jamb_end.x - jamb_start.x).hypot(jamb_end.y - jamb_start.y);
|
||||||
|
|
||||||
|
let swing_sign = if swing.unwrap_or("left") == "left" { 1.0 } else { -1.0 };
|
||||||
|
let dir_sign = if opening_dir.unwrap_or("in") == "in" { 1.0 } else { -1.0 };
|
||||||
|
let swing_dir = scale(frame.n, swing_sign * dir_sign);
|
||||||
|
|
||||||
|
let hinge_at_start = hinge_side.unwrap_or("start") == "start";
|
||||||
|
let hinge = if hinge_at_start { jamb_start } else { jamb_end };
|
||||||
|
let closed_dir = if hinge_at_start { frame.u } else { scale(frame.u, -1.0) };
|
||||||
|
|
||||||
|
let angle = swing_angle.unwrap_or(90.0) * DEG;
|
||||||
|
let open_dir = Vec2 {
|
||||||
|
x: closed_dir.x * angle.cos() + swing_dir.x * angle.sin(),
|
||||||
|
y: closed_dir.y * angle.cos() + swing_dir.y * angle.sin(),
|
||||||
|
};
|
||||||
|
Some(DoorSymbol {
|
||||||
|
hinge,
|
||||||
|
open_end: add(hinge, scale(open_dir, w)),
|
||||||
|
closed_end: add(hinge, scale(closed_dir, w)),
|
||||||
|
radius: w,
|
||||||
|
jamb_start,
|
||||||
|
jamb_end,
|
||||||
|
normal: frame.n,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aufgeloeste Fenster-Linien im Grundriss (Port von `WindowSymbol`).
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WindowSymbol {
|
||||||
|
pub frame: Vec<Vec2>,
|
||||||
|
pub glass_lines: Vec<(Vec2, Vec2)>,
|
||||||
|
pub mullion_lines: Vec<(Vec2, Vec2)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port von `windowSymbol`. `thickness`/`ref_offset` sind bereits aufgeloeste
|
||||||
|
/// Werte (siehe Modul-Kommentar); `wing_count` `None` → TS-Default `1`.
|
||||||
|
pub fn window_symbol(
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
thickness: f64,
|
||||||
|
ref_offset: f64,
|
||||||
|
glass_count: f64,
|
||||||
|
wing_count: Option<f64>,
|
||||||
|
) -> Option<WindowSymbol> {
|
||||||
|
let jambs = opening_jambs(start, end, position, width)?;
|
||||||
|
let (jamb_start, jamb_end) = (jambs.jamb_start, jambs.jamb_end);
|
||||||
|
let n = wall_axis_frame(start, end).n;
|
||||||
|
let outer = ref_offset + thickness / 2.0;
|
||||||
|
let inner = ref_offset - thickness / 2.0;
|
||||||
|
let frame_quad = vec![
|
||||||
|
add(jamb_start, scale(n, outer)),
|
||||||
|
add(jamb_end, scale(n, outer)),
|
||||||
|
add(jamb_end, scale(n, inner)),
|
||||||
|
add(jamb_start, scale(n, inner)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut glass_lines: Vec<(Vec2, Vec2)> = Vec::new();
|
||||||
|
let count = glass_count.max(1.0);
|
||||||
|
let mut i: i64 = 0;
|
||||||
|
while (i as f64) < count {
|
||||||
|
let spread = thickness / 8.0;
|
||||||
|
let off = if count == 1.0 {
|
||||||
|
ref_offset
|
||||||
|
} else {
|
||||||
|
ref_offset + ((i as f64) - (count - 1.0) / 2.0) * spread * 2.0
|
||||||
|
};
|
||||||
|
glass_lines.push((add(jamb_start, scale(n, off)), add(jamb_end, scale(n, off))));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut mullion_lines: Vec<(Vec2, Vec2)> = Vec::new();
|
||||||
|
let wings = wing_count.unwrap_or(1.0).round().min(4.0).max(1.0);
|
||||||
|
if wings > 1.0 {
|
||||||
|
let post_depth = thickness * 0.6;
|
||||||
|
let p_outer = ref_offset + post_depth / 2.0;
|
||||||
|
let p_inner = ref_offset - post_depth / 2.0;
|
||||||
|
let wings_n = wings as i64;
|
||||||
|
for k in 1..wings_n {
|
||||||
|
let t = k as f64 / wings;
|
||||||
|
let on_axis = add(jamb_start, scale(sub(jamb_end, jamb_start), t));
|
||||||
|
mullion_lines.push((add(on_axis, scale(n, p_outer)), add(on_axis, scale(n, p_inner))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(WindowSymbol { frame: frame_quad, glass_lines, mullion_lines })
|
||||||
|
}
|
||||||
|
|
||||||
// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ----------------------------
|
// --- Batch-Fassaden: roomArea / ceiling (Slice 1) ----------------------------
|
||||||
|
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
@@ -2741,6 +2958,183 @@ pub fn rb_point_in_polygon_batch_json(input_json: &str) -> Result<String, wasm_b
|
|||||||
to_js(&out)
|
to_js(&out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Batch-Fassaden: opening (Slice 4) ---------------------------------------
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WallAxisLengthQuery {
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn wall_axis_length_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<f64> = qs.iter().map(|q| wall_axis_length(q.start, q.end)).collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OpeningQuery {
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn opening_interval_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<OpeningQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<Option<Interval>> = qs
|
||||||
|
.iter()
|
||||||
|
.map(|q| opening_interval(q.start, q.end, q.position, q.width))
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn wall_axis_frame_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<WallAxisLengthQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<AxisFrame> = qs.iter().map(|q| wall_axis_frame(q.start, q.end)).collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn opening_jambs_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<OpeningQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<Option<Jambs>> = qs
|
||||||
|
.iter()
|
||||||
|
.map(|q| opening_jambs(q.start, q.end, q.position, q.width))
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OpeningGapQuadQuery {
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
thickness: f64,
|
||||||
|
ref_offset: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn opening_gap_quad_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<OpeningGapQuadQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<Option<Vec<Vec2>>> = qs
|
||||||
|
.iter()
|
||||||
|
.map(|q| opening_gap_quad(q.start, q.end, q.position, q.width, q.thickness, q.ref_offset))
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn opening_center_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<OpeningQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<Option<Vec2>> = qs
|
||||||
|
.iter()
|
||||||
|
.map(|q| opening_center(q.start, q.end, q.position, q.width))
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DoorSymbolQuery {
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
swing: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
opening_dir: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
hinge: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
swing_angle: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn door_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<DoorSymbolQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<Option<DoorSymbol>> = qs
|
||||||
|
.iter()
|
||||||
|
.map(|q| {
|
||||||
|
door_symbol(
|
||||||
|
q.start,
|
||||||
|
q.end,
|
||||||
|
q.position,
|
||||||
|
q.width,
|
||||||
|
q.swing.as_deref(),
|
||||||
|
q.opening_dir.as_deref(),
|
||||||
|
q.hinge.as_deref(),
|
||||||
|
q.swing_angle,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WindowSymbolQuery {
|
||||||
|
start: Vec2,
|
||||||
|
end: Vec2,
|
||||||
|
position: f64,
|
||||||
|
width: f64,
|
||||||
|
thickness: f64,
|
||||||
|
ref_offset: f64,
|
||||||
|
glass_count: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
wing_count: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn window_symbol_batch_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
let qs: Vec<WindowSymbolQuery> = from_js(input_json)?;
|
||||||
|
let out: Vec<Option<WindowSymbol>> = qs
|
||||||
|
.iter()
|
||||||
|
.map(|q| {
|
||||||
|
window_symbol(
|
||||||
|
q.start,
|
||||||
|
q.end,
|
||||||
|
q.position,
|
||||||
|
q.width,
|
||||||
|
q.thickness,
|
||||||
|
q.ref_offset,
|
||||||
|
q.glass_count,
|
||||||
|
q.wing_count,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
|||||||
}],
|
}],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
},
|
},
|
||||||
WallInput {
|
WallInput {
|
||||||
start: [0.0, 3.0],
|
start: [0.0, 3.0],
|
||||||
@@ -44,6 +47,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
},
|
},
|
||||||
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
|
// "Spaeh"-Wand: kurzer Stummel HINTER Schenkel A (groesseres Modell-X,
|
||||||
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
|
// also groessere Tiefe von der Betrachter-Ebene aus), genau im
|
||||||
@@ -60,6 +66,9 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let slabs = vec![SlabInput {
|
let slabs = vec![SlabInput {
|
||||||
@@ -67,6 +76,8 @@ fn l_wall_scene() -> (Vec<WallInput>, Vec<SlabInput>) {
|
|||||||
z_bottom: -0.2,
|
z_bottom: -0.2,
|
||||||
z_top: 0.0,
|
z_top: 0.0,
|
||||||
color: [0.86, 0.86, 0.88],
|
color: [0.86, 0.86, 0.88],
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
}];
|
}];
|
||||||
(walls, slabs)
|
(walls, slabs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ fn demo_walls() -> Vec<WallInput> {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
// Raum 6 x 4 m.
|
// Raum 6 x 4 m.
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
@@ -159,15 +159,45 @@ pub fn build_mesh_edges(mesh: &Mesh) -> Vec<f32> {
|
|||||||
/// Entscheidet, ob eine Kante gezeichnet wird: Randkante (nur ein Dreieck) oder
|
/// Entscheidet, ob eine Kante gezeichnet wird: Randkante (nur ein Dreieck) oder
|
||||||
/// Knickkante (zwei angrenzende Dreiecke stehen ueber dem Crease-Winkel zuein-
|
/// Knickkante (zwei angrenzende Dreiecke stehen ueber dem Crease-Winkel zuein-
|
||||||
/// ander). Koplanare geteilte Kanten (Flaechendiagonalen) werden unterdrueckt.
|
/// ander). Koplanare geteilte Kanten (Flaechendiagonalen) werden unterdrueckt.
|
||||||
|
///
|
||||||
|
/// DOPPELSEITIGE MESHES (Kontext/Dach/Fenster-Glas, s. `mesh.rs::push_ctx_tri` —
|
||||||
|
/// jedes Dreieck wird dort MIT gespiegelter Rueckseite [-Normale] dupliziert,
|
||||||
|
/// weil die Mesh-Pipeline Backface-Culling aktiv hat): jedes Original-Dreieck
|
||||||
|
/// liefert dadurch IMMER ein exaktes +n/-n-Paar an jeder seiner Kanten. Ohne
|
||||||
|
/// Beruecksichtigung wuerde das faelschlich als extremer Knick (dot ≈ -1)
|
||||||
|
/// gewertet und JEDE Flaechen-Innendiagonale eines doppelseitigen Meshes
|
||||||
|
/// gezeichnet (Nutzer-Report: Dreiecks-Diagonalen sichtbar auf Dach/Glas).
|
||||||
|
/// Fix: zuerst Rueckseiten-Partner (dot ≈ -1 zueinander) einander zuordnen und
|
||||||
|
/// nur EINEN Vertreter je Original-Dreieck behalten — danach greift dieselbe
|
||||||
|
/// Rand-/Knick-Logik wie bei einseitigen Meshes (Waende), unabhaengig davon,
|
||||||
|
/// ob doppelseitig gerendert wurde oder nicht.
|
||||||
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
fn should_draw(normals: &[[f32; 3]]) -> bool {
|
||||||
match normals.len() {
|
let mut representatives: Vec<[f32; 3]> = Vec::new();
|
||||||
|
let mut used = vec![false; normals.len()];
|
||||||
|
for i in 0..normals.len() {
|
||||||
|
if used[i] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used[i] = true;
|
||||||
|
representatives.push(normals[i]);
|
||||||
|
for j in (i + 1)..normals.len() {
|
||||||
|
if used[j] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if dot(normals[i], normals[j]) < -CREASE_COS {
|
||||||
|
used[j] = true; // Rueckseiten-Duplikat desselben Dreiecks
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match representatives.len() {
|
||||||
0 => false,
|
0 => false,
|
||||||
1 => true, // Silhouette/Rand: gehoert nur einem Dreieck
|
1 => true, // Silhouette/Rand: gehoert nur einem Original-Dreieck
|
||||||
_ => {
|
_ => {
|
||||||
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
|
// Zeichnen, sobald irgendein Normalen-Paar deutlich abknickt.
|
||||||
for i in 0..normals.len() {
|
for i in 0..representatives.len() {
|
||||||
for j in (i + 1)..normals.len() {
|
for j in (i + 1)..representatives.len() {
|
||||||
if dot(normals[i], normals[j]) < CREASE_COS {
|
if dot(representatives[i], representatives[j]) < CREASE_COS {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,3 +278,134 @@ mod tests {
|
|||||||
assert!(build_mesh_edges(&m).is_empty());
|
assert!(build_mesh_edges(&m).is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod double_sided_tests {
|
||||||
|
// Deckt den Nutzer-Report ab (Dreiecks-Diagonalen sichtbar auf Dach/Glas):
|
||||||
|
// Kontext-Meshes werden von `mesh.rs::push_ctx_tri` IMMER doppelseitig
|
||||||
|
// aufgebaut (jedes Dreieck + gespiegelte Rueckseite [-Normale], weil die
|
||||||
|
// Mesh-Pipeline Backface-Culling aktiv hat). Diese Tests bauen genau dieses
|
||||||
|
// Muster nach (nicht die einfachen einseitigen Test-Meshes oben).
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mesh_single_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh {
|
||||||
|
let mut verts = Vec::new();
|
||||||
|
for p in positions {
|
||||||
|
verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]);
|
||||||
|
}
|
||||||
|
Mesh { verts, indices: indices.to_vec() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut ein Mesh wie `mesh_single_sided`, aber verdoppelt JEDES Dreieck mit
|
||||||
|
/// umgekehrter Wicklung (a,c,b statt a,b,c) — exakt das Muster von
|
||||||
|
/// `mesh.rs::push_ctx_tri` (Vorder- + Rueckseite fuer doppelseitiges Rendering).
|
||||||
|
fn mesh_double_sided(positions: &[[f32; 3]], indices: &[u32]) -> Mesh {
|
||||||
|
let mut verts = Vec::new();
|
||||||
|
for p in positions {
|
||||||
|
verts.extend_from_slice(&[p[0], p[1], p[2], 0.0, 1.0, 0.0, 0.8, 0.8, 0.8]);
|
||||||
|
}
|
||||||
|
let mut doubled = Vec::with_capacity(indices.len() * 2);
|
||||||
|
for tri in indices.chunks_exact(3) {
|
||||||
|
doubled.extend_from_slice(&[tri[0], tri[1], tri[2]]);
|
||||||
|
doubled.extend_from_slice(&[tri[0], tri[2], tri[1]]); // gespiegelte Rueckseite
|
||||||
|
}
|
||||||
|
Mesh { verts, indices: doubled }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_count(v: &[f32]) -> usize {
|
||||||
|
v.len() / EDGE_FLOATS_PER_VERTEX / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doubled_coplanar_quad_still_drops_shared_diagonal() {
|
||||||
|
// Dasselbe ebene Quad wie coplanar_quad_drops_shared_diagonal, aber
|
||||||
|
// doppelseitig aufgebaut — VOR dem Fix haette dies faelschlich 6 statt
|
||||||
|
// 4 Segmente geliefert (Diagonale faelschlich als Knick erkannt).
|
||||||
|
let m = mesh_double_sided(
|
||||||
|
&[
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 1.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
],
|
||||||
|
&[0, 1, 2, 0, 2, 3],
|
||||||
|
);
|
||||||
|
let e = build_mesh_edges(&m);
|
||||||
|
assert_eq!(segment_count(&e), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doubled_single_triangle_still_has_three_boundary_edges() {
|
||||||
|
// Silhouette-Kanten eines EINZELNEN Dreiecks muessen trotz Verdopplung
|
||||||
|
// (Vorder-/Rueckseite) weiterhin als Rand erkannt und gezeichnet werden.
|
||||||
|
let m = mesh_double_sided(
|
||||||
|
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
|
||||||
|
&[0, 1, 2],
|
||||||
|
);
|
||||||
|
let e = build_mesh_edges(&m);
|
||||||
|
assert_eq!(segment_count(&e), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doubled_folded_quad_still_keeps_crease() {
|
||||||
|
// Echter 90°-Knick bleibt auch doppelseitig als Kante erhalten (keine
|
||||||
|
// Ueberkompensation, die auch echte Knicke unterdruecken wuerde).
|
||||||
|
let m = mesh_double_sided(
|
||||||
|
&[
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 1.0],
|
||||||
|
[1.0, 1.0, 0.0],
|
||||||
|
],
|
||||||
|
&[0, 1, 2, 0, 3, 1],
|
||||||
|
);
|
||||||
|
let e = build_mesh_edges(&m);
|
||||||
|
assert_eq!(segment_count(&e), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_opening_box_topology_suppresses_face_diagonals_doubled() {
|
||||||
|
// Exakte Ecken-Reihenfolge/Indizes aus toWalls3d.ts::OPENING_BOX_TRIS,
|
||||||
|
// diesmal ECHT doppelseitig wie im tatsaechlichen Render-Pfad (die
|
||||||
|
// Fenster-Glas-/Rahmen-Boxen laufen ueber append_context_mesh).
|
||||||
|
let from = 0.0f32;
|
||||||
|
let to = 1.2f32;
|
||||||
|
let z_bottom = 0.9f32;
|
||||||
|
let z_top = 2.1f32;
|
||||||
|
let n_min = -0.03f32;
|
||||||
|
let n_max = 0.03f32;
|
||||||
|
let mut positions = Vec::new();
|
||||||
|
for i in 0..8u32 {
|
||||||
|
let s = if i & 1 != 0 { to } else { from };
|
||||||
|
let z = if i & 2 != 0 { z_top } else { z_bottom };
|
||||||
|
let t = if i & 4 != 0 { n_max } else { n_min };
|
||||||
|
positions.push([s, -t, z]);
|
||||||
|
}
|
||||||
|
let indices: Vec<u32> = vec![
|
||||||
|
0, 1, 3, 0, 3, 2,
|
||||||
|
4, 5, 7, 4, 7, 6,
|
||||||
|
0, 1, 5, 0, 5, 4,
|
||||||
|
2, 3, 7, 2, 7, 6,
|
||||||
|
0, 2, 6, 0, 6, 4,
|
||||||
|
1, 3, 7, 1, 7, 5,
|
||||||
|
];
|
||||||
|
let m = mesh_double_sided(&positions, &indices);
|
||||||
|
let e = build_mesh_edges(&m);
|
||||||
|
// Ein Quader hat 12 echte Kanten (Silhouette); bei korrekter Diagonalen-
|
||||||
|
// Unterdrueckung sollten es GENAU 12 sein (nicht 12+6 Diagonalen=18).
|
||||||
|
assert_eq!(segment_count(&e), 12, "erwartet 12 Kanten (Quader-Silhouette), keine Flaechendiagonalen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_sided_helper_matches_module_level_mesh_helper() {
|
||||||
|
// Absicherung: der lokale mesh_single_sided-Helfer verhaelt sich
|
||||||
|
// identisch zum bestehenden `tests::mesh` (keine versehentliche
|
||||||
|
// Abweichung beim Kopieren).
|
||||||
|
let m = mesh_single_sided(
|
||||||
|
&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
|
||||||
|
&[0, 1, 2],
|
||||||
|
);
|
||||||
|
let e = build_mesh_edges(&m);
|
||||||
|
assert_eq!(segment_count(&e), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+346
-31
@@ -15,12 +15,17 @@ use wgpu::util::DeviceExt;
|
|||||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||||
use crate::math::{view_projection, Mat4};
|
use crate::math::{view_projection, Mat4};
|
||||||
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
|
use crate::mesh::{
|
||||||
|
build_scene_mesh, build_scene_mesh_textured, build_walls_mesh, build_walls_mesh_textured,
|
||||||
|
};
|
||||||
use crate::section::SectionPlane;
|
use crate::section::SectionPlane;
|
||||||
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
|
use crate::section_fill::{
|
||||||
|
build_cut_cap_lines, build_cut_caps, CAP_FLOATS_PER_VERTEX, CUT_LINE_FLOATS_PER_VERTEX,
|
||||||
|
};
|
||||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
Camera, Mesh, MeshInput, SlabInput, TexturedMesh, WallInput, FLOATS_PER_VERTEX,
|
||||||
|
TEXTURED_FLOATS_PER_VERTEX,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
||||||
@@ -41,6 +46,11 @@ pub enum RenderStyle {
|
|||||||
/// Hidden-Line: flach-weisse Flaechen (fuellen den Depth-Buffer) + dunkle
|
/// Hidden-Line: flach-weisse Flaechen (fuellen den Depth-Buffer) + dunkle
|
||||||
/// Kanten obenauf, tiefengetestet -> nur sichtbare Kanten erscheinen.
|
/// Kanten obenauf, tiefengetestet -> nur sichtbare Kanten erscheinen.
|
||||||
Hidden,
|
Hidden,
|
||||||
|
/// "Schattiert mit Kanten" (typischer BIM-Look, z. B. Revit/ArchiCAD): wie
|
||||||
|
/// `Shaded` (echte Bauteilfarben, beleuchtet), ZUSAETZLICH die dunklen
|
||||||
|
/// Modell-Kanten obenauf (wie bei `Hidden`, aber mit Material- statt
|
||||||
|
/// Weiss-Flaechen).
|
||||||
|
ShadedEdges,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderStyle {
|
impl RenderStyle {
|
||||||
@@ -51,18 +61,20 @@ impl RenderStyle {
|
|||||||
"textured" => RenderStyle::Textured,
|
"textured" => RenderStyle::Textured,
|
||||||
"wireframe" => RenderStyle::Wireframe,
|
"wireframe" => RenderStyle::Wireframe,
|
||||||
"hidden" => RenderStyle::Hidden,
|
"hidden" => RenderStyle::Hidden,
|
||||||
|
"shaded-edges" => RenderStyle::ShadedEdges,
|
||||||
_ => RenderStyle::Shaded,
|
_ => RenderStyle::Shaded,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `mode.x`-Wert fuer den Mesh-Shader (siehe shaders.rs): 0 = Material/shaded
|
/// `mode.x`-Wert fuer den Mesh-Shader (siehe shaders.rs): 0 = Material/shaded
|
||||||
/// (auch textured), 1 = weiss/Clay, 2 = flach-weisse Hidden-Line-Flaechen.
|
/// (auch textured/shaded-edges), 1 = weiss/Clay, 2 = flach-weisse
|
||||||
|
/// Hidden-Line-Flaechen.
|
||||||
fn mesh_mode(self) -> f32 {
|
fn mesh_mode(self) -> f32 {
|
||||||
match self {
|
match self {
|
||||||
RenderStyle::White => 1.0,
|
RenderStyle::White => 1.0,
|
||||||
RenderStyle::Hidden => 2.0,
|
RenderStyle::Hidden => 2.0,
|
||||||
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
|
// Shaded, Textured, Wireframe (Flaechen werden bei Wireframe eh nicht
|
||||||
// gezeichnet) -> Material.
|
// gezeichnet), ShadedEdges -> Material.
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +86,19 @@ impl RenderStyle {
|
|||||||
|
|
||||||
/// Werden in diesem Stil Modell-Kanten gezeichnet?
|
/// Werden in diesem Stil Modell-Kanten gezeichnet?
|
||||||
fn draws_edges(self) -> bool {
|
fn draws_edges(self) -> bool {
|
||||||
matches!(self, RenderStyle::Wireframe | RenderStyle::Hidden)
|
matches!(
|
||||||
|
self,
|
||||||
|
RenderStyle::Wireframe | RenderStyle::Hidden | RenderStyle::ShadedEdges
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Braucht dieser Stil die leicht nach hinten gebiaste Flaechen-Pipeline,
|
||||||
|
/// damit die (tiefengetesteten) Kanten sauber obenauf liegen, statt mit den
|
||||||
|
/// Flaechen zu flackern (Z-Fighting)? Gilt fuer alle Stile, die BEIDES
|
||||||
|
/// zeichnen (Flaechen + Kanten) — bislang nur `Hidden`, jetzt auch
|
||||||
|
/// `ShadedEdges`.
|
||||||
|
fn needs_biased_face_pipeline(self) -> bool {
|
||||||
|
matches!(self, RenderStyle::Hidden | RenderStyle::ShadedEdges)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +166,10 @@ struct MeshBuffers {
|
|||||||
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
|
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
|
||||||
struct TexturedMeshBuffers {
|
struct TexturedMeshBuffers {
|
||||||
vbo: wgpu::Buffer,
|
vbo: wgpu::Buffer,
|
||||||
|
/// Zweiter Vertex-Buffer: EIN f32 (Material-Textur-Ebene) je Vertex,
|
||||||
|
/// @location(3) im `MESH_TEXTURED_WGSL`. Immer gesetzt (Fallback: `-1` je
|
||||||
|
/// Vertex), damit das Pipeline-Layout stets zwei Vertex-Buffer erwartet.
|
||||||
|
layer_vbo: wgpu::Buffer,
|
||||||
ibo: wgpu::Buffer,
|
ibo: wgpu::Buffer,
|
||||||
index_count: u32,
|
index_count: u32,
|
||||||
}
|
}
|
||||||
@@ -225,21 +253,39 @@ pub struct Renderer {
|
|||||||
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
||||||
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
||||||
cap_pipeline: wgpu::RenderPipeline,
|
cap_pipeline: wgpu::RenderPipeline,
|
||||||
|
/// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): TriangleList von
|
||||||
|
/// Ribbon-Quads (`section_fill::build_cut_cap_lines`), Vertex-Layout [pos vec3,
|
||||||
|
/// color vec3] wie die Grid/Highlight-LineList — teilt darum das `grid_module`
|
||||||
|
/// (GRID_WGSL). KEIN Tiefentest (`depth_compare: Always`, kein Write) wie das
|
||||||
|
/// Highlight: die Fugenlinien liegen (leicht zur Kamera versetzt) sichtbar auf
|
||||||
|
/// den Cut-Caps. Sie werden NICHT an der Schnittebene weggeclippt (sie SIND die
|
||||||
|
/// Schnittflaeche) — der Kamera-Lift in `build_cut_cap_lines` haelt sie vor der
|
||||||
|
/// `depth > 0`-Kappung von GRID_WGSL.
|
||||||
|
cut_line_pipeline: wgpu::RenderPipeline,
|
||||||
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
|
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
|
||||||
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
|
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
|
||||||
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
|
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
|
||||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||||
/// Shader/Layout unterscheiden sich.
|
/// Shader/Layout unterscheiden sich.
|
||||||
textured_pipeline: wgpu::RenderPipeline,
|
textured_pipeline: wgpu::RenderPipeline,
|
||||||
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
|
/// Bind-Group des Material-Textur-Arrays (group 1): Array-View + Sampler.
|
||||||
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
|
/// Wird bei `set_material_textures` neu gebaut (mehr Ebenen); bis dahin haelt
|
||||||
|
/// sie das 1-Ebenen-Array mit dem Fallback-Schachbrett.
|
||||||
texture_bind_group: wgpu::BindGroup,
|
texture_bind_group: wgpu::BindGroup,
|
||||||
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
|
/// Das Textur-Array selbst. Start: 1 Ebene (Schachbrett). `set_material_textures`
|
||||||
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
|
/// ersetzt es durch ein Array `[Schachbrett, Material0, Material1, …]`.
|
||||||
/// Queue bekommt.
|
|
||||||
texture: wgpu::Texture,
|
texture: wgpu::Texture,
|
||||||
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
|
/// Bind-Group-Layout (group 1), festgehalten fuer den Neubau der Bind-Group
|
||||||
/// ersten `render` einmalig gesetzt (lazy Upload).
|
/// beim Hochladen von Material-Texturen.
|
||||||
|
texture_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
/// Sampler (Repeat/Linear), festgehalten fuer den Bind-Group-Neubau.
|
||||||
|
texture_sampler: wgpu::Sampler,
|
||||||
|
/// Anzahl gueltiger Ebenen im Textur-Array (>= 1). 1 = nur Schachbrett; nach
|
||||||
|
/// `set_material_textures` = 1 + Anzahl Material-Karten. Wandert je Frame nach
|
||||||
|
/// `mode.z` (Klemmung der Ebenen-Auswahl im Shader).
|
||||||
|
material_layer_count: u32,
|
||||||
|
/// Ob die Schachbrett-Texel (Ebene 0) schon hochgeladen wurden. Beim ersten
|
||||||
|
/// `render` einmalig gesetzt (lazy Upload, weil `new` keine Queue bekommt).
|
||||||
texture_uploaded: bool,
|
texture_uploaded: bool,
|
||||||
bind_group: wgpu::BindGroup,
|
bind_group: wgpu::BindGroup,
|
||||||
uniform: wgpu::Buffer,
|
uniform: wgpu::Buffer,
|
||||||
@@ -264,6 +310,10 @@ pub struct Renderer {
|
|||||||
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
/// aktiv / keine Schnittgeometrie. Ueber `set_cut_caps` gesetzt/geloescht,
|
||||||
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
/// gezeichnet NACH den Flaechen, VOR Kanten/Grid/Highlight.
|
||||||
caps: Option<CapBuffers>,
|
caps: Option<CapBuffers>,
|
||||||
|
/// Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) als Ribbon-TriangleList
|
||||||
|
/// (`[pos, color]`). None = kein Schnitt aktiv / keine Kanten. Ueber
|
||||||
|
/// `set_cut_lines` gesetzt/geloescht, gezeichnet NACH den Caps.
|
||||||
|
cut_lines: Option<LineBuffers>,
|
||||||
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
|
/// Ob das Bodengitter im naechsten `render` gezeichnet wird. Default false —
|
||||||
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
|
/// bestehende Aufrufer ohne `set_ground_grid` bekommen KEIN Gitter.
|
||||||
grid_visible: bool,
|
grid_visible: bool,
|
||||||
@@ -471,6 +521,52 @@ impl Renderer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Schnitt-Umrisslinien-Pipeline (Cut-Cap-Kanten, P2): dasselbe schlanke
|
||||||
|
// LineList-Vertex-Layout [pos vec3, color vec3] und `grid_module` (GRID_WGSL)
|
||||||
|
// wie Grid/Highlight, aber als TriangleList (die Kanten sind Ribbon-Quads,
|
||||||
|
// damit die Strichstaerke als Welt-Breite durchkommt). Depth wie Highlight
|
||||||
|
// (Always, kein Write) -> immer sichtbar auf den Caps.
|
||||||
|
let cut_line_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("cut_line.pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &grid_module,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[grid_vertex_layout.clone()],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &grid_module,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: color_format,
|
||||||
|
blend: Some(wgpu::BlendState::REPLACE),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
// Ribbons sind flach in der Schnittebene, aus beiden Seiten sichtbar.
|
||||||
|
cull_mode: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: Some(wgpu::DepthStencilState {
|
||||||
|
format: DEPTH_FORMAT,
|
||||||
|
depth_write_enabled: Some(false),
|
||||||
|
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||||
|
stencil: wgpu::StencilState::default(),
|
||||||
|
bias: wgpu::DepthBiasState::default(),
|
||||||
|
}),
|
||||||
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: SAMPLE_COUNT,
|
||||||
|
mask: !0,
|
||||||
|
alpha_to_coverage_enabled: false,
|
||||||
|
},
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
// Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes
|
// Schnittflaechen-Kappen-Pipeline: eigener Shader (CAP_WGSL), eigenes
|
||||||
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
|
// schlankes Vertex-Layout [pos vec3, uv vec2], TriangleList. Dieselbe
|
||||||
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
|
// Bind-Group (Globals) und dasselbe Depth-/MSAA-/Farbziel wie oben.
|
||||||
@@ -478,10 +574,14 @@ impl Renderer {
|
|||||||
label: Some("cap.wgsl"),
|
label: Some("cap.wgsl"),
|
||||||
source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()),
|
source: wgpu::ShaderSource::Wgsl(CAP_WGSL.into()),
|
||||||
});
|
});
|
||||||
|
// Vertex-Layout: [pos vec3, uv vec2, hatch vec4(pattern, angle_rad, scale,
|
||||||
|
// line_weight_mm)], stride 9*4 (`CAP_FLOATS_PER_VERTEX`). Das dritte Attribut
|
||||||
|
// treibt die musterspezifische Schraffur im Cap-Fragment-Shader (CAP_WGSL);
|
||||||
|
// `line_weight_mm` setzt die Musterlinien-Breite je Schraffur.
|
||||||
let cap_vertex_layout = wgpu::VertexBufferLayout {
|
let cap_vertex_layout = wgpu::VertexBufferLayout {
|
||||||
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
|
array_stride: (CAP_FLOATS_PER_VERTEX * 4) as u64,
|
||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
|
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4],
|
||||||
};
|
};
|
||||||
let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
let cap_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
label: Some("cap.pipeline"),
|
label: Some("cap.pipeline"),
|
||||||
@@ -547,7 +647,7 @@ impl Renderer {
|
|||||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
ty: wgpu::BindingType::Texture {
|
ty: wgpu::BindingType::Texture {
|
||||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
view_dimension: wgpu::TextureViewDimension::D2,
|
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||||
multisampled: false,
|
multisampled: false,
|
||||||
},
|
},
|
||||||
count: None,
|
count: None,
|
||||||
@@ -579,6 +679,14 @@ impl Renderer {
|
|||||||
step_mode: wgpu::VertexStepMode::Vertex,
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
|
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
|
||||||
};
|
};
|
||||||
|
// ZWEITER Vertex-Buffer: die Material-Textur-Ebene je Vertex (@location(3),
|
||||||
|
// 1 f32). Separat gehalten, damit das Haupt-`[pos,normal,uv]`-Layout (und
|
||||||
|
// die Geometrie-Paritaet zum Shaded-Pfad) unveraendert bleibt.
|
||||||
|
let textured_layer_layout = wgpu::VertexBufferLayout {
|
||||||
|
array_stride: 4,
|
||||||
|
step_mode: wgpu::VertexStepMode::Vertex,
|
||||||
|
attributes: &wgpu::vertex_attr_array![3 => Float32],
|
||||||
|
};
|
||||||
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
|
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
|
||||||
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
|
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
|
||||||
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
|
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
|
||||||
@@ -590,7 +698,7 @@ impl Renderer {
|
|||||||
vertex: wgpu::VertexState {
|
vertex: wgpu::VertexState {
|
||||||
module: &textured_module,
|
module: &textured_module,
|
||||||
entry_point: Some("vs_main"),
|
entry_point: Some("vs_main"),
|
||||||
buffers: &[textured_vertex_layout],
|
buffers: &[textured_vertex_layout, textured_layer_layout],
|
||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
},
|
},
|
||||||
fragment: Some(wgpu::FragmentState {
|
fragment: Some(wgpu::FragmentState {
|
||||||
@@ -632,8 +740,10 @@ impl Renderer {
|
|||||||
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
|
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
|
||||||
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
|
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
|
||||||
// spike3d) unveraendert.
|
// spike3d) unveraendert.
|
||||||
|
// Textur-ARRAY (D2Array). Start: EINE Ebene (Fallback-Schachbrett).
|
||||||
|
// `set_material_textures` ersetzt das Array spaeter durch mehrere Ebenen.
|
||||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
label: Some("checker.texture"),
|
label: Some("materials.texture_array"),
|
||||||
size: wgpu::Extent3d {
|
size: wgpu::Extent3d {
|
||||||
width: TEXTURE_SIZE,
|
width: TEXTURE_SIZE,
|
||||||
height: TEXTURE_SIZE,
|
height: TEXTURE_SIZE,
|
||||||
@@ -646,10 +756,13 @@ impl Renderer {
|
|||||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
|
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
|
||||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
let texture_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
label: Some("checker.sampler"),
|
label: Some("materials.sampler"),
|
||||||
address_mode_u: wgpu::AddressMode::Repeat,
|
address_mode_u: wgpu::AddressMode::Repeat,
|
||||||
address_mode_v: wgpu::AddressMode::Repeat,
|
address_mode_v: wgpu::AddressMode::Repeat,
|
||||||
address_mode_w: wgpu::AddressMode::Repeat,
|
address_mode_w: wgpu::AddressMode::Repeat,
|
||||||
@@ -659,7 +772,7 @@ impl Renderer {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
label: Some("texture.bind"),
|
label: Some("materials.bind"),
|
||||||
layout: &texture_bind_group_layout,
|
layout: &texture_bind_group_layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
@@ -668,7 +781,7 @@ impl Renderer {
|
|||||||
},
|
},
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 1,
|
binding: 1,
|
||||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
resource: wgpu::BindingResource::Sampler(&texture_sampler),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -694,9 +807,13 @@ impl Renderer {
|
|||||||
grid_pipeline,
|
grid_pipeline,
|
||||||
highlight_pipeline,
|
highlight_pipeline,
|
||||||
cap_pipeline,
|
cap_pipeline,
|
||||||
|
cut_line_pipeline,
|
||||||
textured_pipeline,
|
textured_pipeline,
|
||||||
texture_bind_group,
|
texture_bind_group,
|
||||||
texture,
|
texture,
|
||||||
|
texture_bind_group_layout,
|
||||||
|
texture_sampler,
|
||||||
|
material_layer_count: 1,
|
||||||
texture_uploaded: false,
|
texture_uploaded: false,
|
||||||
bind_group,
|
bind_group,
|
||||||
uniform,
|
uniform,
|
||||||
@@ -706,6 +823,7 @@ impl Renderer {
|
|||||||
grid: None,
|
grid: None,
|
||||||
highlight: None,
|
highlight: None,
|
||||||
caps: None,
|
caps: None,
|
||||||
|
cut_lines: None,
|
||||||
grid_visible: false,
|
grid_visible: false,
|
||||||
style: RenderStyle::Shaded,
|
style: RenderStyle::Shaded,
|
||||||
depth: None,
|
depth: None,
|
||||||
@@ -724,7 +842,7 @@ impl Renderer {
|
|||||||
|
|
||||||
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
|
/// Erzeugt das Mesh aus geflachten Waenden und laedt die Puffer hoch.
|
||||||
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) {
|
pub fn upload_walls(&mut self, device: &wgpu::Device, walls: &[WallInput]) {
|
||||||
self.upload_mesh(device, build_walls_mesh(walls));
|
self.upload_mesh(device, build_walls_mesh(walls), build_walls_mesh_textured(walls));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
|
/// Erzeugt das Mesh aus Waenden, Deckenplatten UND rohen Kontext-Meshes
|
||||||
@@ -736,13 +854,17 @@ impl Renderer {
|
|||||||
slabs: &[SlabInput],
|
slabs: &[SlabInput],
|
||||||
meshes: &[MeshInput],
|
meshes: &[MeshInput],
|
||||||
) {
|
) {
|
||||||
self.upload_mesh(device, build_scene_mesh(walls, slabs, meshes));
|
self.upload_mesh(
|
||||||
|
device,
|
||||||
|
build_scene_mesh(walls, slabs, meshes),
|
||||||
|
build_scene_mesh_textured(walls, slabs, meshes),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
|
/// Laedt ein fertiges Mesh in die GPU-Puffer (oder loescht es bei leer) und
|
||||||
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
/// erzeugt ZUGLEICH die gecachten Modell-Kanten (Feature-Edges, edges.rs) fuer
|
||||||
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
/// die Stile wireframe/hidden — einmalig hier, nicht je Frame.
|
||||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh, tex_mesh: TexturedMesh) {
|
||||||
if mesh.indices.is_empty() {
|
if mesh.indices.is_empty() {
|
||||||
self.mesh = None;
|
self.mesh = None;
|
||||||
self.textured_mesh = None;
|
self.textured_mesh = None;
|
||||||
@@ -782,11 +904,10 @@ impl Renderer {
|
|||||||
index_count: mesh.indices.len() as u32,
|
index_count: mesh.indices.len() as u32,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
|
// Texturiertes Pendant (planare Welt-UV je Vertex + Material-Ebenen-Puffer,
|
||||||
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
|
// siehe `build_scene_mesh_textured`) in zwei Vertex-Puffer hochladen. Die
|
||||||
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
|
// Geometrie bleibt bitgleich zum Alt-Pfad; der Stil `Textured` hat ohne
|
||||||
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
|
// Re-Meshing seine Puffer bereit.
|
||||||
let tex_mesh = textured_from_mesh(&mesh);
|
|
||||||
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("mesh.textured.vbo"),
|
label: Some("mesh.textured.vbo"),
|
||||||
contents: bytemuck::cast_slice(&tex_mesh.verts),
|
contents: bytemuck::cast_slice(&tex_mesh.verts),
|
||||||
@@ -797,8 +918,22 @@ impl Renderer {
|
|||||||
contents: bytemuck::cast_slice(&tex_mesh.indices),
|
contents: bytemuck::cast_slice(&tex_mesh.indices),
|
||||||
usage: wgpu::BufferUsages::INDEX,
|
usage: wgpu::BufferUsages::INDEX,
|
||||||
});
|
});
|
||||||
|
// Material-Ebenen-Puffer (1 f32 je Vertex). Fehlt er (kein Material-Bau),
|
||||||
|
// mit `-1` je Vertex fuellen -> alle Vertices auf das Fallback-Schachbrett.
|
||||||
|
let vertex_count = tex_mesh.verts.len() / TEXTURED_FLOATS_PER_VERTEX;
|
||||||
|
let layer_data: Vec<f32> = if tex_mesh.layers.len() == vertex_count {
|
||||||
|
tex_mesh.layers
|
||||||
|
} else {
|
||||||
|
vec![-1.0; vertex_count]
|
||||||
|
};
|
||||||
|
let tlvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("mesh.textured.layer.vbo"),
|
||||||
|
contents: bytemuck::cast_slice(&layer_data),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
|
});
|
||||||
self.textured_mesh = Some(TexturedMeshBuffers {
|
self.textured_mesh = Some(TexturedMeshBuffers {
|
||||||
vbo: tvbo,
|
vbo: tvbo,
|
||||||
|
layer_vbo: tlvbo,
|
||||||
ibo: tibo,
|
ibo: tibo,
|
||||||
index_count: tex_mesh.indices.len() as u32,
|
index_count: tex_mesh.indices.len() as u32,
|
||||||
});
|
});
|
||||||
@@ -918,6 +1053,27 @@ impl Renderer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt/loescht die Schnitt-Umrisslinien (Cut-Cap-Kanten, P2). `verts` =
|
||||||
|
/// interleaved `[px,py,pz, r,g,b, ...]` (world-Meter + Farbe, TriangleList von
|
||||||
|
/// Ribbon-Quads, `CUT_LINE_FLOATS_PER_VERTEX`), wie `section_fill::
|
||||||
|
/// build_cut_cap_lines` liefert. Leeres Slice loescht die Linien. Wirkt erst
|
||||||
|
/// beim naechsten `render`.
|
||||||
|
pub fn set_cut_lines(&mut self, device: &wgpu::Device, verts: &[f32]) {
|
||||||
|
if verts.is_empty() {
|
||||||
|
self.cut_lines = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let vbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("cut_lines.vbo"),
|
||||||
|
contents: bytemuck::cast_slice(verts),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
|
});
|
||||||
|
self.cut_lines = Some(LineBuffers {
|
||||||
|
vbo,
|
||||||
|
vertex_count: (verts.len() / CUT_LINE_FLOATS_PER_VERTEX) as u32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten)
|
/// Komfort: Schnittebene setzen UND die Cap-Geometrie aus den (gecachten)
|
||||||
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
|
/// Waenden/Decken neu bauen — bei `active == false` werden Uniform-Flag und
|
||||||
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
|
/// Caps geloescht. Buendelt `set_section_plane` + `build_cut_caps` +
|
||||||
@@ -936,11 +1092,110 @@ impl Renderer {
|
|||||||
let plane = SectionPlane::new(point, normal);
|
let plane = SectionPlane::new(point, normal);
|
||||||
let caps = build_cut_caps(&plane, walls, slabs);
|
let caps = build_cut_caps(&plane, walls, slabs);
|
||||||
self.set_cut_caps(device, &caps);
|
self.set_cut_caps(device, &caps);
|
||||||
|
// P2: die Schnitt-Umrisslinien (Fugen-/Materiallinien) je ueberlebendem
|
||||||
|
// Cut-Cap-Rechteck aus DERSELBEN, dominanz-verschnittenen Rechteck-Menge.
|
||||||
|
let lines = build_cut_cap_lines(&plane, walls, slabs);
|
||||||
|
self.set_cut_lines(device, &lines);
|
||||||
} else {
|
} else {
|
||||||
self.set_cut_caps(device, &[]);
|
self.set_cut_caps(device, &[]);
|
||||||
|
self.set_cut_lines(device, &[]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-ARRAY hoch.
|
||||||
|
/// `rgba` = dicht gepackte RGBA8-Bytes von `layer_count` Ebenen, jede exakt
|
||||||
|
/// `TEXTURE_SIZE × TEXTURE_SIZE` (der Aufrufer skaliert/dekodiert die Bilder
|
||||||
|
/// JS-seitig, siehe `web::set_material_textures`/`Wasm3DViewport`). Baut ein
|
||||||
|
/// neues Array `[Schachbrett (Ebene 0), Material0 (Ebene 1), …]`, laedt es hoch
|
||||||
|
/// und ersetzt die group-1-Bind-Group. Ein Wand-Band verweist per
|
||||||
|
/// `WallInput::material_index` (1-basiert) auf seine Ebene; Baender ohne
|
||||||
|
/// Material (`-1`) bleiben beim Schachbrett (Ebene 0). `layer_count == 0` (oder
|
||||||
|
/// leere/zu kleine Daten) setzt auf das reine Schachbrett-Array zurueck.
|
||||||
|
pub fn set_material_textures(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
rgba: &[u8],
|
||||||
|
layer_count: u32,
|
||||||
|
) {
|
||||||
|
let bytes_per_layer = (TEXTURE_SIZE * TEXTURE_SIZE * 4) as usize;
|
||||||
|
// Ungueltige/leere Eingabe -> reines Schachbrett-Array (1 Ebene).
|
||||||
|
let valid = layer_count > 0 && rgba.len() >= layer_count as usize * bytes_per_layer;
|
||||||
|
let material_layers = if valid { layer_count } else { 0 };
|
||||||
|
let total_layers = material_layers + 1; // Ebene 0 = Schachbrett
|
||||||
|
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("materials.texture_array"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: TEXTURE_SIZE,
|
||||||
|
height: TEXTURE_SIZE,
|
||||||
|
depth_or_array_layers: total_layers,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ebene 0: Schachbrett-Fallback.
|
||||||
|
let checker = build_checker_texture();
|
||||||
|
let write_layer = |layer: u32, data: &[u8]| {
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: layer,
|
||||||
|
},
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(4 * TEXTURE_SIZE),
|
||||||
|
rows_per_image: Some(TEXTURE_SIZE),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: TEXTURE_SIZE,
|
||||||
|
height: TEXTURE_SIZE,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
write_layer(0, &checker);
|
||||||
|
for i in 0..material_layers {
|
||||||
|
let start = i as usize * bytes_per_layer;
|
||||||
|
write_layer(i + 1, &rgba[start..start + bytes_per_layer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
self.texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("materials.bind"),
|
||||||
|
layout: &self.texture_bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.texture_sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
self.texture = texture;
|
||||||
|
self.material_layer_count = total_layers;
|
||||||
|
// Ebene 0 (Schachbrett) ist bereits geladen -> den Lazy-Upload ueberspringen.
|
||||||
|
self.texture_uploaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
|
/// Stellt sicher, dass ein Tiefenpuffer passend zur Ziel-Groesse existiert.
|
||||||
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
fn ensure_depth(&mut self, device: &wgpu::Device, w: u32, h: u32) {
|
||||||
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
|
let ok = matches!(&self.depth, Some(d) if d.width == w && d.height == h);
|
||||||
@@ -1019,6 +1274,8 @@ impl Renderer {
|
|||||||
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
|
// `mode.x` fuer den Mesh-Shader aus dem aktuellen Stil ableiten (Material/
|
||||||
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
|
// Clay/Hidden-Flaechen). Wireframe zeichnet keine Flaechen -> Wert egal.
|
||||||
self.globals.mode[0] = self.style.mesh_mode();
|
self.globals.mode[0] = self.style.mesh_mode();
|
||||||
|
// `mode.z` = Anzahl gueltiger Textur-Array-Ebenen (Klemmung im Textur-Shader).
|
||||||
|
self.globals.mode[2] = self.material_layer_count as f32;
|
||||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
||||||
|
|
||||||
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
|
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
|
||||||
@@ -1100,11 +1357,12 @@ impl Renderer {
|
|||||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
pass.set_bind_group(1, &self.texture_bind_group, &[]);
|
pass.set_bind_group(1, &self.texture_bind_group, &[]);
|
||||||
pass.set_vertex_buffer(0, t.vbo.slice(..));
|
pass.set_vertex_buffer(0, t.vbo.slice(..));
|
||||||
|
pass.set_vertex_buffer(1, t.layer_vbo.slice(..));
|
||||||
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||||
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||||
}
|
}
|
||||||
} else if let Some(m) = &self.mesh {
|
} else if let Some(m) = &self.mesh {
|
||||||
let face_pipeline = if self.style == RenderStyle::Hidden {
|
let face_pipeline = if self.style.needs_biased_face_pipeline() {
|
||||||
&self.hidden_face_pipeline
|
&self.hidden_face_pipeline
|
||||||
} else {
|
} else {
|
||||||
&self.pipeline
|
&self.pipeline
|
||||||
@@ -1128,6 +1386,18 @@ impl Renderer {
|
|||||||
pass.draw(0..c.vertex_count, 0..1);
|
pass.draw(0..c.vertex_count, 0..1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1c) Schnitt-Umrisslinien (Cut-Cap-Kanten, P2) NACH den Caps: die
|
||||||
|
// Material-/Fugenlinien je ueberlebendem Cut-Rechteck (Ribbon-
|
||||||
|
// TriangleList, eigener Stil je Schicht). Ohne Tiefentest (Always)
|
||||||
|
// liegen sie sichtbar auf den Caps; der Kamera-Lift in
|
||||||
|
// `build_cut_cap_lines` haelt sie vor der Ebenen-Kappung.
|
||||||
|
if let Some(cl) = &self.cut_lines {
|
||||||
|
pass.set_pipeline(&self.cut_line_pipeline);
|
||||||
|
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||||
|
pass.set_vertex_buffer(0, cl.vbo.slice(..));
|
||||||
|
pass.draw(0..cl.vertex_count, 0..1);
|
||||||
|
}
|
||||||
|
|
||||||
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
|
// 2) Modell-Kanten (wireframe/hidden) — LineList-Pipeline (wie Grid).
|
||||||
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
|
// Im Hidden-Stil obenauf, tiefengetestet gegen die (gebiasten)
|
||||||
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
|
// Flaechen -> verdeckte Kanten fallen weg. Im Wireframe ohne
|
||||||
@@ -1167,3 +1437,48 @@ impl Renderer {
|
|||||||
queue.submit(std::iter::once(encoder.finish()));
|
queue.submit(std::iter::once(encoder.finish()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod render_style_tests {
|
||||||
|
use super::RenderStyle;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shaded_edges_parses_from_api_string() {
|
||||||
|
assert_eq!(
|
||||||
|
RenderStyle::from_str_or_shaded("shaded-edges"),
|
||||||
|
RenderStyle::ShadedEdges
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_style_falls_back_to_shaded() {
|
||||||
|
assert_eq!(RenderStyle::from_str_or_shaded("bogus"), RenderStyle::Shaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shaded_edges_uses_material_colors_like_shaded() {
|
||||||
|
// mode.x = 0.0 -> Material/Bauteilfarben, NICHT weiss (1.0) oder
|
||||||
|
// Hidden-Line-Flaechen (2.0) — der BIM-Look behaelt echte Farben.
|
||||||
|
assert_eq!(RenderStyle::ShadedEdges.mesh_mode(), 0.0);
|
||||||
|
assert_eq!(RenderStyle::Shaded.mesh_mode(), RenderStyle::ShadedEdges.mesh_mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shaded_edges_draws_both_faces_and_edges() {
|
||||||
|
assert!(RenderStyle::ShadedEdges.draws_faces());
|
||||||
|
assert!(RenderStyle::ShadedEdges.draws_edges());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shaded_edges_needs_biased_face_pipeline_like_hidden() {
|
||||||
|
assert!(RenderStyle::ShadedEdges.needs_biased_face_pipeline());
|
||||||
|
assert!(RenderStyle::Hidden.needs_biased_face_pipeline());
|
||||||
|
assert!(!RenderStyle::Shaded.needs_biased_face_pipeline());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_shaded_style_unaffected_by_new_variant() {
|
||||||
|
assert!(!RenderStyle::Shaded.draws_edges());
|
||||||
|
assert!(RenderStyle::Shaded.draws_faces());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod math;
|
|||||||
pub mod mesh;
|
pub mod mesh;
|
||||||
pub(crate) mod openings;
|
pub(crate) mod openings;
|
||||||
pub mod section;
|
pub mod section;
|
||||||
|
pub mod section_boolean;
|
||||||
pub mod section_fill;
|
pub mod section_fill;
|
||||||
pub mod shaders;
|
pub mod shaders;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
@@ -72,6 +73,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +175,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
|
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX);
|
||||||
@@ -250,6 +257,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let mut mesh = Mesh::default();
|
let mut mesh = Mesh::default();
|
||||||
extrude_wall(&mut mesh, &w);
|
extrude_wall(&mut mesh, &w);
|
||||||
@@ -272,6 +282,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let mesh = build_walls_mesh(&[w]);
|
let mesh = build_walls_mesh(&[w]);
|
||||||
assert_eq!(mesh.vertex_count(), 0);
|
assert_eq!(mesh.vertex_count(), 0);
|
||||||
@@ -486,6 +499,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let b = WallInput {
|
let b = WallInput {
|
||||||
start: [0.0, 0.0],
|
start: [0.0, 0.0],
|
||||||
@@ -497,6 +513,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let mesh = build_walls_mesh(&[a, b]);
|
let mesh = build_walls_mesh(&[a, b]);
|
||||||
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
|
assert_eq!(mesh.vertex_count(), 2 * VERTS_PER_BOX, "weiterhin zwei Quader");
|
||||||
@@ -557,6 +576,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let walls = vec![
|
let walls = vec![
|
||||||
mk([0.0, 0.0], [3.0, 0.0]),
|
mk([0.0, 0.0], [3.0, 0.0]),
|
||||||
@@ -586,6 +608,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let walls = vec![
|
let walls = vec![
|
||||||
mk([0.0, 0.0], [3.0, 0.0], 0.0),
|
mk([0.0, 0.0], [3.0, 0.0], 0.0),
|
||||||
@@ -675,6 +700,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: layers(),
|
layers: layers(),
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let mut b = a.clone();
|
let mut b = a.clone();
|
||||||
b.end = [0.0, 3.0];
|
b.end = [0.0, 3.0];
|
||||||
@@ -705,6 +733,8 @@ mod tests {
|
|||||||
z_bottom,
|
z_bottom,
|
||||||
z_top,
|
z_top,
|
||||||
color: [0.86, 0.86, 0.88],
|
color: [0.86, 0.86, 0.88],
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,6 +791,8 @@ mod tests {
|
|||||||
z_bottom: 0.0,
|
z_bottom: 0.0,
|
||||||
z_top: 0.3,
|
z_top: 0.3,
|
||||||
color: [0.8, 0.8, 0.8],
|
color: [0.8, 0.8, 0.8],
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
let mut mesh = Mesh::default();
|
let mut mesh = Mesh::default();
|
||||||
extrude_slab(&mut mesh, &slab);
|
extrude_slab(&mut mesh, &slab);
|
||||||
@@ -789,7 +821,7 @@ mod tests {
|
|||||||
fn entarteter_slab_erzeugt_nichts() {
|
fn entarteter_slab_erzeugt_nichts() {
|
||||||
let mut mesh = Mesh::default();
|
let mut mesh = Mesh::default();
|
||||||
// <3 Ecken.
|
// <3 Ecken.
|
||||||
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8] });
|
extrude_slab(&mut mesh, &SlabInput { outline: vec![[0.0, 0.0], [1.0, 0.0]], z_bottom: 0.0, z_top: 0.2, color: [0.8, 0.8, 0.8], hatch: None, cut: None });
|
||||||
assert_eq!(mesh.vertex_count(), 0);
|
assert_eq!(mesh.vertex_count(), 0);
|
||||||
// Nullhoehe.
|
// Nullhoehe.
|
||||||
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
|
extrude_slab(&mut mesh, &square_slab(4.0, 2.6, 2.6));
|
||||||
@@ -962,6 +994,51 @@ mod tests {
|
|||||||
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
|
assert!(top.eye[1] > t[1], "Top-Kamera ueber dem Ziel");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presets_kardinal_back_left_sind_orthografisch_und_gegenrichtung() {
|
||||||
|
use super::math::preset_camera;
|
||||||
|
use super::types::{CameraPreset, Projection};
|
||||||
|
let t = [0.0, 0.0, 0.0];
|
||||||
|
let front = preset_camera(CameraPreset::Front, t, 10.0);
|
||||||
|
let back = preset_camera(CameraPreset::Back, t, 10.0);
|
||||||
|
let side = preset_camera(CameraPreset::Side, t, 10.0);
|
||||||
|
let left = preset_camera(CameraPreset::Left, t, 10.0);
|
||||||
|
assert_eq!(back.projection, Projection::Orthographic);
|
||||||
|
assert_eq!(left.projection, Projection::Orthographic);
|
||||||
|
// Back/Left sind exakt die Gegenrichtung von Front/Side (gespiegelte Achse).
|
||||||
|
assert!((back.eye[2] - (-front.eye[2])).abs() < 1e-4, "Back == -Front (Z)");
|
||||||
|
assert!((left.eye[0] - (-side.eye[0])).abs() < 1e-4, "Left == -Side (X)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presets_iso_oktanten_sind_orthografisch_gleich_gross() {
|
||||||
|
use super::math::preset_camera;
|
||||||
|
use super::types::{CameraPreset, Projection};
|
||||||
|
let t = [0.0, 0.0, 0.0];
|
||||||
|
let dist = 10.0;
|
||||||
|
let iso = preset_camera(CameraPreset::Iso, t, dist);
|
||||||
|
let fl = preset_camera(CameraPreset::IsoFrontLeft, t, dist);
|
||||||
|
let br = preset_camera(CameraPreset::IsoBackRight, t, dist);
|
||||||
|
let bl = preset_camera(CameraPreset::IsoBackLeft, t, dist);
|
||||||
|
for cam in [&iso, &fl, &br, &bl] {
|
||||||
|
assert_eq!(cam.projection, Projection::Orthographic);
|
||||||
|
// Alle vier oberen Iso-Oktanten haben denselben eye-target-Abstand
|
||||||
|
// (Modell erscheint in jedem Oktanten gleich gross).
|
||||||
|
let d = ((cam.eye[0] - t[0]).powi(2)
|
||||||
|
+ (cam.eye[1] - t[1]).powi(2)
|
||||||
|
+ (cam.eye[2] - t[2]).powi(2))
|
||||||
|
.sqrt();
|
||||||
|
assert!((d - dist).abs() < 1e-3, "Iso-Oktant-Abstand == dist");
|
||||||
|
// Alle vier blicken von OBEN (Y > 0) — untere Oktanten bleiben ungenutzt.
|
||||||
|
assert!(cam.eye[1] > t[1], "Iso-Oktant ueber dem Ziel");
|
||||||
|
}
|
||||||
|
// Vier unterschiedliche horizontale Vorzeichen-Kombinationen (X,Z).
|
||||||
|
assert!(iso.eye[0] > t[0] && iso.eye[2] > t[2]); // vorne-rechts
|
||||||
|
assert!(fl.eye[0] < t[0] && fl.eye[2] > t[2]); // vorne-links
|
||||||
|
assert!(br.eye[0] > t[0] && br.eye[2] < t[2]); // hinten-rechts
|
||||||
|
assert!(bl.eye[0] < t[0] && bl.eye[2] < t[2]); // hinten-links
|
||||||
|
}
|
||||||
|
|
||||||
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
|
/// Validiert die WGSL-Quelle headless ueber naga (Parser + Validator) — faengt
|
||||||
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
|
/// Syntax-/Typfehler ohne GPU/Display ab. Nur mit Feature "render", weil naga
|
||||||
/// sonst nicht mitgebaut wird (Muster: render2d).
|
/// sonst nicht mitgebaut wird (Muster: render2d).
|
||||||
|
|||||||
@@ -185,15 +185,26 @@ pub fn orbit_eye(target: [f32; 3], yaw: f32, pitch: f32, dist: f32) -> [f32; 3]
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Baut eine `Camera` fuer eines der fuenf Presets der three.js-Sicht. `target`
|
/// Baut eine `Camera` fuer eines der Kardinal-/Iso-Presets (ROADMAP §11).
|
||||||
/// ist das Blickziel (Modell-Mitte), `dist` der Abstand. Front/Top/Side/Iso
|
/// `target` ist das Blickziel (Modell-Mitte), `dist` der Abstand. Alle Presets
|
||||||
/// sind orthografisch (Front/Top/Side achsparallel, Iso diagonal); nur Persp
|
/// ausser `Persp` sind orthografisch (Front/Back/Top/Side/Left achsparallel,
|
||||||
/// ist perspektivisch.
|
/// die Iso-Varianten diagonal); nur Persp ist perspektivisch.
|
||||||
|
///
|
||||||
|
/// Achs-Konvention (wie die three.js-Sicht, s. `applyView3d` in Viewport3D.tsx):
|
||||||
|
/// +Z = vorne (Front blickt von +Z entlang -Z), +X = rechts (Side blickt von
|
||||||
|
/// +X entlang -X). Back/Left sind exakt die Gegenrichtungen (-Z/-X); es gibt
|
||||||
|
/// noch KEINEN Nordwinkel — die Kardinalrichtungen sind rein modellrelativ
|
||||||
|
/// (Vorne/Rechts/Hinten/Links im UI, keine Himmelsrichtungen).
|
||||||
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
|
pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camera {
|
||||||
let mut cam = Camera {
|
let mut cam = Camera {
|
||||||
target,
|
target,
|
||||||
..Camera::default()
|
..Camera::default()
|
||||||
};
|
};
|
||||||
|
// Iso-Distanz-Helfer: Augenabstand-Komponente je Achse, sodass der
|
||||||
|
// tatsaechliche eye-target-Abstand exakt `dist` bleibt (d*sqrt(3) == dist,
|
||||||
|
// wie bei den bisherigen Front/Top/Side-Presets), damit alle Iso-Varianten
|
||||||
|
// das Modell gleich gross zeigen.
|
||||||
|
let iso_d = dist / 3.0_f32.sqrt();
|
||||||
match preset {
|
match preset {
|
||||||
// Blick entlang -Z (von vorne), orthografisch.
|
// Blick entlang -Z (von vorne), orthografisch.
|
||||||
CameraPreset::Front => {
|
CameraPreset::Front => {
|
||||||
@@ -202,6 +213,13 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
|
|||||||
cam.up = [0.0, 1.0, 0.0];
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
cam.ortho_half_height = dist * 0.5;
|
cam.ortho_half_height = dist * 0.5;
|
||||||
}
|
}
|
||||||
|
// Blick entlang +Z (von hinten) — Gegenrichtung von Front.
|
||||||
|
CameraPreset::Back => {
|
||||||
|
cam.projection = Projection::Orthographic;
|
||||||
|
cam.eye = [target[0], target[1], target[2] - dist];
|
||||||
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
|
cam.ortho_half_height = dist * 0.5;
|
||||||
|
}
|
||||||
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
|
// Blick von oben entlang -Y (Grundriss), orthografisch. Up = -Z, damit
|
||||||
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
|
// Modell-Y (world +Z) im Bild nach unten zeigt (wie die 2D-Plan-Sicht).
|
||||||
CameraPreset::Top => {
|
CameraPreset::Top => {
|
||||||
@@ -210,24 +228,48 @@ pub fn preset_camera(preset: CameraPreset, target: [f32; 3], dist: f32) -> Camer
|
|||||||
cam.up = [0.0, 0.0, -1.0];
|
cam.up = [0.0, 0.0, -1.0];
|
||||||
cam.ortho_half_height = dist * 0.5;
|
cam.ortho_half_height = dist * 0.5;
|
||||||
}
|
}
|
||||||
// Blick entlang -X (von der Seite), orthografisch.
|
// Blick entlang -X (von der rechten Seite), orthografisch.
|
||||||
CameraPreset::Side => {
|
CameraPreset::Side => {
|
||||||
cam.projection = Projection::Orthographic;
|
cam.projection = Projection::Orthographic;
|
||||||
cam.eye = [target[0] + dist, target[1], target[2]];
|
cam.eye = [target[0] + dist, target[1], target[2]];
|
||||||
cam.up = [0.0, 1.0, 0.0];
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
cam.ortho_half_height = dist * 0.5;
|
cam.ortho_half_height = dist * 0.5;
|
||||||
}
|
}
|
||||||
// Isometrischer Ueberblick (orthografisch, Blick von schraeg oben).
|
// Blick entlang +X (von der linken Seite) — Gegenrichtung von Side.
|
||||||
|
CameraPreset::Left => {
|
||||||
|
cam.projection = Projection::Orthographic;
|
||||||
|
cam.eye = [target[0] - dist, target[1], target[2]];
|
||||||
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
|
cam.ortho_half_height = dist * 0.5;
|
||||||
|
}
|
||||||
|
// Isometrischer Ueberblick von vorne-oben-rechts (Default-Iso).
|
||||||
// Echte Isometrie ist per Definition parallelprojiziert (keine
|
// Echte Isometrie ist per Definition parallelprojiziert (keine
|
||||||
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
|
// perspektivische Verzerrung, Kantenlaengen bleiben massstabsgetreu).
|
||||||
CameraPreset::Iso => {
|
CameraPreset::Iso => {
|
||||||
cam.projection = Projection::Orthographic;
|
cam.projection = Projection::Orthographic;
|
||||||
let d = dist / 3.0_f32.sqrt();
|
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] + iso_d];
|
||||||
cam.eye = [target[0] + d, target[1] + d, target[2] + d];
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
|
cam.ortho_half_height = dist * 0.5;
|
||||||
|
}
|
||||||
|
// Isometrischer Ueberblick von vorne-oben-links.
|
||||||
|
CameraPreset::IsoFrontLeft => {
|
||||||
|
cam.projection = Projection::Orthographic;
|
||||||
|
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] + iso_d];
|
||||||
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
|
cam.ortho_half_height = dist * 0.5;
|
||||||
|
}
|
||||||
|
// Isometrischer Ueberblick von hinten-oben-rechts.
|
||||||
|
CameraPreset::IsoBackRight => {
|
||||||
|
cam.projection = Projection::Orthographic;
|
||||||
|
cam.eye = [target[0] + iso_d, target[1] + iso_d, target[2] - iso_d];
|
||||||
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
|
cam.ortho_half_height = dist * 0.5;
|
||||||
|
}
|
||||||
|
// Isometrischer Ueberblick von hinten-oben-links.
|
||||||
|
CameraPreset::IsoBackLeft => {
|
||||||
|
cam.projection = Projection::Orthographic;
|
||||||
|
cam.eye = [target[0] - iso_d, target[1] + iso_d, target[2] - iso_d];
|
||||||
cam.up = [0.0, 1.0, 0.0];
|
cam.up = [0.0, 1.0, 0.0];
|
||||||
// eye-target-Abstand ist hier (wie bei Front/Top/Side) exakt
|
|
||||||
// `dist` (d*sqrt(3) == dist), darum dieselbe Skalierung wie dort,
|
|
||||||
// damit das Modell im Bild aehnlich gross erscheint.
|
|
||||||
cam.ortho_half_height = dist * 0.5;
|
cam.ortho_half_height = dist * 0.5;
|
||||||
}
|
}
|
||||||
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
|
// Freie perspektivische Standard-Ansicht (leicht von vorn-oben-rechts).
|
||||||
|
|||||||
@@ -940,7 +940,10 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
|
|||||||
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
|
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
|
||||||
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
|
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
|
||||||
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
|
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
|
||||||
textured_from_mesh(&build_walls_mesh(walls))
|
// Ueber den Szenen-Builder (ohne Decken/Kontext) — so traegt der Puffer auch
|
||||||
|
// die Material-Ebenen je Wand (`layers`), waehrend Geometrie/UV bitgleich zu
|
||||||
|
// `textured_from_mesh(&build_walls_mesh(walls))` bleiben.
|
||||||
|
build_scene_mesh_textured(walls, &[], &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
|
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
|
||||||
@@ -980,9 +983,62 @@ pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
|
|||||||
TexturedMesh {
|
TexturedMesh {
|
||||||
verts,
|
verts,
|
||||||
indices: mesh.indices.clone(),
|
indices: mesh.indices.clone(),
|
||||||
|
// Ohne Material-Zuordnung: leer -> die GPU-Schicht bindet einen konstanten
|
||||||
|
// `-1`-Ebenenpuffer (Fallback-Schachbrett fuer alle Vertices).
|
||||||
|
layers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TEXTURIERTES Szenen-Mesh MIT Material-Ebenen: baut dieselbe Szene wie
|
||||||
|
/// `build_scene_mesh` (Waende -> Decken -> Kontext-Meshes, gleiche Reihenfolge/
|
||||||
|
/// Geometrie) und fuellt zusaetzlich den parallelen `layers`-Puffer (ein Eintrag
|
||||||
|
/// je Vertex): fuer jede Wand ihre `material_index`-Textur-Ebene (`None` -> `-1`),
|
||||||
|
/// fuer Decken und Kontext-Meshes `-1` (Fallback-Schachbrett). Die Vertex-Ranges
|
||||||
|
/// werden waehrend des Baus je Bauteil aus `Mesh::vertex_count()` abgegriffen —
|
||||||
|
/// dieselben `extrude_*`/`append_*`-Funktionen wie der Shaded-Pfad bleiben die
|
||||||
|
/// EINE Geometrie-Quelle (Paritaet garantiert), es kommt nur die Ebenen-Zuordnung
|
||||||
|
/// dazu. Genutzt vom `Textured`-Stil (`gpu::upload_*`).
|
||||||
|
pub fn build_scene_mesh_textured(
|
||||||
|
walls: &[WallInput],
|
||||||
|
slabs: &[SlabInput],
|
||||||
|
meshes: &[MeshInput],
|
||||||
|
) -> TexturedMesh {
|
||||||
|
let mut mesh = Mesh::default();
|
||||||
|
let mut layers: Vec<f32> = Vec::new();
|
||||||
|
let fill = |mesh: &Mesh, layers: &mut Vec<f32>, before: usize, layer: f32| {
|
||||||
|
let added = mesh.vertex_count().saturating_sub(before);
|
||||||
|
layers.extend(std::iter::repeat(layer).take(added));
|
||||||
|
};
|
||||||
|
|
||||||
|
let miters = compute_wall_miters(walls);
|
||||||
|
for (w, m) in walls.iter().zip(miters.iter()) {
|
||||||
|
let before = mesh.vertex_count();
|
||||||
|
extrude_wall_with_miters(&mut mesh, w, *m);
|
||||||
|
// Material-Ebene der Wand (1-basiert; 0 ist das Fallback-Schachbrett).
|
||||||
|
let layer = w.material_index.map(|i| i as f32).unwrap_or(-1.0);
|
||||||
|
fill(&mesh, &mut layers, before, layer);
|
||||||
|
}
|
||||||
|
for s in slabs {
|
||||||
|
let before = mesh.vertex_count();
|
||||||
|
extrude_slab(&mut mesh, s);
|
||||||
|
fill(&mesh, &mut layers, before, -1.0);
|
||||||
|
}
|
||||||
|
for m in meshes {
|
||||||
|
let before = mesh.vertex_count();
|
||||||
|
append_context_mesh(&mut mesh, m);
|
||||||
|
fill(&mesh, &mut layers, before, -1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tex = textured_from_mesh(&mesh);
|
||||||
|
debug_assert_eq!(
|
||||||
|
layers.len(),
|
||||||
|
tex.vertex_count(),
|
||||||
|
"ein Ebenen-Eintrag je Vertex"
|
||||||
|
);
|
||||||
|
tex.layers = layers;
|
||||||
|
tex
|
||||||
|
}
|
||||||
|
|
||||||
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
|
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
|
||||||
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
||||||
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
||||||
|
|||||||
@@ -24,12 +24,9 @@
|
|||||||
//! - **Ursprung:** `SectionPlane::point`, projiziert.
|
//! - **Ursprung:** `SectionPlane::point`, projiziert.
|
||||||
//! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene,
|
//! - **u (horizontal in der Zeichnung):** Strecke entlang der Schnittebene,
|
||||||
//! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als
|
//! senkrecht zur Blickrichtung, in der Horizontalen. Berechnet als
|
||||||
//! `u_axis = normalize(cross(normal, world_up))` — dieselbe rechtshaendige
|
//! `u_axis = normalize(cross(world_up, normal))` — das Blickrichtungs-RECHTS
|
||||||
//! Kamera-Konvention wie `math::look_at` (dort `s = cross(f, up)`), damit die
|
//! des Betrachters (wer nach Norden blickt, hat Osten rechts). Die fruehere
|
||||||
//! Vorzeichen-Konvention modulübergreifend konsistent bleibt. Bei den
|
//! look_at-Konvention `cross(normal, up)` spiegelte die Zeichnung.
|
||||||
//! Standard-Konstruktoren entspricht `u` direkt der jeweils ANDEREN
|
|
||||||
//! Grundriss-Achse (Schnitt entlang X -> u = Modell-Y, Schnitt entlang Y ->
|
|
||||||
//! u = -Modell-X).
|
|
||||||
//! - **v (vertikal in der Zeichnung, "Hoehe"):** `v = world.y`, ABSOLUT (nicht
|
//! - **v (vertikal in der Zeichnung, "Hoehe"):** `v = world.y`, ABSOLUT (nicht
|
||||||
//! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig
|
//! relativ zur Schnittebene). `world.y` ist in dieser Codebasis durchgaengig
|
||||||
//! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y =
|
//! die Hoehen-Achse (Y-up, siehe `types.rs`/`mesh.rs`/`math.rs`: "world.y =
|
||||||
@@ -133,7 +130,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::math;
|
use crate::math;
|
||||||
use crate::types::{Opening, Point2, Rgb, SlabInput, WallInput};
|
use crate::types::{CutBandMeta, Hatch, Opening, Point2, Rgb, SlabInput, WallInput};
|
||||||
|
|
||||||
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
|
/// Toleranz fuer "naeher an der Ebene" / Grenzwert-Vergleiche (Meter, Tiefe/Hoehe).
|
||||||
const EPS: f32 = 1e-4;
|
const EPS: f32 = 1e-4;
|
||||||
@@ -189,8 +186,13 @@ impl SectionPlane {
|
|||||||
/// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation
|
/// `pub(crate)`, damit `section_fill` die (u,v)->Welt-Ruecktransformation
|
||||||
/// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet.
|
/// mit EXAKT derselben Achsdefinition (inkl. Entartungs-Fallback) rechnet.
|
||||||
pub(crate) fn u_axis(&self) -> [f32; 3] {
|
pub(crate) fn u_axis(&self) -> [f32; 3] {
|
||||||
|
// Blickrichtungs-RECHTS des Betrachters: cross(up, normal). Die alte
|
||||||
|
// Formel cross(normal, up) (die `look_at`-right-Konvention fuer Blick
|
||||||
|
// entlang -z) SPIEGELTE den Schnitt — wer nach Norden blickt, hat
|
||||||
|
// Osten rechts, die Ausgabe zeigte ihn links. Gleiche Entspiegelung
|
||||||
|
// wie `elevationFrame` (toElevation.ts) auf der TS-Seite.
|
||||||
let up = [0.0, 1.0, 0.0];
|
let up = [0.0, 1.0, 0.0];
|
||||||
let u = math::cross(self.normal, up);
|
let u = math::cross(up, self.normal);
|
||||||
if math::length(u) > 1e-6 {
|
if math::length(u) > 1e-6 {
|
||||||
math::normalize(u)
|
math::normalize(u)
|
||||||
} else {
|
} else {
|
||||||
@@ -268,8 +270,19 @@ pub struct ComponentRef {
|
|||||||
pub struct CutPolygon {
|
pub struct CutPolygon {
|
||||||
pub component: ComponentRef,
|
pub component: ComponentRef,
|
||||||
pub color: Rgb,
|
pub color: Rgb,
|
||||||
|
/// Schnitt-Schraffur des Ursprungs-Bauteils (siehe `WallInput::hatch`). `None`
|
||||||
|
/// -> der Cap-Shader zeichnet das Fallback-Diagonalmuster (Alt-Verhalten).
|
||||||
|
#[serde(default)]
|
||||||
|
pub hatch: Option<Hatch>,
|
||||||
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
|
/// Ring-Ecken (u, v); erste != letzte (Ring implizit geschlossen).
|
||||||
pub pts: Vec<[f32; 2]>,
|
pub pts: Vec<[f32; 2]>,
|
||||||
|
/// Schnitt-Verschneidungs-/Linienstil-Metadaten des Ursprungs-Bandes (siehe
|
||||||
|
/// `WallInput::cut`). Treibt die Boolean-Dominanz in `section_boolean::
|
||||||
|
/// subtract_dominant_bands` (die staerkere Schicht schneidet die schwaechere)
|
||||||
|
/// und die Schnitt-Umrisslinien (`section_fill::build_cut_cap_lines`). `None`
|
||||||
|
/// -> das Band nimmt an keiner Subtraktion teil (Alt-Verhalten).
|
||||||
|
#[serde(default)]
|
||||||
|
pub cut: Option<CutBandMeta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
|
/// Ein Liniensegment in Schnittkoordinaten (u, v), Meter.
|
||||||
@@ -314,9 +327,16 @@ struct Prism {
|
|||||||
z0: f32,
|
z0: f32,
|
||||||
z1: f32,
|
z1: f32,
|
||||||
color: Rgb,
|
color: Rgb,
|
||||||
|
/// Schnitt-Schraffur des Bauteils (siehe `WallInput::hatch`). Wandert in die
|
||||||
|
/// Cut-Polygone, damit `section_fill`/`CAP_WGSL` das richtige Muster zeichnen.
|
||||||
|
hatch: Option<Hatch>,
|
||||||
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
|
/// Nur fuer Waende: Achse + Oeffnungen (siehe `WallAxis`). `None` fuer
|
||||||
/// Decken (Slabs kennen keine Oeffnungen).
|
/// Decken (Slabs kennen keine Oeffnungen).
|
||||||
wall_axis: Option<WallAxis>,
|
wall_axis: Option<WallAxis>,
|
||||||
|
/// Schnitt-Metadaten (Prioritaet/Identitaet/Linienstil), 1:1 aus der
|
||||||
|
/// `WallInput`/`SlabInput`-Eingabe (`cut`) uebernommen und an jedes erzeugte
|
||||||
|
/// `CutPolygon` weitergereicht.
|
||||||
|
cut: Option<CutBandMeta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
|
/// Wandachse + Oeffnungen, wie sie fuer die Oeffnungs-Zuordnung bei Cut-
|
||||||
@@ -399,12 +419,14 @@ fn wall_prism(index: usize, wall: &WallInput) -> Option<Prism> {
|
|||||||
z0: wall.base_elevation,
|
z0: wall.base_elevation,
|
||||||
z1: wall.base_elevation + wall.height,
|
z1: wall.base_elevation + wall.height,
|
||||||
color: wall.color,
|
color: wall.color,
|
||||||
|
hatch: wall.hatch,
|
||||||
wall_axis: Some(WallAxis {
|
wall_axis: Some(WallAxis {
|
||||||
start: wall.start,
|
start: wall.start,
|
||||||
dir,
|
dir,
|
||||||
length,
|
length,
|
||||||
openings: wall.openings.clone(),
|
openings: wall.openings.clone(),
|
||||||
}),
|
}),
|
||||||
|
cut: wall.cut.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +447,9 @@ fn slab_prism(index: usize, slab: &SlabInput) -> Option<Prism> {
|
|||||||
z0,
|
z0,
|
||||||
z1,
|
z1,
|
||||||
color: slab.color,
|
color: slab.color,
|
||||||
|
hatch: slab.hatch,
|
||||||
wall_axis: None,
|
wall_axis: None,
|
||||||
|
cut: slab.cut.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +998,9 @@ pub fn cut_section(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput
|
|||||||
index: prism.index,
|
index: prism.index,
|
||||||
},
|
},
|
||||||
color: prism.color,
|
color: prism.color,
|
||||||
|
hatch: prism.hatch,
|
||||||
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
|
pts: vec![[rlo, rz0], [rhi, rz0], [rhi, rz1], [rlo, rz1]],
|
||||||
|
cut: prism.cut.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1017,6 +1043,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
},
|
},
|
||||||
WallInput {
|
WallInput {
|
||||||
start: [0.0, 3.0],
|
start: [0.0, 3.0],
|
||||||
@@ -1028,6 +1057,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let slabs = vec![SlabInput {
|
let slabs = vec![SlabInput {
|
||||||
@@ -1035,6 +1067,8 @@ mod tests {
|
|||||||
z_bottom: -0.2,
|
z_bottom: -0.2,
|
||||||
z_top: 0.0,
|
z_top: 0.0,
|
||||||
color: [0.86, 0.86, 0.88],
|
color: [0.86, 0.86, 0.88],
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
}];
|
}];
|
||||||
(walls, slabs)
|
(walls, slabs)
|
||||||
}
|
}
|
||||||
@@ -1067,8 +1101,9 @@ mod tests {
|
|||||||
math::dot(u, plane.normal).abs() < 1e-6,
|
math::dot(u, plane.normal).abs() < 1e-6,
|
||||||
"u-Achse orthogonal zur Blickrichtung"
|
"u-Achse orthogonal zur Blickrichtung"
|
||||||
);
|
);
|
||||||
// Fuer looking_plus_x ist u = Modell-Y (world +Z).
|
// Fuer looking_plus_x (Blick nach Osten) zeigt das Betrachter-RECHTS
|
||||||
assert!((u[2] - 1.0).abs() < 1e-6);
|
// nach Sueden: u = world −Z (Entspiegelung, s. u_axis-Doc).
|
||||||
|
assert!((u[2] + 1.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1108,8 +1143,8 @@ mod tests {
|
|||||||
let vs: Vec<f32> = wall_cut.pts.iter().map(|p| p[1]).collect();
|
let vs: Vec<f32> = wall_cut.pts.iter().map(|p| p[1]).collect();
|
||||||
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||||
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||||
assert!((u_lo - 2.9).abs() < 1e-4, "u_lo={u_lo}");
|
assert!((u_lo + 3.1).abs() < 1e-4, "u_lo={u_lo}");
|
||||||
assert!((u_hi - 3.1).abs() < 1e-4, "u_hi={u_hi}");
|
assert!((u_hi + 2.9).abs() < 1e-4, "u_hi={u_hi}");
|
||||||
assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}");
|
assert!((v_lo - 0.0).abs() < 1e-4, "v_lo={v_lo}");
|
||||||
assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}");
|
assert!((v_hi - 2.5).abs() < 1e-4, "v_hi={v_hi}");
|
||||||
|
|
||||||
@@ -1122,8 +1157,8 @@ mod tests {
|
|||||||
let vs: Vec<f32> = slab_cut.pts.iter().map(|p| p[1]).collect();
|
let vs: Vec<f32> = slab_cut.pts.iter().map(|p| p[1]).collect();
|
||||||
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
let (u_lo, u_hi) = (us.iter().cloned().fold(f32::INFINITY, f32::min), us.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||||
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
let (v_lo, v_hi) = (vs.iter().cloned().fold(f32::INFINITY, f32::min), vs.iter().cloned().fold(f32::NEG_INFINITY, f32::max));
|
||||||
assert!((u_lo + 0.5).abs() < 1e-4, "u_lo={u_lo}");
|
assert!((u_lo + 3.5).abs() < 1e-4, "u_lo={u_lo}");
|
||||||
assert!((u_hi - 3.5).abs() < 1e-4, "u_hi={u_hi}");
|
assert!((u_hi - 0.5).abs() < 1e-4, "u_hi={u_hi}");
|
||||||
assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}");
|
assert!((v_lo + 0.2).abs() < 1e-4, "v_lo={v_lo}");
|
||||||
assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}");
|
assert!((v_hi - 0.0).abs() < 1e-4, "v_hi={v_hi}");
|
||||||
}
|
}
|
||||||
@@ -1179,30 +1214,30 @@ mod tests {
|
|||||||
let embedded_corner_hidden = out.hidden_edges.iter().any(|e| {
|
let embedded_corner_hidden = out.hidden_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[0] - 2.9).abs() < 1e-3
|
&& (e.a[0] + 2.9).abs() < 1e-3
|
||||||
&& (e.b[0] - 2.9).abs() < 1e-3
|
&& (e.b[0] + 2.9).abs() < 1e-3
|
||||||
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
||||||
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
||||||
});
|
});
|
||||||
assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=2.9 muss hidden sein");
|
assert!(embedded_corner_hidden, "Eckkante von Schenkel B bei u=-2.9 muss hidden sein");
|
||||||
|
|
||||||
// Die AEUSSERE Eckkante von Schenkel B (u=3.1, ragt ueber Schenkel A
|
// Die AEUSSERE Eckkante von Schenkel B (u=3.1, ragt ueber Schenkel A
|
||||||
// hinaus) bleibt dagegen sichtbar.
|
// hinaus) bleibt dagegen sichtbar.
|
||||||
let outer_corner_visible = out.visible_edges.iter().any(|e| {
|
let outer_corner_visible = out.visible_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[0] - 3.1).abs() < 1e-3
|
&& (e.a[0] + 3.1).abs() < 1e-3
|
||||||
&& (e.b[0] - 3.1).abs() < 1e-3
|
&& (e.b[0] + 3.1).abs() < 1e-3
|
||||||
});
|
});
|
||||||
assert!(outer_corner_visible, "Aeussere Eckkante von Schenkel B (u=3.1) bleibt sichtbar");
|
assert!(outer_corner_visible, "Aeussere Eckkante von Schenkel B (u=3.1) bleibt sichtbar");
|
||||||
|
|
||||||
// Die partiell gesplittete Bodenkante (u: 2.9 -> 3.1 bei v=0) zerfaellt
|
// Die partiell gesplittete Bodenkante (u: -3.1 -> -2.9 bei v=0,
|
||||||
// in einen sichtbaren Anteil ab u=3.0.
|
// gespiegelte u-Achse) zerfaellt in einen sichtbaren Anteil ab u=-3.0.
|
||||||
let split_visible_present = out.visible_edges.iter().any(|e| {
|
let split_visible_present = out.visible_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[1].abs() < 1e-3 && e.b[1].abs() < 1e-3)
|
&& (e.a[1].abs() < 1e-3 && e.b[1].abs() < 1e-3)
|
||||||
&& ((e.a[0] - 3.0).abs() < 1e-3 || (e.b[0] - 3.0).abs() < 1e-3)
|
&& ((e.a[0] + 3.0).abs() < 1e-3 || (e.b[0] + 3.0).abs() < 1e-3)
|
||||||
});
|
});
|
||||||
assert!(split_visible_present, "Sichtbarer Teil der gesplitteten Bodenkante ab u=3.0 fehlt");
|
assert!(split_visible_present, "Sichtbarer Teil der gesplitteten Bodenkante ab u=3.0 fehlt");
|
||||||
}
|
}
|
||||||
@@ -1254,14 +1289,14 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let a = wall_cuts.iter().find(|c| c.component.index == 0).expect("Schenkel A");
|
let a = wall_cuts.iter().find(|c| c.component.index == 0).expect("Schenkel A");
|
||||||
let b = wall_cuts.iter().find(|c| c.component.index == 1).expect("Schenkel B");
|
let b = wall_cuts.iter().find(|c| c.component.index == 1).expect("Schenkel B");
|
||||||
let (_a_lo, a_hi) = u_bounds(a);
|
let (a_lo, _a_hi) = u_bounds(a);
|
||||||
let (b_lo, _b_hi) = u_bounds(b);
|
let (_b_lo, b_hi) = u_bounds(b);
|
||||||
|
|
||||||
// Beide enden/beginnen an der Gehrungslinie (u ~ 2.95) statt sich zu
|
// Beide enden/beginnen an der Gehrungslinie (u ~ -2.95, gespiegelte
|
||||||
// ueberlappen (Schenkel A reichte ungehrt bis u=3.0, Schenkel B ab 2.9).
|
// u-Achse) statt sich zu ueberlappen.
|
||||||
assert!((a_hi - 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_hi={a_hi}");
|
assert!((a_lo + 2.95).abs() < 1e-3, "Schenkel-A-Querschnitt endet an der Gehrung, u_lo={a_lo}");
|
||||||
assert!((b_lo - 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_lo={b_lo}");
|
assert!((b_hi + 2.95).abs() < 1e-3, "Schenkel-B-Querschnitt beginnt an der Gehrung, u_hi={b_hi}");
|
||||||
assert!(a_hi <= b_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
|
assert!(b_hi <= a_lo + 1e-4, "keine u-Ueberlappung der beiden Querschnitte");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Oeffnungen (Tueren/Fenster) -----------------------------------------
|
// --- Oeffnungen (Tueren/Fenster) -----------------------------------------
|
||||||
@@ -1285,6 +1320,9 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1409,9 @@ mod tests {
|
|||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
holes: vec![],
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
|
// Blick von y = -2 nach +Y: beide Waende (y=0 bzw. y=1) liegen dahinter.
|
||||||
let plane = SectionPlane::looking_plus_y(-2.0);
|
let plane = SectionPlane::looking_plus_y(-2.0);
|
||||||
@@ -1389,8 +1430,8 @@ mod tests {
|
|||||||
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
|
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
assert!(jamb_visible(-1.5), "linke Leibung (u=-1.5) muss sichtbar sein");
|
assert!(jamb_visible(1.5), "linke Leibung (u=1.5) muss sichtbar sein");
|
||||||
assert!(jamb_visible(-2.5), "rechte Leibung (u=-2.5) muss sichtbar sein");
|
assert!(jamb_visible(2.5), "rechte Leibung (u=2.5) muss sichtbar sein");
|
||||||
// Bruestungs- und Sturzkante (horizontale Rahmenkanten bei v=1.0/2.0).
|
// Bruestungs- und Sturzkante (horizontale Rahmenkanten bei v=1.0/2.0).
|
||||||
let horizontal_edge_visible = |v: f32| {
|
let horizontal_edge_visible = |v: f32| {
|
||||||
out.visible_edges.iter().any(|e| {
|
out.visible_edges.iter().any(|e| {
|
||||||
@@ -1398,8 +1439,8 @@ mod tests {
|
|||||||
&& e.component.index == 0
|
&& e.component.index == 0
|
||||||
&& (e.a[1] - v).abs() < 1e-3
|
&& (e.a[1] - v).abs() < 1e-3
|
||||||
&& (e.b[1] - v).abs() < 1e-3
|
&& (e.b[1] - v).abs() < 1e-3
|
||||||
&& (e.a[0].min(e.b[0]) + 2.5).abs() < 1e-3
|
&& (e.a[0].min(e.b[0]) - 1.5).abs() < 1e-3
|
||||||
&& (e.a[0].max(e.b[0]) + 1.5).abs() < 1e-3
|
&& (e.a[0].max(e.b[0]) - 2.5).abs() < 1e-3
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein");
|
assert!(horizontal_edge_visible(1.0), "Bruestungskante (v=1.0) muss sichtbar sein");
|
||||||
@@ -1409,21 +1450,21 @@ mod tests {
|
|||||||
let frame_hidden = out.hidden_edges.iter().any(|e| {
|
let frame_hidden = out.hidden_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 0
|
&& e.component.index == 0
|
||||||
&& ((e.a[0] + 1.5).abs() < 1e-3 || (e.a[0] + 2.5).abs() < 1e-3)
|
&& ((e.a[0] - 1.5).abs() < 1e-3 || (e.a[0] - 2.5).abs() < 1e-3)
|
||||||
&& e.a[1] >= 1.0 - 1e-3
|
&& e.a[1] >= 1.0 - 1e-3
|
||||||
&& e.a[1] <= 2.0 + 1e-3
|
&& e.a[1] <= 2.0 + 1e-3
|
||||||
});
|
});
|
||||||
assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein");
|
assert!(!frame_hidden, "Fensterrahmen darf nicht (teilweise) hidden sein");
|
||||||
|
|
||||||
// -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei
|
// -- Durchblick: die (naeherliegende) Vorderkante von Wand 1 bei
|
||||||
// u=-2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich -2.5..-1.5) muss
|
// u=2.0 (Wandstart x=2, liegt IM Fenster-u-Bereich 1.5..2.5) muss
|
||||||
// GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter
|
// GENAU im Fensterband (v: 1.0..2.0) sichtbar sein, darueber/darunter
|
||||||
// (verdeckt durch Bruestung/Sturz der Wand 0) hidden. --
|
// (verdeckt durch Bruestung/Sturz der Wand 0) hidden. --
|
||||||
let w1_visible_window = out.visible_edges.iter().any(|e| {
|
let w1_visible_window = out.visible_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[0] + 2.0).abs() < 1e-3
|
&& (e.a[0] - 2.0).abs() < 1e-3
|
||||||
&& (e.b[0] + 2.0).abs() < 1e-3
|
&& (e.b[0] - 2.0).abs() < 1e-3
|
||||||
&& (e.a[1].min(e.b[1]) - 1.0).abs() < 1e-3
|
&& (e.a[1].min(e.b[1]) - 1.0).abs() < 1e-3
|
||||||
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
|
&& (e.a[1].max(e.b[1]) - 2.0).abs() < 1e-3
|
||||||
});
|
});
|
||||||
@@ -1432,8 +1473,8 @@ mod tests {
|
|||||||
let w1_hidden_below = out.hidden_edges.iter().any(|e| {
|
let w1_hidden_below = out.hidden_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[0] + 2.0).abs() < 1e-3
|
&& (e.a[0] - 2.0).abs() < 1e-3
|
||||||
&& (e.b[0] + 2.0).abs() < 1e-3
|
&& (e.b[0] - 2.0).abs() < 1e-3
|
||||||
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
||||||
&& (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3
|
&& (e.a[1].max(e.b[1]) - 1.0).abs() < 1e-3
|
||||||
});
|
});
|
||||||
@@ -1442,20 +1483,20 @@ mod tests {
|
|||||||
let w1_hidden_above = out.hidden_edges.iter().any(|e| {
|
let w1_hidden_above = out.hidden_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[0] + 2.0).abs() < 1e-3
|
&& (e.a[0] - 2.0).abs() < 1e-3
|
||||||
&& (e.b[0] + 2.0).abs() < 1e-3
|
&& (e.b[0] - 2.0).abs() < 1e-3
|
||||||
&& (e.a[1].min(e.b[1]) - 2.0).abs() < 1e-3
|
&& (e.a[1].min(e.b[1]) - 2.0).abs() < 1e-3
|
||||||
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
||||||
});
|
});
|
||||||
assert!(w1_hidden_above, "Wand 1 oberhalb des Sturzes (v 2.0..2.5) muss hidden sein");
|
assert!(w1_hidden_above, "Wand 1 oberhalb des Sturzes (v 2.0..2.5) muss hidden sein");
|
||||||
|
|
||||||
// Kante hinter der GESCHLOSSENEN Wandflaeche (u=-4.0, ausserhalb des
|
// Kante hinter der GESCHLOSSENEN Wandflaeche (u=4.0, ausserhalb des
|
||||||
// Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt.
|
// Fensters, x=4-Ende von Wand 1) bleibt vollstaendig verdeckt.
|
||||||
let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| {
|
let w1_closed_wall_hidden = out.hidden_edges.iter().any(|e| {
|
||||||
e.component.kind == ComponentKind::Wall
|
e.component.kind == ComponentKind::Wall
|
||||||
&& e.component.index == 1
|
&& e.component.index == 1
|
||||||
&& (e.a[0] + 4.0).abs() < 1e-3
|
&& (e.a[0] - 4.0).abs() < 1e-3
|
||||||
&& (e.b[0] + 4.0).abs() < 1e-3
|
&& (e.b[0] - 4.0).abs() < 1e-3
|
||||||
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
&& (e.a[1].min(e.b[1]) - 0.0).abs() < 1e-3
|
||||||
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
&& (e.a[1].max(e.b[1]) - 2.5).abs() < 1e-3
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
//! ## Ausgabeformat
|
||||||
//!
|
//!
|
||||||
//! Interleaved `[world_x, world_y, world_z, u, v]` je Vertex (5 f32,
|
//! Interleaved `[world_x, world_y, world_z, u, v, pattern, angle_rad, scale]` je
|
||||||
//! `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern wandern als
|
//! Vertex (8 f32, `CAP_FLOATS_PER_VERTEX`), Dreiecksliste. `(u,v)` in Modell-Metern
|
||||||
//! Attribut mit, damit die Schraffur im Fragment-Shader ein durchgaengiges,
|
//! wandern als Attribut mit, damit die Schraffur im Fragment-Shader ein
|
||||||
//! blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet (nicht im
|
//! durchgaengiges, blickwinkel-unabhaengiges Muster IN der Schnittebene zeichnet
|
||||||
//! Bildschirmraum). Ein Cut-Polygon ist stets ein konvexes Rechteck (4 Ecken,
|
//! (nicht im Bildschirmraum). `pattern`/`angle_rad`/`scale` kommen aus der
|
||||||
//! siehe `section.rs`) — Fan-Triangulierung genuegt.
|
//! Bauteil-Schraffur (`CutPolygon::hatch` -> `types::Hatch`) und waehlen im Shader
|
||||||
|
//! das MUSTER je geschnittener Schicht (statt eines fixen 45°-Musters). Sie sind je
|
||||||
|
//! Cut-Polygon konstant, wandern aber als (redundantes) Vertex-Attribut mit — das
|
||||||
|
//! haelt die Cap-Pipeline ohne zusaetzliche per-Draw-Uniform-Verwaltung (die Caps
|
||||||
|
//! werden in EINEM `draw` gezeichnet). Ein Cut-Polygon ist stets ein konvexes
|
||||||
|
//! Rechteck (4 Ecken, siehe `section.rs`) — Fan-Triangulierung genuegt.
|
||||||
|
|
||||||
use crate::section::{cut_section, SectionPlane};
|
use crate::section::{cut_section, CutPolygon, SectionPlane};
|
||||||
|
use crate::section_boolean::{rect_of_poly, subtract_dominant_bands};
|
||||||
use crate::types::{SlabInput, WallInput};
|
use crate::types::{SlabInput, WallInput};
|
||||||
|
|
||||||
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v]. Spiegel des Vertex-Layouts
|
/// f32 pro Cap-Vertex: [pos.x, pos.y, pos.z, u, v, pattern, angle_rad, scale,
|
||||||
/// der `cap_pipeline` (gpu.rs) und des `VsIn` in `CAP_WGSL` (shaders.rs).
|
/// line_weight_mm]. Spiegel des Vertex-Layouts der `cap_pipeline` (gpu.rs) und des
|
||||||
pub const CAP_FLOATS_PER_VERTEX: usize = 5;
|
/// `VsIn` in `CAP_WGSL` (shaders.rs). `line_weight_mm` steuert die Musterlinien-
|
||||||
|
/// Breite (mm-Papier -> Welt) je Schraffur.
|
||||||
|
pub const CAP_FLOATS_PER_VERTEX: usize = 9;
|
||||||
|
|
||||||
|
/// Default-Musterlinien-Staerke (mm-Papier), wenn ein Cut-Polygon keine `hatch`
|
||||||
|
/// traegt (Fallback-Diagonale) — Haarlinie, wie `HatchRender.lineWeight`-Default.
|
||||||
|
const FALLBACK_HATCH_LINE_WEIGHT_MM: f32 = 0.13;
|
||||||
|
|
||||||
|
/// f32 pro Cut-Linien-Vertex: [pos.x, pos.y, pos.z, r, g, b] — dasselbe Layout wie
|
||||||
|
/// die Grid-/Highlight-LineList (`GRID_WGSL`), damit die Schnitt-Umrisslinien ohne
|
||||||
|
/// eigenes Vertex-Format ueber eine (Dreiecks-)Ribbon-Pipeline laufen.
|
||||||
|
pub const CUT_LINE_FLOATS_PER_VERTEX: usize = 6;
|
||||||
|
|
||||||
|
/// Default-Schnittkanten-Farbe (dunkle Tinte), wenn ein Band keinen eigenen
|
||||||
|
/// `line_color` traegt — passend zur 2D-Schnittkonvention (dunkle Fugenlinie).
|
||||||
|
const DEFAULT_CUT_LINE_COLOR: [f32; 3] = [0.10, 0.10, 0.10];
|
||||||
|
|
||||||
|
/// Default-Strichstaerke der Schnitt-Umrisslinie in mm-Papier (Haarlinie), wenn
|
||||||
|
/// ein Band keinen eigenen `line_weight` traegt.
|
||||||
|
const DEFAULT_CUT_LINE_WEIGHT_MM: f32 = 0.35;
|
||||||
|
|
||||||
|
/// mm-Papier -> Welt-Meter fuer die Ribbon-BREITE der Schnitt-Umrisslinien.
|
||||||
|
///
|
||||||
|
/// EHRLICHE EINSCHRAENKUNG: die 2D-Strichstaerke ist in mm-Papier (masstabsabhaengig).
|
||||||
|
/// Der 3D-Schnitt kennt keinen Papiermasstab; die Linie wird hier als flaches Ribbon
|
||||||
|
/// mit KONSTANTER Welt-Breite in die Schnittebene gelegt (blickwinkel-unabhaengig,
|
||||||
|
/// skaliert also mit der Kamera-Distanz wie gemalte Geometrie — NICHT bildschirmfest).
|
||||||
|
/// Der Faktor ist bewusst grob gewaehlt (0.35 mm -> ~3.5 mm Welt), damit die Fuge
|
||||||
|
/// ueberhaupt lesbar bleibt; eine bildschirmfeste Strichstaerke braeuchte eine
|
||||||
|
/// Screen-Space-Expansion in einer eigenen Pipeline (bekannte Luecke, siehe
|
||||||
|
/// Schlussbericht/Moduldoc).
|
||||||
|
const CUT_LINE_MM_TO_WORLD: f32 = 0.01;
|
||||||
|
|
||||||
|
/// Kleiner Versatz (Meter) der Umrisslinien-Ribbons ZUR KAMERA (entgegen der
|
||||||
|
/// Ebenennormale), damit sie (a) nicht durch die Schnittebenen-Kappung von
|
||||||
|
/// `GRID_WGSL` (`depth > 0` = hinter der Ebene) weggeschnitten werden und (b)
|
||||||
|
/// sichtbar VOR den Cut-Caps liegen (die exakt auf der Ebene sitzen).
|
||||||
|
const CUT_LINE_LIFT: f32 = 0.002;
|
||||||
|
|
||||||
|
/// Fallback-Muster-Id, wenn ein Cut-Polygon keine `hatch` traegt: 2 = diagonal
|
||||||
|
/// (45°) — reproduziert mit `angle_rad = 0`, `scale = 1` exakt das fruehere fest
|
||||||
|
/// verdrahtete 45°-Muster (Rueckwaertskompatibilitaet fuer Waende/Decken ohne
|
||||||
|
/// aufgeloeste Bauteil-Schraffur).
|
||||||
|
const FALLBACK_PATTERN: f32 = 2.0;
|
||||||
|
|
||||||
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
|
/// Erzeugt die Schnittflaechen-Geometrie (Cut-Caps) fuer eine vertikale
|
||||||
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
|
/// Schnittebene: ruft `cut_section` und trianguliert jedes (u,v)-Rechteck in die
|
||||||
@@ -44,12 +93,23 @@ pub const CAP_FLOATS_PER_VERTEX: usize = 5;
|
|||||||
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
|
/// zeichnet ohne Backface-Culling (`CullMode::None`, siehe gpu.rs), sodass die
|
||||||
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
|
/// flache Schnittflaeche aus beiden Richtungen sichtbar ist. Die Schraffur haengt
|
||||||
/// nicht von der Normale ab.
|
/// nicht von der Normale ab.
|
||||||
|
/// Berechnet die (u,v)-Cut-Polygone der Schnittebene MIT angewandter Boolean-
|
||||||
|
/// Dominanz (`section_boolean::subtract_dominant_bands`): die staerkere Schicht
|
||||||
|
/// schneidet die schwaechere weg — elementuebergreifend, exakt wie der 2D-Schnitt
|
||||||
|
/// (`toSection.ts::attachCutStyles` Phase 2). Gemeinsame Basis der Cut-Cap-Flaechen
|
||||||
|
/// (`build_cut_caps`) UND der Cut-Umrisslinien (`build_cut_cap_lines`), damit beide
|
||||||
|
/// auf DERSELBEN, ueberlappungsfreien Rechteck-Menge arbeiten.
|
||||||
|
fn cut_cap_polys(plane: &SectionPlane, walls: &[WallInput], slabs: &[SlabInput]) -> Vec<CutPolygon> {
|
||||||
|
let out = cut_section(plane, walls, slabs);
|
||||||
|
subtract_dominant_bands(out.cut_polygons)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_cut_caps(
|
pub fn build_cut_caps(
|
||||||
plane: &SectionPlane,
|
plane: &SectionPlane,
|
||||||
walls: &[WallInput],
|
walls: &[WallInput],
|
||||||
slabs: &[SlabInput],
|
slabs: &[SlabInput],
|
||||||
) -> Vec<f32> {
|
) -> Vec<f32> {
|
||||||
let out = cut_section(plane, walls, slabs);
|
let polys = cut_cap_polys(plane, walls, slabs);
|
||||||
let point = plane.point;
|
let point = plane.point;
|
||||||
let u_axis = plane.u_axis();
|
let u_axis = plane.u_axis();
|
||||||
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
|
// (u, v) -> Welt: horizontale Lage aus point + u*u_axis, Hoehe = v.
|
||||||
@@ -62,14 +122,28 @@ pub fn build_cut_caps(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut verts: Vec<f32> = Vec::new();
|
let mut verts: Vec<f32> = Vec::new();
|
||||||
for poly in &out.cut_polygons {
|
for poly in &polys {
|
||||||
let pts = &poly.pts;
|
let pts = &poly.pts;
|
||||||
if pts.len() < 3 {
|
if pts.len() < 3 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Muster/Winkel/Skalierung aus der Bauteil-Schraffur (oder Fallback-
|
||||||
|
// Diagonale). Winkel von Grad in Radiant, damit der Shader direkt
|
||||||
|
// cos/sin rechnen kann.
|
||||||
|
let (pattern, angle_rad, scale, line_weight) = match poly.hatch {
|
||||||
|
Some(h) => (
|
||||||
|
h.pattern as f32,
|
||||||
|
h.angle.to_radians(),
|
||||||
|
h.scale.max(0.05),
|
||||||
|
h.line_weight.max(0.0),
|
||||||
|
),
|
||||||
|
None => (FALLBACK_PATTERN, 0.0, 1.0, FALLBACK_HATCH_LINE_WEIGHT_MM),
|
||||||
|
};
|
||||||
let mut push_vertex = |uv: [f32; 2]| {
|
let mut push_vertex = |uv: [f32; 2]| {
|
||||||
let w = to_world(uv);
|
let w = to_world(uv);
|
||||||
verts.extend_from_slice(&[w[0], w[1], w[2], uv[0], uv[1]]);
|
verts.extend_from_slice(&[
|
||||||
|
w[0], w[1], w[2], uv[0], uv[1], pattern, angle_rad, scale, line_weight,
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
|
// Fan-Triangulierung (konvexe Rechtecke): (0, i, i+1).
|
||||||
for i in 1..pts.len() - 1 {
|
for i in 1..pts.len() - 1 {
|
||||||
@@ -80,3 +154,429 @@ pub fn build_cut_caps(
|
|||||||
}
|
}
|
||||||
verts
|
verts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erzeugt die Schnitt-UMRISSLINIEN (P2): fuer JEDES ueberlebende Cut-Cap-Rechteck
|
||||||
|
/// seine vier Kanten als flache Ribbon-Dreiecke auf der Schnittebene, in Welt-
|
||||||
|
/// Koordinaten (dieselbe (u,v)->Welt-Ruecktransformation wie `build_cut_caps`).
|
||||||
|
/// Rueckgabe = interleaved `[px,py,pz, r,g,b]` (`CUT_LINE_FLOATS_PER_VERTEX`,
|
||||||
|
/// TriangleList), leer wenn die Ebene das Modell nicht schneidet.
|
||||||
|
///
|
||||||
|
/// STIL je Kante: Farbe/Strichstaerke aus dem `line_color`/`line_weight` des Bandes
|
||||||
|
/// (aus dem `jointLineStyleId`-LineStyle, TS-seitig aufgeloest), sonst der Default-
|
||||||
|
/// Schnittkanten-Stil. Die Strichstaerke (mm-Papier) wird ueber `CUT_LINE_MM_TO_WORLD`
|
||||||
|
/// in eine konstante Welt-Breite umgesetzt (siehe dortige Doku — bewusste
|
||||||
|
/// Vereinfachung, keine bildschirmfeste Strichstaerke). Jede Kante wird als Quad
|
||||||
|
/// (2 Dreiecke) um ihre Mittellinie gelegt, mit der Ribbon-Breite senkrecht zur
|
||||||
|
/// Kante IN der Ebene; alle Ribbons um `CUT_LINE_LIFT` zur Kamera versetzt, damit
|
||||||
|
/// sie nicht an der Ebene weggeclippt werden und vor den Caps liegen.
|
||||||
|
pub fn build_cut_cap_lines(
|
||||||
|
plane: &SectionPlane,
|
||||||
|
walls: &[WallInput],
|
||||||
|
slabs: &[SlabInput],
|
||||||
|
) -> Vec<f32> {
|
||||||
|
let polys = cut_cap_polys(plane, walls, slabs);
|
||||||
|
let point = plane.point;
|
||||||
|
let u_axis = plane.u_axis();
|
||||||
|
let normal = plane.normal;
|
||||||
|
// Kamera-waertiger Lift entlang -normal (normal zeigt vom Betrachter ins Modell).
|
||||||
|
let lift = [
|
||||||
|
-normal[0] * CUT_LINE_LIFT,
|
||||||
|
-normal[1] * CUT_LINE_LIFT,
|
||||||
|
-normal[2] * CUT_LINE_LIFT,
|
||||||
|
];
|
||||||
|
let to_world = |uv: [f32; 2]| -> [f32; 3] {
|
||||||
|
[
|
||||||
|
point[0] + uv[0] * u_axis[0] + lift[0],
|
||||||
|
uv[1] + lift[1],
|
||||||
|
point[2] + uv[0] * u_axis[2] + lift[2],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut verts: Vec<f32> = Vec::new();
|
||||||
|
for poly in &polys {
|
||||||
|
let Some(r) = rect_of_poly(poly) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Stil aus den Band-Metadaten (oder Default).
|
||||||
|
let (color, weight_mm) = match &poly.cut {
|
||||||
|
Some(meta) => (
|
||||||
|
meta.line_color.unwrap_or(DEFAULT_CUT_LINE_COLOR),
|
||||||
|
meta.line_weight.unwrap_or(DEFAULT_CUT_LINE_WEIGHT_MM),
|
||||||
|
),
|
||||||
|
None => (DEFAULT_CUT_LINE_COLOR, DEFAULT_CUT_LINE_WEIGHT_MM),
|
||||||
|
};
|
||||||
|
let half_w = (weight_mm.max(0.0) * CUT_LINE_MM_TO_WORLD * 0.5).max(1e-4);
|
||||||
|
|
||||||
|
// Die vier Rechteck-Ecken (u,v) im Umlauf; je Kante ein Ribbon.
|
||||||
|
let corners = [
|
||||||
|
[r.u_min, r.v_min],
|
||||||
|
[r.u_max, r.v_min],
|
||||||
|
[r.u_max, r.v_max],
|
||||||
|
[r.u_min, r.v_max],
|
||||||
|
];
|
||||||
|
for k in 0..4 {
|
||||||
|
let a = to_world(corners[k]);
|
||||||
|
let b = to_world(corners[(k + 1) % 4]);
|
||||||
|
push_line_ribbon(&mut verts, a, b, normal, half_w, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt ein flaches Ribbon-Quad (2 Dreiecke) der halben Breite `half_w` um die
|
||||||
|
/// Kante `a`->`b`, verbreitert IN der Schnittebene (senkrecht zur Kante ueber
|
||||||
|
/// `cross(normal, dir)`), und schreibt es als 6 Vertices `[pos, color]` in `out`.
|
||||||
|
fn push_line_ribbon(
|
||||||
|
out: &mut Vec<f32>,
|
||||||
|
a: [f32; 3],
|
||||||
|
b: [f32; 3],
|
||||||
|
normal: [f32; 3],
|
||||||
|
half_w: f32,
|
||||||
|
color: [f32; 3],
|
||||||
|
) {
|
||||||
|
let dir = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||||
|
let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
|
||||||
|
if len < 1e-9 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let d = [dir[0] / len, dir[1] / len, dir[2] / len];
|
||||||
|
// In-Ebene-Senkrechte zur Kante: cross(normal, dir) liegt in der Ebene und
|
||||||
|
// steht senkrecht auf der Kante.
|
||||||
|
let mut perp = [
|
||||||
|
normal[1] * d[2] - normal[2] * d[1],
|
||||||
|
normal[2] * d[0] - normal[0] * d[2],
|
||||||
|
normal[0] * d[1] - normal[1] * d[0],
|
||||||
|
];
|
||||||
|
let plen = (perp[0] * perp[0] + perp[1] * perp[1] + perp[2] * perp[2]).sqrt();
|
||||||
|
if plen < 1e-9 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
perp = [perp[0] / plen * half_w, perp[1] / plen * half_w, perp[2] / plen * half_w];
|
||||||
|
let p0 = [a[0] - perp[0], a[1] - perp[1], a[2] - perp[2]];
|
||||||
|
let p1 = [a[0] + perp[0], a[1] + perp[1], a[2] + perp[2]];
|
||||||
|
let p2 = [b[0] + perp[0], b[1] + perp[1], b[2] + perp[2]];
|
||||||
|
let p3 = [b[0] - perp[0], b[1] - perp[1], b[2] - perp[2]];
|
||||||
|
let mut push = |p: [f32; 3]| {
|
||||||
|
out.extend_from_slice(&[p[0], p[1], p[2], color[0], color[1], color[2]]);
|
||||||
|
};
|
||||||
|
// Zwei Dreiecke: p0,p1,p2 und p0,p2,p3.
|
||||||
|
push(p0);
|
||||||
|
push(p1);
|
||||||
|
push(p2);
|
||||||
|
push(p0);
|
||||||
|
push(p2);
|
||||||
|
push(p3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::Hatch;
|
||||||
|
|
||||||
|
/// Eine Wand entlang der X-Achse, senkrecht von einer +X-Ebene geschnitten:
|
||||||
|
/// die Muster-Id/Winkel/Skalierung der Bauteil-Schraffur muessen in JEDEM
|
||||||
|
/// Cap-Vertex an den Positionen 5/6/7 landen (Vertex-Stride = 8).
|
||||||
|
#[test]
|
||||||
|
fn hatch_muster_id_landet_in_den_cap_vertices() {
|
||||||
|
let wall = WallInput {
|
||||||
|
start: [0.0, 0.0],
|
||||||
|
end: [3.0, 0.0],
|
||||||
|
thickness: 0.3,
|
||||||
|
height: 2.5,
|
||||||
|
base_elevation: 0.0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
openings: vec![],
|
||||||
|
layers: None,
|
||||||
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: Some(Hatch {
|
||||||
|
pattern: 3, // crosshatch
|
||||||
|
angle: 90.0,
|
||||||
|
scale: 2.0,
|
||||||
|
line_weight: 0.5,
|
||||||
|
}),
|
||||||
|
cut: None,
|
||||||
|
};
|
||||||
|
let plane = SectionPlane::looking_plus_x(1.5);
|
||||||
|
let verts = build_cut_caps(&plane, &[wall], &[]);
|
||||||
|
|
||||||
|
assert!(!verts.is_empty(), "Schnitt liefert Cap-Geometrie");
|
||||||
|
assert_eq!(
|
||||||
|
verts.len() % CAP_FLOATS_PER_VERTEX,
|
||||||
|
0,
|
||||||
|
"Vertexpuffer ist ein Vielfaches des Strides ({CAP_FLOATS_PER_VERTEX})"
|
||||||
|
);
|
||||||
|
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
|
||||||
|
for i in 0..n {
|
||||||
|
let base = i * CAP_FLOATS_PER_VERTEX;
|
||||||
|
assert_eq!(verts[base + 5], 3.0, "pattern-Id je Vertex");
|
||||||
|
assert!(
|
||||||
|
(verts[base + 6] - 90.0_f32.to_radians()).abs() < 1e-5,
|
||||||
|
"angle in Radiant"
|
||||||
|
);
|
||||||
|
assert_eq!(verts[base + 7], 2.0, "scale je Vertex");
|
||||||
|
assert_eq!(verts[base + 8], 0.5, "line_weight (mm) je Vertex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ohne `hatch` faellt jedes Cap-Polygon auf das Diagonalmuster (Id 2,
|
||||||
|
/// angle 0, scale 1) zurueck — das reproduziert das fruehere fixe 45°-Muster.
|
||||||
|
#[test]
|
||||||
|
fn ohne_hatch_fallback_auf_diagonale() {
|
||||||
|
let wall = WallInput {
|
||||||
|
start: [0.0, 0.0],
|
||||||
|
end: [3.0, 0.0],
|
||||||
|
thickness: 0.3,
|
||||||
|
height: 2.5,
|
||||||
|
base_elevation: 0.0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
openings: vec![],
|
||||||
|
layers: None,
|
||||||
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
|
};
|
||||||
|
let plane = SectionPlane::looking_plus_x(1.5);
|
||||||
|
let verts = build_cut_caps(&plane, &[wall], &[]);
|
||||||
|
assert!(!verts.is_empty());
|
||||||
|
assert_eq!(verts[5], FALLBACK_PATTERN, "Fallback = Diagonale (2)");
|
||||||
|
assert_eq!(verts[6], 0.0, "Fallback-Winkel 0");
|
||||||
|
assert_eq!(verts[7], 1.0, "Fallback-Skalierung 1");
|
||||||
|
assert_eq!(verts[8], FALLBACK_HATCH_LINE_WEIGHT_MM, "Fallback-Musterlinienstaerke 0.13 mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::types::CutBandMeta;
|
||||||
|
|
||||||
|
/// Ein Wand-Band (Prio 50, volle Hoehe) und ein u-deckungsgleiches Decken-Band
|
||||||
|
/// (Prio 100) ueberlappen in einem z-Intervall. Nach `build_cut_caps` (das die
|
||||||
|
/// Boolean-Dominanz anwendet) darf im Ueberlappungs-z-Intervall KEINE Wand-
|
||||||
|
/// Cap-Geometrie mehr liegen — die geometrische Kernaussage von P1, direkt auf
|
||||||
|
/// den erzeugten Welt-Vertices geprueft (nicht bloss „laeuft").
|
||||||
|
#[test]
|
||||||
|
fn build_cut_caps_wendet_dominanz_an_wand_verliert_ueberlappung() {
|
||||||
|
// Wand entlang +X bei x in [0,3], Dicke 0.4, volle Hoehe [0,2.5]; Prio 50.
|
||||||
|
let wall = WallInput {
|
||||||
|
start: [0.0, 0.0],
|
||||||
|
end: [3.0, 0.0],
|
||||||
|
thickness: 0.4,
|
||||||
|
height: 2.5,
|
||||||
|
base_elevation: 0.0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
openings: vec![],
|
||||||
|
layers: None,
|
||||||
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||||
|
cut: Some(CutBandMeta {
|
||||||
|
join_priority: Some(50),
|
||||||
|
component_id: Some("brick".into()),
|
||||||
|
line_color: None,
|
||||||
|
line_weight: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// Decken-Slab, deren Umriss die Wand an der Schnittstelle (x=1.5) ueberdeckt,
|
||||||
|
// z in [2.0, 2.4]; Prio 100 (Beton laeuft durch, schneidet die Wand).
|
||||||
|
let slab = SlabInput {
|
||||||
|
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
|
||||||
|
z_bottom: 2.0,
|
||||||
|
z_top: 2.4,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
hatch: Some(Hatch { pattern: 1, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||||
|
cut: Some(CutBandMeta {
|
||||||
|
join_priority: Some(100),
|
||||||
|
component_id: Some("concrete".into()),
|
||||||
|
line_color: None,
|
||||||
|
line_weight: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let plane = SectionPlane::looking_plus_x(1.5);
|
||||||
|
let verts = build_cut_caps(&plane, &[wall], &[slab]);
|
||||||
|
assert!(!verts.is_empty());
|
||||||
|
|
||||||
|
// Die Wand-Caps tragen pattern==2 (diagonal), die Beton-Caps pattern==1
|
||||||
|
// (solid). Fuer JEDES Wand-Vertex (pattern 2) muss die Hoehe (v = pos.y,
|
||||||
|
// Index 1) AUSSERHALB des Beton-Intervalls [2.0,2.4] liegen.
|
||||||
|
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
|
||||||
|
let mut wall_verts = 0;
|
||||||
|
let mut concrete_verts = 0;
|
||||||
|
for i in 0..n {
|
||||||
|
let base = i * CAP_FLOATS_PER_VERTEX;
|
||||||
|
let y = verts[base + 1];
|
||||||
|
let pattern = verts[base + 5];
|
||||||
|
if (pattern - 2.0).abs() < 0.5 {
|
||||||
|
wall_verts += 1;
|
||||||
|
assert!(
|
||||||
|
y <= 2.0 + 1e-3 || y >= 2.4 - 1e-3,
|
||||||
|
"Wand-Cap-Vertex bei v={y} liegt im Beton-z-Intervall (muesste weggeschnitten sein)"
|
||||||
|
);
|
||||||
|
} else if (pattern - 1.0).abs() < 0.5 {
|
||||||
|
concrete_verts += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(wall_verts > 0, "Wand-Caps vorhanden (ober-/unterhalb des Betons)");
|
||||||
|
assert!(concrete_verts > 0, "Beton-Caps vorhanden (voll durchlaufend)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reziprok zum vorigen Test (Nutzer-Report 2026-07-08): eine Backstein-WAND
|
||||||
|
/// (Prio 50) muss die SCHWAECHEREN Decken-Schichten Estrich (30) und Daemmung
|
||||||
|
/// (20) in der u/v-Ueberlappung wegschneiden — wie im 2D-Schnitt. Wir bauen die
|
||||||
|
/// Wand als EIN Backstein-Band (Prio 50) und den Boden als zwei duenne Slabs
|
||||||
|
/// (Estrich oben, Daemmung darunter), deren z-Intervall die Wand ueberlappt.
|
||||||
|
/// Nach der Dominanz darf im Wand-u-Intervall KEINE Estrich-/Daemmung-Cap mehr
|
||||||
|
/// liegen (die Wand-Cap dagegen bleibt — Backstein ist dort dominant).
|
||||||
|
#[test]
|
||||||
|
fn wand_schneidet_schwaechere_decken_schichten() {
|
||||||
|
// Backstein-Wand entlang +X, x in [0,3], Dicke 0.4 → u (=y) in [-0.2,0.2],
|
||||||
|
// volle Hoehe [0,2.5]; Prio 50, Muster 2 (diagonal).
|
||||||
|
let wall = WallInput {
|
||||||
|
start: [0.0, 0.0],
|
||||||
|
end: [3.0, 0.0],
|
||||||
|
thickness: 0.4,
|
||||||
|
height: 2.5,
|
||||||
|
base_elevation: 0.0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
openings: vec![],
|
||||||
|
layers: None,
|
||||||
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: Some(Hatch { pattern: 2, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||||
|
cut: Some(CutBandMeta {
|
||||||
|
join_priority: Some(50),
|
||||||
|
component_id: Some("brick".into()),
|
||||||
|
line_color: None,
|
||||||
|
line_weight: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// Estrich-Slab (Prio 30, Muster 3 crosshatch), z in [1.0,1.06], deckt die
|
||||||
|
// Wand in u ab (Umriss ueber y in [-1,1]).
|
||||||
|
let screed = SlabInput {
|
||||||
|
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
|
||||||
|
z_bottom: 1.0,
|
||||||
|
z_top: 1.06,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
hatch: Some(Hatch { pattern: 3, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||||
|
cut: Some(CutBandMeta {
|
||||||
|
join_priority: Some(30),
|
||||||
|
component_id: Some("screed".into()),
|
||||||
|
line_color: None,
|
||||||
|
line_weight: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// Daemmung-Slab (Prio 20, Muster 4), z in [0.96,1.0], gleiche u-Deckung.
|
||||||
|
let insulation = SlabInput {
|
||||||
|
outline: vec![[-1.0, -1.0], [4.0, -1.0], [4.0, 1.0], [-1.0, 1.0]],
|
||||||
|
z_bottom: 0.96,
|
||||||
|
z_top: 1.0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
hatch: Some(Hatch { pattern: 4, angle: 0.0, scale: 1.0, line_weight: 0.13 }),
|
||||||
|
cut: Some(CutBandMeta {
|
||||||
|
join_priority: Some(20),
|
||||||
|
component_id: Some("insulation".into()),
|
||||||
|
line_color: None,
|
||||||
|
line_weight: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let plane = SectionPlane::looking_plus_x(1.5);
|
||||||
|
let verts = build_cut_caps(&plane, &[wall], &[screed, insulation]);
|
||||||
|
assert!(!verts.is_empty());
|
||||||
|
|
||||||
|
// Wand-u-Intervall (=y) ist [-0.2,0.2]. Fuer JEDES Estrich- (Muster 3) oder
|
||||||
|
// Daemmung-Vertex (Muster 4) muss u AUSSERHALB [-0.2,0.2] liegen — sonst hat
|
||||||
|
// der Backstein die schwaechere Schicht dort NICHT weggeschnitten.
|
||||||
|
let n = verts.len() / CAP_FLOATS_PER_VERTEX;
|
||||||
|
let mut weak_verts = 0;
|
||||||
|
for i in 0..n {
|
||||||
|
let base = i * CAP_FLOATS_PER_VERTEX;
|
||||||
|
let u = verts[base]; // pos.x = y-Welt (Schnitt entlang +X)
|
||||||
|
let pattern = verts[base + 5];
|
||||||
|
if (pattern - 3.0).abs() < 0.5 || (pattern - 4.0).abs() < 0.5 {
|
||||||
|
weak_verts += 1;
|
||||||
|
assert!(
|
||||||
|
u <= -0.2 + 1e-3 || u >= 0.2 - 1e-3,
|
||||||
|
"Estrich/Daemmung-Vertex bei u={u} liegt im Backstein-u-Intervall \
|
||||||
|
[-0.2,0.2] (muesste weggeschnitten sein)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(weak_verts > 0, "Estrich/Daemmung-Caps existieren (ausserhalb der Wand)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P2: je ueberlebendem Cut-Cap-Rechteck entstehen 4 Umriss-Kanten als Ribbon-
|
||||||
|
/// Dreiecke (4 Kanten * 6 Vertices = 24 Vertices je Rechteck), und Farbe +
|
||||||
|
/// Strichstaerke kommen aus dem Band-Stil durch (Farbe je Vertex, Staerke ueber
|
||||||
|
/// die Ribbon-Breite messbar).
|
||||||
|
#[test]
|
||||||
|
fn build_cut_cap_lines_kantenzahl_und_stil() {
|
||||||
|
let color = [0.2, 0.4, 0.9];
|
||||||
|
let wall = WallInput {
|
||||||
|
start: [0.0, 0.0],
|
||||||
|
end: [3.0, 0.0],
|
||||||
|
thickness: 0.4,
|
||||||
|
height: 2.5,
|
||||||
|
base_elevation: 0.0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
openings: vec![],
|
||||||
|
layers: None,
|
||||||
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: Some(CutBandMeta {
|
||||||
|
join_priority: Some(50),
|
||||||
|
component_id: Some("brick".into()),
|
||||||
|
line_color: Some(color),
|
||||||
|
line_weight: Some(2.0),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let plane = SectionPlane::looking_plus_x(1.5);
|
||||||
|
let verts = build_cut_cap_lines(&plane, &[wall.clone()], &[]);
|
||||||
|
assert!(!verts.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
verts.len() % CUT_LINE_FLOATS_PER_VERTEX,
|
||||||
|
0,
|
||||||
|
"Vielfaches des Cut-Linien-Strides"
|
||||||
|
);
|
||||||
|
// EIN Rechteck (keine Dominanz-Subtraktion, lone band) -> 4 Kanten * 6 = 24 Vertices.
|
||||||
|
assert_eq!(
|
||||||
|
verts.len() / CUT_LINE_FLOATS_PER_VERTEX,
|
||||||
|
24,
|
||||||
|
"4 Kanten * 6 Vertices je Ribbon"
|
||||||
|
);
|
||||||
|
// Farbe je Vertex aus dem Stil (Index 3/4/5).
|
||||||
|
let n = verts.len() / CUT_LINE_FLOATS_PER_VERTEX;
|
||||||
|
for i in 0..n {
|
||||||
|
let base = i * CUT_LINE_FLOATS_PER_VERTEX;
|
||||||
|
assert!((verts[base + 3] - color[0]).abs() < 1e-6);
|
||||||
|
assert!((verts[base + 4] - color[1]).abs() < 1e-6);
|
||||||
|
assert!((verts[base + 5] - color[2]).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strichstaerke kommt durch: eine DICKERE Linie erzeugt ein breiteres Ribbon.
|
||||||
|
// Messbar an der (u,v)-Ausdehnung senkrecht zur Kante — hier ueber die
|
||||||
|
// world-Bounding-Box-Hoehe der beiden horizontalen Kanten (v-Ausdehnung der
|
||||||
|
// gesamten Geometrie) im Vergleich duenn vs. dick.
|
||||||
|
let bbox_y = |vs: &[f32]| -> f32 {
|
||||||
|
let n = vs.len() / CUT_LINE_FLOATS_PER_VERTEX;
|
||||||
|
let mut lo = f32::INFINITY;
|
||||||
|
let mut hi = f32::NEG_INFINITY;
|
||||||
|
for i in 0..n {
|
||||||
|
let y = vs[i * CUT_LINE_FLOATS_PER_VERTEX + 1];
|
||||||
|
lo = lo.min(y);
|
||||||
|
hi = hi.max(y);
|
||||||
|
}
|
||||||
|
hi - lo
|
||||||
|
};
|
||||||
|
let mut thin = wall.clone();
|
||||||
|
thin.cut = Some(CutBandMeta {
|
||||||
|
join_priority: Some(50),
|
||||||
|
component_id: Some("brick".into()),
|
||||||
|
line_color: Some(color),
|
||||||
|
line_weight: Some(0.2),
|
||||||
|
});
|
||||||
|
let thin_verts = build_cut_cap_lines(&plane, &[thin], &[]);
|
||||||
|
assert!(
|
||||||
|
bbox_y(&verts) > bbox_y(&thin_verts) + 1e-4,
|
||||||
|
"dickere Strichstaerke -> groessere v-Ausdehnung der Ribbons"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,15 +144,20 @@ struct Globals {
|
|||||||
};
|
};
|
||||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||||
|
|
||||||
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
|
// Material-Textur-ARRAY + Sampler (group 1). Sampler mit Repeat/Linear (siehe
|
||||||
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
|
// gpu.rs) -> das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
|
||||||
@group(1) @binding(0) var tex : texture_2d<f32>;
|
// Ebene 0 = Fallback-Schachbrett; Ebenen 1..N = per `set_material_textures`
|
||||||
|
// hochgeladene Material-Farbkarten. Die Ebene je Vertex kommt aus einem ZWEITEN
|
||||||
|
// Vertex-Buffer (@location(3)); `mode.z` traegt die Anzahl gueltiger Ebenen.
|
||||||
|
@group(1) @binding(0) var tex : texture_2d_array<f32>;
|
||||||
@group(1) @binding(1) var samp : sampler;
|
@group(1) @binding(1) var samp : sampler;
|
||||||
|
|
||||||
struct VsIn {
|
struct VsIn {
|
||||||
@location(0) position : vec3<f32>,
|
@location(0) position : vec3<f32>,
|
||||||
@location(1) normal : vec3<f32>,
|
@location(1) normal : vec3<f32>,
|
||||||
@location(2) uv : vec2<f32>,
|
@location(2) uv : vec2<f32>,
|
||||||
|
// Material-Textur-Ebene dieses Vertex (`-1` = kein Material -> Schachbrett).
|
||||||
|
@location(3) layer : f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct VsOut {
|
struct VsOut {
|
||||||
@@ -160,6 +165,7 @@ struct VsOut {
|
|||||||
@location(0) world_normal : vec3<f32>,
|
@location(0) world_normal : vec3<f32>,
|
||||||
@location(1) uv : vec2<f32>,
|
@location(1) uv : vec2<f32>,
|
||||||
@location(2) world_pos : vec3<f32>,
|
@location(2) world_pos : vec3<f32>,
|
||||||
|
@location(3) @interpolate(flat) layer : f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
@@ -169,6 +175,7 @@ fn vs_main(in : VsIn) -> VsOut {
|
|||||||
out.world_normal = in.normal;
|
out.world_normal = in.normal;
|
||||||
out.uv = in.uv;
|
out.uv = in.uv;
|
||||||
out.world_pos = in.position;
|
out.world_pos = in.position;
|
||||||
|
out.layer = in.layer;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +202,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
|||||||
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
|
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
|
||||||
* vec3<f32>(0.24, 0.27, 0.30);
|
* vec3<f32>(0.24, 0.27, 0.30);
|
||||||
|
|
||||||
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
|
// Albedo aus dem Material-Textur-Array statt Vertexfarbe. Ebene je Vertex:
|
||||||
let albedo = textureSample(tex, samp, in.uv).rgb;
|
// `layer < 0` (kein Material) -> Ebene 0 (Schachbrett). Sonst auf den
|
||||||
|
// gueltigen Bereich [0, mode.z-1] geklemmt (Sicherheit gegen ueberzaehlige
|
||||||
|
// Indizes, falls die Material-Menge kleiner ist als erwartet).
|
||||||
|
let max_layer = max(i32(globals.mode.z) - 1, 0);
|
||||||
|
let li = clamp(i32(round(in.layer)), 0, max_layer);
|
||||||
|
let albedo = textureSample(tex, samp, in.uv, li).rgb;
|
||||||
|
|
||||||
var shaded = albedo * (ambient + diffuse + fill);
|
var shaded = albedo * (ambient + diffuse + fill);
|
||||||
|
|
||||||
@@ -261,11 +273,16 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
|||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
|
/// WGSL der Schnittflaechen-Kappen (Cut-Caps, section_fill.rs / gpu::cap_pipeline).
|
||||||
/// Eigene TriangleList-Pipeline: schlankes Vertex-Layout [pos vec3, uv vec2],
|
/// Eigene TriangleList-Pipeline: Vertex-Layout [pos vec3, uv vec2, hatch vec3],
|
||||||
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
|
/// dieselbe `Globals`-Bind-Group (group(0) binding(0)) wie Mesh/Grid. Die Caps
|
||||||
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: prozedurale
|
/// werden NICHT geclippt (sie SIND die Schnittflaeche). Fragment: PROZEDURALE
|
||||||
/// 45-Grad-Diagonalschraffur aus den Ebenen-Koordinaten (u,v) in Modell-Metern —
|
/// Bauteil-Schraffur je geschnittener Schicht — Muster/Winkel/Skalierung kommen aus
|
||||||
/// dunkle Tinte auf hellem Papier, passend zur 2D-Schnittkonvention.
|
/// dem `hatch`-Vertex-Attribut (pattern, angle_rad, scale; siehe
|
||||||
|
/// `section_fill.rs`/`types::Hatch`). MONOCHROM (dunkle Tinte auf hellem Papier,
|
||||||
|
/// passend zur 2D-Schnittkonvention `HATCH_INK`): es variiert das MUSTER, nicht die
|
||||||
|
/// Farbe. Muster-Ids: 0 = none (leer/weiss), 1 = solid (Vollton),
|
||||||
|
/// 2 = diagonal (Linienschar, `angle` steuert die Neigung — 0° = horizontal, wie
|
||||||
|
/// im 2D), 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung).
|
||||||
pub const CAP_WGSL: &str = r#"
|
pub const CAP_WGSL: &str = r#"
|
||||||
struct Globals {
|
struct Globals {
|
||||||
view_proj : mat4x4<f32>,
|
view_proj : mat4x4<f32>,
|
||||||
@@ -281,11 +298,18 @@ struct Globals {
|
|||||||
struct VsIn {
|
struct VsIn {
|
||||||
@location(0) position : vec3<f32>,
|
@location(0) position : vec3<f32>,
|
||||||
@location(1) uv : vec2<f32>,
|
@location(1) uv : vec2<f32>,
|
||||||
|
// hatch: x = Muster-Id, y = Winkel (Radiant), z = Skalierung, w = Musterlinien-
|
||||||
|
// Staerke (mm-Papier). Je Cut-Polygon konstant (redundant je Vertex, siehe
|
||||||
|
// section_fill.rs).
|
||||||
|
@location(2) hatch : vec4<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct VsOut {
|
struct VsOut {
|
||||||
@builtin(position) clip_pos : vec4<f32>,
|
@builtin(position) clip_pos : vec4<f32>,
|
||||||
@location(0) uv : vec2<f32>,
|
@location(0) uv : vec2<f32>,
|
||||||
|
// flat: die Muster-Parameter sind je Flaeche konstant und duerfen nicht
|
||||||
|
// interpoliert werden (Muster-Id ist eine Ganzzahl-Auswahl).
|
||||||
|
@location(1) @interpolate(flat) hatch : vec4<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
@@ -293,27 +317,85 @@ fn vs_main(in : VsIn) -> VsOut {
|
|||||||
var out : VsOut;
|
var out : VsOut;
|
||||||
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||||
out.uv = in.uv;
|
out.uv = in.uv;
|
||||||
|
out.hatch = in.hatch;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein AA-gekantetes Linienband entlang der Koordinate `s` (in Einheiten von
|
||||||
|
// `spacing`): 1 auf der Linie, 0 dazwischen. `fwidth` glaettet blickwinkelrichtig.
|
||||||
|
fn line_band(s : f32, half_s : f32) -> f32 {
|
||||||
|
let frac = fract(s);
|
||||||
|
let dist = min(frac, 1.0 - frac);
|
||||||
|
let aa = max(fwidth(s), 1e-5);
|
||||||
|
return 1.0 - smoothstep(half_s, half_s + aa, dist);
|
||||||
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||||
// 45-Grad-Diagonalschraffur in Modell-Metern: Linien bei ganzzahligen
|
|
||||||
// Vielfachen von `spacing` entlang (u + v). `dist` = Abstand (in Einheiten
|
|
||||||
// von `spacing`) zur naechsten Linie; `fwidth` liefert eine bildschirm-
|
|
||||||
// groessenrichtige Kantenglaettung (kein Aliasing bei flachem Blickwinkel).
|
|
||||||
let spacing = 0.08; // Linienabstand ~8 cm
|
|
||||||
let half = 0.004; // halbe Linienbreite ~4 mm (Strich ~8 mm)
|
|
||||||
let ink = vec3<f32>(0.06, 0.06, 0.06);
|
let ink = vec3<f32>(0.06, 0.06, 0.06);
|
||||||
let paper = vec3<f32>(0.94, 0.94, 0.94);
|
let paper = vec3<f32>(0.94, 0.94, 0.94);
|
||||||
|
|
||||||
let s = (in.uv.x + in.uv.y) / spacing;
|
let pattern = i32(round(in.hatch.x));
|
||||||
let frac = fract(s);
|
let angle = in.hatch.y;
|
||||||
let dist = min(frac, 1.0 - frac); // 0 auf der Linie
|
let scale = max(in.hatch.z, 0.05);
|
||||||
let half_s = half / spacing;
|
|
||||||
let aa = max(fwidth(s), 1e-5);
|
// none (0): leere (weisse) Schnittflaeche — wie „keine Schraffur" im 2D.
|
||||||
let line = 1.0 - smoothstep(half_s, half_s + aa, dist);
|
if (pattern == 0) {
|
||||||
let color = mix(paper, ink, line);
|
return vec4<f32>(paper, 1.0);
|
||||||
|
}
|
||||||
|
// solid (1): Vollton (Poché) — z. B. Beton.
|
||||||
|
if (pattern == 1) {
|
||||||
|
return vec4<f32>(ink, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muster-Koordinaten: (u,v) in Modell-Metern, um `angle` gedreht. Konvention
|
||||||
|
// wie die 2D-Schraffur (`hatchPreview.parallelLines`): `angle == 0` ergibt
|
||||||
|
// HORIZONTALE Linien (Phasen-Achse = dv), `+angle` dreht die Schar gegen den
|
||||||
|
// Uhrzeigersinn wie im 2D-Swatch. `du` laeuft ENTLANG der Linienrichtung, `dv`
|
||||||
|
// ist die Quer-/Phasen-Achse. (Frueher lag hier ein fixer 45°-Versatz, weil das
|
||||||
|
// Diagonalmuster ueber `du+dv` lief — dadurch war alles 45° verdreht.)
|
||||||
|
let ca = cos(angle);
|
||||||
|
let sa = sin(angle);
|
||||||
|
let du = in.uv.x * ca + in.uv.y * sa; // entlang der Linienrichtung
|
||||||
|
let dv = -in.uv.x * sa + in.uv.y * ca; // quer (Phase)
|
||||||
|
|
||||||
|
let spacing = 0.08 * scale; // Grund-Linienabstand ~8 cm * Skalierung
|
||||||
|
// Musterlinien-Staerke PRO Schraffur aus `hatch.w` (mm-Papier, aus dem LineStyle
|
||||||
|
// des Hatch — wie die 2D-Schnitt-Schraffur: SIA `hatch-hair` 0.02 mm vs. `thin`
|
||||||
|
// 0.13 mm). Dieselbe mm→Welt-Kalibrierung wie die Schicht-Trennlinien
|
||||||
|
// (`section_fill::CUT_LINE_MM_TO_WORLD` = 0.01, halbe Breite ×0.5 → Faktor 0.005),
|
||||||
|
// damit Schraffur- und Trennlinien-Staerke konsistent skalieren. Kleiner Floor,
|
||||||
|
// damit eine Haarlinie nicht ganz verschwindet.
|
||||||
|
let half = max(in.hatch.w * 0.005, 0.0001);
|
||||||
|
let half_s = half / spacing;
|
||||||
|
|
||||||
|
var line = 0.0;
|
||||||
|
if (pattern == 2) {
|
||||||
|
// diagonal: EINE Linienschar quer zur Phasen-Achse (dv). angle==0 →
|
||||||
|
// horizontal; `hatch.angle` (z. B. 45° bei Backstein) dreht sie wie im 2D.
|
||||||
|
line = line_band(dv / spacing, half_s);
|
||||||
|
} else if (pattern == 3) {
|
||||||
|
// crosshatch: zwei Scharen ueber Kreuz (Phase dv + Phase du = +90°). Bei
|
||||||
|
// angle==0 horizontal+vertikal, bei 45° (Beton) das gekippte Kreuz — wie 2D.
|
||||||
|
line = max(
|
||||||
|
line_band(dv / spacing, half_s),
|
||||||
|
line_band(du / spacing, half_s)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// insulation (4, Daemmung): Zickzack. Die Quer-Position (dv) folgt einer
|
||||||
|
// Dreieckswelle entlang der Lauf-Achse (du) — ein durchgehender Zickzack.
|
||||||
|
let cell = spacing * 2.0;
|
||||||
|
let a = du / cell;
|
||||||
|
let b = dv / cell;
|
||||||
|
let tri = abs(fract(a) - 0.5) * 2.0; // 0..1 Dreieckswelle
|
||||||
|
let fb = fract(b);
|
||||||
|
let d = min(abs(fb - tri), min(abs(fb - tri + 1.0), abs(fb - tri - 1.0)));
|
||||||
|
let aa = max(fwidth(fb) + fwidth(tri), 1e-5);
|
||||||
|
let hw = half / cell;
|
||||||
|
line = 1.0 - smoothstep(hw, hw + aa, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = mix(paper, ink, clamp(line, 0.0, 1.0));
|
||||||
return vec4<f32>(color, 1.0);
|
return vec4<f32>(color, 1.0);
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -20,6 +20,79 @@ pub type Point2 = [f32; 2];
|
|||||||
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
|
/// RGB-Farbe/Albedo, Komponenten in [0,1]. Alpha wird (noch) nicht gebraucht.
|
||||||
pub type Rgb = [f32; 3];
|
pub type Rgb = [f32; 3];
|
||||||
|
|
||||||
|
/// Schnitt-Schraffur eines Bauteils (fuer den 3D-Live-Schnitt). Rust-seitiges
|
||||||
|
/// Minimal-Aequivalent zu `HatchStyle` (src/model/types.ts): nur die drei Felder,
|
||||||
|
/// die der prozedurale Cap-Shader (`shaders::CAP_WGSL`) auswertet — Muster-Id,
|
||||||
|
/// Winkel und Skalierung. Die Muster-Farbe bleibt MONOCHROM (Tinte auf Papier,
|
||||||
|
/// siehe 2D-Direktive `HATCH_INK`) und wandert deshalb bewusst NICHT mit.
|
||||||
|
///
|
||||||
|
/// `pattern`: 0 = none (leer/weiss), 1 = solid (Vollton), 2 = diagonal (45°),
|
||||||
|
/// 3 = crosshatch (Kreuz), 4 = insulation (Zickzack/Daemmung). Die Zuordnung ist
|
||||||
|
/// mit der TS-Seite (`toWalls3d::hatchPatternId`) und dem Shader synchron zu halten.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct Hatch {
|
||||||
|
/// Muster-Id (siehe oben). Default 0 (none).
|
||||||
|
#[serde(default)]
|
||||||
|
pub pattern: u32,
|
||||||
|
/// Musterdrehung in Grad (Bildschirm-/Schnittebenen-Konvention). Default 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub angle: f32,
|
||||||
|
/// Grundmassstab (1 = Standardteilung). Default 1.
|
||||||
|
#[serde(default = "default_hatch_scale")]
|
||||||
|
pub scale: f32,
|
||||||
|
/// Strichstaerke der Musterlinien in mm-Papier (aus dem LineStyle des Hatch,
|
||||||
|
/// `HatchRender.lineWeight`). Der Cap-Shader (`CAP_WGSL`) setzt die Muster-
|
||||||
|
/// linienbreite daraus (mm->Welt wie die Trennlinien), sodass die Schraffur-
|
||||||
|
/// Staerke pro Schraffur einstellbar ist (z. B. SIA `hatch-hair` 0.02 mm vs.
|
||||||
|
/// `thin` 0.13 mm). Default 0.13 (Alt-Verhalten/Haarlinie).
|
||||||
|
#[serde(default = "default_hatch_line_weight", rename = "lineWeight")]
|
||||||
|
pub line_weight: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_hatch_scale() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_hatch_line_weight() -> f32 {
|
||||||
|
0.13
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verschneidungs-/Stil-Metadaten EINES geschnittenen Bauteil-Bandes fuer den
|
||||||
|
/// 3D-Live-Schnitt — das Rust-Gegenstueck zu den Feldern, mit denen `toSection.ts`
|
||||||
|
/// die Schnitt-Boolean-Dominanz (`subtractDominantBands`) rechnet. Rein additiv
|
||||||
|
/// (`#[serde(default)]` an jedem Feld); fehlt der ganze Block (`WallInput::cut ==
|
||||||
|
/// None`), verhaelt sich der Schnitt wie zuvor (Band nimmt an keiner Subtraktion
|
||||||
|
/// teil).
|
||||||
|
///
|
||||||
|
/// Da der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`/`SlabInput`-Box
|
||||||
|
/// emittiert (`toWalls3d`, layered), traegt jede geschnittene Schicht hier IHRE
|
||||||
|
/// eigene Prioritaet/Identitaet/Linienstil — die Dominanz-Verschneidung
|
||||||
|
/// (`section_boolean.rs`) laeuft dann elementuebergreifend auf den erzeugten
|
||||||
|
/// (u,v)-Rechtecken, exakt wie im 2D-Schnitt.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CutBandMeta {
|
||||||
|
/// `Component.joinPriority` der Schicht: ein Band mit STRIKT hoeherer
|
||||||
|
/// Prioritaet schneidet ein ueberlappendes schwaecheres per Rechteck-
|
||||||
|
/// Subtraktion weg (elementuebergreifend). `None` -> keine Teilnahme.
|
||||||
|
#[serde(default)]
|
||||||
|
pub join_priority: Option<i32>,
|
||||||
|
/// `Component.id` der Schicht — steuert die MERGE-Regel (gleiche Komponente +
|
||||||
|
/// Prioritaet verschmelzen zu einem Koerper, keine innere Trennlinie). `None`
|
||||||
|
/// -> keine Verschmelzung.
|
||||||
|
#[serde(default)]
|
||||||
|
pub component_id: Option<String>,
|
||||||
|
/// Farbe (RGB 0..1) der Schnitt-Umrisslinie dieses Bandes (P2, aus dem
|
||||||
|
/// `jointLineStyleId`-LineStyle). `None` -> Default-Schnittkanten-Stil.
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_color: Option<Rgb>,
|
||||||
|
/// Strichstaerke der Schnitt-Umrisslinie in mm-Papier (aus dem LineStyle).
|
||||||
|
/// `None` -> Default. Siehe `section_fill::build_cut_cap_lines` fuer die (ehrlich
|
||||||
|
/// dokumentierte) mm->Welt-Umsetzung.
|
||||||
|
#[serde(default)]
|
||||||
|
pub line_weight: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
|
/// Eine geflachte Wand: Achse (Mittellinie) Start->Ende im Grundriss, plus Dicke,
|
||||||
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
|
/// Hoehe und Basis-Hoehe. Aus diesen Feldern wird ein extrudiertes Quader-Mesh
|
||||||
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
|
/// erzeugt (Band aus Achse+Dicke, hochgezogen auf `height` ab `base_elevation`).
|
||||||
@@ -82,6 +155,30 @@ pub struct WallInput {
|
|||||||
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
|
/// extrudiert den vollen Achsen-Abschnitt mit Loechern.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub holes: Vec<Hole>,
|
pub holes: Vec<Hole>,
|
||||||
|
/// Optionaler Material-Textur-Index fuer den Stil `Textured`: verweist auf eine
|
||||||
|
/// per `Renderer::set_material_textures` hochgeladene Material-Farbkarte
|
||||||
|
/// (Textur-Array-Ebene, 1-basiert; Ebene 0 ist das Fallback-Schachbrett). Da
|
||||||
|
/// der 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
|
||||||
|
/// (`toWalls3d`, `layeredWalls:true`), traegt jede Schicht so IHRE Material-
|
||||||
|
/// Textur. `None` -> Fallback-Schachbrett (bisheriges Verhalten). Vom
|
||||||
|
/// Schnitt/Serde-Pfad (`section.rs`) ignoriert.
|
||||||
|
#[serde(default, rename = "materialIndex")]
|
||||||
|
pub material_index: Option<u32>,
|
||||||
|
/// Optionale Schnitt-Schraffur dieses Wand-Bandes (siehe `Hatch`). Nur der
|
||||||
|
/// 3D-Live-Schnitt (`section.rs`/`section_fill.rs`) wertet sie aus, um die
|
||||||
|
/// Schnittflaeche im MUSTER des Bauteils zu schraffieren (statt eines fixen
|
||||||
|
/// 45°-Musters). `None` -> Fallback-Diagonalmuster (Alt-Verhalten). Da der
|
||||||
|
/// 3D-Viewer-Pfad je Materiallage EINE eigene `WallInput`-Box emittiert
|
||||||
|
/// (`toWalls3d`, `layeredWalls:true`), traegt jede geschnittene Schicht so
|
||||||
|
/// ihre eigene Schraffur.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hatch: Option<Hatch>,
|
||||||
|
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieses Wand-Bandes (siehe
|
||||||
|
/// `CutBandMeta`). Nur der 3D-Live-Schnitt (`section_fill`/`section_boolean`)
|
||||||
|
/// wertet sie aus; fehlt sie, bleibt das Alt-Verhalten (keine Dominanz-
|
||||||
|
/// Subtraktion, Default-Schnittkante).
|
||||||
|
#[serde(default)]
|
||||||
|
pub cut: Option<CutBandMeta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
|
/// Ein rechteckiges Loch (Fenster/Tuer) in EINEM Wandkoerper. Siehe
|
||||||
@@ -162,6 +259,13 @@ pub struct SlabInput {
|
|||||||
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
|
/// Albedo-Farbe (RGB 0..1). Default heller Deckenton.
|
||||||
#[serde(default = "default_slab_color")]
|
#[serde(default = "default_slab_color")]
|
||||||
pub color: Rgb,
|
pub color: Rgb,
|
||||||
|
/// Optionale Schnitt-Schraffur der Decke (siehe `Hatch`/`WallInput::hatch`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub hatch: Option<Hatch>,
|
||||||
|
/// Schnitt-Verschneidungs-/Linienstil-Metadaten dieser Decken-Schicht (siehe
|
||||||
|
/// `CutBandMeta`/`WallInput::cut`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub cut: Option<CutBandMeta>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_slab_color() -> Rgb {
|
fn default_slab_color() -> Rgb {
|
||||||
@@ -179,6 +283,9 @@ pub enum MeshKind {
|
|||||||
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
|
/// Importiertes Volumen (Gebaeude/DXF) — neutrales Hellgrau.
|
||||||
#[default]
|
#[default]
|
||||||
Imported,
|
Imported,
|
||||||
|
/// Per truck (Profil-Extrusion, `src-tauri/trucksolid`) erzeugter Koerper —
|
||||||
|
/// warmes Orange, damit er sich klar von Wand/Decke/Kontext abhebt.
|
||||||
|
Extrusion,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MeshKind {
|
impl MeshKind {
|
||||||
@@ -191,6 +298,8 @@ impl MeshKind {
|
|||||||
MeshKind::Terrain => [0.663, 0.690, 0.635],
|
MeshKind::Terrain => [0.663, 0.690, 0.635],
|
||||||
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
|
// 0x9fa6ae — neutrales Hellgrau fuer importierte Meshes.
|
||||||
MeshKind::Imported => [0.624, 0.651, 0.682],
|
MeshKind::Imported => [0.624, 0.651, 0.682],
|
||||||
|
// Warmes Orange fuer truck-Profil-Extrusionen (Phase-1-Viewer-Beweis).
|
||||||
|
MeshKind::Extrusion => [0.85, 0.55, 0.25],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,14 +417,24 @@ impl Default for Camera {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die fuenf Kamera-Presets der three.js-Sicht. `Iso` und `Persp` sind
|
/// Die Kamera-Presets der Kardinal-/Iso-Sicht (ROADMAP §11). `Persp` ist frei
|
||||||
/// perspektivisch/frei; `Front`/`Top`/`Side` sind achsparallel (orthografisch).
|
/// perspektivisch; alle anderen sind achsparallel bzw. diagonal orthografisch.
|
||||||
|
/// `Front`/`Back`/`Side`/`Left` sind die vier Kardinalrichtungen (Vorne/Hinten/
|
||||||
|
/// Rechts/Links im UI, s. `View3d` in TopBar.tsx — keine Himmelsrichtungen, da
|
||||||
|
/// es noch keinen Nordwinkel gibt); `Iso`/`IsoFrontLeft`/`IsoBackRight`/
|
||||||
|
/// `IsoBackLeft` sind die vier OBEREN Iso-Oktanten (untere vier bleiben im
|
||||||
|
/// Hochbau ungenutzt) — `Iso` (vorne-oben-rechts) ist der bestehende Default.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum CameraPreset {
|
pub enum CameraPreset {
|
||||||
Front,
|
Front,
|
||||||
|
Back,
|
||||||
Top,
|
Top,
|
||||||
Side,
|
Side,
|
||||||
|
Left,
|
||||||
Iso,
|
Iso,
|
||||||
|
IsoFrontLeft,
|
||||||
|
IsoBackRight,
|
||||||
|
IsoBackLeft,
|
||||||
Persp,
|
Persp,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +464,14 @@ pub struct TexturedMesh {
|
|||||||
pub verts: Vec<f32>,
|
pub verts: Vec<f32>,
|
||||||
/// Dreiecks-Indizes (0-basiert auf die Vertices).
|
/// Dreiecks-Indizes (0-basiert auf die Vertices).
|
||||||
pub indices: Vec<u32>,
|
pub indices: Vec<u32>,
|
||||||
|
/// Optionaler PARALLELER Puffer: EIN f32 je Vertex = die Material-Textur-
|
||||||
|
/// Ebene (Textur-Array-Index) dieses Vertex, oder `-1` fuer „kein Material"
|
||||||
|
/// (Fallback-Schachbrett, Ebene 0). Leer, wenn ohne Material-Zuordnung gebaut
|
||||||
|
/// (`textured_from_mesh`) — dann bindet die GPU-Schicht einen konstanten
|
||||||
|
/// `-1`-Puffer. Als ZWEITER Vertex-Buffer gehalten (nicht in `verts`
|
||||||
|
/// interleaved), damit das bestehende `[pos,normal,uv]`-Layout (und die
|
||||||
|
/// Geometrie-Paritaets-Tests) bitgleich bleiben.
|
||||||
|
pub layers: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
|
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
|
||||||
|
|||||||
@@ -15,12 +15,13 @@
|
|||||||
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
|
// (eye/target/up + Projektionsart) entgegen — die freie Orbit/Pan/Zoom-Interaktion
|
||||||
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
|
// (Yaw/Pitch/Distanz) rechnet weiterhin die TS-Seite (`math::orbit_eye`-Konvention),
|
||||||
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
|
// damit die Maus-UX 1:1 der bestehenden three.js-OrbitControls-Bedienung folgt.
|
||||||
// `set_view_preset` wendet stattdessen eines der fuenf `View3d`-Praesete
|
// `set_view_preset` wendet stattdessen eines der `View3d`-Praesete (front/back/
|
||||||
// (front/top/side/iso/perspective) direkt ueber `math::preset_camera` an — Front/
|
// top/side/left/iso/isoFrontLeft/isoBackRight/isoBackLeft/perspective) direkt
|
||||||
// Top/Side/Iso werden dabei ECHT orthografisch (`preset_camera`; Iso = echte
|
// ueber `math::preset_camera` an — alle ausser Perspective werden dabei ECHT
|
||||||
// Isometrie ohne perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung
|
// orthografisch (`preset_camera`; die Iso-Varianten = echte Isometrie ohne
|
||||||
// nicht leisten kann. Nach einem Praeset-Sprung bleibt die
|
// perspektivische Verzerrung), was eine reine TS-Orbit-Naeherung nicht leisten
|
||||||
// weitere Navigation frei ueber `set_camera` (kein Lock).
|
// kann. Nach einem Praeset-Sprung bleibt die weitere Navigation frei ueber
|
||||||
|
// `set_camera` (kein Lock).
|
||||||
//
|
//
|
||||||
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
|
// MODELL-FORMAT: JSON-Struct { walls: WallInput[], slabs: SlabInput[] } — identisch
|
||||||
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
|
// zu `RModel3d` (src/plan/toWalls3d.ts) und zum nativen Tauri-Push (nativeSync.ts).
|
||||||
@@ -330,19 +331,25 @@ impl WebModelRenderer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wendet eines der fuenf Kamera-Praesets der three.js-Sicht
|
/// Wendet eines der Kardinal-/Iso-Kamera-Praesets der three.js-Sicht
|
||||||
/// (`front`/`top`/`side`/`iso`/`perspective`, s. `View3d` in TopBar.tsx) auf
|
/// (`front`/`back`/`top`/`side`/`left`/`iso`/`isoFrontLeft`/`isoBackRight`/
|
||||||
/// das aktuelle Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend
|
/// `isoBackLeft`/`perspective`, s. `View3d` in TopBar.tsx) auf das aktuelle
|
||||||
/// (mirror der TS-`fitTargetDist`); Front/Top/Side/Iso werden orthografisch,
|
/// Modell an: Ziel = Modell-Mitte, Distanz = formatfuellend (mirror der
|
||||||
|
/// TS-`fitTargetDist`); alle Presets ausser Perspective werden orthografisch,
|
||||||
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
|
/// nur Perspective ist perspektivisch (`math::preset_camera`). Wirkt erst beim
|
||||||
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
|
/// naechsten `render`. „Praeset = hinspringen, kein Lock" — manuelles
|
||||||
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
|
/// Orbit/Pan/Zoom bleibt danach frei ueber `set_camera` (s. Wasm3DViewport.tsx).
|
||||||
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
|
pub fn set_view_preset(&mut self, preset: &str) -> Result<(), JsValue> {
|
||||||
let preset = match preset {
|
let preset = match preset {
|
||||||
"front" => CameraPreset::Front,
|
"front" => CameraPreset::Front,
|
||||||
|
"back" => CameraPreset::Back,
|
||||||
"top" => CameraPreset::Top,
|
"top" => CameraPreset::Top,
|
||||||
"side" => CameraPreset::Side,
|
"side" => CameraPreset::Side,
|
||||||
|
"left" => CameraPreset::Left,
|
||||||
"iso" => CameraPreset::Iso,
|
"iso" => CameraPreset::Iso,
|
||||||
|
"isoFrontLeft" => CameraPreset::IsoFrontLeft,
|
||||||
|
"isoBackRight" => CameraPreset::IsoBackRight,
|
||||||
|
"isoBackLeft" => CameraPreset::IsoBackLeft,
|
||||||
"perspective" => CameraPreset::Persp,
|
"perspective" => CameraPreset::Persp,
|
||||||
other => {
|
other => {
|
||||||
return Err(JsValue::from_str(&format!(
|
return Err(JsValue::from_str(&format!(
|
||||||
@@ -377,11 +384,14 @@ impl WebModelRenderer {
|
|||||||
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
|
/// - `"shaded"` — beleuchtete Bauteilfarben (Default).
|
||||||
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
|
/// - `"white"` — Clay-Look: einheitlich helles Material, beleuchtet
|
||||||
/// (identisch zum bisherigen `set_render_mode_white(true)`).
|
/// (identisch zum bisherigen `set_render_mode_white(true)`).
|
||||||
/// - `"textured"` — mangels Textur-Pipeline VORERST identisch zu `"shaded"`
|
/// - `"textured"` — Material-Farbkarten je Wand-Band aus dem Textur-Array
|
||||||
/// behandelt (KEIN Fake-Stub); Platzhalter fuer eine spaetere Textur-Schicht.
|
/// (`set_material_textures`), Fallback-Schachbrett fuer Baender ohne Material.
|
||||||
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
|
/// - `"wireframe"`— nur Modell-Kanten (Feature-Edges), keine Flaechen.
|
||||||
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
|
/// - `"hidden"` — Hidden-Line: flach-weisse Flaechen fuellen den Depth-
|
||||||
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
|
/// Buffer, dunkle Kanten liegen tiefengetestet obenauf (nur sichtbare).
|
||||||
|
/// - `"shaded-edges"` — "Schattiert mit Kanten" (typischer BIM-Look): wie
|
||||||
|
/// `"shaded"` (echte Bauteilfarben), ZUSAETZLICH dunkle Modell-Kanten
|
||||||
|
/// obenauf wie bei `"hidden"`.
|
||||||
/// Unbekannte Werte fallen auf `"shaded"` zurueck. Wirkt erst beim naechsten
|
/// Unbekannte Werte fallen auf `"shaded"` zurueck. Wirkt erst beim naechsten
|
||||||
/// `render`. Loest `set_render_mode_white` ab (das bleibt kompatibel).
|
/// `render`. Loest `set_render_mode_white` ab (das bleibt kompatibel).
|
||||||
pub fn set_render_style(&mut self, style: &str) -> Result<(), JsValue> {
|
pub fn set_render_style(&mut self, style: &str) -> Result<(), JsValue> {
|
||||||
@@ -431,6 +441,20 @@ impl WebModelRenderer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Laedt die Material-Farbkarten fuer den Stil `Textured` als Textur-Array hoch
|
||||||
|
/// (`Renderer::set_material_textures`). `rgba` = dicht gepackte RGBA8-Bytes von
|
||||||
|
/// `layer_count` Ebenen, JEDE exakt 256×256 (der JS-Aufrufer dekodiert/skaliert
|
||||||
|
/// die Material-Bilder, siehe `viewport/materialTextures.ts`). Ebene 0 bleibt
|
||||||
|
/// intern das Fallback-Schachbrett; die hochgeladenen Karten belegen die Ebenen
|
||||||
|
/// 1..N, auf die `WallInput::materialIndex` (1-basiert) zeigt. `layer_count == 0`
|
||||||
|
/// oder zu kleine Daten setzen auf das reine Schachbrett zurueck. Wirkt erst beim
|
||||||
|
/// naechsten `render`.
|
||||||
|
pub fn set_material_textures(&mut self, rgba: &[u8], layer_count: u32) -> Result<(), JsValue> {
|
||||||
|
self.renderer
|
||||||
|
.set_material_textures(&self.device, &self.queue, rgba, layer_count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
/// Surface an eine neue Pixelgroesse anpassen (DPR beachtet der Aufrufer).
|
||||||
pub fn resize(&mut self, width: u32, height: u32) {
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
let (w, h) = (width.max(1), height.max(1));
|
let (w, h) = (width.max(1), height.max(1));
|
||||||
|
|||||||
+43
-3
@@ -6,6 +6,32 @@
|
|||||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||||
mod native;
|
mod native;
|
||||||
|
|
||||||
|
// Lock-Datei gegen gleichzeitiges Oeffnen derselben Projektdatei aus zwei
|
||||||
|
// App-Instanzen (OS-Advisory-Lock via `fs4`, siehe lock.rs).
|
||||||
|
mod lock;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Fordert den exklusiven Lock fuer eine Projektdatei an. `Ok(())` wenn frei
|
||||||
|
/// (oder schon von uns gehalten), sonst `Err(Some(LockInfo))` mit Angaben zur
|
||||||
|
/// haltenden Instanz (pid/hostname/timestamp), oder `Err(None)` falls die
|
||||||
|
/// Sidecar-Datei gesperrt, aber ihr Info-Block nicht lesbar war.
|
||||||
|
#[tauri::command]
|
||||||
|
fn acquire_project_lock(
|
||||||
|
path: String,
|
||||||
|
state: State<lock::LockState>,
|
||||||
|
) -> Result<(), Option<lock::LockInfo>> {
|
||||||
|
lock::acquire(&state, &PathBuf::from(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt einen von dieser Instanz gehaltenen Lock frei. No-op, wenn der Pfad
|
||||||
|
/// nicht gelockt war.
|
||||||
|
#[tauri::command]
|
||||||
|
fn release_project_lock(path: String, state: State<lock::LockState>) {
|
||||||
|
lock::release(&state, &PathBuf::from(path));
|
||||||
|
}
|
||||||
|
|
||||||
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
|
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn compute_joins(
|
async fn compute_joins(
|
||||||
@@ -40,7 +66,15 @@ fn push_native_walls(walls: serde_json::Value) {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = tauri::Builder::default().setup(|_app| {
|
let builder = tauri::Builder::default()
|
||||||
|
// Native „Speichern unter"-Dialog + Datei-Schreiben aus der Webview.
|
||||||
|
// Immer aktiv (unabhaengig von native2d/native3d), damit Datei-Exporte
|
||||||
|
// im Tauri-Fenster einen echten Save-Dialog zeigen statt still zu laden.
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
// Haelt die pro Instanz offen gelockten Projektdatei-Handles (lock.rs).
|
||||||
|
.manage(lock::LockState::default())
|
||||||
|
.setup(|_app| {
|
||||||
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
|
// Native GPU-Fenster (2D und/oder 3D je nach Feature) auf einem eigenen
|
||||||
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
|
// Thread hochfahren, damit der Tauri-/GTK-Hauptthread frei bleibt.
|
||||||
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
#[cfg(any(feature = "native2d", feature = "native3d"))]
|
||||||
@@ -52,10 +86,16 @@ pub fn run() {
|
|||||||
let builder = builder.invoke_handler(tauri::generate_handler![
|
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||||
compute_joins,
|
compute_joins,
|
||||||
push_native_scene,
|
push_native_scene,
|
||||||
push_native_walls
|
push_native_walls,
|
||||||
|
acquire_project_lock,
|
||||||
|
release_project_lock
|
||||||
]);
|
]);
|
||||||
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
|
#[cfg(not(any(feature = "native2d", feature = "native3d")))]
|
||||||
let builder = builder.invoke_handler(tauri::generate_handler![compute_joins]);
|
let builder = builder.invoke_handler(tauri::generate_handler![
|
||||||
|
compute_joins,
|
||||||
|
acquire_project_lock,
|
||||||
|
release_project_lock
|
||||||
|
]);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -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,
|
color: grey,
|
||||||
openings: vec![],
|
openings: vec![],
|
||||||
layers: None,
|
layers: None,
|
||||||
|
// Additive Felder (Loecher/Material/Schnitt) — die Demo-Waende nutzen sie
|
||||||
|
// nicht; Defaults wie im Serde-Pfad.
|
||||||
|
holes: vec![],
|
||||||
|
material_index: None,
|
||||||
|
hatch: None,
|
||||||
|
cut: None,
|
||||||
};
|
};
|
||||||
vec![
|
vec![
|
||||||
mk([0.0, 0.0], [6.0, 0.0]),
|
mk([0.0, 0.0], [6.0, 0.0]),
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2073
-142
File diff suppressed because it is too large
Load Diff
@@ -73,14 +73,14 @@ function circlePts(center: Vec2, r: number): Vec2[] {
|
|||||||
return pts;
|
return pts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
|
/** Vorschau (offener Bogen) + HUD (Radius als L, Spanne als W — L/W-Kästchen). */
|
||||||
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
|
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
|
||||||
if (r < EPS) return { preview: [], vertices: [center] };
|
if (r < EPS) return { preview: [], vertices: [center] };
|
||||||
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
||||||
const draft: ToolDraft = { preview, vertices: [center] };
|
const draft: ToolDraft = { preview, vertices: [center] };
|
||||||
if (at) {
|
if (at) {
|
||||||
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
||||||
draft.hud = { at, text: `${deg.toFixed(0)}°` };
|
draft.hud = { at, length: r, angleDeg: deg };
|
||||||
}
|
}
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ export const arcCommand: Command = {
|
|||||||
draft: {
|
draft: {
|
||||||
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
||||||
vertices: [s.center],
|
vertices: [s.center],
|
||||||
hud: { at: point, text: `r ${r.toFixed(2)} m` },
|
hud: { at: point, text: `R: ${r.toFixed(3)}m` },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Deckenloch — Aussparung (Treppenauge/Schacht) als Engine-Befehl: ein RECHTECK
|
||||||
|
// wird aufgezogen (zwei Klicks: erste Ecke → Gegenecke) und der Decke des
|
||||||
|
// aktiven Geschosses hinzugefügt, die das Rechteck ENTHÄLT (alle vier Ecken
|
||||||
|
// innerhalb des Decken-Umrisses). Gerendert wird das Loch in 2D (Poché-Loch +
|
||||||
|
// Randlinien) und 3D (Loch im Slab), s. Ceiling.openings/mergeHoles.
|
||||||
|
//
|
||||||
|
// Schritte:
|
||||||
|
// 1) „Erste Ecke der Aussparung:" → Punkt
|
||||||
|
// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet.
|
||||||
|
|
||||||
|
import { pointInOutline } from "../../geometry/ceiling";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */
|
||||||
|
function rectCorners(a: Vec2, b: Vec2): Vec2[] {
|
||||||
|
const x0 = Math.min(a.x, b.x);
|
||||||
|
const x1 = Math.max(a.x, b.x);
|
||||||
|
const y0 = Math.min(a.y, b.y);
|
||||||
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
return [
|
||||||
|
{ x: x0, y: y0 },
|
||||||
|
{ x: x1, y: y0 },
|
||||||
|
{ x: x1, y: y1 },
|
||||||
|
{ x: x0, y: y1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoleIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
interface HoleRect extends CommandState {
|
||||||
|
phase: "corner";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type HoleState = HoleIdle | HoleRect;
|
||||||
|
|
||||||
|
/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */
|
||||||
|
function holeDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
|
const pts = cursor ? rectCorners(a, cursor) : [a];
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }];
|
||||||
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
|
if (cursor) {
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt die Aussparung der ERSTEN Decke des aktiven Geschosses hinzu, die das
|
||||||
|
* Rechteck vollständig enthält (alle 4 Ecken im Umriss). Entartete Rechtecke
|
||||||
|
* und Rechtecke ausserhalb jeder Decke werden verworfen (Projekt unverändert).
|
||||||
|
*/
|
||||||
|
function appendCeilingOpening(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
|
||||||
|
const w = Math.abs(b.x - a.x);
|
||||||
|
const d = Math.abs(b.y - a.y);
|
||||||
|
if (w < 0.05 || d < 0.05) return p;
|
||||||
|
const corners = rectCorners(a, b);
|
||||||
|
const host = (p.ceilings ?? []).find(
|
||||||
|
(c) =>
|
||||||
|
c.floorId === ctx.level.id &&
|
||||||
|
c.outline.length >= 3 &&
|
||||||
|
corners.every((pt) => pointInOutline(pt, c.outline)),
|
||||||
|
);
|
||||||
|
if (!host) return p;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
ceilings: (p.ceilings ?? []).map((c) =>
|
||||||
|
c.id === host.id ? { ...c, openings: [...(c.openings ?? []), corners] } : c,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null } as HoleIdle,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ceilingOpeningCommand: Command = {
|
||||||
|
name: "ceilingopening",
|
||||||
|
labelKey: "cmd.ceilingOpening.label",
|
||||||
|
floorOnly: true,
|
||||||
|
prompt: (s) =>
|
||||||
|
(s as HoleState).phase === "corner" ? "cmd.ceilingOpening.corner" : "cmd.ceilingOpening.start",
|
||||||
|
accepts: () => ["point"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): HoleIdle => ({ phase: "start", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as HoleState;
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [s, { draft: s.phase === "corner" ? holeDraft(s.a, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
if (s.phase !== "corner") {
|
||||||
|
const ns: HoleRect = {
|
||||||
|
phase: "corner",
|
||||||
|
a: input.point,
|
||||||
|
cursor: input.point,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [ns, { draft: holeDraft(input.point, input.point) }];
|
||||||
|
}
|
||||||
|
// Zweite Ecke → committen.
|
||||||
|
const a = s.a;
|
||||||
|
const b = input.point;
|
||||||
|
return [
|
||||||
|
{ phase: "start", lastPoint: null } as HoleIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendCeilingOpening(p, a, b, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as HoleState;
|
||||||
|
if (s.phase !== "corner") return [s, { draft: null }];
|
||||||
|
const ns: HoleRect = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: holeDraft(s.a, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -68,7 +68,7 @@ function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
|
|||||||
if (r < EPS) return { preview: [], vertices: [center] };
|
if (r < EPS) return { preview: [], vertices: [center] };
|
||||||
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
|
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
|
||||||
const draft: ToolDraft = { preview, vertices: [center] };
|
const draft: ToolDraft = { preview, vertices: [center] };
|
||||||
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
|
if (at) draft.hud = { at, text: `R: ${r.toFixed(3)}m` };
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
function hasSelection(sel: CommandSelection): boolean {
|
||||||
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
return (
|
||||||
|
sel.wallIds.length > 0 ||
|
||||||
|
sel.drawingId !== null ||
|
||||||
|
!!sel.extrudedSolidId ||
|
||||||
|
!!sel.columnId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||||
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
return {
|
||||||
|
wallIds: sel.wallIds,
|
||||||
|
drawingId: sel.drawingId,
|
||||||
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
|
columnId: sel.columnId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CopyBase extends CommandState {
|
interface CopyBase extends CommandState {
|
||||||
|
|||||||
@@ -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,58 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { georefCommand } from "./georef";
|
||||||
|
import type { CommandContext, Project, Vec2 } from "../types";
|
||||||
|
|
||||||
|
const ctx = {} as CommandContext;
|
||||||
|
const P = (x: number, y: number): Vec2 => ({ x, y });
|
||||||
|
|
||||||
|
function projectWith(wallStart: Vec2): Project {
|
||||||
|
return {
|
||||||
|
id: "p",
|
||||||
|
name: "T",
|
||||||
|
layers: [],
|
||||||
|
drawingLevels: [],
|
||||||
|
walls: [
|
||||||
|
{
|
||||||
|
id: "W1",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: wallStart,
|
||||||
|
end: { x: wallStart.x + 5, y: wallStart.y },
|
||||||
|
wallTypeId: "aw",
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
drawings2d: [],
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("georefCommand", () => {
|
||||||
|
it("Hover zeigt die resultierende Verschiebung im HUD (negativer Klickpunkt)", () => {
|
||||||
|
const s0 = georefCommand.init();
|
||||||
|
const [, r] = georefCommand.onMove(s0, P(120, 45), null, ctx);
|
||||||
|
expect(r.draft?.hud).toEqual({ at: P(120, 45), dx: -120, dy: -45 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Klick committet sofort: der geklickte Punkt wird zum neuen Ursprung", () => {
|
||||||
|
const s0 = georefCommand.init();
|
||||||
|
const [ns, r] = georefCommand.onInput(s0, { kind: "point", point: P(120, 45) }, ctx);
|
||||||
|
expect(r.done).toBe(true);
|
||||||
|
expect((ns as { phase: string }).phase).toBe("start");
|
||||||
|
const p = projectWith({ x: 120, y: 45 });
|
||||||
|
const out = r.commit!(p);
|
||||||
|
// Die Wand begann exakt am geklickten Punkt -> liegt danach auf (0,0).
|
||||||
|
expect(out.walls[0].start).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(out.walls[0].end).toEqual({ x: 5, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Esc/Rechtsklick auf leerem Zustand beendet ohne Commit", () => {
|
||||||
|
const s0 = georefCommand.init();
|
||||||
|
const [, r1] = georefCommand.onCancel(s0);
|
||||||
|
expect(r1.done).toBe(true);
|
||||||
|
expect(r1.commit).toBeUndefined();
|
||||||
|
const [, r2] = georefCommand.onConfirm(s0, ctx);
|
||||||
|
expect(r2.done).toBe(true);
|
||||||
|
expect(r2.commit).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Neuer Bezugspunkt — verschiebt das GESAMTE Projekt (Wände, Decken, Dächer,
|
||||||
|
// Treppen, Räume, Stützen, Extrusionen, freie 2D-Geometrie, Kontext-Schicht,
|
||||||
|
// Schnittlinien) so, dass der geklickte Punkt zum neuen Modell-Ursprung (0,0)
|
||||||
|
// wird. Praktisch nach einem Kataster-/3D-Import (Kontext-Geometrie landet
|
||||||
|
// dort, wo der gesuchte Standort war — kann weit vom bisherigen Ursprung
|
||||||
|
// liegen, s. `Project.geoAnchor`): man klickt den gewünschten Bezugspunkt
|
||||||
|
// (z. B. eine Parzellenecke aus dem Import) und zeichnet danach mit kleinen,
|
||||||
|
// bequemen Koordinaten weiter. `geoAnchor` wandert automatisch mit (reine
|
||||||
|
// Translation, s. `model/geoRebase.ts`), der reale LV95-Bezug bleibt exakt
|
||||||
|
// erhalten — beim Export ist die Verschiebung jederzeit rekonstruierbar.
|
||||||
|
//
|
||||||
|
// Ein-Klick-Befehl: der erste (einzige) Punkt committet sofort.
|
||||||
|
|
||||||
|
import { translateProject } from "../../model/geoRebase";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
interface GeorefStart extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: GeorefStart = { phase: "start", lastPoint: null };
|
||||||
|
const idle = (): [CommandState, CommandResult] => [{ ...IDLE }, { draft: null, done: true }];
|
||||||
|
|
||||||
|
/** HUD am Cursor: zeigt die resultierende Verschiebung, bevor geklickt wird. */
|
||||||
|
function draft(point: Vec2): ToolDraft {
|
||||||
|
return {
|
||||||
|
preview: [],
|
||||||
|
vertices: [],
|
||||||
|
hud: { at: point, dx: -point.x, dy: -point.y },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const georefCommand: Command = {
|
||||||
|
name: "georef",
|
||||||
|
labelKey: "cmd.georef.label",
|
||||||
|
prompt: () => "cmd.georef.prompt",
|
||||||
|
accepts: () => ["point"],
|
||||||
|
options: () => [],
|
||||||
|
init: (): GeorefStart => ({ ...IDLE }),
|
||||||
|
|
||||||
|
onInput: (state, input): [CommandState, CommandResult] => {
|
||||||
|
if (input.kind !== "point") return [state, { draft: null }];
|
||||||
|
const { x, y } = input.point;
|
||||||
|
return [
|
||||||
|
{ ...IDLE },
|
||||||
|
{
|
||||||
|
draft: null,
|
||||||
|
done: true,
|
||||||
|
commit: (p: Project) => translateProject(p, -x, -y),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => [state, { draft: draft(point) }],
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Unit-Tests für das Cursor-HUD (Länge/Winkel) des `line`-Befehls: nach dem
|
||||||
|
* Startpunkt muss `onMove` im Draft `hud.length`/`hud.angleDeg` mit den
|
||||||
|
* korrekten Werten liefern (VW-Stil L/W-Kästchen, PlanView rendert es).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { lineCommand } from "./line";
|
||||||
|
import type { CommandContext, Project, Vec2 } from "../types";
|
||||||
|
|
||||||
|
/** Minimalprojekt — nur die Felder, die der Befehl/Context anfassen. */
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [],
|
||||||
|
wallTypes: [],
|
||||||
|
drawingLevels: [
|
||||||
|
{
|
||||||
|
id: "eg",
|
||||||
|
name: "EG",
|
||||||
|
kind: "floor",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
floorHeight: 2.6,
|
||||||
|
cutHeight: 1.0,
|
||||||
|
baseElevation: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(lastPoint: Vec2 | null): CommandContext {
|
||||||
|
const p = project();
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint,
|
||||||
|
selection: { wallIds: [], drawingId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lineCommand — Cursor-HUD (Länge/Winkel)", () => {
|
||||||
|
it("liefert hud.length/angleDeg im onMove nach dem Startpunkt", () => {
|
||||||
|
const start: Vec2 = { x: 0, y: 0 };
|
||||||
|
const [afterStart] = lineCommand.onInput(
|
||||||
|
lineCommand.init(),
|
||||||
|
{ kind: "point", point: start },
|
||||||
|
makeCtx(null),
|
||||||
|
);
|
||||||
|
const [, res] = lineCommand.onMove(afterStart, { x: 3, y: 4 }, null, makeCtx(start));
|
||||||
|
expect(res.draft?.hud?.length).toBeCloseTo(5, 9);
|
||||||
|
expect(res.draft?.hud?.angleDeg).toBeCloseTo((Math.atan2(4, 3) * 180) / Math.PI, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert einen negativen Winkel (Bereich (−180,180]) für Segmente nach unten-links", () => {
|
||||||
|
const start: Vec2 = { x: 0, y: 0 };
|
||||||
|
const [afterStart] = lineCommand.onInput(
|
||||||
|
lineCommand.init(),
|
||||||
|
{ kind: "point", point: start },
|
||||||
|
makeCtx(null),
|
||||||
|
);
|
||||||
|
// Richtung (-1,-1) → -135° (wie im VW-Screenshot).
|
||||||
|
const [, res] = lineCommand.onMove(afterStart, { x: -2, y: -2 }, null, makeCtx(start));
|
||||||
|
expect(res.draft?.hud?.angleDeg).toBeCloseTo(-135, 9);
|
||||||
|
expect(res.draft?.hud?.length).toBeCloseTo(Math.hypot(2, 2), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt KEIN HUD vor dem Startpunkt (phase=start)", () => {
|
||||||
|
const [, res] = lineCommand.onMove(lineCommand.init(), { x: 1, y: 1 }, null, makeCtx(null));
|
||||||
|
expect(res.draft).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
|
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -61,14 +61,7 @@ function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
|||||||
const preview: DraftShape[] = [];
|
const preview: DraftShape[] = [];
|
||||||
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
|
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
|
||||||
const draft: ToolDraft = { preview, vertices: [a] };
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
if (cursor && segLen(a, cursor) >= EPS) {
|
if (cursor && segLen(a, cursor) >= EPS) draft.hud = segmentHud(a, cursor);
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
|
|
||||||
(segAngleDeg(a, cursor) + 360) % 360,
|
|
||||||
).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { measureCommand } from "./measure";
|
||||||
|
import type { CommandContext, Vec2 } from "../types";
|
||||||
|
|
||||||
|
// Der Kontext wird vom Mess-Werkzeug nicht gelesen (rein visuell) — Dummy genügt.
|
||||||
|
const ctx = {} as CommandContext;
|
||||||
|
const P = (x: number, y: number): Vec2 => ({ x, y });
|
||||||
|
|
||||||
|
describe("measureCommand", () => {
|
||||||
|
it("misst Segment + Winkel ab dem ersten Punkt", () => {
|
||||||
|
const s0 = measureCommand.init();
|
||||||
|
const [s1] = measureCommand.onInput(s0, { kind: "point", point: P(0, 0) }, ctx);
|
||||||
|
const [, r] = measureCommand.onMove(s1, P(3, 0), null, ctx);
|
||||||
|
expect(r.draft?.measure?.segment).toBeCloseTo(3, 6);
|
||||||
|
expect(r.draft?.measure?.angle).toBeCloseTo(0, 6);
|
||||||
|
expect(r.draft?.measure?.area).toBeNull();
|
||||||
|
expect(r.draft?.measure?.points).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summiert die Pfadlänge und zeigt die Fläche ab 3 Ecken", () => {
|
||||||
|
let s = measureCommand.init();
|
||||||
|
[s] = measureCommand.onInput(s, { kind: "point", point: P(0, 0) }, ctx);
|
||||||
|
[s] = measureCommand.onInput(s, { kind: "point", point: P(4, 0) }, ctx);
|
||||||
|
[s] = measureCommand.onInput(s, { kind: "point", point: P(4, 3) }, ctx);
|
||||||
|
// Cursor zurück Richtung Ursprung → geschlossenes Dreieck 4×3/2 = 6 m².
|
||||||
|
const [, r] = measureCommand.onMove(s, P(0, 0), null, ctx);
|
||||||
|
expect(r.draft?.measure?.total).toBeCloseTo(4 + 3 + 5, 6); // 0→4→3→Hypotenuse 5
|
||||||
|
expect(r.draft?.measure?.area).toBeCloseTo(6, 6);
|
||||||
|
expect(r.draft?.measure?.points).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rechtsklick beendet den Pfad und armiert einen neuen (kein done)", () => {
|
||||||
|
let s = measureCommand.init();
|
||||||
|
[s] = measureCommand.onInput(s, { kind: "point", point: P(1, 1) }, ctx);
|
||||||
|
const [ns, r] = measureCommand.onConfirm(s, ctx);
|
||||||
|
expect(r.done).not.toBe(true);
|
||||||
|
expect((ns as { phase: string }).phase).toBe("start");
|
||||||
|
expect(r.draft).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rechtsklick auf leerem Pfad beendet das Werkzeug", () => {
|
||||||
|
const s = measureCommand.init();
|
||||||
|
const [, r] = measureCommand.onConfirm(s, ctx);
|
||||||
|
expect(r.done).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Messen — schnelles, unverbindliches Mess-Werkzeug (kein Element wird erzeugt).
|
||||||
|
// Ein Mess-Pfad ist ein Polygonzug: jeder Klick setzt einen weiteren Stützpunkt.
|
||||||
|
// • 1 Punkt gesetzt → Live-Strecke (Länge · Winkel) zum Cursor.
|
||||||
|
// • ≥ 2 Punkte gesetzt → zusätzlich aufsummierte Pfadlänge; sobald der Zug
|
||||||
|
// (inkl. Cursor) ≥ 3 Ecken hat, zeigt er auch die umschlossene FLÄCHE.
|
||||||
|
// Rechtsklick/Enter beendet den aktuellen Pfad und armiert einen NEUEN — so misst
|
||||||
|
// man mehrere nacheinander. Ein weiterer Rechtsklick auf leerem Pfad (bzw. Esc)
|
||||||
|
// beendet das Werkzeug. Rein visuell: `commit` bleibt immer leer.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
import type { MeasurementReadout } from "../../tools/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;
|
||||||
|
|
||||||
|
/** Aufsummierte Länge eines Polygonzugs (offene Kette). */
|
||||||
|
function pathLength(pts: Vec2[]): number {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) sum += segLen(pts[i - 1], pts[i]);
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorzeichenlose Fläche (Gauss/Schuhband), Zug als geschlossen gedacht. */
|
||||||
|
function polygonArea(pts: Vec2[]): number {
|
||||||
|
if (pts.length < 3) return 0;
|
||||||
|
let a = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const p = pts[i];
|
||||||
|
const q = pts[(i + 1) % pts.length];
|
||||||
|
a += p.x * q.y - q.x * p.y;
|
||||||
|
}
|
||||||
|
return Math.abs(a) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasureStart extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
}
|
||||||
|
interface MeasureDraw extends CommandState {
|
||||||
|
phase: "draw";
|
||||||
|
/** Bereits gesetzte Stützpunkte (≥ 1). */
|
||||||
|
points: Vec2[];
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
}
|
||||||
|
type MeasureState = MeasureStart | MeasureDraw;
|
||||||
|
|
||||||
|
/** Messwerte aus gesetzten Punkten + aktuellem Cursor bilden. */
|
||||||
|
function readout(points: Vec2[], cursor: Vec2 | null): MeasurementReadout {
|
||||||
|
const chain = cursor ? [...points, cursor] : points;
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
const segment = cursor && last ? segLen(last, cursor) : 0;
|
||||||
|
const angle = cursor && last ? ((segAngleDeg(last, cursor) % 360) + 360) % 360 : 0;
|
||||||
|
return {
|
||||||
|
segment,
|
||||||
|
total: pathLength(chain),
|
||||||
|
area: chain.length >= 3 ? polygonArea(chain) : null,
|
||||||
|
angle,
|
||||||
|
points: points.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: gesetzter Zug + Live-Segment zum Cursor + HUD + Messwerte. */
|
||||||
|
function measureDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||||
|
const chain = cursor ? [...points, cursor] : points;
|
||||||
|
const r = readout(points, cursor);
|
||||||
|
const hudText =
|
||||||
|
r.area != null
|
||||||
|
? `${r.segment.toFixed(3)} m · Σ ${r.total.toFixed(3)} m · ${r.area.toFixed(2)} m²`
|
||||||
|
: `${r.segment.toFixed(3)} m · ${r.angle.toFixed(1)}°`;
|
||||||
|
return {
|
||||||
|
preview: [{ kind: "poly", pts: chain, closed: chain.length >= 3 }],
|
||||||
|
vertices: points,
|
||||||
|
hud: cursor ? { at: cursor, text: hudText } : undefined,
|
||||||
|
measure: r,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === "draw" ? "cmd.measure.next" : "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") {
|
||||||
|
const draft =
|
||||||
|
s.phase === "draw" ? measureDraft(s.points, s.cursor) : null;
|
||||||
|
return [s, { draft }];
|
||||||
|
}
|
||||||
|
// Klick hängt einen Stützpunkt an den Pfad (bzw. beginnt einen neuen).
|
||||||
|
const points = s.phase === "draw" ? [...s.points, input.point] : [input.point];
|
||||||
|
const ns: MeasureDraw = {
|
||||||
|
phase: "draw",
|
||||||
|
points,
|
||||||
|
cursor: input.point,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [ns, { draft: measureDraft(points, input.point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as MeasureState;
|
||||||
|
if (s.phase !== "draw") return [s, { draft: null }];
|
||||||
|
const ns: MeasureDraw = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: measureDraft(s.points, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rechtsklick/Enter: laufenden Pfad beenden und einen NEUEN armieren (mehrere
|
||||||
|
// Messungen nacheinander). Auf leerem Pfad = Werkzeug beenden.
|
||||||
|
onConfirm: (state): [CommandState, CommandResult] => {
|
||||||
|
const s = state as MeasureState;
|
||||||
|
if (s.phase === "draw" && s.points.length > 0) {
|
||||||
|
return [{ phase: "start", lastPoint: null } as MeasureStart, { draft: null }];
|
||||||
|
}
|
||||||
|
return idle();
|
||||||
|
},
|
||||||
|
// Esc: Werkzeug ganz beenden.
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -32,12 +32,22 @@ const EPS = 1e-6;
|
|||||||
|
|
||||||
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
|
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
|
||||||
function hasSelection(sel: CommandSelection): boolean {
|
function hasSelection(sel: CommandSelection): boolean {
|
||||||
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
return (
|
||||||
|
sel.wallIds.length > 0 ||
|
||||||
|
sel.drawingId !== null ||
|
||||||
|
!!sel.extrudedSolidId ||
|
||||||
|
!!sel.columnId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auswahl → TransformSelection (gleiche Form). */
|
/** Auswahl → TransformSelection (gleiche Form). */
|
||||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||||
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
return {
|
||||||
|
wallIds: sel.wallIds,
|
||||||
|
drawingId: sel.drawingId,
|
||||||
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
|
columnId: sel.columnId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
|
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
|
||||||
|
|||||||
@@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
|
|||||||
|
|
||||||
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
|
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
|
||||||
function hasSelection(sel: CommandSelection): boolean {
|
function hasSelection(sel: CommandSelection): boolean {
|
||||||
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
return (
|
||||||
|
sel.wallIds.length > 0 ||
|
||||||
|
sel.drawingId !== null ||
|
||||||
|
!!sel.extrudedSolidId ||
|
||||||
|
!!sel.columnId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auswahl → TransformSelection (gleiche Form). */
|
/** Auswahl → TransformSelection (gleiche Form). */
|
||||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||||
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
return {
|
||||||
|
wallIds: sel.wallIds,
|
||||||
|
drawingId: sel.drawingId,
|
||||||
|
extrudedSolidId: sel.extrudedSolidId,
|
||||||
|
columnId: sel.columnId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
|
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
|
||||||
|
|||||||
@@ -187,6 +187,12 @@ function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
|
|||||||
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
|
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
|
||||||
const width = Math.max(0.1, Math.min(s.width, len));
|
const width = Math.max(0.1, Math.min(s.width, len));
|
||||||
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
|
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
|
||||||
|
// Standard-Bauteiltyp zuweisen (erster Tür-/Fenstertyp der Bibliothek), damit
|
||||||
|
// frisch platzierte Öffnungen SOFORT die volle Rahmen-/Laibungs-Darstellung
|
||||||
|
// (2D + 3D) bekommen — ohne Typ blieben sie ein blosses Loch mit flacher
|
||||||
|
// Scheibe. Fehlt eine Bibliothek, bleibt es beim Inline-Verhalten (kein Typ).
|
||||||
|
const typeId =
|
||||||
|
s.kind === "door" ? ctx.project.doorTypes?.[0]?.id : ctx.project.windowTypes?.[0]?.id;
|
||||||
const opening: Opening = {
|
const opening: Opening = {
|
||||||
id: uniqueId("O"),
|
id: uniqueId("O"),
|
||||||
type: "opening",
|
type: "opening",
|
||||||
@@ -197,6 +203,7 @@ function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
|
|||||||
width,
|
width,
|
||||||
height: s.height,
|
height: s.height,
|
||||||
sillHeight: s.kind === "door" ? 0 : s.sill,
|
sillHeight: s.kind === "door" ? 0 : s.sill,
|
||||||
|
...(typeId ? { typeId } : {}),
|
||||||
...(s.kind === "door"
|
...(s.kind === "door"
|
||||||
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
|
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
|
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -74,14 +74,7 @@ function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDra
|
|||||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||||
const draft: ToolDraft = { preview, vertices: points };
|
const draft: ToolDraft = { preview, vertices: points };
|
||||||
const last = points[points.length - 1];
|
const last = points[points.length - 1];
|
||||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
|
||||||
(segAngleDeg(last, cursor) + 360) % 360,
|
|
||||||
).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-19
@@ -17,7 +17,7 @@
|
|||||||
// werden (die Engine löst `r…` relativ zu lastPoint auf).
|
// werden (die Engine löst `r…` relativ zu lastPoint auf).
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { deltaHud, segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -196,51 +196,47 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
|||||||
|
|
||||||
// ── Vorschau ─────────────────────────────────────────────────────────────────
|
// ── Vorschau ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
|
function corners4Draft(pts: Vec2[], hud: ToolDraft["hud"] | null): ToolDraft {
|
||||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
||||||
const draft: ToolDraft = { preview, vertices: [pts[0]] };
|
const draft: ToolDraft = { preview, vertices: [pts[0]] };
|
||||||
if (hudAt) draft.hud = { at: hudAt, text: hudText };
|
if (hud) draft.hud = hud;
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
|
/** Vorschau des 2-Punkt-Rechtecks (achsparallel) — HUD zeigt die Diagonale a→cursor. */
|
||||||
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor) return { preview: [], vertices: [a] };
|
if (!cursor) return { preview: [], vertices: [a] };
|
||||||
const g = rectGeom(a, cursor);
|
const g = rectGeom(a, cursor);
|
||||||
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
||||||
const w = g.max.x - g.min.x;
|
return corners4Draft(pts, segLen(a, cursor) >= EPS ? deltaHud(a, cursor) : null);
|
||||||
const h = g.max.y - g.min.y;
|
|
||||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
|
/** Vorschau des Zentrum-Rechtecks (achsparallel) — HUD zeigt die Diagonale center→cursor. */
|
||||||
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
|
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor) return { preview: [], vertices: [center] };
|
if (!cursor) return { preview: [], vertices: [center] };
|
||||||
const g = centerGeom(center, cursor);
|
const g = centerGeom(center, cursor);
|
||||||
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
||||||
const w = g.max.x - g.min.x;
|
return corners4Draft(
|
||||||
const h = g.max.y - g.min.y;
|
pts,
|
||||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
segLen(center, cursor) >= EPS
|
||||||
|
? { at: cursor, dx: 2 * (cursor.x - center.x), dy: 2 * (cursor.y - center.y) }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
|
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
|
||||||
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
|
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
|
||||||
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
|
return { preview: [{ kind: "line", a, b: cursor }], vertices: [a], hud: segmentHud(a, cursor) };
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
return draft;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
|
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil) — HUD: Basisbreite + Höhe am Cursor. */
|
||||||
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
|
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
|
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
|
||||||
const pts = rotatedCorners(a, b, cursor);
|
const pts = rotatedCorners(a, b, cursor);
|
||||||
const w = segLen(a, b);
|
const w = segLen(a, b);
|
||||||
const h = Math.abs(riseFrom(a, b, cursor));
|
const h = Math.abs(riseFrom(a, b, cursor));
|
||||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
return corners4Draft(pts, { at: cursor, text: `B: ${w.toFixed(3)}m H: ${h.toFixed(3)}m` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Commit ───────────────────────────────────────────────────────────────────
|
// ── Commit ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Roof — Dach als Engine-Befehl: der Grundriss wird als RECHTECK aufgezogen
|
||||||
|
// (zwei Klicks: erste Ecke → Gegenecke), committet ein `Roof`-Bauteil. Die
|
||||||
|
// Dachform/Neigung wählt man nach dem Platzieren im Attribut-Panel; Default ist
|
||||||
|
// ein Satteldach mit First entlang der LÄNGEREN Rechteckseite.
|
||||||
|
//
|
||||||
|
// Schritte:
|
||||||
|
// 1) „Erste Dachecke:" → Punkt
|
||||||
|
// 2) „Gegenecke:" → Live-Rechteck-Vorschau; Klick committet das Dach.
|
||||||
|
//
|
||||||
|
// Herkunft der Dach-Felder (CommandContext):
|
||||||
|
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||||
|
// • categoryCode← "35" (Ebene „Dächer"; Fallback ctx.defaultCategoryCode)
|
||||||
|
|
||||||
|
import type { Roof, RoofShape } from "../../model/types";
|
||||||
|
import { uniqueId } from "../../tools/types";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandContext,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** Default-Ebene (Kategorie) für Dächer („31 Dächer"). */
|
||||||
|
const ROOF_CATEGORY = "31";
|
||||||
|
|
||||||
|
/** Wählbare Dachformen (Inline-Optionen der Befehlszeile). */
|
||||||
|
const SHAPE_OPTIONS: CmdOption[] = [
|
||||||
|
{ id: "sattel", labelKey: "cmd.roof.shape.sattel" },
|
||||||
|
{ id: "walm", labelKey: "cmd.roof.shape.walm" },
|
||||||
|
{ id: "pult", labelKey: "cmd.roof.shape.pult" },
|
||||||
|
{ id: "mansarde", labelKey: "cmd.roof.shape.mansarde" },
|
||||||
|
{ id: "zelt", labelKey: "cmd.roof.shape.zelt" },
|
||||||
|
{ id: "flach", labelKey: "cmd.roof.shape.flach" },
|
||||||
|
];
|
||||||
|
const SHAPE_IDS = new Set(SHAPE_OPTIONS.map((o) => o.id));
|
||||||
|
|
||||||
|
/** Existiert die Dach-Kategorie („35")? Sonst Fallback auf die aktive Kategorie. */
|
||||||
|
function roofCategory(ctx: CommandContext): string {
|
||||||
|
const flat: string[] = [];
|
||||||
|
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||||
|
for (const c of list) {
|
||||||
|
flat.push(c.code);
|
||||||
|
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||||
|
return flat.includes(ROOF_CATEGORY) ? ROOF_CATEGORY : ctx.defaultCategoryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vier Eckpunkte eines Rechtecks aus zwei Gegenecken (CCW). */
|
||||||
|
function rectCorners(a: Vec2, b: Vec2): Vec2[] {
|
||||||
|
const x0 = Math.min(a.x, b.x);
|
||||||
|
const x1 = Math.max(a.x, b.x);
|
||||||
|
const y0 = Math.min(a.y, b.y);
|
||||||
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
return [
|
||||||
|
{ x: x0, y: y0 },
|
||||||
|
{ x: x1, y: y0 },
|
||||||
|
{ x: x1, y: y1 },
|
||||||
|
{ x: x0, y: y1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoofIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
shape: RoofShape;
|
||||||
|
}
|
||||||
|
interface RoofRect extends CommandState {
|
||||||
|
phase: "corner";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
shape: RoofShape;
|
||||||
|
}
|
||||||
|
type RoofState = RoofIdle | RoofRect;
|
||||||
|
|
||||||
|
/** Vorschau: aufgezogenes Rechteck + Masse im HUD. */
|
||||||
|
function roofDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
|
const pts = cursor ? rectCorners(a, cursor) : [a];
|
||||||
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: !!cursor }];
|
||||||
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
|
if (cursor) {
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${Math.abs(cursor.x - a.x).toFixed(2)} × ${Math.abs(cursor.y - a.y).toFixed(2)} m`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hängt ein Dach ans Projekt (immutabel). Entartete Rechtecke werden verworfen. */
|
||||||
|
function appendRoof(p: Project, a: Vec2, b: Vec2, shape: RoofShape, ctx: CommandContext): Project {
|
||||||
|
const w = Math.abs(b.x - a.x);
|
||||||
|
const d = Math.abs(b.y - a.y);
|
||||||
|
if (w < 0.1 || d < 0.1) return p;
|
||||||
|
const roof: Roof = {
|
||||||
|
id: uniqueId("RF"),
|
||||||
|
type: "roof",
|
||||||
|
floorId: ctx.level.id,
|
||||||
|
categoryCode: roofCategory(ctx),
|
||||||
|
outline: rectCorners(a, b),
|
||||||
|
shape,
|
||||||
|
// Flach/Pult brauchen keine „Firsthöhe"; Sattel/Walm/Mansarde/Zelt ~30°.
|
||||||
|
pitchDeg: shape === "flach" ? 0 : 30,
|
||||||
|
overhang: 0.4,
|
||||||
|
// First entlang der längeren Seite (Standard).
|
||||||
|
ridgeAxis: w >= d ? "x" : "y",
|
||||||
|
thickness: 0.2,
|
||||||
|
};
|
||||||
|
const roofs = p.roofs ? [...p.roofs, roof] : [roof];
|
||||||
|
return { ...p, roofs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = (shape: RoofShape = "sattel"): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", shape, lastPoint: null } as RoofIdle,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const roofCommand: Command = {
|
||||||
|
name: "roof",
|
||||||
|
labelKey: "cmd.roof.label",
|
||||||
|
floorOnly: true,
|
||||||
|
prompt: (s) => ((s as RoofState).phase === "corner" ? "cmd.roof.corner" : "cmd.roof.start"),
|
||||||
|
accepts: () => ["point", "option"],
|
||||||
|
// Dachform als Inline-Optionen — vor/während des Aufziehens wählbar.
|
||||||
|
options: () => SHAPE_OPTIONS,
|
||||||
|
init: (): RoofIdle => ({ phase: "start", shape: "sattel", lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
|
const s = state as RoofState;
|
||||||
|
// Dachform-Option: aktualisiert die Form, Phase/Rechteck bleiben.
|
||||||
|
if (input.kind === "option" && SHAPE_IDS.has(input.id)) {
|
||||||
|
const ns = { ...s, shape: input.id as RoofShape } as RoofState;
|
||||||
|
const draft = ns.phase === "corner" ? roofDraft(ns.a, ns.cursor) : null;
|
||||||
|
return [ns, { draft }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [s, { draft: s.phase === "corner" ? roofDraft(s.a, s.cursor) : null }];
|
||||||
|
}
|
||||||
|
if (s.phase !== "corner") {
|
||||||
|
const ns: RoofRect = {
|
||||||
|
phase: "corner",
|
||||||
|
a: input.point,
|
||||||
|
cursor: input.point,
|
||||||
|
shape: s.shape,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [ns, { draft: roofDraft(input.point, input.point) }];
|
||||||
|
}
|
||||||
|
// Zweite Ecke → committen.
|
||||||
|
const a = s.a;
|
||||||
|
const b = input.point;
|
||||||
|
const shape = s.shape;
|
||||||
|
return [
|
||||||
|
{ phase: "start", shape, lastPoint: null } as RoofIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => appendRoof(p, a, b, shape, ctx) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as RoofState;
|
||||||
|
if (s.phase !== "corner") return [s, { draft: null }];
|
||||||
|
const ns: RoofRect = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: roofDraft(s.a, point) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||||
|
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Schnittlinien-Befehl: der reine Datenpfad (commitLine/nextLevelName) — setzt
|
||||||
|
// die Grundriss-Schnittlinie auf die richtige Ziel-Ebene bzw. legt eine neue an.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { _test } from "./sectionline";
|
||||||
|
import type { DrawingLevel, Project } from "../../model/types";
|
||||||
|
|
||||||
|
const { commitLine, nextLevelName } = _test;
|
||||||
|
|
||||||
|
function projectWith(levels: DrawingLevel[]): Project {
|
||||||
|
return {
|
||||||
|
id: "p",
|
||||||
|
name: "T",
|
||||||
|
layers: [],
|
||||||
|
drawingLevels: levels,
|
||||||
|
walls: [],
|
||||||
|
drawings2d: [],
|
||||||
|
} as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = { x: 0, y: 0 };
|
||||||
|
const b = { x: 5, y: 0 };
|
||||||
|
|
||||||
|
describe("sectionline — commitLine", () => {
|
||||||
|
it("belegt die EINE Platzhalter-Schnittebene ohne Linie", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false },
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, b, "section");
|
||||||
|
const s = out.drawingLevels.find((z) => z.id === "s1")!;
|
||||||
|
expect(s.linePoints).toEqual([a, b]);
|
||||||
|
expect(s.directionSign).toBe(1);
|
||||||
|
// Keine neue Ebene angelegt.
|
||||||
|
expect(out.drawingLevels).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt eine NEUE Schnittebene an, wenn keine Platzhalter-Ebene existiert", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false },
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
name: "Schnitt A",
|
||||||
|
kind: "section",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 1, y: 1 }, { x: 2, y: 2 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, b, "section");
|
||||||
|
expect(out.drawingLevels).toHaveLength(3);
|
||||||
|
const created = out.drawingLevels[out.drawingLevels.length - 1];
|
||||||
|
expect(created.kind).toBe("section");
|
||||||
|
expect(created.linePoints).toEqual([a, b]);
|
||||||
|
// Zweite Ebene → Buchstabe B.
|
||||||
|
expect(created.name).toContain("B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt bei ZWEI Platzhaltern (mehrdeutig) eine neue Ebene an", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
{ id: "s2", name: "Schnitt B", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, b, "section");
|
||||||
|
expect(out.drawingLevels).toHaveLength(3);
|
||||||
|
// Die bestehenden Platzhalter bleiben unangetastet.
|
||||||
|
expect(out.drawingLevels[0].linePoints).toBeUndefined();
|
||||||
|
expect(out.drawingLevels[1].linePoints).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft entartete (zu kurze) Linien", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
const out = commitLine(p, a, { x: 0.02, y: 0 }, "section");
|
||||||
|
expect(out).toBe(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trennt Schnitt- und Ansichts-Ebenen (kind)", () => {
|
||||||
|
const p = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
// Ansichtslinie darf die Schnitt-Platzhalter-Ebene NICHT belegen.
|
||||||
|
const out = commitLine(p, a, b, "elevation");
|
||||||
|
expect(out.drawingLevels).toHaveLength(2);
|
||||||
|
expect(out.drawingLevels[0].linePoints).toBeUndefined();
|
||||||
|
expect(out.drawingLevels[1].kind).toBe("elevation");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sectionline — nextLevelName", () => {
|
||||||
|
it("zählt Buchstaben je Art hoch (A, B, C …)", () => {
|
||||||
|
const p0 = projectWith([]);
|
||||||
|
expect(nextLevelName(p0, "section")).toContain("A");
|
||||||
|
const p1 = projectWith([
|
||||||
|
{ id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false },
|
||||||
|
]);
|
||||||
|
expect(nextLevelName(p1, "section")).toContain("B");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Schnittlinie — setzt die Grundriss-Schnitt-/Ansichtslinie einer Zeichnungsebene
|
||||||
|
// (DrawingLevel, kind "section"/"elevation") per zwei Klicks (Start → Ende). Die
|
||||||
|
// Linie steuert `sectionPlaneFromLevel` (plan/toSection.ts): senkrecht auf ihr
|
||||||
|
// steht die Blickrichtung (directionSign, Voreinstellung +1).
|
||||||
|
//
|
||||||
|
// Schritte:
|
||||||
|
// 1) „Schnitt-Startpunkt:" → Punkt
|
||||||
|
// 2) „Schnitt-Endpunkt:" → Live-Linie + Richtungspfeil; Klick committet.
|
||||||
|
//
|
||||||
|
// Ziel-Ebene (im Commit bestimmt): existiert GENAU EINE Ebene der gewählten Art
|
||||||
|
// OHNE gesetzte Linie (frisch angelegter Platzhalter), wird deren Linie gesetzt;
|
||||||
|
// sonst wird eine NEUE Ebene angelegt (Name „Schnitt B/C…" bzw. „Ansicht …").
|
||||||
|
//
|
||||||
|
// Die Art (Schnitt/Ansicht) ist als Inline-Option umschaltbar — dieselbe Mechanik
|
||||||
|
// für beide, nur `kind` unterscheidet sich (Alias `viewline` startet mit
|
||||||
|
// „elevation"). Bezeichner englisch, sichtbarer Text via t().
|
||||||
|
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { DrawingLevel, DrawingLevelKind } from "../../model/types";
|
||||||
|
import { leftNormal, normalize, sub } from "../../model/geometry";
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
CommandResult,
|
||||||
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
|
DraftShape,
|
||||||
|
Project,
|
||||||
|
ToolDraft,
|
||||||
|
Vec2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/** Nur Schnitt/Ansicht tragen eine Linie. */
|
||||||
|
type LineKind = "section" | "elevation";
|
||||||
|
|
||||||
|
/** Wählbare Art (Inline-Optionen der Befehlszeile). */
|
||||||
|
const KIND_OPTIONS: CmdOption[] = [
|
||||||
|
{ id: "section", labelKey: "cmd.sectionline.kind.section" },
|
||||||
|
{ id: "elevation", labelKey: "cmd.sectionline.kind.elevation" },
|
||||||
|
];
|
||||||
|
const KIND_IDS = new Set(KIND_OPTIONS.map((o) => o.id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Richtungspfeil-Vorschau: kurze Linie vom Mittelpunkt der Schnittlinie entlang
|
||||||
|
* der Blicknormalen (leftNormal der Linie, per directionSign gedreht) plus zwei
|
||||||
|
* Flügel-Segmente — zeigt, wohin der Schnitt „blickt".
|
||||||
|
*/
|
||||||
|
function directionArrow(a: Vec2, b: Vec2, sign: 1 | -1): DraftShape[] {
|
||||||
|
const dir = sub(b, a);
|
||||||
|
const len = Math.hypot(dir.x, dir.y);
|
||||||
|
if (len < 1e-6) return [];
|
||||||
|
const u = normalize(dir);
|
||||||
|
const n = leftNormal(u); // (-uy, ux)
|
||||||
|
const nx = n.x * sign;
|
||||||
|
const ny = n.y * sign;
|
||||||
|
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||||
|
// Pfeillänge ~ min(1 m, Viertel der Linienlänge), damit die Vorschau lesbar bleibt.
|
||||||
|
const arrowLen = Math.min(1, len / 4);
|
||||||
|
const tip = { x: mid.x + nx * arrowLen, y: mid.y + ny * arrowLen };
|
||||||
|
const wing = arrowLen * 0.35;
|
||||||
|
// Flügel: vom Spitzenpunkt zurück, je halb entlang −u und +u geneigt.
|
||||||
|
const back = { x: tip.x - nx * wing * 2, y: tip.y - ny * wing * 2 };
|
||||||
|
const w1 = { x: back.x + u.x * wing, y: back.y + u.y * wing };
|
||||||
|
const w2 = { x: back.x - u.x * wing, y: back.y - u.y * wing };
|
||||||
|
return [
|
||||||
|
{ kind: "line", a: mid, b: tip },
|
||||||
|
{ kind: "line", a: tip, b: w1 },
|
||||||
|
{ kind: "line", a: tip, b: w2 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: Schnittlinie + Richtungspfeil + Länge im HUD. */
|
||||||
|
function lineDraft(a: Vec2, cursor: Vec2 | null, sign: 1 | -1): ToolDraft {
|
||||||
|
if (!cursor) return { preview: [{ kind: "poly", pts: [a] }], vertices: [a] };
|
||||||
|
const preview: DraftShape[] = [
|
||||||
|
{ kind: "line", a, b: cursor },
|
||||||
|
...directionArrow(a, cursor, sign),
|
||||||
|
];
|
||||||
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
|
draft.hud = {
|
||||||
|
at: cursor,
|
||||||
|
text: `${Math.hypot(cursor.x - a.x, cursor.y - a.y).toFixed(2)} m`,
|
||||||
|
};
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nächster freier Ebenen-Name der Art (A, B, C … über die Anzahl bestehender). */
|
||||||
|
function nextLevelName(p: Project, kind: LineKind): string {
|
||||||
|
const count = p.drawingLevels.filter((z) => z.kind === kind).length;
|
||||||
|
// Buchstaben-Suffix (A, B, …, Z, dann AA…) — klassische Schnitt-Benennung.
|
||||||
|
let n = count;
|
||||||
|
let letters = "";
|
||||||
|
do {
|
||||||
|
letters = String.fromCharCode(65 + (n % 26)) + letters;
|
||||||
|
n = Math.floor(n / 26) - 1;
|
||||||
|
} while (n >= 0);
|
||||||
|
const key = kind === "section" ? "cmd.sectionline.newSection" : "cmd.sectionline.newElevation";
|
||||||
|
return t(key, { letter: letters });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Schnittlinie [a, b] auf die Ziel-Ebene (immutabel). Existiert GENAU
|
||||||
|
* EINE Ebene der Art ohne Linie, wird sie belegt; sonst wird eine neue angelegt.
|
||||||
|
* Entartete (zu kurze) Linien werden verworfen.
|
||||||
|
*/
|
||||||
|
function commitLine(p: Project, a: Vec2, b: Vec2, kind: LineKind): Project {
|
||||||
|
if (Math.hypot(b.x - a.x, b.y - a.y) < 0.1) return p;
|
||||||
|
const line: [Vec2, Vec2] = [a, b];
|
||||||
|
const orphans = p.drawingLevels.filter((z) => z.kind === kind && !z.linePoints);
|
||||||
|
if (orphans.length === 1) {
|
||||||
|
const targetId = orphans[0].id;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
drawingLevels: p.drawingLevels.map((z) =>
|
||||||
|
z.id === targetId ? { ...z, linePoints: line, directionSign: z.directionSign ?? 1 } : z,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const level: DrawingLevel = {
|
||||||
|
id: `${kind}-${Date.now()}`,
|
||||||
|
name: nextLevelName(p, kind),
|
||||||
|
kind: kind as DrawingLevelKind,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: line,
|
||||||
|
directionSign: 1,
|
||||||
|
};
|
||||||
|
return { ...p, drawingLevels: [...p.drawingLevels, level] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineIdle extends CommandState {
|
||||||
|
phase: "start";
|
||||||
|
kind: LineKind;
|
||||||
|
}
|
||||||
|
interface LineEnd extends CommandState {
|
||||||
|
phase: "end";
|
||||||
|
a: Vec2;
|
||||||
|
cursor: Vec2 | null;
|
||||||
|
kind: LineKind;
|
||||||
|
}
|
||||||
|
type LineState = LineIdle | LineEnd;
|
||||||
|
|
||||||
|
const DIRECTION_SIGN: 1 = 1;
|
||||||
|
|
||||||
|
function makeCommand(name: string, startKind: LineKind): Command {
|
||||||
|
const idle = (kind: LineKind): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", kind, lastPoint: null } as LineIdle,
|
||||||
|
{ draft: null, done: true },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
labelKey: "cmd.sectionline.label",
|
||||||
|
// Nicht floorOnly: eine Schnittlinie zieht man IM Grundriss, wo Bauteile
|
||||||
|
// liegen — die aktive Ebene ist dann ein Geschoss. Aber der Befehl soll auch
|
||||||
|
// auf freien Zeichnungsebenen nutzbar bleiben; die Prüfung übernimmt der
|
||||||
|
// Grundriss-Kontext (keine harte Sperre nötig).
|
||||||
|
floorOnly: true,
|
||||||
|
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.sectionline.end" : "cmd.sectionline.start"),
|
||||||
|
accepts: () => ["point", "option"],
|
||||||
|
options: () => KIND_OPTIONS,
|
||||||
|
init: (): LineIdle => ({ phase: "start", kind: startKind, lastPoint: null }),
|
||||||
|
|
||||||
|
onInput: (state, input): [CommandState, CommandResult] => {
|
||||||
|
const s = state as LineState;
|
||||||
|
if (input.kind === "option" && KIND_IDS.has(input.id)) {
|
||||||
|
const ns = { ...s, kind: input.id as LineKind } as LineState;
|
||||||
|
const draft =
|
||||||
|
ns.phase === "end" ? lineDraft(ns.a, ns.cursor, DIRECTION_SIGN) : null;
|
||||||
|
return [ns, { draft }];
|
||||||
|
}
|
||||||
|
if (input.kind !== "point") {
|
||||||
|
return [
|
||||||
|
s,
|
||||||
|
{ draft: s.phase === "end" ? lineDraft(s.a, s.cursor, DIRECTION_SIGN) : null },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (s.phase !== "end") {
|
||||||
|
const ns: LineEnd = {
|
||||||
|
phase: "end",
|
||||||
|
a: input.point,
|
||||||
|
cursor: input.point,
|
||||||
|
kind: s.kind,
|
||||||
|
lastPoint: input.point,
|
||||||
|
};
|
||||||
|
return [ns, { draft: lineDraft(input.point, input.point, DIRECTION_SIGN) }];
|
||||||
|
}
|
||||||
|
const a = s.a;
|
||||||
|
const b = input.point;
|
||||||
|
const kind = s.kind;
|
||||||
|
return [
|
||||||
|
{ phase: "start", kind, lastPoint: null } as LineIdle,
|
||||||
|
{ draft: null, done: true, commit: (p) => commitLine(p, a, b, kind) },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
|
const s = state as LineState;
|
||||||
|
if (s.phase !== "end") return [s, { draft: null }];
|
||||||
|
const ns: LineEnd = { ...s, cursor: point };
|
||||||
|
return [ns, { draft: lineDraft(s.a, point, DIRECTION_SIGN) }];
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfirm: (state): [CommandState, CommandResult] => idle((state as LineState).kind),
|
||||||
|
onCancel: (state): [CommandState, CommandResult] => idle((state as LineState).kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schnittlinie (startet als „section"). */
|
||||||
|
export const sectionLineCommand: Command = makeCommand("sectionline", "section");
|
||||||
|
/** Ansichtslinie (startet als „elevation"; identische Mechanik). */
|
||||||
|
export const viewLineCommand: Command = makeCommand("viewline", "elevation");
|
||||||
|
|
||||||
|
// Für Tests: die reinen Datenpfad-Helfer.
|
||||||
|
export const _test = { commitLine, nextLevelName, directionArrow };
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Unit-Tests für das Cursor-HUD (Länge/Winkel) des `wall`-Befehls: nach dem
|
||||||
|
* Startpunkt muss `onMove` im Draft `hud.length`/`hud.angleDeg` relativ zum
|
||||||
|
* ZULETZT gesetzten Achspunkt liefern — auch nach mehreren Segmenten
|
||||||
|
* (Chaining), damit ein mehrteiliger Wandzug je Segment ein frisches HUD zeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { wallCommand } from "./wall";
|
||||||
|
import type { CommandContext, Project, Vec2 } from "../types";
|
||||||
|
|
||||||
|
function project(): Project {
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
|
||||||
|
wallTypes: [
|
||||||
|
{ id: "aw", name: "AW", 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: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(lastPoint: Vec2 | null): CommandContext {
|
||||||
|
const p = project();
|
||||||
|
return {
|
||||||
|
project: p,
|
||||||
|
level: p.drawingLevels[0],
|
||||||
|
defaultCategoryCode: "20",
|
||||||
|
activeLineStyleId: "solid",
|
||||||
|
activeWallTypeId: "aw",
|
||||||
|
lastPoint,
|
||||||
|
selection: { wallIds: [], drawingId: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("wallCommand — Cursor-HUD (Länge/Winkel)", () => {
|
||||||
|
it("liefert hud.length/angleDeg relativ zum Startpunkt", () => {
|
||||||
|
const start: Vec2 = { x: 0, y: 0 };
|
||||||
|
const [afterStart] = wallCommand.onInput(
|
||||||
|
wallCommand.init(),
|
||||||
|
{ kind: "point", point: start },
|
||||||
|
makeCtx(null),
|
||||||
|
);
|
||||||
|
const [, res] = wallCommand.onMove(afterStart, { x: 4, y: 0 }, null, makeCtx(start));
|
||||||
|
expect(res.draft?.hud?.length).toBeCloseTo(4, 9);
|
||||||
|
expect(res.draft?.hud?.angleDeg).toBeCloseTo(0, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HUD bezieht sich nach dem zweiten Punkt auf das NEUE (letzte) Segment", () => {
|
||||||
|
const p0: Vec2 = { x: 0, y: 0 };
|
||||||
|
const p1: Vec2 = { x: 4, y: 0 };
|
||||||
|
const [s1] = wallCommand.onInput(wallCommand.init(), { kind: "point", point: p0 }, makeCtx(null));
|
||||||
|
const [s2] = wallCommand.onInput(s1, { kind: "point", point: p1 }, makeCtx(p0));
|
||||||
|
// Nächstes Segment ab p1 nach oben (0,3) → Länge 3, Winkel 90°.
|
||||||
|
const [, res] = wallCommand.onMove(s2, { x: 4, y: 3 }, null, makeCtx(p1));
|
||||||
|
expect(res.draft?.hud?.length).toBeCloseTo(3, 9);
|
||||||
|
expect(res.draft?.hud?.angleDeg).toBeCloseTo(90, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt KEIN HUD vor dem Startpunkt (phase=start)", () => {
|
||||||
|
const [, res] = wallCommand.onMove(wallCommand.init(), { x: 1, y: 1 }, null, makeCtx(null));
|
||||||
|
expect(res.draft).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
import type { Wall, WallType } from "../../model/types";
|
import type { Wall, WallType } from "../../model/types";
|
||||||
import { wallTypeThickness } from "../../model/types";
|
import { wallTypeThickness } from "../../model/types";
|
||||||
import { wallCorners } from "../../model/geometry";
|
import { wallCorners } from "../../model/geometry";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -164,14 +164,7 @@ function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): Tool
|
|||||||
}
|
}
|
||||||
const draft: ToolDraft = { preview, vertices: points };
|
const draft: ToolDraft = { preview, vertices: points };
|
||||||
const last = points[points.length - 1];
|
const last = points[points.length - 1];
|
||||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
|
||||||
(segAngleDeg(last, cursor) + 360) % 360,
|
|
||||||
).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,14 @@ export class CommandEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Freitext-Schritt (z. B. Text-Platzierung): der Schritt nimmt beliebigen
|
||||||
|
// Text als Label — die komplette getippte Zeile geht 1:1 durch (auch Zahlen/
|
||||||
|
// Kommas), bevor die numerische/Koordinaten-Auflösung greift.
|
||||||
|
if (accepts.includes("text")) {
|
||||||
|
this.feed({ kind: "text", text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseInput(text);
|
const parsed = parseInput(text);
|
||||||
|
|
||||||
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
|
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
|
||||||
|
|||||||
@@ -10,18 +10,27 @@ import { wallCommand } from "./cmds/wall";
|
|||||||
import { ceilingCommand } from "./cmds/ceiling";
|
import { ceilingCommand } from "./cmds/ceiling";
|
||||||
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
|
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
|
||||||
import { stairCommand } from "./cmds/stair";
|
import { stairCommand } from "./cmds/stair";
|
||||||
|
import { columnCommand } from "./cmds/column";
|
||||||
|
import { roofCommand } from "./cmds/roof";
|
||||||
|
import { ceilingOpeningCommand } from "./cmds/ceilingOpening";
|
||||||
import { roomCommand } from "./cmds/room";
|
import { roomCommand } from "./cmds/room";
|
||||||
import { rectCommand } from "./cmds/rect";
|
import { rectCommand } from "./cmds/rect";
|
||||||
import { circleCommand } from "./cmds/circle";
|
import { circleCommand } from "./cmds/circle";
|
||||||
import { arcCommand } from "./cmds/arc";
|
import { arcCommand } from "./cmds/arc";
|
||||||
|
import { textCommand } from "./cmds/text";
|
||||||
|
import { textboxCommand } from "./cmds/textbox";
|
||||||
import { moveCommand } from "./cmds/move";
|
import { moveCommand } from "./cmds/move";
|
||||||
import { mirrorCommand } from "./cmds/mirror";
|
import { mirrorCommand } from "./cmds/mirror";
|
||||||
import { joinCommand } from "./cmds/join";
|
import { joinCommand } from "./cmds/join";
|
||||||
import { copyCommand } from "./cmds/copy";
|
import { copyCommand } from "./cmds/copy";
|
||||||
import { offsetCommand } from "./cmds/offset";
|
import { offsetCommand } from "./cmds/offset";
|
||||||
|
import { extrudeCommand } from "./cmds/extrude";
|
||||||
import { trimCommand } from "./cmds/trim";
|
import { trimCommand } from "./cmds/trim";
|
||||||
import { importCommand } from "./cmds/import";
|
import { importCommand } from "./cmds/import";
|
||||||
import { terrainCommand } from "./cmds/terrain";
|
import { terrainCommand } from "./cmds/terrain";
|
||||||
|
import { measureCommand } from "./cmds/measure";
|
||||||
|
import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline";
|
||||||
|
import { georefCommand } from "./cmds/georef";
|
||||||
|
|
||||||
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||||
export const COMMANDS: Record<string, Command> = {
|
export const COMMANDS: Record<string, Command> = {
|
||||||
@@ -31,20 +40,30 @@ export const COMMANDS: Record<string, Command> = {
|
|||||||
fenster: fensterCommand,
|
fenster: fensterCommand,
|
||||||
tuer: tuerCommand,
|
tuer: tuerCommand,
|
||||||
stair: stairCommand,
|
stair: stairCommand,
|
||||||
|
column: columnCommand,
|
||||||
|
roof: roofCommand,
|
||||||
|
ceilingopening: ceilingOpeningCommand,
|
||||||
room: roomCommand,
|
room: roomCommand,
|
||||||
line: lineCommand,
|
line: lineCommand,
|
||||||
polyline: polylineCommand,
|
polyline: polylineCommand,
|
||||||
rect: rectCommand,
|
rect: rectCommand,
|
||||||
circle: circleCommand,
|
circle: circleCommand,
|
||||||
arc: arcCommand,
|
arc: arcCommand,
|
||||||
|
text: textCommand,
|
||||||
|
textbox: textboxCommand,
|
||||||
move: moveCommand,
|
move: moveCommand,
|
||||||
mirror: mirrorCommand,
|
mirror: mirrorCommand,
|
||||||
join: joinCommand,
|
join: joinCommand,
|
||||||
copy: copyCommand,
|
copy: copyCommand,
|
||||||
offset: offsetCommand,
|
offset: offsetCommand,
|
||||||
|
extrude: extrudeCommand,
|
||||||
trim: trimCommand,
|
trim: trimCommand,
|
||||||
import: importCommand,
|
import: importCommand,
|
||||||
terrain: terrainCommand,
|
terrain: terrainCommand,
|
||||||
|
measure: measureCommand,
|
||||||
|
sectionline: sectionLineCommand,
|
||||||
|
viewline: viewLineCommand,
|
||||||
|
georef: georefCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +82,12 @@ export const ALIASES: Record<string, string> = {
|
|||||||
oe: "opening",
|
oe: "opening",
|
||||||
treppe: "stair",
|
treppe: "stair",
|
||||||
tp: "stair",
|
tp: "stair",
|
||||||
|
stuetze: "column",
|
||||||
|
stütze: "column",
|
||||||
|
dach: "roof",
|
||||||
|
deckenloch: "ceilingopening",
|
||||||
|
aussparung: "ceilingopening",
|
||||||
|
rf: "roof",
|
||||||
raum: "room",
|
raum: "room",
|
||||||
rm: "room",
|
rm: "room",
|
||||||
l: "line",
|
l: "line",
|
||||||
@@ -71,6 +96,9 @@ export const ALIASES: Record<string, string> = {
|
|||||||
c: "circle",
|
c: "circle",
|
||||||
a: "arc",
|
a: "arc",
|
||||||
bogen: "arc",
|
bogen: "arc",
|
||||||
|
tx: "text",
|
||||||
|
txt: "text",
|
||||||
|
beschriftung: "text",
|
||||||
m: "move",
|
m: "move",
|
||||||
s: "mirror",
|
s: "mirror",
|
||||||
spiegeln: "mirror",
|
spiegeln: "mirror",
|
||||||
@@ -78,9 +106,14 @@ export const ALIASES: Record<string, string> = {
|
|||||||
verbinden: "join",
|
verbinden: "join",
|
||||||
cp: "copy",
|
cp: "copy",
|
||||||
o: "offset",
|
o: "offset",
|
||||||
|
ex: "extrude",
|
||||||
tr: "trim",
|
tr: "trim",
|
||||||
imp: "import",
|
imp: "import",
|
||||||
ter: "terrain",
|
ter: "terrain",
|
||||||
|
schnittlinie: "sectionline",
|
||||||
|
ansichtslinie: "viewline",
|
||||||
|
bezugspunkt: "georef",
|
||||||
|
nullpunkt: "georef",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import type { DrawingLevel } from "../model/types";
|
|||||||
import type { SnapResult, ToolDraft } from "../tools/types";
|
import type { SnapResult, ToolDraft } from "../tools/types";
|
||||||
|
|
||||||
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
|
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
|
||||||
|
export { segmentHud, angleDegOf } from "../tools/types";
|
||||||
|
|
||||||
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
|
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Arten von Eingaben, die ein Schritt annehmen kann. */
|
/** Arten von Eingaben, die ein Schritt annehmen kann. */
|
||||||
export type AcceptKind = "point" | "number" | "option" | "selection";
|
export type AcceptKind = "point" | "number" | "option" | "text" | "selection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
|
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
|
||||||
@@ -67,6 +68,10 @@ export interface CommandSelection {
|
|||||||
* `drawingId` (falls gesetzt) als einziges Element.
|
* `drawingId` (falls gesetzt) als einziges Element.
|
||||||
*/
|
*/
|
||||||
drawingIds?: string[];
|
drawingIds?: string[];
|
||||||
|
/** Gewählter extrudierter Körper (truck-Integration), falls einer selektiert ist. */
|
||||||
|
extrudedSolidId?: string | null;
|
||||||
|
/** Gewählte Stütze (Tragwerk), falls eine selektiert ist. */
|
||||||
|
columnId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
|
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
|
||||||
|
|||||||
@@ -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_SYMBOLS = "SYMBOLS";
|
||||||
const LAYER_CONTEXT = "CONTEXT";
|
const LAYER_CONTEXT = "CONTEXT";
|
||||||
const LAYER_DEFAULT = "PLAN";
|
const LAYER_DEFAULT = "PLAN";
|
||||||
|
/** Sammel-Layer für Extrusions-Footprints (truck-Integration) — die Quell-
|
||||||
|
* Drawing2D wird beim Extrudieren entfernt, es gibt also keine categoryCode
|
||||||
|
* mehr nachzuschlagen; eigener Layer statt Absturz in LAYER_DEFAULT. */
|
||||||
|
const LAYER_EXTRUSION = "EXTRUSION";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
|
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
|
||||||
@@ -60,6 +64,8 @@ export function buildPlanDxf(
|
|||||||
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
|
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
|
||||||
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
|
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
|
||||||
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
|
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
|
||||||
|
// Warmes Orange, identisch zum 3D-/Grundriss-Footprint der Extrusionen (EXTRUSION_STROKE/EXTRUSION_RGB).
|
||||||
|
dxf.addLayer({ name: LAYER_EXTRUSION, color: aciFromHex("#d98c40"), trueColor: rgbFromHex("#d98c40"), lineWeight: 13 });
|
||||||
|
|
||||||
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
|
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
|
||||||
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
|
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
|
||||||
@@ -68,11 +74,15 @@ export function buildPlanDxf(
|
|||||||
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
|
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
|
||||||
const codeByDrawing = new Map<string, string>();
|
const codeByDrawing = new Map<string, string>();
|
||||||
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
|
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
|
||||||
|
const codeByColumn = new Map<string, string>();
|
||||||
|
for (const c of project.columns ?? []) codeByColumn.set(c.id, c.categoryCode);
|
||||||
|
|
||||||
const layerFor = (p: Primitive): string => {
|
const layerFor = (p: Primitive): string => {
|
||||||
if (p.kind === "polygon") {
|
if (p.kind === "polygon") {
|
||||||
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
|
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
|
||||||
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
|
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
|
||||||
|
if (p.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT;
|
||||||
|
if (p.extrudedSolidId) return LAYER_EXTRUSION;
|
||||||
return LAYER_DEFAULT;
|
return LAYER_DEFAULT;
|
||||||
}
|
}
|
||||||
if (p.kind === "line") {
|
if (p.kind === "line") {
|
||||||
|
|||||||
@@ -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).
|
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2).
|
||||||
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
|
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände, 1 Decke, 1 Tür,
|
||||||
|
// 1 Fenster, 1 Treppe, 1 Extrusion).
|
||||||
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
|
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
|
||||||
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
|
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
|
||||||
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
|
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
|
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
|
||||||
import type { Project, Wall, Ceiling } from "../model/types";
|
import type { Project, Wall, Ceiling, Door, Opening, Stair, ExtrudedSolid } from "../model/types";
|
||||||
|
|
||||||
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */
|
/**
|
||||||
|
* Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2) +
|
||||||
|
* 1 Tür + 1 Fenster (je an W1 gehostet) + 1 Treppe + 1 Extrusion.
|
||||||
|
*/
|
||||||
function fixtureProject(): Project {
|
function fixtureProject(): Project {
|
||||||
const walls: Wall[] = [
|
const walls: Wall[] = [
|
||||||
{
|
{
|
||||||
@@ -48,6 +52,61 @@ function fixtureProject(): Project {
|
|||||||
ceilingTypeId: "dt",
|
ceilingTypeId: "dt",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const doors: Door[] = [
|
||||||
|
{
|
||||||
|
id: "T1",
|
||||||
|
type: "door",
|
||||||
|
hostWallId: "W1",
|
||||||
|
categoryCode: "21",
|
||||||
|
position: 1,
|
||||||
|
width: 0.9,
|
||||||
|
height: 2.1, // Fläche 1.89
|
||||||
|
swing: "left",
|
||||||
|
hinge: "start",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const openings: Opening[] = [
|
||||||
|
{
|
||||||
|
id: "F1",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "W2",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
position: 1,
|
||||||
|
width: 1.2,
|
||||||
|
height: 1.5, // Fläche 1.80
|
||||||
|
sillHeight: 0.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const stairs: Stair[] = [
|
||||||
|
{
|
||||||
|
id: "S1",
|
||||||
|
type: "stair",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "40",
|
||||||
|
shape: "straight",
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
dir: { x: 1, y: 0 },
|
||||||
|
runLength: 3, // Länge 3 (kein 2. Lauf)
|
||||||
|
width: 1.2,
|
||||||
|
totalRise: 2.6,
|
||||||
|
stepCount: 16,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const extrudedSolids: ExtrudedSolid[] = [
|
||||||
|
{
|
||||||
|
id: "E1",
|
||||||
|
type: "extrudedSolid",
|
||||||
|
levelId: "eg",
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2, y: 0 },
|
||||||
|
{ x: 2, y: 3 },
|
||||||
|
{ x: 0, y: 3 },
|
||||||
|
], // 2×3 = 6 m²
|
||||||
|
height: 2.5,
|
||||||
|
},
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
id: "t",
|
id: "t",
|
||||||
name: "T",
|
name: "T",
|
||||||
@@ -61,10 +120,11 @@ function fixtureProject(): Project {
|
|||||||
],
|
],
|
||||||
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
walls,
|
walls,
|
||||||
doors: [],
|
doors,
|
||||||
openings: [],
|
openings,
|
||||||
ceilings,
|
ceilings,
|
||||||
stairs: [],
|
stairs,
|
||||||
|
extrudedSolids,
|
||||||
rooms: [],
|
rooms: [],
|
||||||
drawings2d: [],
|
drawings2d: [],
|
||||||
context: [],
|
context: [],
|
||||||
@@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
|||||||
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
|
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
|
||||||
const lines = csv.split("\r\n");
|
const lines = csv.split("\r\n");
|
||||||
|
|
||||||
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
|
// Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion).
|
||||||
expect(lines).toHaveLength(4);
|
expect(lines).toHaveLength(8);
|
||||||
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
|
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
|
||||||
|
|
||||||
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
|
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
|
||||||
@@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
|||||||
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
|
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
|
||||||
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
|
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
|
||||||
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
|
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
|
||||||
|
// Tür T1: Breite 0.9, Höhe 2.1, keine Dicke, Fläche 1.89.
|
||||||
|
expect(lines[4]).toBe("Tür;T1;Tür;EG;0.90;2.10;;1.89");
|
||||||
|
// Fenster F1: Breite 1.2, Höhe 1.5, keine Dicke, Fläche 1.80.
|
||||||
|
expect(lines[5]).toBe("Fenster;F1;Fenster;EG;1.20;1.50;;1.80");
|
||||||
|
// Treppe S1: Lauflänge 3 (kein 2. Lauf), Steighöhe 2.6, keine Dicke/Fläche.
|
||||||
|
expect(lines[6]).toBe("Treppe;S1;Treppe;EG;3.00;2.60;;");
|
||||||
|
// Extrusion E1: keine Länge, Höhe 2.5, keine Dicke, Grundrissfläche 6.
|
||||||
|
expect(lines[7]).toBe("Extrusion;E1;Extrusion;EG;;2.50;;6.00");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
|
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
|
||||||
const csv = exportScheduleCsv(fixtureProject());
|
const csv = exportScheduleCsv(fixtureProject());
|
||||||
const lines = csv.split("\r\n");
|
const lines = csv.split("\r\n");
|
||||||
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
|
// Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16.
|
||||||
expect(lines).toHaveLength(8);
|
expect(lines).toHaveLength(16);
|
||||||
expect(lines[4]).toBe("");
|
expect(lines[8]).toBe("");
|
||||||
expect(lines[5]).toContain("Zusammenfassung");
|
expect(lines[9]).toContain("Zusammenfassung");
|
||||||
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
|
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
|
||||||
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
|
expect(lines[10]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
|
||||||
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
|
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
|
||||||
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
|
expect(lines[11]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
|
||||||
|
// Tür-Aggregat: 1 Stk, Gesamtbreite 0.9, Fläche 1.89.
|
||||||
|
expect(lines[12]).toBe("Tür;;Tür;1 Stk;0.90;;;1.89");
|
||||||
|
// Fenster-Aggregat: 1 Stk, Gesamtbreite 1.2, Fläche 1.80.
|
||||||
|
expect(lines[13]).toBe("Fenster;;Fenster;1 Stk;1.20;;;1.80");
|
||||||
|
// Treppen-Aggregat: 1 Stk, Gesamtlänge 3, keine bekannte Fläche (leer statt 0.00).
|
||||||
|
expect(lines[14]).toBe("Treppe;;Treppe;1 Stk;3.00;;;");
|
||||||
|
// Extrusions-Aggregat: 1 Stk, keine Länge, Fläche 6.
|
||||||
|
expect(lines[15]).toBe("Extrusion;;Extrusion;1 Stk;;;;6.00");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
|
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
|
||||||
@@ -114,6 +190,10 @@ describe("exportScheduleCsv — Bauteilliste", () => {
|
|||||||
const proj = fixtureProject();
|
const proj = fixtureProject();
|
||||||
proj.walls = [];
|
proj.walls = [];
|
||||||
proj.ceilings = [];
|
proj.ceilings = [];
|
||||||
|
proj.doors = [];
|
||||||
|
proj.openings = [];
|
||||||
|
proj.stairs = [];
|
||||||
|
proj.extrudedSolids = [];
|
||||||
const csv = exportScheduleCsv(proj);
|
const csv = exportScheduleCsv(proj);
|
||||||
expect(csv.split("\r\n")).toHaveLength(1);
|
expect(csv.split("\r\n")).toHaveLength(1);
|
||||||
expect(scheduleRows(proj)).toHaveLength(0);
|
expect(scheduleRows(proj)).toHaveLength(0);
|
||||||
|
|||||||
Binary file not shown.
@@ -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,
|
stairGeometry,
|
||||||
type StairGeometry as TSStairGeometry,
|
type StairGeometry as TSStairGeometry,
|
||||||
} from "./stair";
|
} from "./stair";
|
||||||
import type { Stair } from "../model/types";
|
import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
|
||||||
import {
|
import {
|
||||||
detectRooms,
|
detectRooms,
|
||||||
pointInPolygon as rbPointInPolygon,
|
pointInPolygon as rbPointInPolygon,
|
||||||
@@ -69,6 +69,19 @@ import {
|
|||||||
type WallSegment,
|
type WallSegment,
|
||||||
type WallFace,
|
type WallFace,
|
||||||
} from "./roomBoundary";
|
} from "./roomBoundary";
|
||||||
|
import { wallReferenceOffset } from "../model/wall";
|
||||||
|
import {
|
||||||
|
doorSymbol,
|
||||||
|
openingCenter,
|
||||||
|
openingGapQuad,
|
||||||
|
openingInterval,
|
||||||
|
openingJambs,
|
||||||
|
wallAxisFrame,
|
||||||
|
wallAxisLength,
|
||||||
|
windowSymbol,
|
||||||
|
type DoorSymbol,
|
||||||
|
type WindowSymbol,
|
||||||
|
} from "./opening";
|
||||||
|
|
||||||
type Poly = { pts: Vec2[]; closed: boolean };
|
type Poly = { pts: Vec2[]; closed: boolean };
|
||||||
|
|
||||||
@@ -912,6 +925,317 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBou
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Slice 4: opening ──────────────────────────────────────────────────────────
|
||||||
|
// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch
|
||||||
|
// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert
|
||||||
|
// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port —
|
||||||
|
// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`,
|
||||||
|
// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf).
|
||||||
|
|
||||||
|
/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */
|
||||||
|
function genWall(rng: () => number): Wall {
|
||||||
|
const start = v(rng);
|
||||||
|
const angle = rng() * Math.PI * 2;
|
||||||
|
const length = 0.5 + rng() * 15;
|
||||||
|
const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length };
|
||||||
|
const refs = ["left", "center", "right"] as const;
|
||||||
|
return {
|
||||||
|
id: "w1",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "f1",
|
||||||
|
categoryCode: "20",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
wallTypeId: "wt1",
|
||||||
|
height: 2.4,
|
||||||
|
referenceLine: refs[Math.floor(rng() * 3)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */
|
||||||
|
function genWallType(rng: () => number): WallType {
|
||||||
|
return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimales Project — `getWallType` liest nur `wallTypes`. */
|
||||||
|
function makeProject(wallType: WallType): Project {
|
||||||
|
return { wallTypes: [wallType] } as unknown as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse
|
||||||
|
* oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */
|
||||||
|
function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening {
|
||||||
|
const axis = wallAxisLength(wall);
|
||||||
|
const position = (rng() * 1.6 - 0.3) * axis;
|
||||||
|
const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis);
|
||||||
|
const swings = ["left", "right"] as const;
|
||||||
|
const hinges = ["start", "end"] as const;
|
||||||
|
const dirs = ["in", "out"] as const;
|
||||||
|
return {
|
||||||
|
id: "o1",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: wall.id,
|
||||||
|
categoryCode: "21",
|
||||||
|
kind,
|
||||||
|
position,
|
||||||
|
width,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: kind === "door" ? 0 : 0.8,
|
||||||
|
wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4),
|
||||||
|
hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)],
|
||||||
|
swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)],
|
||||||
|
swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120,
|
||||||
|
openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => {
|
||||||
|
it("wallAxisLength (Zufallswaende, rel 1e-9)", () => {
|
||||||
|
const rng = mulberry32(50);
|
||||||
|
const walls = Array.from({ length: N }, () => genWall(rng));
|
||||||
|
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
|
||||||
|
const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[];
|
||||||
|
walls.forEach((wl, i) => {
|
||||||
|
expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => {
|
||||||
|
const rng = mulberry32(51);
|
||||||
|
const cases = Array.from({ length: N }, () => {
|
||||||
|
const wall = genWall(rng);
|
||||||
|
return { wall, o: genOpening(rng, wall, "window") };
|
||||||
|
});
|
||||||
|
const qs = cases.map(({ wall, o }) => ({
|
||||||
|
start: wall.start,
|
||||||
|
end: wall.end,
|
||||||
|
position: o.position,
|
||||||
|
width: o.width,
|
||||||
|
}));
|
||||||
|
const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as (
|
||||||
|
| { from: number; to: number }
|
||||||
|
| null
|
||||||
|
)[];
|
||||||
|
cases.forEach(({ wall, o }, i) => {
|
||||||
|
const t = openingInterval(wall, o);
|
||||||
|
expect(w[i] === null, `iv-null#${i}`).toBe(t === null);
|
||||||
|
if (t && w[i]) {
|
||||||
|
expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true);
|
||||||
|
expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wallAxisFrame (u/n, rel 1e-9)", () => {
|
||||||
|
const rng = mulberry32(52);
|
||||||
|
const walls = Array.from({ length: N }, () => genWall(rng));
|
||||||
|
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
|
||||||
|
const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[];
|
||||||
|
walls.forEach((wl, i) => {
|
||||||
|
const t = wallAxisFrame(wl);
|
||||||
|
expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true);
|
||||||
|
expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => {
|
||||||
|
const rng = mulberry32(53);
|
||||||
|
const cases = Array.from({ length: N }, () => {
|
||||||
|
const wall = genWall(rng);
|
||||||
|
return { wall, o: genOpening(rng, wall, "window") };
|
||||||
|
});
|
||||||
|
const qs = cases.map(({ wall, o }) => ({
|
||||||
|
start: wall.start,
|
||||||
|
end: wall.end,
|
||||||
|
position: o.position,
|
||||||
|
width: o.width,
|
||||||
|
}));
|
||||||
|
const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as (
|
||||||
|
| { jambStart: Vec2; jambEnd: Vec2 }
|
||||||
|
| null
|
||||||
|
)[];
|
||||||
|
const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[];
|
||||||
|
cases.forEach(({ wall, o }, i) => {
|
||||||
|
const tJ = openingJambs(wall, o);
|
||||||
|
expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null);
|
||||||
|
if (tJ && wJ[i]) {
|
||||||
|
expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true);
|
||||||
|
expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true);
|
||||||
|
}
|
||||||
|
const tC = openingCenter(wall, o);
|
||||||
|
expect(wC[i] === null, `center-null#${i}`).toBe(tC === null);
|
||||||
|
if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => {
|
||||||
|
const rng = mulberry32(54);
|
||||||
|
const cases = Array.from({ length: 150 }, () => {
|
||||||
|
const wall = genWall(rng);
|
||||||
|
const wallType = genWallType(rng);
|
||||||
|
wall.wallTypeId = wallType.id;
|
||||||
|
const project = makeProject(wallType);
|
||||||
|
const o = genOpening(rng, wall, "window");
|
||||||
|
const total = wallTypeThickness(wallType);
|
||||||
|
const refOffset = wallReferenceOffset(wall, total);
|
||||||
|
const glassCount = 1 + Math.floor(rng() * 3);
|
||||||
|
return { wall, o, project, total, refOffset, glassCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
const qsGap = cases.map((c) => ({
|
||||||
|
start: c.wall.start,
|
||||||
|
end: c.wall.end,
|
||||||
|
position: c.o.position,
|
||||||
|
width: c.o.width,
|
||||||
|
thickness: c.total,
|
||||||
|
refOffset: c.refOffset,
|
||||||
|
}));
|
||||||
|
const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[];
|
||||||
|
cases.forEach((c, i) => {
|
||||||
|
const t = openingGapQuad(c.project, c.wall, c.o);
|
||||||
|
expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null);
|
||||||
|
if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const qsWin = cases.map((c) => ({
|
||||||
|
start: c.wall.start,
|
||||||
|
end: c.wall.end,
|
||||||
|
position: c.o.position,
|
||||||
|
width: c.o.width,
|
||||||
|
thickness: c.total,
|
||||||
|
refOffset: c.refOffset,
|
||||||
|
glassCount: c.glassCount,
|
||||||
|
wingCount: c.o.wingCount,
|
||||||
|
}));
|
||||||
|
const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[];
|
||||||
|
cases.forEach((c, i) => {
|
||||||
|
const t = windowSymbol(c.project, c.wall, c.o, c.glassCount);
|
||||||
|
expect(wWin[i] === null, `win-null#${i}`).toBe(t === null);
|
||||||
|
if (t && wWin[i]) {
|
||||||
|
expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true);
|
||||||
|
expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true);
|
||||||
|
expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => {
|
||||||
|
const rng = mulberry32(55);
|
||||||
|
const cases = Array.from({ length: 150 }, () => {
|
||||||
|
const wall = genWall(rng);
|
||||||
|
return { wall, o: genOpening(rng, wall, "door") };
|
||||||
|
});
|
||||||
|
const qs = cases.map(({ wall, o }) => ({
|
||||||
|
start: wall.start,
|
||||||
|
end: wall.end,
|
||||||
|
position: o.position,
|
||||||
|
width: o.width,
|
||||||
|
swing: o.swing,
|
||||||
|
openingDir: o.openingDir,
|
||||||
|
hinge: o.hinge,
|
||||||
|
swingAngle: o.swingAngle,
|
||||||
|
}));
|
||||||
|
const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[];
|
||||||
|
cases.forEach(({ wall, o }, i) => {
|
||||||
|
const t = doorSymbol(wall, o);
|
||||||
|
expect(w[i] === null, `door-null#${i}`).toBe(t === null);
|
||||||
|
if (t && w[i]) {
|
||||||
|
expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true);
|
||||||
|
expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true);
|
||||||
|
expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true);
|
||||||
|
expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true);
|
||||||
|
expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true);
|
||||||
|
expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true);
|
||||||
|
expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => {
|
||||||
|
const wall: Wall = {
|
||||||
|
id: "w",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "f",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
end: { x: 5, y: 0 },
|
||||||
|
wallTypeId: "wt",
|
||||||
|
height: 2.4,
|
||||||
|
referenceLine: "center",
|
||||||
|
};
|
||||||
|
const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] };
|
||||||
|
const project = makeProject(wallType);
|
||||||
|
|
||||||
|
// Breite 0 → entartet.
|
||||||
|
const oZero: Opening = {
|
||||||
|
id: "o",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "w",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
position: 2,
|
||||||
|
width: 0,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.8,
|
||||||
|
};
|
||||||
|
const ivZero = JSON.parse(
|
||||||
|
K.opening_interval_batch_json(
|
||||||
|
JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]),
|
||||||
|
),
|
||||||
|
) as ({ from: number; to: number } | null)[];
|
||||||
|
expect(ivZero[0] === null).toBe(true);
|
||||||
|
expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null);
|
||||||
|
|
||||||
|
// Vollstaendig ausserhalb der Achse.
|
||||||
|
const oOut: Opening = { ...oZero, position: 10, width: 1 };
|
||||||
|
const ivOut = JSON.parse(
|
||||||
|
K.opening_interval_batch_json(
|
||||||
|
JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]),
|
||||||
|
),
|
||||||
|
) as ({ from: number; to: number } | null)[];
|
||||||
|
expect(ivOut[0] === null).toBe(true);
|
||||||
|
expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null);
|
||||||
|
|
||||||
|
// wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten.
|
||||||
|
const total = wallTypeThickness(wallType);
|
||||||
|
const refOffset = wallReferenceOffset(wall, total);
|
||||||
|
const oWings: Opening = {
|
||||||
|
id: "o",
|
||||||
|
type: "opening",
|
||||||
|
hostWallId: "w",
|
||||||
|
categoryCode: "21",
|
||||||
|
kind: "window",
|
||||||
|
position: 1,
|
||||||
|
width: 2,
|
||||||
|
height: 1.2,
|
||||||
|
sillHeight: 0.8,
|
||||||
|
wingCount: 9,
|
||||||
|
};
|
||||||
|
const wWin = JSON.parse(
|
||||||
|
K.window_symbol_batch_json(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
start: wall.start,
|
||||||
|
end: wall.end,
|
||||||
|
position: oWings.position,
|
||||||
|
width: oWings.width,
|
||||||
|
thickness: total,
|
||||||
|
refOffset,
|
||||||
|
glassCount: 1,
|
||||||
|
wingCount: oWings.wingCount,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
) as (WindowSymbol | null)[];
|
||||||
|
const tWin = windowSymbol(project, wall, oWings, 1);
|
||||||
|
expect(tWin).not.toBeNull();
|
||||||
|
expect(wWin[0]).not.toBeNull();
|
||||||
|
expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length);
|
||||||
|
expect(wWin[0]!.mullionLines.length).toBe(3);
|
||||||
|
expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
|
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
|
||||||
it("parallele/kollineare Strecken → null (beide)", () => {
|
it("parallele/kollineare Strecken → null (beide)", () => {
|
||||||
const qs = [
|
const qs = [
|
||||||
|
|||||||
+313
-17
@@ -11,7 +11,7 @@
|
|||||||
//
|
//
|
||||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
import type { Opening, Project, Vec2, Wall } from "../model/types";
|
import type { Opening, Project, SashDef, Vec2, Wall } from "../model/types";
|
||||||
import { getWallType, wallTypeThickness } from "../model/types";
|
import { getWallType, wallTypeThickness } from "../model/types";
|
||||||
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
|
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
|
||||||
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
|
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
|
||||||
@@ -162,35 +162,207 @@ export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein einzelnes Türblatt (Grundriss): Scharnier + geschlossenes/offenes Ende + Radius. */
|
||||||
|
export interface DoorLeaf {
|
||||||
|
hinge: Vec2;
|
||||||
|
openEnd: Vec2;
|
||||||
|
closedEnd: Vec2;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blatt-Geometrie aus Scharnier, geschlossener Richtung, Schwenkrichtung, Winkel, Breite. */
|
||||||
|
function computeLeaf(
|
||||||
|
hinge: Vec2,
|
||||||
|
closedDir: Vec2,
|
||||||
|
swingDir: Vec2,
|
||||||
|
angle: number,
|
||||||
|
width: number,
|
||||||
|
): DoorLeaf {
|
||||||
|
const openDir = {
|
||||||
|
x: closedDir.x * Math.cos(angle) + swingDir.x * Math.sin(angle),
|
||||||
|
y: closedDir.y * Math.cos(angle) + swingDir.y * Math.sin(angle),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
hinge,
|
||||||
|
openEnd: add(hinge, scale(openDir, width)),
|
||||||
|
closedEnd: add(hinge, scale(closedDir, width)),
|
||||||
|
radius: width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Türblätter im Grundriss (1 oder 2 Flügel). Einflüglig: ein Blatt über die
|
||||||
|
* volle Breite (Scharnier je `o.hinge`). Zweiflüglig (`leafCount === 2`): zwei
|
||||||
|
* halbbreite Blätter, an den GEGENüberliegenden Pfosten angeschlagen, beide zur
|
||||||
|
* selben Seite schwenkend (symmetrische Doppeltür / Stulptür). `doorSymbol`
|
||||||
|
* bleibt unverändert (einflügelig) für Bestandspfade/Parität; dies ist additiv.
|
||||||
|
*/
|
||||||
|
export function doorLeaves(wall: Wall, o: Opening, leafCount: number): DoorLeaf[] {
|
||||||
|
const jambs = openingJambs(wall, o);
|
||||||
|
if (!jambs) return [];
|
||||||
|
const { u, n } = wallAxisFrame(wall);
|
||||||
|
const { jambStart, jambEnd } = jambs;
|
||||||
|
const width = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
|
||||||
|
const swingSign = (o.swing ?? "left") === "left" ? 1 : -1;
|
||||||
|
const dirSign = (o.openingDir ?? "in") === "in" ? 1 : -1;
|
||||||
|
const swingDir = scale(n, swingSign * dirSign);
|
||||||
|
const angle = (o.swingAngle ?? 90) * DEG;
|
||||||
|
if (leafCount >= 2) {
|
||||||
|
const hw = width / 2;
|
||||||
|
return [
|
||||||
|
computeLeaf(jambStart, u, swingDir, angle, hw),
|
||||||
|
computeLeaf(jambEnd, scale(u, -1), swingDir, angle, hw),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const hinge = (o.hinge ?? "start") === "start" ? jambStart : jambEnd;
|
||||||
|
const closedDir = (o.hinge ?? "start") === "start" ? u : scale(u, -1);
|
||||||
|
return [computeLeaf(hinge, closedDir, swingDir, angle, width)];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fenster-Symbol (Plan) ────────────────────────────────────────────────────
|
// ── Fenster-Symbol (Plan) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Aufgelöste Fenster-Linien im Grundriss (Weltkoordinaten). */
|
/** Aufgelöste Fenster-Linien im Grundriss (Weltkoordinaten). */
|
||||||
export interface WindowSymbol {
|
export interface WindowSymbol {
|
||||||
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
|
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
|
||||||
frame: Vec2[];
|
frame: Vec2[];
|
||||||
/** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */
|
/** Glaslinien-Segmente quer über die Lücke (1–3 parallele Linien). */
|
||||||
glassLines: [Vec2, Vec2][];
|
glassLines: [Vec2, Vec2][];
|
||||||
/**
|
/**
|
||||||
* Flügel-Mittelpfosten (`wingCount − 1` Querlinien), jeweils von einer
|
* Flügel-/Pfosten-Trennlinien, jeweils von einer Laibungsseite zur anderen
|
||||||
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
|
* (parallel zu den Laibungslinien, quer zur Öffnungsrichtung). Ist `sashes`
|
||||||
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
|
* (dritter Parameter von {@link windowSymbol}) gesetzt, ergeben sich die
|
||||||
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
|
* Positionen aus den Flügel-/Pfostenbreiten (siehe `sashSegments`); sonst
|
||||||
* des Rhino-Plugins (`oeff_fluegel`).
|
* (Alt-Fall) `wingCount − 1` gleichmäßig verteilte Querlinien. Entspricht den
|
||||||
|
* Mittelpfosten in `_make_oeffnung_pieces` des Rhino-Plugins (`oeff_fluegel`).
|
||||||
*/
|
*/
|
||||||
mullionLines: [Vec2, Vec2][];
|
mullionLines: [Vec2, Vec2][];
|
||||||
|
/**
|
||||||
|
* Flügelrahmen (Flügelkontur) je ÖFFENBAREM Flügel (Dreh/Kipp/Drehkipp/
|
||||||
|
* Schiebe): ein Rechteck INNERHALB des Blendrahmens (Schachtelung
|
||||||
|
* Blendrahmen → Flügelrahmen → Glas), das den öffenbaren Flügel vom festen
|
||||||
|
* Feld unterscheidet. Feste Flügel (`opening:"fest"`) bekommen KEINEN
|
||||||
|
* Flügelrahmen — dort sitzt das Glas direkt im Blendrahmen. Nur befüllt im
|
||||||
|
* neuen (spec-)Pfad; leer im Alt-Fall (kein `spec`).
|
||||||
|
*/
|
||||||
|
sashFrames: Vec2[][];
|
||||||
|
/**
|
||||||
|
* (Alt-Feld, für Rückwärtskompatibilität der Schnittstelle beibehalten) — im
|
||||||
|
* neuen Pfad leer; die Öffenbarkeit wird jetzt über {@link sashFrames}
|
||||||
|
* ausgedrückt statt über eine Diagonale quer über die Box.
|
||||||
|
*/
|
||||||
|
sashOpeningLines: [Vec2, Vec2][];
|
||||||
|
/**
|
||||||
|
* Stulp-/Flügelstoss-Marken (SIA-„kleines Quadrat mittig", Figur 37/38): je
|
||||||
|
* Flügel-Trennung ein kleines Rechteck-Polygon auf der Glasebene, das den
|
||||||
|
* Anschlag der zwei zusammenstossenden Flügelprofile darstellt. Bei „mittel"
|
||||||
|
* ein Quadrat je Stoss, bei „fein" zwei (die beiden Stulpprofile). „grob":
|
||||||
|
* leer. Zählt zur "Fenster"-Linien-Kategorie (Flügel/Sprossen), NICHT zum
|
||||||
|
* Blendrahmen — separat von {@link jambMarks} steuerbar. Der Aufrufer
|
||||||
|
* zeichnet sie als dünne Umriss-Rechtecke.
|
||||||
|
*/
|
||||||
|
meetingMarks: Vec2[][];
|
||||||
|
/**
|
||||||
|
* Blendrahmen-Querschnittsblöcke an den beiden Laibungs-Enden (ein Block je
|
||||||
|
* Seite, unabhängig von Flügelanzahl/Detailgrad) — Teil des Blendrahmens,
|
||||||
|
* zählt zur "Rahmen"-Linien-Kategorie (im Unterschied zu {@link meetingMarks},
|
||||||
|
* die an inneren Flügelstössen sitzen und zur "Fenster"-Kategorie zählen).
|
||||||
|
*/
|
||||||
|
jambMarks: Vec2[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-Parameter des Fensters für die reiche 2D-Symbolik (aus dem
|
||||||
|
* referenzierten {@link WindowType}): Flügeltabelle, Profilbreite, Rahmentiefe
|
||||||
|
* und Schichteinzug. Fehlt der Spec (kein Typ auflösbar), zeichnet
|
||||||
|
* {@link windowSymbol} das schlichte Alt-Symbol (`o.wingCount`, volle Wanddicke).
|
||||||
|
*/
|
||||||
|
export interface WindowRenderSpec {
|
||||||
|
sashes: SashDef[];
|
||||||
|
/** Sichtbare Profilbreite (Meter) von Blend-/Flügelrahmen in der Öffnungsebene. */
|
||||||
|
frameWidth: number;
|
||||||
|
/** Rahmentiefe quer zur Wand (Meter); fehlt ⇒ {@link DEFAULT_FRAME_DEPTH}. */
|
||||||
|
frameDepth?: number;
|
||||||
|
/** Schichteinzug: Abstand der Rahmen-Vorderkante von {@link insetFace} (Meter). */
|
||||||
|
insetFromFace?: number;
|
||||||
|
/** Bezugsfläche des Schichteinzugs; Default "aussen". */
|
||||||
|
insetFace?: "aussen" | "innen";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Segment (Flügel/Pfosten) der Flügeleinteilung, mit aufgelöster Breite. */
|
||||||
|
interface SashSegment {
|
||||||
|
sash: SashDef;
|
||||||
|
/** Abstand vom Laibungsstart in Metern (0 = jambStart). */
|
||||||
|
from: number;
|
||||||
|
/** Abstand vom Laibungsstart in Metern. */
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default-Pfostenbreite (Meter), wenn ein `kind:"pfosten"`-Eintrag keine `postWidth` angibt. */
|
||||||
|
const DEFAULT_POST_WIDTH = 0.06;
|
||||||
|
/**
|
||||||
|
* Default-Rahmentiefe (Meter), wenn ein Fenster-/Türtyp keine explizite
|
||||||
|
* `frameDepth` trägt: reale Fensterrahmen sind ~70–90 mm tief (Holz/PVC/Alu),
|
||||||
|
* unabhängig von der Wanddicke — NICHT die volle Wanddicke (das liess Fenster
|
||||||
|
* wie einen massiven Block in der Öffnung wirken statt eines schlanken Rahmens
|
||||||
|
* mit sichtbarer Laibung/Leibung ringsum, s. Recherche zu VW/ArchiCAD-Fenster-
|
||||||
|
* konstruktion). Deckt sich mit `toWalls3d.ts::DEFAULT_FRAME_DEPTH` (2D=3D).
|
||||||
|
*/
|
||||||
|
const DEFAULT_FRAME_DEPTH = 0.07;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst eine Flügeleinteilung ({@link SashDef}-Liste) zu konkreten Segment-
|
||||||
|
* Intervallen (Meter, gemessen ab Laibungsstart) über die gegebene
|
||||||
|
* Gesamtbreite auf: `kind:"pfosten"` und Flügel mit `autoWidth === false`
|
||||||
|
* erhalten ihre feste Breite (`postWidth` bzw. `width`), die restlichen
|
||||||
|
* (`autoWidth`, Default) teilen sich die verbleibende Breite gleichmäßig.
|
||||||
|
* Degeneriert bei GLEICHMÄSSIGEN Flügeln (alle `autoWidth`, keine Pfosten) zur
|
||||||
|
* bisherigen `wingCount`-Gleichverteilung (Regressionssicherheit).
|
||||||
|
*/
|
||||||
|
function sashSegments(sashes: SashDef[], totalWidth: number): SashSegment[] {
|
||||||
|
if (sashes.length === 0) return [];
|
||||||
|
let fixedSum = 0;
|
||||||
|
let autoCount = 0;
|
||||||
|
for (const s of sashes) {
|
||||||
|
if (s.kind === "pfosten") fixedSum += Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH);
|
||||||
|
else if (s.autoWidth === false) fixedSum += Math.max(0, s.width ?? 0);
|
||||||
|
else autoCount++;
|
||||||
|
}
|
||||||
|
const remaining = Math.max(0, totalWidth - fixedSum);
|
||||||
|
const autoWidth = autoCount > 0 ? remaining / autoCount : 0;
|
||||||
|
const segs: SashSegment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const s of sashes) {
|
||||||
|
const w =
|
||||||
|
s.kind === "pfosten"
|
||||||
|
? Math.max(0, s.postWidth ?? DEFAULT_POST_WIDTH)
|
||||||
|
: s.autoWidth === false
|
||||||
|
? Math.max(0, s.width ?? 0)
|
||||||
|
: autoWidth;
|
||||||
|
segs.push({ sash: s, from: cursor, to: cursor + w });
|
||||||
|
cursor += w;
|
||||||
|
}
|
||||||
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
|
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
|
||||||
* Wanddicke) plus 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
* Wanddicke) plus 1–3 Glaslinien (dünne parallele Linien in der Wandmitte,
|
||||||
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
|
* `glassCount` steuert die Anzahl) plus optionale Flügel-/Pfosten-Trennlinien
|
||||||
* (`wingCount − 1` Querlinien).
|
* und Öffnungsandeutungen je Flügel.
|
||||||
|
*
|
||||||
|
* `sashes` (optional) ist die aufgelöste Flügeltabelle (siehe
|
||||||
|
* `sashesOfWindowType`) EINES referenzierten Fenstertyps. Fehlt sie (kein Typ
|
||||||
|
* referenziert/auflösbar), bleibt die Mittelpfosten-Berechnung beim
|
||||||
|
* BISHERIGEN Verhalten — `o.wingCount` gleichmäßig verteilt, KEINE
|
||||||
|
* Öffnungsandeutungen — exakt wie vor dieser Erweiterung (Rückwärtskompatibilität).
|
||||||
*/
|
*/
|
||||||
export function windowSymbol(
|
export function windowSymbol(
|
||||||
project: Project,
|
project: Project,
|
||||||
wall: Wall,
|
wall: Wall,
|
||||||
o: Opening,
|
o: Opening,
|
||||||
glassCount: number,
|
glassCount: number,
|
||||||
|
spec?: WindowRenderSpec,
|
||||||
|
detail: "grob" | "mittel" | "fein" = "fein",
|
||||||
): WindowSymbol | null {
|
): WindowSymbol | null {
|
||||||
const jambs = openingJambs(wall, o);
|
const jambs = openingJambs(wall, o);
|
||||||
if (!jambs) return null;
|
if (!jambs) return null;
|
||||||
@@ -200,6 +372,135 @@ export function windowSymbol(
|
|||||||
const outer = refOff + total / 2;
|
const outer = refOff + total / 2;
|
||||||
const inner = refOff - total / 2;
|
const inner = refOff - total / 2;
|
||||||
const { jambStart, jambEnd } = jambs;
|
const { jambStart, jambEnd } = jambs;
|
||||||
|
|
||||||
|
// ── Reicher Pfad (typisiertes Fenster mit Flügeltabelle), SIA-Darstellung ─
|
||||||
|
// Detailstufe = SIA-Massstab (fig. 36/37/38):
|
||||||
|
// • grob (1:100): NUR Blendrahmen-Band + eine Glaslinie über die ganze
|
||||||
|
// Öffnung — schematisch (SIA fig. 36).
|
||||||
|
// • mittel(1:50): Blendrahmen + Flügel-Trennlinien + je Flügel eine Glas-
|
||||||
|
// linie + kleines Stulp-Quadrat je Stoss (SIA fig. 37). KEINE Flügelrahmen.
|
||||||
|
// • fein (1:20): zusätzlich verschachtelte Flügelrahmen, Glas als Doppel-
|
||||||
|
// linie (Isolierverglasung) und je Stoss ZWEI Stulp-Quadrate (SIA fig. 38).
|
||||||
|
if (spec && spec.sashes.length > 0) {
|
||||||
|
const fw = Math.max(0.01, spec.frameWidth);
|
||||||
|
const depth = Math.min(total, spec.frameDepth && spec.frameDepth > 1e-9 ? spec.frameDepth : DEFAULT_FRAME_DEPTH);
|
||||||
|
const inset = Math.max(0, spec.insetFromFace ?? 0);
|
||||||
|
const halfT = total / 2;
|
||||||
|
// "aussen" = die Wandfläche, an der die ERSTE Wandtyp-Schicht (Aussenputz
|
||||||
|
// etc., "Schichten außen → innen") liegt — das ist die Seite mit dem
|
||||||
|
// NEGATIVEN Achs-Offset (s. addWallPoche: `off` startet bei `refOff-total/2`
|
||||||
|
// für die erste Schicht). Bug bis 2026-07-12: hier stand `+1` für "aussen"
|
||||||
|
// (positiver, NICHT negativer Offset) — der Rahmen landete dadurch bündig
|
||||||
|
// an der INNEREN statt der äusseren Wandfläche (Nutzer-Report: "im 2D innen
|
||||||
|
// bündig und im 3D aussen bündig" — das 3D-Pendant nutzt kompensierend eine
|
||||||
|
// gespiegelte Normale, s. resolveFrameNormalRange/openingAxisBox).
|
||||||
|
const sign = (spec.insetFace ?? "aussen") === "aussen" ? -1 : 1;
|
||||||
|
// Rahmen-Vorderkante bündig mit der Bezugsfläche, um `inset` zurück; der
|
||||||
|
// Körper reicht `depth` zur Wandmitte. Auf die Wanddicke geklemmt (mirror
|
||||||
|
// von resolveFrameNormalRange im 3D). Alles relativ zur Referenzlinie.
|
||||||
|
let frontRel = sign * (halfT - inset);
|
||||||
|
let backRel = frontRel - sign * depth;
|
||||||
|
frontRel = Math.max(-halfT, Math.min(halfT, frontRel));
|
||||||
|
backRel = Math.max(-halfT, Math.min(halfT, backRel));
|
||||||
|
const frameOuterN = refOff + Math.max(frontRel, backRel);
|
||||||
|
const frameInnerN = refOff + Math.min(frontRel, backRel);
|
||||||
|
const bandDepth = frameOuterN - frameInnerN;
|
||||||
|
const gMidBase = (frameOuterN + frameInnerN) / 2;
|
||||||
|
|
||||||
|
const frame = [
|
||||||
|
add(jambStart, scale(n, frameOuterN)),
|
||||||
|
add(jambEnd, scale(n, frameOuterN)),
|
||||||
|
add(jambEnd, scale(n, frameInnerN)),
|
||||||
|
add(jambStart, scale(n, frameInnerN)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const glassLines: [Vec2, Vec2][] = [];
|
||||||
|
const mullionLines: [Vec2, Vec2][] = [];
|
||||||
|
const sashFrames: Vec2[][] = [];
|
||||||
|
const meetingMarks: Vec2[][] = [];
|
||||||
|
const jambMarks: Vec2[][] = [];
|
||||||
|
|
||||||
|
// grob (1:100): nur der Rahmenkasten + EINE Glaslinie über die ganze Öffnung.
|
||||||
|
if (detail === "grob") {
|
||||||
|
glassLines.push([add(jambStart, scale(n, gMidBase)), add(jambEnd, scale(n, gMidBase))]);
|
||||||
|
return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [], meetingMarks, jambMarks };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fein = detail === "fein";
|
||||||
|
const axisWidth = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
|
||||||
|
const segs = sashSegments(spec.sashes, axisWidth);
|
||||||
|
// Öffenbarer Flügel sitzt `reveal` tiefer im Blendrahmen (nur „fein").
|
||||||
|
const reveal = fein ? Math.min(bandDepth * 0.3, fw * 0.7) : 0;
|
||||||
|
// Profil-Block über die VOLLE Rahmentiefe (aussen bis innen), Breite `2·halfU`
|
||||||
|
// entlang der Achse, zentriert bei `uCenter` (Achsabstand ab jambStart) — der
|
||||||
|
// Rahmenquerschnitt, wie er im Grundriss-Schnitt sichtbar ist (Nutzer:
|
||||||
|
// „Rahmen aussen als zwei Flächen gleichbreit wie das Fenster [tief]").
|
||||||
|
const frameBlock = (uCenter: number, halfU: number): Vec2[] => {
|
||||||
|
const a = along(jambStart, jambEnd, uCenter - halfU);
|
||||||
|
const b = along(jambStart, jambEnd, uCenter + halfU);
|
||||||
|
return [
|
||||||
|
add(a, scale(n, frameOuterN)),
|
||||||
|
add(b, scale(n, frameOuterN)),
|
||||||
|
add(b, scale(n, frameInnerN)),
|
||||||
|
add(a, scale(n, frameInnerN)),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
// Laibungs-Pfosten an beiden Enden (Blendrahmen-Querschnitt an der Wand-
|
||||||
|
// öffnung) — EIN Block je Seite, unabhängig vom Detailgrad. Teil des
|
||||||
|
// Blendrahmens (jambMarks), NICHT der Flügelstoss-Marken (meetingMarks) —
|
||||||
|
// separat steuerbar (Nutzer-Feedback).
|
||||||
|
jambMarks.push(frameBlock(fw / 2, fw / 2), frameBlock(axisWidth - fw / 2, fw / 2));
|
||||||
|
|
||||||
|
// Flügel-Trennlinien (Mittelpfosten) an den inneren Segmentgrenzen +
|
||||||
|
// Stulp-Block: mittel 1, fein 2 (die beiden zusammenstossenden Profile) —
|
||||||
|
// je über die volle Rahmentiefe. KEINE zusätzliche dünne Trennlinie mehr
|
||||||
|
// daneben (Nutzer-Feedback: „keine vertikale Trennlinie, sondern ein
|
||||||
|
// Rechteck") — der Block selbst markiert den Stoss.
|
||||||
|
for (let i = 0; i < segs.length - 1; i++) {
|
||||||
|
const uPos = segs[i].to;
|
||||||
|
if (fein) {
|
||||||
|
meetingMarks.push(frameBlock(uPos - fw / 2, fw / 2), frameBlock(uPos + fw / 2, fw / 2));
|
||||||
|
} else {
|
||||||
|
meetingMarks.push(frameBlock(uPos, fw / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const seg of segs) {
|
||||||
|
if (seg.sash.kind === "pfosten") continue;
|
||||||
|
const clearFrom = seg.from + fw;
|
||||||
|
const clearTo = seg.to - fw;
|
||||||
|
if (clearTo - clearFrom <= 1e-6) continue;
|
||||||
|
const operable = !!seg.sash.opening && seg.sash.opening !== "fest";
|
||||||
|
// „mittel" (SIA fig. 37) zeigt NUR Blendrahmen + Pfosten/Stulp — noch KEINE
|
||||||
|
// Glaslinie/Flügelrahmen (Nutzer-Vorgabe: „im 1:50 muss man die Fenster-
|
||||||
|
// linie noch nicht sehen, nur die Vierecke aussen/innen"). Das kommt erst
|
||||||
|
// bei „fein" (SIA fig. 38) dazu.
|
||||||
|
if (!fein) continue;
|
||||||
|
const gOuterN = frameOuterN - (operable ? reveal : 0);
|
||||||
|
const gInnerN = frameInnerN + (operable ? reveal : 0);
|
||||||
|
// Verschachtelter Flügelrahmen NUR bei „fein" (SIA fig. 38); „mittel" bleibt
|
||||||
|
// beim reinen Blendrahmen-Band (SIA fig. 37).
|
||||||
|
// Keine Glaslinie mehr im Feld (auch nicht bei „fein") — Nutzer-Feedback:
|
||||||
|
// eine feine Haarlinie mittig im Glasfeld wird nicht gebraucht, unabhängig
|
||||||
|
// von der Fensterposition. Der Flügelrahmen (nur bei öffenbaren Flügeln)
|
||||||
|
// bleibt als einzige zusätzliche Kontur bei „fein" bestehen.
|
||||||
|
if (operable) {
|
||||||
|
const sFrom = along(jambStart, jambEnd, clearFrom);
|
||||||
|
const sTo = along(jambStart, jambEnd, clearTo);
|
||||||
|
sashFrames.push([
|
||||||
|
add(sFrom, scale(n, gOuterN)),
|
||||||
|
add(sTo, scale(n, gOuterN)),
|
||||||
|
add(sTo, scale(n, gInnerN)),
|
||||||
|
add(sFrom, scale(n, gInnerN)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { frame, glassLines, mullionLines, sashFrames, sashOpeningLines: [], meetingMarks, jambMarks };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alt-Pfad (kein Typ auflösbar) ────────────────────────────────────────
|
||||||
|
// UNVERÄNDERT (Rust-Parität kernel2d): Rahmen an voller Wanddicke, Glaslinien
|
||||||
|
// mittig, (wingCount−1) gleichmäßige Mittelpfosten. Keine Flügelrahmen.
|
||||||
const frame = [
|
const frame = [
|
||||||
add(jambStart, scale(n, outer)),
|
add(jambStart, scale(n, outer)),
|
||||||
add(jambEnd, scale(n, outer)),
|
add(jambEnd, scale(n, outer)),
|
||||||
@@ -209,15 +510,10 @@ export function windowSymbol(
|
|||||||
const glassLines: [Vec2, Vec2][] = [];
|
const glassLines: [Vec2, Vec2][] = [];
|
||||||
const count = Math.max(1, glassCount);
|
const count = Math.max(1, glassCount);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8).
|
|
||||||
const spread = total / 8;
|
const spread = total / 8;
|
||||||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||||||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||||||
}
|
}
|
||||||
// Flügel-Mittelpfosten: (wingCount − 1) Querlinien gleichmäßig zwischen den
|
|
||||||
// Pfosten. Jede Linie läuft quer über die Öffnung (n-Richtung), auf ~60 % der
|
|
||||||
// Mauerdicke symmetrisch um die Wandmitte (Referenz-Offset) — analog Rhino, wo
|
|
||||||
// die Pfosten x_mid = inner_l + span·(i/fluegel) über die Rahmentiefe sitzen.
|
|
||||||
const mullionLines: [Vec2, Vec2][] = [];
|
const mullionLines: [Vec2, Vec2][] = [];
|
||||||
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
|
const wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
|
||||||
if (wings > 1) {
|
if (wings > 1) {
|
||||||
@@ -225,10 +521,10 @@ export function windowSymbol(
|
|||||||
const pOuter = refOff + postDepth / 2;
|
const pOuter = refOff + postDepth / 2;
|
||||||
const pInner = refOff - postDepth / 2;
|
const pInner = refOff - postDepth / 2;
|
||||||
for (let i = 1; i < wings; i++) {
|
for (let i = 1; i < wings; i++) {
|
||||||
const t = i / wings; // gleichmäßige Teilung entlang der Achse
|
const t = i / wings;
|
||||||
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
|
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
|
||||||
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
|
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { frame, glassLines, mullionLines };
|
return { frame, glassLines, mullionLines, sashFrames: [], sashOpeningLines: [], meetingMarks: [], jambMarks: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Unit-Tests für {@link mergeHoles} (Brücken-Trick: Aussparungen → EIN
|
||||||
|
* einfacher Polygonring). Geprüft werden die drei Kern-Eigenschaften:
|
||||||
|
* • der Ergebnis-Ring ist selbstüberschneidungsfrei (echte Kanten-Kreuzungen
|
||||||
|
* ausgeschlossen, benachbarte Kanten teilen sich naturgemäss einen Punkt),
|
||||||
|
* • die Shoelace-Fläche entspricht Aussenfläche − Lochfläche(n),
|
||||||
|
* • `bridgeEdges` benennt genau die künstlichen Brückenkanten (2 je Loch).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
import { mergeHoles } from "./polygonHoles";
|
||||||
|
|
||||||
|
function signedArea(pts: Vec2[]): number {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[i];
|
||||||
|
const b = pts[(i + 1) % pts.length];
|
||||||
|
s += a.x * b.y - b.x * a.y;
|
||||||
|
}
|
||||||
|
return s / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob sich zwei Strecken ECHT (im Inneren beider) schneiden — Test-Kopie der
|
||||||
|
* Produktionslogik (bewusst unabhängig, damit der Test die Implementierung
|
||||||
|
* nicht einfach "durchreicht"). */
|
||||||
|
function properlyCross(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2): boolean {
|
||||||
|
const dax = a2.x - a1.x, day = a2.y - a1.y;
|
||||||
|
const dbx = b2.x - b1.x, dby = b2.y - b1.y;
|
||||||
|
const denom = dax * dby - day * dbx;
|
||||||
|
if (Math.abs(denom) < 1e-12) return false;
|
||||||
|
const ex = b1.x - a1.x, ey = b1.y - a1.y;
|
||||||
|
const t = (ex * dby - ey * dbx) / denom;
|
||||||
|
const s = (ex * day - ey * dax) / denom;
|
||||||
|
const eps = 1e-9;
|
||||||
|
return t > eps && t < 1 - eps && s > eps && s < 1 - eps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, dass KEIN Paar nicht-benachbarter Kanten sich echt schneidet. */
|
||||||
|
function isSimpleRing(ring: Vec2[]): boolean {
|
||||||
|
const n = ring.length;
|
||||||
|
const edges: [Vec2, Vec2][] = [];
|
||||||
|
for (let i = 0; i < n; i++) edges.push([ring[i], ring[(i + 1) % n]]);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = i + 1; j < n; j++) {
|
||||||
|
// Benachbarte Kanten (teilen einen Endpunkt) sind erlaubt.
|
||||||
|
if (j === i + 1 || (i === 0 && j === n - 1)) continue;
|
||||||
|
if (properlyCross(edges[i][0], edges[i][1], edges[j][0], edges[j][1])) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = (x0: number, y0: number, x1: number, y1: number): Vec2[] => [
|
||||||
|
{ x: x0, y: y0 },
|
||||||
|
{ x: x1, y: y0 },
|
||||||
|
{ x: x1, y: y1 },
|
||||||
|
{ x: x0, y: y1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("mergeHoles", () => {
|
||||||
|
it("Rechteck 6x4 mit 1x1-Loch mittig -> einfacher Ring, Fläche = Aussen - Loch, 2 Brückenkanten", () => {
|
||||||
|
const outline = rect(0, 0, 6, 4); // Fläche 24
|
||||||
|
const hole = rect(2.5, 1.5, 3.5, 2.5); // Fläche 1
|
||||||
|
const { ring, bridgeEdges } = mergeHoles(outline, [hole]);
|
||||||
|
|
||||||
|
expect(ring.length).toBe(outline.length + hole.length + 2);
|
||||||
|
expect(isSimpleRing(ring)).toBe(true);
|
||||||
|
expect(Math.abs(signedArea(ring))).toBeCloseTo(24 - 1, 2);
|
||||||
|
expect(bridgeEdges.length).toBe(2);
|
||||||
|
|
||||||
|
// Die Brückenkanten sind kurz (Aussenring↔Loch-Rand), nicht Teil des
|
||||||
|
// eigentlichen Loch- oder Aussenumrisses.
|
||||||
|
for (const e of bridgeEdges) {
|
||||||
|
const a = ring[e];
|
||||||
|
const b = ring[(e + 1) % ring.length];
|
||||||
|
const len = Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
expect(len).toBeGreaterThan(0);
|
||||||
|
expect(len).toBeLessThan(3); // deutlich kürzer als Aussen-/Lochkanten (>=1)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne Löcher -> Ring identisch zur Outline, keine Brückenkanten (Regression)", () => {
|
||||||
|
const outline = rect(0, 0, 6, 4);
|
||||||
|
const { ring, bridgeEdges } = mergeHoles(outline, []);
|
||||||
|
expect(ring).toEqual(outline);
|
||||||
|
expect(bridgeEdges).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Loch ausserhalb der Outline wird übersprungen", () => {
|
||||||
|
const outline = rect(0, 0, 6, 4);
|
||||||
|
const outsideHole = rect(10, 10, 11, 11);
|
||||||
|
const { ring, bridgeEdges } = mergeHoles(outline, [outsideHole]);
|
||||||
|
expect(ring).toEqual(outline);
|
||||||
|
expect(bridgeEdges).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degeneriertes Loch (< 3 Punkte) wird übersprungen", () => {
|
||||||
|
const outline = rect(0, 0, 6, 4);
|
||||||
|
const { ring, bridgeEdges } = mergeHoles(outline, [[{ x: 1, y: 1 }, { x: 2, y: 2 }]]);
|
||||||
|
expect(ring).toEqual(outline);
|
||||||
|
expect(bridgeEdges).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zwei Löcher -> beide eingefügt, Ring einfach, Fläche = Aussen - Loch1 - Loch2, 4 Brückenkanten", () => {
|
||||||
|
const outline = rect(0, 0, 10, 4);
|
||||||
|
const holeA = rect(1, 1, 2, 2); // Fläche 1
|
||||||
|
const holeB = rect(7, 1, 9, 3); // Fläche 4
|
||||||
|
const { ring, bridgeEdges } = mergeHoles(outline, [holeA, holeB]);
|
||||||
|
expect(isSimpleRing(ring)).toBe(true);
|
||||||
|
expect(Math.abs(signedArea(ring))).toBeCloseTo(40 - 1 - 4, 2);
|
||||||
|
expect(bridgeEdges.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Loch mit CCW-Orientierung (gleich wie Aussenring) wird korrekt gegenläufig eingefügt", () => {
|
||||||
|
// rect() liefert CCW; die Fläche muss trotzdem korrekt subtrahiert werden,
|
||||||
|
// unabhängig von der gespeicherten Loch-Wicklung.
|
||||||
|
const outline = rect(0, 0, 6, 4);
|
||||||
|
const hole = rect(2.5, 1.5, 3.5, 2.5).reverse(); // CW
|
||||||
|
const { ring } = mergeHoles(outline, [hole]);
|
||||||
|
expect(Math.abs(signedArea(ring))).toBeCloseTo(24 - 1, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Brücken-Trick: Aussparungen (Löcher) werden über schmale Brücken in den
|
||||||
|
// Aussenring eingefügt, sodass EIN einfacher (nicht selbstüberschneidender)
|
||||||
|
// Polygonring entsteht — kein Loch-Support in den nachgelagerten Primitiven
|
||||||
|
// nötig. Ein solcher Ring ist paritätsbasiert korrekt für SVG-/Canvas-Fill,
|
||||||
|
// Scanline-Hatch, Ear-Clipping-Triangulierung UND Punkt-in-Polygon-Pick.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import type { Vec2 } from "../model/types";
|
||||||
|
import { add, cross, sub } from "../model/geometry";
|
||||||
|
|
||||||
|
const EPS = 1e-9;
|
||||||
|
|
||||||
|
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW. */
|
||||||
|
function signedArea(pts: Vec2[]): number {
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const a = pts[i];
|
||||||
|
const b = pts[(i + 1) % pts.length];
|
||||||
|
s += a.x * b.y - b.x * a.y;
|
||||||
|
}
|
||||||
|
return s / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schwerpunkt (einfaches Punkt-Mittel, genügt für den Innen/Aussen-Test). */
|
||||||
|
function centroid(pts: Vec2[]): Vec2 {
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
for (const p of pts) {
|
||||||
|
x += p.x;
|
||||||
|
y += p.y;
|
||||||
|
}
|
||||||
|
return { x: x / pts.length, y: y / pts.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Punkt-in-Polygon (Ray-Casting). */
|
||||||
|
function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const a = poly[i];
|
||||||
|
const b = poly[j];
|
||||||
|
const hit =
|
||||||
|
a.y > p.y !== b.y > p.y &&
|
||||||
|
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||||
|
if (hit) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einheits-Normale nach LINKS (90° CCW) eines Vektors ({0,0} bei Nulllänge). */
|
||||||
|
function leftNormalUnit(d: Vec2): Vec2 {
|
||||||
|
const l = Math.hypot(d.x, d.y);
|
||||||
|
if (l < EPS) return { x: 0, y: 0 };
|
||||||
|
return { x: -d.y / l, y: d.x / l };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ob sich zwei Strecken ECHT (im Inneren BEIDER Strecken) schneiden. */
|
||||||
|
function segmentsProperlyCross(a1: Vec2, a2: Vec2, b1: Vec2, b2: Vec2): boolean {
|
||||||
|
const da = sub(a2, a1);
|
||||||
|
const db = sub(b2, b1);
|
||||||
|
const denom = cross(da, db);
|
||||||
|
if (Math.abs(denom) < EPS) return false; // parallel/kollinear — blockiert nicht
|
||||||
|
const t = cross(sub(b1, a1), db) / denom;
|
||||||
|
const s = cross(sub(b1, a1), da) / denom;
|
||||||
|
const e = 1e-9;
|
||||||
|
return t > e && t < 1 - e && s > e && s < 1 - e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kanten eines geschlossenen Rings als [from,to]-Paare. */
|
||||||
|
function ringEdges(pts: Vec2[]): [Vec2, Vec2][] {
|
||||||
|
const out: [Vec2, Vec2][] = [];
|
||||||
|
for (let i = 0; i < pts.length; i++) out.push([pts[i], pts[(i + 1) % pts.length]]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis von {@link mergeHoles}. */
|
||||||
|
export interface MergedRing {
|
||||||
|
/** Einfacher (nicht selbstüberschneidender) Polygonring mit eingefügten Löchern. */
|
||||||
|
ring: Vec2[];
|
||||||
|
/**
|
||||||
|
* Kanten-Indizes der eingefügten Brückenkanten im Ergebnis-Ring (Kante i =
|
||||||
|
* ring[i]→ring[(i+1)%n]) — für `noStrokeEdges` im 2D (Brückenkanten sind
|
||||||
|
* künstliche Verbindungen, kein realer Bauteilrand, daher nicht stricheln).
|
||||||
|
* Je Loch genau 2 Einträge.
|
||||||
|
*/
|
||||||
|
bridgeEdges: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt Aussparungen (`holes`) über schmale Brücken in `outline` ein, sodass
|
||||||
|
* EIN einfacher Polygonring entsteht (der Brücken-Trick). Löcher werden
|
||||||
|
* ITERATIV in den (wachsenden) Ring eingefügt: je Loch das nächstgelegene
|
||||||
|
* SICHTBARE Ring-/Loch-Vertex-Paar verbinden (Sichtbarkeit = Verbindungs-
|
||||||
|
* strecke schneidet keine andere Ring-/Loch-Kante; bei Schnitt wird das
|
||||||
|
* nächstbeste Paar versucht). Der Loch-Ring wird dabei in GEGENLÄUFIGER
|
||||||
|
* Orientierung zum Aussenring eingefügt (Shoelace-Fläche des Ergebnisses =
|
||||||
|
* Aussenfläche − Lochfläche). Die Brücke besteht aus ZWEI knapp parallelen
|
||||||
|
* Kanten (`gap`-Versatz quer zur Brückenrichtung statt identischer Punkte),
|
||||||
|
* damit der Ring einfach (selbstüberschneidungsfrei) bleibt.
|
||||||
|
*
|
||||||
|
* Robustheit: degenerierte Löcher (< 3 Punkte oder ~Nullfläche) sowie Löcher,
|
||||||
|
* deren Schwerpunkt ausserhalb des aktuellen Rings liegt (Loch ausserhalb der
|
||||||
|
* Outline, oder Überlappung mit einem bereits eingefügten Loch), werden
|
||||||
|
* übersprungen. Ohne Löcher (leere Liste) liefert die Funktion `outline`
|
||||||
|
* unverändert (Kopie) mit leeren `bridgeEdges`.
|
||||||
|
*/
|
||||||
|
export function mergeHoles(outline: Vec2[], holes: Vec2[][], gap = 1e-4): MergedRing {
|
||||||
|
if (outline.length < 3 || holes.length === 0) {
|
||||||
|
return { ring: outline.slice(), bridgeEdges: [] };
|
||||||
|
}
|
||||||
|
const outerCCW = signedArea(outline) > 0;
|
||||||
|
let ring = outline.slice();
|
||||||
|
// Referenz-Paare der eingefügten Brückenkanten (Objekt-Identität) — damit
|
||||||
|
// die Kanten-Indizes robust am fertigen Ring ermittelt werden können,
|
||||||
|
// unabhängig von Index-Verschiebungen durch spätere Loch-Einfügungen.
|
||||||
|
const bridgePairs: [Vec2, Vec2][] = [];
|
||||||
|
|
||||||
|
for (const holeRaw of holes) {
|
||||||
|
if (holeRaw.length < 3) continue; // degeneriert
|
||||||
|
if (Math.abs(signedArea(holeRaw)) < 1e-9) continue; // Nulllinie/Punkt
|
||||||
|
const c = centroid(holeRaw);
|
||||||
|
if (!pointInPolygon(c, ring)) continue; // ausserhalb (oder bereits verlocht)
|
||||||
|
|
||||||
|
// Loch in Gegenrichtung zum Aussenring einfügen.
|
||||||
|
const holeCCW = signedArea(holeRaw) > 0;
|
||||||
|
const hole = holeCCW === outerCCW ? holeRaw.slice().reverse() : holeRaw.slice();
|
||||||
|
const m = hole.length;
|
||||||
|
|
||||||
|
// Nächstgelegenes SICHTBARES Paar (Ring-Vertex i, Loch-Vertex j): nach
|
||||||
|
// Abstand sortiert, erstes Paar ohne echten Schnitt mit Ring- oder
|
||||||
|
// Loch-Kanten nehmen (Fallback: nächstbestes Paar in der Reihenfolge,
|
||||||
|
// notfalls das absolut nächste).
|
||||||
|
const rEdges = ringEdges(ring);
|
||||||
|
const hEdges = ringEdges(hole);
|
||||||
|
const candidates: { i: number; j: number; d: number }[] = [];
|
||||||
|
for (let i = 0; i < ring.length; i++) {
|
||||||
|
for (let j = 0; j < m; j++) candidates.push({ i, j, d: dist(ring[i], hole[j]) });
|
||||||
|
}
|
||||||
|
candidates.sort((a, b) => a.d - b.d);
|
||||||
|
let chosen = candidates[0];
|
||||||
|
for (const cand of candidates) {
|
||||||
|
const P = ring[cand.i];
|
||||||
|
const Q = hole[cand.j];
|
||||||
|
const blocked =
|
||||||
|
rEdges.some(([e1, e2]) => segmentsProperlyCross(P, Q, e1, e2)) ||
|
||||||
|
hEdges.some(([e1, e2]) => segmentsProperlyCross(P, Q, e1, e2));
|
||||||
|
if (!blocked) {
|
||||||
|
chosen = cand;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { i, j } = chosen;
|
||||||
|
const A = ring[i];
|
||||||
|
const B = hole[j];
|
||||||
|
const perp = leftNormalUnit(sub(B, A));
|
||||||
|
const off: Vec2 = { x: perp.x * gap, y: perp.y * gap };
|
||||||
|
const A1: Vec2 = add(A, off);
|
||||||
|
const A2: Vec2 = sub(A, off);
|
||||||
|
const B1: Vec2 = add(B, off);
|
||||||
|
const B2: Vec2 = sub(B, off);
|
||||||
|
|
||||||
|
const holePart: Vec2[] = [];
|
||||||
|
for (let k = 1; k < m; k++) holePart.push(hole[(j + k) % m]);
|
||||||
|
|
||||||
|
ring = [...ring.slice(0, i), A1, B1, ...holePart, B2, A2, ...ring.slice(i + 1)];
|
||||||
|
bridgePairs.push([A1, B1], [B2, A2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridgeEdges: number[] = [];
|
||||||
|
for (let k = 0; k < ring.length; k++) {
|
||||||
|
const a = ring[k];
|
||||||
|
const b = ring[(k + 1) % ring.length];
|
||||||
|
if (bridgePairs.some(([x, y]) => x === a && y === b)) bridgeEdges.push(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ring, bridgeEdges };
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { roofGeometry, roofBBox, roofUndersideAt } from "./roof";
|
||||||
|
import type { Roof, Vec2 } from "../model/types";
|
||||||
|
|
||||||
|
// Rechteck 6×4 (x:0..6, y:0..4), Neigung 45° (tan=1), Traufhöhe 10, kein Überstand.
|
||||||
|
const RECT: Vec2[] = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 6, y: 0 },
|
||||||
|
{ x: 6, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
];
|
||||||
|
const E = 10;
|
||||||
|
|
||||||
|
function roof(over: Partial<Roof>): Roof {
|
||||||
|
return {
|
||||||
|
id: "R1",
|
||||||
|
type: "roof",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "35",
|
||||||
|
outline: RECT,
|
||||||
|
shape: "sattel",
|
||||||
|
pitchDeg: 45,
|
||||||
|
overhang: 0,
|
||||||
|
ridgeAxis: "x",
|
||||||
|
thickness: 0.2,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle 3D-Punkte einer Geometrie (Flächen + Giebel). */
|
||||||
|
function allZ(g: ReturnType<typeof roofGeometry>): number[] {
|
||||||
|
const zs: number[] = [];
|
||||||
|
for (const pl of g.planes) for (const p of pl.pts) zs.push(p[2]);
|
||||||
|
for (const gp of g.gables) for (const p of gp) zs.push(p[2]);
|
||||||
|
return zs;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("roofBBox", () => {
|
||||||
|
it("liefert die Bounding-Box", () => {
|
||||||
|
expect(roofBBox(RECT)).toEqual({ x0: 0, y0: 0, x1: 6, y1: 4 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("roofGeometry — Formen (6×4, 45°, First entlang X)", () => {
|
||||||
|
it("flach: eine waagrechte Fläche auf Traufhöhe, kein First", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "flach" }), E);
|
||||||
|
expect(g.planes).toHaveLength(1);
|
||||||
|
expect(g.ridges).toHaveLength(0);
|
||||||
|
expect(g.ridgeHeight).toBe(0);
|
||||||
|
expect(allZ(g).every((z) => z === E)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sattel: Firsthöhe = halbe Tiefe · tan, First mittig, 2 Flächen + 2 Giebel", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "sattel" }), E);
|
||||||
|
expect(g.ridgeHeight).toBeCloseTo(2, 6); // halfD 2 · tan45 1
|
||||||
|
expect(g.planes).toHaveLength(2);
|
||||||
|
expect(g.gables).toHaveLength(2);
|
||||||
|
expect(g.ridges).toHaveLength(1);
|
||||||
|
// First auf yc=2, z=E+2.
|
||||||
|
expect(g.ridges[0][0]).toEqual({ x: 0, y: 2 });
|
||||||
|
expect(g.ridges[0][1]).toEqual({ x: 6, y: 2 });
|
||||||
|
expect(Math.max(...allZ(g))).toBeCloseTo(E + 2, 6);
|
||||||
|
expect(Math.min(...allZ(g))).toBeCloseTo(E, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walm: First um halbe Tiefe verkürzt, 4 Flächen, 4 Grate, keine Giebel", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "walm" }), E);
|
||||||
|
expect(g.planes).toHaveLength(4);
|
||||||
|
expect(g.hips).toHaveLength(4);
|
||||||
|
expect(g.gables).toHaveLength(0);
|
||||||
|
// ra=2, rb=4.
|
||||||
|
expect(g.ridges[0][0]).toEqual({ x: 2, y: 2 });
|
||||||
|
expect(g.ridges[0][1]).toEqual({ x: 4, y: 2 });
|
||||||
|
expect(g.ridgeHeight).toBeCloseTo(2, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zelt: Spitze über der Mitte, 4 Flächen, 4 Grate zur Mitte", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "zelt" }), E);
|
||||||
|
expect(g.planes).toHaveLength(4);
|
||||||
|
expect(g.hips).toHaveLength(4);
|
||||||
|
expect(g.ridges).toHaveLength(0);
|
||||||
|
// hz = min(halfW 3, halfD 2)=2.
|
||||||
|
expect(g.ridgeHeight).toBeCloseTo(2, 6);
|
||||||
|
// Alle Grate laufen zum Mittelpunkt (3,2).
|
||||||
|
for (const hip of g.hips) expect(hip[1]).toEqual({ x: 3, y: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pult: eine Fläche, hohe Kante = Breite·tan über der Traufe", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "pult" }), E);
|
||||||
|
expect(g.planes).toHaveLength(1);
|
||||||
|
expect(g.ridgeHeight).toBeCloseTo(4, 6); // Tiefe 4 · tan45
|
||||||
|
expect(Math.max(...allZ(g))).toBeCloseTo(E + 4, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mansarde: 4 Flächen, 2 Knicklinien, First mittig, Knick tiefer als First", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "mansarde", pitchDeg: 70, pitchUpperDeg: 30 }), E);
|
||||||
|
expect(g.planes).toHaveLength(4);
|
||||||
|
expect(g.breaks).toHaveLength(2);
|
||||||
|
expect(g.ridges).toHaveLength(1);
|
||||||
|
expect(g.gables).toHaveLength(2); // Pentagon-Giebel
|
||||||
|
// First höher als 0, Knicklinien innerhalb der Tiefe.
|
||||||
|
expect(g.ridgeHeight).toBeGreaterThan(0);
|
||||||
|
expect(g.breaks[0][0].y).toBeCloseTo(0 + 2 * 0.4, 6); // yf = y0 + halfD·0.4
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mansarde giebel: Knick-Ratio parametriert die Knicklinien-Lage", () => {
|
||||||
|
const g = roofGeometry(
|
||||||
|
roof({ shape: "mansarde", mansardType: "giebel", mansardKneeRatio: 0.25 }),
|
||||||
|
E,
|
||||||
|
);
|
||||||
|
// yf = y0 + halfD·ratio = 0 + 2·0.25 = 0.5.
|
||||||
|
expect(g.breaks[0][0].y).toBeCloseTo(0.5, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mansarde walm: allseitig geknickt -> 8 Flächen, 4 Knicklinien (Rechteck), 8 Grate, keine Giebel", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "mansarde", mansardType: "walm" }), E);
|
||||||
|
expect(g.planes).toHaveLength(8); // 4 Sockel + 2 Walm-Trapeze + 2 Walm-Dreiecke
|
||||||
|
expect(g.breaks).toHaveLength(4); // inneres Knick-Rechteck
|
||||||
|
expect(g.hips).toHaveLength(8); // 4 Sockel-Grate + 4 obere Walm-Grate
|
||||||
|
expect(g.ridges).toHaveLength(1);
|
||||||
|
expect(g.gables).toHaveLength(0); // Walm hat keine Giebel
|
||||||
|
expect(g.ridgeHeight).toBeGreaterThan(0);
|
||||||
|
// Knick-Höhe < First-Höhe (Sockel endet unter dem First).
|
||||||
|
const kneeZ = g.planes[0].pts[2][2] - E; // z1 - e am inneren Punkt
|
||||||
|
expect(kneeZ).toBeGreaterThan(0);
|
||||||
|
expect(kneeZ).toBeLessThan(g.ridgeHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mansarde zelt: allseitig geknickt zur Spitze -> 8 Flächen, kein First, 8 Grate", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "mansarde", mansardType: "zelt" }), E);
|
||||||
|
expect(g.planes).toHaveLength(8); // 4 Sockel + 4 Spitzen-Dreiecke
|
||||||
|
expect(g.breaks).toHaveLength(4);
|
||||||
|
expect(g.hips).toHaveLength(8);
|
||||||
|
expect(g.ridges).toHaveLength(0); // Zelt hat keinen First
|
||||||
|
expect(g.gables).toHaveLength(0);
|
||||||
|
expect(g.ridgeHeight).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("roofGeometry — First entlang Y (Transponierung)", () => {
|
||||||
|
it("dreht die Sattel-First-Linie auf die X-Mitte", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "sattel", ridgeAxis: "y" }), E);
|
||||||
|
// Firstrichtung Y → First liegt bei x = xc = 3, läuft in Y.
|
||||||
|
expect(g.ridges[0][0]).toEqual({ x: 3, y: 0 });
|
||||||
|
expect(g.ridges[0][1]).toEqual({ x: 3, y: 4 });
|
||||||
|
// Firsthöhe nun aus der halben BREITE (3) · tan = 3.
|
||||||
|
expect(g.ridgeHeight).toBeCloseTo(3, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("roofGeometry — Überstand", () => {
|
||||||
|
it("weitet die Traufe um overhang nach aussen", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "flach", overhang: 0.5 }), E);
|
||||||
|
const bb = roofBBox(g.eaves);
|
||||||
|
expect(bb).toEqual({ x0: -0.5, y0: -0.5, x1: 6.5, y1: 4.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getrennter Traufe/Ortgang-Überstand: First entlang X -> Ortgang weitet X, Traufe Y", () => {
|
||||||
|
// ridgeAxis "x": First entlang X -> Ortgang (Giebel) an den X-Enden,
|
||||||
|
// Traufe an den Y-Seiten. overhang(Traufe)=0.3, overhangGable(Ortgang)=0.8.
|
||||||
|
const g = roofGeometry(
|
||||||
|
roof({ shape: "sattel", ridgeAxis: "x", overhang: 0.3, overhangGable: 0.8 }),
|
||||||
|
E,
|
||||||
|
);
|
||||||
|
const bb = roofBBox(g.eaves);
|
||||||
|
expect(bb).toEqual({ x0: -0.8, y0: -0.3, x1: 6.8, y1: 4.3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getrennter Überstand: First entlang Y -> Ortgang weitet Y, Traufe X", () => {
|
||||||
|
const g = roofGeometry(
|
||||||
|
roof({ shape: "sattel", ridgeAxis: "y", overhang: 0.3, overhangGable: 0.8 }),
|
||||||
|
E,
|
||||||
|
);
|
||||||
|
const bb = roofBBox(g.eaves);
|
||||||
|
expect(bb).toEqual({ x0: -0.3, y0: -0.8, x1: 6.3, y1: 4.8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overhangGable fehlt -> ringsum gleich overhang (rückwärtskompatibel)", () => {
|
||||||
|
const g = roofGeometry(roof({ shape: "sattel", overhang: 0.4 }), E);
|
||||||
|
const bb = roofBBox(g.eaves);
|
||||||
|
expect(bb).toEqual({ x0: -0.4, y0: -0.4, x1: 6.4, y1: 4.4 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("roofUndersideAt — Dach-Unterkante an einem Grundriss-Punkt", () => {
|
||||||
|
// Sattel 6×4, First entlang X bei yc=2, Neigung 45° (tan=1) -> Firsthöhe 2.
|
||||||
|
// Fläche 1 (y0=0..yc=2): z(x,y) = 10 + y (linear, unabhängig von x).
|
||||||
|
const g = roofGeometry(roof({ shape: "sattel" }), E);
|
||||||
|
|
||||||
|
it("an der Traufe (y=0): Oberkante 10, Unterkante minus Schichtdicke", () => {
|
||||||
|
expect(roofUndersideAt(g, 0, 3, 0)).toBeCloseTo(10, 6);
|
||||||
|
expect(roofUndersideAt(g, 0.2, 3, 0)).toBeCloseTo(9.8, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("am First (y=2): Oberkante 12 (Firsthöhe 10+2)", () => {
|
||||||
|
expect(roofUndersideAt(g, 0, 3, 2)).toBeCloseTo(12, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpoliert linear zwischen Traufe und First (y=1 -> 11)", () => {
|
||||||
|
expect(roofUndersideAt(g, 0, 3, 1)).toBeCloseTo(11, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ausserhalb des Dach-Umrisses -> null", () => {
|
||||||
|
expect(roofUndersideAt(g, 0, 3, -1)).toBeNull();
|
||||||
|
expect(roofUndersideAt(g, 0, 8, 1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative/fehlende Schichtdicke wird auf 0 geklemmt (keine Anhebung)", () => {
|
||||||
|
expect(roofUndersideAt(g, -0.5, 3, 0)).toBeCloseTo(10, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
// Dach-Geometrie: leitet aus einem (rechteckigen) Grundriss-Umriss + Dachform +
|
||||||
|
// Neigung die Grundriss-Linien (Traufe/First/Grat/Knick) UND die 3D-Dachflächen
|
||||||
|
// ab. Bewusst auf der BOUNDING-BOX des Umrisses gerechnet (First entlang einer
|
||||||
|
// Hauptachse) — die gängige, intuitive Vereinfachung, die alle Standardformen
|
||||||
|
// (Flach/Pult/Sattel/Walm/Mansarde/Zelt) exakt und ohne Straight-Skeleton abdeckt.
|
||||||
|
//
|
||||||
|
// Koordinaten: Grundriss (x, y) in Metern; 3D-Punkte als [x, y, z] mit z = Höhe.
|
||||||
|
// Reine Datenschicht (kein React/Store), voll unit-testbar.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import type { Project, Roof, Vec2 } from "../model/types";
|
||||||
|
import { getFloor } from "../model/types";
|
||||||
|
|
||||||
|
/** 3D-Punkt [x, y, z=Höhe] in Modell-Metern. */
|
||||||
|
export type Vec3 = [number, number, number];
|
||||||
|
|
||||||
|
/** Eine ebene Dachfläche als Polygon (3D-Eckpunkte in Umlaufreihenfolge). */
|
||||||
|
export interface RoofPlane {
|
||||||
|
pts: Vec3[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollständige Dach-Geometrie (Grundriss-Linien + 3D-Flächen). */
|
||||||
|
export interface RoofGeometry {
|
||||||
|
/** Traufe-Umriss (Grundriss, inkl. Überstand) als geschlossener Ring. */
|
||||||
|
eaves: Vec2[];
|
||||||
|
/** Firstlinie(n) im Grundriss (leer bei Flach/Zelt). */
|
||||||
|
ridges: [Vec2, Vec2][];
|
||||||
|
/** Gratlinien (Walm/Zelt: Ecke → First/Spitze). */
|
||||||
|
hips: [Vec2, Vec2][];
|
||||||
|
/** Knicklinien (Mansarde). */
|
||||||
|
breaks: [Vec2, Vec2][];
|
||||||
|
/** 3D-Dachflächen. */
|
||||||
|
planes: RoofPlane[];
|
||||||
|
/** Giebelflächen (vertikale Polygone an Sattel-/Mansarde-Enden). */
|
||||||
|
gables: Vec3[][];
|
||||||
|
/** Firsthöhe über der Traufe (Meter). */
|
||||||
|
ridgeHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEG = Math.PI / 180;
|
||||||
|
|
||||||
|
/** Bounding-Box eines Umrisses. */
|
||||||
|
export function roofBBox(outline: Vec2[]): { x0: number; y0: number; x1: number; y1: number } {
|
||||||
|
let x0 = Infinity;
|
||||||
|
let y0 = Infinity;
|
||||||
|
let x1 = -Infinity;
|
||||||
|
let y1 = -Infinity;
|
||||||
|
for (const p of outline) {
|
||||||
|
if (p.x < x0) x0 = p.x;
|
||||||
|
if (p.y < y0) y0 = p.y;
|
||||||
|
if (p.x > x1) x1 = p.x;
|
||||||
|
if (p.y > y1) y1 = p.y;
|
||||||
|
}
|
||||||
|
return { x0, y0, x1, y1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traufhöhe (absolutes Z) eines Dachs: `baseElevation`, sonst die OBERKANTE des
|
||||||
|
* zugehörigen Geschosses (baseElevation + floorHeight) — das Dach sitzt also
|
||||||
|
* standardmäßig auf der Geschossdecke auf.
|
||||||
|
*/
|
||||||
|
export function roofBaseElevation(project: Project, roof: Roof): number {
|
||||||
|
if (roof.baseElevation !== undefined) return roof.baseElevation;
|
||||||
|
try {
|
||||||
|
const floor = getFloor(project, roof.floorId);
|
||||||
|
return (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const P = (x: number, y: number): Vec2 => ({ x, y });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kanonische Berechnung mit First entlang der X-Achse auf dem Rechteck
|
||||||
|
* [x0,x1]×[y0,y1] (Traufe, inkl. Überstand) auf Traufhöhe `e`. Die „y"-First-
|
||||||
|
* Richtung wird im Wrapper durch Transponieren (x↔y) erreicht.
|
||||||
|
*/
|
||||||
|
function computeCanonical(
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
e: number,
|
||||||
|
roof: Roof,
|
||||||
|
): RoofGeometry {
|
||||||
|
const xc = (x0 + x1) / 2;
|
||||||
|
const yc = (y0 + y1) / 2;
|
||||||
|
const halfW = (x1 - x0) / 2;
|
||||||
|
const halfD = (y1 - y0) / 2;
|
||||||
|
const a = Math.max(0, roof.pitchDeg) * DEG;
|
||||||
|
const tan = Math.tan(a);
|
||||||
|
const h = halfD * tan; // Firsthöhe (Sattel/Walm/Mansarde)
|
||||||
|
|
||||||
|
const eaves: Vec2[] = [P(x0, y0), P(x1, y0), P(x1, y1), P(x0, y1)];
|
||||||
|
const flat = (): RoofGeometry => ({
|
||||||
|
eaves,
|
||||||
|
ridges: [],
|
||||||
|
hips: [],
|
||||||
|
breaks: [],
|
||||||
|
planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e], [x0, y1, e]] }],
|
||||||
|
gables: [],
|
||||||
|
ridgeHeight: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (roof.shape) {
|
||||||
|
case "flach":
|
||||||
|
return flat();
|
||||||
|
|
||||||
|
case "pult": {
|
||||||
|
// Eine Fläche: tiefe Seite y0 auf Traufe, hohe Seite y1 auf e+H.
|
||||||
|
const H = (y1 - y0) * tan;
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [[P(x0, y1), P(x1, y1)]], // hohe Kante (First-Ersatz)
|
||||||
|
hips: [],
|
||||||
|
breaks: [],
|
||||||
|
planes: [{ pts: [[x0, y0, e], [x1, y0, e], [x1, y1, e + H], [x0, y1, e + H]] }],
|
||||||
|
gables: [
|
||||||
|
[[x0, y0, e], [x0, y1, e + H], [x0, y1, e]],
|
||||||
|
[[x1, y0, e], [x1, y1, e + H], [x1, y1, e]],
|
||||||
|
],
|
||||||
|
ridgeHeight: H,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sattel": {
|
||||||
|
const rz = e + h;
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [[P(x0, yc), P(x1, yc)]],
|
||||||
|
hips: [],
|
||||||
|
breaks: [],
|
||||||
|
planes: [
|
||||||
|
{ pts: [[x0, y0, e], [x1, y0, e], [x1, yc, rz], [x0, yc, rz]] },
|
||||||
|
{ pts: [[x0, y1, e], [x1, y1, e], [x1, yc, rz], [x0, yc, rz]] },
|
||||||
|
],
|
||||||
|
gables: [
|
||||||
|
[[x0, y0, e], [x0, y1, e], [x0, yc, rz]],
|
||||||
|
[[x1, y0, e], [x1, y1, e], [x1, yc, rz]],
|
||||||
|
],
|
||||||
|
ridgeHeight: h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "walm": {
|
||||||
|
const rz = e + h;
|
||||||
|
// First um die halbe Tiefe an beiden Enden verkürzt (45°-Grate bei
|
||||||
|
// gleicher Neigung). Bei schmalem Baukörper (halfW<halfD) degeneriert er
|
||||||
|
// zum Punkt (xc) → Walm wird zeltartig.
|
||||||
|
let ra = x0 + halfD;
|
||||||
|
let rb = x1 - halfD;
|
||||||
|
if (ra > rb) ra = rb = xc;
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [[P(ra, yc), P(rb, yc)]],
|
||||||
|
hips: [
|
||||||
|
[P(x0, y0), P(ra, yc)],
|
||||||
|
[P(x0, y1), P(ra, yc)],
|
||||||
|
[P(x1, y0), P(rb, yc)],
|
||||||
|
[P(x1, y1), P(rb, yc)],
|
||||||
|
],
|
||||||
|
breaks: [],
|
||||||
|
planes: [
|
||||||
|
{ pts: [[x0, y0, e], [x1, y0, e], [rb, yc, rz], [ra, yc, rz]] },
|
||||||
|
{ pts: [[x0, y1, e], [x1, y1, e], [rb, yc, rz], [ra, yc, rz]] },
|
||||||
|
{ pts: [[x0, y0, e], [x0, y1, e], [ra, yc, rz]] },
|
||||||
|
{ pts: [[x1, y0, e], [x1, y1, e], [rb, yc, rz]] },
|
||||||
|
],
|
||||||
|
gables: [],
|
||||||
|
ridgeHeight: h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mansarde": {
|
||||||
|
// Mansarde: steile untere Neigung (pitchDeg) bis zur Knicklinie, dann
|
||||||
|
// flache obere (pitchUpperDeg ?? halbe Hauptneigung). Untertyp steuert, ob
|
||||||
|
// die Knickform an ZWEI Seiten sitzt (Giebel) oder RINGSUM (Walm/Zelt).
|
||||||
|
const aLow = a;
|
||||||
|
const aUp = Math.max(0, roof.pitchUpperDeg ?? roof.pitchDeg / 2) * DEG;
|
||||||
|
const ratio = Math.min(0.49, Math.max(0.05, roof.mansardKneeRatio ?? 0.4));
|
||||||
|
const mType = roof.mansardType ?? "giebel";
|
||||||
|
|
||||||
|
if (mType === "giebel") {
|
||||||
|
// Mansard-Satteldach: Knick nur an den beiden Traufseiten, Giebel an den
|
||||||
|
// Enden (bisheriges Verhalten, jetzt mit parametrierbarem Knick).
|
||||||
|
const d1 = halfD * ratio;
|
||||||
|
const z1 = e + d1 * Math.tan(aLow);
|
||||||
|
const z2 = z1 + (halfD - d1) * Math.tan(aUp);
|
||||||
|
const yf = y0 + d1;
|
||||||
|
const yb = y1 - d1;
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [[P(x0, yc), P(x1, yc)]],
|
||||||
|
hips: [],
|
||||||
|
breaks: [
|
||||||
|
[P(x0, yf), P(x1, yf)],
|
||||||
|
[P(x0, yb), P(x1, yb)],
|
||||||
|
],
|
||||||
|
planes: [
|
||||||
|
{ pts: [[x0, y0, e], [x1, y0, e], [x1, yf, z1], [x0, yf, z1]] },
|
||||||
|
{ pts: [[x0, yf, z1], [x1, yf, z1], [x1, yc, z2], [x0, yc, z2]] },
|
||||||
|
{ pts: [[x0, y1, e], [x1, y1, e], [x1, yb, z1], [x0, yb, z1]] },
|
||||||
|
{ pts: [[x0, yb, z1], [x1, yb, z1], [x1, yc, z2], [x0, yc, z2]] },
|
||||||
|
],
|
||||||
|
gables: [
|
||||||
|
[[x0, y0, e], [x0, yf, z1], [x0, yc, z2], [x0, yb, z1], [x0, y1, e]],
|
||||||
|
[[x1, y0, e], [x1, yf, z1], [x1, yc, z2], [x1, yb, z1], [x1, y1, e]],
|
||||||
|
],
|
||||||
|
ridgeHeight: z2 - e,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allseitige Mansarde (Walm/Zelt): steiler Sockel ringsum bis zur
|
||||||
|
// Knicklinie (inneres Rechteck), darüber flacher Walm bzw. flache Spitze.
|
||||||
|
const dLow = Math.min(halfW, halfD) * ratio;
|
||||||
|
const z1 = e + dLow * Math.tan(aLow);
|
||||||
|
const ix0 = x0 + dLow;
|
||||||
|
const iy0 = y0 + dLow;
|
||||||
|
const ix1 = x1 - dLow;
|
||||||
|
const iy1 = y1 - dLow;
|
||||||
|
const ihalfD = (iy1 - iy0) / 2;
|
||||||
|
const ihalfW = (ix1 - ix0) / 2;
|
||||||
|
// Sockel-Trapeze (4 Seiten, steil) + Sockel-Grate (Ecken aussen→innen) +
|
||||||
|
// Knicklinie (inneres Rechteck) sind beiden allseitigen Formen gemein.
|
||||||
|
const socketPlanes = [
|
||||||
|
{ pts: [[x0, y0, e], [x1, y0, e], [ix1, iy0, z1], [ix0, iy0, z1]] as Vec3[] },
|
||||||
|
{ pts: [[x1, y1, e], [x0, y1, e], [ix0, iy1, z1], [ix1, iy1, z1]] as Vec3[] },
|
||||||
|
{ pts: [[x0, y1, e], [x0, y0, e], [ix0, iy0, z1], [ix0, iy1, z1]] as Vec3[] },
|
||||||
|
{ pts: [[x1, y0, e], [x1, y1, e], [ix1, iy1, z1], [ix1, iy0, z1]] as Vec3[] },
|
||||||
|
];
|
||||||
|
const socketHips: [Vec2, Vec2][] = [
|
||||||
|
[P(x0, y0), P(ix0, iy0)],
|
||||||
|
[P(x1, y0), P(ix1, iy0)],
|
||||||
|
[P(x1, y1), P(ix1, iy1)],
|
||||||
|
[P(x0, y1), P(ix0, iy1)],
|
||||||
|
];
|
||||||
|
const kneeBreaks: [Vec2, Vec2][] = [
|
||||||
|
[P(ix0, iy0), P(ix1, iy0)],
|
||||||
|
[P(ix1, iy0), P(ix1, iy1)],
|
||||||
|
[P(ix1, iy1), P(ix0, iy1)],
|
||||||
|
[P(ix0, iy1), P(ix0, iy0)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (mType === "walm") {
|
||||||
|
// Oberer Walm auf dem inneren Rechteck (First entlang X).
|
||||||
|
const z2 = z1 + ihalfD * Math.tan(aUp);
|
||||||
|
let ra = ix0 + ihalfD;
|
||||||
|
let rb = ix1 - ihalfD;
|
||||||
|
if (ra > rb) ra = rb = xc;
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [[P(ra, yc), P(rb, yc)]],
|
||||||
|
hips: [
|
||||||
|
...socketHips,
|
||||||
|
[P(ix0, iy0), P(ra, yc)],
|
||||||
|
[P(ix1, iy0), P(rb, yc)],
|
||||||
|
[P(ix1, iy1), P(rb, yc)],
|
||||||
|
[P(ix0, iy1), P(ra, yc)],
|
||||||
|
],
|
||||||
|
breaks: kneeBreaks,
|
||||||
|
planes: [
|
||||||
|
...socketPlanes,
|
||||||
|
{ pts: [[ix0, iy0, z1], [ix1, iy0, z1], [rb, yc, z2], [ra, yc, z2]] },
|
||||||
|
{ pts: [[ix1, iy1, z1], [ix0, iy1, z1], [ra, yc, z2], [rb, yc, z2]] },
|
||||||
|
{ pts: [[ix0, iy1, z1], [ix0, iy0, z1], [ra, yc, z2]] },
|
||||||
|
{ pts: [[ix1, iy0, z1], [ix1, iy1, z1], [rb, yc, z2]] },
|
||||||
|
],
|
||||||
|
gables: [],
|
||||||
|
ridgeHeight: z2 - e,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "zelt": flache Spitze über der Mitte des inneren Rechtecks.
|
||||||
|
const z2 = z1 + Math.min(ihalfW, ihalfD) * Math.tan(aUp);
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [],
|
||||||
|
hips: [
|
||||||
|
...socketHips,
|
||||||
|
[P(ix0, iy0), P(xc, yc)],
|
||||||
|
[P(ix1, iy0), P(xc, yc)],
|
||||||
|
[P(ix1, iy1), P(xc, yc)],
|
||||||
|
[P(ix0, iy1), P(xc, yc)],
|
||||||
|
],
|
||||||
|
breaks: kneeBreaks,
|
||||||
|
planes: [
|
||||||
|
...socketPlanes,
|
||||||
|
{ pts: [[ix0, iy0, z1], [ix1, iy0, z1], [xc, yc, z2]] },
|
||||||
|
{ pts: [[ix1, iy0, z1], [ix1, iy1, z1], [xc, yc, z2]] },
|
||||||
|
{ pts: [[ix1, iy1, z1], [ix0, iy1, z1], [xc, yc, z2]] },
|
||||||
|
{ pts: [[ix0, iy1, z1], [ix0, iy0, z1], [xc, yc, z2]] },
|
||||||
|
],
|
||||||
|
gables: [],
|
||||||
|
ridgeHeight: z2 - e,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "zelt": {
|
||||||
|
// Allseitig zur Spitze über der Mitte.
|
||||||
|
const hz = Math.min(halfW, halfD) * tan;
|
||||||
|
const az = e + hz;
|
||||||
|
return {
|
||||||
|
eaves,
|
||||||
|
ridges: [],
|
||||||
|
hips: [
|
||||||
|
[P(x0, y0), P(xc, yc)],
|
||||||
|
[P(x1, y0), P(xc, yc)],
|
||||||
|
[P(x1, y1), P(xc, yc)],
|
||||||
|
[P(x0, y1), P(xc, yc)],
|
||||||
|
],
|
||||||
|
breaks: [],
|
||||||
|
planes: [
|
||||||
|
{ pts: [[x0, y0, e], [x1, y0, e], [xc, yc, az]] },
|
||||||
|
{ pts: [[x1, y0, e], [x1, y1, e], [xc, yc, az]] },
|
||||||
|
{ pts: [[x1, y1, e], [x0, y1, e], [xc, yc, az]] },
|
||||||
|
{ pts: [[x0, y1, e], [x0, y0, e], [xc, yc, az]] },
|
||||||
|
],
|
||||||
|
gables: [],
|
||||||
|
ridgeHeight: hz,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const swap2 = (p: Vec2): Vec2 => ({ x: p.y, y: p.x });
|
||||||
|
const swap3 = (p: Vec3): Vec3 => [p[1], p[0], p[2]];
|
||||||
|
const swapLine = (l: [Vec2, Vec2]): [Vec2, Vec2] => [swap2(l[0]), swap2(l[1])];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dach-Geometrie aus Form/Neigung/Überstand + Traufhöhe `eavesZ`. Rechnet auf
|
||||||
|
* der Bounding-Box des Umrisses, um `overhang` nach aussen geweitet. Für
|
||||||
|
* `ridgeAxis:"y"` wird kanonisch (First entlang X) gerechnet und danach x↔y
|
||||||
|
* transponiert.
|
||||||
|
*/
|
||||||
|
export function roofGeometry(roof: Roof, eavesZ: number): RoofGeometry {
|
||||||
|
const bb = roofBBox(roof.outline);
|
||||||
|
const oe = Math.max(0, roof.overhang); // Traufüberstand (senkrecht zum First)
|
||||||
|
const og = Math.max(0, roof.overhangGable ?? roof.overhang); // Ortgang (entlang First)
|
||||||
|
// Der First liegt entlang der `ridgeAxis`-Achse: dort wirkt der Ortgang-, quer
|
||||||
|
// dazu der Traufüberstand. In OUTLINE-Koordinaten je nach Firstrichtung mappen.
|
||||||
|
const ox = roof.ridgeAxis === "x" ? og : oe;
|
||||||
|
const oy = roof.ridgeAxis === "x" ? oe : og;
|
||||||
|
let x0 = bb.x0 - ox;
|
||||||
|
let y0 = bb.y0 - oy;
|
||||||
|
let x1 = bb.x1 + ox;
|
||||||
|
let y1 = bb.y1 + oy;
|
||||||
|
const flip = roof.ridgeAxis === "y";
|
||||||
|
if (flip) {
|
||||||
|
[x0, y0, x1, y1] = [y0, x0, y1, x1];
|
||||||
|
}
|
||||||
|
const g = computeCanonical(x0, y0, x1, y1, eavesZ, roof);
|
||||||
|
if (!flip) return g;
|
||||||
|
return {
|
||||||
|
eaves: g.eaves.map(swap2),
|
||||||
|
ridges: g.ridges.map(swapLine),
|
||||||
|
hips: g.hips.map(swapLine),
|
||||||
|
breaks: g.breaks.map(swapLine),
|
||||||
|
planes: g.planes.map((pl) => ({ pts: pl.pts.map(swap3) })),
|
||||||
|
gables: g.gables.map((poly) => poly.map(swap3)),
|
||||||
|
ridgeHeight: g.ridgeHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Punkt-in-Polygon (Ray-Casting), 2D-Aufsicht. */
|
||||||
|
function pointInPolygon2D(p: Vec2, poly: Vec2[]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
const n = poly.length;
|
||||||
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||||
|
const a = poly[i];
|
||||||
|
const b = poly[j];
|
||||||
|
const intersect =
|
||||||
|
a.y > p.y !== b.y > p.y &&
|
||||||
|
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dach-UNTERKANTE (Innenseite der untersten Schicht) an einem Grundriss-Punkt
|
||||||
|
* (x,y) — `null`, wenn der Punkt ausserhalb aller Dachflächen liegt (Aufsicht).
|
||||||
|
* Ebenengleichung je Fläche über die Newell-Normale (identische Herleitung wie
|
||||||
|
* `toSection.ts::appendRoofSections`, dort inline für den Vertikalschnitt) minus
|
||||||
|
* `totalLayerThickness` (Summe aller Dachschicht-Dicken, s. `roofLayers`).
|
||||||
|
* Dient dem Wand-Zuschnitt unter geneigten Dächern ({@link trimWallTopForRoofs}
|
||||||
|
* in `toWalls3d.ts`) — vermeidet, dass ein flacher Wand-Top die Dachfläche
|
||||||
|
* durchdringt (Z-Fighting).
|
||||||
|
*/
|
||||||
|
export function roofUndersideAt(
|
||||||
|
geo: RoofGeometry,
|
||||||
|
totalLayerThickness: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): number | null {
|
||||||
|
for (const pl of geo.planes) {
|
||||||
|
const pts = pl.pts;
|
||||||
|
if (pts.length < 3) continue;
|
||||||
|
if (!pointInPolygon2D({ x, y }, pts.map((p) => ({ x: p[0], y: p[1] })))) continue;
|
||||||
|
let A = 0;
|
||||||
|
let B = 0;
|
||||||
|
let C = 0;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const c = pts[i];
|
||||||
|
const d = pts[(i + 1) % pts.length];
|
||||||
|
A += (c[1] - d[1]) * (c[2] + d[2]);
|
||||||
|
B += (c[2] - d[2]) * (c[0] + d[0]);
|
||||||
|
C += (c[0] - d[0]) * (c[1] + d[1]);
|
||||||
|
}
|
||||||
|
if (Math.abs(C) < 1e-9) continue; // (nahezu) vertikale Fläche
|
||||||
|
const topZ = pts[0][2] - (A * (x - pts[0][0]) + B * (y - pts[0][1])) / C;
|
||||||
|
return topZ - Math.max(0, totalLayerThickness);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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
|
// GF Geschossfläche
|
||||||
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
|
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
|
||||||
|
// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …)
|
||||||
// └── NGF Nettogeschossfläche
|
// └── NGF Nettogeschossfläche
|
||||||
// ├── NF Nutzfläche
|
// ├── NF Nutzfläche
|
||||||
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
|
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
|
||||||
@@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 {
|
|||||||
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
|
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
|
||||||
//
|
//
|
||||||
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
|
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
|
||||||
// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF.
|
// HNF, NNF, VF, FF, KGF und AGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF+AGF = GF.
|
||||||
|
// AGF zählt zur Geschossfläche, ist aber ausserhalb der Gebäudehülle (Aussen-
|
||||||
|
// bereich) und fliesst daher NICHT in NGF (bzw. NF) ein.
|
||||||
|
|
||||||
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
|
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
|
||||||
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
|
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF";
|
||||||
|
|
||||||
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
|
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
|
||||||
export type SiaAggregate = "NF" | "NGF" | "GF";
|
export type SiaAggregate = "NF" | "NGF" | "GF";
|
||||||
@@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
|
|||||||
"VF",
|
"VF",
|
||||||
"FF",
|
"FF",
|
||||||
"KGF",
|
"KGF",
|
||||||
|
"AGF",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
|
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
|
||||||
@@ -134,6 +138,7 @@ const SIA_LABELS: Record<SiaKey, string> = {
|
|||||||
VF: "Verkehrsfläche",
|
VF: "Verkehrsfläche",
|
||||||
FF: "Funktionsfläche",
|
FF: "Funktionsfläche",
|
||||||
KGF: "Konstruktionsfläche",
|
KGF: "Konstruktionsfläche",
|
||||||
|
AGF: "Aussengeschossfläche",
|
||||||
NF: "Nutzfläche",
|
NF: "Nutzfläche",
|
||||||
NGF: "Nettogeschossfläche",
|
NGF: "Nettogeschossfläche",
|
||||||
GF: "Geschossfläche",
|
GF: "Geschossfläche",
|
||||||
@@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string {
|
|||||||
/**
|
/**
|
||||||
* Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu
|
* Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu
|
||||||
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
|
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
|
||||||
* Wird für den Bilanz-Roll-up genutzt.
|
* AGF (Aussengeschossfläche) trägt nur zu GF bei — sie liegt ausserhalb der
|
||||||
|
* Gebäudehülle und zählt NICHT zu NF/NGF. Wird für den Bilanz-Roll-up genutzt.
|
||||||
*/
|
*/
|
||||||
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
|
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
|
||||||
HNF: ["NF", "NGF", "GF"],
|
HNF: ["NF", "NGF", "GF"],
|
||||||
@@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
|
|||||||
VF: ["NGF", "GF"],
|
VF: ["NGF", "GF"],
|
||||||
FF: ["NGF", "GF"],
|
FF: ["NGF", "GF"],
|
||||||
KGF: ["GF"],
|
KGF: ["GF"],
|
||||||
|
AGF: ["GF"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
|
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
|
||||||
@@ -242,14 +249,14 @@ export interface Balance {
|
|||||||
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
|
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
|
||||||
*
|
*
|
||||||
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
|
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
|
||||||
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ)
|
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, AGF, GF(=Σ)
|
||||||
* — passend für die Bilanz-Tabelle und den CSV-Export.
|
* — passend für die Bilanz-Tabelle und den CSV-Export.
|
||||||
*/
|
*/
|
||||||
export function balance(
|
export function balance(
|
||||||
rooms: { category: SiaCategory; area: number }[],
|
rooms: { category: SiaCategory; area: number }[],
|
||||||
): Balance {
|
): Balance {
|
||||||
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
|
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
|
||||||
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
|
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
|
||||||
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
||||||
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
||||||
|
|
||||||
@@ -269,6 +276,7 @@ export function balance(
|
|||||||
VF: leaf.VF,
|
VF: leaf.VF,
|
||||||
FF: leaf.FF,
|
FF: leaf.FF,
|
||||||
KGF: leaf.KGF,
|
KGF: leaf.KGF,
|
||||||
|
AGF: leaf.AGF,
|
||||||
NF: agg.NF,
|
NF: agg.NF,
|
||||||
NGF: agg.NGF,
|
NGF: agg.NGF,
|
||||||
GF: agg.GF,
|
GF: agg.GF,
|
||||||
@@ -297,6 +305,7 @@ export function balance(
|
|||||||
mkLeaf("FF"),
|
mkLeaf("FF"),
|
||||||
mkAgg("NGF"),
|
mkAgg("NGF"),
|
||||||
mkLeaf("KGF"),
|
mkLeaf("KGF"),
|
||||||
|
mkLeaf("AGF"),
|
||||||
mkAgg("GF"),
|
mkAgg("GF"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+562
-8
@@ -15,16 +15,28 @@ export const de = {
|
|||||||
"topbar.perspektive": "Perspektive",
|
"topbar.perspektive": "Perspektive",
|
||||||
|
|
||||||
"view3d.group": "Ansicht 3D",
|
"view3d.group": "Ansicht 3D",
|
||||||
"view3d.front": "Front",
|
"view3d.front": "Vorne",
|
||||||
"view3d.front.hint": "Frontalansicht von vorne",
|
"view3d.front.hint": "Frontalansicht von vorne",
|
||||||
|
"view3d.back": "Hinten",
|
||||||
|
"view3d.back.hint": "Rückansicht von hinten",
|
||||||
"view3d.top": "Oben",
|
"view3d.top": "Oben",
|
||||||
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
|
||||||
"view3d.side": "Seite",
|
"view3d.side": "Rechts",
|
||||||
"view3d.side.hint": "Seitenansicht (90° zur Front)",
|
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
|
||||||
|
"view3d.left": "Links",
|
||||||
|
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
|
||||||
"view3d.perspective": "Perspektive",
|
"view3d.perspective": "Perspektive",
|
||||||
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
|
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
|
||||||
"view3d.iso": "Isometrie",
|
"view3d.iso": "Isometrie",
|
||||||
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
|
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
|
||||||
|
"view3d.isoFrontLeft": "Vorne-Links",
|
||||||
|
"view3d.isoFrontLeft.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-links)",
|
||||||
|
"view3d.isoBackRight": "Hinten-Rechts",
|
||||||
|
"view3d.isoBackRight.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-rechts)",
|
||||||
|
"view3d.isoBackLeft": "Hinten-Links",
|
||||||
|
"view3d.isoBackLeft.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-links)",
|
||||||
|
"view3d.iso.menu": "Weitere Iso-Ansichten",
|
||||||
|
"view3d.iso.menu.hint": "Einen der vier oberen Iso-Oktanten wählen",
|
||||||
"view3d.camera": "Kamera",
|
"view3d.camera": "Kamera",
|
||||||
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
|
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
|
||||||
"view3d.fov": "Sichtwinkel",
|
"view3d.fov": "Sichtwinkel",
|
||||||
@@ -72,6 +84,7 @@ export const de = {
|
|||||||
"display.style.color": "Farbig",
|
"display.style.color": "Farbig",
|
||||||
"display.style.mono": "Schwarz-Weiss",
|
"display.style.mono": "Schwarz-Weiss",
|
||||||
"display.style.shaded": "Schattiert",
|
"display.style.shaded": "Schattiert",
|
||||||
|
"display.style.shadedEdges": "Schattiert mit Kanten",
|
||||||
"display.style.white": "Weiss",
|
"display.style.white": "Weiss",
|
||||||
"display.style.textured": "Texturiert",
|
"display.style.textured": "Texturiert",
|
||||||
"display.style.wireframe": "Drahtgitter",
|
"display.style.wireframe": "Drahtgitter",
|
||||||
@@ -86,12 +99,12 @@ export const de = {
|
|||||||
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
|
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
|
||||||
|
|
||||||
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
|
||||||
"layout.title": "Layout",
|
"layout.title": "Arbeitsumgebung",
|
||||||
"layout.placeholder": "Layout …",
|
"layout.placeholder": "Arbeitsumgebung …",
|
||||||
"layout.saveCurrent": "+ Aktuelles speichern …",
|
"layout.saveCurrent": "+ Aktuelle speichern …",
|
||||||
"layout.loadGroup": "Laden",
|
"layout.loadGroup": "Laden",
|
||||||
"layout.deleteGroup": "Löschen",
|
"layout.deleteGroup": "Löschen",
|
||||||
"layout.savePrompt": "Layout speichern als:",
|
"layout.savePrompt": "Arbeitsumgebung speichern als:",
|
||||||
|
|
||||||
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
|
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
|
||||||
"combo.layers": "Ebenen-Komb.",
|
"combo.layers": "Ebenen-Komb.",
|
||||||
@@ -120,6 +133,10 @@ export const de = {
|
|||||||
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
|
||||||
"tool.arc": "Bogen",
|
"tool.arc": "Bogen",
|
||||||
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
|
||||||
|
"tool.text": "Text",
|
||||||
|
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
|
||||||
|
"tool.textbox": "Textspalte",
|
||||||
|
"tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen",
|
||||||
// Ribbon-Oberleiste (Tabs + Gruppen)
|
// Ribbon-Oberleiste (Tabs + Gruppen)
|
||||||
"ribbon.tab.2d": "2D",
|
"ribbon.tab.2d": "2D",
|
||||||
"ribbon.tab.3d": "3D",
|
"ribbon.tab.3d": "3D",
|
||||||
@@ -129,6 +146,7 @@ export const de = {
|
|||||||
"ribbon.group.draw": "Zeichnen",
|
"ribbon.group.draw": "Zeichnen",
|
||||||
"ribbon.group.modify": "Ändern",
|
"ribbon.group.modify": "Ändern",
|
||||||
"ribbon.group.components": "Bauteile",
|
"ribbon.group.components": "Bauteile",
|
||||||
|
"ribbon.group.sections": "Schnitte",
|
||||||
"ribbon.group.custom": "Eigene Leiste",
|
"ribbon.group.custom": "Eigene Leiste",
|
||||||
"ribbon.custom.add": "+ Hinzufügen",
|
"ribbon.custom.add": "+ Hinzufügen",
|
||||||
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
"ribbon.custom.empty": "Elemente über + Hinzufügen wählen",
|
||||||
@@ -165,6 +183,8 @@ export const de = {
|
|||||||
"snap.ortho": "Ortho (Shift)",
|
"snap.ortho": "Ortho (Shift)",
|
||||||
"snap.gridSize": "Rasterweite (m)",
|
"snap.gridSize": "Rasterweite (m)",
|
||||||
"snap.angleStep": "Winkelraster",
|
"snap.angleStep": "Winkelraster",
|
||||||
|
"snap.commonAngles": "Gängige Winkel",
|
||||||
|
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
|
||||||
|
|
||||||
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
|
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
|
||||||
"transform.bar": "Transformation",
|
"transform.bar": "Transformation",
|
||||||
@@ -234,6 +254,7 @@ export const de = {
|
|||||||
"attr.fill": "Füllung",
|
"attr.fill": "Füllung",
|
||||||
"attr.fillColor": "Füllfarbe",
|
"attr.fillColor": "Füllfarbe",
|
||||||
"attr.clearFill": "Füllfarbe entfernen",
|
"attr.clearFill": "Füllfarbe entfernen",
|
||||||
|
"attr.pickColor": "Farbe wählen",
|
||||||
"attr.foreground": "Vordergrund",
|
"attr.foreground": "Vordergrund",
|
||||||
"attr.background": "Hintergrund",
|
"attr.background": "Hintergrund",
|
||||||
"attr.bySystem": "Nach System",
|
"attr.bySystem": "Nach System",
|
||||||
@@ -249,6 +270,8 @@ export const de = {
|
|||||||
// Objekt-Info-Palette (ObjectInfoPanel)
|
// Objekt-Info-Palette (ObjectInfoPanel)
|
||||||
"objinfo.title": "Objekt-Info",
|
"objinfo.title": "Objekt-Info",
|
||||||
"objinfo.empty": "Kein Objekt gewählt",
|
"objinfo.empty": "Kein Objekt gewählt",
|
||||||
|
"objinfo.category": "Klasse",
|
||||||
|
"objinfo.level": "Ebene",
|
||||||
"objinfo.refpoint": "Bezugspunkt",
|
"objinfo.refpoint": "Bezugspunkt",
|
||||||
"objinfo.setRefpoint": "Bezugspunkt wählen",
|
"objinfo.setRefpoint": "Bezugspunkt wählen",
|
||||||
"objinfo.x": "X",
|
"objinfo.x": "X",
|
||||||
@@ -266,12 +289,60 @@ export const de = {
|
|||||||
"objinfo.kind.opening": "Öffnung",
|
"objinfo.kind.opening": "Öffnung",
|
||||||
"objinfo.kind.stair": "Treppe",
|
"objinfo.kind.stair": "Treppe",
|
||||||
"objinfo.kind.room": "Raum",
|
"objinfo.kind.room": "Raum",
|
||||||
|
"objinfo.kind.extrudedSolid": "Extrusion",
|
||||||
|
"objinfo.kind.column": "Stütze",
|
||||||
|
"objinfo.kind.roof": "Dach",
|
||||||
|
// ── Dach-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||||
|
"objinfo.roof.section": "Dach",
|
||||||
|
"objinfo.roof.shape": "Dachform",
|
||||||
|
"objinfo.roof.shape.flach": "Flachdach",
|
||||||
|
"objinfo.roof.shape.pult": "Pultdach",
|
||||||
|
"objinfo.roof.shape.sattel": "Satteldach",
|
||||||
|
"objinfo.roof.shape.walm": "Walmdach",
|
||||||
|
"objinfo.roof.shape.mansarde": "Mansarddach",
|
||||||
|
"objinfo.roof.shape.zelt": "Zeltdach",
|
||||||
|
"objinfo.roof.ridgeAxis": "Firstrichtung",
|
||||||
|
"objinfo.roof.ridgeAxis.x": "entlang X",
|
||||||
|
"objinfo.roof.ridgeAxis.y": "entlang Y",
|
||||||
|
"objinfo.roof.pitch": "Neigung (°)",
|
||||||
|
"objinfo.roof.pitchUpper": "Obere Neigung (°)",
|
||||||
|
"objinfo.roof.type": "Dachtyp",
|
||||||
|
"objinfo.roof.type.single": "Einschichtig (Dicke)",
|
||||||
|
"objinfo.roof.mansardType": "Mansard-Art",
|
||||||
|
"objinfo.roof.mansard.giebel": "Giebel-Mansarde",
|
||||||
|
"objinfo.roof.mansard.walm": "Walm-Mansarde",
|
||||||
|
"objinfo.roof.mansard.zelt": "Zelt-Mansarde",
|
||||||
|
"objinfo.roof.mansardKnee": "Knicklage (% Spannweite)",
|
||||||
|
"objinfo.roof.width": "Breite (m)",
|
||||||
|
"objinfo.roof.depth": "Tiefe (m)",
|
||||||
|
"objinfo.roof.overhang": "Überstand Traufe (m)",
|
||||||
|
"objinfo.roof.overhangGable": "Überstand Ortgang (m)",
|
||||||
|
"objinfo.roof.thickness": "Dachdicke (m)",
|
||||||
|
"objinfo.roof.baseElevation": "Traufhöhe (m)",
|
||||||
|
"objinfo.roof.ridgeHeight": "Firsthöhe",
|
||||||
|
"objinfo.roof.area": "Grundfläche",
|
||||||
|
// ── Stützen-Attribute (Object-Info-Panel) ───────────────────────────────
|
||||||
|
"objinfo.column.section": "Stütze",
|
||||||
|
"objinfo.column.profile": "Profil",
|
||||||
|
"objinfo.column.profile.rect": "Rechteck",
|
||||||
|
"objinfo.column.profile.round": "Rund",
|
||||||
|
"objinfo.column.width": "Breite",
|
||||||
|
"objinfo.column.depth": "Tiefe",
|
||||||
|
"objinfo.column.radius": "Radius",
|
||||||
|
"objinfo.column.height": "Höhe",
|
||||||
|
"objinfo.column.rotation": "Drehung (°)",
|
||||||
|
"objinfo.column.floor": "Geschoss",
|
||||||
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
|
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||||
"objinfo.wall.section": "Wand",
|
"objinfo.wall.section": "Wand",
|
||||||
"objinfo.wall.refLine": "Referenzlinie",
|
"objinfo.wall.refLine": "Referenzlinie",
|
||||||
"objinfo.wall.refLine.left": "Außen (links)",
|
"objinfo.wall.refLine.left": "Außen (links)",
|
||||||
"objinfo.wall.refLine.center": "Mitte",
|
"objinfo.wall.refLine.center": "Mitte",
|
||||||
"objinfo.wall.refLine.right": "Innen (rechts)",
|
"objinfo.wall.refLine.right": "Innen (rechts)",
|
||||||
|
"objinfo.wall.refLine.joint": "Fuge",
|
||||||
|
"objinfo.wall.sliceTermination": "An Decke enden",
|
||||||
|
"objinfo.wall.sliceTermination.both": "Aus",
|
||||||
|
"objinfo.wall.sliceTermination.below": "Unten",
|
||||||
|
"objinfo.wall.sliceTermination.above": "Oben",
|
||||||
"objinfo.wall.buildup": "Aufbau",
|
"objinfo.wall.buildup": "Aufbau",
|
||||||
"objinfo.wall.single": "Solid",
|
"objinfo.wall.single": "Solid",
|
||||||
"objinfo.wall.multi": "Mehrschichtig",
|
"objinfo.wall.multi": "Mehrschichtig",
|
||||||
@@ -284,6 +355,11 @@ export const de = {
|
|||||||
"objinfo.wall.top": "Oberkante (OK)",
|
"objinfo.wall.top": "Oberkante (OK)",
|
||||||
"objinfo.wall.anchorFloor": "Gebunden",
|
"objinfo.wall.anchorFloor": "Gebunden",
|
||||||
"objinfo.wall.anchorCustom": "Eigene",
|
"objinfo.wall.anchorCustom": "Eigene",
|
||||||
|
"objinfo.anchor.refFloor": "Referenzgeschoss",
|
||||||
|
"objinfo.anchor.customHeight": "Eigene Höhe",
|
||||||
|
"objinfo.wall.length": "Länge",
|
||||||
|
"objinfo.wall.volumeGross": "Brutto",
|
||||||
|
"objinfo.volume": "Volumen",
|
||||||
"objinfo.wall.height": "Höhe",
|
"objinfo.wall.height": "Höhe",
|
||||||
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
||||||
"objinfo.ceiling.section": "Decke",
|
"objinfo.ceiling.section": "Decke",
|
||||||
@@ -294,11 +370,88 @@ export const de = {
|
|||||||
"objinfo.ceiling.top": "Oberkante (OK)",
|
"objinfo.ceiling.top": "Oberkante (OK)",
|
||||||
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
||||||
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
||||||
|
"objinfo.ceiling.openings": "Aussparungen",
|
||||||
|
"objinfo.ceiling.removeOpening": "Aussparung entfernen",
|
||||||
"objinfo.ceiling.area": "Fläche",
|
"objinfo.ceiling.area": "Fläche",
|
||||||
"objinfo.ceiling.anchorFloor": "Gebunden",
|
"objinfo.ceiling.anchorFloor": "Gebunden",
|
||||||
"objinfo.ceiling.anchorCustom": "Eigene",
|
"objinfo.ceiling.anchorCustom": "Eigene",
|
||||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||||
"objinfo.opening.section": "Öffnung",
|
"objinfo.opening.section": "Öffnung",
|
||||||
|
"objinfo.opening.type": "Typ",
|
||||||
|
"objinfo.opening.editType": "Einstellungen …",
|
||||||
|
// Fenster-/Tür-Einstellungsdialog (OpeningEditorDialog).
|
||||||
|
"oed.title.window": "Fenster-Einstellungen",
|
||||||
|
"oed.title.door": "Tür-Einstellungen",
|
||||||
|
"oed.style": "Stil",
|
||||||
|
"oed.saveStyle": "Als Stil speichern …",
|
||||||
|
"oed.saveStyle.hint": "Aktuelle Einstellungen als neuen, wiederverwendbaren Stil ablegen",
|
||||||
|
"oed.saveStyle.prompt": "Name des neuen Stils:",
|
||||||
|
"oed.saveStyle.copySuffix": "(Kopie)",
|
||||||
|
"oed.categories": "Kategorien",
|
||||||
|
"oed.noStyle": "Kein Stil zugewiesen — über die Stil-Leiste einen Stil wählen oder speichern.",
|
||||||
|
"oed.preview": "Ansicht",
|
||||||
|
"oed.cat.basis": "Basis",
|
||||||
|
"oed.cat.groesse": "Grösse/Position",
|
||||||
|
"oed.cat.rahmen": "Rahmen",
|
||||||
|
"oed.cat.fluegel": "Flügel/Sprossen",
|
||||||
|
"oed.cat.sonnenschutz": "Sonnenschutz",
|
||||||
|
"oed.cat.tuerblatt": "Türblatt",
|
||||||
|
"oed.f.kind": "Öffnungsart",
|
||||||
|
"oed.f.glazing": "Verglasung",
|
||||||
|
"oed.f.insetFace": "Einbaulage ab",
|
||||||
|
"oed.f.insetFromFace": "Abstand von Kante (m)",
|
||||||
|
"oed.f.width": "Breite (m)",
|
||||||
|
"oed.f.height": "Höhe (m)",
|
||||||
|
"oed.f.sillHeight": "Brüstungshöhe (m)",
|
||||||
|
"oed.f.position": "Position (m)",
|
||||||
|
"oed.f.frameThickness": "Rahmenstärke quer (m)",
|
||||||
|
"oed.f.frameDepth": "Rahmentiefe (m)",
|
||||||
|
"oed.f.frameWidth": "Rahmenbreite Ansicht (m)",
|
||||||
|
"oed.f.transomHeight": "Oberlichthöhe (m)",
|
||||||
|
"oed.f.mullionRows": "Kämpfer-/Sprossenzeilen",
|
||||||
|
"oed.f.mullionCols": "Sprossen-Spalten",
|
||||||
|
"oed.f.sillLineFaces": "Auf-/Untersicht-Linie",
|
||||||
|
"oed.f.sillLineView": "Blickrichtung",
|
||||||
|
"oed.sillLine.off": "Keine",
|
||||||
|
"oed.sillLine.beide": "Beide",
|
||||||
|
"oed.sillLine.auf": "Aufsicht (von oben)",
|
||||||
|
"oed.sillLine.unter": "Untersicht (von unten)",
|
||||||
|
"oed.kind.dreh": "Dreh",
|
||||||
|
"oed.kind.kipp": "Kipp",
|
||||||
|
"oed.kind.drehkipp": "Dreh-Kipp",
|
||||||
|
"oed.kind.fest": "Fest",
|
||||||
|
"oed.kind.schiebe": "Schiebe",
|
||||||
|
"oed.glazing.1": "Einfach",
|
||||||
|
"oed.glazing.2": "Zweifach",
|
||||||
|
"oed.glazing.3": "Dreifach",
|
||||||
|
"oed.insetFace.aussen": "Aussenfläche",
|
||||||
|
"oed.insetFace.innen": "Innenfläche",
|
||||||
|
"oed.fluegel.hint": "Flügel und Pfosten von links nach rechts. Öffnungsart und Anschlag je Flügel.",
|
||||||
|
"oed.fluegel.col.kind": "Typ",
|
||||||
|
"oed.fluegel.col.opening": "Öffnung",
|
||||||
|
"oed.fluegel.col.hinge": "Anschlag",
|
||||||
|
"oed.fluegel.type.fluegel": "Flügel",
|
||||||
|
"oed.fluegel.type.pfosten": "Pfosten",
|
||||||
|
"oed.fluegel.remove": "Entfernen",
|
||||||
|
"oed.hinge.left": "Links",
|
||||||
|
"oed.hinge.right": "Rechts",
|
||||||
|
"oed.shading.create": "Rollladen-/Sonnenschutzkasten",
|
||||||
|
"oed.shading.kind": "Art",
|
||||||
|
"oed.shading.rollladen": "Rollladen",
|
||||||
|
"oed.shading.raffstore": "Raffstore",
|
||||||
|
"oed.shading.markise": "Markise",
|
||||||
|
"oed.shading.boxHeight": "Kastenhöhe (m)",
|
||||||
|
"oed.shading.boxDepth": "Kastentiefe (m)",
|
||||||
|
"oed.door.leafCount": "Türblätter",
|
||||||
|
"oed.door.leaf1": "Einflüglig",
|
||||||
|
"oed.door.leaf2": "Zweiflüglig",
|
||||||
|
"oed.door.leafStyle": "Blattart",
|
||||||
|
"oed.door.glatt": "Glatt",
|
||||||
|
"oed.door.kassette": "Kassette",
|
||||||
|
"oed.door.glas": "Glas",
|
||||||
|
"oed.door.wandoeffnung": "Wandöffnung",
|
||||||
|
"objinfo.stair.type": "Typ",
|
||||||
|
"objinfo.type.none": "(kein Typ)",
|
||||||
"objinfo.opening.kind": "Art",
|
"objinfo.opening.kind": "Art",
|
||||||
"objinfo.opening.kind.window": "Fenster",
|
"objinfo.opening.kind.window": "Fenster",
|
||||||
"objinfo.opening.kind.door": "Tür",
|
"objinfo.opening.kind.door": "Tür",
|
||||||
@@ -329,6 +482,11 @@ export const de = {
|
|||||||
"objinfo.opening.lintelLines.innen": "Innen",
|
"objinfo.opening.lintelLines.innen": "Innen",
|
||||||
"objinfo.opening.lintelLines.aussen": "Außen",
|
"objinfo.opening.lintelLines.aussen": "Außen",
|
||||||
"objinfo.opening.lintelLines.beide": "Beide",
|
"objinfo.opening.lintelLines.beide": "Beide",
|
||||||
|
"objinfo.opening.frameLine": "Rahmen (Schnitt)",
|
||||||
|
"objinfo.opening.frameViewLine": "Rahmen (Aufsicht)",
|
||||||
|
"objinfo.opening.sashLine": "Fenster (Flügel/Sprossen)",
|
||||||
|
"objinfo.opening.sillLine": "Sims",
|
||||||
|
"objinfo.lineWeight": "Strichstärke (mm)",
|
||||||
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
|
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
|
||||||
"objinfo.stair.section": "Treppe",
|
"objinfo.stair.section": "Treppe",
|
||||||
"objinfo.stair.shape": "Grundform",
|
"objinfo.stair.shape": "Grundform",
|
||||||
@@ -359,6 +517,28 @@ export const de = {
|
|||||||
"objinfo.room.perimeter": "Umfang",
|
"objinfo.room.perimeter": "Umfang",
|
||||||
"objinfo.room.refFloor": "Referenzgeschoss",
|
"objinfo.room.refFloor": "Referenzgeschoss",
|
||||||
"objinfo.room.editStamp": "Stempel bearbeiten …",
|
"objinfo.room.editStamp": "Stempel bearbeiten …",
|
||||||
|
// ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ─────────
|
||||||
|
"objinfo.sectionLine.section": "Schnittlinie",
|
||||||
|
"objinfo.sectionLine.kind.section": "Schnitt",
|
||||||
|
"objinfo.sectionLine.kind.elevation": "Ansicht",
|
||||||
|
"objinfo.sectionLine.name": "Name",
|
||||||
|
"objinfo.sectionLine.flip": "Blickrichtung umkehren",
|
||||||
|
"objinfo.sectionLine.startX": "Start X",
|
||||||
|
"objinfo.sectionLine.startY": "Start Y",
|
||||||
|
"objinfo.sectionLine.endX": "Ende X",
|
||||||
|
"objinfo.sectionLine.endY": "Ende Y",
|
||||||
|
"objinfo.sectionLine.depth": "Schnitt-Tiefe (m)",
|
||||||
|
"objinfo.sectionLine.hiddenLines": "Verdeckte Kanten",
|
||||||
|
"objinfo.sectionLine.depthHint": "leer = unbegrenzt",
|
||||||
|
"objinfo.sectionLine.delete": "Schnittlinie löschen",
|
||||||
|
"objinfo.extrudedSolid.section": "Extrusion",
|
||||||
|
"objinfo.extrudedSolid.height": "Höhe",
|
||||||
|
"objinfo.extrudedSolid.taper": "Verjüngung",
|
||||||
|
"objinfo.extrudedSolid.area": "Grundfläche",
|
||||||
|
"objinfo.extrudedSolid.floor": "Geschoss",
|
||||||
|
"objinfo.rotate": "Drehen",
|
||||||
|
"objinfo.drawing.length": "Länge",
|
||||||
|
"objinfo.drawing.radius": "Radius",
|
||||||
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
|
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
|
||||||
"levels.addFloor": "+ Geschoss",
|
"levels.addFloor": "+ Geschoss",
|
||||||
"levels.addSection": "+ Schnitt",
|
"levels.addSection": "+ Schnitt",
|
||||||
@@ -461,7 +641,9 @@ export const de = {
|
|||||||
"section.loading": "{kind} wird berechnet …",
|
"section.loading": "{kind} wird berechnet …",
|
||||||
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
|
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
|
||||||
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
|
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
|
||||||
"section.legend": "{kind} · abgeleitete Projektion (render3d)",
|
"section.legend": "{kind} · abgeleitete Projektion aus dem Modell",
|
||||||
|
"elevation.shadows": "Schatten",
|
||||||
|
"elevation.shadows.hint": "Schlagschatten auskragender Bauteile (45°) in der Ansicht ein-/ausblenden",
|
||||||
"props.floorHeight": "Geschosshöhe",
|
"props.floorHeight": "Geschosshöhe",
|
||||||
"props.cutHeight": "Schnitthöhe",
|
"props.cutHeight": "Schnitthöhe",
|
||||||
"props.okff": "OKFF",
|
"props.okff": "OKFF",
|
||||||
@@ -489,6 +671,10 @@ export const de = {
|
|||||||
"default.ceilingTypeSingle": "Decke {thickness} m",
|
"default.ceilingTypeSingle": "Decke {thickness} m",
|
||||||
"default.wallTypeName": "Neuer Wandtyp",
|
"default.wallTypeName": "Neuer Wandtyp",
|
||||||
"default.ceilingTypeName": "Neuer Deckentyp",
|
"default.ceilingTypeName": "Neuer Deckentyp",
|
||||||
|
"default.roofTypeName": "Neuer Dachtyp",
|
||||||
|
"default.doorTypeName": "Neuer Türtyp",
|
||||||
|
"default.windowTypeName": "Neuer Fenstertyp",
|
||||||
|
"default.stairTypeName": "Neuer Treppentyp",
|
||||||
|
|
||||||
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
|
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
|
||||||
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
|
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
|
||||||
@@ -497,6 +683,10 @@ export const de = {
|
|||||||
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
|
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
|
||||||
"alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.",
|
"alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.",
|
||||||
"alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.",
|
"alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.roofTypeInUse": "Dachtyp „{name}\" wird von einem Dach verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.doorTypeInUse": "Türtyp „{name}\" wird von einer Tür verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.windowTypeInUse": "Fenstertyp „{name}\" wird von einem Fenster verwendet und kann nicht gelöscht werden.",
|
||||||
|
"alert.stairTypeInUse": "Treppentyp „{name}\" wird von einer Treppe verwendet und kann nicht gelöscht werden.",
|
||||||
|
|
||||||
// ── Ressourcen-Manager ───────────────────────────────────────────────────
|
// ── Ressourcen-Manager ───────────────────────────────────────────────────
|
||||||
"resources.title": "Ressourcen",
|
"resources.title": "Ressourcen",
|
||||||
@@ -507,7 +697,39 @@ export const de = {
|
|||||||
"resources.tab.lines": "Linien",
|
"resources.tab.lines": "Linien",
|
||||||
"resources.tab.wallStyles": "Wandstile",
|
"resources.tab.wallStyles": "Wandstile",
|
||||||
"resources.tab.ceilingStyles": "Deckenstile",
|
"resources.tab.ceilingStyles": "Deckenstile",
|
||||||
|
"resources.tab.roofStyles": "Dachtypen",
|
||||||
|
"resources.tab.doorStyles": "Türtypen",
|
||||||
|
"resources.tab.windowStyles": "Fenstertypen",
|
||||||
|
"resources.tab.stairStyles": "Treppentypen",
|
||||||
"resources.tab.materials": "Materialien",
|
"resources.tab.materials": "Materialien",
|
||||||
|
"resources.tab.overrides": "Overrides",
|
||||||
|
|
||||||
|
// Grafische Overrides (Regel-Engine).
|
||||||
|
"resources.overrides.hint":
|
||||||
|
"Regeln übersteuern Farbe/Strichstärke/Linienstil beim Rendern (Elementdaten bleiben unverändert). Reihenfolge = Priorität: pro Feld gewinnt die oberste aktive Regel.",
|
||||||
|
"resources.overrides.empty": "Noch keine Override-Regeln.",
|
||||||
|
"resources.overrides.placeholder": "Regel",
|
||||||
|
"resources.overrides.defaultName": "Neue Regel",
|
||||||
|
"resources.overrides.delete": "Regel löschen",
|
||||||
|
"resources.overrides.moveUp": "Regel nach oben (höhere Priorität)",
|
||||||
|
"resources.overrides.moveDown": "Regel nach unten (tiefere Priorität)",
|
||||||
|
"resources.overrides.enabled": "Regel aktiv",
|
||||||
|
"resources.overrides.condition": "Bedingung",
|
||||||
|
"resources.overrides.condType": "Kriterium",
|
||||||
|
"resources.overrides.condType.layer": "Ebene (Name/Code)",
|
||||||
|
"resources.overrides.condType.object": "Objektname",
|
||||||
|
"resources.overrides.operator": "Operator",
|
||||||
|
"resources.overrides.op.equals": "ist gleich",
|
||||||
|
"resources.overrides.op.contains": "enthält",
|
||||||
|
"resources.overrides.op.startsWith": "beginnt mit",
|
||||||
|
"resources.overrides.op.notEquals": "ist ungleich",
|
||||||
|
"resources.overrides.value": "Wert",
|
||||||
|
"resources.overrides.value.placeholder": "z. B. Wände oder 20",
|
||||||
|
"resources.overrides.actions": "Aktionen",
|
||||||
|
"resources.overrides.action.color": "Farbe",
|
||||||
|
"resources.overrides.action.lineweight": "Strichstärke (mm)",
|
||||||
|
"resources.overrides.action.linetype": "Linienstil",
|
||||||
|
"resources.overrides.linetype.none": "— (unverändert)",
|
||||||
|
|
||||||
"resources.wallStyles.hint":
|
"resources.wallStyles.hint":
|
||||||
"Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
|
"Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
|
||||||
@@ -517,6 +739,89 @@ export const de = {
|
|||||||
"resources.ceilingStyles.empty": "Noch keine Deckentypen.",
|
"resources.ceilingStyles.empty": "Noch keine Deckentypen.",
|
||||||
"resources.wallStyles.placeholder": "Wandtyp",
|
"resources.wallStyles.placeholder": "Wandtyp",
|
||||||
"resources.ceilingStyles.placeholder": "Deckentyp",
|
"resources.ceilingStyles.placeholder": "Deckentyp",
|
||||||
|
"resources.roofStyles.hint":
|
||||||
|
"Je Dachtyp (Schichtaufbau aussen/Eindeckung → innen/Verkleidung) die Schichtfugen als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
|
||||||
|
"resources.roofStyles.empty": "Noch keine Dachtypen.",
|
||||||
|
"resources.roofStyles.placeholder": "Dachtyp",
|
||||||
|
|
||||||
|
// 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.frameKind": "Rahmenart",
|
||||||
|
"resources.field.frameWidth": "Rahmenbreite (m)",
|
||||||
|
"resources.field.insetFromFace": "Schichteinzug (m)",
|
||||||
|
"resources.field.insetFace": "Einzug ab Fläche",
|
||||||
|
"resources.field.transomHeight": "Oberlicht-Höhe (m)",
|
||||||
|
"resources.field.mullionRows": "Kämpfer-Zeilen",
|
||||||
|
"resources.frameKind.zarge": "Zarge",
|
||||||
|
"resources.frameKind.blockrahmen": "Blockrahmen",
|
||||||
|
"resources.insetFace.aussen": "Aussen",
|
||||||
|
"resources.insetFace.innen": "Innen",
|
||||||
|
"resources.field.windowKind": "Öffnungsart",
|
||||||
|
"resources.field.wingCount": "Flügelzahl",
|
||||||
|
"resources.field.glazing": "Verglasung",
|
||||||
|
"resources.field.defaultSillHeight": "Brüstungshöhe (m)",
|
||||||
|
"resources.field.sillBoard": "Fensterbank",
|
||||||
|
"resources.field.structure": "Tragart",
|
||||||
|
"resources.field.closedRisers": "Setzstufen geschlossen",
|
||||||
|
"resources.field.treadThickness": "Trittstufendicke (m)",
|
||||||
|
"resources.field.nosing": "Trittkanten-Überstand (m)",
|
||||||
|
"resources.field.railing": "Geländer",
|
||||||
|
"resources.field.stairWidth": "Laufbreite (m)",
|
||||||
|
|
||||||
|
"resources.doorKind.dreh": "Drehflügel",
|
||||||
|
"resources.doorKind.schiebe": "Schiebe",
|
||||||
|
"resources.doorKind.wandoeffnung": "Wandöffnung",
|
||||||
|
"resources.leafCount.1": "1-flügelig",
|
||||||
|
"resources.leafCount.2": "2-flügelig",
|
||||||
|
"resources.leafStyle.glatt": "Glatt",
|
||||||
|
"resources.leafStyle.kassette": "Kassette",
|
||||||
|
"resources.leafStyle.glas": "Glas",
|
||||||
|
"resources.windowKind.dreh": "Dreh",
|
||||||
|
"resources.windowKind.kipp": "Kipp",
|
||||||
|
"resources.windowKind.drehkipp": "Dreh-Kipp",
|
||||||
|
"resources.windowKind.fest": "Fest",
|
||||||
|
"resources.windowKind.schiebe": "Schiebe",
|
||||||
|
"resources.glazing.einfach": "Einfach",
|
||||||
|
"resources.glazing.zweifach": "Zweifach",
|
||||||
|
"resources.glazing.dreifach": "Dreifach",
|
||||||
|
"resources.sillBoard.keine": "Keine",
|
||||||
|
"resources.sillBoard.innen": "Innen",
|
||||||
|
"resources.sillBoard.aussen": "Außen",
|
||||||
|
"resources.sillBoard.beide": "Beide",
|
||||||
|
"resources.structure.massiv": "Massiv",
|
||||||
|
"resources.structure.beton": "Betontreppe (Laufplatte)",
|
||||||
|
"resources.structure.wange": "Wange",
|
||||||
|
"resources.structure.aufgesattelt": "Aufgesattelt",
|
||||||
|
"resources.structure.spindel": "Spindel",
|
||||||
|
"resources.railing.keine": "Keine",
|
||||||
|
"resources.railing.links": "Links",
|
||||||
|
"resources.railing.rechts": "Rechts",
|
||||||
|
"resources.railing.beide": "Beide",
|
||||||
|
|
||||||
"resources.col.layer": "Schicht",
|
"resources.col.layer": "Schicht",
|
||||||
"resources.col.component": "Bauteil",
|
"resources.col.component": "Bauteil",
|
||||||
"resources.col.thickness": "Dicke (mm)",
|
"resources.col.thickness": "Dicke (mm)",
|
||||||
@@ -545,6 +850,9 @@ export const de = {
|
|||||||
"resources.components.placeholder": "Bauteil",
|
"resources.components.placeholder": "Bauteil",
|
||||||
"resources.col.name": "Name",
|
"resources.col.name": "Name",
|
||||||
"resources.col.color": "Farbe",
|
"resources.col.color": "Farbe",
|
||||||
|
"resources.col.abbrev": "Kürzel",
|
||||||
|
"resources.col.abbrev.hint": "Kurz-Code für Wandtyp-Dropdowns (z. B. «BET», «HLZ»)",
|
||||||
|
"resources.col.abbrev.placeholder": "z. B. BET",
|
||||||
"resources.col.hatch": "Schnittschraffur",
|
"resources.col.hatch": "Schnittschraffur",
|
||||||
"resources.col.viewHatch": "Ansichtsschraffur",
|
"resources.col.viewHatch": "Ansichtsschraffur",
|
||||||
"resources.viewHatch.none": "(keine)",
|
"resources.viewHatch.none": "(keine)",
|
||||||
@@ -610,6 +918,7 @@ export const de = {
|
|||||||
"resources.delete.hatch": "Schraffur „{name}\" löschen",
|
"resources.delete.hatch": "Schraffur „{name}\" löschen",
|
||||||
"resources.delete.wallType": "Wandtyp „{name}\" löschen",
|
"resources.delete.wallType": "Wandtyp „{name}\" löschen",
|
||||||
"resources.delete.ceilingType": "Deckentyp „{name}\" löschen",
|
"resources.delete.ceilingType": "Deckentyp „{name}\" löschen",
|
||||||
|
"resources.delete.roofType": "Dachtyp „{name}\" löschen",
|
||||||
|
|
||||||
// Schraffur-/Linien-Typ (Master-Detail-Editor).
|
// Schraffur-/Linien-Typ (Master-Detail-Editor).
|
||||||
"resources.field.type": "Typ",
|
"resources.field.type": "Typ",
|
||||||
@@ -684,6 +993,25 @@ export const de = {
|
|||||||
"cmd.ceiling.label": "Decke",
|
"cmd.ceiling.label": "Decke",
|
||||||
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
|
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
|
||||||
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
|
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
|
||||||
|
"cmd.roof.label": "Dach",
|
||||||
|
"cmd.roof.start": "Erste Dachecke:",
|
||||||
|
"cmd.roof.corner": "Gegenecke:",
|
||||||
|
"cmd.ceilingOpening.label": "Deckenloch",
|
||||||
|
"cmd.ceilingOpening.start": "Erste Ecke der Aussparung (in einer Decke):",
|
||||||
|
"cmd.ceilingOpening.corner": "Gegenecke:",
|
||||||
|
"cmd.roof.shape.sattel": "Satteldach",
|
||||||
|
"cmd.roof.shape.walm": "Walmdach",
|
||||||
|
"cmd.roof.shape.pult": "Pultdach",
|
||||||
|
"cmd.roof.shape.mansarde": "Mansarddach",
|
||||||
|
"cmd.roof.shape.zelt": "Zeltdach",
|
||||||
|
"cmd.roof.shape.flach": "Flachdach",
|
||||||
|
"cmd.sectionline.label": "Schnittlinie",
|
||||||
|
"cmd.sectionline.start": "Schnitt-Startpunkt:",
|
||||||
|
"cmd.sectionline.end": "Schnitt-Endpunkt:",
|
||||||
|
"cmd.sectionline.kind.section": "Schnitt",
|
||||||
|
"cmd.sectionline.kind.elevation": "Ansicht",
|
||||||
|
"cmd.sectionline.newSection": "Schnitt {letter}",
|
||||||
|
"cmd.sectionline.newElevation": "Ansicht {letter}",
|
||||||
"cmd.opening.label": "Öffnung",
|
"cmd.opening.label": "Öffnung",
|
||||||
"cmd.opening.windowLabel": "Fenster",
|
"cmd.opening.windowLabel": "Fenster",
|
||||||
"cmd.opening.doorLabel": "Tür",
|
"cmd.opening.doorLabel": "Tür",
|
||||||
@@ -695,6 +1023,9 @@ export const de = {
|
|||||||
"cmd.opening.dir": "Richtung",
|
"cmd.opening.dir": "Richtung",
|
||||||
"cmd.opening.sillField": "Brüstung",
|
"cmd.opening.sillField": "Brüstung",
|
||||||
"cmd.opening.swingField": "Winkel",
|
"cmd.opening.swingField": "Winkel",
|
||||||
|
"cmd.column.label": "Stütze",
|
||||||
|
"cmd.column.place": "Stütze setzen:",
|
||||||
|
"cmd.column.profile": "Profil",
|
||||||
"cmd.stair.label": "Treppe",
|
"cmd.stair.label": "Treppe",
|
||||||
"cmd.stair.start": "Startpunkt der Treppe:",
|
"cmd.stair.start": "Startpunkt der Treppe:",
|
||||||
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
|
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
|
||||||
@@ -729,11 +1060,30 @@ export const de = {
|
|||||||
"cmd.arc.center": "Mittelpunkt:",
|
"cmd.arc.center": "Mittelpunkt:",
|
||||||
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
|
||||||
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
"cmd.arc.end": "Endpunkt (Endwinkel):",
|
||||||
|
"cmd.text.label": "Text",
|
||||||
|
"cmd.text.point": "Ankerpunkt:",
|
||||||
|
"cmd.text.enter": "Text eingeben:",
|
||||||
|
"cmd.textbox.label": "Textspalte",
|
||||||
|
"cmd.textbox.point": "Ankerpunkt:",
|
||||||
|
"cmd.textbox.width": "Spaltenbreite (zweiter Punkt):",
|
||||||
|
"cmd.textbox.enter": "Text eingeben:",
|
||||||
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
|
||||||
"cmd.move.label": "Verschieben",
|
"cmd.move.label": "Verschieben",
|
||||||
"cmd.move.base": "Basispunkt:",
|
"cmd.move.base": "Basispunkt:",
|
||||||
"cmd.move.target": "Zielpunkt:",
|
"cmd.move.target": "Zielpunkt:",
|
||||||
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
|
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
|
||||||
|
"cmd.rotate.label": "Drehen",
|
||||||
|
"cmd.measure.label": "Messen",
|
||||||
|
"cmd.measure.start": "Messen von:",
|
||||||
|
"cmd.measure.to": "bis (Distanz · Winkel):",
|
||||||
|
"cmd.measure.next": "nächster Punkt (Rechtsklick = neuer Pfad):",
|
||||||
|
"objinfo.measure.title": "Messung",
|
||||||
|
"objinfo.measure.segment": "Segment",
|
||||||
|
"objinfo.measure.total": "Länge gesamt",
|
||||||
|
"objinfo.measure.area": "Fläche",
|
||||||
|
"objinfo.measure.angle": "Winkel",
|
||||||
|
"objinfo.measure.hint": "Klick = Punkt · Rechtsklick = neuer Pfad · Esc = Ende",
|
||||||
|
"transform.needSelection": "Erst Objekte wählen, dann drehen.",
|
||||||
"cmd.mirror.label": "Spiegeln",
|
"cmd.mirror.label": "Spiegeln",
|
||||||
"cmd.mirror.first": "Erster Punkt der Spiegelachse:",
|
"cmd.mirror.first": "Erster Punkt der Spiegelachse:",
|
||||||
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
|
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
|
||||||
@@ -746,6 +1096,10 @@ export const de = {
|
|||||||
"cmd.offset.label": "Versatz",
|
"cmd.offset.label": "Versatz",
|
||||||
"cmd.offset.pick": "Kurve wählen:",
|
"cmd.offset.pick": "Kurve wählen:",
|
||||||
"cmd.offset.side": "Seite oder Distanz:",
|
"cmd.offset.side": "Seite oder Distanz:",
|
||||||
|
"cmd.extrude.label": "Extrudieren",
|
||||||
|
"cmd.extrude.pick": "Geschlossenes Profil wählen:",
|
||||||
|
"cmd.extrude.height": "Höhe:",
|
||||||
|
"cmd.extrude.taper": "Verjüngung (0–1, 1=Spitze):",
|
||||||
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
||||||
"cmd.trim.label": "Stutzen",
|
"cmd.trim.label": "Stutzen",
|
||||||
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
||||||
@@ -769,6 +1123,8 @@ export const de = {
|
|||||||
"cmd.import.prompt": "DXF-Datei wählen …",
|
"cmd.import.prompt": "DXF-Datei wählen …",
|
||||||
"cmd.terrain.label": "Gelände",
|
"cmd.terrain.label": "Gelände",
|
||||||
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
|
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
|
||||||
|
"cmd.georef.label": "Neuer Bezugspunkt",
|
||||||
|
"cmd.georef.prompt": "Neuer Nullpunkt (verschiebt das ganze Projekt):",
|
||||||
|
|
||||||
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
|
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
|
||||||
"nav.roomBalance": "Flächenbilanz",
|
"nav.roomBalance": "Flächenbilanz",
|
||||||
@@ -860,11 +1216,43 @@ export const de = {
|
|||||||
"file.exportPdf": "Als PDF exportieren…",
|
"file.exportPdf": "Als PDF exportieren…",
|
||||||
"file.exportDxf": "Als DXF exportieren…",
|
"file.exportDxf": "Als DXF exportieren…",
|
||||||
"file.exportSchedule": "Bauteilliste (CSV)…",
|
"file.exportSchedule": "Bauteilliste (CSV)…",
|
||||||
|
"file.exportIfc": "IFC exportieren",
|
||||||
|
"file.exportObj": "OBJ exportieren (3D-Mesh)",
|
||||||
|
"file.exportStl": "STL exportieren (3D-Mesh)",
|
||||||
|
"file.export": "Exportieren",
|
||||||
|
"file.openFailed": "Projekt-Datei konnte nicht gelesen werden.",
|
||||||
|
|
||||||
|
// ── Lock-Konflikt (Projektdatei bereits in anderer Instanz geöffnet) ────
|
||||||
|
"lock.title": "Projekt bereits geöffnet",
|
||||||
|
"lock.message":
|
||||||
|
"Diese Projektdatei ist bereits in einer anderen Instanz geöffnet.",
|
||||||
|
"lock.messageInfo": "Geöffnet auf {host} (Prozess {pid}).",
|
||||||
|
"lock.openReadOnly": "Schreibgeschützt öffnen",
|
||||||
|
"lock.forceOpen": "Trotzdem erzwingen",
|
||||||
|
"lock.forceOpenWarning":
|
||||||
|
"Achtung: Öffnet zum Schreiben, obwohl eine andere Instanz die Datei hält — Änderungen können sich gegenseitig überschreiben.",
|
||||||
|
"exportSave.title": "Exportieren",
|
||||||
|
"exportSave.format": "Format",
|
||||||
|
"exportSave.filename": "Dateiname",
|
||||||
|
"exportSave.locationNote": "Wird in den Standard-Download-Ordner gespeichert.",
|
||||||
|
"exportSave.fmt.csv": "Bauteilliste (CSV)",
|
||||||
|
"exportSave.fmt.ifc": "IFC (BIM)",
|
||||||
|
"exportSave.fmt.obj": "OBJ (3D-Mesh)",
|
||||||
|
"exportSave.fmt.stl": "STL (3D-Mesh)",
|
||||||
|
"about.title": "Über dossier",
|
||||||
|
"about.version": "Version",
|
||||||
|
"about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.",
|
||||||
|
"about.copyright": "© 2026 Karim Gabriele Varano",
|
||||||
|
"about.license": "Lizenz: AGPL-3.0-or-later",
|
||||||
|
"about.suite": "Teil der openbureau-Suite",
|
||||||
|
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
|
||||||
|
"about.close": "Schließen",
|
||||||
|
|
||||||
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
|
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
|
||||||
"topbar.settings": "Einstellungen",
|
"topbar.settings": "Einstellungen",
|
||||||
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
|
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
|
||||||
"settings.title": "Einstellungen",
|
"settings.title": "Einstellungen",
|
||||||
|
"settings.section.workspace": "Arbeitsumgebung",
|
||||||
"settings.section.display": "Darstellung",
|
"settings.section.display": "Darstellung",
|
||||||
"settings.marqueeColor": "Auswahlrahmen-Farbe",
|
"settings.marqueeColor": "Auswahlrahmen-Farbe",
|
||||||
"settings.marqueeColor.hint":
|
"settings.marqueeColor.hint":
|
||||||
@@ -888,8 +1276,13 @@ export const de = {
|
|||||||
|
|
||||||
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
|
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
|
||||||
"site.importLocation": "Standort importieren",
|
"site.importLocation": "Standort importieren",
|
||||||
|
"site.setGeoref": "Neuer Bezugspunkt",
|
||||||
|
"site.setGeoref.hint": "Klick im Grundriss verschiebt das ganze Projekt — der geklickte Punkt wird zum neuen Ursprung (0,0). Der reale Standort-Bezug bleibt erhalten.",
|
||||||
"ctxImport.title": "Standort importieren",
|
"ctxImport.title": "Standort importieren",
|
||||||
"ctxImport.close": "Schließen",
|
"ctxImport.close": "Schließen",
|
||||||
|
"ctxImport.geoAnchor": "Georeferenziert ab: {label}",
|
||||||
|
"ctxImport.geoAnchor.reset": "Zurücksetzen",
|
||||||
|
"ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.",
|
||||||
"ctxImport.location": "Ort / Adresse",
|
"ctxImport.location": "Ort / Adresse",
|
||||||
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
|
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
|
||||||
"ctxImport.search": "Suchen",
|
"ctxImport.search": "Suchen",
|
||||||
@@ -899,11 +1292,21 @@ export const de = {
|
|||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
"ctxImport.sources": "Quellen",
|
"ctxImport.sources": "Quellen",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
||||||
|
"ctxImport.buildings.off": "Aus",
|
||||||
|
"ctxImport.buildings.flach": "Vereinfacht (Umriss, schnell)",
|
||||||
|
"ctxImport.buildings.v2": "Echt 2.0 (stabil)",
|
||||||
|
"ctxImport.buildings.v3": "Echt 3.0 (Beta)",
|
||||||
"ctxImport.src.swissTerrain": "Swisstopo-Gelände",
|
"ctxImport.src.swissTerrain": "Swisstopo-Gelände",
|
||||||
|
"ctxImport.terrain.resolution": "Auflösung",
|
||||||
|
"ctxImport.terrain.res2": "2 m (schnell)",
|
||||||
|
"ctxImport.terrain.res05": "0.5 m (fein, grössere Kacheln)",
|
||||||
"ctxImport.src.osmBuildings": "OSM-Gebäude",
|
"ctxImport.src.osmBuildings": "OSM-Gebäude",
|
||||||
"ctxImport.src.osmRoads": "OSM-Strassen",
|
"ctxImport.src.osmRoads": "OSM-Strassen",
|
||||||
"ctxImport.src.osmWater": "OSM-Wasser",
|
"ctxImport.src.osmWater": "OSM-Wasser",
|
||||||
"ctxImport.src.osmGreen": "OSM-Grün",
|
"ctxImport.src.osmGreen": "OSM-Grün",
|
||||||
|
"ctxImport.src.osmParking": "OSM-Parkplätze",
|
||||||
|
"ctxImport.src.osmRailway": "OSM-Bahn",
|
||||||
|
"ctxImport.src.osmForest": "OSM-Wald",
|
||||||
"ctxImport.cancel": "Abbrechen",
|
"ctxImport.cancel": "Abbrechen",
|
||||||
"ctxImport.confirm": "Importieren",
|
"ctxImport.confirm": "Importieren",
|
||||||
"ctxImport.importing": "Importiere…",
|
"ctxImport.importing": "Importiere…",
|
||||||
@@ -911,6 +1314,8 @@ export const de = {
|
|||||||
"ctxImport.empty": "Keine Geometrie im gewählten Bereich gefunden.",
|
"ctxImport.empty": "Keine Geometrie im gewählten Bereich gefunden.",
|
||||||
"ctxImport.importError": "Import fehlgeschlagen.",
|
"ctxImport.importError": "Import fehlgeschlagen.",
|
||||||
"ctxImport.imported": "{count} Objekte in {sets} Sätzen importiert.",
|
"ctxImport.imported": "{count} Objekte in {sets} Sätzen importiert.",
|
||||||
|
"ctxImport.importedWithSkipped": "{count} Objekte in {sets} Sätzen importiert ({skipped} Gebäude-Kachel(n) übersprungen, zu gross).",
|
||||||
|
"ctxImport.buildingsSkipped": "Keine Gebäude geladen — {count} Kachel(n) waren zu gross (dicht bebautes Gebiet). Kleineren Radius versuchen oder Generation wechseln.",
|
||||||
|
|
||||||
// ── Raum-Befehl ─────────────────────────────────────────────────────────────
|
// ── Raum-Befehl ─────────────────────────────────────────────────────────────
|
||||||
"cmd.room.label": "Raum",
|
"cmd.room.label": "Raum",
|
||||||
@@ -947,6 +1352,9 @@ export const de = {
|
|||||||
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
|
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
|
||||||
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
|
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
|
||||||
"roomStamp.showUsage": "Nutzung anzeigen",
|
"roomStamp.showUsage": "Nutzung anzeigen",
|
||||||
|
"roomStamp.roundingStep": "Flächen-Rundung",
|
||||||
|
"roomStamp.roundingStepDefault": "Standard (2 Nachkommastellen)",
|
||||||
|
"roomStamp.occupancy": "Personenzahl",
|
||||||
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
|
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
|
||||||
|
|
||||||
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
|
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
|
||||||
@@ -955,6 +1363,8 @@ export const de = {
|
|||||||
"rt.underline": "Unterstrichen",
|
"rt.underline": "Unterstrichen",
|
||||||
"rt.strike": "Durchgestrichen",
|
"rt.strike": "Durchgestrichen",
|
||||||
"rt.strikethrough": "Durchgestrichen",
|
"rt.strikethrough": "Durchgestrichen",
|
||||||
|
"rt.super": "Hochgestellt",
|
||||||
|
"rt.sub": "Tiefgestellt",
|
||||||
"rt.fontSize": "Schriftgrösse",
|
"rt.fontSize": "Schriftgrösse",
|
||||||
"rt.color": "Farbe",
|
"rt.color": "Farbe",
|
||||||
"rt.preset": "Vorlage",
|
"rt.preset": "Vorlage",
|
||||||
@@ -966,6 +1376,150 @@ export const de = {
|
|||||||
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.",
|
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.",
|
||||||
"viewport3d.grid.show": "Bodenraster einblenden",
|
"viewport3d.grid.show": "Bodenraster einblenden",
|
||||||
"viewport3d.grid.hide": "Bodenraster ausblenden",
|
"viewport3d.grid.hide": "Bodenraster ausblenden",
|
||||||
|
"viewport3d.section.label": "Schnittebene",
|
||||||
|
"viewport3d.section.show": "Schnittebene ein",
|
||||||
|
"viewport3d.section.hide": "Schnittebene aus",
|
||||||
|
"viewport3d.grip.vertex": "Wandende ziehen",
|
||||||
|
"viewport3d.grip.height": "Höhe ziehen",
|
||||||
|
"viewport3d.grip.move": "Verschieben",
|
||||||
|
"viewport3d.grip.edge": "Kante ziehen",
|
||||||
|
"viewport3d.grip.roofpitch": "Dachneigung ziehen",
|
||||||
|
|
||||||
|
// ── Element-Baum (Elemente-Panel, ROADMAP §11) ──────────────────────────────
|
||||||
|
"elements.title": "Elemente",
|
||||||
|
"elements.search": "Elemente suchen…",
|
||||||
|
"elements.empty": "Keine Bauteile im Projekt.",
|
||||||
|
"elements.noMatch": "Keine Treffer.",
|
||||||
|
"elements.kind.wall": "Wand",
|
||||||
|
"elements.kind.ceiling": "Decke",
|
||||||
|
"elements.kind.window": "Fenster",
|
||||||
|
"elements.kind.opening": "Öffnung",
|
||||||
|
"elements.kind.stair": "Treppe",
|
||||||
|
"elements.kind.extrusion": "Extrusion",
|
||||||
|
|
||||||
|
// Ausschnitte / View-Snapshots (DOSSIER A2).
|
||||||
|
"viewsnap.title": "Ausschnitte",
|
||||||
|
"viewsnap.saveCurrent": "+ Aktuellen Ausschnitt speichern …",
|
||||||
|
"viewsnap.savePrompt": "Ausschnitt speichern als:",
|
||||||
|
"viewsnap.renamePrompt": "Ausschnitt umbenennen:",
|
||||||
|
"viewsnap.rename": "Umbenennen",
|
||||||
|
"viewsnap.delete": "Löschen",
|
||||||
|
"viewsnap.deleteConfirm": "Ausschnitt „{name}\" löschen?",
|
||||||
|
"viewsnap.empty": "Noch keine Ausschnitte. Ansicht einrichten und speichern.",
|
||||||
|
"viewsnap.applyHint": "Ausschnitt „{name}\" wiederherstellen",
|
||||||
|
"viewsnap.add.folder": "Ordner erstellen",
|
||||||
|
"viewsnap.add.snapshot": "Ausschnitt speichern",
|
||||||
|
"viewsnap.folder.defaultName": "Neuer Ordner",
|
||||||
|
"viewsnap.folder.rename": "Ordner umbenennen",
|
||||||
|
"viewsnap.folder.delete": "Ordner löschen",
|
||||||
|
"viewsnap.folder.empty": "Leerer Ordner",
|
||||||
|
"viewsnap.tree.empty": "Noch keine Ordner oder Ausschnitte. Über „+\" anlegen.",
|
||||||
|
"viewsnap.footer.scale": "Massstab",
|
||||||
|
"viewsnap.footer.layerCombo": "Ebenenkombi",
|
||||||
|
"viewsnap.footer.drawingCombo": "Zeichnungskombi",
|
||||||
|
"viewsnap.footer.overrides": "Overrides",
|
||||||
|
"viewsnap.footer.none": "—",
|
||||||
|
|
||||||
|
// Layout-Blätter mit Masterlayout (DOSSIER A3).
|
||||||
|
"layouts.panelTitle": "Mappe",
|
||||||
|
"layouts.title": "Layouts",
|
||||||
|
"layouts.new": "Anlegen",
|
||||||
|
"layouts.namePlaceholder": "Blattname …",
|
||||||
|
"layouts.defaultName": "Neues Layout",
|
||||||
|
"layouts.rename": "Umbenennen",
|
||||||
|
"layouts.delete": "Löschen",
|
||||||
|
"layouts.renamePrompt": "Umbenennen:",
|
||||||
|
"layouts.deleteConfirm": "„{name}\" löschen?",
|
||||||
|
"layouts.openHint": "Layout „{name}\" öffnen",
|
||||||
|
"layouts.empty": "Noch keine Layouts. Blatt anlegen und öffnen.",
|
||||||
|
"layouts.paper": "Papierformat",
|
||||||
|
"layouts.orientation": "Ausrichtung",
|
||||||
|
"layouts.portrait": "Hoch",
|
||||||
|
"layouts.landscape": "Quer",
|
||||||
|
"layouts.border": "Rahmen",
|
||||||
|
"layouts.masters": "Masterlayouts",
|
||||||
|
"layouts.newMaster": "+ Master",
|
||||||
|
"layouts.newMasterPrompt": "Neues Masterlayout:",
|
||||||
|
"layouts.defaultMasterName": "Master",
|
||||||
|
"layouts.noMasters": "Noch keine Masterlayouts.",
|
||||||
|
"layouts.master": "Master",
|
||||||
|
"layouts.noMaster": "Kein Master",
|
||||||
|
"layouts.tb.projectName": "Projekt",
|
||||||
|
"layouts.tb.sheetName": "Blattname",
|
||||||
|
"layouts.tb.scale": "Massstab",
|
||||||
|
"layouts.tb.date": "Datum",
|
||||||
|
"layouts.tb.author": "Bearbeiter",
|
||||||
|
"layouts.tb.autoPlaceholder": "(automatisch)",
|
||||||
|
// Editor
|
||||||
|
"layouts.editorTitle": "Layout-Editor",
|
||||||
|
"layouts.fit": "Einpassen",
|
||||||
|
"layouts.addViewport": "Viewport hinzufügen",
|
||||||
|
"layouts.pickSnapshot": "Ausschnitt wählen …",
|
||||||
|
"layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.",
|
||||||
|
"layouts.viewportProps": "Viewport-Eigenschaften",
|
||||||
|
"layouts.noViewports": "Noch keine Viewports. Ausschnitt wählen und hinzufügen.",
|
||||||
|
"layouts.selectViewport": "Viewport auf dem Blatt anklicken.",
|
||||||
|
"layouts.missingSnapshot": "Ausschnitt fehlt",
|
||||||
|
"layouts.xMm": "X (mm)",
|
||||||
|
"layouts.yMm": "Y (mm)",
|
||||||
|
"layouts.widthMm": "Breite (mm)",
|
||||||
|
"layouts.heightMm": "Höhe (mm)",
|
||||||
|
"layouts.scale": "Massstab",
|
||||||
|
"layouts.scaleReset": "Massstab vom Ausschnitt",
|
||||||
|
"layouts.removeViewport": "Viewport entfernen",
|
||||||
|
"layouts.backToModel": "Zurück zum Modell",
|
||||||
|
"layouts.placeViewport": "Viewport aufziehen",
|
||||||
|
"layouts.placeHint": "Rechteck auf dem Blatt aufziehen, dann Ausschnitt binden.",
|
||||||
|
"layouts.bindSnapshot": "Ausschnitt binden",
|
||||||
|
"layouts.emptyViewports":
|
||||||
|
"Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.",
|
||||||
|
// Annotationen (Linie/Rechteck/Text) direkt auf dem Blatt
|
||||||
|
"layouts.annotLine": "Linie zeichnen",
|
||||||
|
"layouts.annotRect": "Rechteck zeichnen",
|
||||||
|
"layouts.annotText": "Text setzen",
|
||||||
|
"layouts.annotTextTitle": "Text",
|
||||||
|
"layouts.annotTextLabel": "Textinhalt:",
|
||||||
|
"layouts.annotEditText": "Text ändern",
|
||||||
|
"layouts.annotColor": "Farbe",
|
||||||
|
"layouts.annotWeight": "Strichstärke (mm)",
|
||||||
|
"layouts.annotRemove": "Annotation entfernen",
|
||||||
|
"layouts.annot.line": "Linie",
|
||||||
|
"layouts.annot.rect": "Rechteck",
|
||||||
|
"layouts.annot.text": "Text",
|
||||||
|
// Baum / „+"-Menü / Ordner
|
||||||
|
"layouts.add": "Neu …",
|
||||||
|
"layouts.addFolder": "Ordner erstellen",
|
||||||
|
"layouts.addLayout": "Layout erstellen",
|
||||||
|
"layouts.addMaster": "Masterlayout erstellen",
|
||||||
|
"layouts.add.folder": "Ordner erstellen",
|
||||||
|
"layouts.add.layout": "Layout erstellen",
|
||||||
|
"layouts.add.master": "Masterlayout erstellen",
|
||||||
|
"layouts.folder.defaultName": "Neuer Ordner",
|
||||||
|
"layouts.folder.rename": "Ordner umbenennen",
|
||||||
|
"layouts.folder.delete": "Ordner löschen",
|
||||||
|
"layouts.folder.export": "Ordner als PDF exportieren",
|
||||||
|
"layouts.folder.empty": "Leerer Ordner",
|
||||||
|
"layouts.folder.exportEmpty": "Ordner enthält keine Layouts.",
|
||||||
|
"layouts.tree.empty": "Noch keine Ordner oder Layouts. Über „+\" anlegen.",
|
||||||
|
// Dialog „Neues Layout"
|
||||||
|
"layouts.newLayout.title": "Neues Layout",
|
||||||
|
"layouts.newLayout.source": "Vorlage",
|
||||||
|
"layouts.newLayout.fromMaster": "Masterlayout als Vorlage",
|
||||||
|
"layouts.newLayout.freeSize": "Freie Grösse",
|
||||||
|
"layouts.newLayout.pickMaster": "Masterlayout wählen …",
|
||||||
|
"layouts.newLayout.noMasters": "Keine Masterlayouts vorhanden.",
|
||||||
|
// Dialog „Neues Masterlayout"
|
||||||
|
"layouts.newMaster.title": "Neues Masterlayout",
|
||||||
|
// Gemeinsame Grössen-Auswahl (beide Dialoge)
|
||||||
|
"layouts.size.format": "Format",
|
||||||
|
"layouts.size.custom": "Freie Grösse",
|
||||||
|
"layouts.size.a4": "A4",
|
||||||
|
"layouts.size.a3": "A3",
|
||||||
|
"layouts.paperGroup.a": "A-Reihe",
|
||||||
|
"layouts.paperGroup.b": "B-Reihe",
|
||||||
|
"layouts.size.widthMm": "Breite (mm)",
|
||||||
|
"layouts.size.heightMm": "Höhe (mm)",
|
||||||
|
"layouts.size.create": "Erstellen",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof de;
|
export type TranslationKey = keyof typeof de;
|
||||||
|
|||||||
+559
-6
@@ -16,14 +16,26 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"view3d.group": "3D view",
|
"view3d.group": "3D view",
|
||||||
"view3d.front": "Front",
|
"view3d.front": "Front",
|
||||||
"view3d.front.hint": "Front view (frontal)",
|
"view3d.front.hint": "Front view (frontal)",
|
||||||
|
"view3d.back": "Back",
|
||||||
|
"view3d.back.hint": "Back view (rear)",
|
||||||
"view3d.top": "Top",
|
"view3d.top": "Top",
|
||||||
"view3d.top.hint": "Top view (straight down)",
|
"view3d.top.hint": "Top view (straight down)",
|
||||||
"view3d.side": "Side",
|
"view3d.side": "Right",
|
||||||
"view3d.side.hint": "Side view (90° to front)",
|
"view3d.side.hint": "Right side view (90° to the front)",
|
||||||
|
"view3d.left": "Left",
|
||||||
|
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
|
||||||
"view3d.perspective": "Perspective",
|
"view3d.perspective": "Perspective",
|
||||||
"view3d.perspective.hint": "Free perspective (3/4 view)",
|
"view3d.perspective.hint": "Free perspective (3/4 view)",
|
||||||
"view3d.iso": "Isometric",
|
"view3d.iso": "Isometric",
|
||||||
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
|
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
|
||||||
|
"view3d.isoFrontLeft": "Front Left",
|
||||||
|
"view3d.isoFrontLeft.hint": "Isometric view (equiangular from front-top-left)",
|
||||||
|
"view3d.isoBackRight": "Back Right",
|
||||||
|
"view3d.isoBackRight.hint": "Isometric view (equiangular from back-top-right)",
|
||||||
|
"view3d.isoBackLeft": "Back Left",
|
||||||
|
"view3d.isoBackLeft.hint": "Isometric view (equiangular from back-top-left)",
|
||||||
|
"view3d.iso.menu": "More isometric views",
|
||||||
|
"view3d.iso.menu.hint": "Pick one of the four upper isometric octants",
|
||||||
"view3d.camera": "Camera",
|
"view3d.camera": "Camera",
|
||||||
"view3d.camera.hint": "Camera settings (field of view)",
|
"view3d.camera.hint": "Camera settings (field of view)",
|
||||||
"view3d.fov": "Field of view",
|
"view3d.fov": "Field of view",
|
||||||
@@ -71,6 +83,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"display.style.color": "Color",
|
"display.style.color": "Color",
|
||||||
"display.style.mono": "Black & white",
|
"display.style.mono": "Black & white",
|
||||||
"display.style.shaded": "Shaded",
|
"display.style.shaded": "Shaded",
|
||||||
|
"display.style.shadedEdges": "Shaded with edges",
|
||||||
"display.style.white": "White",
|
"display.style.white": "White",
|
||||||
"display.style.textured": "Textured",
|
"display.style.textured": "Textured",
|
||||||
"display.style.wireframe": "Wireframe",
|
"display.style.wireframe": "Wireframe",
|
||||||
@@ -85,12 +98,12 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"topbar.resources.hint": "Show/hide the resources window",
|
"topbar.resources.hint": "Show/hide the resources window",
|
||||||
|
|
||||||
// Layout menu.
|
// Layout menu.
|
||||||
"layout.title": "Layout",
|
"layout.title": "Workspace",
|
||||||
"layout.placeholder": "Layout …",
|
"layout.placeholder": "Workspace …",
|
||||||
"layout.saveCurrent": "+ Save current …",
|
"layout.saveCurrent": "+ Save current …",
|
||||||
"layout.loadGroup": "Load",
|
"layout.loadGroup": "Load",
|
||||||
"layout.deleteGroup": "Delete",
|
"layout.deleteGroup": "Delete",
|
||||||
"layout.savePrompt": "Save layout as:",
|
"layout.savePrompt": "Save workspace as:",
|
||||||
|
|
||||||
// Visibility combinations (topbar).
|
// Visibility combinations (topbar).
|
||||||
"combo.layers": "Layer comb.",
|
"combo.layers": "Layer comb.",
|
||||||
@@ -119,6 +132,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"tool.circle.hint": "Circle: set center, then radius",
|
"tool.circle.hint": "Circle: set center, then radius",
|
||||||
"tool.arc": "Arc",
|
"tool.arc": "Arc",
|
||||||
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
|
||||||
|
"tool.text": "Text",
|
||||||
|
"tool.text.hint": "Text: set anchor point, then type the text",
|
||||||
|
"tool.textbox": "Text column",
|
||||||
|
"tool.textbox.hint": "Text column: anchor, drag column width, then type the text",
|
||||||
// Ribbon top bar (tabs + groups)
|
// Ribbon top bar (tabs + groups)
|
||||||
"ribbon.tab.2d": "2D",
|
"ribbon.tab.2d": "2D",
|
||||||
"ribbon.tab.3d": "3D",
|
"ribbon.tab.3d": "3D",
|
||||||
@@ -128,6 +145,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ribbon.group.draw": "Draw",
|
"ribbon.group.draw": "Draw",
|
||||||
"ribbon.group.modify": "Modify",
|
"ribbon.group.modify": "Modify",
|
||||||
"ribbon.group.components": "Components",
|
"ribbon.group.components": "Components",
|
||||||
|
"ribbon.group.sections": "Sections",
|
||||||
"ribbon.group.custom": "Custom bar",
|
"ribbon.group.custom": "Custom bar",
|
||||||
"ribbon.custom.add": "+ Add",
|
"ribbon.custom.add": "+ Add",
|
||||||
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
"ribbon.custom.empty": "Pick elements via “+ Add”",
|
||||||
@@ -164,6 +182,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"snap.ortho": "Ortho (Shift)",
|
"snap.ortho": "Ortho (Shift)",
|
||||||
"snap.gridSize": "Grid size (m)",
|
"snap.gridSize": "Grid size (m)",
|
||||||
"snap.angleStep": "Angle step",
|
"snap.angleStep": "Angle step",
|
||||||
|
"snap.commonAngles": "Common angles",
|
||||||
|
"snap.commonAngleTolerance": "Angle tolerance (°)",
|
||||||
|
|
||||||
// Transform (move/mirror/rotate) + mode bar.
|
// Transform (move/mirror/rotate) + mode bar.
|
||||||
"transform.bar": "Transform",
|
"transform.bar": "Transform",
|
||||||
@@ -233,6 +253,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"attr.fill": "Fill",
|
"attr.fill": "Fill",
|
||||||
"attr.fillColor": "Fill color",
|
"attr.fillColor": "Fill color",
|
||||||
"attr.clearFill": "Remove fill color",
|
"attr.clearFill": "Remove fill color",
|
||||||
|
"attr.pickColor": "Choose color",
|
||||||
"attr.foreground": "Foreground",
|
"attr.foreground": "Foreground",
|
||||||
"attr.background": "Background",
|
"attr.background": "Background",
|
||||||
"attr.bySystem": "By system",
|
"attr.bySystem": "By system",
|
||||||
@@ -248,6 +269,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
// Objekt-Info-Palette (ObjectInfoPanel)
|
// Objekt-Info-Palette (ObjectInfoPanel)
|
||||||
"objinfo.title": "Object Info",
|
"objinfo.title": "Object Info",
|
||||||
"objinfo.empty": "No object selected",
|
"objinfo.empty": "No object selected",
|
||||||
|
"objinfo.category": "Class",
|
||||||
|
"objinfo.level": "Layer",
|
||||||
"objinfo.refpoint": "Reference point",
|
"objinfo.refpoint": "Reference point",
|
||||||
"objinfo.setRefpoint": "Set reference point",
|
"objinfo.setRefpoint": "Set reference point",
|
||||||
"objinfo.x": "X",
|
"objinfo.x": "X",
|
||||||
@@ -265,12 +288,60 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.kind.opening": "Opening",
|
"objinfo.kind.opening": "Opening",
|
||||||
"objinfo.kind.stair": "Stair",
|
"objinfo.kind.stair": "Stair",
|
||||||
"objinfo.kind.room": "Room",
|
"objinfo.kind.room": "Room",
|
||||||
|
"objinfo.kind.extrudedSolid": "Extrusion",
|
||||||
|
"objinfo.kind.column": "Column",
|
||||||
|
"objinfo.kind.roof": "Roof",
|
||||||
|
// ── Roof attributes (object info panel) ─────────────────────────────────
|
||||||
|
"objinfo.roof.section": "Roof",
|
||||||
|
"objinfo.roof.shape": "Roof shape",
|
||||||
|
"objinfo.roof.shape.flach": "Flat roof",
|
||||||
|
"objinfo.roof.shape.pult": "Mono-pitch roof",
|
||||||
|
"objinfo.roof.shape.sattel": "Gable roof",
|
||||||
|
"objinfo.roof.shape.walm": "Hip roof",
|
||||||
|
"objinfo.roof.shape.mansarde": "Mansard roof",
|
||||||
|
"objinfo.roof.shape.zelt": "Pyramid roof",
|
||||||
|
"objinfo.roof.ridgeAxis": "Ridge direction",
|
||||||
|
"objinfo.roof.ridgeAxis.x": "along X",
|
||||||
|
"objinfo.roof.ridgeAxis.y": "along Y",
|
||||||
|
"objinfo.roof.pitch": "Pitch (°)",
|
||||||
|
"objinfo.roof.pitchUpper": "Upper pitch (°)",
|
||||||
|
"objinfo.roof.type": "Roof type",
|
||||||
|
"objinfo.roof.type.single": "Single layer (thickness)",
|
||||||
|
"objinfo.roof.mansardType": "Mansard type",
|
||||||
|
"objinfo.roof.mansard.giebel": "Gable mansard",
|
||||||
|
"objinfo.roof.mansard.walm": "Hip mansard",
|
||||||
|
"objinfo.roof.mansard.zelt": "Pavilion mansard",
|
||||||
|
"objinfo.roof.mansardKnee": "Knee position (% span)",
|
||||||
|
"objinfo.roof.width": "Width (m)",
|
||||||
|
"objinfo.roof.depth": "Depth (m)",
|
||||||
|
"objinfo.roof.overhang": "Eaves overhang (m)",
|
||||||
|
"objinfo.roof.overhangGable": "Rake overhang (m)",
|
||||||
|
"objinfo.roof.thickness": "Roof thickness (m)",
|
||||||
|
"objinfo.roof.baseElevation": "Eaves height (m)",
|
||||||
|
"objinfo.roof.ridgeHeight": "Ridge height",
|
||||||
|
"objinfo.roof.area": "Footprint area",
|
||||||
|
// ── Column attributes (object info panel) ────────────────────────────────
|
||||||
|
"objinfo.column.section": "Column",
|
||||||
|
"objinfo.column.profile": "Profile",
|
||||||
|
"objinfo.column.profile.rect": "Rectangle",
|
||||||
|
"objinfo.column.profile.round": "Round",
|
||||||
|
"objinfo.column.width": "Width",
|
||||||
|
"objinfo.column.depth": "Depth",
|
||||||
|
"objinfo.column.radius": "Radius",
|
||||||
|
"objinfo.column.height": "Height",
|
||||||
|
"objinfo.column.rotation": "Rotation (°)",
|
||||||
|
"objinfo.column.floor": "Floor",
|
||||||
// ── Wall attributes (object info panel) ──────────────────────────────────
|
// ── Wall attributes (object info panel) ──────────────────────────────────
|
||||||
"objinfo.wall.section": "Wall",
|
"objinfo.wall.section": "Wall",
|
||||||
"objinfo.wall.refLine": "Reference line",
|
"objinfo.wall.refLine": "Reference line",
|
||||||
"objinfo.wall.refLine.left": "Exterior (left)",
|
"objinfo.wall.refLine.left": "Exterior (left)",
|
||||||
"objinfo.wall.refLine.center": "Center",
|
"objinfo.wall.refLine.center": "Center",
|
||||||
"objinfo.wall.refLine.right": "Interior (right)",
|
"objinfo.wall.refLine.right": "Interior (right)",
|
||||||
|
"objinfo.wall.refLine.joint": "Joint",
|
||||||
|
"objinfo.wall.sliceTermination": "End at ceiling",
|
||||||
|
"objinfo.wall.sliceTermination.both": "Off",
|
||||||
|
"objinfo.wall.sliceTermination.below": "Below",
|
||||||
|
"objinfo.wall.sliceTermination.above": "Above",
|
||||||
"objinfo.wall.buildup": "Build-up",
|
"objinfo.wall.buildup": "Build-up",
|
||||||
"objinfo.wall.single": "Solid",
|
"objinfo.wall.single": "Solid",
|
||||||
"objinfo.wall.multi": "Multi layer",
|
"objinfo.wall.multi": "Multi layer",
|
||||||
@@ -283,6 +354,11 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.wall.top": "Top (OK)",
|
"objinfo.wall.top": "Top (OK)",
|
||||||
"objinfo.wall.anchorFloor": "Bound",
|
"objinfo.wall.anchorFloor": "Bound",
|
||||||
"objinfo.wall.anchorCustom": "Custom",
|
"objinfo.wall.anchorCustom": "Custom",
|
||||||
|
"objinfo.anchor.refFloor": "Reference storey",
|
||||||
|
"objinfo.anchor.customHeight": "Custom height",
|
||||||
|
"objinfo.wall.length": "Length",
|
||||||
|
"objinfo.wall.volumeGross": "Gross",
|
||||||
|
"objinfo.volume": "Volume",
|
||||||
"objinfo.wall.height": "Height",
|
"objinfo.wall.height": "Height",
|
||||||
"objinfo.wall.floorAboveNone": "No floor above",
|
"objinfo.wall.floorAboveNone": "No floor above",
|
||||||
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
||||||
@@ -294,11 +370,88 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.ceiling.top": "Top (OK)",
|
"objinfo.ceiling.top": "Top (OK)",
|
||||||
"objinfo.ceiling.bottom": "Bottom (UK)",
|
"objinfo.ceiling.bottom": "Bottom (UK)",
|
||||||
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
||||||
|
"objinfo.ceiling.openings": "Openings",
|
||||||
|
"objinfo.ceiling.removeOpening": "Remove opening",
|
||||||
"objinfo.ceiling.area": "Area",
|
"objinfo.ceiling.area": "Area",
|
||||||
"objinfo.ceiling.anchorFloor": "Bound",
|
"objinfo.ceiling.anchorFloor": "Bound",
|
||||||
"objinfo.ceiling.anchorCustom": "Custom",
|
"objinfo.ceiling.anchorCustom": "Custom",
|
||||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||||
"objinfo.opening.section": "Opening",
|
"objinfo.opening.section": "Opening",
|
||||||
|
"objinfo.opening.type": "Type",
|
||||||
|
"objinfo.opening.editType": "Settings …",
|
||||||
|
// Window/door settings dialog (OpeningEditorDialog).
|
||||||
|
"oed.title.window": "Window settings",
|
||||||
|
"oed.title.door": "Door settings",
|
||||||
|
"oed.style": "Style",
|
||||||
|
"oed.saveStyle": "Save as style …",
|
||||||
|
"oed.saveStyle.hint": "Store the current settings as a new, reusable style",
|
||||||
|
"oed.saveStyle.prompt": "Name of the new style:",
|
||||||
|
"oed.saveStyle.copySuffix": "(copy)",
|
||||||
|
"oed.categories": "Categories",
|
||||||
|
"oed.noStyle": "No style assigned — choose or save one via the style bar.",
|
||||||
|
"oed.preview": "View",
|
||||||
|
"oed.cat.basis": "Basics",
|
||||||
|
"oed.cat.groesse": "Size/position",
|
||||||
|
"oed.cat.rahmen": "Frame",
|
||||||
|
"oed.cat.fluegel": "Sashes/muntins",
|
||||||
|
"oed.cat.sonnenschutz": "Shading",
|
||||||
|
"oed.cat.tuerblatt": "Leaf",
|
||||||
|
"oed.f.kind": "Opening type",
|
||||||
|
"oed.f.glazing": "Glazing",
|
||||||
|
"oed.f.insetFace": "Position from",
|
||||||
|
"oed.f.insetFromFace": "Distance from edge (m)",
|
||||||
|
"oed.f.width": "Width (m)",
|
||||||
|
"oed.f.height": "Height (m)",
|
||||||
|
"oed.f.sillHeight": "Sill height (m)",
|
||||||
|
"oed.f.position": "Position (m)",
|
||||||
|
"oed.f.frameThickness": "Frame depth across (m)",
|
||||||
|
"oed.f.frameDepth": "Frame depth (m)",
|
||||||
|
"oed.f.frameWidth": "Frame face width (m)",
|
||||||
|
"oed.f.transomHeight": "Transom height (m)",
|
||||||
|
"oed.f.mullionRows": "Transom/muntin rows",
|
||||||
|
"oed.f.mullionCols": "Muntin columns",
|
||||||
|
"oed.f.sillLineFaces": "Top-/bottom-view line",
|
||||||
|
"oed.f.sillLineView": "View direction",
|
||||||
|
"oed.sillLine.off": "None",
|
||||||
|
"oed.sillLine.beide": "Both",
|
||||||
|
"oed.sillLine.auf": "Top view (from above)",
|
||||||
|
"oed.sillLine.unter": "Bottom view (from below)",
|
||||||
|
"oed.kind.dreh": "Casement",
|
||||||
|
"oed.kind.kipp": "Tilt",
|
||||||
|
"oed.kind.drehkipp": "Turn-tilt",
|
||||||
|
"oed.kind.fest": "Fixed",
|
||||||
|
"oed.kind.schiebe": "Sliding",
|
||||||
|
"oed.glazing.1": "Single",
|
||||||
|
"oed.glazing.2": "Double",
|
||||||
|
"oed.glazing.3": "Triple",
|
||||||
|
"oed.insetFace.aussen": "Outer face",
|
||||||
|
"oed.insetFace.innen": "Inner face",
|
||||||
|
"oed.fluegel.hint": "Sashes and posts from left to right. Opening type and hinge per sash.",
|
||||||
|
"oed.fluegel.col.kind": "Type",
|
||||||
|
"oed.fluegel.col.opening": "Opening",
|
||||||
|
"oed.fluegel.col.hinge": "Hinge",
|
||||||
|
"oed.fluegel.type.fluegel": "Sash",
|
||||||
|
"oed.fluegel.type.pfosten": "Post",
|
||||||
|
"oed.fluegel.remove": "Remove",
|
||||||
|
"oed.hinge.left": "Left",
|
||||||
|
"oed.hinge.right": "Right",
|
||||||
|
"oed.shading.create": "Roller shutter / shading box",
|
||||||
|
"oed.shading.kind": "Kind",
|
||||||
|
"oed.shading.rollladen": "Roller shutter",
|
||||||
|
"oed.shading.raffstore": "Venetian blind",
|
||||||
|
"oed.shading.markise": "Awning",
|
||||||
|
"oed.shading.boxHeight": "Box height (m)",
|
||||||
|
"oed.shading.boxDepth": "Box depth (m)",
|
||||||
|
"oed.door.leafCount": "Door leaves",
|
||||||
|
"oed.door.leaf1": "Single leaf",
|
||||||
|
"oed.door.leaf2": "Double leaf",
|
||||||
|
"oed.door.leafStyle": "Leaf style",
|
||||||
|
"oed.door.glatt": "Flush",
|
||||||
|
"oed.door.kassette": "Panelled",
|
||||||
|
"oed.door.glas": "Glass",
|
||||||
|
"oed.door.wandoeffnung": "Wall opening",
|
||||||
|
"objinfo.stair.type": "Type",
|
||||||
|
"objinfo.type.none": "(no type)",
|
||||||
"objinfo.opening.kind": "Type",
|
"objinfo.opening.kind": "Type",
|
||||||
"objinfo.opening.kind.window": "Window",
|
"objinfo.opening.kind.window": "Window",
|
||||||
"objinfo.opening.kind.door": "Door",
|
"objinfo.opening.kind.door": "Door",
|
||||||
@@ -328,6 +481,11 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.opening.lintelLines.innen": "Inside",
|
"objinfo.opening.lintelLines.innen": "Inside",
|
||||||
"objinfo.opening.lintelLines.aussen": "Outside",
|
"objinfo.opening.lintelLines.aussen": "Outside",
|
||||||
"objinfo.opening.lintelLines.beide": "Both",
|
"objinfo.opening.lintelLines.beide": "Both",
|
||||||
|
"objinfo.opening.frameLine": "Frame (cut)",
|
||||||
|
"objinfo.opening.frameViewLine": "Frame (view)",
|
||||||
|
"objinfo.opening.sashLine": "Window (sash/muntins)",
|
||||||
|
"objinfo.opening.sillLine": "Sill",
|
||||||
|
"objinfo.lineWeight": "Line weight (mm)",
|
||||||
"objinfo.stair.section": "Stair",
|
"objinfo.stair.section": "Stair",
|
||||||
"objinfo.stair.shape": "Shape",
|
"objinfo.stair.shape": "Shape",
|
||||||
"objinfo.stair.shape.straight": "Straight",
|
"objinfo.stair.shape.straight": "Straight",
|
||||||
@@ -356,6 +514,28 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.room.perimeter": "Perimeter",
|
"objinfo.room.perimeter": "Perimeter",
|
||||||
"objinfo.room.refFloor": "Reference floor",
|
"objinfo.room.refFloor": "Reference floor",
|
||||||
"objinfo.room.editStamp": "Edit stamp …",
|
"objinfo.room.editStamp": "Edit stamp …",
|
||||||
|
// ── Extruded solid attributes (truck integration, object info panel) ────
|
||||||
|
"objinfo.sectionLine.section": "Section line",
|
||||||
|
"objinfo.sectionLine.kind.section": "Section",
|
||||||
|
"objinfo.sectionLine.kind.elevation": "Elevation",
|
||||||
|
"objinfo.sectionLine.name": "Name",
|
||||||
|
"objinfo.sectionLine.flip": "Flip view direction",
|
||||||
|
"objinfo.sectionLine.startX": "Start X",
|
||||||
|
"objinfo.sectionLine.startY": "Start Y",
|
||||||
|
"objinfo.sectionLine.endX": "End X",
|
||||||
|
"objinfo.sectionLine.endY": "End Y",
|
||||||
|
"objinfo.sectionLine.depth": "Section depth (m)",
|
||||||
|
"objinfo.sectionLine.hiddenLines": "Hidden lines",
|
||||||
|
"objinfo.sectionLine.depthHint": "empty = unlimited",
|
||||||
|
"objinfo.sectionLine.delete": "Delete section line",
|
||||||
|
"objinfo.extrudedSolid.section": "Extrusion",
|
||||||
|
"objinfo.extrudedSolid.height": "Height",
|
||||||
|
"objinfo.extrudedSolid.taper": "Taper",
|
||||||
|
"objinfo.extrudedSolid.area": "Footprint area",
|
||||||
|
"objinfo.extrudedSolid.floor": "Floor",
|
||||||
|
"objinfo.rotate": "Rotate",
|
||||||
|
"objinfo.drawing.length": "Length",
|
||||||
|
"objinfo.drawing.radius": "Radius",
|
||||||
"levels.addFloor": "+ Floor",
|
"levels.addFloor": "+ Floor",
|
||||||
"levels.addSection": "+ Section",
|
"levels.addSection": "+ Section",
|
||||||
"levels.addElevation": "+ Elevation",
|
"levels.addElevation": "+ Elevation",
|
||||||
@@ -456,7 +636,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"section.loading": "Computing {kind} …",
|
"section.loading": "Computing {kind} …",
|
||||||
"section.empty": "{kind} — no section line set in the plan",
|
"section.empty": "{kind} — no section line set in the plan",
|
||||||
"section.error": "{kind} — computation failed (rebuild render3d WASM?)",
|
"section.error": "{kind} — computation failed (rebuild render3d WASM?)",
|
||||||
"section.legend": "{kind} · derived projection (render3d)",
|
"section.legend": "{kind} · projection derived from the model",
|
||||||
|
"elevation.shadows": "Shadows",
|
||||||
|
"elevation.shadows.hint": "Toggle 45° cast shadows of overhanging elements in the elevation",
|
||||||
"props.floorHeight": "Floor height",
|
"props.floorHeight": "Floor height",
|
||||||
"props.cutHeight": "Cut height",
|
"props.cutHeight": "Cut height",
|
||||||
"props.okff": "FFL",
|
"props.okff": "FFL",
|
||||||
@@ -484,6 +666,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"default.ceilingTypeSingle": "Ceiling {thickness} m",
|
"default.ceilingTypeSingle": "Ceiling {thickness} m",
|
||||||
"default.wallTypeName": "New wall type",
|
"default.wallTypeName": "New wall type",
|
||||||
"default.ceilingTypeName": "New ceiling type",
|
"default.ceilingTypeName": "New ceiling type",
|
||||||
|
"default.roofTypeName": "New roof type",
|
||||||
|
"default.doorTypeName": "New door type",
|
||||||
|
"default.windowTypeName": "New window type",
|
||||||
|
"default.stairTypeName": "New stair type",
|
||||||
|
|
||||||
// Alerts.
|
// Alerts.
|
||||||
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
|
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
|
||||||
@@ -492,6 +678,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
|
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
|
||||||
"alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.",
|
"alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.",
|
||||||
"alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.",
|
"alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.",
|
||||||
|
"alert.roofTypeInUse": "Roof type “{name}” is used by a roof and cannot be deleted.",
|
||||||
|
"alert.doorTypeInUse": "Door type “{name}” is used by a door and cannot be deleted.",
|
||||||
|
"alert.windowTypeInUse": "Window type “{name}” is used by a window and cannot be deleted.",
|
||||||
|
"alert.stairTypeInUse": "Stair type “{name}” is used by a stair and cannot be deleted.",
|
||||||
|
|
||||||
// Resource manager.
|
// Resource manager.
|
||||||
"resources.title": "Resources",
|
"resources.title": "Resources",
|
||||||
@@ -502,7 +692,39 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.tab.lines": "Lines",
|
"resources.tab.lines": "Lines",
|
||||||
"resources.tab.wallStyles": "Wall styles",
|
"resources.tab.wallStyles": "Wall styles",
|
||||||
"resources.tab.ceilingStyles": "Ceiling styles",
|
"resources.tab.ceilingStyles": "Ceiling styles",
|
||||||
|
"resources.tab.roofStyles": "Roof types",
|
||||||
|
"resources.tab.doorStyles": "Door types",
|
||||||
|
"resources.tab.windowStyles": "Window types",
|
||||||
|
"resources.tab.stairStyles": "Stair types",
|
||||||
"resources.tab.materials": "Materials",
|
"resources.tab.materials": "Materials",
|
||||||
|
"resources.tab.overrides": "Overrides",
|
||||||
|
|
||||||
|
// Graphic overrides (rule engine).
|
||||||
|
"resources.overrides.hint":
|
||||||
|
"Rules override color/lineweight/line style at render time (element data stays untouched). Order = priority: per field, the topmost active rule wins.",
|
||||||
|
"resources.overrides.empty": "No override rules yet.",
|
||||||
|
"resources.overrides.placeholder": "Rule",
|
||||||
|
"resources.overrides.defaultName": "New rule",
|
||||||
|
"resources.overrides.delete": "Delete rule",
|
||||||
|
"resources.overrides.moveUp": "Move rule up (higher priority)",
|
||||||
|
"resources.overrides.moveDown": "Move rule down (lower priority)",
|
||||||
|
"resources.overrides.enabled": "Rule active",
|
||||||
|
"resources.overrides.condition": "Condition",
|
||||||
|
"resources.overrides.condType": "Criterion",
|
||||||
|
"resources.overrides.condType.layer": "Layer (name/code)",
|
||||||
|
"resources.overrides.condType.object": "Object name",
|
||||||
|
"resources.overrides.operator": "Operator",
|
||||||
|
"resources.overrides.op.equals": "equals",
|
||||||
|
"resources.overrides.op.contains": "contains",
|
||||||
|
"resources.overrides.op.startsWith": "starts with",
|
||||||
|
"resources.overrides.op.notEquals": "not equal to",
|
||||||
|
"resources.overrides.value": "Value",
|
||||||
|
"resources.overrides.value.placeholder": "e.g. Walls or 20",
|
||||||
|
"resources.overrides.actions": "Actions",
|
||||||
|
"resources.overrides.action.color": "Color",
|
||||||
|
"resources.overrides.action.lineweight": "Lineweight (mm)",
|
||||||
|
"resources.overrides.action.linetype": "Line style",
|
||||||
|
"resources.overrides.linetype.none": "— (unchanged)",
|
||||||
|
|
||||||
"resources.wallStyles.hint":
|
"resources.wallStyles.hint":
|
||||||
"Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.",
|
"Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.",
|
||||||
@@ -512,6 +734,89 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.ceilingStyles.empty": "No ceiling types yet.",
|
"resources.ceilingStyles.empty": "No ceiling types yet.",
|
||||||
"resources.wallStyles.placeholder": "Wall type",
|
"resources.wallStyles.placeholder": "Wall type",
|
||||||
"resources.ceilingStyles.placeholder": "Ceiling type",
|
"resources.ceilingStyles.placeholder": "Ceiling type",
|
||||||
|
"resources.roofStyles.hint":
|
||||||
|
"Per roof type (layer build-up outside/covering → inside/lining) the layer joints as a selectable line style. Without assignment: hairline 0.02 mm.",
|
||||||
|
"resources.roofStyles.empty": "No roof types yet.",
|
||||||
|
"resources.roofStyles.placeholder": "Roof 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.frameKind": "Frame type",
|
||||||
|
"resources.field.frameWidth": "Frame width (m)",
|
||||||
|
"resources.field.insetFromFace": "Layer inset (m)",
|
||||||
|
"resources.field.insetFace": "Inset from face",
|
||||||
|
"resources.field.transomHeight": "Transom height (m)",
|
||||||
|
"resources.field.mullionRows": "Transom rows",
|
||||||
|
"resources.frameKind.zarge": "Lining",
|
||||||
|
"resources.frameKind.blockrahmen": "Block frame",
|
||||||
|
"resources.insetFace.aussen": "Outer",
|
||||||
|
"resources.insetFace.innen": "Inner",
|
||||||
|
"resources.field.windowKind": "Opening style",
|
||||||
|
"resources.field.wingCount": "Wing count",
|
||||||
|
"resources.field.glazing": "Glazing",
|
||||||
|
"resources.field.defaultSillHeight": "Sill height (m)",
|
||||||
|
"resources.field.sillBoard": "Sill board",
|
||||||
|
"resources.field.structure": "Structure",
|
||||||
|
"resources.field.closedRisers": "Closed risers",
|
||||||
|
"resources.field.treadThickness": "Tread thickness (m)",
|
||||||
|
"resources.field.nosing": "Nosing (m)",
|
||||||
|
"resources.field.railing": "Railing",
|
||||||
|
"resources.field.stairWidth": "Run width (m)",
|
||||||
|
|
||||||
|
"resources.doorKind.dreh": "Hinged",
|
||||||
|
"resources.doorKind.schiebe": "Sliding",
|
||||||
|
"resources.doorKind.wandoeffnung": "Wall opening",
|
||||||
|
"resources.leafCount.1": "Single-leaf",
|
||||||
|
"resources.leafCount.2": "Double-leaf",
|
||||||
|
"resources.leafStyle.glatt": "Flush",
|
||||||
|
"resources.leafStyle.kassette": "Panelled",
|
||||||
|
"resources.leafStyle.glas": "Glazed",
|
||||||
|
"resources.windowKind.dreh": "Turn",
|
||||||
|
"resources.windowKind.kipp": "Tilt",
|
||||||
|
"resources.windowKind.drehkipp": "Turn-tilt",
|
||||||
|
"resources.windowKind.fest": "Fixed",
|
||||||
|
"resources.windowKind.schiebe": "Sliding",
|
||||||
|
"resources.glazing.einfach": "Single",
|
||||||
|
"resources.glazing.zweifach": "Double",
|
||||||
|
"resources.glazing.dreifach": "Triple",
|
||||||
|
"resources.sillBoard.keine": "None",
|
||||||
|
"resources.sillBoard.innen": "Inside",
|
||||||
|
"resources.sillBoard.aussen": "Outside",
|
||||||
|
"resources.sillBoard.beide": "Both",
|
||||||
|
"resources.structure.massiv": "Solid",
|
||||||
|
"resources.structure.beton": "Concrete (waist slab)",
|
||||||
|
"resources.structure.wange": "String",
|
||||||
|
"resources.structure.aufgesattelt": "Saddled",
|
||||||
|
"resources.structure.spindel": "Spiral",
|
||||||
|
"resources.railing.keine": "None",
|
||||||
|
"resources.railing.links": "Left",
|
||||||
|
"resources.railing.rechts": "Right",
|
||||||
|
"resources.railing.beide": "Both",
|
||||||
|
|
||||||
"resources.col.layer": "Layer",
|
"resources.col.layer": "Layer",
|
||||||
"resources.col.component": "Component",
|
"resources.col.component": "Component",
|
||||||
"resources.col.thickness": "Thickness (mm)",
|
"resources.col.thickness": "Thickness (mm)",
|
||||||
@@ -539,6 +844,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.components.placeholder": "Component",
|
"resources.components.placeholder": "Component",
|
||||||
"resources.col.name": "Name",
|
"resources.col.name": "Name",
|
||||||
"resources.col.color": "Color",
|
"resources.col.color": "Color",
|
||||||
|
"resources.col.abbrev": "Code",
|
||||||
|
"resources.col.abbrev.hint": "Short code for wall-type dropdowns (e.g. «CON», «BRK»)",
|
||||||
|
"resources.col.abbrev.placeholder": "e.g. CON",
|
||||||
"resources.col.hatch": "Section hatch",
|
"resources.col.hatch": "Section hatch",
|
||||||
"resources.col.viewHatch": "View hatch",
|
"resources.col.viewHatch": "View hatch",
|
||||||
"resources.viewHatch.none": "(none)",
|
"resources.viewHatch.none": "(none)",
|
||||||
@@ -603,6 +911,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"resources.delete.hatch": "Delete hatch “{name}”",
|
"resources.delete.hatch": "Delete hatch “{name}”",
|
||||||
"resources.delete.wallType": "Delete wall type “{name}”",
|
"resources.delete.wallType": "Delete wall type “{name}”",
|
||||||
"resources.delete.ceilingType": "Delete ceiling type “{name}”",
|
"resources.delete.ceilingType": "Delete ceiling type “{name}”",
|
||||||
|
"resources.delete.roofType": "Delete roof type “{name}”",
|
||||||
|
|
||||||
// Hatch/line type (master-detail editor).
|
// Hatch/line type (master-detail editor).
|
||||||
"resources.field.type": "Type",
|
"resources.field.type": "Type",
|
||||||
@@ -674,6 +983,25 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.ceiling.label": "Ceiling",
|
"cmd.ceiling.label": "Ceiling",
|
||||||
"cmd.ceiling.start": "First outline point of ceiling:",
|
"cmd.ceiling.start": "First outline point of ceiling:",
|
||||||
"cmd.ceiling.next": "Next point ( Close ):",
|
"cmd.ceiling.next": "Next point ( Close ):",
|
||||||
|
"cmd.roof.label": "Roof",
|
||||||
|
"cmd.roof.start": "First roof corner:",
|
||||||
|
"cmd.roof.corner": "Opposite corner:",
|
||||||
|
"cmd.ceilingOpening.label": "Slab opening",
|
||||||
|
"cmd.ceilingOpening.start": "First corner of the opening (inside a slab):",
|
||||||
|
"cmd.ceilingOpening.corner": "Opposite corner:",
|
||||||
|
"cmd.roof.shape.sattel": "Gable roof",
|
||||||
|
"cmd.roof.shape.walm": "Hip roof",
|
||||||
|
"cmd.roof.shape.pult": "Mono-pitch roof",
|
||||||
|
"cmd.roof.shape.mansarde": "Mansard roof",
|
||||||
|
"cmd.roof.shape.zelt": "Pyramid roof",
|
||||||
|
"cmd.roof.shape.flach": "Flat roof",
|
||||||
|
"cmd.sectionline.label": "Section line",
|
||||||
|
"cmd.sectionline.start": "Section start point:",
|
||||||
|
"cmd.sectionline.end": "Section end point:",
|
||||||
|
"cmd.sectionline.kind.section": "Section",
|
||||||
|
"cmd.sectionline.kind.elevation": "Elevation",
|
||||||
|
"cmd.sectionline.newSection": "Section {letter}",
|
||||||
|
"cmd.sectionline.newElevation": "Elevation {letter}",
|
||||||
"cmd.opening.label": "Opening",
|
"cmd.opening.label": "Opening",
|
||||||
"cmd.opening.windowLabel": "Window",
|
"cmd.opening.windowLabel": "Window",
|
||||||
"cmd.opening.doorLabel": "Door",
|
"cmd.opening.doorLabel": "Door",
|
||||||
@@ -685,6 +1013,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.opening.dir": "Direction",
|
"cmd.opening.dir": "Direction",
|
||||||
"cmd.opening.sillField": "Sill",
|
"cmd.opening.sillField": "Sill",
|
||||||
"cmd.opening.swingField": "Angle",
|
"cmd.opening.swingField": "Angle",
|
||||||
|
"cmd.column.label": "Column",
|
||||||
|
"cmd.column.place": "Place column:",
|
||||||
|
"cmd.column.profile": "Profile",
|
||||||
"cmd.stair.label": "Stair",
|
"cmd.stair.label": "Stair",
|
||||||
"cmd.stair.start": "Start point of the stair:",
|
"cmd.stair.start": "Start point of the stair:",
|
||||||
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
|
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
|
||||||
@@ -719,10 +1050,29 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.arc.center": "Center:",
|
"cmd.arc.center": "Center:",
|
||||||
"cmd.arc.start": "Start point (radius + start angle):",
|
"cmd.arc.start": "Start point (radius + start angle):",
|
||||||
"cmd.arc.end": "End point (end angle):",
|
"cmd.arc.end": "End point (end angle):",
|
||||||
|
"cmd.text.label": "Text",
|
||||||
|
"cmd.text.point": "Anchor point:",
|
||||||
|
"cmd.text.enter": "Enter text:",
|
||||||
|
"cmd.textbox.label": "Text column",
|
||||||
|
"cmd.textbox.point": "Anchor point:",
|
||||||
|
"cmd.textbox.width": "Column width (second point):",
|
||||||
|
"cmd.textbox.enter": "Enter text:",
|
||||||
"cmd.move.label": "Move",
|
"cmd.move.label": "Move",
|
||||||
"cmd.move.base": "Base point:",
|
"cmd.move.base": "Base point:",
|
||||||
"cmd.move.target": "Target point:",
|
"cmd.move.target": "Target point:",
|
||||||
"cmd.move.empty": "Nothing selected (select objects first).",
|
"cmd.move.empty": "Nothing selected (select objects first).",
|
||||||
|
"cmd.rotate.label": "Rotate",
|
||||||
|
"cmd.measure.label": "Measure",
|
||||||
|
"cmd.measure.start": "Measure from:",
|
||||||
|
"cmd.measure.to": "to (distance · angle):",
|
||||||
|
"cmd.measure.next": "next point (right-click = new path):",
|
||||||
|
"objinfo.measure.title": "Measurement",
|
||||||
|
"objinfo.measure.segment": "Segment",
|
||||||
|
"objinfo.measure.total": "Total length",
|
||||||
|
"objinfo.measure.area": "Area",
|
||||||
|
"objinfo.measure.angle": "Angle",
|
||||||
|
"objinfo.measure.hint": "Click = point · right-click = new path · Esc = end",
|
||||||
|
"transform.needSelection": "Select objects first, then rotate.",
|
||||||
"cmd.mirror.label": "Mirror",
|
"cmd.mirror.label": "Mirror",
|
||||||
"cmd.mirror.first": "First point of mirror axis:",
|
"cmd.mirror.first": "First point of mirror axis:",
|
||||||
"cmd.mirror.second": "Second point of mirror axis:",
|
"cmd.mirror.second": "Second point of mirror axis:",
|
||||||
@@ -735,6 +1085,10 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.offset.label": "Offset",
|
"cmd.offset.label": "Offset",
|
||||||
"cmd.offset.pick": "Select curve:",
|
"cmd.offset.pick": "Select curve:",
|
||||||
"cmd.offset.side": "Side or distance:",
|
"cmd.offset.side": "Side or distance:",
|
||||||
|
"cmd.extrude.label": "Extrude",
|
||||||
|
"cmd.extrude.pick": "Select closed profile:",
|
||||||
|
"cmd.extrude.height": "Height:",
|
||||||
|
"cmd.extrude.taper": "Taper (0–1, 1=point):",
|
||||||
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
||||||
"cmd.trim.label": "Trim",
|
"cmd.trim.label": "Trim",
|
||||||
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
|
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
|
||||||
@@ -758,6 +1112,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.import.prompt": "Choose DXF file …",
|
"cmd.import.prompt": "Choose DXF file …",
|
||||||
"cmd.terrain.label": "Terrain",
|
"cmd.terrain.label": "Terrain",
|
||||||
"cmd.terrain.prompt": "Generate terrain from contours …",
|
"cmd.terrain.prompt": "Generate terrain from contours …",
|
||||||
|
"cmd.georef.label": "New reference point",
|
||||||
|
"cmd.georef.prompt": "New origin (shifts the whole project):",
|
||||||
|
|
||||||
// Area balance (SIA 416).
|
// Area balance (SIA 416).
|
||||||
"nav.roomBalance": "Area balance",
|
"nav.roomBalance": "Area balance",
|
||||||
@@ -849,11 +1205,42 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"file.exportPdf": "Export as PDF…",
|
"file.exportPdf": "Export as PDF…",
|
||||||
"file.exportDxf": "Export as DXF…",
|
"file.exportDxf": "Export as DXF…",
|
||||||
"file.exportSchedule": "Component schedule (CSV)…",
|
"file.exportSchedule": "Component schedule (CSV)…",
|
||||||
|
"file.exportIfc": "Export IFC",
|
||||||
|
"file.exportObj": "Export OBJ (3D mesh)",
|
||||||
|
"file.exportStl": "Export STL (3D mesh)",
|
||||||
|
"file.export": "Export",
|
||||||
|
"file.openFailed": "Could not read the project file.",
|
||||||
|
|
||||||
|
// Lock conflict (project file already open in another instance).
|
||||||
|
"lock.title": "Project already open",
|
||||||
|
"lock.message": "This project file is already open in another instance.",
|
||||||
|
"lock.messageInfo": "Opened on {host} (process {pid}).",
|
||||||
|
"lock.openReadOnly": "Open read-only",
|
||||||
|
"lock.forceOpen": "Force open anyway",
|
||||||
|
"lock.forceOpenWarning":
|
||||||
|
"Warning: opens for writing even though another instance holds the file — changes may overwrite each other.",
|
||||||
|
"exportSave.title": "Export",
|
||||||
|
"exportSave.format": "Format",
|
||||||
|
"exportSave.filename": "File name",
|
||||||
|
"exportSave.locationNote": "Saved to the default downloads folder.",
|
||||||
|
"exportSave.fmt.csv": "Component schedule (CSV)",
|
||||||
|
"exportSave.fmt.ifc": "IFC (BIM)",
|
||||||
|
"exportSave.fmt.obj": "OBJ (3D mesh)",
|
||||||
|
"exportSave.fmt.stl": "STL (3D mesh)",
|
||||||
|
"about.title": "About dossier",
|
||||||
|
"about.version": "Version",
|
||||||
|
"about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.",
|
||||||
|
"about.copyright": "© 2026 Karim Gabriele Varano",
|
||||||
|
"about.license": "License: AGPL-3.0-or-later",
|
||||||
|
"about.suite": "Part of the openbureau suite",
|
||||||
|
"about.licenses": "Open-source licenses (third-party)",
|
||||||
|
"about.close": "Close",
|
||||||
|
|
||||||
// Settings dialog.
|
// Settings dialog.
|
||||||
"topbar.settings": "Settings",
|
"topbar.settings": "Settings",
|
||||||
"topbar.settings.hint": "Open settings (display colors, project)",
|
"topbar.settings.hint": "Open settings (display colors, project)",
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
|
"settings.section.workspace": "Workspace",
|
||||||
"settings.section.display": "Display",
|
"settings.section.display": "Display",
|
||||||
"settings.marqueeColor": "Selection box color",
|
"settings.marqueeColor": "Selection box color",
|
||||||
"settings.marqueeColor.hint":
|
"settings.marqueeColor.hint":
|
||||||
@@ -877,8 +1264,13 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
|
|
||||||
// Location import (geo.admin / OSM).
|
// Location import (geo.admin / OSM).
|
||||||
"site.importLocation": "Import location",
|
"site.importLocation": "Import location",
|
||||||
|
"site.setGeoref": "New reference point",
|
||||||
|
"site.setGeoref.hint": "Click in the plan to shift the whole project — the clicked point becomes the new origin (0,0). The real-world reference is preserved.",
|
||||||
"ctxImport.title": "Import location",
|
"ctxImport.title": "Import location",
|
||||||
"ctxImport.close": "Close",
|
"ctxImport.close": "Close",
|
||||||
|
"ctxImport.geoAnchor": "Georeferenced from: {label}",
|
||||||
|
"ctxImport.geoAnchor.reset": "Reset",
|
||||||
|
"ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.",
|
||||||
"ctxImport.location": "Place / address",
|
"ctxImport.location": "Place / address",
|
||||||
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
|
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
|
||||||
"ctxImport.search": "Search",
|
"ctxImport.search": "Search",
|
||||||
@@ -888,11 +1280,21 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.radius": "Radius",
|
"ctxImport.radius": "Radius",
|
||||||
"ctxImport.sources": "Sources",
|
"ctxImport.sources": "Sources",
|
||||||
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
||||||
|
"ctxImport.buildings.off": "Off",
|
||||||
|
"ctxImport.buildings.flach": "Simplified (footprint, fast)",
|
||||||
|
"ctxImport.buildings.v2": "Real 2.0 (stable)",
|
||||||
|
"ctxImport.buildings.v3": "Real 3.0 (Beta)",
|
||||||
"ctxImport.src.swissTerrain": "Swisstopo terrain",
|
"ctxImport.src.swissTerrain": "Swisstopo terrain",
|
||||||
|
"ctxImport.terrain.resolution": "Resolution",
|
||||||
|
"ctxImport.terrain.res2": "2 m (fast)",
|
||||||
|
"ctxImport.terrain.res05": "0.5 m (fine, larger tiles)",
|
||||||
"ctxImport.src.osmBuildings": "OSM buildings",
|
"ctxImport.src.osmBuildings": "OSM buildings",
|
||||||
"ctxImport.src.osmRoads": "OSM roads",
|
"ctxImport.src.osmRoads": "OSM roads",
|
||||||
"ctxImport.src.osmWater": "OSM water",
|
"ctxImport.src.osmWater": "OSM water",
|
||||||
"ctxImport.src.osmGreen": "OSM greenery",
|
"ctxImport.src.osmGreen": "OSM greenery",
|
||||||
|
"ctxImport.src.osmParking": "OSM parking",
|
||||||
|
"ctxImport.src.osmRailway": "OSM railway",
|
||||||
|
"ctxImport.src.osmForest": "OSM forest",
|
||||||
"ctxImport.cancel": "Cancel",
|
"ctxImport.cancel": "Cancel",
|
||||||
"ctxImport.confirm": "Import",
|
"ctxImport.confirm": "Import",
|
||||||
"ctxImport.importing": "Importing…",
|
"ctxImport.importing": "Importing…",
|
||||||
@@ -900,6 +1302,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"ctxImport.empty": "No geometry found in the selected area.",
|
"ctxImport.empty": "No geometry found in the selected area.",
|
||||||
"ctxImport.importError": "Import failed.",
|
"ctxImport.importError": "Import failed.",
|
||||||
"ctxImport.imported": "Imported {count} objects in {sets} sets.",
|
"ctxImport.imported": "Imported {count} objects in {sets} sets.",
|
||||||
|
"ctxImport.importedWithSkipped": "Imported {count} objects in {sets} sets ({skipped} building tile(s) skipped, too large).",
|
||||||
|
"ctxImport.buildingsSkipped": "No buildings loaded — {count} tile(s) were too large (dense built-up area). Try a smaller radius or switch generation.",
|
||||||
|
|
||||||
// Room command.
|
// Room command.
|
||||||
"cmd.room.label": "Room",
|
"cmd.room.label": "Room",
|
||||||
@@ -937,6 +1341,9 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"roomStamp.showFloorArea": "Show floor area",
|
"roomStamp.showFloorArea": "Show floor area",
|
||||||
"roomStamp.floorAreaPrefix": "Floor area prefix",
|
"roomStamp.floorAreaPrefix": "Floor area prefix",
|
||||||
"roomStamp.showUsage": "Show usage",
|
"roomStamp.showUsage": "Show usage",
|
||||||
|
"roomStamp.roundingStep": "Area rounding",
|
||||||
|
"roomStamp.roundingStepDefault": "Default (2 decimal places)",
|
||||||
|
"roomStamp.occupancy": "Occupancy",
|
||||||
"text.selectedHint": "Formatting applies to the selected text",
|
"text.selectedHint": "Formatting applies to the selected text",
|
||||||
|
|
||||||
"rt.bold": "Bold",
|
"rt.bold": "Bold",
|
||||||
@@ -944,6 +1351,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"rt.underline": "Underline",
|
"rt.underline": "Underline",
|
||||||
"rt.strike": "Strikethrough",
|
"rt.strike": "Strikethrough",
|
||||||
"rt.strikethrough": "Strikethrough",
|
"rt.strikethrough": "Strikethrough",
|
||||||
|
"rt.super": "Superscript",
|
||||||
|
"rt.sub": "Subscript",
|
||||||
"rt.fontSize": "Font size",
|
"rt.fontSize": "Font size",
|
||||||
"rt.color": "Color",
|
"rt.color": "Color",
|
||||||
"rt.preset": "Preset",
|
"rt.preset": "Preset",
|
||||||
@@ -955,4 +1364,148 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.",
|
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.",
|
||||||
"viewport3d.grid.show": "Show ground grid",
|
"viewport3d.grid.show": "Show ground grid",
|
||||||
"viewport3d.grid.hide": "Hide ground grid",
|
"viewport3d.grid.hide": "Hide ground grid",
|
||||||
|
"viewport3d.section.label": "Section plane",
|
||||||
|
"viewport3d.section.show": "Section plane on",
|
||||||
|
"viewport3d.section.hide": "Section plane off",
|
||||||
|
"viewport3d.grip.vertex": "Drag wall endpoint",
|
||||||
|
"viewport3d.grip.edge": "Drag edge",
|
||||||
|
"viewport3d.grip.height": "Drag height",
|
||||||
|
"viewport3d.grip.move": "Move",
|
||||||
|
"viewport3d.grip.roofpitch": "Drag roof pitch",
|
||||||
|
|
||||||
|
// ── 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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -537,3 +537,65 @@ describe("contoursToDrawings — HATCH-Füllung", () => {
|
|||||||
expect(d.fillColor).toBeUndefined();
|
expect(d.fillColor).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseDxf — Polyface-Mesh (POLYLINE Flag 64, swissBUILDINGS3D-Format)", () => {
|
||||||
|
// Regression: die installierte dxf-parser-Version liefert Face-Vertex-
|
||||||
|
// Indizes NICHT als `faces`-Array, sondern als vier einzelne Felder
|
||||||
|
// (Gruppencodes 71–74, faceA..faceD) — reale swissBUILDINGS3D-DXF-Kacheln
|
||||||
|
// ergaben dadurch 0 Dreiecke (Nutzer-Report „Gebäude-Import funktioniert
|
||||||
|
// nicht"). Rohes DXF (keine gemockte Entity-Form), damit auch die
|
||||||
|
// zugrundeliegende Bibliothek mitgeprüft wird.
|
||||||
|
it("ein Dreieck (3 Geometrie-Vertices + 1 Face-Record) ergibt genau 1 Dreieck", () => {
|
||||||
|
const lines = [
|
||||||
|
"0", "SECTION", "2", "ENTITIES",
|
||||||
|
"0", "POLYLINE", "8", "0", "70", "64",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "0.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "0.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "1.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "128", "10", "0.0", "20", "0.0", "30", "0.0",
|
||||||
|
"71", "1", "72", "2", "73", "3",
|
||||||
|
"0", "SEQEND",
|
||||||
|
"0", "ENDSEC", "0", "EOF",
|
||||||
|
];
|
||||||
|
const res = parseDxf(lines.join("\n"));
|
||||||
|
expect(res.meshes).toHaveLength(1);
|
||||||
|
expect(res.meshes[0].positions.length / 3).toBe(3);
|
||||||
|
expect(res.meshes[0].indices).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein Viereck (4 Geometrie-Vertices + 1 Face-Record mit faceD) ergibt 2 Dreiecke (Fan)", () => {
|
||||||
|
const lines = [
|
||||||
|
"0", "SECTION", "2", "ENTITIES",
|
||||||
|
"0", "POLYLINE", "8", "0", "70", "64",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "0.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "0.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "1.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "1.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "128", "10", "0.0", "20", "0.0", "30", "0.0",
|
||||||
|
"71", "1", "72", "2", "73", "3", "74", "4",
|
||||||
|
"0", "SEQEND",
|
||||||
|
"0", "ENDSEC", "0", "EOF",
|
||||||
|
];
|
||||||
|
const res = parseDxf(lines.join("\n"));
|
||||||
|
expect(res.meshes).toHaveLength(1);
|
||||||
|
expect(res.meshes[0].positions.length / 3).toBe(4);
|
||||||
|
expect(res.meshes[0].indices.length / 3).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative (unsichtbare-Kante) Face-Indizes werden per abs() aufgelöst", () => {
|
||||||
|
const lines = [
|
||||||
|
"0", "SECTION", "2", "ENTITIES",
|
||||||
|
"0", "POLYLINE", "8", "0", "70", "64",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "0.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "1.0", "20", "0.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "192", "10", "0.0", "20", "1.0", "30", "0.0",
|
||||||
|
"0", "VERTEX", "8", "0", "70", "128", "10", "0.0", "20", "0.0", "30", "0.0",
|
||||||
|
"71", "1", "72", "-2", "73", "3",
|
||||||
|
"0", "SEQEND",
|
||||||
|
"0", "ENDSEC", "0", "EOF",
|
||||||
|
];
|
||||||
|
const res = parseDxf(lines.join("\n"));
|
||||||
|
expect(res.meshes).toHaveLength(1);
|
||||||
|
expect(res.meshes[0].indices).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+26
-10
@@ -58,8 +58,17 @@ interface DxfVertex {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
z?: number;
|
z?: number;
|
||||||
// Polyface-Mesh: 1-basierte, ggf. negative Vertex-Indizes (negativ = unsichtbare Kante).
|
// Polyface-Mesh-Face-Record (VERTEX mit polyfaceMeshVertex-Flag): 1-basierte,
|
||||||
faces?: number[];
|
// ggf. negative Vertex-Indizes (negativ = unsichtbare Kante) — die installierte
|
||||||
|
// dxf-parser-Version liefert sie NICHT als `faces`-Array, sondern als vier
|
||||||
|
// EINZELNE Felder (Gruppencodes 71–74, ein Dreieck nutzt nur faceA–faceC,
|
||||||
|
// ein Viereck alle vier). `polyfaceMeshVertex` markiert einen Face- statt
|
||||||
|
// Geometrie-Vertex (Gruppencode-70-Bit 128, s. isMeshPolyline/addPolyfaceMesh).
|
||||||
|
faceA?: number;
|
||||||
|
faceB?: number;
|
||||||
|
faceC?: number;
|
||||||
|
faceD?: number;
|
||||||
|
polyfaceMeshVertex?: boolean;
|
||||||
}
|
}
|
||||||
interface DxfEntity {
|
interface DxfEntity {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -295,18 +304,25 @@ function addMesh(positions: number[], indices: number[], e: DxfEntity): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rohe Face-Vertex-Indizes eines VERTEX-Records (faceA..faceD, nur gesetzte,
|
||||||
|
* von Null verschiedene Felder — ein Dreieck belegt nur faceA–faceC). */
|
||||||
|
function faceIndicesOf(v: DxfVertex): number[] {
|
||||||
|
return [v.faceA, v.faceB, v.faceC, v.faceD].filter(
|
||||||
|
(i): i is number => typeof i === "number" && i !== 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */
|
/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */
|
||||||
function isMeshPolyline(e: DxfEntity): boolean {
|
function isMeshPolyline(e: DxfEntity): boolean {
|
||||||
if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true;
|
if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true;
|
||||||
// Heuristik: enthält irgendein Vertex eine `faces`-Liste → Polyface-Mesh.
|
// Heuristik: enthält irgendein Vertex Face-Indizes → Polyface-Mesh.
|
||||||
return (e.vertices ?? []).some(
|
return (e.vertices ?? []).some((v) => faceIndicesOf(v).length > 0);
|
||||||
(v) => Array.isArray(v.faces) && v.faces.length > 0,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records
|
* Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records
|
||||||
* mit 1-basierten (ggf. negativen = unsichtbare Kante) Vertex-Indizes. Wir
|
* (VERTEX mit `polyfaceMeshVertex`-Flag, s. {@link DxfVertex}) mit 1-basierten
|
||||||
|
* (ggf. negativen = unsichtbare Kante) Vertex-Indizes in faceA..faceD. Wir
|
||||||
* trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face.
|
* trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face.
|
||||||
*/
|
*/
|
||||||
function addPolyfaceMesh(
|
function addPolyfaceMesh(
|
||||||
@@ -315,8 +331,8 @@ function addPolyfaceMesh(
|
|||||||
e: DxfEntity,
|
e: DxfEntity,
|
||||||
): void {
|
): void {
|
||||||
const all = e.vertices ?? [];
|
const all = e.vertices ?? [];
|
||||||
const geom = all.filter((v) => !(Array.isArray(v.faces) && v.faces.length));
|
const geom = all.filter((v) => faceIndicesOf(v).length === 0);
|
||||||
const faceRecs = all.filter((v) => Array.isArray(v.faces) && v.faces.length);
|
const faceRecs = all.filter((v) => faceIndicesOf(v).length > 0);
|
||||||
if (geom.length < 3) return;
|
if (geom.length < 3) return;
|
||||||
const base = positions.length / 3;
|
const base = positions.length / 3;
|
||||||
for (const v of geom) {
|
for (const v of geom) {
|
||||||
@@ -324,7 +340,7 @@ function addPolyfaceMesh(
|
|||||||
}
|
}
|
||||||
for (const fr of faceRecs) {
|
for (const fr of faceRecs) {
|
||||||
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
|
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
|
||||||
const ring = (fr.faces ?? [])
|
const ring = faceIndicesOf(fr)
|
||||||
.map((i) => Math.abs(i))
|
.map((i) => Math.abs(i))
|
||||||
.filter((i) => i >= 1)
|
.filter((i) => i >= 1)
|
||||||
.map((i) => base + (i - 1));
|
.map((i) => base + (i - 1));
|
||||||
|
|||||||
+15
-2
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
|
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
|
||||||
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
|
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
|
||||||
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
|
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün/Parkplatz/Bahn/Wald). Die UI wandelt diese in
|
||||||
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
|
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
|
||||||
//
|
//
|
||||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
@@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95";
|
|||||||
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
|
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
|
||||||
|
|
||||||
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
||||||
export type GeoCategory = "building" | "road" | "water" | "green";
|
export type GeoCategory =
|
||||||
|
| "building"
|
||||||
|
| "road"
|
||||||
|
| "water"
|
||||||
|
| "green"
|
||||||
|
| "parking"
|
||||||
|
| "railway"
|
||||||
|
| "forest";
|
||||||
|
|
||||||
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
|
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
|
||||||
export interface GeoFeature {
|
export interface GeoFeature {
|
||||||
@@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record<GeoCategory, string> = {
|
|||||||
road: "Strassen",
|
road: "Strassen",
|
||||||
water: "Gewässer",
|
water: "Gewässer",
|
||||||
green: "Grünflächen",
|
green: "Grünflächen",
|
||||||
|
parking: "Parkplätze",
|
||||||
|
railway: "Bahn",
|
||||||
|
forest: "Wald",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
|
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
|
||||||
@@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record<GeoCategory, string> = {
|
|||||||
road: "context-roads",
|
road: "context-roads",
|
||||||
water: "context-water",
|
water: "context-water",
|
||||||
green: "context-green",
|
green: "context-green",
|
||||||
|
parking: "context-parking",
|
||||||
|
railway: "context-railway",
|
||||||
|
forest: "context-forest",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
|
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
|
||||||
|
|||||||
+28
-8
@@ -1,7 +1,8 @@
|
|||||||
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
|
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
|
||||||
//
|
//
|
||||||
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
|
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
|
||||||
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
|
// Bahnlinien / Wald und wandelt sie in Polylinien in LOKALEN Modell-Metern (über
|
||||||
|
// LV95 + Origin, siehe
|
||||||
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
|
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
|
||||||
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
|
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
|
||||||
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
|
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
|
||||||
@@ -24,6 +25,9 @@ export interface OsmSelection {
|
|||||||
roads: boolean;
|
roads: boolean;
|
||||||
water: boolean;
|
water: boolean;
|
||||||
green: boolean;
|
green: boolean;
|
||||||
|
parking: boolean;
|
||||||
|
railway: boolean;
|
||||||
|
forest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
|
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
|
||||||
@@ -55,7 +59,12 @@ function buildQuery(
|
|||||||
}
|
}
|
||||||
if (sel.green) {
|
if (sel.green) {
|
||||||
parts.push(`way["leisure"="park"](${b});`);
|
parts.push(`way["leisure"="park"](${b});`);
|
||||||
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
|
parts.push(`way["landuse"~"grass|meadow|recreation_ground"](${b});`);
|
||||||
|
}
|
||||||
|
if (sel.parking) parts.push(`way["amenity"="parking"](${b});`);
|
||||||
|
if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`);
|
||||||
|
if (sel.forest) {
|
||||||
|
parts.push(`way["landuse"="forest"](${b});`);
|
||||||
parts.push(`way["natural"="wood"](${b});`);
|
parts.push(`way["natural"="wood"](${b});`);
|
||||||
}
|
}
|
||||||
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
|
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
|
||||||
@@ -69,20 +78,23 @@ function categorize(
|
|||||||
if (!tags) return null;
|
if (!tags) return null;
|
||||||
if (sel.buildings && tags.building) return "building";
|
if (sel.buildings && tags.building) return "building";
|
||||||
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
|
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
|
||||||
|
if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest"))
|
||||||
|
return "forest";
|
||||||
if (
|
if (
|
||||||
sel.green &&
|
sel.green &&
|
||||||
(tags.leisure === "park" ||
|
(tags.leisure === "park" ||
|
||||||
tags.natural === "wood" ||
|
/grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
|
||||||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
|
|
||||||
)
|
)
|
||||||
return "green";
|
return "green";
|
||||||
|
if (sel.parking && tags.amenity === "parking") return "parking";
|
||||||
|
if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway";
|
||||||
if (sel.roads && tags.highway) return "road";
|
if (sel.roads && tags.highway) return "road";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
|
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
|
||||||
function isClosedCategory(cat: GeoCategory): boolean {
|
function isClosedCategory(cat: GeoCategory): boolean {
|
||||||
return cat !== "road";
|
return cat !== "road" && cat !== "railway";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +130,15 @@ export async function fetchOsm(
|
|||||||
originIn?: GeoOrigin,
|
originIn?: GeoOrigin,
|
||||||
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
||||||
const origin = originIn ?? makeOrigin(center);
|
const origin = originIn ?? makeOrigin(center);
|
||||||
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
|
if (
|
||||||
|
!sel.buildings &&
|
||||||
|
!sel.roads &&
|
||||||
|
!sel.water &&
|
||||||
|
!sel.green &&
|
||||||
|
!sel.parking &&
|
||||||
|
!sel.railway &&
|
||||||
|
!sel.forest
|
||||||
|
) {
|
||||||
return { origin, features: [] };
|
return { origin, features: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Tests für die reinen (netzwerkfreien) Bausteine der gemeinsamen STAC-API-
|
||||||
|
// Anbindung: Asset-Auswahl (Prioritäts-/Tile-Filter). Die Netzwerk-Funktionen
|
||||||
|
// (`stacQuery`/`downloadAssetText`) bleiben ungetestet, analog zu
|
||||||
|
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { pickAsset } from "./stacApi";
|
||||||
|
import type { StacItem } from "./stacApi";
|
||||||
|
|
||||||
|
const DXF_PRIORITY = [".dxf", ".dwg", ".obj", ".ifc", ".dxf.zip", ".dwg.zip", ".obj.zip", ".ifc.zip"];
|
||||||
|
|
||||||
|
describe("pickAsset", () => {
|
||||||
|
it("bevorzugt die erste Endung der Prioritätsliste (z. B. DXF vor OBJ/IFC/ZIP)", () => {
|
||||||
|
const item: StacItem = {
|
||||||
|
id: "t_1150-23",
|
||||||
|
assets: [
|
||||||
|
{ href: "https://x/t_1150-23_.ifc" },
|
||||||
|
{ href: "https://x/t_1150-23_.obj" },
|
||||||
|
{ href: "https://x/t_1150-23_.dxf" },
|
||||||
|
{ href: "https://x/t_1150-23_.dxf.zip" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(pickAsset(item, DXF_PRIORITY)?.href).toBe("https://x/t_1150-23_.dxf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fällt auf ZIP zurück, wenn keine rohe Datei vorhanden ist", () => {
|
||||||
|
const item: StacItem = {
|
||||||
|
id: "t_1150-23",
|
||||||
|
assets: [
|
||||||
|
{ href: "https://x/t_1150-23_.ifc.zip" },
|
||||||
|
{ href: "https://x/t_1150-23_.dxf.zip" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(pickAsset(item, DXF_PRIORITY)?.href).toBe("https://x/t_1150-23_.dxf.zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert auf Per-Tile-Assets (Tile-Coord-Marker im Dateinamen)", () => {
|
||||||
|
const item: StacItem = {
|
||||||
|
id: "t",
|
||||||
|
assets: [
|
||||||
|
{ href: "https://x/gesamte_schweiz.dxf" }, // kein Tile-Marker -> Fallback-Kandidat
|
||||||
|
{ href: "https://x/t_1150-23_.dxf" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(pickAsset(item, DXF_PRIORITY)?.href).toBe("https://x/t_1150-23_.dxf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert null bei leerer Asset-Liste", () => {
|
||||||
|
expect(pickAsset({ id: "t", assets: [] }, DXF_PRIORITY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respektiert eine andere Prioritätsliste (z. B. XYZ vor TIF)", () => {
|
||||||
|
const item: StacItem = {
|
||||||
|
id: "t_1150-23",
|
||||||
|
assets: [
|
||||||
|
{ href: "https://x/t_1150-23_0.5_2056.tif" },
|
||||||
|
{ href: "https://x/t_1150-23_0.5_2056.xyz.zip" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(pickAsset(item, [".xyz", ".xyz.zip", ".tif"])?.href).toBe(
|
||||||
|
"https://x/t_1150-23_0.5_2056.xyz.zip",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Gemeinsame STAC-API-Bausteine für swisstopo-Datensätze (data.geo.admin.ch,
|
||||||
|
// offen, kein Auth/Key, CORS `*`) — genutzt von `swissBuildings3d.ts`
|
||||||
|
// (swissBUILDINGS3D 2.0/3.0) und `swissAlti3d.ts` (Höhenmodell). Referenz-
|
||||||
|
// Implementierung (Rhino-Vorgänger-Plugin, 1:1 als Blaupause portiert):
|
||||||
|
// git.kgva.ch/karim/DOSSIER, Datei rhino/swisstopo.py.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||||
|
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { viaProxy } from "./geoContext";
|
||||||
|
|
||||||
|
export const STAC_BASE = "https://data.geo.admin.ch/api/stac/v1";
|
||||||
|
|
||||||
|
/** Asset-Grössenlimit (Bytes) — schützt vor riesigen Kacheln (v3-Stadt-Tiles,
|
||||||
|
* 0.5-m-Terrain über einen grossen Radius). Grosszügig, aber endlich. */
|
||||||
|
export const MAX_ASSET_BYTES = 150 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface StacAsset {
|
||||||
|
href: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
export interface StacItem {
|
||||||
|
id: string;
|
||||||
|
assets: StacAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STAC-Items einer Collection in einer WGS84-bbox, gefiltert nach Datei-Endung. */
|
||||||
|
export async function stacQuery(
|
||||||
|
collectionId: string,
|
||||||
|
bbox: { south: number; west: number; north: number; east: number },
|
||||||
|
assetExtensions: string[],
|
||||||
|
limit = 100,
|
||||||
|
): Promise<StacItem[]> {
|
||||||
|
const url =
|
||||||
|
`${STAC_BASE}/collections/${collectionId}/items` +
|
||||||
|
`?bbox=${bbox.west},${bbox.south},${bbox.east},${bbox.north}&limit=${limit}`;
|
||||||
|
const res = await fetch(viaProxy(url));
|
||||||
|
if (!res.ok) throw new Error(`STAC-Abfrage fehlgeschlagen (${res.status})`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
features?: {
|
||||||
|
id?: string;
|
||||||
|
assets?: Record<string, { href?: string; "file:size"?: number }>;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
const out: StacItem[] = [];
|
||||||
|
for (const f of data.features ?? []) {
|
||||||
|
if (!f.id) continue;
|
||||||
|
const assets: StacAsset[] = [];
|
||||||
|
for (const v of Object.values(f.assets ?? {})) {
|
||||||
|
if (!v.href) continue;
|
||||||
|
const low = v.href.toLowerCase();
|
||||||
|
if (!assetExtensions.some((ext) => low.endsWith(ext))) continue;
|
||||||
|
assets.push({ href: v.href, size: v["file:size"] });
|
||||||
|
}
|
||||||
|
if (assets.length === 0) continue;
|
||||||
|
out.push({ id: f.id, assets });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile-Coord-Marker im Dateinamen (z. B. "_1150-23_") — filtert versehentliche
|
||||||
|
// gesamt-CH-Assets aus (Python-Pendant: `_TILE_FILE_PAT`).
|
||||||
|
const TILE_FILE_PAT = /_\d{3,4}-\d{2,4}[_.]/;
|
||||||
|
|
||||||
|
/** Bevorzugtes Kachel-Asset EINES STAC-Items wählen: `priority`-Endungen zuerst
|
||||||
|
* (in Reihenfolge), nur Per-Tile-Assets (kein versehentlicher CH-Komplett-
|
||||||
|
* datensatz). Exportiert (pur, kein Netzwerk) für Unit-Tests der Auswahllogik. */
|
||||||
|
export function pickAsset(item: StacItem, priority: string[]): StacAsset | null {
|
||||||
|
const perTile = item.assets.filter((a) => TILE_FILE_PAT.test(a.href));
|
||||||
|
const pool = perTile.length > 0 ? perTile : item.assets;
|
||||||
|
for (const ext of priority) {
|
||||||
|
const hit = pool.find((a) => a.href.toLowerCase().endsWith(ext));
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return pool[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grobe jszip-interne Schätzung der UNKOMPRIMIERTEN Grösse eines Zip-Eintrags
|
||||||
|
* (Bytes), falls verfügbar — `_data.uncompressedSize` ist kein offiziell
|
||||||
|
* typisiertes API, aber ein stabiles, verbreitet genutztes internes Feld
|
||||||
|
* (siehe `jszip/lib/compressedObject.js`). `null`, wenn nicht auslesbar. */
|
||||||
|
function estimateUncompressedSize(entry: JSZip.JSZipObject): number | null {
|
||||||
|
const data = (entry as unknown as { _data?: { uncompressedSize?: number } })._data;
|
||||||
|
return typeof data?.uncompressedSize === "number" ? data.uncompressedSize : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt ein Asset als Text; entpackt automatisch, wenn es ein `.zip` ist
|
||||||
|
* (liefert den Inhalt der ERSTEN enthaltenen Datei mit einer der `innerExt`-
|
||||||
|
* Endungen). `null` bei Fehler, fehlendem Inhalt im ZIP, oder wenn das Asset
|
||||||
|
* {@link MAX_ASSET_BYTES} überschreitet (komprimiert ODER — DXF/Text
|
||||||
|
* komprimiert stark — unkomprimiert, s. u.). Wirft NIE (fängt Netzwerk-/
|
||||||
|
* ZIP-/String-Längen-Fehler ab und liefert `null`), damit eine einzelne
|
||||||
|
* kaputte/überdimensionierte Kachel nicht den ganzen Import abbricht. */
|
||||||
|
export async function downloadAssetText(
|
||||||
|
asset: StacAsset,
|
||||||
|
innerExt: string[],
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (asset.size != null && asset.size > MAX_ASSET_BYTES) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(viaProxy(asset.href));
|
||||||
|
if (!res.ok) return null;
|
||||||
|
if (asset.href.toLowerCase().endsWith(".zip")) {
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
if (buf.byteLength > MAX_ASSET_BYTES) return null;
|
||||||
|
const zip = await JSZip.loadAsync(buf);
|
||||||
|
const entry = Object.values(zip.files).find(
|
||||||
|
(f) => !f.dir && innerExt.some((ext) => f.name.toLowerCase().endsWith(ext)),
|
||||||
|
);
|
||||||
|
if (!entry) return null;
|
||||||
|
// Proaktiv prüfen: DXF/Text komprimiert stark, ein Zip unter dem Limit
|
||||||
|
// kann trotzdem eine Text-Datei enthalten, die die max. JS-String-Länge
|
||||||
|
// sprengt (führte zu einem harten Absturz statt einem übersprungenen
|
||||||
|
// Tile). Bekannte, aber unschätzbare Grösse (`null`) lässt es zu — der
|
||||||
|
// äussere try/catch fängt den Fall dann trotzdem sicher ab.
|
||||||
|
const uncompressed = estimateUncompressedSize(entry);
|
||||||
|
if (uncompressed != null && uncompressed > MAX_ASSET_BYTES) return null;
|
||||||
|
return await entry.async("text");
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Tests für die reinen (netzwerkfreien) Bausteine des swissALTI3D-XYZ-Imports:
|
||||||
|
// ASCII-Parsing + Grid-Aufbau (Merge/Clip/Dezimierung). Der eigentliche
|
||||||
|
// Netzwerk-Fetch (`fetchTerrainXyz`) bleibt ungetestet, analog zu
|
||||||
|
// `swissTopo.ts::fetchTerrain`/`geocode` (kein Fetch-Mocking im Repo).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseXyzPoints, buildTerrainGrid } from "./swissAlti3d";
|
||||||
|
|
||||||
|
describe("parseXyzPoints", () => {
|
||||||
|
it("parst 'E N Z'-Zeilen zu einer Punkt-Map + sortierten Achsen", () => {
|
||||||
|
const text = "2600000.00 1200000.00 500.5\n2600002.00 1200000.00 501.0\n2600000.00 1200002.00 502.3\n";
|
||||||
|
const { points, es, ns } = parseXyzPoints(text);
|
||||||
|
expect(points.get("2600000,1200000")).toBe(500.5);
|
||||||
|
expect(points.get("2600002,1200000")).toBe(501);
|
||||||
|
expect(es).toEqual([2600000, 2600002]);
|
||||||
|
expect(ns).toEqual([1200000, 1200002]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("überspringt Kopfzeilen/kaputte Zeilen ohne 3 Zahlen", () => {
|
||||||
|
const text = "E N Z\nnot a number line\n2600000 1200000 500\n\n";
|
||||||
|
const { points } = parseXyzPoints(text);
|
||||||
|
expect(points.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert eine leere Map bei leerem Text", () => {
|
||||||
|
const { points, es, ns } = parseXyzPoints("");
|
||||||
|
expect(points.size).toBe(0);
|
||||||
|
expect(es).toEqual([]);
|
||||||
|
expect(ns).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTerrainGrid", () => {
|
||||||
|
it("baut ein reguläres Grid aus einer einzelnen Kachel, x/y relativ zur Origin", () => {
|
||||||
|
const tile = parseXyzPoints(
|
||||||
|
"2600000 1200000 500\n2600002 1200000 501\n2600000 1200002 502\n2600002 1200002 503\n",
|
||||||
|
);
|
||||||
|
const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [
|
||||||
|
2599990, 1199990, 2600010, 1200010,
|
||||||
|
]);
|
||||||
|
expect(grid).not.toBeNull();
|
||||||
|
expect(grid!.xs).toEqual([0, 2]);
|
||||||
|
expect(grid!.ys).toEqual([0, 2]);
|
||||||
|
expect(grid!.z).toEqual([
|
||||||
|
[500, 501],
|
||||||
|
[502, 503],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clippt Punkte ausserhalb der bbox weg", () => {
|
||||||
|
const tile = parseXyzPoints("2600000 1200000 500\n2600100 1200100 999\n");
|
||||||
|
const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [
|
||||||
|
2599990, 1199990, 2600010, 1200010,
|
||||||
|
]);
|
||||||
|
expect(grid!.xs).toEqual([0]);
|
||||||
|
expect(grid!.ys).toEqual([0]);
|
||||||
|
expect(grid!.z).toEqual([[500]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merged mehrere Kacheln zu einem gemeinsamen Grid", () => {
|
||||||
|
const a = parseXyzPoints("2600000 1200000 500\n2600002 1200000 501\n");
|
||||||
|
const b = parseXyzPoints("2600000 1200002 502\n2600002 1200002 503\n");
|
||||||
|
const grid = buildTerrainGrid([a, b], { e: 2600000, n: 1200000 }, [
|
||||||
|
2599990, 1199990, 2600010, 1200010,
|
||||||
|
]);
|
||||||
|
expect(grid!.xs).toEqual([0, 2]);
|
||||||
|
expect(grid!.ys).toEqual([0, 2]);
|
||||||
|
expect(grid!.z).toEqual([
|
||||||
|
[500, 501],
|
||||||
|
[502, 503],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert null, wenn nach dem Clipping nichts übrig bleibt", () => {
|
||||||
|
const tile = parseXyzPoints("2600100 1200100 999\n");
|
||||||
|
const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [
|
||||||
|
2599990, 1199990, 2600010, 1200010,
|
||||||
|
]);
|
||||||
|
expect(grid).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dezimiert das Raster gleichmässig, wenn es die Zellenzahl-Grenze überschreitet", () => {
|
||||||
|
// 700×700 = 490'000 Zellen > MAX_GRID_CELLS (400'000) -> muss dezimiert werden.
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < 700; i++) {
|
||||||
|
for (let j = 0; j < 700; j++) lines.push(`${2600000 + i} ${1200000 + j} ${500 + i + j}`);
|
||||||
|
}
|
||||||
|
const tile = parseXyzPoints(lines.join("\n"));
|
||||||
|
const grid = buildTerrainGrid([tile], { e: 2600000, n: 1200000 }, [
|
||||||
|
2599000, 1199000, 2601000, 1201000,
|
||||||
|
]);
|
||||||
|
expect(grid).not.toBeNull();
|
||||||
|
expect(grid!.xs.length * grid!.ys.length).toBeLessThanOrEqual(490_000 / 4 + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// swissALTI3D-Import (STAC-API) — echtes Höhenraster (0.5 m oder 2 m native
|
||||||
|
// Punktdichte) statt der groben `profile.json`-Näherung (s.
|
||||||
|
// `swissTopo.ts::fetchTerrain`, ~20 m Rasterweite über wenige Profillinien).
|
||||||
|
// Referenz-Implementierung (Rhino-Vorgänger-Plugin, 1:1 als Blaupause genutzt):
|
||||||
|
// git.kgva.ch/karim/DOSSIER, Datei rhino/swisstopo.py
|
||||||
|
// (`fetch_terrain_xyz`/`xyz_to_grid`).
|
||||||
|
//
|
||||||
|
// swissALTI3D-Kacheln liegen als ASCII-XYZ vor ("E N Z" je Zeile, LV95),
|
||||||
|
// bereits in einem regulären, LV95-ausgerichteten Raster bei der gewählten
|
||||||
|
// Auflösung (Dateiname trägt `_0.5_` bzw. `_2_`) — kein Downsampling nötig,
|
||||||
|
// die Punkte werden direkt in ein `TerrainGrid` (model/terrain.ts) übernommen.
|
||||||
|
// Bei sehr grossem Radius (viele Zellen) wird das Achsenraster gleichmässig
|
||||||
|
// dezimiert (s. {@link MAX_GRID_CELLS}).
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||||
|
|
||||||
|
import { bboxAround, lv95BboxToWgs84, makeOrigin } from "./lv95";
|
||||||
|
import type { GeoOrigin, LV95 } from "./lv95";
|
||||||
|
import { stacQuery, downloadAssetText } from "./stacApi";
|
||||||
|
import type { TerrainGrid } from "../model/terrain";
|
||||||
|
|
||||||
|
const COLLECTION = "ch.swisstopo.swissalti3d";
|
||||||
|
|
||||||
|
/** Native swissALTI3D-Auflösungen (Dateiname-Tag `_0.5_`/`_2_`). */
|
||||||
|
export type TerrainResolution = "0.5" | "2";
|
||||||
|
|
||||||
|
/** Max. Rasterzellen im finalen Grid — schützt vor Speicherexplosion bei
|
||||||
|
* grossem Radius + 0.5-m-Auflösung (z. B. 2000 m Radius ⇒ sonst 8000×8000). */
|
||||||
|
const MAX_GRID_CELLS = 400_000;
|
||||||
|
|
||||||
|
interface XyzTile {
|
||||||
|
points: Map<string, number>;
|
||||||
|
es: number[];
|
||||||
|
ns: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parst rohe swissALTI3D-XYZ-ASCII-Zeilen ("E N Z", LV95) in eine Punkt-Map +
|
||||||
|
* sortierte Achsen. Exportiert (pur, kein Netzwerk) für Unit-Tests. */
|
||||||
|
export function parseXyzPoints(text: string): XyzTile {
|
||||||
|
const points = new Map<string, number>();
|
||||||
|
const esSet = new Set<number>();
|
||||||
|
const nsSet = new Set<number>();
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length < 3) continue;
|
||||||
|
const e = Number(parts[0]);
|
||||||
|
const n = Number(parts[1]);
|
||||||
|
const z = Number(parts[2]);
|
||||||
|
if (!Number.isFinite(e) || !Number.isFinite(n) || !Number.isFinite(z)) continue;
|
||||||
|
// Auf 2 Nachkommastellen runden (swissALTI3D-XYZ hat begrenzte Präzision) —
|
||||||
|
// vermeidet Float-Drift-Duplikate am Kachel-Rand.
|
||||||
|
const eR = Math.round(e * 100) / 100;
|
||||||
|
const nR = Math.round(n * 100) / 100;
|
||||||
|
points.set(`${eR},${nR}`, z);
|
||||||
|
esSet.add(eR);
|
||||||
|
nsSet.add(nR);
|
||||||
|
}
|
||||||
|
return { points, es: [...esSet].sort((a, b) => a - b), ns: [...nsSet].sort((a, b) => a - b) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut ein {@link TerrainGrid} aus mehreren geparsten Kacheln (Punkte werden
|
||||||
|
* vereinigt, auf `clipBbox` beschnitten, Achsen aus der Vereinigung aller
|
||||||
|
* verbleibenden Punkte). Dezimiert die Achsen gleichmässig, falls die
|
||||||
|
* Zellenzahl {@link MAX_GRID_CELLS} überschritten würde. Exportiert (pur,
|
||||||
|
* kein Netzwerk) für Unit-Tests. `null` bei leerem Ergebnis.
|
||||||
|
*/
|
||||||
|
export function buildTerrainGrid(
|
||||||
|
tiles: XyzTile[],
|
||||||
|
origin: GeoOrigin,
|
||||||
|
clipBbox: [number, number, number, number],
|
||||||
|
): TerrainGrid | null {
|
||||||
|
const merged = new Map<string, number>();
|
||||||
|
const esSet = new Set<number>();
|
||||||
|
const nsSet = new Set<number>();
|
||||||
|
const [eMin, nMin, eMax, nMax] = clipBbox;
|
||||||
|
for (const tile of tiles) {
|
||||||
|
for (const [key, z] of tile.points) {
|
||||||
|
const sep = key.indexOf(",");
|
||||||
|
const e = Number(key.slice(0, sep));
|
||||||
|
const n = Number(key.slice(sep + 1));
|
||||||
|
if (e < eMin || e > eMax || n < nMin || n > nMax) continue;
|
||||||
|
merged.set(key, z);
|
||||||
|
esSet.add(e);
|
||||||
|
nsSet.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let es = [...esSet].sort((a, b) => a - b);
|
||||||
|
let ns = [...nsSet].sort((a, b) => a - b);
|
||||||
|
if (es.length === 0 || ns.length === 0) return null;
|
||||||
|
|
||||||
|
const totalCells = es.length * ns.length;
|
||||||
|
if (totalCells > MAX_GRID_CELLS) {
|
||||||
|
const factor = Math.ceil(Math.sqrt(totalCells / MAX_GRID_CELLS));
|
||||||
|
es = es.filter((_, i) => i % factor === 0);
|
||||||
|
ns = ns.filter((_, i) => i % factor === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const z: (number | null)[][] = ns.map((n) =>
|
||||||
|
es.map((e) => merged.get(`${e},${n}`) ?? null),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
xs: es.map((e) => e - origin.e),
|
||||||
|
ys: ns.map((n) => n - origin.n),
|
||||||
|
z,
|
||||||
|
name: "Gelände (swissALTI3D)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchTerrainXyzResult {
|
||||||
|
origin: GeoOrigin;
|
||||||
|
grid: TerrainGrid | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echtes swissALTI3D-Höhenraster (0.5 m oder 2 m native Punktdichte, je nach
|
||||||
|
* `resolution`) für eine LV95-bbox — ersetzt die grobe `profile.json`-
|
||||||
|
* Näherung (`swissTopo.ts::fetchTerrain`, ~20 m Rasterweite). Mehrere
|
||||||
|
* überdeckte 1-km-Kacheln werden zu einem gemeinsamen Grid vereinigt.
|
||||||
|
*/
|
||||||
|
export async function fetchTerrainXyz(
|
||||||
|
center: LV95,
|
||||||
|
radius: number,
|
||||||
|
originIn?: GeoOrigin,
|
||||||
|
resolution: TerrainResolution = "2",
|
||||||
|
): Promise<FetchTerrainXyzResult> {
|
||||||
|
const origin = originIn ?? makeOrigin(center);
|
||||||
|
const bboxLv95 = bboxAround(center, radius);
|
||||||
|
const bboxWgs = lv95BboxToWgs84(bboxLv95);
|
||||||
|
|
||||||
|
const items = await stacQuery(COLLECTION, bboxWgs, [".xyz", ".xyz.zip"]);
|
||||||
|
const tag = `_${resolution}_`;
|
||||||
|
const tiles: XyzTile[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
// Asset mit passendem Auflösungs-Tag bevorzugen, sonst irgendeines nehmen
|
||||||
|
// (bessere Teilabdeckung als gar nichts).
|
||||||
|
const asset = item.assets.find((a) => a.href.includes(tag)) ?? item.assets[0];
|
||||||
|
if (!asset) continue;
|
||||||
|
const text = await downloadAssetText(asset, [".xyz"]);
|
||||||
|
if (!text) continue;
|
||||||
|
tiles.push(parseXyzPoints(text));
|
||||||
|
}
|
||||||
|
if (tiles.length === 0) return { origin, grid: null };
|
||||||
|
return { origin, grid: buildTerrainGrid(tiles, origin, bboxLv95) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Tests für die reinen (netzwerkfreien) Bausteine des swissBUILDINGS3D-STAC-
|
||||||
|
// Imports: Koordinaten-Verschiebung der geparsten Meshes. Asset-Auswahl wird
|
||||||
|
// generisch in `stacApi.test.ts` getestet. Der eigentliche Netzwerk-Fetch
|
||||||
|
// (`fetchBuildings3d`) bleibt ungetestet, analog zu
|
||||||
|
// `swissTopo.ts::fetchBuildings`/`geocode` (kein Fetch-Mocking im Repo).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { shiftMeshToOrigin, clipMeshToBbox } from "./swissBuildings3d";
|
||||||
|
import type { ImportedMesh } from "../model/types";
|
||||||
|
|
||||||
|
describe("shiftMeshToOrigin", () => {
|
||||||
|
it("verschiebt x/y um die Origin, lässt z unverändert", () => {
|
||||||
|
const mesh: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "roh",
|
||||||
|
positions: [2600100, 1200050, 3.5, 2600110, 1200060, 7.2],
|
||||||
|
indices: [0, 1, 0],
|
||||||
|
};
|
||||||
|
const shifted = shiftMeshToOrigin(mesh, { e: 2600000, n: 1200000 }, "tile-42");
|
||||||
|
expect(shifted.positions).toEqual([100, 50, 3.5, 110, 60, 7.2]);
|
||||||
|
expect(shifted.indices).toBe(mesh.indices);
|
||||||
|
expect(shifted.name).toContain("tile-42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mutiert das Eingabe-Mesh nicht", () => {
|
||||||
|
const mesh: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "roh",
|
||||||
|
positions: [2600100, 1200050, 0],
|
||||||
|
indices: [],
|
||||||
|
};
|
||||||
|
const original = mesh.positions.slice();
|
||||||
|
shiftMeshToOrigin(mesh, { e: 2600000, n: 1200000 }, "x");
|
||||||
|
expect(mesh.positions).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipMeshToBbox", () => {
|
||||||
|
// Zwei Dreiecke: #0 ganz innerhalb der Box, #1 ganz ausserhalb (weit weg,
|
||||||
|
// simuliert ein Nachbargebäude aus derselben STAC-Kachel ausserhalb des
|
||||||
|
// gesuchten Radius).
|
||||||
|
const mesh: ImportedMesh = {
|
||||||
|
id: "m1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "roh",
|
||||||
|
positions: [
|
||||||
|
0, 0, 0, 1, 0, 0, 0, 1, 0, // Dreieck 0: innerhalb [-5,5]
|
||||||
|
1000, 1000, 0, 1001, 1000, 0, 1000, 1001, 0, // Dreieck 1: weit ausserhalb
|
||||||
|
],
|
||||||
|
indices: [0, 1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("behält nur Dreiecke mit mindestens einem Eckpunkt in der Box", () => {
|
||||||
|
const clipped = clipMeshToBbox(mesh, [-5, -5, 5, 5]);
|
||||||
|
expect(clipped.indices).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leere Box -> leere Indizes (kein Absturz)", () => {
|
||||||
|
const clipped = clipMeshToBbox(mesh, [500, 500, 500, 500]);
|
||||||
|
expect(clipped.indices).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Box umfasst alles -> alle Dreiecke bleiben", () => {
|
||||||
|
const clipped = clipMeshToBbox(mesh, [-2000, -2000, 2000, 2000]);
|
||||||
|
expect(clipped.indices).toEqual(mesh.indices);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ein Dreieck, das die Box-Kante überragt, bleibt VOLLSTÄNDIG erhalten (kein Mittendurch-Schnitt)", () => {
|
||||||
|
const straddling: ImportedMesh = {
|
||||||
|
id: "m2",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "roh",
|
||||||
|
positions: [4, 0, 0, 10, 0, 0, 4, 10, 0], // ein Eckpunkt (4,0) in [-5,5], zwei ausserhalb
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
};
|
||||||
|
const clipped = clipMeshToBbox(straddling, [-5, -5, 5, 5]);
|
||||||
|
expect(clipped.indices).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// swissBUILDINGS3D-Import (STAC-API) — echte Gebäude-3D-Geometrie (Wände +
|
||||||
|
// Dachformen) statt der pauschal auf DEFAULT_BUILDING_HEIGHT extrudierten
|
||||||
|
// VEC25-Footprints (s. `swissTopo.ts::fetchBuildings`). Zwei Datensatz-
|
||||||
|
// Generationen, beide über dieselbe offene STAC-API (`stacApi.ts`):
|
||||||
|
// • v2 ("ch.swisstopo.swissbuildings3d_2") — stabil, 1-km-Tiles, ~50 MB.
|
||||||
|
// • v3 ("ch.swisstopo.swissbuildings3d_3_0") — Beta, Solid/Separated-
|
||||||
|
// Varianten, in Städten teils >150 MB/Tile (nicht 1-km-strukturiert) →
|
||||||
|
// wir fallen bei leeren/zu grossen v3-Treffern automatisch auf v2 zurück.
|
||||||
|
// Referenz-Implementierung (Rhino-Vorgänger-Plugin, 1:1 als Blaupause genutzt):
|
||||||
|
// git.kgva.ch/karim/DOSSIER, Datei rhino/swisstopo.py.
|
||||||
|
//
|
||||||
|
// Assets liegen als DXF/DWG/OBJ/IFC vor (teils `.zip`) — wir wählen bevorzugt
|
||||||
|
// DXF: `parseDxf` (dxfParser.ts) liest 3DFACE/MESH/Polyface-Entities bereits
|
||||||
|
// verlustfrei zu `ImportedMesh` (positions/indices, echtes Z) — kein neuer
|
||||||
|
// Parser nötig.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||||
|
|
||||||
|
import { bboxAround, lv95BboxToWgs84, makeOrigin } from "./lv95";
|
||||||
|
import type { GeoOrigin, LV95 } from "./lv95";
|
||||||
|
import { stacQuery, pickAsset, downloadAssetText } from "./stacApi";
|
||||||
|
import { parseDxf } from "./dxfParser";
|
||||||
|
import type { ImportedMesh } from "../model/types";
|
||||||
|
|
||||||
|
const COLLECTION_V2 = "ch.swisstopo.swissbuildings3d_2";
|
||||||
|
const COLLECTION_V3 = "ch.swisstopo.swissbuildings3d_3_0";
|
||||||
|
|
||||||
|
/** Bevorzugte Asset-Endungen, in Prioritätsreihenfolge (DXF am stabilsten,
|
||||||
|
* da `parseDxf` ihn direkt zu `ImportedMesh` verarbeitet). */
|
||||||
|
const ASSET_PRIORITY = [
|
||||||
|
".dxf",
|
||||||
|
".dwg",
|
||||||
|
".obj",
|
||||||
|
".ifc",
|
||||||
|
".dxf.zip",
|
||||||
|
".dwg.zip",
|
||||||
|
".obj.zip",
|
||||||
|
".ifc.zip",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type BuildingsVersion = "v2" | "v3";
|
||||||
|
|
||||||
|
/** Verschiebt ein DXF-geparstes Mesh (rohe LV95-Koordinaten) um die Origin in
|
||||||
|
* lokale Modell-Meter — dieselbe Konvention wie `swissTopo.ts::fetchBuildings`.
|
||||||
|
* Exportiert (pur, kein Netzwerk) für Unit-Tests der Koordinaten-Verschiebung. */
|
||||||
|
export function shiftMeshToOrigin(
|
||||||
|
mesh: ImportedMesh,
|
||||||
|
origin: GeoOrigin,
|
||||||
|
sourceId: string,
|
||||||
|
): ImportedMesh {
|
||||||
|
const positions = mesh.positions.slice();
|
||||||
|
for (let i = 0; i < positions.length; i += 3) {
|
||||||
|
positions[i] -= origin.e;
|
||||||
|
positions[i + 1] -= origin.n;
|
||||||
|
}
|
||||||
|
return { ...mesh, positions, name: `swissBUILDINGS3D ${sourceId}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet ein Mesh auf eine (bereits origin-verschobene, lokale) Bounding-Box
|
||||||
|
* zurecht: ein Dreieck bleibt, sobald MINDESTENS EIN Eckpunkt innerhalb der Box
|
||||||
|
* liegt (grosszügig — schneidet Gebäude am Rand nicht mittendurch ab). Nötig,
|
||||||
|
* weil eine einzelne swissBUILDINGS3D-DXF-Kachel (STAC-Tile) alle Gebäude der
|
||||||
|
* GANZEN Kachel als EIN gemeinsames Mesh liefert (s. `dxfParser.ts::parseDxf`
|
||||||
|
* — alle 3DFACE/Polyface-Entities eines Files landen im selben Positions-/
|
||||||
|
* Indexpuffer) — ohne diesen Zuschnitt liefert ein einzelner Import ALLES in
|
||||||
|
* der Kachel, auch weit ausserhalb des gesuchten Radius (bis zu mehreren km,
|
||||||
|
* je nach Kachelgrösse). `positions` wird dabei KOMPAKTIERT (nicht referenzierte
|
||||||
|
* Ecken entfernt, Indizes neu gemappt) — sonst sähe jeder Verbraucher, der roh
|
||||||
|
* über `positions` statt über `indices` iteriert (z. B. eine Bounding-Box/
|
||||||
|
* Kamera-Fit-Berechnung), weiterhin die volle, ungeschnittene Ausdehnung. Rein
|
||||||
|
* (kein Netzwerk), exportiert für Unit-Tests.
|
||||||
|
*/
|
||||||
|
export function clipMeshToBbox(
|
||||||
|
mesh: ImportedMesh,
|
||||||
|
bboxLocal: [number, number, number, number],
|
||||||
|
): ImportedMesh {
|
||||||
|
const [xMin, yMin, xMax, yMax] = bboxLocal;
|
||||||
|
const pos = mesh.positions;
|
||||||
|
const inBox = (idx: number): boolean => {
|
||||||
|
const x = pos[idx * 3];
|
||||||
|
const y = pos[idx * 3 + 1];
|
||||||
|
return x >= xMin && x <= xMax && y >= yMin && y <= yMax;
|
||||||
|
};
|
||||||
|
const keptTris: [number, number, number][] = [];
|
||||||
|
for (let i = 0; i + 2 < mesh.indices.length; i += 3) {
|
||||||
|
const a = mesh.indices[i];
|
||||||
|
const b = mesh.indices[i + 1];
|
||||||
|
const c = mesh.indices[i + 2];
|
||||||
|
if (inBox(a) || inBox(b) || inBox(c)) keptTris.push([a, b, c]);
|
||||||
|
}
|
||||||
|
const remap = new Map<number, number>();
|
||||||
|
const positions: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
const mapVertex = (old: number): number => {
|
||||||
|
let ni = remap.get(old);
|
||||||
|
if (ni === undefined) {
|
||||||
|
ni = positions.length / 3;
|
||||||
|
positions.push(pos[old * 3], pos[old * 3 + 1], pos[old * 3 + 2]);
|
||||||
|
remap.set(old, ni);
|
||||||
|
}
|
||||||
|
return ni;
|
||||||
|
};
|
||||||
|
for (const [a, b, c] of keptTris) indices.push(mapVertex(a), mapVertex(b), mapVertex(c));
|
||||||
|
return { ...mesh, positions, indices };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchBuildings3dResult {
|
||||||
|
origin: GeoOrigin;
|
||||||
|
meshes: ImportedMesh[];
|
||||||
|
/** Tatsächlich gelieferte Version (nach evtl. v3→v2-Fallback). */
|
||||||
|
versionUsed: BuildingsVersion;
|
||||||
|
/**
|
||||||
|
* Anzahl STAC-Kacheln, die NICHT geladen werden konnten (zu gross — DXF
|
||||||
|
* komprimiert stark, eine Kachel unter {@link MAX_ASSET_BYTES} kann
|
||||||
|
* entpackt trotzdem die max. Textlänge sprengen —, Netzwerkfehler oder
|
||||||
|
* fehlender Inhalt). Dient der UI, „0 Gebäude" von „X Kacheln übersprungen,
|
||||||
|
* zu gross" zu unterscheiden statt eines stummen Leer-Ergebnisses.
|
||||||
|
*/
|
||||||
|
skippedTiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Echte swissBUILDINGS3D-Gebäude (Wände + Dachformen, keine Box-Extrusion) für
|
||||||
|
* eine LV95-bbox. `version="v3"` versucht zuerst die Beta-Generation und fällt
|
||||||
|
* bei leeren/unbrauchbaren Treffern automatisch auf v2 zurück (analog zum
|
||||||
|
* Rhino-Referenz-Plugin — v3-Tiles können in Städten riesig sein).
|
||||||
|
*
|
||||||
|
* `originIn` fehlt ⇒ neue Origin aus `center` (wie `swissTopo.ts::fetchBuildings`) —
|
||||||
|
* so teilen sich mehrere Importquellen eines Laufs dieselbe Origin, wenn der
|
||||||
|
* Aufrufer die erste zurückgelieferte `origin` an die nächste Quelle reicht.
|
||||||
|
*/
|
||||||
|
export async function fetchBuildings3d(
|
||||||
|
center: LV95,
|
||||||
|
radius: number,
|
||||||
|
originIn?: GeoOrigin,
|
||||||
|
version: BuildingsVersion = "v2",
|
||||||
|
): Promise<FetchBuildings3dResult> {
|
||||||
|
const origin = originIn ?? makeOrigin(center);
|
||||||
|
const bboxLv95 = bboxAround(center, radius);
|
||||||
|
const bboxWgs = lv95BboxToWgs84(bboxLv95);
|
||||||
|
// Lokale (origin-verschobene) Zuschnitt-Box — s. clipMeshToBbox: eine STAC-
|
||||||
|
// Kachel liefert ALLE Gebäude der Kachel als EIN Mesh, nicht nur die im
|
||||||
|
// gesuchten Radius (kann mehrere km ausserhalb des Suchpunkts umfassen).
|
||||||
|
const clipBoxLocal: [number, number, number, number] = [
|
||||||
|
bboxLv95[0] - origin.e,
|
||||||
|
bboxLv95[1] - origin.n,
|
||||||
|
bboxLv95[2] - origin.e,
|
||||||
|
bboxLv95[3] - origin.n,
|
||||||
|
];
|
||||||
|
|
||||||
|
const tryCollection = async (
|
||||||
|
collectionId: string,
|
||||||
|
): Promise<{ meshes: ImportedMesh[]; skipped: number }> => {
|
||||||
|
const items = await stacQuery(collectionId, bboxWgs, ASSET_PRIORITY);
|
||||||
|
const meshes: ImportedMesh[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const asset = pickAsset(item, ASSET_PRIORITY);
|
||||||
|
if (!asset) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = await downloadAssetText(asset, [".dxf"]);
|
||||||
|
if (!text) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = parseDxf(text);
|
||||||
|
for (const m of parsed.meshes) {
|
||||||
|
const clipped = clipMeshToBbox(shiftMeshToOrigin(m, origin, item.id), clipBoxLocal);
|
||||||
|
if (clipped.indices.length > 0) meshes.push(clipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { meshes, skipped };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (version === "v3") {
|
||||||
|
const v3 = await tryCollection(COLLECTION_V3);
|
||||||
|
if (v3.meshes.length > 0) {
|
||||||
|
return { origin, meshes: v3.meshes, versionUsed: "v3", skippedTiles: v3.skipped };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const v2 = await tryCollection(COLLECTION_V2);
|
||||||
|
return { origin, meshes: v2.meshes, versionUsed: "v2", skippedTiles: v2.skipped };
|
||||||
|
}
|
||||||
+14
-108
@@ -3,13 +3,17 @@
|
|||||||
// • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten.
|
// • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten.
|
||||||
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
|
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
|
||||||
// daher Direktabruf; Proxy nur als Fallback).
|
// daher Direktabruf; Proxy nur als Fallback).
|
||||||
// • fetchBuildings() — Gebäude-GRUNDRISSE für eine LV95-Box → Ringe in
|
// • fetchBuildings() — Gebäude-GRUNDRISSE (vereinfachter 1:25'000-Kartografie-
|
||||||
|
// Layer, KEINE Höhe) für eine LV95-Box → Ringe in
|
||||||
// lokalen Modell-Metern. Nutzt den geo.admin
|
// lokalen Modell-Metern. Nutzt den geo.admin
|
||||||
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`
|
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`.
|
||||||
// (liefert Polygon-`rings` in LV95, aus dem Browser
|
// Schneller "Vorschau"-Pfad; für echte Gebäudegeometrie
|
||||||
// abrufbar). swissBUILDINGS3D-Kachel-Downloads (STAC)
|
// (Wände+Dach, reale Höhe) s. `swissBuildings3d.ts`
|
||||||
// sind für einen Live-Box-Query im Browser zu schwer und
|
// (STAC-API, entgegen einer früheren Annahme hier DOCH
|
||||||
// werden bewusst NICHT verwendet.
|
// gut browser-tauglich — s. Recherche in PENDENZEN.md).
|
||||||
|
//
|
||||||
|
// Echtes Höhenraster (0.5 m/2 m): s. `swissAlti3d.ts::fetchTerrainXyz`
|
||||||
|
// (ersetzt die frühere `fetchTerrain`-profile.json-Näherung in dieser Datei).
|
||||||
//
|
//
|
||||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||||
|
|
||||||
@@ -17,7 +21,6 @@ import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95";
|
|||||||
import type { GeoOrigin, LV95 } from "./lv95";
|
import type { GeoOrigin, LV95 } from "./lv95";
|
||||||
import type { GeoFeature } from "./geoContext";
|
import type { GeoFeature } from "./geoContext";
|
||||||
import { viaProxy } from "./geoContext";
|
import { viaProxy } from "./geoContext";
|
||||||
import type { TerrainGrid } from "../model/terrain";
|
|
||||||
|
|
||||||
const GEOADMIN = "https://api3.geo.admin.ch";
|
const GEOADMIN = "https://api3.geo.admin.ch";
|
||||||
|
|
||||||
@@ -136,104 +139,7 @@ export async function fetchBuildings(
|
|||||||
return { origin, features };
|
return { origin, features };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Punkt aus dem geo.admin-Höhenprofil (profile.json, sr=2056). */
|
// Das frühere `fetchTerrain` (grobe `profile.json`-Näherung, ~20 m Raster-
|
||||||
interface ProfilePoint {
|
// weite über wenige Profillinien) ist durch `swissAlti3d.ts::fetchTerrainXyz`
|
||||||
dist: number;
|
// ersetzt (echtes swissALTI3D-Raster bei 0.5 m/2 m nativer Punktdichte, STAC-
|
||||||
easting: number;
|
// API, s. Referenz git.kgva.ch/karim/DOSSIER rhino/swisstopo.py).
|
||||||
northing: number;
|
|
||||||
alts?: { DTM2?: number; DTM25?: number; COMB?: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gelände-Höhenraster (swissALTI3D-DTM) für Zentrum + Radius laden.
|
|
||||||
*
|
|
||||||
* Statt schwerer swissALTI3D-Kachel-Downloads (STAC) nutzt der Port den
|
|
||||||
* CORS-fähigen geo.admin `profile.json`-Dienst: pro Rasterzeile EIN Request
|
|
||||||
* liefert die Höhen entlang einer Ost-West-Linie (nb_points Stützstellen). So
|
|
||||||
* entsteht ein reguläres N×N-Höhenraster mit wenigen Requests, direkt aus dem
|
|
||||||
* Browser.
|
|
||||||
*
|
|
||||||
* Die Höhen werden auf den Wert am Zentrum bezogen (z=0 im Zentrum), damit das
|
|
||||||
* Gelände beim Modell-Ursprung liegt und lagerichtig zu den Gebäuden (Basis
|
|
||||||
* z=0) sitzt. Rückgabe: das Raster in lokalen Metern + die verwendete Origin.
|
|
||||||
*/
|
|
||||||
export async function fetchTerrain(
|
|
||||||
center: LV95,
|
|
||||||
radius: number,
|
|
||||||
originIn?: GeoOrigin,
|
|
||||||
): Promise<{ origin: GeoOrigin; grid: TerrainGrid | null }> {
|
|
||||||
const origin = originIn ?? makeOrigin(center);
|
|
||||||
const [eMin, nMin, eMax] = bboxAround(center, radius);
|
|
||||||
const span = 2 * radius;
|
|
||||||
// Ziel-Rasterweite ~20 m, aber N in [6, 24] begrenzen (Request-Zahl zähmen).
|
|
||||||
const n = Math.max(6, Math.min(24, Math.round(span / 20) + 1));
|
|
||||||
|
|
||||||
// Achsen (LV95) + lokale Achsen.
|
|
||||||
const eAxis: number[] = [];
|
|
||||||
const nAxis: number[] = [];
|
|
||||||
const xs: number[] = [];
|
|
||||||
const ys: number[] = [];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const e = eMin + (span * i) / (n - 1);
|
|
||||||
const nn = nMin + (span * i) / (n - 1);
|
|
||||||
eAxis.push(e);
|
|
||||||
nAxis.push(nn);
|
|
||||||
xs.push(e - origin.e);
|
|
||||||
ys.push(nn - origin.n);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine Rasterzeile (konstantes N) via profile.json abrufen → Höhen je Spalte.
|
|
||||||
const fetchRow = async (north: number): Promise<(number | null)[]> => {
|
|
||||||
const geom = JSON.stringify({
|
|
||||||
type: "LineString",
|
|
||||||
coordinates: [
|
|
||||||
[eMin, north],
|
|
||||||
[eMax, north],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const url =
|
|
||||||
`${GEOADMIN}/rest/services/profile.json` +
|
|
||||||
`?geom=${encodeURIComponent(geom)}` +
|
|
||||||
`&sr=2056&nb_points=${n}&distinct_points=true&offset=0`;
|
|
||||||
try {
|
|
||||||
const res = await fetch(viaProxy(url));
|
|
||||||
if (!res.ok) return new Array(n).fill(null);
|
|
||||||
const pts = (await res.json()) as ProfilePoint[];
|
|
||||||
const row: (number | null)[] = new Array(n).fill(null);
|
|
||||||
// Die Punkte kommen nach Distanz sortiert (West→Ost) — direkt Spalte i.
|
|
||||||
pts.forEach((p, i) => {
|
|
||||||
if (i >= n) return;
|
|
||||||
const z = p.alts?.DTM2 ?? p.alts?.COMB ?? p.alts?.DTM25;
|
|
||||||
row[i] = Number.isFinite(z as number) ? (z as number) : null;
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
} catch {
|
|
||||||
return new Array(n).fill(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Alle Zeilen parallel (n ≤ 24 Requests).
|
|
||||||
const rows = await Promise.all(nAxis.map((north) => fetchRow(north)));
|
|
||||||
|
|
||||||
// Höhen-Nullpunkt: Wert am Rasterzentrum (fällt der aus, Mittel aller Werte).
|
|
||||||
const mid = Math.floor(n / 2);
|
|
||||||
let zRef = rows[mid]?.[mid] ?? null;
|
|
||||||
if (zRef == null || !Number.isFinite(zRef)) {
|
|
||||||
let sum = 0;
|
|
||||||
let cnt = 0;
|
|
||||||
for (const r of rows)
|
|
||||||
for (const v of r)
|
|
||||||
if (v != null && Number.isFinite(v)) {
|
|
||||||
sum += v;
|
|
||||||
cnt++;
|
|
||||||
}
|
|
||||||
zRef = cnt > 0 ? sum / cnt : null;
|
|
||||||
}
|
|
||||||
if (zRef == null) return { origin, grid: null };
|
|
||||||
|
|
||||||
// Höhen relativ zum Zentrum (z=0 im Zentrum).
|
|
||||||
const z: (number | null)[][] = rows.map((r) =>
|
|
||||||
r.map((v) => (v == null ? null : v - (zRef as number))),
|
|
||||||
);
|
|
||||||
return { origin, grid: { xs, ys, z, name: "Gelände (swisstopo)" } };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Column, ExtrudedSolid, Project } from "./types";
|
||||||
|
import { sampleProject } from "./sampleProject";
|
||||||
|
import { translateProject } from "./geoRebase";
|
||||||
|
|
||||||
|
const DX = 100;
|
||||||
|
const DY = -50;
|
||||||
|
|
||||||
|
describe("translateProject", () => {
|
||||||
|
it("dx=dy=0 -> unverändertes Projekt (Referenzgleichheit)", () => {
|
||||||
|
expect(translateProject(sampleProject, 0, 0)).toBe(sampleProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschiebt Wand-Achsen (start/end)", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
const w = sampleProject.walls[0];
|
||||||
|
const ow = out.walls.find((x) => x.id === w.id)!;
|
||||||
|
expect(ow.start).toEqual({ x: w.start.x + DX, y: w.start.y + DY });
|
||||||
|
expect(ow.end).toEqual({ x: w.end.x + DX, y: w.end.y + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschiebt Decken-Umriss inkl. Aussparungen", () => {
|
||||||
|
const withHole: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
ceilings: [
|
||||||
|
{
|
||||||
|
...sampleProject.ceilings![0],
|
||||||
|
openings: [
|
||||||
|
[{ x: 1, y: 1 }, { x: 2, y: 1 }, { x: 2, y: 2 }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withHole, DX, DY);
|
||||||
|
const oc = out.ceilings![0];
|
||||||
|
const src = withHole.ceilings![0];
|
||||||
|
expect(oc.outline).toEqual(src.outline.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
expect(oc.openings![0]).toEqual(src.openings![0].map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verschiebt Dach-Umriss", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
const r = sampleProject.roofs![0];
|
||||||
|
const or_ = out.roofs!.find((x) => x.id === r.id)!;
|
||||||
|
expect(or_.outline).toEqual(r.outline.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Treppe: start/center verschieben sich, dir (Richtungsvektor) bleibt unverändert", () => {
|
||||||
|
const withSpiral: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
stairs: [...sampleProject.stairs!, { ...sampleProject.stairs![0], id: "ST2", center: { x: 5, y: 5 } }],
|
||||||
|
};
|
||||||
|
const out = translateProject(withSpiral, DX, DY);
|
||||||
|
const st1 = out.stairs!.find((s) => s.id === "ST1")!;
|
||||||
|
const st2 = out.stairs!.find((s) => s.id === "ST2")!;
|
||||||
|
expect(st1.start).toEqual({ x: sampleProject.stairs![0].start.x + DX, y: sampleProject.stairs![0].start.y + DY });
|
||||||
|
expect(st1.dir).toEqual(sampleProject.stairs![0].dir); // Richtung bleibt
|
||||||
|
expect(st2.center).toEqual({ x: 5 + DX, y: 5 + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Raum: boundary + stampAnchor verschieben sich", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
const r = sampleProject.rooms![0];
|
||||||
|
const or_ = out.rooms!.find((x) => x.id === r.id)!;
|
||||||
|
expect(or_.boundary).toEqual(r.boundary.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
expect(or_.stampAnchor).toEqual({ x: r.stampAnchor!.x + DX, y: r.stampAnchor!.y + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Stütze: position verschiebt sich, rotation bleibt unverändert", () => {
|
||||||
|
const col: Column = {
|
||||||
|
id: "C1",
|
||||||
|
type: "column",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "50",
|
||||||
|
position: { x: 2, y: 3 },
|
||||||
|
profile: { kind: "round", radius: 0.15 },
|
||||||
|
rotation: Math.PI / 4,
|
||||||
|
height: 2.6,
|
||||||
|
};
|
||||||
|
const withColumn: Project = { ...sampleProject, columns: [col] };
|
||||||
|
const out = translateProject(withColumn, DX, DY);
|
||||||
|
const oc = out.columns![0];
|
||||||
|
expect(oc.position).toEqual({ x: 2 + DX, y: 3 + DY });
|
||||||
|
expect(oc.rotation).toBe(col.rotation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Extrusion: points + Kreis-Mitte verschieben sich", () => {
|
||||||
|
const solid: ExtrudedSolid = {
|
||||||
|
id: "EX1",
|
||||||
|
type: "extrudedSolid",
|
||||||
|
levelId: "eg",
|
||||||
|
points: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }],
|
||||||
|
height: 1,
|
||||||
|
circle: { center: { x: 5, y: 5 }, r: 1 },
|
||||||
|
};
|
||||||
|
const withSolid: Project = { ...sampleProject, extrudedSolids: [solid] };
|
||||||
|
const out = translateProject(withSolid, DX, DY);
|
||||||
|
const os = out.extrudedSolids![0];
|
||||||
|
expect(os.points).toEqual(solid.points.map((p) => ({ x: p.x + DX, y: p.y + DY })));
|
||||||
|
expect(os.circle!.center).toEqual({ x: 5 + DX, y: 5 + DY });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Drawing2D: alle Geometrie-Varianten verschieben ihre Punkte", () => {
|
||||||
|
const withDrawings: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
drawings2d: [
|
||||||
|
{ id: "D1", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "line", a: { x: 0, y: 0 }, b: { x: 1, y: 1 } } },
|
||||||
|
{ id: "D2", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "polyline", pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }], closed: false } },
|
||||||
|
{ id: "D3", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "rect", min: { x: 0, y: 0 }, max: { x: 1, y: 1 } } },
|
||||||
|
{ id: "D4", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "circle", center: { x: 1, y: 1 }, r: 0.5 } },
|
||||||
|
{ id: "D5", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "arc", center: { x: 2, y: 2 }, r: 0.5, a0: 0, a1: 1 } },
|
||||||
|
{ id: "D6", type: "drawing2d", levelId: "eg", categoryCode: "10", geom: { shape: "text", at: { x: 3, y: 3 }, text: "x", height: 0.2, angle: 0.7 } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withDrawings, DX, DY);
|
||||||
|
const byId = (id: string) => out.drawings2d.find((d) => d.id === id)!.geom;
|
||||||
|
expect(byId("D1")).toMatchObject({ a: { x: DX, y: DY }, b: { x: 1 + DX, y: 1 + DY } });
|
||||||
|
expect(byId("D2")).toMatchObject({ pts: [{ x: DX, y: DY }, { x: 1 + DX, y: DY }] });
|
||||||
|
expect(byId("D3")).toMatchObject({ min: { x: DX, y: DY }, max: { x: 1 + DX, y: 1 + DY } });
|
||||||
|
expect(byId("D4")).toMatchObject({ center: { x: 1 + DX, y: 1 + DY } });
|
||||||
|
expect(byId("D5")).toMatchObject({ center: { x: 2 + DX, y: 2 + DY }, a0: 0, a1: 1 }); // Winkel unverändert
|
||||||
|
expect(byId("D6")).toMatchObject({ at: { x: 3 + DX, y: 3 + DY }, angle: 0.7 }); // Winkel unverändert
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kontext-Mesh: nur x/y verschieben sich, Höhe (z) bleibt", () => {
|
||||||
|
const withContext: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CTX1",
|
||||||
|
type: "importedMesh",
|
||||||
|
name: "Test",
|
||||||
|
positions: [0, 0, 5, 2, 3, 7],
|
||||||
|
indices: [0, 1, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withContext, DX, DY);
|
||||||
|
const obj = out.context![0];
|
||||||
|
if (obj.type !== "importedMesh") throw new Error("expected importedMesh");
|
||||||
|
expect(obj.positions).toEqual([0 + DX, 0 + DY, 5, 2 + DX, 3 + DY, 7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Konturen-Satz: Punkte + Kreis-/Bogen-Mitte verschieben sich, Winkel bleiben", () => {
|
||||||
|
const withContours: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
id: "CS1",
|
||||||
|
type: "contourSet",
|
||||||
|
name: "Höhenlinien",
|
||||||
|
contours: [
|
||||||
|
{
|
||||||
|
z: 400,
|
||||||
|
pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }],
|
||||||
|
closed: false,
|
||||||
|
curve: { kind: "arc", cx: 1, cy: 1, r: 0.5, a0: 0, a1: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withContours, DX, DY);
|
||||||
|
const obj = out.context![0];
|
||||||
|
if (obj.type !== "contourSet") throw new Error("expected contourSet");
|
||||||
|
expect(obj.contours[0].pts).toEqual([{ x: DX, y: DY }, { x: 1 + DX, y: DY }]);
|
||||||
|
expect(obj.contours[0].curve).toEqual({ kind: "arc", cx: 1 + DX, cy: 1 + DY, r: 0.5, a0: 0, a1: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Schnittlinie (DrawingLevel.linePoints) verschiebt sich", () => {
|
||||||
|
const withSection: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
drawingLevels: [
|
||||||
|
...sampleProject.drawingLevels,
|
||||||
|
{
|
||||||
|
id: "SEC1",
|
||||||
|
name: "Schnitt A",
|
||||||
|
kind: "section",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: 0, y: 0 }, { x: 5, y: 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = translateProject(withSection, DX, DY);
|
||||||
|
const lvl = out.drawingLevels.find((l) => l.id === "SEC1")!;
|
||||||
|
expect(lvl.linePoints).toEqual([{ x: DX, y: DY }, { x: 5 + DX, y: DY }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geoAnchor.model verschiebt sich mit, lv95 bleibt unverändert (Bezug bleibt exakt erhalten)", () => {
|
||||||
|
const withAnchor: Project = {
|
||||||
|
...sampleProject,
|
||||||
|
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 }, label: "Test" },
|
||||||
|
};
|
||||||
|
const out = translateProject(withAnchor, DX, DY);
|
||||||
|
expect(out.geoAnchor).toEqual({ lv95: { e: 2600000, n: 1200000 }, model: { x: DX, y: DY }, label: "Test" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kein geoAnchor gesetzt -> bleibt undefined", () => {
|
||||||
|
const out = translateProject(sampleProject, DX, DY);
|
||||||
|
expect(out.geoAnchor).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Ursprungs-Verschiebung des GESAMTEN Projekts (Neuer Bezugspunkt) — reine,
|
||||||
|
// verlustfreie Translation aller Modell-Koordinaten um einen festen Vektor
|
||||||
|
// (dx,dy). Genutzt, um nach einem Kataster-/3D-Import (der weit weg vom
|
||||||
|
// Modell-Ursprung landen kann, s. `Project.geoAnchor`) einen neuen, praktischen
|
||||||
|
// Bezugspunkt zu setzen: die komplette Zeichnung wandert relativ zueinander
|
||||||
|
// unverändert, nur ihre absoluten Koordinaten verschieben sich. `geoAnchor`
|
||||||
|
// (falls gesetzt) wandert MIT, sodass der LV95-Bezug exakt erhalten bleibt —
|
||||||
|
// beim Export ist die Verschiebung damit jederzeit rekonstruierbar.
|
||||||
|
//
|
||||||
|
// Bewusst NUR Translation (keine Rotation): mehrere Felder im Modell sind an
|
||||||
|
// die Achsen gebunden (z. B. `Roof.ridgeAxis: "x"|"y"`) und mehrere Winkel-Felder
|
||||||
|
// (Column.rotation, Text-/Bild-Rotation, Tür-Schwenk) müssten bei einer Drehung
|
||||||
|
// mitgeführt werden — eine Rotation ist ein eigener, grösserer Schritt.
|
||||||
|
//
|
||||||
|
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ContextObject,
|
||||||
|
Contour,
|
||||||
|
Project,
|
||||||
|
Vec2,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const shift = (p: Vec2, dx: number, dy: number): Vec2 => ({ x: p.x + dx, y: p.y + dy });
|
||||||
|
const shiftAll = (pts: Vec2[], dx: number, dy: number): Vec2[] => pts.map((p) => shift(p, dx, dy));
|
||||||
|
|
||||||
|
/** Kontur (Höhenlinie): Punkte + optionale Kreis-/Bogen-Mitte verschieben. */
|
||||||
|
function shiftContour(c: Contour, dx: number, dy: number): Contour {
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
pts: shiftAll(c.pts, dx, dy),
|
||||||
|
curve: c.curve ? { ...c.curve, cx: c.curve.cx + dx, cy: c.curve.cy + dy } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontext-Objekt (importiertes Mesh/Terrain/Konturen-Satz) verschieben.
|
||||||
|
* `positions` ist ein flaches [x,y,z=Höhe, …]-Array — nur x/y (Grundriss)
|
||||||
|
* verschieben, die Höhe (z) bleibt unverändert.
|
||||||
|
*/
|
||||||
|
function shiftContextObject(obj: ContextObject, dx: number, dy: number): ContextObject {
|
||||||
|
if (obj.type === "contourSet") {
|
||||||
|
return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) };
|
||||||
|
}
|
||||||
|
const positions = obj.positions.slice();
|
||||||
|
for (let i = 0; i + 2 < positions.length; i += 3) {
|
||||||
|
positions[i] += dx;
|
||||||
|
positions[i + 1] += dy;
|
||||||
|
}
|
||||||
|
return { ...obj, positions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt SÄMTLICHE Modell-Koordinaten eines Projekts um den festen Vektor
|
||||||
|
* (dx,dy) — Wände, Decken, Dächer, Öffnungen-Host (folgt automatisch der Wand),
|
||||||
|
* Treppen, Räume, Stützen, Extrusionen, freie 2D-Geometrie, Kontext-Schicht
|
||||||
|
* (Import/Terrain/Konturen), Schnitt-/Ansichtslinien sowie den Geo-Anker
|
||||||
|
* (`geoAnchor.model`, damit der LV95-Bezug exakt erhalten bleibt). Richtungs-
|
||||||
|
* Vektoren (z. B. `Stair.dir`) und Winkel-Felder bleiben unangetastet — eine
|
||||||
|
* reine Translation ändert keine Orientierung.
|
||||||
|
*/
|
||||||
|
export function translateProject(project: Project, dx: number, dy: number): Project {
|
||||||
|
if (dx === 0 && dy === 0) return project;
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
walls: project.walls.map((w) => ({
|
||||||
|
...w,
|
||||||
|
start: shift(w.start, dx, dy),
|
||||||
|
end: shift(w.end, dx, dy),
|
||||||
|
})),
|
||||||
|
ceilings: project.ceilings?.map((c) => ({
|
||||||
|
...c,
|
||||||
|
outline: shiftAll(c.outline, dx, dy),
|
||||||
|
openings: c.openings?.map((h) => shiftAll(h, dx, dy)),
|
||||||
|
})),
|
||||||
|
roofs: project.roofs?.map((r) => ({ ...r, outline: shiftAll(r.outline, dx, dy) })),
|
||||||
|
stairs: project.stairs?.map((s) => ({
|
||||||
|
...s,
|
||||||
|
start: shift(s.start, dx, dy),
|
||||||
|
center: s.center ? shift(s.center, dx, dy) : undefined,
|
||||||
|
})),
|
||||||
|
rooms: project.rooms?.map((r) => ({
|
||||||
|
...r,
|
||||||
|
boundary: shiftAll(r.boundary, dx, dy),
|
||||||
|
stampAnchor: r.stampAnchor ? shift(r.stampAnchor, dx, dy) : undefined,
|
||||||
|
})),
|
||||||
|
columns: project.columns?.map((c) => ({ ...c, position: shift(c.position, dx, dy) })),
|
||||||
|
extrudedSolids: project.extrudedSolids?.map((s) => ({
|
||||||
|
...s,
|
||||||
|
points: shiftAll(s.points, dx, dy),
|
||||||
|
circle: s.circle ? { ...s.circle, center: shift(s.circle.center, dx, dy) } : undefined,
|
||||||
|
})),
|
||||||
|
drawings2d: project.drawings2d.map((d) => {
|
||||||
|
const g = d.geom;
|
||||||
|
switch (g.shape) {
|
||||||
|
case "line":
|
||||||
|
return { ...d, geom: { ...g, a: shift(g.a, dx, dy), b: shift(g.b, dx, dy) } };
|
||||||
|
case "polyline":
|
||||||
|
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
|
||||||
|
case "rect":
|
||||||
|
return { ...d, geom: { ...g, min: shift(g.min, dx, dy), max: shift(g.max, dx, dy) } };
|
||||||
|
case "circle":
|
||||||
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
|
case "arc":
|
||||||
|
return { ...d, geom: { ...g, center: shift(g.center, dx, dy) } };
|
||||||
|
case "text":
|
||||||
|
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
context: project.context?.map((o) => shiftContextObject(o, dx, dy)),
|
||||||
|
drawingLevels: project.drawingLevels.map((lvl) =>
|
||||||
|
lvl.linePoints
|
||||||
|
? { ...lvl, linePoints: [shift(lvl.linePoints[0], dx, dy), shift(lvl.linePoints[1], dx, dy)] }
|
||||||
|
: lvl,
|
||||||
|
),
|
||||||
|
geoAnchor: project.geoAnchor
|
||||||
|
? { ...project.geoAnchor, model: shift(project.geoAnchor.model, dx, dy) }
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
roomStampToDoc,
|
roomStampToDoc,
|
||||||
roomStampExtraLines,
|
roomStampExtraLines,
|
||||||
roomDisplayName,
|
roomDisplayName,
|
||||||
|
formatStampArea,
|
||||||
} from "./roomStamp";
|
} from "./roomStamp";
|
||||||
import type { Room } from "./types";
|
import type { Room } from "./types";
|
||||||
|
|
||||||
@@ -82,6 +83,54 @@ describe("roomStampExtraLines", () => {
|
|||||||
);
|
);
|
||||||
expect(lines).toHaveLength(0);
|
expect(lines).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rundet die Fläche auf roundingStep, falls gesetzt", () => {
|
||||||
|
const lines = roomStampExtraLines(
|
||||||
|
{ name: "Bad", showFloorArea: true, showUsage: false, roundingStep: 0.5 },
|
||||||
|
{ netArea: 12.34, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||||
|
);
|
||||||
|
expect(lines[0].text).toBe("12.5 m²");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nutzt 2 Nachkommastellen ohne roundingStep (Alt-Verhalten)", () => {
|
||||||
|
const lines = roomStampExtraLines(
|
||||||
|
{ name: "Bad", showFloorArea: true, showUsage: false },
|
||||||
|
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||||
|
);
|
||||||
|
expect(lines[0].text).toBe("12.35 m²");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt die Personenzahl als eigene Zeile, wenn gesetzt", () => {
|
||||||
|
const lines = roomStampExtraLines(
|
||||||
|
{ name: "Bad", showFloorArea: false, showUsage: false, occupancy: 4 },
|
||||||
|
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||||
|
);
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(lines[0].text).toBe("4 Pers.");
|
||||||
|
expect(lines[0].align).toBe("center");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blendet die Personenzahl-Zeile aus, wenn occupancy fehlt", () => {
|
||||||
|
const lines = roomStampExtraLines(
|
||||||
|
{ name: "Bad", showFloorArea: false, showUsage: false },
|
||||||
|
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||||
|
);
|
||||||
|
expect(lines).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatStampArea", () => {
|
||||||
|
it("2 Nachkommastellen ohne roundingStep", () => {
|
||||||
|
expect(formatStampArea(12.345)).toBe("12.35");
|
||||||
|
expect(formatStampArea(12)).toBe("12.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rundet auf das nächste Vielfache von roundingStep", () => {
|
||||||
|
expect(formatStampArea(12.24, 0.5)).toBe("12");
|
||||||
|
expect(formatStampArea(12.26, 0.5)).toBe("12.5");
|
||||||
|
expect(formatStampArea(12.3, 0.1)).toBe("12.3");
|
||||||
|
expect(formatStampArea(12.34, 1)).toBe("12");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("roomDisplayName", () => {
|
describe("roomDisplayName", () => {
|
||||||
|
|||||||
+24
-3
@@ -67,17 +67,32 @@ export interface RoomStampLine {
|
|||||||
align: Align;
|
align: Align;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert die Bodenfläche für die Stempel-Anzeige: ohne `roundingStep` wie
|
||||||
|
* bisher auf 2 Nachkommastellen; mit `roundingStep` auf das nächste Vielfache
|
||||||
|
* davon gerundet (z. B. 0.5 → halbe m²), ohne Nachkommastellen-Rauschen.
|
||||||
|
*/
|
||||||
|
export function formatStampArea(area: number, roundingStep?: number): string {
|
||||||
|
if (roundingStep && roundingStep > 0) {
|
||||||
|
const rounded = Math.round(area / roundingStep) * roundingStep;
|
||||||
|
// Rundungsfehler aus der Fliesskomma-Division kappen (max. 2 Nachkommastellen).
|
||||||
|
return `${Math.round(rounded * 100) / 100}`;
|
||||||
|
}
|
||||||
|
return area.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
|
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
|
||||||
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. Die Ausrichtung folgt den
|
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage, Personenzahl wenn gesetzt.
|
||||||
* Stempel-Feldern; fehlt sie, gilt „zentriert" (Alt-Verhalten).
|
* Die Ausrichtung folgt den Stempel-Feldern; fehlt sie, gilt „zentriert"
|
||||||
|
* (Alt-Verhalten).
|
||||||
*/
|
*/
|
||||||
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] {
|
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] {
|
||||||
const lines: RoomStampLine[] = [];
|
const lines: RoomStampLine[] = [];
|
||||||
if (stamp.showFloorArea) {
|
if (stamp.showFloorArea) {
|
||||||
const prefix = stamp.floorAreaPrefix ?? "";
|
const prefix = stamp.floorAreaPrefix ?? "";
|
||||||
lines.push({
|
lines.push({
|
||||||
text: `${prefix}${ctx.netArea.toFixed(2)} m²`,
|
text: `${prefix}${formatStampArea(ctx.netArea, stamp.roundingStep)} m²`,
|
||||||
align: stamp.floorAreaAlign ?? "center",
|
align: stamp.floorAreaAlign ?? "center",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -87,6 +102,12 @@ export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): Ro
|
|||||||
align: stamp.usageAlign ?? "center",
|
align: stamp.usageAlign ?? "center",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (stamp.occupancy != null) {
|
||||||
|
lines.push({
|
||||||
|
text: `${stamp.occupancy} Pers.`,
|
||||||
|
align: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+204
-5
@@ -1,5 +1,15 @@
|
|||||||
import type { Project } from "./types";
|
import type { Project } from "./types";
|
||||||
import { recomputeFloorElevations } from "./types";
|
import { recomputeFloorElevations } from "./types";
|
||||||
|
import { MATERIAL_LIBRARY } from "../materials/library";
|
||||||
|
import { materialFromAsset } from "../materials/runtime";
|
||||||
|
|
||||||
|
// Bibliotheks-Materialien für die Basis-Components ab Werk (PBR-Texturen,
|
||||||
|
// s. `src/materials/library.ts`). Zuordnung nur, wo ein Bibliotheks-Asset
|
||||||
|
// sinnvoll passt — Wärmedämmung und Zementestrich bleiben bewusst ohne
|
||||||
|
// Zuweisung (kein passendes Asset in der Bibliothek), statt zu raten.
|
||||||
|
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
|
||||||
|
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
|
||||||
|
const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!;
|
||||||
|
|
||||||
// Demo-Haus mit zwei Geschossen:
|
// Demo-Haus mit zwei Geschossen:
|
||||||
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
|
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
|
||||||
@@ -111,14 +121,44 @@ export const sampleProject: Project = {
|
|||||||
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
|
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
|
||||||
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
|
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
|
||||||
components: [
|
components: [
|
||||||
{ id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 },
|
{
|
||||||
|
id: "render-ext",
|
||||||
|
name: "Aussenputz",
|
||||||
|
color: "#d8d2c7",
|
||||||
|
hatchId: "none",
|
||||||
|
joinPriority: 10,
|
||||||
|
material: materialFromAsset(PLASTER_ASSET),
|
||||||
|
},
|
||||||
// Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand).
|
// Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand).
|
||||||
|
// Kein passendes Bibliotheks-Asset (Dämmung) → bewusst ohne `material`.
|
||||||
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 },
|
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 },
|
||||||
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "sia-brick", joinPriority: 50 },
|
{
|
||||||
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 },
|
id: "brick",
|
||||||
|
name: "Backstein",
|
||||||
|
color: "#8c5544",
|
||||||
|
hatchId: "sia-brick",
|
||||||
|
joinPriority: 50,
|
||||||
|
material: materialFromAsset(BRICK_ASSET),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "render-int",
|
||||||
|
name: "Innenputz",
|
||||||
|
color: "#efece6",
|
||||||
|
hatchId: "none",
|
||||||
|
joinPriority: 10,
|
||||||
|
material: materialFromAsset(PLASTER_ASSET),
|
||||||
|
},
|
||||||
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
|
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
|
||||||
{ id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "sia-concrete", joinPriority: 100 },
|
{
|
||||||
|
id: "concrete",
|
||||||
|
name: "Beton",
|
||||||
|
color: "#9aa0a6",
|
||||||
|
hatchId: "sia-concrete",
|
||||||
|
joinPriority: 100,
|
||||||
|
material: materialFromAsset(CONCRETE_ASSET),
|
||||||
|
},
|
||||||
// Zementestrich — oberste Schicht des mehrschichtigen Deckenaufbaus (Decken-Stile).
|
// Zementestrich — oberste Schicht des mehrschichtigen Deckenaufbaus (Decken-Stile).
|
||||||
|
// Kein passendes Bibliotheks-Asset (Estrich) → bewusst ohne `material`.
|
||||||
{ id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 },
|
{ id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 },
|
||||||
],
|
],
|
||||||
wallTypes: [
|
wallTypes: [
|
||||||
@@ -162,6 +202,130 @@ export const sampleProject: Project = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
roofTypes: [
|
||||||
|
{
|
||||||
|
id: "roof-warm",
|
||||||
|
name: "Steildach gedämmt",
|
||||||
|
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
|
||||||
|
layers: [
|
||||||
|
{ componentId: "brick", thickness: 0.04 }, // Eindeckung (Ziegel, Platzhalter)
|
||||||
|
{ componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung
|
||||||
|
{ componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 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,
|
||||||
|
frameKind: "zarge",
|
||||||
|
frameWidth: 0.06,
|
||||||
|
defaultWidth: 0.9,
|
||||||
|
defaultHeight: 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Aussentür: Blockrahmen, in die Aussenschale zurückgesetzt, mit Oberlicht.
|
||||||
|
id: "door-entry",
|
||||||
|
name: "Haustür Blockrahmen (Oberlicht)",
|
||||||
|
kind: "dreh",
|
||||||
|
leafCount: 1,
|
||||||
|
leafStyle: "glas",
|
||||||
|
glazingRatio: 0.35,
|
||||||
|
frameThickness: 0.07,
|
||||||
|
frameKind: "blockrahmen",
|
||||||
|
frameWidth: 0.09,
|
||||||
|
insetFromFace: 0.14,
|
||||||
|
insetFace: "aussen",
|
||||||
|
transomHeight: 0.45,
|
||||||
|
threshold: true,
|
||||||
|
defaultWidth: 1.1,
|
||||||
|
defaultHeight: 2.1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
windowTypes: [
|
||||||
|
{
|
||||||
|
id: "window-standard",
|
||||||
|
name: "Fenster 1.2 × 1.4 m",
|
||||||
|
kind: "drehkipp",
|
||||||
|
wingCount: 1,
|
||||||
|
glazing: "zweifach",
|
||||||
|
frameThickness: 0.06,
|
||||||
|
frameWidth: 0.06,
|
||||||
|
defaultSillHeight: 0.9,
|
||||||
|
defaultWidth: 1.2,
|
||||||
|
defaultHeight: 1.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Zweiflügliges Fenster mit Kämpfer, Oberlicht und Schichteinzug.
|
||||||
|
id: "window-double",
|
||||||
|
name: "Fenster 2-flügl. + Oberlicht",
|
||||||
|
kind: "drehkipp",
|
||||||
|
wingCount: 2,
|
||||||
|
mullionRows: 1,
|
||||||
|
glazing: "dreifach",
|
||||||
|
frameThickness: 0.07,
|
||||||
|
frameWidth: 0.07,
|
||||||
|
insetFromFace: 0.12,
|
||||||
|
insetFace: "aussen",
|
||||||
|
transomHeight: 0.4,
|
||||||
|
defaultSillHeight: 0.85,
|
||||||
|
defaultWidth: 1.8,
|
||||||
|
defaultHeight: 1.9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stairTypes: [
|
||||||
|
{
|
||||||
|
// Standard: normale Ortbeton-Laufplatte (schräge, offene Untersicht).
|
||||||
|
id: "stair-standard",
|
||||||
|
name: "Betontreppe 1.0 m",
|
||||||
|
structure: "beton",
|
||||||
|
closedRisers: true,
|
||||||
|
treadThickness: 0.05,
|
||||||
|
nosing: 0.03,
|
||||||
|
railing: "keine",
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stair-massiv",
|
||||||
|
name: "Massivtreppe (auf Sockel)",
|
||||||
|
structure: "massiv",
|
||||||
|
closedRisers: true,
|
||||||
|
treadThickness: 0.04,
|
||||||
|
nosing: 0.03,
|
||||||
|
railing: "keine",
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Offene Wangentreppe: schwebende Tritte (unten offen), zum direkten
|
||||||
|
// Vergleich mit der Massivtreppe — im ObjectInfoPanel der Treppe zuweisen.
|
||||||
|
id: "stair-wange-holz",
|
||||||
|
name: "Wangentreppe Holz (offen)",
|
||||||
|
structure: "wange",
|
||||||
|
closedRisers: false,
|
||||||
|
treadThickness: 0.05,
|
||||||
|
nosing: 0.03,
|
||||||
|
railing: "keine",
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Aufgesattelte Treppe mit geschlossenen Setzstufen (Tritt + Setzstufe,
|
||||||
|
// aber unten offen — der Raum darunter bleibt frei).
|
||||||
|
id: "stair-aufgesattelt",
|
||||||
|
name: "Aufgesattelt (Setzstufen)",
|
||||||
|
structure: "aufgesattelt",
|
||||||
|
closedRisers: true,
|
||||||
|
treadThickness: 0.05,
|
||||||
|
nosing: 0.025,
|
||||||
|
railing: "keine",
|
||||||
|
defaultWidth: 1.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
|
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
|
||||||
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
|
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
|
||||||
drawingLevels: recomputeFloorElevations([
|
drawingLevels: recomputeFloorElevations([
|
||||||
@@ -176,7 +340,18 @@ export const sampleProject: Project = {
|
|||||||
linePoints: [{ x: -1, y: 2 }, { x: 6, y: 2 }],
|
linePoints: [{ x: -1, y: 2 }, { x: 6, y: 2 }],
|
||||||
directionSign: 1,
|
directionSign: 1,
|
||||||
},
|
},
|
||||||
{ id: "ansicht-sued", name: "Ansicht Süd", kind: "elevation", visible: true, locked: false },
|
{
|
||||||
|
id: "ansicht-sued",
|
||||||
|
name: "Ansicht Süd",
|
||||||
|
kind: "elevation",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
// Ansichtslinie südlich des Demo-Hauses, Blick nach Norden (+y) — damit
|
||||||
|
// die Demo-Ansicht sofort rendert (analog zur gesäten Schnitt-A-Linie).
|
||||||
|
linePoints: [{ x: -1, y: -2 }, { x: 6, y: -2 }],
|
||||||
|
directionSign: 1,
|
||||||
|
shadows: true,
|
||||||
|
},
|
||||||
{ id: "detail-1", name: "Detail 1", kind: "drawing", visible: true, locked: false },
|
{ id: "detail-1", name: "Detail 1", kind: "drawing", visible: true, locked: false },
|
||||||
]),
|
]),
|
||||||
// Grafik-Kategorie-Baum (DOSSIER-Codes 1:1).
|
// Grafik-Kategorie-Baum (DOSSIER-Codes 1:1).
|
||||||
@@ -257,6 +432,7 @@ export const sampleProject: Project = {
|
|||||||
hostWallId: "W1",
|
hostWallId: "W1",
|
||||||
categoryCode: "21",
|
categoryCode: "21",
|
||||||
kind: "window",
|
kind: "window",
|
||||||
|
typeId: "window-standard",
|
||||||
position: 3.0, // Startkante 3.0 m → Fenster x 3.0–4.0, rechts von W9 mit Massiv-Abstand
|
position: 3.0, // Startkante 3.0 m → Fenster x 3.0–4.0, rechts von W9 mit Massiv-Abstand
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
@@ -268,6 +444,7 @@ export const sampleProject: Project = {
|
|||||||
hostWallId: "W3",
|
hostWallId: "W3",
|
||||||
categoryCode: "21",
|
categoryCode: "21",
|
||||||
kind: "window",
|
kind: "window",
|
||||||
|
typeId: "window-standard",
|
||||||
// Startkante 0.6 m ab W3-Start (x=5) → Fenster x 3.0–4.4, rechts von W9 (x=2.4)
|
// 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).
|
// mit sichtbarer Massiv-Wand dazwischen (die Querwand stösst auf Mauerwerk).
|
||||||
position: 0.6,
|
position: 0.6,
|
||||||
@@ -297,6 +474,27 @@ export const sampleProject: Project = {
|
|||||||
ceilingTypeId: "dg-massiv",
|
ceilingTypeId: "dg-massiv",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Dach — Satteldach über dem Baukörper (5×4), First entlang X (längere Seite),
|
||||||
|
// 0.4 m Traufüberstand. Sitzt auf der OG-Oberkante (roofBaseElevation-Default).
|
||||||
|
roofs: [
|
||||||
|
{
|
||||||
|
id: "RF1",
|
||||||
|
type: "roof",
|
||||||
|
floorId: "og",
|
||||||
|
categoryCode: "31",
|
||||||
|
outline: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 5, y: 0 },
|
||||||
|
{ x: 5, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
],
|
||||||
|
shape: "sattel",
|
||||||
|
pitchDeg: 30,
|
||||||
|
overhang: 0.4,
|
||||||
|
ridgeAxis: "x",
|
||||||
|
thickness: 0.2,
|
||||||
|
},
|
||||||
|
],
|
||||||
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
|
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
|
||||||
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
|
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
|
||||||
stairs: [
|
stairs: [
|
||||||
@@ -306,6 +504,7 @@ export const sampleProject: Project = {
|
|||||||
floorId: "eg",
|
floorId: "eg",
|
||||||
categoryCode: "40",
|
categoryCode: "40",
|
||||||
shape: "straight",
|
shape: "straight",
|
||||||
|
typeId: "stair-standard",
|
||||||
start: { x: 3.7, y: 0.4 },
|
start: { x: 3.7, y: 0.4 },
|
||||||
dir: { x: 0, y: 1 },
|
dir: { x: 0, y: 1 },
|
||||||
runLength: 3.2,
|
runLength: 3.2,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user