Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05bc5aa6e1 | |||
| d36c84689f | |||
| 8f9123ce0d | |||
| a44572bf7b | |||
| 79ea6b0b3b | |||
| 83da278ed2 | |||
| 33c2c6c22e | |||
| b7551d4930 | |||
| 25daec6be9 | |||
| 5229174551 | |||
| df0e5e432a | |||
| 715e950d7c | |||
| dbf78a9d75 | |||
| 26fbd11b73 | |||
| 6e5998ce72 | |||
| b65c676643 | |||
| ae18766b01 | |||
| c7128fe669 | |||
| b37c9f467b | |||
| 08b0b23a68 | |||
| 4a26c34db1 | |||
| 903dc19cec | |||
| 09c5178c85 | |||
| 8750c8f547 | |||
| 028a3637b4 | |||
| 74eecf7a73 | |||
| 0ca3b1dd57 | |||
| ecefe61611 | |||
| 2d96a864da | |||
| dec431579e | |||
| 9520191bde | |||
| 9136cad0ee | |||
| c7e080f639 | |||
| aa4205bf0b | |||
| 9a80d9cf42 | |||
| ea86cf7456 | |||
| 05cfade483 | |||
| 87a30b7061 | |||
| 23dddf893e | |||
| 45ded83294 | |||
| 77f32f14ce | |||
| 07042ebc75 | |||
| ecf262ef07 | |||
| 9acce9d7cd | |||
| cf8b638f33 | |||
| 20cf870c4c | |||
| e095b51d15 | |||
| 65d6e843fb | |||
| c79d15dda4 | |||
| 6035b8a8c2 | |||
| 6c04935711 | |||
| 8c96a5dfb9 | |||
| 8547d38e9a | |||
| 41d8fafa4e | |||
| 0d3a0a081f | |||
| d1df9d4d5c | |||
| e11743d6e7 | |||
| c54a2f916c | |||
| fde27f6838 | |||
| 00c90857ad | |||
| 018fef56bf | |||
| 1db661f19a | |||
| 6ea562ceda | |||
| e137353b95 | |||
| 16d32223a4 | |||
| 8f4fac633f | |||
| d4065c5a41 | |||
| d0b94a75ec | |||
| 8b6b9291a1 | |||
| 7e8764b21b | |||
| efc9dd87ce | |||
| f724c088e0 | |||
| 44942e6976 | |||
| e0d71691e1 | |||
| 9f6d4e8858 | |||
| 19d002d403 | |||
| 2c9633438d | |||
| 19fcafbcab | |||
| 4aed168760 | |||
| 2eeafb9aca | |||
| 7d3e0943e1 | |||
| d0f26cd874 | |||
| 777e02c927 | |||
| c34b7e5771 | |||
| b55ab9eb8f | |||
| bd13495923 | |||
| 683242ba78 | |||
| e54bb318bb | |||
| a302d512d9 | |||
| f09ba0e49f | |||
| 07475cbe6c | |||
| 87ef7988d1 | |||
| f3966f99e9 | |||
| f3639cfb15 | |||
| 6288f755a8 | |||
| 724db0f9bf | |||
| c2d6893a59 | |||
| c6d2641664 | |||
| 3d4c4985a9 | |||
| 912f0bc16d | |||
| ac7af9b9ef | |||
| 400ec890b0 | |||
| 558a24cf98 | |||
| e29f5a2a7c | |||
| b03614c35b | |||
| 1086225f7b | |||
| ca1ed1b0d2 | |||
| 04c7334cc4 | |||
| 93b70fa95a | |||
| fae4f6fcb6 | |||
| d65d84183a | |||
| 1f6da09b2b | |||
| 5fd0161bc7 | |||
| f4e70902d0 | |||
| af0b044fca | |||
| 692dd3f719 | |||
| 4d721a62f7 | |||
| b028fc6b31 | |||
| 581ddccf1a | |||
| 1fd688896c | |||
| f9a5e763fd | |||
| ec2568276d | |||
| 2b6b99059d | |||
| 658536029b | |||
| 675c68de55 | |||
| e9dc423a12 | |||
| 6aaef8f46a | |||
| eaf6d8ed7b | |||
| d30f79e162 | |||
| bde1f3f8d1 | |||
| 2dd7fe339d | |||
| 3c4d983a7d | |||
| 094d8b4504 | |||
| 330c01bbbd | |||
| d2503c1191 | |||
| e3f460c9f8 | |||
| 28b5492def | |||
| 4a62dbf83c | |||
| 8a79f6cbaa | |||
| 9371b65b3b | |||
| e8f5e7272d | |||
| f95df75ea1 | |||
| 918f60c498 | |||
| 23410f9b9e | |||
| 98994c96aa | |||
| 0e484746de | |||
| 87d12b976f | |||
| 519735c782 | |||
| 47f1c24bb3 | |||
| 1bbcc7038f | |||
| e6466a5027 | |||
| 1ab8c7e496 | |||
| 0e839b50d6 | |||
| 623b243306 | |||
| 6a7bbeec2f | |||
| cbf54e6b65 | |||
| 543d06adb5 | |||
| a3b25da96d | |||
| 2a2b3b294c | |||
| 926dedca40 | |||
| 4311a7dfbd | |||
| 31ac91b2a7 | |||
| 0189eed5ac | |||
| 45f9d0c381 | |||
| faa84e98af | |||
| e82f0b6de1 | |||
| 128c04a5d0 | |||
| 19f99382f6 | |||
| ec181998ca | |||
| c400a96575 | |||
| b4c3c2de4a | |||
| 788f4d58ca | |||
| 229169cea1 | |||
| f4cd16b7ac | |||
| e7ea1eeddd | |||
| c96239a794 | |||
| 260320af2e | |||
| f08ef13fe8 | |||
| 3d2d4d6321 | |||
| cfe5249440 | |||
| b9731a4979 | |||
| ce3b594403 | |||
| 94a5af6b6f |
+1
-1
@@ -57,7 +57,7 @@ Rendern angewandt, nie in die Geometrie eingebacken.
|
||||
|
||||
## Arbeitsweise (für Beiträge)
|
||||
|
||||
- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich.
|
||||
- Substanzielle, mehrstufige Arbeit schrittweise in isolierten Schritten angehen.
|
||||
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
|
||||
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
|
||||
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
|
||||
|
||||
+10
-11
@@ -4,25 +4,25 @@
|
||||
|
||||
> **➡️ Aufgaben-Queue steht in `PENDENZEN.md`** (Single Source of Truth, priorisierte Checkliste + Arbeitsprotokoll). HANDOVER = nur noch Kontext/Konventionen/Environment/Zielmodelle, KEIN Backlog mehr. Neue Pendenzen/Rückfragen dort eintragen, nicht hier.
|
||||
|
||||
## ⚠️ NACHTRAG Session 3b (2026-07-04 abends) — Session hart abgebrochen (Spend-Limit), 3 Slices mitten in der Arbeit VERLOREN
|
||||
## ⚠️ NACHTRAG 2026-07-04 abends — Unterbruch, 3 Slices verloren
|
||||
|
||||
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Der zuständige Agent stallte kurz vor Schluss (Watchdog), aber alle Datei-Edits waren gelandet — Hauptinstanz hat unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
|
||||
> **STAND-UPDATE (Session 4, neues Gerät):** **Environment behoben** — Toolchain vorhanden (`node`/`npm`/`cargo` unter `/usr/bin`, `wasm-pack` via `npx` 0.15.0; es war die Flatpak-Sandbox); fehlendes Rust-Target per `sudo dnf install rust-std-static-wasm32-unknown-unknown` (+ lld) nachinstalliert; beide WASM-Engines gebaut → App läuft. **Slice 1 (Locked-Iso `cb8fae5`) + Slice 2 (Joins Phase 1c `c5a344d`) committet.** **Slice 3 (Öffnungen als Boolean-Löcher + Deckentrim-nur-3D) ✅ committet `1407c68`:** Fenster/Türen = 1 Wandkörper pro Schicht-Band mit rechteckigen `holes` (render3d stanzt sie per achsparalleler Rechteck-Gitter-Zerlegung + 4 Laibungsquads/Loch), Segment-Boxen weg; `holes`/`openings` schliessen sich im Emitter aus; Schnitt-Pfad (layered=false) segmentiert unverändert weiter. Deckentrim `trimWallTopForCeilings` gilt nur noch im 3D-Pfad, Schnitt-Pfad bekommt volle Wandhöhe. Alle Datei-Edits waren gelandet — unabhängig verifiziert: cargo test 56 (inkl. beider Pflicht-Tests versetzt-überlappende Fenster + Tür-Loch-bis-Boden), vitest 230, build:engine3d + tsc sauber, Trace clean. **Damit sind alle drei verlorenen 3b-Slices rekonstruiert.** **Memory-Direktiven `priority-based-joins.md` + `openings-as-booleans.md` wiederhergestellt.**
|
||||
>
|
||||
> **⚠️ PUSH BLOCKIERT:** keine Git-Credentials auf diesem Gerät (kein Helper, keine `~/.git-credentials`, kein SSH-Key; Remote HTTPS `git.openbureau.ch/karim/DOSSIER-STANDALONE`). `cb8fae5` + `c5a344d` (+ folgender Öffnungen-Commit) sind **lokal committet, NICHT gepusht** (`ahead of origin/master`). Sobald Gitea-PAT (repo-write) oder SSH-Key da ist: `git push origin master`. Bis dahin holt das andere Gerät die Arbeit nicht.
|
||||
>
|
||||
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
|
||||
|
||||
Die letzte Session endete nicht durch normales Handover, sondern weil das monatliche Spend-Limit des KI-Anbieters **mitten in laufender Arbeit** griff (mehrere Agenten liefen parallel, „arbeite die nächsten 2h selbstständig"-Direktive des Nutzers). Diese Übergabe wurde nachträglich aus dem rohen Chat-Transkript (`Edit MEMORY md Added 1.txt`, lag im Repo-Root, siehe Aufräum-Hinweis unten) rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
|
||||
Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Diese Übergabe wurde nachträglich rekonstruiert. **Der tatsächliche HEAD-Commit ist `03f0c40`** (Nordstern-3D Live-Schnittebene) — alles danach existierte nur im Arbeitsbaum der alten Maschine und ist auf **diesem** Checkout (neues Gerät, `git status` sauber bis auf `package-lock.json`) **nicht vorhanden**. Diese drei Slices waren in echter Arbeit (nicht nur geplant) und gelten als verloren — neu beauftragen, nicht nur „prüfen ob gelandet":
|
||||
|
||||
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War von einem Agenten (kleineres Modell) fertig gebaut UND bereits quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit per Screenshot-Analyse, 225/225 Tests grün), aber **nicht mehr committet**, bevor die Session starb — der komplette Fix-Ansatz inkl. Diff-Beschreibung steht im Transkript ab „Bash Verify locked-iso file isolated". Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
|
||||
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Der Agent (grosses Modell) war weit in der Umsetzung (schrieb bereits in `joins.ts`), letzte sichtbare Nachricht vor dem Abbruch: „The join looks correct — W9's brick runs through and W1's near plaster is notched" (Agent wollte gerade einen finalen Bestätigungs-Screenshot holen) — dann `failed` durch API-Fehler (Spend-Limit). **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
|
||||
1. **Locked-Iso-Fix** (`src/viewport/Wasm3DViewport.tsx`, `OrbitState` um `ortho: boolean` + `orthoHalfHeight: number` erweitern, `orbitCamera()`/Preset-Effect/Pan/Zoom entsprechend anpassen) — Bug: Iso-Ansicht kippt bei der ersten Kamerabewegung sofort in Perspektive statt orthografisch (parallel) zu bleiben. War fertig gebaut und quantitativ verifiziert (Parallelogramm-Kantenvergleich vor/nach Orbit, 225/225 Tests grün), aber **nicht mehr committet**, bevor der Unterbruch kam. Kleinster, unabhängigster der drei Fixes — zuerst neu machen.
|
||||
2. **Joins Phase 1c — Durchgangswand echt aufbrechen** (`spanCutouts` in `src/model/joins.ts` + `src/plan/generatePlan.ts` + Rust-Parität `src-tauri/geometry/src/lib.rs`): Nutzer-Befund nach Phase 1b war, dass der Innenputz der DURCHGANGSWAND am T-Stoss nur überdeckt wird (Zeichenreihenfolge), nicht geometrisch ausgeschnitten — Fugen-/Nahflächenlinien laufen noch durch den Backstein-Durchstoss, es „liest" noch nicht wie ein einziger Join. Das war die **explizite Priorität des Nutzers** für diese Session. Die Umsetzung war weit fortgeschritten (schrieb bereits in `joins.ts`), dann abgebrochen. **Nicht committet, nicht im Baum.** Vollständige Aufgabenbeschreibung mit allen Datei:Zeile-Ankern steht im Transkript bei „Agent:Phase 1c: Durchgangswand aufbrechen" — 1:1 als Vorlage für den Neu-Auftrag wiederverwenden.
|
||||
3. **Öffnungen als echte Boolean-Löcher** (`RWall.holes`, `src/plan/toWalls3d.ts`, `src-tauri/render3d/src/mesh.rs`, additiv `types.rs`): EIN Wandkörper mit rechteckigen Löchern statt der heutigen Pfeiler/Brüstung/Sturz-Ersatzkörper (sichtbare Segment-Nähte, keine echten Löcher). Pflicht-Testfall (Nutzer-Direktive, Kernmotivation des Slices): zwei Fenster in derselben Wand mit überlappendem Achsen-Intervall, aber unterschiedlicher Höhe (z. B. u=[1.0,2.0]/z=[0.9,2.1] und u=[1.5,2.5]/z=[0.3,0.7]) — die alte Segment-Zerlegung kann das nicht abbilden, echte Löcher schon. Zusatz-Auftrag im selben Slice: `trimWallTopForCeilings` (Phase-3-Deckentrim, Commit `e4b8df6`) darf **nur** im 3D-Viewer-Pfad (`layeredWalls:true`) gelten, **nicht** im Schnitt-Pfad (`layeredWalls:false`) — der Schnitt braucht die volle, ungetrimmte Wandhöhe für seine eigene schichtweise Prioritäts-Subtraktion (`subtractDominantBands`), sonst zeigt der Schnitt die Wände fälschlich abgeschnitten. Fortschritt bei Abbruch **unklar** (letztes Status-Update deutete auf „noch in der Kartierungsphase" hin) — vermutlich am wenigsten weit von den dreien; komplett neu beauftragen. Vollständiger Auftragstext im Transkript bei „Agent:Oeffnungen als Boolean-Loecher" + die zwei Zusatz-Direktiven (Decken-Trim-Fix, Pflicht-Testfall versetzte Fenster) kurz danach.
|
||||
|
||||
**Zwei Memory-Direktiven aus der alten Session sind ebenfalls weg** (`priority-based-joins.md`, `openings-as-booleans.md` — auf der alten Maschine im Instanz-Memory-Verzeichnis abgelegt, existieren auf diesem Gerät nicht): die in Punkt 2/3 beschriebenen Zielverhalten waren dort als dauerhafte Engine-Direktiven gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit ins Memory dieser Instanz schreiben, nicht nur einmalig in den Auftragstext — sonst geht dieselbe Information beim nächsten Abbruch wieder verloren.
|
||||
**Zwei Engine-Direktiven vom alten Stand fehlen** (`priority-based-joins.md`, `openings-as-booleans.md`): die in Punkt 2/3 beschriebenen Zielverhalten waren gesichert (u. a. „Trennlinien an Merge-Kontakten verschwinden immer", „Öffnungen dürfen nie wieder zu Segmenten vereinfacht werden"). Beim Neu-Beauftragen diese Direktiven wieder explizit festhalten.
|
||||
|
||||
**Environment-Lücke auf diesem Gerät:** `node`/`npm`/`cargo`/`wasm-pack` waren zum Zeitpunkt dieser Übergabe **nicht auf dem PATH** (geprüft: kein Treffer, auch nicht unter der VSCodium-Flatpak-Datenlage). **Vermutliche Ursache: VSCodium lief als Flatpak** — Flatpak-Sandboxing blendet system-installierte Toolchains (Node/Rust unter `/usr/...` oder `~/.cargo`) typischerweise aus, auch wenn sie auf dem Host tatsächlich installiert sind. Der Nutzer wechselt gerade auf **natives VSCodium** (kein Flatpak mehr) — das könnte die Lücke von selbst beheben, MUSS aber nach dem Wechsel neu geprüft werden (`which node npm cargo wasm-pack`), bevor man von "Toolchain fehlt" auf "Toolchain neu installieren" schließt. Erst wenn nach dem Wechsel auf natives VSCodium immer noch nichts gefunden wird, wirklich neu installieren (Node LTS + Rust via rustup + `wasm-pack`, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml` durchlaufen lassen). Ohne Toolchain ist die in `CONVENTIONS.md` vorgeschriebene Verifikation (`tsc`/`vitest`/`cargo test`/`wasm-pack`) nicht möglich — das zuerst klären, bevor einer der drei Slices neu beauftragt wird, sonst baut ein Agent blind ohne Verifikation.
|
||||
|
||||
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (roher Chat-Export dieser Session, lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung (vermutlich von einem `npm install` auf diesem Gerät) waren dirty/untracked. Die txt-Datei gehört NICHT ins Repo (Konvention: keine KI-Spuren) — außerhalb des Repos verschieben oder löschen, sobald nichts mehr daraus gebraucht wird. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
|
||||
**Aufräum-Hinweis:** `Edit MEMORY md Added 1.txt` (lag unversioniert im Repo-Root) und die lokale `package-lock.json`-Änderung waren dirty/untracked. Die txt-Datei gehört nicht ins Repo — außerhalb verschieben oder löschen. `package-lock.json` prüfen (`git diff package-lock.json`) und nur committen, falls die Dependency-Änderung beabsichtigt ist.
|
||||
|
||||
### Empfohlene Reihenfolge zum Wiederaufsetzen
|
||||
1. Nach dem Wechsel auf natives VSCodium zuerst `which node npm cargo wasm-pack` prüfen — evtl. war es nur die Flatpak-Sandbox, die die Toolchain versteckt hat. Nur falls immer noch nichts gefunden wird: Node + Rust + wasm-pack installieren + `npm install`. Blocker für alles Weitere.
|
||||
@@ -75,10 +75,9 @@ Die letzte Session endete nicht durch normales Handover, sondern weil das monatl
|
||||
WASM-Workflow: `src/engine/pkg3d/` ist gitignore't → nach Rust-Änderung `npm run build:engine3d`, nur die `.rs` committen. DOSSIER-Audit-Details jetzt in `docs/design/dossier-feature-audit.md` (A1–A6/B1–B4/C/D/E) + `ROADMAP.md` §11.
|
||||
|
||||
## Konventionen (ZWINGEND — zuerst lesen)
|
||||
- **Keine KI-/Assistant-Spuren** im Repo: vor jedem Commit `git diff | grep -iE "claude|anthropic|opus|sonnet|generated with|co-authored|gpt|openai|assistant"` → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
|
||||
- **Keine Fremd-Tool-Spuren** im Repo: vor jedem Commit Trace-Scan (`git diff | grep -iE "co-authored|generated with"`) → muss leer sein. Commits deutsch, sachlich, KEINE Co-Authored-By-Trailer.
|
||||
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
|
||||
- **Agenten-Regeln:** Subagenten default **Sonnet** (Nutzer-Wunsch 2026-07-04); Opus nur bei intricater Geometrie. Agenten dürfen NICHT weiterdelegieren (Agent/Task-Tool verbieten — sonst Spawn-Schleifen), NICHT committen, keine Memory. Hauptinstanz verifiziert (`tsc --noEmit` clean + `vitest run`) + trace-scan, committet dann selbst, staged nur die Task-Dateien (Kollisionen: parallelen Agenten disjunkte Datei-Lanes geben; i18n exklusiv einem Agenten).
|
||||
- **Budget:** Session-Ende bei 98% — Sparflamme.
|
||||
- **Verifikation:** `tsc --noEmit` clean + `vitest run` + trace-scan vor jedem Commit. Nur Task-Dateien stagen (Kollisionen: parallele Tasks disjunkte Datei-Lanes geben; i18n exklusiv einem Task).
|
||||
- **Engine = „Nordstern"** (render2d/render3d/WASM). Referenz DOSSIER-Rhino: `https://git.openbureau.ch/karim/dossier` (Alias git.kgva.ch), geklont `/tmp/dossier-ref`.
|
||||
|
||||
## In dieser Session gelandet (Auswahl Commits)
|
||||
@@ -86,7 +85,7 @@ Schraffur/Linien-Epic komplett: Typmodell (vector/image Hatch, dash/zigzag/custo
|
||||
Weiter: Gehrung spitze Winkel (2D+Schnitt+Rust), TopBar/Footer-Reorg, Zoom-% am Massstab, Tool-Shortcuts 1..0, Verschiebe-Dreieck flacher + Snap, Statusleiste „Nordstern", ResourceManager als **floating nicht-modales Fenster**, **swisstopo** Gebäude(extrudiert)+Terrain-Mesh in 3D (`f22970f`), Schnitt-Wandschicht-Orientierung (`7569524`), **Schnitt-Boolean-Dominanz** nach joinPriority (`23d81be`).
|
||||
|
||||
## Backlog & Rückfragen → `PENDENZEN.md`
|
||||
Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll (Worker liest zuerst PENDENZEN, arbeitet top-down, hakt ab, Rückfrage-Recht via ❓).
|
||||
Der frühere „Offener Backlog"/„Offene Rückfragen"/„IN FLIGHT"-Abschnitt ist vollständig nach `PENDENZEN.md` migriert (Single Source of Truth). Dort steht auch das Arbeitsprotokoll.
|
||||
|
||||
## Verifikation
|
||||
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
|
||||
|
||||
+39
-13
@@ -5,14 +5,14 @@
|
||||
|
||||
## Arbeitsprotokoll (ZWINGEND)
|
||||
|
||||
**Worker-Instanz** (führt aus):
|
||||
**Bearbeiter:**
|
||||
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
|
||||
2. Oberstes nicht-blockiertes Item aus **🔧 In Arbeit** bzw. sonst **⏭️ Als Nächstes** nehmen, **fertig** machen, verifizieren (`npx tsc --noEmit` + `npx vitest run` + trace-scan), abhaken, eine Ergebniszeile nach **✅ Erledigt** schreiben (mit Commit-Hash, sobald committet).
|
||||
3. **Rückfrage-Recht:** Bei Unklarheit/Design-Entscheid NICHT raten — Item mit `❓` markieren, kurze Frage darunter notieren, nächstes freies Item nehmen. Fragen sammeln sich unter **❓ Offene Rückfragen**.
|
||||
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
|
||||
5. Worker **committet nicht selbst** und delegiert nicht weiter (Agent/Task verboten) — das macht die Hauptinstanz. (Konvention HANDOVER.)
|
||||
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
|
||||
|
||||
**Planungs-Instanz** (mit dem Nutzer, kuratiert):
|
||||
**Planer** (mit dem Nutzer, kuratiert):
|
||||
- Nimmt Nutzer-Feedback, pflegt daraus die Liste (rein/umpriorisieren/aufspalten). Führt selbst keine Queue-Items aus.
|
||||
- Prüft **❓**-Zeilen mit dem Nutzer, wandelt sie in konkrete Items.
|
||||
|
||||
@@ -20,31 +20,52 @@
|
||||
|
||||
## ⛔ Blocker (zuerst klären)
|
||||
|
||||
- [ ] **Toolchain prüfen:** `which node npm cargo wasm-pack`. Auf dem alten Gerät fehlten sie (vermutlich Flatpak-Sandbox von VSCodium). Nutzer wechselt auf natives VSCodium → evtl. behoben. Nur falls immer noch nichts: Node LTS + Rust (rustup) + `wasm-pack` installieren, `npm install`, einmal `cargo build --manifest-path src-tauri/render3d/Cargo.toml`. **Ohne Toolchain keine Verifikation → keinen Engine-Slice blind bauen.** | Detail: HANDOVER §Nachtrag-3b, Environment-Lücke
|
||||
- [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
|
||||
|
||||
_(leer — Worker trägt hier ein, was er gerade anpackt)_
|
||||
- [ ] **kernel2d-Port nach Rust/WASM** (Plan: [PORT_PLAN.md](PORT_PLAN.md), Crate `src-tauri/kernel2d`, TS-Referenz bleibt `kernel2d.ts`, Differential-Harness `src/geometry/kernel2d.parity.test.ts`). Fortschritt:
|
||||
- [x] Phase 1: Crate-Skelett + WASM-Fassade + `build:kernel2d` — `a78e7c7`
|
||||
- [x] Phase 2: Primitive/Schnitt/Fläche/Kreis + Batch-Fassaden + Diff-Harness (Zufall+Golden) — `9e2c521`
|
||||
- [x] Phase 3: Offset (Miter + 1e-9-Fallback) + Fillet — `c8ea2bf`
|
||||
- [x] Phase 4: Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — Löwenanteil) — `28471c1`
|
||||
- [x] Phase 5: `roomArea`-Flächen/`ceiling`/`stair`/`roomBoundary` portiert (vitest 263, 33 Parity) — `a608a59`. **Offen in Phase 5:** `opening` (mit Aufrufstellen-Änderung) — noch nicht portiert.
|
||||
- [ ] 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).
|
||||
|
||||
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
|
||||
|
||||
- [ ] **Snap an Wand-Schichttrennlinien** (war Agent-Slice, Session 3): `git status`/`git log` prüfen — sollte `src/tools/snapping.ts` (+ ggf. `src/model/geometry.ts` + Test) geändert haben. Falls nicht gelandet: neu beauftragen (Schicht-Offsetlinien aus `addWallPoche`-Logik in generatePlan spiegeln [nur LESEN], als Snap-Targets einspeisen). | Detail: HANDOVER §IN FLIGHT Session-Ende
|
||||
- [x] ~~**Snap an Wand-Schichttrennlinien**~~ — **gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
|
||||
|
||||
## ⏭️ Als Nächstes
|
||||
|
||||
_(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
|
||||
- [ ] **SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen).** Kleinster ehrlicher Durchstich: beweisen, dass der wgpu-3D-Renderer echte Bild-Texturen auf Wandflächen zeigt, sichtbar im `spike3d`-Fenster. **Voller Auftrag/Übergabe: [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md)** (Ausgangslage verifiziert, Symbol-Karte, exakter Umfang, harte Randbedingungen, Akzeptanz, Abschlussbericht). Kurz: UVs auf Wandflächen (planar in Metern, Alt-Pfad `[pos,normal,color]` bitgleich unangetastet) · prozedurales 256×256-Schachbrett (kein Asset, kein `image`-Crate) · Textur-Bind-Group group 1 (Globals bleibt group 0) · `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus `textureSample`) · Verdrahtung bei `RenderStyle::Textured` · `spike3d` umschaltbar (`T`). **Nur `render3d`-Crate, keine neuen Deps, Default-Build unverändert.** Akzeptanz: `cargo test` grün (Mesh-Regression + naga-Test für `MESH_TEXTURED_WGSL`), `spike3d` zeigt weltmassstäbliche Textur. Ist der **erste Durchstich der Textur-/PBR-Pipeline** (§3D-REST unten) → am Ende ehrliche Lückenliste + Aufwandsschätzung für „richtig gutes" Texturing.
|
||||
|
||||
## 📋 Backlog (Priorität grob absteigend)
|
||||
|
||||
- [ ] **Ebene-Schraffur editierbar:** „Nach Ebene"-Schraffur ist im Resolve fertig, aber `LayerCategory.hatch` im Kategorie-Dialog (`App.tsx` ~3350, editiert nur name/color/lw) noch nicht editierbar. Kleine Folge-Arbeit.
|
||||
- [ ] **BAUTEILE aufs Rhino-Niveau heben (Treppe/Fenster/Tür).** Vergleich Rhino-Plugin ↔ TS + priorisierte Ansätze: **[RESEARCH_BAUTEILE_RHINO.md](RESEARCH_BAUTEILE_RHINO.md)**. Gruppe A (2D, sofort, kernel2d): (1) **Treppe Aussenlinie/Outline** (gerade/L/Wendel), (2) **Fenster Brüstungslinie** im Plan, (3) **Tür Sturzlinien** (gestrichelt, SIA), (4) Treppe Referenz links/mitte/rechts, (5) Fenster Flügel-Mittelpfosten, (6) Tür `wandoeffnung`-Typ. Gruppe B (2D mittel), Gruppe C (3D, nach Mesh-Pipeline/truck). **A-Items 1–3 fallen mit „Schnitt- vs. Ansichts-Darstellung" zusammen** (Sturz = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion).
|
||||
- [ ] **DWG/DXF-Import via `acadrust` (weiterbauen).** ✅ Spike `763a558`: `acadrust` 0.4 (MPL-2.0, pure Rust) **baut zu wasm32** (Crate `src-tauri/dwgimport`, 839 KB), parst DXF aus Byte-Buffer (`DxfReader::from_reader`+`Cursor`), headless getestet. **Offen:** (1) Entity→DOSSIER-Modell-Mapping (LINE/ARC/… → Wand/Öffnung — die eigentliche Domainarbeit, Wochen), (2) Datei-Upload-Glue im Browser (`<input type=file>`→Uint8Array→`parse_dxf_summary_json`, trivial), (3) DWG-binär (`DwgReader::from_reader` analog, aber R13–R2018-Korrektheit unverifiziert), (4) WASM-Größe (nalgebra Haupttreiber). **Klarstellung:** der TS-DXF/DWG-Import (`parseDxf`/`parseDwg`/`dxfToDrawings`) + Upload-UI (`App.tsx`, `ImportDialog.tsx`) existieren längst und funktionieren — der acadrust-Weg wäre eine Rust-Neuimplementierung des Lesens (nur DWG-**Schreiben** ist eine echte Lücke). ✅ **2887794 (2026-07-05): Kurven-Abdeckungslücke geschlossen** — `parseDxf` deckt jetzt ARC/CIRCLE/ELLIPSE (tesselliert zu Konturen, Winkel Radiant, voller Umlauf geschlossen) zusätzlich zu LINE/LWPOLYLINE/POLYLINE/MESH ab; 5 Tests, volle Suite 307 grün. ✅ **c481373 (2026-07-05): SPLINE + INSERT ergänzt** — `parseDxf` wertet SPLINE als echte B-Spline (De Boor, Grad/Knoten; Fallback fitPoints/Kontrollpolygon) aus und expandiert INSERT-Block-Referenzen (2D-Transform Scale/Rotation/Basispunkt + MINSERT-Array + verschachtelte Blöcke, Tiefe ≤8) zu transformierten Konturen; Kontur-Dispatch in gemeinsamen `collectContours` refaktoriert; +7 Tests, volle Suite 314 grün. **Bekannte Grenzen:** rationale SPLINE-Gewichte ignoriert (dxf-parser liefert sie nicht); Block-interne MESH/3DFACE-Entities werden im 2D-Import nicht expandiert. ✅ **c29f27e (2026-07-05): HATCH ergänzt** — dxf-parser hat KEINEN HATCH-Handler (verwarf HATCH stumm); Lösung via `registerEntityHandler` + eigenem `HatchHandler` (sammelt rohe Gruppencodes) + testbarer `hatchContours`-Auswertung: Randpfade (Polyline-Pfade + Linien-/Bogen-Kanten, Bögen über vorhandene Tessellierung) → geschlossene Konturen mit `Contour.filled`; `contoursToDrawings` macht daraus gefüllte `polyline`-Drawing2D (fillColor-Default, restylebar). +6 Tests, Suite 320 grün. **Bekannte Grenzen HATCH:** Bulges an Polyline-Rändern als Sehne; Ellipse-/Spline-Kanten übersprungen; Insel-Loops = eigene Ringe (keine echten Löcher). **Weiter offen (grob absteigend):** TEXT/MTEXT (→ editierbarer Plan-Text via Drawings-Import, Nutzer-bestätigt); dann Entity→Wand-Semantik (die dicke Domainarbeit); DWG-Schreiben (einzige echte Export-Lücke).
|
||||
- [ ] **STRATEGIE — „von BIM-Tool zu echtem CAD".** Direkt am Quellcode studierte Referenzen (OpenCADStudio/truck/acadrust) + Web-Import-Landkarte → konkrete, priorisierte Ansätze in **[RESEARCH_CAD_APPROACHES.md](RESEARCH_CAD_APPROACHES.md)**. Kern: (1) generisches Entity-Modell + Trait-Dispatch, (2) modeless Command-System (`StepInput`-Funnel + Kommandozeile), (3) DWG/DXF-Round-Trip via `acadrust` (MPL-2.0, pure Rust, WASM-tauglich), (4) volle Object-Snap-Schicht, (5) 3D-B-Rep später selektiv via `truck` (Apache-2.0, Geometrie-Crates WASM-fähig, Booleans/Fillets noch instabil). Der `kernel2d`-Rust/WASM-Kurs ist damit bestätigt. **Mit Nutzer priorisieren, welcher Ansatz zuerst.**
|
||||
|
||||
- [ ] **Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe (BIM-Standard).** Kern-Idee (Nutzer): jedes Bauteil bekommt getrennt eine **Schnittlinie** (kräftig, z. B. 0.25–0.35 mm, + Schnitt-Poché) und eine **Ansichtslinie** (Haarlinie); WELCHE gilt, entscheidet die z-Ausdehnung des Bauteils vs. die **Schnitthöhe** des Grundrisses (Default ~1 m):
|
||||
- Bauteil wird von der Schnittebene GESCHNITTEN → Schnittlinie + Schnitt-Poché (heutiges Wandverhalten).
|
||||
- Bauteil liegt ganz UNTER der Ebene (z. B. 30-cm-Wand bei 1 m Schnitthöhe, Brüstung, Podest) → nur „von oben gesehen" = **Ansichtslinie/Haarlinie, KEINE Schnitt-Poché**. ← genau der vom Nutzer genannte Fall.
|
||||
- Bauteil liegt ganz ÜBER der Ebene (Decke/Slab, Unterzug) → Ansicht, üblicherweise **gestrichelte** Überkopf-Haarlinie.
|
||||
- **Passt konsistent zum bereits existierenden `viewHatchId` (Ansichts-Schraffur) vs. Schnitt-Schraffur** — die Linienstärke-Dualität (View-/Cut-Weight je Component) ist die natürliche Erweiterung derselben Logik.
|
||||
- **Nicht nur cut/view, sondern cut / AUFSICHT / UNTERSICHT (Nutzer):** ein Bauteil sieht von oben anders aus als von unten. Der Grundriss (Blick nach unten) zeigt Bauteile UNTER der Ebene in **Aufsicht** (Oberseite); ein **Deckenspiegel/Reflected Ceiling Plan** (Blick nach oben) zeigt Bauteile ÜBER der Ebene in **Untersicht** (Unterseite — z. B. Kassettendecke, Leuchten). Also je Component potenziell **drei** Darstellungs-Slots (Schnitt / Aufsicht / Untersicht) × {Schraffur + Linienstärke}. Das heutige `viewHatchId` ist faktisch EIN View-Slot und vermischt Auf-/Untersicht; sauber wäre die Trennung. WELCHER Slot gilt, entscheidet die **Blickrichtung der Sicht** (Grundriss ↓ / Deckenspiegel ↑) UND die z-Lage relativ zur Schnittebene.
|
||||
- **Verallgemeinert den Decken-Footprint-Clip** (`a2f6923`): „Decke unter Wand verdeckt" ist ein Spezialfall von „Ansichtsbauteil vs. schneidende/überdeckende Bauteile".
|
||||
- Aufwand: mittel–groß, phasenweise machbar (1: z-Extent-vs-Schnitthöhe-Klassifikation cut/above/below; 2: Ansichtslinie-Weight je Component + Haarlinie/gestrichelt; 3: 30-cm-Wände & Slabs verdrahten). Nicht zu komplex im Konzept — es ist der reguläre CAD/BIM-Weg (ArchiCAD/Vectorworks/Revit). Mit Nutzer Detailgrade/Defaults festlegen.
|
||||
|
||||
|
||||
- [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):
|
||||
- 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.
|
||||
- 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); Building-Draping; höhere DTM-Auflösung.
|
||||
- [ ] **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
|
||||
- [ ] **Einstellungs-Fenster:** Auswahlrahmen Default **Yuyake #CEB188**; Snap/Endpunkt Default = ❓„aki" (s.u.); freier Picker + Accents-Presets; verdrahten in PlanView (Selektionsrahmen + Snap-Marker). Dazu Projekt-MüM-Feld.
|
||||
- [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.
|
||||
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||
- [ ] **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
|
||||
@@ -53,14 +74,14 @@ _(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
|
||||
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||
|
||||
### 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).
|
||||
- [ ] **Wand-Schicht-Bänder in 3D** Option B (jede Materiallage als eigener extrudierter Teilquader statt repräsentativer Farbe — pro-Schicht-Farben in `RWall.layers` liegen vor).
|
||||
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). **Erster Durchstich als Spike terminiert → [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md) (steht unter ⏭️ Als Nächstes).**
|
||||
- [x] ~~**Wand-Schicht-Bänder in 3D** Option B~~ — **bereits umgesetzt** in `resolveWallBands(layered=true)` (`src/plan/toWalls3d.ts:236-241`): 3D-Viewer-Pfad liefert je Materiallage ein eigenes `WallBand` (Dicke + Component-Albedo + Normalen-Versatz), `pushSegment` emittiert jede Lage als eigene Voll-Box. `dominantLayerColor` ist nur noch Fallback für den Schnitt-Einkörper-Pfad (`layered=false`). Verifiziert per Code-Lesung.
|
||||
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
||||
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||
|
||||
## ❓ Offene Rückfragen (an den Nutzer)
|
||||
|
||||
- [ ] **„aki"** = welcher Accent für Snap/Endpunkt-Farbe? (nicht in Accents: Ajisai/Sakura/Suna/Ichigo/Yuyake/Sora/Kusa/Kori/Amagumo/Yuki; vorläufig Sora #5FA1C9)
|
||||
- [x] ~~**„aki"** Snap/Endpunkt-Farbe~~ — **entschieden 2026-07-04: Sora #5FA1C9** als endgültiger Default festgeschrieben (`src/theme/accents.ts` `DEFAULT_SNAP_COLOR`, Doc aktualisiert). Frage geschlossen.
|
||||
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
|
||||
- [ ] Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering?
|
||||
- [ ] Verifizieren/klären: Isometrie „echte" orthographische Iso (teilw. durch Locked-Iso `cb8fae5` adressiert)? · TopBar-Detailgrad (zoom% ganz aus Footer)? · DOSSIER-Audit welche Features konkret übernommen?
|
||||
@@ -71,6 +92,11 @@ _(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
|
||||
|
||||
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
||||
|
||||
- [x] 2026-07-05 **Textur-Spike render3d** (`RenderStyle::Textured` real) — zweiter Vertex-Pfad `[pos,normal,uv]` aus dem Mesh abgeleitet (Alt-Pfad bitgleich, per Test belegt), prozedurales 256×256-Schachbrett (kein Asset/`image`-Crate), Textur-Bind-Group group 1, `MESH_TEXTURED_WGSL` (gleiche Beleuchtung, Albedo aus Sampler), Pipeline bitidentisch zur Haupt-Pipeline, `spike3d` per `T` umschaltbar. Verifiziert: cargo test 58/59 (inkl. naga-Test) grün, alle 4 Builds sauber. **Visuelle Fenster-Abnahme durch Nutzer bestanden 2026-07-05** (Schachbrett korrekt, `T`-Umschaltung ok). Erster Durchstich der Textur-/PBR-Pipeline; ehrliche Lückenliste im Bericht — `8556037`
|
||||
- [x] 2026-07-04 **Grundriss: Decke drückt nicht mehr durch die Wände** — Decken-Umriss an Wand-Footprints (OBB) geclippt; Füllfläche strokelos, Umriss nur über unverdeckte Teilstücke (Überstände). Deckungsgleiche Decke ⇒ Umriss entfällt. vitest 230, tsc sauber — `a2f6923`
|
||||
- [x] 2026-07-04 **T-Stoss-Putznaht entfernt** — L-Seitenlinie nur noch bei materialFREMDEM Nah-Putz; materialgleicher Putz verschmilzt nahtlos (Nutzer-Direktive „Naht entfernen"). TS+Rust synchron, 3 Tests gezogen, cargo 8/8 — `a5ebfa7`
|
||||
- [x] 2026-07-04 **Snap-Farbe entschieden: Sora #5FA1C9** als endgültiger Default festgeschrieben (`DEFAULT_SNAP_COLOR`, Doc), „aki"-❓ geschlossen.
|
||||
- [x] 2026-07-04 **Mac-Build via Tauri + Queue-Abgleich** — Toolchain auf macOS-Gerät verifiziert, `cad.app`/`cad_0.1.0_aarch64.dmg` gebaut & gestartet. Verifikations-Baseline grün (tsc/vitest 230/cargo 56). Queue-Reconciliation: Toolchain-Blocker, Snap-Slice (`339202b`), Ebene-Schraffur (`dcb6ed5`), Bauteile-Tab-Master-Detail, Einstellungs-Verdrahtung UND Wand-Schicht-Bänder 3D (Option B, `toWalls3d.ts`) waren allesamt **bereits gelandet**, aber in PENDENZEN noch als offen gelistet → abgehakt. (kein Code-Commit)
|
||||
- [x] 2026-07-04 **Öffnungen als echte Boolean-Löcher** (+ Deckentrim-nur-3D) — Fenster/Türen = 1 Wandkörper mit rechteckigen `holes` (Rechteck-Gitter-Zerlegung + Laibungsquads), Segment-Boxen weg; Deckentrim nur noch im 3D-Pfad, Schnitt volle Höhe. Pflicht-Testfall versetzt-überlappende Fenster grün. cargo test 56, vitest 230, build:engine3d + tsc sauber — `1407c68`
|
||||
- [x] 2026-07-04 **Joins Phase 1c** Durchgangswand am T-Stoss echt aufbrechen (spanCutouts) — `c5a344d`
|
||||
- [x] 2026-07-04 **Locked-Iso-Fix** freie Kamera bleibt nach Ortho-Preset orthografisch — `cb8fae5`
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# PORT_PLAN — kernel2d nach Rust/WASM (hinter identischem TS-Interface)
|
||||
|
||||
Stand: 2026-07-04. **Dieser Plan ist der geforderte erste Schritt — noch kein Code.**
|
||||
Ziel: `src/geometry/kernel2d.ts` (+ reine Geometrie aus room/ceiling/opening/stair) in
|
||||
ein Rust-Crate portieren, zu WASM bauen, hinter einer TS-Fassade mit *exakt gleichen*
|
||||
Signaturen einhängen. TS-Kernel bleibt als Referenz (`kernel2d.legacy.ts`).
|
||||
Differential-Test: Rust-WASM == TS-Legacy auf identischen Eingaben (epsilon je Funktion).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope-Inventar (was wirklich portiert wird)
|
||||
|
||||
**Externe JS-Geometrie-Libs: KEINE.** Der Kernel ist handgeschriebene f64-Mathematik,
|
||||
einzige Abhängigkeit sind die Vektor-Helfer aus `src/model/geometry.ts`
|
||||
(`add/sub/scale/len/normalize/leftNormal/cross/dot/lineIntersect`) — trivial mit zu
|
||||
portieren. `polygon-clipping`/`delaunator` werden im Kernel **nicht** genutzt.
|
||||
|
||||
### Voll im Scope (reine Geometrie)
|
||||
- **`kernel2d.ts`** — komplett: Primitive, Schnitt, Offset, Trim/Split/Join, Kreis, Fläche/Orientierung, Fillet.
|
||||
- **`roomBoundary.ts`** — komplett: `detectRooms`, `roomFromPointInside(Faces)`, `pointInPolygon`, `WallSegment`/`WallFace`/`DetectRoomsOptions` (generische Geometrie-Typen, `thickness: number`).
|
||||
- **`ceiling.ts`** — komplett: `normalizeOutline`, `isValidOutline`, `ceilingArea`, `outlineBBox`, `outlineCentroid`, `pointInOutline` (generische Polygon-Utilities; Name irreführend, keine Decken-Semantik).
|
||||
|
||||
### Teilweise im Scope
|
||||
- **`roomArea.ts`** — NUR `signedArea`, `polygonArea`, `perimeter`, `centroid`. Alles ab `SiaCategory` (`siaLabel`, `evaluateRoom`, `balance`, `roomsToCsv`) ist SIA-416-Domänenlogik/CSV → **bleibt TS**.
|
||||
- **`stair.ts`** — portierbar, aber `stairGeometry` nimmt `Stair`. „Scheinkopplung": es werden nur geometrische Felder gelesen (`shape/start/dir/runLength/width/stepCount/…`), `totalRise` kommt bereits aufgelöst vom Aufrufer. Port via Rust-Struct `StairParams` (strukturgleich, null Model-Semantik).
|
||||
|
||||
### ⚠️ Scope-Spannung: `opening.ts` (im Auftrag genannt, aber stark model-gekoppelt)
|
||||
7 von 9 Exporten binden `Wall`/`Opening` direkt ein; `getWallType`/`wallTypeThickness`/`wallReferenceOffset`/`wallVerticalExtent` lösen gegen `Project` auf.
|
||||
- **Portierbar mit abgeflachter Signatur** (Vec2/number statt Wall/Opening): `wallAxisLength`, `openingInterval`, `wallAxisFrame`, `openingJambs`, `openingCenter`, `doorSymbol`, `openingGapQuad`/`windowSymbol` (letztere brauchen `thickness`/`refOff` als `number`-Parameter).
|
||||
- **Bleibt TS** (braucht `Project`/Geschoss-Auflösung): `openingVerticalExtent` (via `wallVerticalExtent` → `Project.drawingLevels`).
|
||||
- **→ Entscheidung nötig** (siehe §7): Der Auftrag verbietet Änderungen an Aufrufstellen außer dem Import-Pfad. Ein Port von `opening` würde die *Signaturen* ändern (Wall→Vec2/number) und damit die Aufrufstellen brechen — das widerspricht „keine Änderung an Aufrufstellen". Empfehlung: **opening in Phase 1 ausklammern**, nur den echt reinen Kern (kernel2d/roomBoundary/ceiling/roomArea-Flächen/stair) portieren.
|
||||
|
||||
---
|
||||
|
||||
## 2. Crate-vs-Portieren — pro Operation
|
||||
|
||||
Akzeptanzkriterium ist **Differential-Parität gegen die naive TS-Routine**, nicht „geometrisch besser". Jedes Fremd-Crate mit anderem Algorithmus bricht die Parität per Konstruktion → Default = **PORTIEREN** (TS-Routinen sind 5–40 Zeilen f64, 1:1 übersetzbar).
|
||||
|
||||
| Operation | geprüftes Crate | Entscheidung | Grund |
|
||||
|---|---|---|---|
|
||||
| `offsetPolyline`/`offsetSegment` | cavalier_contours 0.6 | **PORTIEREN** | Crate liefert **Arcs (bulge)** statt `Vec2[]`, **heilt Selbstschnitte**, gibt mehrere Polylinien — TS heilt bewusst NICHT. Semantik nicht angleichbar. |
|
||||
| Segment/Line-Schnitt | geo / robust | **PORTIEREN** | Cramer-Formel; jede andere denom-/Epsilon-Politik driftet in Parallel-Grenzfällen. |
|
||||
| Trim/Split/`segmentPolylineHits` | geo `Relate` | **PORTIEREN** | Projektspezifisch (t-Dedup `1e-6`, Pick-nächster-Bogen, Wrap-Logik). |
|
||||
| Kreis-Schnitte | — | **PORTIEREN** | Quadratik mit projekt-EPS-Disc-Klemmung. |
|
||||
| `signedArea`/`isCCW` | geo `Area` | **PORTIEREN** | Shoelace-Summierung in **identischer Vertex-Reihenfolge** (f64 nicht assoziativ). |
|
||||
| `filletCorner` | — | **PORTIEREN** | `acos/atan2/tan(θ/2)`-Kette + projekt-Cutoffs; liefert projekt-spezifische `Fillet`-Struktur. |
|
||||
| `detectRooms` / `joinChains` | geo / i_overlay | **PORTIEREN** | Topologie-/reihenfolgeabhängig; andere Kantendurchlauf-Reihenfolge → andere (gleich gültige) Ringe → Parität bricht. |
|
||||
| point-in-polygon | robust | **PORTIEREN** (robust nur intern, optional) | Siehe §3. |
|
||||
|
||||
Fremd-Crates (cavalier_contours/geo/i_overlay) wären für einen späteren *Tier-2-Rewrite* (echter Arc-Offset, Boolean-Ops) wertvoll — das ist ein **anderes Produkt**, nicht dieser paritätserhaltende Port.
|
||||
|
||||
---
|
||||
|
||||
## 3. `robust`-Prädikate vs. Differential-Parität (Spannung auflösen)
|
||||
|
||||
TS testet Orientierung überall via naives `cross()` gegen `EPS`. `robust::orient2d` liefert das **exakte** Vorzeichen — weicht von naiv **nur in der nahe-degenerierten Zone** ab (fast-parallele Segmente, Null-Fläche-Polygone, Punkt-auf-Kante). Genau dort schlägt der Diff-Test am ehesten an. Man kann nicht gleichzeitig „bit-Parität gegen naiv" und „robuste Prädikate" im *selben* Vergleich haben.
|
||||
|
||||
**Entscheidung:**
|
||||
1. **v1 portiert die naiven `cross`-Vergleiche 1:1** (KEIN `robust`) → Zufalls-Diff-Test wird bit-nah grün. Robustheit kommt aus denselben f64-Formeln + demselben `EPS` wie TS.
|
||||
2. **Additiv, getrennt:** `robust` nur *intern* in `detectRooms`/point-in-polygon hinter optionalem Feature `robust-predicates`, flankiert von **Golden-Cases, die die KORREKTE (robuste) Antwort asserten** (nicht TS-Parität). Diese Fälle sind aus dem Zufalls-Diff-Test ausgenommen.
|
||||
3. Zwei Testklassen, nie gemischt: **Zufalls-Parität = naiv**, **Golden-Korrektheit = robust**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Crate-Setup & Vite (exakter Klon von `src-tauri/geometry`)
|
||||
|
||||
Ort: **`src-tauri/kernel2d`** (analog render2d/render3d/geometry; das gesamte Tooling ist auf `src-tauri/<crate>` + `../../src/engine/pkg<X>` verdrahtet). *Namens-Hinweis:* Auftrag sagt `crates/kernel2d` — siehe §7.
|
||||
|
||||
- `Cargo.toml`: `crate-type = ["cdylib","rlib"]`; Features `default=[]`, `web=[wasm-bindgen, serde_json, console_error_panic_hook]`, `robust-predicates=[robust]` (additiv, nicht in web-Default).
|
||||
- `src-tauri/Cargo.toml`: `exclude = [..., "kernel2d"]` erweitern (sonst „multiple workspace roots").
|
||||
- `src/lib.rs`: reiner f64-Rechenkern feature-frei (`cargo test`-bar, headless) + `#[cfg(feature="web")]` JSON-**Batch**-Fassade pro Operation (`offset_polylines_json`, `intersect_batch_json`, `fillet_batch_json`, `circle_intersect_batch_json`, `detect_rooms_json`), Muster `compute_joins_json`.
|
||||
- `examples/parity.rs`: Klon von `geometry/examples/parity.rs` (Batch-JSON stdin→stdout) — nativer Diff-Kanal.
|
||||
- `package.json`: `"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web"`.
|
||||
- **Vite: kein Config-Eintrag nötig** — `--target web`-Pakete werden als normales ES-Modul importiert, Vite bündelt `kernel2d_bg.wasm` automatisch (`new URL(..., import.meta.url)`). `pkgKernel2d/` ist git-ignoriert (self-`.gitignore = *`) → vor `vitest`/`build` muss `build:kernel2d` laufen (CI-Schritt).
|
||||
- **TS-Fassade** `src/geometry/kernel2d.ts` wird dünner Wrapper (init-WASM, JSON-Marshalling, gleiche Exports); Alt-Impl → `src/geometry/kernel2d.legacy.ts` (nicht löschen, ist die Diff-Referenz).
|
||||
|
||||
---
|
||||
|
||||
## 5. Differential-Test-Harness
|
||||
|
||||
- **Grobkörnige WASM-Grenze:** eine Batch-Funktion je Operation (N Polylinien rein, N Ergebnisse raus). Keine Per-Punkt-Calls (jeder Call marshallt einen String = O(n)-Kopie).
|
||||
- **Diff-Kanal:** Der Auftrag verlangt **Rust-WASM** vs. TS-Legacy → primär WASM (via `initSync`). Zusätzlich `cargo run --example parity` (nativ) als schneller Sekundär-Kanal (bit-identisch zu WASM für `+ - * / sqrt`; **Ausnahme** `atan2/acos/tan` in Fillet: libm nativ ≠ wasm um letzte ULP → Winkel-Epsilon).
|
||||
- **vitest-Init synchron:** `initSync({ module: readFileSync(pkgKernel2d/kernel2d_bg.wasm) })` (kein `fetch`), einmal in `beforeAll`.
|
||||
- **Zufallsgeneratoren:** seed-basiert (Seed im Testnamen), Polylinien 3–20 Vertices, Koordinaten `[-100,100] m`, `closed`/`d` zufällig; zusätzlich Cluster nahe `0` und `1e-6..1e-3`, um Toleranzschwellen zu treffen.
|
||||
- **Vergleichsreihenfolge:** zuerst **Struktur exakt** (Array-Längen, closed-Flags, Punktzahl, null/nicht-null), dann Werte mit op-Epsilon. Struktur ist der schärfste Paritäts-Wächter.
|
||||
|
||||
### Epsilon pro Funktion
|
||||
| Größe | Toleranz | Grund |
|
||||
|---|---|---|
|
||||
| Punktkoordinaten (Offset/Trim/Split/Kreis) | `abs 1e-9` | Bestehender Paritätstest nutzt `1e-9` und besteht bit-nah. |
|
||||
| Fläche (`signedArea`) | `rel 1e-9·max(1,\|A\|)` | Shoelace ∝ coord² → absolute ULP wächst mit Flächengröße; relativ skaliert korrekt. |
|
||||
| Winkel (`filletCorner`) | `abs 1e-7 rad` | `atan2/acos` libm-abhängig (nativ↔wasm ULP-Drift); 1e-7 rad ≈ 5.7e-6°, weit unter Zeichenrelevanz. |
|
||||
| Parameter t/s | `abs 1e-9` | TS dedupliziert erst ab `1e-6` → kleinere Diffs ändern nie die Struktur. |
|
||||
| Struktur | **exakt** | Kein Epsilon. Hier bricht ein Fremd-Crate. |
|
||||
|
||||
### Golden-Cases (explizit, aus Zufallstest teils ausgenommen)
|
||||
kollineare Tripel · Null-Länge-Segmente (Dublett-Vertex) · Offset-Selbstschnitt (enges U, großes d — hier bräche cavalier_contours) · spitze Fillet-Winkel (θ→0) · fast-paralleler Schnitt (denom knapp <>EPS → **Korrektheits-Golden/robust**) · konzentrische/tangentiale Kreise · Punkt exakt auf Polygonkante (**Korrektheits-Golden**).
|
||||
|
||||
---
|
||||
|
||||
## 6. Kritische Paritäts-Details (MÜSSEN exakt repliziert werden)
|
||||
- `len` = `Math.hypot` → **`f64::hypot`** (nicht `(x²+y²).sqrt()`).
|
||||
- `normalize` Null-Guard: `len || 1` → `if l==0.0 {1.0} else {l}` (Ergebnis `{0,0}`, kein NaN).
|
||||
- **Zwei Epsilons:** `EPS=1e-7` (kernel2d) UND hartkodiert **`1e-9`** in `lineIntersect` (Offset-Miter-Fallback hängt daran — geometrischer Sprung, nicht epsilon).
|
||||
- Dedup-Schwelle `1e-6`, Fillet-Kollinearität `1e-4` (nicht EPS).
|
||||
- **Stabile Sortierung** (JS `Array.sort` ist stabil): Rust `sort_by`, nicht `sort_unstable_by`.
|
||||
- Term-Reihenfolge in `cross`, `signedArea`-Summierung, Kreis-Diskriminante `B*B−4*A*C` exakt beibehalten (f64 nicht assoziativ; kein Kahan/Reorder).
|
||||
- Modulo-Indizierung `(i+len-1)%len` (usize-Unterlauf vermeiden).
|
||||
- `joinChains`: greedy `i<j`-erster-Treffer-dann-Neustart exakt nachbilden (reihenfolgeabhängiges Ergebnis).
|
||||
|
||||
---
|
||||
|
||||
## 7. Offene Entscheidungen (vor Coding klären)
|
||||
1. **`opening` im Scope?** Portieren würde Signaturen (Wall→Vec2/number) und damit Aufrufstellen ändern — widerspricht „keine Änderung an Aufrufstellen außer Import-Pfad". **Empfehlung: opening in Phase 1 ausklammern.**
|
||||
2. **Crate-Ort:** Auftrag `crates/kernel2d` vs. Repo-Konvention `src-tauri/kernel2d` (analog render2d/render3d). **Empfehlung: `src-tauri/kernel2d`** (Tooling passt out-of-the-box).
|
||||
3. **Diff-Kanal:** Auftrag verlangt Rust-**WASM**; nativer `parity`-Kanal ist schneller (kein wasm-pack in CI) und bit-identisch außer Fillet-Transzendente. **Empfehlung: WASM primär (Auftragskonform) + nativ sekundär.**
|
||||
|
||||
## 8. Phasen (Reihenfolge)
|
||||
1. Crate-Skelett `src-tauri/kernel2d` + Build-Script + Workspace-exclude + leere WASM-Fassade → `build:kernel2d` grün.
|
||||
2. Primitive + Schnitt + Fläche + Kreis portieren (trivial–mittel) + Batch-Fassade + Diff-Test-Harness (Zufall+Golden) → grün.
|
||||
3. Offset (Miter+1e-9-Fallback) + Fillet portieren → Golden für Selbstschnitt/spitze Winkel grün.
|
||||
4. Trim/Split/Join (`trimPolyline`, `splitAtIntersections`, `joinChains` — der Löwenanteil) → Struktur-Golden grün.
|
||||
5. `roomBoundary` (`detectRooms`) + `ceiling` + `roomArea`-Flächen + `stair` (StairParams).
|
||||
6. TS-Fassade umstellen (Alt → `.legacy.ts`), Import-Pfade der Aufrufstellen unverändert lassen, bestehende Suite grün, `npm run build` + WASM-Build sauber.
|
||||
@@ -0,0 +1,43 @@
|
||||
# RESEARCH — Bauteile Treppe/Fenster/Tür: Rhino-Plugin als Vorbild
|
||||
|
||||
Stand: 2026-07-05. Vergleich der authoritativen Rhino-Plugin-Implementierung
|
||||
(`/Users/karim/PROJECTS/DOSSIER/rhino/`, C#/Python + RhinoCommon-B-Rep) mit
|
||||
DOSSIER-STANDALONEs vereinfachter TS-Umsetzung. Ziel: Bauteile aufs Rhino-Niveau
|
||||
heben. Belege = Rhino-Funktionsnamen.
|
||||
|
||||
**Grundsatz:** Rhino hat echten B-Rep-Kernel (3D). DOSSIER hat extrudierte Meshes +
|
||||
`kernel2d` (2D). Darum getrennt: **2D-Symbol-Logik** (leicht in TS/kernel2d
|
||||
übernehmbar) vs. **3D** (braucht Mesh-Pipeline bzw. später truck).
|
||||
|
||||
## Treppe (Rhino `treppe.py`, 1784 Z. — massiv reicher als `stair.ts`)
|
||||
- **Typen:** gerade / L (3- und 4-Punkt mit Podest) / Wendel (inkl. Spindel-Cone bei r_in<0.05). TS hat alle drei, aber simpler.
|
||||
- **3D-Querschnitt-Modi** `massiv`/`flach`/`plattenrand` (`_treppe_profile_2d`) — TS: nur Stufenboxen. **fehlt.**
|
||||
- **2D-Plansymbol** (`_make_treppe_2d_symbol`): Tritte, Lauflinie (4 Pfeil-Styles: klassisch/filled/breit/voll, visuell zentriert via `mid_off`), **Aussenlinie/Outline** (alle 3 Typen), Bruchlinie, Podest-Hexagon. TS: Tritte+Bruch+Lauflinie(1 Style) da; **Aussenlinie fehlt komplett**, Referenz links/mitte/rechts fehlt.
|
||||
- **Soll-Schrittmass** (2S+A, Lock), Show-Flags, Grips — fehlen in TS.
|
||||
|
||||
## Fenster (Rhino `elements.py` `_make_oeffnung_pieces`)
|
||||
- **3D:** Rahmen (BooleanDiff), Mittelpfosten je Flügel (1–4), Glas (einfach 12mm / Doppel 2×6+16), Sims aussen (4 Styles), Rahmen-Offset innen/mitte/aussen. TS: nur 2D-Rahmen+Glaslinien, Wand-Boolean-Loch (`1407c68`); kein Rahmen-3D.
|
||||
- **2D:** TS hat Rahmen + 1–2 Glaslinien (grob/mittel/fein). **Fehlt:** Brüstungslinie im Plan, Flügel-Mittelpfosten, Anschlag-Striche (nur Tür hat sie), Flügelanzahl.
|
||||
|
||||
## Tür (Rhino `elements.py`)
|
||||
- **Typen:** `normal` / `wandoeffnung` (reiner Durchbruch, kein Blatt). **Rahmen:** `zarge` (3-seitig, im Wandquerschnitt) / `block` (+5cm Überhang).
|
||||
- **2D-Schwung** (`_make_tuer_swing_curves`): Blatt+Arc, hinge_side/open_angle/aussenseite/swing_invert; Bogen-Anlage am Rahmen (standard/detail). **2D-Sturzlinien** (`_make_tuer_sturz_curves`, gestrichelt, Modi keine/innen/aussen/beide — SIA: Sturz über Schnittebene → gestrichelt). TS: Blatt+Arc+Anschlag (fein) da; **Sturzlinien fehlen komplett**, `wandoeffnung`-Typ + swing_invert fehlen.
|
||||
|
||||
---
|
||||
|
||||
## Gesamtreihenfolge „was zuerst" (Agenten-Empfehlung)
|
||||
|
||||
**Gruppe A — 2D-leicht, hoher Plan-Wert, kernel2d-kompatibel:**
|
||||
1. **Treppe Aussenlinie/Outline** (alle 3 Typen; gerade=4 Linien, L via Linienschnitt `_line_intersect_xy`, Wendel=2 Bögen+radiale). Ohne sie wirkt der Plan unfertig.
|
||||
2. **Fenster Brüstungslinie** im Plan (1 gepunktete Linie bei sillHeight>0). XS.
|
||||
3. **Tür Sturzlinien** (gestrichelt, SIA) — Schulfall der geplanten Schnitt/Ansichts-Logik.
|
||||
4. **Treppe Referenz links/mitte/rechts** + Lauflinie visuell zentriert.
|
||||
5. **Fenster Flügel-Mittelpfosten** (2D, `wingCount` ins Modell).
|
||||
6. **Tür `wandoeffnung`-Typ** (reine Öffnung ohne Blatt/Symbol).
|
||||
7. Treppe Show-Flags + `obere_dashed`; 8. Fenster Anschlag-Striche.
|
||||
|
||||
**Gruppe B — 2D mittel:** Tür-Schwung am Rahmen; Treppe Pfeil-Style `filled`; Fenster/Tür Styles/Presets; swing_invert.
|
||||
|
||||
**Gruppe C — 3D (nach Mesh-/B-Rep-Pipeline):** Treppe `flach`-Querschnitt; Rahmen als Mesh (Fenster/Tür); Türblatt+Glas; Sims; L-Podest-Hexagon; Wendel helikoidale Unterseite (truck).
|
||||
|
||||
**Bezug PENDENZEN:** Gruppe-A-Items 1–3 (Aussenlinie/Brüstung/Sturz) fallen in das Backlog-Item „Schnitt- vs. Ansichts-Darstellung nach Schnitthöhe" — Sturzlinie = Bauteil unter Schnittebene → gestrichelte Überkopf-Projektion. Gruppe C wartet auf den Textur-/Mesh-Pipeline-Nachfolger bzw. truck.
|
||||
@@ -0,0 +1,162 @@
|
||||
# RESEARCH — Von BIM-Tool zu „echtem CAD": Ansätze aus fortgeschrittenen Programmen
|
||||
|
||||
Stand: 2026-07-05. Direkt am Quellcode studiert (Repos nach `/tmp/cad-study` geklont,
|
||||
liegen NICHT im Projekt). Ziel: konkrete, verfolgbare Ansätze, um DOSSIER von einem
|
||||
spezialisierten BIM/Architektur-Werkzeug zu einem allgemeineren CAD zu erweitern.
|
||||
|
||||
## Studierte Programme
|
||||
|
||||
| Repo | Was | Lizenz | Stack | Für uns |
|
||||
|---|---|---|---|---|
|
||||
| [OpenCADStudio](https://github.com/HakanSeven12/OpenCADStudio) | Eigenständige 2D/3D-CAD-App, ~200k LOC Rust | GPL-3.0 | Iced (GUI) + wgpu + WASM-Web-Build | **App-Architektur-Blaupause** (Entity-Modell, Command-System, Snap, Grips). Fast unser Stack. |
|
||||
| [truck](https://github.com/ricosjp/truck) | Reiner Rust-CAD-**Kernel** (NURBS/B-Rep/Mesh/Boolean/STEP) | Apache-2.0 | 16 Crates, wgpu-Rendering + `truck-js` (WASM) | **3D-Kernel-Blaupause**. OpenCADStudio nutzt ihn als 3D-Backend. |
|
||||
| [acadrust](https://github.com/HakanSeven12/acadrust) | Pure-Rust **DWG+DXF** R13–R2018 read+write, 41 Entity-Typen | **MPL-2.0** | nom/byteorder/flate2/nalgebra/encoding_rs (alles pure Rust) | **DWG/DXF-Rückgrat**, MPL-2.0 nutzbar, sehr wahrscheinlich WASM-baubar. |
|
||||
|
||||
**Lizenz-Klarstellung:** `acadrust` ist **MPL-2.0** (file-level copyleft — für uns nutzbar,
|
||||
keine GPL-Ansteckung), NICHT GPL. `truck` ist Apache-2.0 (frei nutzbar). Nur OpenCADStudio
|
||||
selbst ist GPL-3.0 — wir lesen es als **Architektur-Referenz**, kopieren keinen Code.
|
||||
|
||||
---
|
||||
|
||||
## Die zentrale Lektion: was ein „echtes CAD" architektonisch ausmacht
|
||||
|
||||
DOSSIER ist heute **domänenzentriert** (Wand/Öffnung/Component/Plan-Pipeline, TS-Modell).
|
||||
Ein echtes CAD (Rhino/AutoCAD-artig) hat stattdessen einen **generischen Kern**. Aus
|
||||
OpenCADStudios `src/` destilliert:
|
||||
|
||||
### 1. Generisches Entity-Modell mit Traits (statt Wand-Spezialfall)
|
||||
`src/entities/` = 41 Entity-Typen (line, arc, circle, lwpolyline, spline, ellipse, hatch,
|
||||
text, mtext, dimension, leader, insert/block, table, viewport, mesh, solid3d, …). Jeder
|
||||
Typ implementiert ein **Trait-Set** statt einer Sonderbehandlung:
|
||||
- `TruckConvertible` → 3D-Kernel-Geometrie
|
||||
- `FallbackTess` → Display-Geometrie (Punkte/Snap-Vertices)
|
||||
- `Grippable` → editierbare Griffe + Hover-Menü (Add Vertex / Convert to Arc / Reverse / Lengthen…)
|
||||
- `PropertyEditable` → Eigenschaften-Panel
|
||||
- `Transformable` → Move/Rotate/Scale/Mirror
|
||||
- `MassProps` → Fläche/Umfang für Abfragen
|
||||
|
||||
→ **DOSSIER-Lehre:** ein generisches `Entity`-Modell mit Trait-Dispatch neben das BIM-Modell
|
||||
stellen. Wir haben Ansätze (`Drawing2D`, splitJoin-Editoren, kernel2d), aber keine einheitliche
|
||||
Entity-Taxonomie mit Traits.
|
||||
|
||||
### 2. Modeless Command-System — der eine `StepInput`-Step-Machine
|
||||
`src/command.rs`: EIN Enum `StepInput { Point / Text / EntityPick / StructurePick /
|
||||
SelectionComplete }`. **Jede** Eingabequelle (Kommandozeile, Viewport-Klick, Pick, Selektion,
|
||||
Dynamic Input, Plugin-API, Headless-Script) übersetzt in `StepInput` und läuft durch **eine**
|
||||
`feed_command`-Funktion → treibt die aktive `CadCommand`-Step-Machine, egal woher der Schritt kam.
|
||||
|
||||
→ **DOSSIER-Lehre:** Das ist der größte UX-Unterschied. Echtes CAD = „Befehl tippen → Punkte
|
||||
picken → Optionen". DOSSIER ist heute Tool-Button + Direktmanipulation. Ein `StepInput`-Funnel +
|
||||
Kommandozeile wäre der Hebel, um beliebige Werkzeuge einheitlich, scriptbar und headless-testbar
|
||||
zu machen.
|
||||
|
||||
### 3. Object-Snap-Engine (eigenständig, groß)
|
||||
`src/snap.rs` = 70 KB nur für OSNAP (endpoint/midpoint/center/intersection/tangent/
|
||||
perpendicular/…). Wir haben Teil-Snapping; ein echtes CAD hat eine dedizierte, vollständige
|
||||
Snap-Schicht.
|
||||
|
||||
### 4. Universelle Grip-Editierung
|
||||
Jede Entity liefert Griffe + kontextuelle Griff-Menüs mit teils numerischem Follow-up
|
||||
(„Lengthen" fragt Wert an der Kommandozeile ab). Einheitlich über das `Grippable`-Trait.
|
||||
|
||||
### 5. `CadDocument` als Dokumentmodell = das DWG/DXF-Objektmodell
|
||||
OpenCADStudio nutzt `acadrust::CadDocument` DIREKT als sein Datenmodell (Entities + Objects +
|
||||
Tables: Layer/Linetype/Style/Blocks + XData). D. h. das native CAD-Austauschformat IST das
|
||||
interne Modell → verlustfreier Round-Trip „for free".
|
||||
|
||||
---
|
||||
|
||||
## truck — 3D-Kernel-Blaupause (+ harte WASM-Realität)
|
||||
|
||||
Saubere Schichtung (Ship-of-Theseus, kleine ersetzbare Crates):
|
||||
|
||||
```
|
||||
truck-base Basistraits/Toleranz (cgmath)
|
||||
truck-geotrait ParametricCurve / ParametricSurface Traits
|
||||
truck-geometry Knot-Vektor, B-Spline, NURBS ← Kurven/Flächen
|
||||
truck-topology vertex/edge/wire/face/shell/solid ← B-Rep-Topologie
|
||||
truck-modeling Geometrie + Topologie integriert ← Solids bauen
|
||||
truck-polymesh Polygon-Datenstruktur + Meshing
|
||||
truck-meshalgo Tessellation der Shapes
|
||||
truck-shapeops Boolean-Ops auf Solids (v0.4 — früh)
|
||||
truck-stepio STEP read/write (v0.3)
|
||||
truck-platform wgpu-Grafik-Utility ┐ Rendering — brauchen wir NICHT
|
||||
truck-rendimpl Shape/Mesh-Visualisierung ┘ (wir haben eigenen wgpu-Renderer „Nordstern")
|
||||
truck-js WASM-Wrapper (v0.2)
|
||||
```
|
||||
|
||||
**WASM-Realität (zwei Befunde abgeglichen):**
|
||||
- OpenCADStudio pinnt `truck-meshalgo` 0.4 und schaltet den `solid3d`-Feature **im WASM-Build ab**,
|
||||
weil dessen ACIS-/vtkio-Pfad über `xz2 → lzma-sys` (C-Bibliothek) nicht nach wasm32 kommt →
|
||||
**im Web ist OpenCADStudio nur 2D.**
|
||||
- Neuere `truck-meshalgo` (0.6) gated `rayon`/`vtkio` selbst per `cfg(not(target_arch="wasm32"))`
|
||||
aus → **baut zu wasm32, aber single-threaded**. Der reine Geometrie-Stack
|
||||
(base → geotrait → geometry → topology → polymesh → meshalgo → modeling → shapeops) hat keine
|
||||
native-only-Deps und ist **sauber vom wgpu-Rendering trennbar** (truck-platform/-rendimpl weglassen).
|
||||
|
||||
**Reifegrad (Apache-2.0, aktiv, ~1 Hauptentwickler; ausdrücklich KEIN Produktionskern):**
|
||||
- NURBS/B-Spline + B-Rep-Topologie: **stabil** (v0.5/0.6).
|
||||
- Booleans (`truck-shapeops`): `and`/`or` vorhanden, aber **instabil bei coincident geometry**
|
||||
(Issue #114); **`difference` fehlt** (#85); **Fillets fehlen komplett**. Der Fork
|
||||
[`monstertruck`](https://github.com/virtualritz/monstertruck) hat difference + rolling-ball-Fillet.
|
||||
→ Für unsere Wandöffnungen (boolean holes) haben wir das ohnehin schon selbst gelöst (`spanCutouts`).
|
||||
- `truck-stepio`: STEP **schreiben** (aber NICHT für boolean-operierte Shapes) + **lesen** (v0.6 beta,
|
||||
neues `truck_stepio::in`).
|
||||
- `truck-js`: fertiger wasm-bindgen-Wrapper (kein npm-Paket → selbst per `wasm-pack` bauen), bündelt
|
||||
modeling/shapeops/meshalgo/stepio; `IntoWasm`-Muster.
|
||||
|
||||
---
|
||||
|
||||
## acadrust — DWG/DXF-Rückgrat
|
||||
|
||||
Pure Rust, **MPL-2.0**, `CadDocument`-Objektmodell (entities/objects/tables/xdata/classes),
|
||||
41 Entity-Typen, DWG „208/208 roundtrip-perfect", serde optional, failsafe-Parsing, ~40 Codepages.
|
||||
**Alle Deps pure Rust** (nom/byteorder/flate2-miniz/nalgebra/indexmap/ahash/encoding_rs) → sehr
|
||||
wahrscheinlich zu wasm32 baubar (Datei-I/O-API müsste auf Bytes/Reader statt `std::fs` umgestellt
|
||||
werden — vermutlich schon vorhanden). Heute nutzen wir JS `dxf-parser` (nur 2D) + `libredwg-web`.
|
||||
|
||||
---
|
||||
|
||||
## Web-Import-Landkarte (für den GEO-BLOCK / Import-Backlog)
|
||||
|
||||
Pro Format die aktive, web-/WASM-taugliche Lib (Stand Mitte 2026):
|
||||
|
||||
| Format | Empfohlene Lib | Lizenz | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| DWG/DXF (browser) | `@mlightcad/cad-viewer` / `libredwg-web` (nutzen wir) **oder** `acadrust` (Rust) | MIT / GPL-3.0 / **MPL-2.0** | acadrust = Rust-Weg, MPL-2.0 |
|
||||
| STEP/IGES/BREP | `occt-wasm` (OCCT V8, ~4.5 MB) oder unser `opencascade.js` | LGPL-2.1 | occt-wasm = moderne TS-API |
|
||||
| **IFC** (BIM!) | `web-ifc` (ThatOpen) | MPL-2.0 | De-facto-Standard, read+write, sehr aktiv |
|
||||
| SHP/GIS | `shpjs` + `proj4` | MIT | direkt für GEO-BLOCK (SWISSIMAGE/Terrain) |
|
||||
| Punktwolken LAS/LAZ | `@loaders.gl/las` (+ `laz-perf`) | MIT/Apache | Rendering: `potree-core` oder deck.gl |
|
||||
| E57 | `e57-js` (Emscripten/libE57Format) | MIT | neu, funktional, sehr obskur |
|
||||
| glTF/OBJ/STL | three.js-Loader (haben wir via `three`) | MIT | |
|
||||
| 3MF | `lib3mf` (WASM) oder `THREE.3MFLoader` | BSD-2 / MIT | |
|
||||
| PDF-Vektor | `pdfjs-dist` `getOperatorList()` | Apache-2.0 | kein fertiges PDF→DXF; roher Pfad-Stream |
|
||||
|
||||
---
|
||||
|
||||
## Priorisierte Ansätze für DOSSIER (die eigentliche Antwort)
|
||||
|
||||
Reihenfolge = Nutzen × Machbarkeit, ohne die BIM-Stärke zu opfern.
|
||||
|
||||
1. **Generisches Entity-Modell + Trait-Dispatch** (Fundament). Neben das BIM-Modell eine
|
||||
generische Entity-Schicht (line/arc/circle/polyline/spline/text/dimension/hatch/block) mit
|
||||
Traits à la OpenCADStudio (Display/Grips/Props/Transform). Unser `kernel2d` liefert schon die
|
||||
Geometrie-Operationen dafür. **Größter struktureller Hebel.**
|
||||
2. **Modeless Command-System** (`StepInput`-Funnel + Kommandozeile). Ein Eingabe-Enum, eine
|
||||
`feed_command`-Schleife, `CadCommand`-Step-Machines. Macht Werkzeuge einheitlich, scriptbar,
|
||||
headless-testbar. **Größter UX-Hebel Richtung „echtes CAD".**
|
||||
3. **DWG/DXF-Round-Trip über `acadrust`** (Rust/WASM). MPL-2.0, pure Rust → passt in unseren
|
||||
neuen `src-tauri`-Kern. Ersetzt langfristig die JS-Parser, bringt echten Import/Export.
|
||||
Erst: WASM-Baubarkeit + Bytes-API verifizieren (Spike wie beim Textur-Spike).
|
||||
4. **Vollständige Object-Snap-Schicht** als eigenes Modul (endpoint/mid/center/intersection/
|
||||
tangent/perp/…), aufbauend auf `kernel2d`.
|
||||
5. **3D-B-Rep später, selektiv** (truck-geometry/-topology/-modeling als reine Crates, OHNE
|
||||
truck-Rendering — wir haben Nordstern). NUR wenn wir echte NURBS/Solids/Booleans brauchen; der
|
||||
Meshing-/Boolean-Pfad ist WASM-problematisch (native C-Deps) und Booleans sind noch früh.
|
||||
Bis dahin: truck als **Referenz** lesen, nicht einbinden.
|
||||
|
||||
**Kernaussage:** DOSSIERs Rust-nach-WASM-Kurs (den wir mit `kernel2d` gerade gehen) ist genau
|
||||
richtig — OpenCADStudio bestätigt ihn. Der Weg zu „echtem CAD" führt über (1) generisches
|
||||
Entity-Modell und (2) modeless Command-System; (3) `acadrust` bringt echten DWG/DXF-Austausch.
|
||||
3D-B-Rep (truck) ist ein späterer, selektiver Schritt mit klaren WASM-Vorbehalten.
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPIKE — Bild-Texturen in `render3d` (`RenderStyle::Textured` real machen)
|
||||
|
||||
Stand: 2026-07-05. **Auftrag/Übergabe für einen Agenten. Kleinster ehrlicher
|
||||
Durchstich — kein Produktfeature, keine Integration.**
|
||||
|
||||
Ziel: Beweisen, dass der bestehende wgpu-3D-Renderer echte **Bild-Texturen** auf
|
||||
Wandflächen darstellen kann, sichtbar im `spike3d`-Fenster. Am Ende steht eine
|
||||
belastbare Aussage, wie viel Arbeit „richtig gutes texturiertes 3D" wirklich ist —
|
||||
statt Spekulation.
|
||||
|
||||
---
|
||||
|
||||
## 0. Ausgangslage (verifiziert am 2026-07-05)
|
||||
|
||||
- `render3d` ist **kein** Three.js-Wrapper, sondern ein eigenständiger wgpu-Renderer
|
||||
(~6400 LOC): echte GPU-Pipeline (wgpu 29), Tiefenpuffer, MSAA, WGSL-Shader.
|
||||
Läuft nativ (winit-Spike), headless (naga-validiert) und im Browser (WebGPU).
|
||||
- **`RenderStyle::Textured` existiert bereits als Stub** (`gpu.rs:34` Enum-Variante,
|
||||
`gpu.rs:47` Parse aus `"textured"`) — es gibt aber **kein echtes Texturing**:
|
||||
kein Sampler, keine Textur-Bind-Group, keine Bilddaten.
|
||||
- Die `cap_pipeline` mit Layout `[pos vec3, uv vec2]` + `CAP_WGSL` ist **nicht** für
|
||||
Bildtexturen, sondern für die **Schnittflächen-Kappen** (prozedurale Schraffur);
|
||||
die UVs steuern dort den Schraffur-Abstand. **Nicht damit verwechseln.**
|
||||
- **Günstig für uns:** Die Muster, die der Spike braucht, existieren schon —
|
||||
ein UV-tragendes Vertex-Layout und eine zweite/dritte Pipeline, die sich die
|
||||
`Globals`-Bind-Group teilt (`grid`, `cap`). Der texturierte Mesh-Pfad reiht sich
|
||||
1:1 in dieses Muster ein.
|
||||
|
||||
---
|
||||
|
||||
## 1. Echte Symbole — vor dem Coding lesen
|
||||
|
||||
| Was | Ort |
|
||||
|---|---|
|
||||
| Vertex heute interleaved `[pos.xyz, normal.xyz, color.rgb]`, `FLOATS_PER_VERTEX`, `Mesh`-Struct | `src-tauri/render3d/src/types.rs:322` ff. |
|
||||
| Quad-Emitter (Normale + Farbe je Vertex, Reihenfolge `(0,1,2)+(0,2,3)`) | `src-tauri/render3d/src/mesh.rs:886` |
|
||||
| Wand-Extrusion / Mesh-Bau | `src-tauri/render3d/src/mesh.rs` — `extrude_wall` (`:91`), `build_walls_mesh` (`:911`) |
|
||||
| Haupt-Pipeline + `Globals`-Bind-Group (group 0) | `src-tauri/render3d/src/gpu.rs:232`–`:320` |
|
||||
| Vorlage „zweite Pipeline teilt sich Globals" — Grid | `src-tauri/render3d/src/gpu.rs:329` |
|
||||
| Vorlage „Pipeline mit UV-Layout `[pos vec3, uv vec2]`" — Cap | `src-tauri/render3d/src/gpu.rs:408` |
|
||||
| `RenderStyle`-Enum + Stub `Textured` | `src-tauri/render3d/src/gpu.rs:34`, `:47` |
|
||||
| Pipeline-Bindung im Render-Pass (Muster für Stil-Umschaltung) | `src-tauri/render3d/src/gpu.rs:847` |
|
||||
| Shader als WGSL-Konstanten (`MESH_WGSL`, `CAP_WGSL`) | `src-tauri/render3d/src/shaders.rs` |
|
||||
| Beleuchtungsmodell (hemisphärisch + Directional + Fill) — Doku | `src-tauri/render3d/src/shaders.rs:1`–`40` |
|
||||
| naga-WGSL-Validierung headless (Test-Vorlage) | `src-tauri/render3d/src/lib.rs:924` (`cap_module`) |
|
||||
| Fenster-Spike mit Orbit-Kamera | `src-tauri/render3d/src/bin/spike3d.rs` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Umfang — exakt das, nicht mehr
|
||||
|
||||
### 2.1 UVs auf Wandflächen
|
||||
- In der Quad-Emitter-Funktion (`mesh.rs:886`) je Vertex eine **UV** berechnen:
|
||||
planare Projektion in **Metern** — `u` = Distanz entlang der Wandachse,
|
||||
`v` = Höhe (z). Textur-Raster damit weltmassstäblich (z. B. 1 Kachel = 1 m).
|
||||
- Den bestehenden `[pos, normal, color]`-Pfad **bitgleich unangetastet** lassen.
|
||||
Zwei zulässige Wege (Agent wählt begründet):
|
||||
1. **Separates additives UV-Array** in `Mesh` (Default leer/None), oder
|
||||
2. **Paralleler `build_walls_mesh_textured`** → interleaved
|
||||
`[pos.xyz, normal.xyz, uv.xy]`.
|
||||
- Regressionstests für den Alt-Pfad müssen grün bleiben (siehe §4).
|
||||
|
||||
### 2.2 Test-Textur prozedural (kein Asset, keine `image`-Crate)
|
||||
- Ein **256×256 RGBA-Schachbrett/Grid im Code** generieren (`Vec<u8>`).
|
||||
- `device.create_texture` + `queue.write_texture` + `Sampler`
|
||||
(`FilterMode::Linear`, `AddressMode::Repeat`). Mipmaps optional (nice-to-have für
|
||||
flache Blickwinkel; kein Muss für den Spike).
|
||||
- Selbstständig, damit der Spike ohne Dateipfade/Asset-Pipeline läuft.
|
||||
|
||||
### 2.3 Textur-Bind-Group (group 1)
|
||||
- Neue Bind-Group-Layout mit `texture_view` (`TextureSampleType::Float`) +
|
||||
`sampler`. **`Globals` bleibt group 0** und unverändert.
|
||||
|
||||
### 2.4 Textured-Pipeline + WGSL (`MESH_TEXTURED_WGSL`)
|
||||
- Vertex-Layout `[pos vec3, normal vec3, uv vec2]`, `TriangleList`.
|
||||
- **Dieselbe Beleuchtung wie `MESH_WGSL`** (hemisphärisches Ambient + Directional +
|
||||
Fill) — nur **Albedo = `textureSample(tex, samp, uv)`** statt Vertex-Farbe.
|
||||
- Depth-Format, MSAA (`SAMPLE_COUNT`) und Color-Target **identisch** zur
|
||||
Haupt-Pipeline (sonst inkompatibler Render-Pass).
|
||||
- Pipeline-Layout bindet group 0 (Globals) **und** group 1 (Textur).
|
||||
|
||||
### 2.5 Verdrahten
|
||||
- Bei `RenderStyle::Textured` im Render-Pass die neue Pipeline + beide Bind-Groups
|
||||
setzen (Muster: `cap_pipeline`-Bindung bei `gpu.rs:847`).
|
||||
|
||||
### 2.6 Spike sichtbar machen
|
||||
- `spike3d.rs` so erweitern, dass der Stil auf `Textured` schaltbar ist
|
||||
(Tastendruck, z. B. `T`, **oder** Startkonstante). Die Demo-Wände sollen
|
||||
texturiert im Orbit erscheinen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Randbedingungen (hart)
|
||||
|
||||
- **Nur** die `render3d`-Crate. `src/web.rs` und die Tauri-/`native3d`-Oberfläche
|
||||
**nicht** anfassen.
|
||||
- Feature-gegatet unter dem bestehenden `render`/`window`-Feature.
|
||||
**Default-Build und Default-Darstellung bleiben unverändert.**
|
||||
- **Keine neuen Dependencies** (insbesondere **kein `image`-Crate**) für den Spike.
|
||||
- Term-/Reihenfolge-sensible Geometrie (Parität) wird **nicht** berührt — es kommt
|
||||
nur additiv ein UV-Kanal + ein zweiter Render-Pfad dazu.
|
||||
- Kommentar-Stil und Sprache (Deutsch, ausführliche Begründungs-Kommentare) wie im
|
||||
umgebenden Code beibehalten.
|
||||
|
||||
---
|
||||
|
||||
## 4. Akzeptanz / Verifikation
|
||||
|
||||
1. `cargo test` (im Crate-Verzeichnis `src-tauri/render3d`) **grün**, inklusive:
|
||||
- bestehende Mesh-Regression (Alt-Pfad `[pos,normal,color]` unverändert),
|
||||
- **neuer naga-Validierungstest** für `MESH_TEXTURED_WGSL` (Vorlage:
|
||||
`lib.rs:924`).
|
||||
2. `cargo run --features window --bin spike3d` zeigt die Demo-Wände mit
|
||||
**erkennbarer, korrekt gemappter** Schachbrett-Textur:
|
||||
- Raster weltmassstäblich (in Metern), keine Verzerrung an Gehrungen/Ecken,
|
||||
- beleuchtet wie im Shaded-Modus (Volumen bleibt ablesbar).
|
||||
3. Umschalten Shaded ↔ Textured zur Laufzeit (oder per Startkonstante) funktioniert
|
||||
ohne Re-Meshing-Crash.
|
||||
|
||||
---
|
||||
|
||||
## 5. Abschlussbericht (vom Agenten am Ende zu liefern)
|
||||
|
||||
- Welche Dateien geändert/hinzugefügt wurden und warum.
|
||||
- Wie die UVs projiziert werden (Achswahl, Massstab, Verhalten an Gehrungen).
|
||||
- Welcher der beiden UV-Wege (§2.1) gewählt wurde und weshalb.
|
||||
- **Ehrliche Lückenliste für „richtig gutes" Texturing:** Asset-/Bild-Datei-Laden,
|
||||
Material→Textur-Zuordnung (Wandtyp/Layer → Material), Normal-/Roughness-Maps
|
||||
(PBR), anisotropes Filtern + Mipmaps, Web-Pfad (`web.rs`/WebGPU), UI zum
|
||||
Zuweisen. Grobschätzung Aufwand je Punkt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Nicht im Scope
|
||||
|
||||
Asset-/Bild-Datei-Laden · Material-System · mehrere Texturen gleichzeitig ·
|
||||
PBR/Normal-Maps · Web-Pfad (`web.rs`) · jegliche UI · Anbindung unter die Webview.
|
||||
@@ -1,4 +1,4 @@
|
||||
# HAUPTINSTANZ-BRIEFING: Tauri + wgpu (Korrigiert)
|
||||
# ARCHITEKTUR-BRIEFING: Tauri + wgpu (Korrigiert)
|
||||
|
||||
**Stand:** 2026-07-01 — **KORREKTUR** (vorherige Dokumente waren unvollständig)
|
||||
|
||||
@@ -158,7 +158,7 @@ npm run tauri:build # → Windows .exe / macOS .app / Linux .deb
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte (für Hauptinstanz)
|
||||
## Nächste Schritte
|
||||
|
||||
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
|
||||
2. **Spawn:** vier Agents (parallel, unabhängig)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, E)
|
||||
|
||||
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
|
||||
Einzeilern verweist. Ursprünglich von einem Opus-Subagent erarbeitet, der das
|
||||
DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen den aktuellen Browser-Port
|
||||
abgeglichen hat — das Ergebnis lag bisher nur in einem Session-Transkript, nicht
|
||||
im Repo. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
||||
Einzeilern verweist. Abgleich DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen
|
||||
den aktuellen Browser-Port. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
||||
|
||||
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
|
||||
|
||||
|
||||
@@ -146,6 +146,6 @@ Neue Keys in de.ts UND en.ts: `text.style`, `text.font`, `text.size`,
|
||||
## 7. Gate (Pflicht)
|
||||
|
||||
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
|
||||
AI-Spuren (grep auf Claude/Anthropic/AI/Generated/Co-Authored), Boot-Probe
|
||||
Trace-Scan (grep auf Co-Authored/Generated), Boot-Probe
|
||||
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
|
||||
KEIN Commit.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
|
||||
|
||||
> Für die Instanz/den Agenten, der den nativen GPU-Renderer baut. Isoliert vom
|
||||
> Für den Entwickler, der den nativen GPU-Renderer baut. Isoliert vom
|
||||
> Web-Renderer; koordiniert über dieses Dokument.
|
||||
|
||||
## COMMIT-REGEL (verbindlich)
|
||||
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
|
||||
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
|
||||
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
|
||||
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
|
||||
Dieses Repo darf **keinerlei Fremd-Tool-Hinweise** enthalten — nicht im Code,
|
||||
in Kommentaren oder in der Git-Historie. Kommentare deutsch, Identifier englisch.
|
||||
**Kein Commit ohne Absprache.**
|
||||
|
||||
## Warum
|
||||
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
|
||||
@@ -72,9 +71,9 @@ per Matrix-Uniform macht. Fenster-Integration in Tauri ist der zweite, separate
|
||||
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
|
||||
`feature/parametric-walls` committen. Isolierter Worktree.
|
||||
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
|
||||
`src/plan/*`, `types.ts` anfassen (die Hauptinstanz arbeitet dort an Features).
|
||||
`src/plan/*`, `types.ts` anfassen (dort laufen parallel Features).
|
||||
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
|
||||
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
|
||||
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
|
||||
|
||||
## Gates
|
||||
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
|
||||
|
||||
## Commit-/Spuren-Regel
|
||||
Wie im ganzen Repo: keine Hinweise auf KI-Werkzeuge — nicht im Code, in Kommentaren
|
||||
oder in der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne
|
||||
Ansage der Hauptinstanz.
|
||||
Wie im ganzen Repo: keine Fremd-Tool-Hinweise im Code, in Kommentaren oder der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne Absprache.
|
||||
|
||||
## Warum
|
||||
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
|
||||
|
||||
+4
-1
@@ -16,7 +16,10 @@
|
||||
"shell": "scripts/chromium-shell.sh",
|
||||
"electron": "scripts/electron-shell.sh",
|
||||
"build:engine": "wasm-pack build src-tauri/render2d --release --target web --out-dir ../../src/engine/pkg --out-name render2d --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:engine3d": "wasm-pack build src-tauri/render3d --release --target web --out-dir ../../src/engine/pkg3d --out-name render3d --no-default-features --features web",
|
||||
"build:geometry": "wasm-pack build src-tauri/geometry --release --target web --out-dir ../../src/engine/pkgGeometry --out-name geometry --no-default-features --features web",
|
||||
"build:kernel2d": "wasm-pack build src-tauri/kernel2d --release --target web --out-dir ../../src/engine/pkgKernel2d --out-name kernel2d --no-default-features --features web",
|
||||
"build:dwgimport": "wasm-pack build src-tauri/dwgimport --release --target web --out-dir ../../src/engine/pkgDwgImport --out-name dwgimport --no-default-features --features web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mlightcad/libredwg-web": "^0.7.7",
|
||||
|
||||
Generated
+2
@@ -1488,8 +1488,10 @@ dependencies = [
|
||||
name = "geometry"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+10
-6
@@ -1,10 +1,14 @@
|
||||
[workspace]
|
||||
members = [".", "geometry"]
|
||||
# render2d/render3d sind eigenstaendige Crates (jeweils eigener [workspace]), damit
|
||||
# sie headless ohne Tauri-Toolchain baubar/testbar bleiben. Sie liegen zwar im
|
||||
# src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — hier ausschliessen,
|
||||
# damit `cad-tauri` sie dennoch per Pfad als (optionale) Abhaengigkeit nutzen kann.
|
||||
exclude = ["render2d", "render3d"]
|
||||
members = ["."]
|
||||
# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne
|
||||
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
|
||||
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
|
||||
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
|
||||
# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut
|
||||
# wird und das TS-Frontend die Joins direkt per WASM aufruft (statt TS-Duplikat).
|
||||
# kernel2d: analog — eigenstaendiger 2D-Geometrie-Kern (Port von kernel2d.ts),
|
||||
# per wasm-pack (Feature "web") zu WASM gebaut, ausserhalb des cad-tauri-Workspace.
|
||||
exclude = ["render2d", "render3d", "geometry", "kernel2d", "dwgimport"]
|
||||
|
||||
[package]
|
||||
name = "cad-tauri"
|
||||
|
||||
Generated
+578
@@ -0,0 +1,578 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "153498e7c9b058326ff0fae11bc9fd870cf2ea0aa9111875d03625fa9018c218"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"nalgebra",
|
||||
"nom",
|
||||
"once_cell",
|
||||
"ryu",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dwgimport"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"console_error_panic_hook",
|
||||
"getrandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra"
|
||||
version = "0.32.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"matrixmultiply",
|
||||
"nalgebra-macros",
|
||||
"num-complex",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"simba",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra-macros"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,36 @@
|
||||
# Eigener leerer Workspace-Block: entkoppelt dwgimport vollstaendig vom
|
||||
# cad-tauri-Workspace (src-tauri/Cargo.toml). Muster: kernel2d/render2d.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "dwgimport"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "DXF-Import-Probe: beweist, dass acadrust (MPL-2.0) aus einem Byte-Buffer parst und nach wasm32-unknown-unknown kompiliert."
|
||||
|
||||
# cdylib: fuer wasm-pack (Feature \"web\"). rlib: fuer cargo test + Pfad-Dep.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
# Standard: headless, kein WASM-Overhead — per `cargo test` pruefbar.
|
||||
default = []
|
||||
# Browser-Bindings: wasm-bindgen-Fassade; nur fuer wasm32-unknown-unknown.
|
||||
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
# DXF/DWG-Parser (MPL-2.0), von crates.io.
|
||||
acadrust = "0.4"
|
||||
|
||||
# getrandom wird transitiv via ahash→acadrust gezogen. Fuer wasm32 muss das
|
||||
# Feature "wasm_js" explizit aktiviert werden (sonst Compile-Fehler).
|
||||
# Direkte Dep mit Feature-Aktivierung unifiziert das Flag fuer alle Abhaengigen.
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
|
||||
# Serialisierung: immer vorhanden (fuer den headless parse_dxf_summary-Kern).
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
# WASM-Bindings — nur im Feature "web" aktiv.
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
@@ -0,0 +1,284 @@
|
||||
// dwgimport — SPIKE: beweist, dass acadrust (MPL-2.0) aus einem Byte-Buffer
|
||||
// ein DXF parst und nach wasm32-unknown-unknown kompiliert.
|
||||
//
|
||||
// Aufbau:
|
||||
// - `parse_dxf_summary`: headless, feature-frei testbar.
|
||||
// - `#[cfg(feature="web")]` wasm-bindgen-Fassade darueber.
|
||||
//
|
||||
// API-Stuetzpunkt acadrust:
|
||||
// `DxfReader::from_reader<R: Read + Seek + 'static>(reader: R)`
|
||||
// → std::io::Cursor<&[u8]> implementiert Read + Seek → WASM-tauglich.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use acadrust::DxfReader;
|
||||
|
||||
// serde_json nur bei Feature "web" als optionale Dep, aber serde ist immer da.
|
||||
// Fuer den headless-Kern bauen wir das JSON mit einem einfachen Format-String,
|
||||
// damit keine Compile-Time-Dep auf serde_json im Default-Feature noetig ist.
|
||||
|
||||
/// Parst ein DXF aus einem Byte-Slice und gibt ein JSON-Summary zurueck.
|
||||
///
|
||||
/// Rueckgabe-JSON-Schema:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "version": "AC1015",
|
||||
/// "entityCount": 5,
|
||||
/// "entityTypeCounts": { "LINE": 3, "CIRCLE": 2 },
|
||||
/// "layerNames": ["0", "Wand"]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Fehler: String mit Fehlerbeschreibung.
|
||||
pub fn parse_dxf_summary(bytes: &[u8]) -> Result<String, String> {
|
||||
// Cursor<Vec<u8>> implementiert Read + Seek + 'static — kein std::fs noetig.
|
||||
// (from_reader verlangt R: 'static; Cursor<&[u8]> wuerde die Lifetime verletzen.)
|
||||
let cursor = Cursor::new(bytes.to_vec());
|
||||
let reader = DxfReader::from_reader(cursor)
|
||||
.map_err(|e| format!("DxfReader::from_reader fehlgeschlagen: {e}"))?;
|
||||
|
||||
let doc = reader
|
||||
.read()
|
||||
.map_err(|e| format!("DxfReader::read fehlgeschlagen: {e}"))?;
|
||||
|
||||
// Versions-String aus dem Enum (Debug-Format, z. B. "AC1015").
|
||||
let version = format!("{:?}", doc.version);
|
||||
|
||||
// Entitaeten zaehlen und nach Typ aufschluesseln.
|
||||
let mut type_counts: HashMap<&str, usize> = HashMap::new();
|
||||
for entity in doc.entities() {
|
||||
let typ = entity.as_entity().entity_type();
|
||||
*type_counts.entry(typ).or_insert(0) += 1;
|
||||
}
|
||||
let entity_count = type_counts.values().sum::<usize>();
|
||||
|
||||
// Layer-Namen aus der Layer-Tabelle.
|
||||
let mut layer_names: Vec<String> = doc
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
layer_names.sort();
|
||||
|
||||
// JSON-Serialisierung ohne optionale serde_json-Dep im Default-Feature.
|
||||
// Fuer den headless-Test reicht ein sauberer Format-String.
|
||||
let type_counts_json = {
|
||||
let mut pairs: Vec<String> = type_counts
|
||||
.iter()
|
||||
.map(|(k, v)| format!(" \"{k}\": {v}"))
|
||||
.collect();
|
||||
pairs.sort(); // deterministisch fuer Tests
|
||||
format!("{{\n{}\n }}", pairs.join(",\n"))
|
||||
};
|
||||
|
||||
let layer_names_json = {
|
||||
let quoted: Vec<String> = layer_names
|
||||
.iter()
|
||||
.map(|n| format!("\"{}\"", n.replace('"', "\\\"")))
|
||||
.collect();
|
||||
format!("[{}]", quoted.join(", "))
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"{{\n \"version\": \"{version}\",\n \"entityCount\": {entity_count},\n \"entityTypeCounts\": {type_counts_json},\n \"layerNames\": {layer_names_json}\n}}"
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WASM-Fassade (nur bei Feature "web" compiliert)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
mod wasm {
|
||||
use super::parse_dxf_summary;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Initialisiert den Panic-Hook fuer bessere Fehlermeldungen im Browser.
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init_panic_hook() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// WASM-Fassade: nimmt einen Byte-Buffer (Uint8Array aus JS) und gibt ein
|
||||
/// JSON-String zurueck, das das DXF-Summary beschreibt.
|
||||
///
|
||||
/// JS-Aufruf:
|
||||
/// ```js
|
||||
/// import init, { parse_dxf_summary_json } from './pkgDwgImport/dwgimport.js';
|
||||
/// await init();
|
||||
/// const json = parse_dxf_summary_json(new Uint8Array(buffer));
|
||||
/// ```
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_dxf_summary_json(bytes: &[u8]) -> Result<String, JsValue> {
|
||||
parse_dxf_summary(bytes)
|
||||
.map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Minimales ASCII-DXF mit 3 LINE- und 2 CIRCLE-Entitaeten auf Layer "WAND"
|
||||
/// und Layer "0". Wird direkt als Byte-Slice eingebettet — kein Datei-IO.
|
||||
const MINI_DXF: &str = r#" 0
|
||||
SECTION
|
||||
2
|
||||
HEADER
|
||||
9
|
||||
$ACADVER
|
||||
1
|
||||
AC1015
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
TABLES
|
||||
0
|
||||
TABLE
|
||||
2
|
||||
LAYER
|
||||
70
|
||||
2
|
||||
0
|
||||
LAYER
|
||||
2
|
||||
0
|
||||
70
|
||||
0
|
||||
62
|
||||
7
|
||||
6
|
||||
Continuous
|
||||
0
|
||||
LAYER
|
||||
2
|
||||
WAND
|
||||
70
|
||||
0
|
||||
62
|
||||
3
|
||||
6
|
||||
Continuous
|
||||
0
|
||||
ENDTAB
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
LINE
|
||||
8
|
||||
WAND
|
||||
10
|
||||
0.0
|
||||
20
|
||||
0.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
100.0
|
||||
21
|
||||
0.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
WAND
|
||||
10
|
||||
100.0
|
||||
20
|
||||
0.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
100.0
|
||||
21
|
||||
50.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
0
|
||||
10
|
||||
100.0
|
||||
20
|
||||
50.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
0.0
|
||||
21
|
||||
0.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
WAND
|
||||
10
|
||||
50.0
|
||||
20
|
||||
25.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
10.0
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
0
|
||||
10
|
||||
20.0
|
||||
20
|
||||
10.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
5.0
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mini_dxf() {
|
||||
let result = parse_dxf_summary(MINI_DXF.as_bytes());
|
||||
assert!(result.is_ok(), "parse_dxf_summary schlug fehl: {:?}", result);
|
||||
|
||||
let json = result.unwrap();
|
||||
println!("DXF-Summary:\n{json}");
|
||||
|
||||
// Version muss AC1015 sein.
|
||||
assert!(json.contains("AC1015"), "Version AC1015 nicht im JSON: {json}");
|
||||
|
||||
// Genau 5 Entitaeten: 3 LINE + 2 CIRCLE.
|
||||
assert!(
|
||||
json.contains("\"entityCount\": 5"),
|
||||
"entityCount soll 5 sein: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"LINE\": 3"),
|
||||
"LINE-Zaehler soll 3 sein: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"CIRCLE\": 2"),
|
||||
"CIRCLE-Zaehler soll 2 sein: {json}"
|
||||
);
|
||||
|
||||
// Layer "WAND" und "0" muessen erscheinen.
|
||||
assert!(json.contains("\"WAND\""), "Layer WAND fehlt: {json}");
|
||||
assert!(json.contains("\"0\""), "Layer 0 fehlt: {json}");
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,28 @@
|
||||
name = "geometry"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Serde-only Geometrie-Kern (Wand-Verschneidungen/Joins) — headless und per wasm-pack (Feature \"web\") zu WASM baubar; Single Source of Truth fuer TS + nativen Host."
|
||||
|
||||
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul benoetigt. rlib:
|
||||
# damit die Crate weiterhin als Pfad-Abhaengigkeit (cad-tauri) und im Test-Build
|
||||
# nutzbar bleibt. Der cdylib-Artefakt-Build auf nativen Zielen ist harmlos (leere
|
||||
# Export-Oberflaeche ohne Feature "web"). Muster: render2d/render3d.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
# Standard: reine serde-Geometrie, headless per `cargo test` pruefbar.
|
||||
default = []
|
||||
# Browser-Bindings: dieselbe Geometrie hinter einer wasm-bindgen-Fassade
|
||||
# (`compute_joins_json`), aus TS via wasm-pack aufgerufen. Baut nur fuer
|
||||
# target wasm32-unknown-unknown sinnvoll. Muster: render3d/Cargo.toml.
|
||||
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 }
|
||||
|
||||
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Browser-Fassade (Feature "web"): JSON rein/raus, damit das TS-Frontend die
|
||||
/// EINE Rust-Join-Implementierung per wasm-pack aufrufen kann (Single Source of
|
||||
/// Truth statt TS-Duplikat). `input_json` = serialisiertes `JoinInput`, Rueckgabe
|
||||
/// = serialisiertes `Vec<WallCuts>`.
|
||||
#[cfg(feature = "web")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn compute_joins_json(input_json: &str) -> Result<String, wasm_bindgen::JsValue> {
|
||||
console_error_panic_hook::set_once();
|
||||
let input: JoinInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))?;
|
||||
let out = compute_joins(input);
|
||||
serde_json::to_string(&out).map_err(|e| wasm_bindgen::JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||
pub struct Vec2 {
|
||||
pub x: f64,
|
||||
@@ -228,6 +242,18 @@ fn apply_layer_cuts(
|
||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die
|
||||
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
|
||||
// Durchgangswand unten endet.
|
||||
// Nah-Putz-Komponenten der Durchgangswand (dieselbe sign-Seite wie
|
||||
// near_plaster oben): ist die getrimmte Abzweig-Schicht materialgleich mit
|
||||
// einer davon, fuellt der Nah-Putz den Abzweig-Putz an dieser Stelle
|
||||
// durchgehend (spanCutout schneidet nur die KERN-Breite aus, nicht die
|
||||
// schmalen Putzflanken) -> Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie.
|
||||
let near_plaster_comps: Vec<&str> = through_layers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| if sign > 0.0 { *i > backbone_idx } else { *i < backbone_idx })
|
||||
.map(|(_, l)| l.component_id.as_str())
|
||||
.collect();
|
||||
|
||||
{
|
||||
let side_line = merge_cut;
|
||||
let (face_arr, side_arr) = match branch_end {
|
||||
@@ -238,7 +264,13 @@ fn apply_layer_cuts(
|
||||
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
|
||||
let adj_merged =
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
|
||||
side_arr[i] = if adj_merged { Some(side_line) } else { None };
|
||||
let merges_with_near_plaster =
|
||||
near_plaster_comps.contains(&branch_layers[i].component_id.as_str());
|
||||
side_arr[i] = if adj_merged && !merges_with_near_plaster {
|
||||
Some(side_line)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,8 +968,8 @@ mod tests {
|
||||
// Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom
|
||||
// W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den
|
||||
// Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden
|
||||
// Innenputz-Schichten (Index 0/2) werden getrimmt und bekommen
|
||||
// zusaetzlich die seitliche L-Linie an derselben Flaeche.
|
||||
// Innenputz-Schichten (Index 0/2) werden getrimmt. Da der Nah-Putz der
|
||||
// Durchgangswand materialgleich ist, entsteht KEINE L-Trennnaht.
|
||||
let input = JoinInput {
|
||||
walls: vec![
|
||||
w_iw("A", 0.0, 0.0, 5.0, 0.0),
|
||||
@@ -972,13 +1004,12 @@ mod tests {
|
||||
let l2 = lc.start[2].expect("Schicht 2 getrimmt");
|
||||
assert!((l0.point.y - 0.075).abs() < 1e-9);
|
||||
assert!((l2.point.y - 0.075).abs() < 1e-9);
|
||||
// ... UND die seitliche L-Linie an der Rueckgrat-Nahflaeche (y=0.06,
|
||||
// parallel zur x-Achse).
|
||||
let s0 = lc.start_side[0].expect("Schicht 0 hat L-Seitenlinie");
|
||||
let s2 = lc.start_side[2].expect("Schicht 2 hat L-Seitenlinie");
|
||||
assert!(dist(s0, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
|
||||
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||
// ... aber KEINE seitliche L-Linie: der Innenputz der Durchgangswand ist
|
||||
// materialgleich und fuellt den getrimmten Abzweig-Putz durchgehend
|
||||
// (Putz-L ohne Trennnaht). Nur bei materialFREMDEM Nah-Putz stuende hier
|
||||
// eine L-Linie.
|
||||
assert!(lc.start_side[0].is_none());
|
||||
assert!(lc.start_side[2].is_none());
|
||||
|
||||
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
|
||||
let ca = find(&out, "A");
|
||||
|
||||
Generated
+195
@@ -0,0 +1,195 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "kernel2d"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "robust"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,40 @@
|
||||
# Eigener leerer Workspace-Block: entkoppelt kernel2d vollstaendig vom
|
||||
# cad-tauri-Workspace (src-tauri/Cargo.toml), damit `cargo test`/`wasm-pack`
|
||||
# aus diesem Verzeichnis heraus nicht faelschlich dessen Workspace erben.
|
||||
# Muster: render2d/render3d. (Parent-`exclude` allein greift beim Bauen aus
|
||||
# dem Unterverzeichnis nicht zuverlaessig.)
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "kernel2d"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "2D-Geometrie-Kern (Port von src/geometry/kernel2d.ts) — handgeschriebene f64-Mathematik, headless per `cargo test` UND per wasm-pack (Feature \"web\") zu WASM baubar. Hinter identischer TS-Fassade; TS-Legacy bleibt Differential-Referenz."
|
||||
|
||||
# cdylib: von wasm-pack (Feature "web") fuer das .wasm-Modul. rlib: als
|
||||
# Pfad-Abhaengigkeit und fuer den Test-/Example-Build (parity). Muster:
|
||||
# render2d/render3d/geometry.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
# Standard: reiner f64-Rechenkern, headless per `cargo test` pruefbar.
|
||||
default = []
|
||||
# Browser-Bindings: dieselbe Geometrie hinter wasm-bindgen-Batch-Fassaden,
|
||||
# aus TS via wasm-pack aufgerufen. Nur fuer wasm32-unknown-unknown sinnvoll.
|
||||
web = ["dep:wasm-bindgen", "dep:serde_json", "dep:console_error_panic_hook"]
|
||||
# ADDITIV, NICHT im web-Default: exakte Orientierungs-Praedikate (robust::orient2d)
|
||||
# nur intern fuer Korrektheits-Golden-Cases (detectRooms/point-in-polygon). Der
|
||||
# Zufalls-Diff-Test gegen TS-naiv laeuft OHNE dieses Feature (siehe PORT_PLAN §3).
|
||||
robust-predicates = ["dep:robust"]
|
||||
|
||||
[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 }
|
||||
robust = { version = "1", optional = true }
|
||||
|
||||
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,13 +13,14 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use render3d::gpu::Renderer;
|
||||
use render3d::gpu::{RenderStyle, Renderer};
|
||||
use render3d::math::orbit_eye;
|
||||
use render3d::types::{Camera, Projection, WallInput};
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
|
||||
@@ -196,6 +197,9 @@ struct App {
|
||||
orbit: Orbit,
|
||||
dragging: bool,
|
||||
last_cursor: (f64, f64),
|
||||
/// Darstellung: false = Shaded (Default, unveraendert), true = Textured
|
||||
/// (prozedurales Schachbrett auf den Wandflaechen). Per Taste `T` umschaltbar.
|
||||
textured: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
@@ -223,6 +227,27 @@ impl ApplicationHandler for App {
|
||||
state.resize(size.width, size.height);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::KeyboardInput {
|
||||
event:
|
||||
KeyEvent {
|
||||
physical_key: PhysicalKey::Code(KeyCode::KeyT),
|
||||
state: ElementState::Pressed,
|
||||
repeat: false,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => {
|
||||
// `T` schaltet Shaded <-> Textured um (Laufzeit, kein Re-Meshing:
|
||||
// der Renderer haelt beide Vertex-Puffer bereit).
|
||||
self.textured = !self.textured;
|
||||
let style = if self.textured {
|
||||
RenderStyle::Textured
|
||||
} else {
|
||||
RenderStyle::Shaded
|
||||
};
|
||||
state.renderer.set_render_style(style);
|
||||
state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::MouseInput { state: s, button, .. } => {
|
||||
if button == MouseButton::Left {
|
||||
self.dragging = s == ElementState::Pressed;
|
||||
|
||||
@@ -15,11 +15,13 @@ use wgpu::util::DeviceExt;
|
||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||
use crate::math::{view_projection, Mat4};
|
||||
use crate::mesh::{build_scene_mesh, build_walls_mesh};
|
||||
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
|
||||
use crate::section::SectionPlane;
|
||||
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
|
||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL};
|
||||
use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
|
||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
||||
use crate::types::{
|
||||
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
||||
/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und
|
||||
@@ -30,7 +32,9 @@ pub enum RenderStyle {
|
||||
Shaded,
|
||||
/// Clay-Look: einheitlich helles Material, beleuchtet.
|
||||
White,
|
||||
/// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake).
|
||||
/// Echte Bild-Textur (prozedurales Schachbrett) auf den Wandflaechen,
|
||||
/// beleuchtet wie `Shaded` (eigene Pipeline + Textur-Bind-Group group 1,
|
||||
/// siehe `textured_pipeline`).
|
||||
Textured,
|
||||
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
|
||||
Wireframe,
|
||||
@@ -132,6 +136,55 @@ struct MeshBuffers {
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
/// GPU-seitige Puffer des TEXTURIERTEN Wand-Meshes (`RenderStyle::Textured`).
|
||||
/// Eigenes Vertex-Layout `[pos vec3, normal vec3, uv vec2]`
|
||||
/// (`TEXTURED_FLOATS_PER_VERTEX`), sonst wie `MeshBuffers`. Zweiter, additiver
|
||||
/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet.
|
||||
struct TexturedMeshBuffers {
|
||||
vbo: wgpu::Buffer,
|
||||
ibo: wgpu::Buffer,
|
||||
index_count: u32,
|
||||
}
|
||||
|
||||
/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln.
|
||||
const TEXTURE_SIZE: u32 = 256;
|
||||
|
||||
/// Erzeugt ein 256x256-RGBA-Schachbrett IM CODE (kein Asset, keine `image`-Crate):
|
||||
/// 8x8 Felder abwechselnd hell/dunkel, mit einem duennen Raster als Kachel-Grenze.
|
||||
/// Rueckgabe = flaches RGBA8-Byte-Array (`4 * TEXTURE_SIZE * TEXTURE_SIZE`), direkt
|
||||
/// fuer `queue.write_texture` (Zeilen sind `4 * TEXTURE_SIZE` Byte breit, ohne
|
||||
/// Padding, weil `256 * 4 = 1024` bereits ein Vielfaches von 256 ist). Bewusst
|
||||
/// selbststaendig, damit der Spike ohne Dateipfade/Asset-Pipeline laeuft.
|
||||
fn build_checker_texture() -> Vec<u8> {
|
||||
let size = TEXTURE_SIZE as usize;
|
||||
// 8 Felder je Kante -> jedes Feld 32 Texel breit. Das macht 1 Textur-Kachel
|
||||
// (= 1 m Welt, siehe UV-Massstab) in ein 8x8-Schachbrett sichtbar.
|
||||
let cell = size / 8;
|
||||
let mut data = vec![0u8; size * size * 4];
|
||||
// Zwei Grautoene als Schachbrett + eine dunkle Rasterlinie an den Feldgrenzen.
|
||||
let light = [220u8, 220, 224, 255];
|
||||
let dark = [150u8, 152, 160, 255];
|
||||
let line = [70u8, 72, 80, 255];
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let checker = ((x / cell) + (y / cell)) % 2 == 0;
|
||||
// Duenne Rasterlinie (2 Texel) exakt auf den Feldkanten — betont die
|
||||
// weltmassstaebliche Kachelung an Gehrungen/Ecken sichtbar.
|
||||
let on_line = (x % cell) < 2 || (y % cell) < 2;
|
||||
let px = if on_line {
|
||||
line
|
||||
} else if checker {
|
||||
light
|
||||
} else {
|
||||
dark
|
||||
};
|
||||
let o = (y * size + x) * 4;
|
||||
data[o..o + 4].copy_from_slice(&px);
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// GPU-seitiger Vertex-Buffer einer LineList (kein Indexpuffer). Format
|
||||
/// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten
|
||||
/// (identisches Layout/Shader, siehe grid.rs / edges.rs).
|
||||
@@ -172,9 +225,29 @@ pub struct Renderer {
|
||||
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
||||
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
||||
cap_pipeline: wgpu::RenderPipeline,
|
||||
/// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList,
|
||||
/// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet
|
||||
/// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel
|
||||
/// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur
|
||||
/// Shader/Layout unterscheiden sich.
|
||||
textured_pipeline: wgpu::RenderPipeline,
|
||||
/// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler.
|
||||
/// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende).
|
||||
texture_bind_group: wgpu::BindGroup,
|
||||
/// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim
|
||||
/// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine
|
||||
/// Queue bekommt.
|
||||
texture: wgpu::Texture,
|
||||
/// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim
|
||||
/// ersten `render` einmalig gesetzt (lazy Upload).
|
||||
texture_uploaded: bool,
|
||||
bind_group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
mesh: Option<MeshBuffers>,
|
||||
/// Texturiertes Wand-Mesh (`[pos, normal, uv]`) — zweiter, additiver Puffer
|
||||
/// neben `mesh`. Nur im Stil `Textured` gezeichnet; bei `upload_*` zusammen mit
|
||||
/// `mesh` erzeugt. None = kein Mesh geladen.
|
||||
textured_mesh: Option<TexturedMeshBuffers>,
|
||||
/// Modell-Kanten (Feature-Edges, edges.rs) als LineList. Einmalig bei
|
||||
/// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in
|
||||
/// den Stilen wireframe/hidden.
|
||||
@@ -462,6 +535,144 @@ impl Renderer {
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// ── Texturierter Wand-Pfad (RenderStyle::Textured) ──────────────────────
|
||||
// Bild-Textur-Bind-Group-Layout (group 1): sampelbare 2D-Float-Textur +
|
||||
// Filter-Sampler. `Globals` bleibt group 0 (oben) UND unveraendert.
|
||||
let texture_bind_group_layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("texture.layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pipeline-Layout des texturierten Pfades: group 0 = Globals (wie oben),
|
||||
// group 1 = Textur. Reihenfolge = Bindungs-Index, deshalb beide hier.
|
||||
let textured_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("3d.textured.layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout), Some(&texture_bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let textured_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("mesh_textured.wgsl"),
|
||||
source: wgpu::ShaderSource::Wgsl(MESH_TEXTURED_WGSL.into()),
|
||||
});
|
||||
// Vertex-Layout: [pos vec3, normal vec3, uv vec2], stride 8*4.
|
||||
let textured_vertex_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: (TEXTURED_FLOATS_PER_VERTEX * 4) as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2],
|
||||
};
|
||||
// WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format,
|
||||
// Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline)
|
||||
// — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der
|
||||
// Pipeline-State kompatibel sein (sonst wgpu-Validierungspanik). Einziger
|
||||
// Unterschied: Shader/Vertex-Layout und das zusaetzliche group-1-Layout.
|
||||
let textured_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("mesh.textured.pipeline"),
|
||||
layout: Some(&textured_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &textured_module,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[textured_vertex_layout],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &textured_module,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
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,
|
||||
});
|
||||
|
||||
// Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb,
|
||||
// damit die im Shader gesampelten Werte im linearen Raum landen (die
|
||||
// Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das
|
||||
// Hochladen der Texel (queue.write_texture) passiert einmalig beim ersten
|
||||
// `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue
|
||||
// bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs,
|
||||
// spike3d) unveraendert.
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("checker.texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: TEXTURE_SIZE,
|
||||
height: TEXTURE_SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
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: &[],
|
||||
});
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
// Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung).
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("checker.sampler"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
address_mode_w: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("texture.bind"),
|
||||
layout: &texture_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("globals.buffer"),
|
||||
size: std::mem::size_of::<Globals>() as u64,
|
||||
@@ -483,9 +694,14 @@ impl Renderer {
|
||||
grid_pipeline,
|
||||
highlight_pipeline,
|
||||
cap_pipeline,
|
||||
textured_pipeline,
|
||||
texture_bind_group,
|
||||
texture,
|
||||
texture_uploaded: false,
|
||||
bind_group,
|
||||
uniform,
|
||||
mesh: None,
|
||||
textured_mesh: None,
|
||||
edges: None,
|
||||
grid: None,
|
||||
highlight: None,
|
||||
@@ -529,6 +745,7 @@ impl Renderer {
|
||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
||||
if mesh.indices.is_empty() {
|
||||
self.mesh = None;
|
||||
self.textured_mesh = None;
|
||||
self.edges = None;
|
||||
return;
|
||||
}
|
||||
@@ -564,6 +781,27 @@ impl Renderer {
|
||||
ibo,
|
||||
index_count: mesh.indices.len() as u32,
|
||||
});
|
||||
|
||||
// Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je
|
||||
// Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten
|
||||
// Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad,
|
||||
// und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit.
|
||||
let tex_mesh = textured_from_mesh(&mesh);
|
||||
let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.textured.vbo"),
|
||||
contents: bytemuck::cast_slice(&tex_mesh.verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let tibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mesh.textured.ibo"),
|
||||
contents: bytemuck::cast_slice(&tex_mesh.indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
self.textured_mesh = Some(TexturedMeshBuffers {
|
||||
vbo: tvbo,
|
||||
ibo: tibo,
|
||||
index_count: tex_mesh.indices.len() as u32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische
|
||||
@@ -783,6 +1021,33 @@ impl Renderer {
|
||||
self.globals.mode[0] = self.style.mesh_mode();
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
||||
|
||||
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
|
||||
// Queue bekommt — Signatur unveraendert). Danach steht sie fuer den Stil
|
||||
// `Textured` bereit; kostet ab dem zweiten Frame nichts mehr.
|
||||
if !self.texture_uploaded {
|
||||
let texel_data = build_checker_texture();
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&texel_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,
|
||||
},
|
||||
);
|
||||
self.texture_uploaded = true;
|
||||
}
|
||||
|
||||
self.ensure_depth(device, w, h);
|
||||
self.ensure_msaa(device, w, h);
|
||||
let depth_view = &self.depth.as_ref().unwrap().view;
|
||||
@@ -825,7 +1090,20 @@ impl Renderer {
|
||||
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
||||
// die folgenden Kanten sauber obenauf liegen.
|
||||
if self.style.draws_faces() {
|
||||
if let Some(m) = &self.mesh {
|
||||
if self.style == RenderStyle::Textured {
|
||||
// Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer
|
||||
// ([pos, normal, uv]) + zusaetzliche Textur-Bind-Group (group 1).
|
||||
// Globals bleiben group 0. Fehlt der texturierte Puffer (kein
|
||||
// Mesh geladen), wird nichts gezeichnet.
|
||||
if let Some(t) = &self.textured_mesh {
|
||||
pass.set_pipeline(&self.textured_pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_bind_group(1, &self.texture_bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, t.vbo.slice(..));
|
||||
pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..t.index_count, 0, 0..1);
|
||||
}
|
||||
} else if let Some(m) = &self.mesh {
|
||||
let face_pipeline = if self.style == RenderStyle::Hidden {
|
||||
&self.hidden_face_pipeline
|
||||
} else {
|
||||
|
||||
@@ -38,23 +38,27 @@ pub use math::{
|
||||
view_matrix, view_projection, Mat4,
|
||||
};
|
||||
pub use mesh::{
|
||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
|
||||
extrude_wall, triangulate,
|
||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
|
||||
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
|
||||
};
|
||||
pub use section::{
|
||||
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
|
||||
};
|
||||
pub use types::{
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
|
||||
FLOATS_PER_VERTEX,
|
||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
|
||||
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
|
||||
use super::types::{Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX};
|
||||
use super::mesh::{
|
||||
build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX,
|
||||
};
|
||||
use super::types::{
|
||||
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
||||
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
|
||||
@@ -80,6 +84,67 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturierter_pfad_ist_geometrisch_deckungsgleich_mit_shaded() {
|
||||
// Der texturierte Wand-Pfad (`build_walls_mesh_textured`, [pos,normal,uv])
|
||||
// muss GEOMETRISCH bitgleich zum Alt-Pfad (`build_walls_mesh`,
|
||||
// [pos,normal,color]) sein: gleiche Vertex-/Index-Zahl und -Reihenfolge,
|
||||
// identische Positionen und Normalen. Nur der letzte Kanal (Farbe -> UV)
|
||||
// unterscheidet sich. So ist belegt, dass der additive UV-Kanal den
|
||||
// Alt-Pfad nicht beruehrt.
|
||||
let walls = [wall_x(3.0, 0.2, 2.5)];
|
||||
let shaded = build_walls_mesh(&walls);
|
||||
let tex = build_walls_mesh_textured(&walls);
|
||||
assert_eq!(tex.indices, shaded.indices, "Index-Puffer identisch");
|
||||
assert_eq!(
|
||||
tex.vertex_count(),
|
||||
shaded.vertex_count(),
|
||||
"gleiche Vertex-Zahl"
|
||||
);
|
||||
for i in 0..shaded.vertex_count() {
|
||||
let sb = i * FLOATS_PER_VERTEX;
|
||||
let tb = i * TEXTURED_FLOATS_PER_VERTEX;
|
||||
// Position (0..3) und Normale (3..6) bitgleich uebernommen.
|
||||
for k in 0..6 {
|
||||
assert_eq!(
|
||||
shaded.verts[sb + k],
|
||||
tex.verts[tb + k],
|
||||
"Vertex {i} Kanal {k}: pos/normal muss identisch sein"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texturierte_uv_ist_weltmassstaeblich() {
|
||||
// UV-Massstab: `u`/`v` in Metern (1 Kachel = 1 m). Fuer eine Wand entlang +X
|
||||
// liegt die +Z-Mantelflaeche bei Normale (0,0,1); dort ist die Tangente
|
||||
// t=(-n.z,0,n.x)=(-1,0,0), also u = -x und v = Hoehe. Die Deckflaeche
|
||||
// (Normale +Y) projiziert auf u=x, v=z. Wir pruefen, dass es UEBERHAUPT
|
||||
// Vertices mit v == Wandhoehe (2.5) bzw. v == 0 gibt (Hoehe wandert als v
|
||||
// mit) — belegt den weltmassstaeblichen Hoehen-Kanal.
|
||||
let tex = build_walls_mesh_textured(&[wall_x(3.0, 0.2, 2.5)]);
|
||||
let n = tex.vertex_count();
|
||||
let mut saw_top = false;
|
||||
let mut saw_bottom = false;
|
||||
for i in 0..n {
|
||||
let b = i * TEXTURED_FLOATS_PER_VERTEX;
|
||||
let ny = tex.verts[b + 4];
|
||||
let v = tex.verts[b + 7];
|
||||
// Nur Mantelflaechen (ny ~ 0) tragen die Hoehe als v.
|
||||
if ny.abs() < 0.5 {
|
||||
if (v - 2.5).abs() < 1e-5 {
|
||||
saw_top = true;
|
||||
}
|
||||
if v.abs() < 1e-5 {
|
||||
saw_bottom = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(saw_top, "Mantel-Vertex mit v == Wandhoehe (2.5 m) erwartet");
|
||||
assert!(saw_bottom, "Mantel-Vertex mit v == 0 (Sockel) erwartet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eine_wand_hat_quader_zaehlung() {
|
||||
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
|
||||
@@ -927,5 +992,14 @@ mod tests {
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&cap_module)
|
||||
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
|
||||
// Texturierter Wand-Shader (RenderStyle::Textured, Bild-Textur in group 1)
|
||||
// ebenso headless validieren — faengt Textur-/Sampler-Bindungsfehler ab.
|
||||
let tex_src = super::shaders::MESH_TEXTURED_WGSL;
|
||||
let tex_module = naga::front::wgsl::parse_str(tex_src)
|
||||
.unwrap_or_else(|e| panic!("textured: WGSL-Parse-Fehler: {e:?}"));
|
||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&tex_module)
|
||||
.unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::openings::{split_range_by_voids, MIN_SPAN};
|
||||
use crate::types::{
|
||||
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer,
|
||||
FLOATS_PER_VERTEX,
|
||||
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer,
|
||||
FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
||||
};
|
||||
|
||||
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
|
||||
@@ -917,6 +917,72 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
/// TEXTURIERTER Wand-Pfad (`RenderStyle::Textured`): liefert DIESELBE Geometrie wie
|
||||
/// `build_walls_mesh` — Positionen und Normalen bitgleich, gleiche Vertex-/Dreiecks-
|
||||
/// Reihenfolge — aber mit einer planaren, WELTMASSSTAEBLICHEN UV je Vertex statt der
|
||||
/// Bauteilfarbe. Bewusst ALS ABLEITUNG aus dem fertigen `Mesh` implementiert (nicht
|
||||
/// als parallele Re-Extrusion): so bleibt die paritaets-/reihenfolgesensible
|
||||
/// Geometrie (Gehrungen, Oeffnungen, Loecher, Schichten) die EINE Quelle der
|
||||
/// Wahrheit und der Alt-Pfad `[pos, normal, color]` bleibt bitgleich unangetastet —
|
||||
/// wir tauschen nur den color-Kanal gegen den aus Position+Normale abgeleiteten
|
||||
/// UV-Kanal aus.
|
||||
///
|
||||
/// UV-PROJEKTION (planar, in Metern, damit das Textur-Raster weltmassstaeblich ist,
|
||||
/// z. B. 1 Kachel = 1 m; kein Verzug an Gehrungen/Ecken, weil jede Flaeche planar
|
||||
/// projiziert wird und benachbarte Waende dieselbe Welt-Projektion fortsetzen —
|
||||
/// die Kacheln stossen im Weltraum sauber aneinander):
|
||||
/// - Senkrechte Flaechen (Wand-Mantel, Normale ~ horizontal): `u` = Weglaenge
|
||||
/// entlang der horizontalen Flaechen-Tangente `t = (-n.z, 0, n.x)`
|
||||
/// (`u = dot(pos.xz, t.xz)`), `v` = Hoehe `pos.y`.
|
||||
/// - Waagerechte Flaechen (Deckel/Boden, Normale ~ +/-Y): direkt aus dem
|
||||
/// Grundriss `u = pos.x`, `v = pos.z`.
|
||||
/// Die Wahl der Achse allein aus der Flaechen-Normale (statt aus der Wand-Achse)
|
||||
/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie
|
||||
/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst.
|
||||
pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh {
|
||||
textured_from_mesh(&build_walls_mesh(walls))
|
||||
}
|
||||
|
||||
/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh`
|
||||
/// (`[pos, normal, uv]`) um: Positionen/Normalen/Indizes werden 1:1 uebernommen,
|
||||
/// die Farbe je Vertex wird durch die planare Welt-UV ersetzt (siehe
|
||||
/// `build_walls_mesh_textured` fuer die Achswahl). Indizes bleiben unveraendert,
|
||||
/// weil sich die Vertex-Anzahl/-Reihenfolge nicht aendert. `pub`, damit die
|
||||
/// GPU-Schicht (`gpu.rs`) das texturierte Pendant AUS DEMSELBEN `Mesh` ableiten
|
||||
/// kann, das sie ohnehin schon gebaut/hochgeladen hat (eine Geometrie-Quelle).
|
||||
pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh {
|
||||
let n = mesh.vertex_count();
|
||||
let mut verts: Vec<f32> = Vec::with_capacity(n * TEXTURED_FLOATS_PER_VERTEX);
|
||||
for i in 0..n {
|
||||
let b = i * FLOATS_PER_VERTEX;
|
||||
let px = mesh.verts[b];
|
||||
let py = mesh.verts[b + 1];
|
||||
let pz = mesh.verts[b + 2];
|
||||
let nx = mesh.verts[b + 3];
|
||||
let ny = mesh.verts[b + 4];
|
||||
let nz = mesh.verts[b + 5];
|
||||
// Waagerecht (Deckel/Boden) vs. senkrecht (Mantel) an der Vertikal-
|
||||
// Komponente der Normale unterscheiden. Waende sind vertikal extrudiert,
|
||||
// ihre Mantelnormale ist streng horizontal (ny == 0) — die Schwelle 0.5
|
||||
// trennt sauber Deckel/Boden (|ny| == 1) von Mantelflaechen (ny == 0).
|
||||
let (u, v) = if ny.abs() > 0.5 {
|
||||
// Grundriss-Projektion: u = x, v = z (Meter).
|
||||
(px, pz)
|
||||
} else {
|
||||
// Horizontale Flaechen-Tangente t = (-n.z, 0, n.x): u = Weglaenge
|
||||
// entlang t, v = Hoehe. `t` ist ein Einheitsvektor, weil (n.x, n.z)
|
||||
// fuer Mantelflaechen normiert ist.
|
||||
let u = px * (-nz) + pz * nx;
|
||||
(u, py)
|
||||
};
|
||||
verts.extend_from_slice(&[px, py, pz, nx, ny, nz, u, v]);
|
||||
}
|
||||
TexturedMesh {
|
||||
verts,
|
||||
indices: mesh.indices.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
|
||||
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
||||
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
||||
|
||||
@@ -125,6 +125,89 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
"#;
|
||||
|
||||
/// WGSL des TEXTURIERTEN Wand-Pfades (`RenderStyle::Textured`, gpu::textured_pipeline).
|
||||
/// GLEICHE Beleuchtung wie `MESH_WGSL` (hemisphaerisches Ambient Himmel/Boden +
|
||||
/// Lambert-Directional + Gegen-Fuelllicht + Kantenbetonung) — nur die ALBEDO kommt
|
||||
/// aus einer Bild-Textur (`textureSample`) statt aus der Vertexfarbe. Vertex-Layout
|
||||
/// [pos vec3, normal vec3, uv vec2]; `Globals` bleibt group(0) binding(0) unveraendert,
|
||||
/// die Textur haengt in group(1). Die Weiss-/Hidden-Modi (`mode.x`) sind hier nicht
|
||||
/// relevant: `Textured` reicht `mode.x == 0` (Material) durch (siehe gpu.rs).
|
||||
pub const MESH_TEXTURED_WGSL: &str = r#"
|
||||
struct Globals {
|
||||
view_proj : mat4x4<f32>,
|
||||
light_dir : vec4<f32>,
|
||||
sky_color : vec4<f32>,
|
||||
ground_color : vec4<f32>,
|
||||
sun_color : vec4<f32>,
|
||||
mode : vec4<f32>,
|
||||
section_plane : vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> globals : Globals;
|
||||
|
||||
// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) ->
|
||||
// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen.
|
||||
@group(1) @binding(0) var tex : texture_2d<f32>;
|
||||
@group(1) @binding(1) var samp : sampler;
|
||||
|
||||
struct VsIn {
|
||||
@location(0) position : vec3<f32>,
|
||||
@location(1) normal : vec3<f32>,
|
||||
@location(2) uv : vec2<f32>,
|
||||
};
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) clip_pos : vec4<f32>,
|
||||
@location(0) world_normal : vec3<f32>,
|
||||
@location(1) uv : vec2<f32>,
|
||||
@location(2) world_pos : vec3<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(in : VsIn) -> VsOut {
|
||||
var out : VsOut;
|
||||
out.clip_pos = globals.view_proj * vec4<f32>(in.position, 1.0);
|
||||
out.world_normal = in.normal;
|
||||
out.uv = in.uv;
|
||||
out.world_pos = in.position;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
|
||||
// Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv).
|
||||
if (globals.mode.y > 0.5) {
|
||||
if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
let n = normalize(in.world_normal);
|
||||
let l = normalize(globals.light_dir.xyz);
|
||||
|
||||
// Hemisphaerisches Ambient (identisch zu MESH_WGSL).
|
||||
let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t);
|
||||
|
||||
// Gerichteter Lambert-Anteil (Sonne).
|
||||
let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb;
|
||||
|
||||
// Gegen-Fuelllicht (identisch zu MESH_WGSL).
|
||||
let fill_l = normalize(vec3<f32>(-l.x, abs(l.y) * 0.35 + 0.15, -l.z));
|
||||
let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb
|
||||
* vec3<f32>(0.24, 0.27, 0.30);
|
||||
|
||||
// EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe.
|
||||
let albedo = textureSample(tex, samp, in.uv).rgb;
|
||||
|
||||
var shaded = albedo * (ambient + diffuse + fill);
|
||||
|
||||
// Sanfte Kantenbetonung (identisch zu MESH_WGSL).
|
||||
let edge = 0.90 + 0.10 * abs(n.y);
|
||||
shaded = shaded * edge;
|
||||
|
||||
return vec4<f32>(shaded, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
|
||||
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
||||
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
||||
|
||||
@@ -333,6 +333,30 @@ pub struct Mesh {
|
||||
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
|
||||
pub const FLOATS_PER_VERTEX: usize = 9;
|
||||
|
||||
/// Ausgabe der TEXTURIERTEN Mesh-Erzeugung (`RenderStyle::Textured`): interleaved
|
||||
/// [pos.xyz, normal.xyz, uv.xy] je Vertex plus Indexpuffer. Ein ZWEITER, additiver
|
||||
/// Vertex-Pfad neben `Mesh` — der bestehende `[pos, normal, color]`-Pfad (`Mesh`)
|
||||
/// bleibt dadurch bitgleich unangetastet. Genutzt nur mit den GPU-Features
|
||||
/// (`render`/`window`); serde-frei (reine GPU-Zwischenform), damit der Default-Bau
|
||||
/// ohne GPU nichts davon zieht.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TexturedMesh {
|
||||
/// Interleaved Vertices: je 8 f32 = [px,py,pz, nx,ny,nz, u,v].
|
||||
pub verts: Vec<f32>,
|
||||
/// Dreiecks-Indizes (0-basiert auf die Vertices).
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV.
|
||||
pub const TEXTURED_FLOATS_PER_VERTEX: usize = 8;
|
||||
|
||||
impl TexturedMesh {
|
||||
/// Anzahl Vertices im Puffer.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
self.verts.len() / TEXTURED_FLOATS_PER_VERTEX
|
||||
}
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Anzahl Vertices im Puffer.
|
||||
pub fn vertex_count(&self) -> usize {
|
||||
|
||||
+13
@@ -2337,6 +2337,16 @@ export default function App() {
|
||||
onSetOpeningDir: (openingDir) => {
|
||||
if (selection?.kind === "opening") updateOpening(selection.id, { openingDir });
|
||||
},
|
||||
onSetOpeningWingCount: (wingCount) => {
|
||||
if (selection?.kind === "opening")
|
||||
updateOpening(selection.id, { wingCount: Math.max(1, Math.min(4, Math.round(wingCount))) });
|
||||
},
|
||||
onSetOpeningDoorType: (doorType) => {
|
||||
if (selection?.kind === "opening") updateOpening(selection.id, { doorType });
|
||||
},
|
||||
onSetOpeningLintelLines: (lintelLines) => {
|
||||
if (selection?.kind === "opening") updateOpening(selection.id, { lintelLines });
|
||||
},
|
||||
// ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
|
||||
onSetStairShape: (shape) => {
|
||||
if (selection?.kind === "stair") updateStair(selection.id, { shape });
|
||||
@@ -2356,6 +2366,9 @@ export default function App() {
|
||||
onSetStairUp: (up) => {
|
||||
if (selection?.kind === "stair") updateStair(selection.id, { up });
|
||||
},
|
||||
onSetStairReferenz: (referenz) => {
|
||||
if (selection?.kind === "stair") updateStair(selection.id, { referenz });
|
||||
},
|
||||
// ── Raum-Attribute (nur bei selektiertem Raum) ─────────────────────────
|
||||
onSetRoomName: (name) => {
|
||||
if (selection?.kind === "room") updateRoom(selection.id, { name });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Unit-Tests für `stepsFromTread` — Stufenzahl-Ableitung aus Soll-Auftritt + Lauflänge.
|
||||
*
|
||||
* Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler,
|
||||
* Variable `target_a` = Soll-Auftritt).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stepsFromTread } from "./stair";
|
||||
import { MIN_STEPS, IDEAL_TREAD } from "../../geometry/stair";
|
||||
|
||||
describe("stepsFromTread — Grundfälle", () => {
|
||||
it("ergibt das ideale Soll-Trittmass bei exakter Teilbarkeit", () => {
|
||||
// 10 × 0.29 m = 2.9 m → 10 Stufen
|
||||
const steps = stepsFromTread(IDEAL_TREAD, 10 * IDEAL_TREAD);
|
||||
expect(steps).toBe(10);
|
||||
});
|
||||
|
||||
it("rundet auf die nächste ganze Stufenzahl", () => {
|
||||
// 3.0 / 0.29 ≈ 10.34 → 10
|
||||
expect(stepsFromTread(0.29, 3.0)).toBe(10);
|
||||
// 3.1 / 0.29 ≈ 10.69 → 11
|
||||
expect(stepsFromTread(0.29, 3.1)).toBe(11);
|
||||
});
|
||||
|
||||
it("bleibt mindestens bei MIN_STEPS", () => {
|
||||
// Sehr kurze Lauflänge → immer MIN_STEPS (2)
|
||||
expect(stepsFromTread(0.29, 0.01)).toBe(MIN_STEPS);
|
||||
});
|
||||
|
||||
it("klemmt nach oben: Stufenzahl durch 0.24-m-Auftritts-Grenze begrenzt", () => {
|
||||
// 10 m Lauflänge, Auftritt 0.01 m → raw = 1000, aber durch Clamp auf
|
||||
// floor(10/0.24)+1 = 42 begrenzt. Der resultierende Auftritt (≈ 0.238 m)
|
||||
// ist der Grenzwert der floor-Formel aus defaultStepCount — kein echter Bug,
|
||||
// sondern die bewusste Clamp-Schwelle (≥ min ~ 0.24 m, off-by-one durch +1).
|
||||
const steps = stepsFromTread(0.01, 10.0);
|
||||
// Die Stufenzahl muss deutlich kleiner als 1000 sein (Clamp wirkt).
|
||||
expect(steps).toBeLessThan(100);
|
||||
expect(steps).toBeGreaterThanOrEqual(MIN_STEPS);
|
||||
// Auftrittstiefe muss im vernünftigen Bereich liegen (nahe 0.24 m).
|
||||
const treadDepth = 10.0 / steps;
|
||||
expect(treadDepth).toBeGreaterThanOrEqual(0.23 - 1e-6);
|
||||
expect(treadDepth).toBeLessThanOrEqual(0.30);
|
||||
});
|
||||
|
||||
it("typische Nutzung: Lauflänge 3 m, Auftritt 0.29 m → ca. 10 Stufen", () => {
|
||||
const steps = stepsFromTread(0.29, 3.0);
|
||||
expect(steps).toBeGreaterThanOrEqual(MIN_STEPS);
|
||||
// Auftrittstiefe nahe am Soll
|
||||
const tread = 3.0 / steps;
|
||||
expect(tread).toBeGreaterThan(0.20);
|
||||
expect(tread).toBeLessThan(0.40);
|
||||
});
|
||||
|
||||
it("nullartige Auftritt-Eingabe wird intern auf 0.05 m geklippt", () => {
|
||||
// Sollte nicht abstürzen, Ergebnis durch 0.24-m-Clamp begrenzt.
|
||||
const steps = stepsFromTread(0, 3.0);
|
||||
expect(steps).toBeGreaterThanOrEqual(MIN_STEPS);
|
||||
});
|
||||
|
||||
it("negative Lauflänge → MIN_STEPS", () => {
|
||||
expect(stepsFromTread(0.29, -1)).toBe(MIN_STEPS);
|
||||
});
|
||||
|
||||
it("Auftritt 0.25 m, Lauflänge 2.5 m → 10 Stufen (exakt)", () => {
|
||||
expect(stepsFromTread(0.25, 2.5)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepsFromTread — Grenzwerte / Konsistenz", () => {
|
||||
it("liefert immer ganze Zahlen", () => {
|
||||
for (const run of [1.0, 2.5, 3.14, 5.0, 10.0]) {
|
||||
const steps = stepsFromTread(0.29, run);
|
||||
expect(Number.isInteger(steps)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("resultierender Auftritt liegt stets im erlaubten Bereich (>= 0.24 m)", () => {
|
||||
for (const run of [1.0, 2.0, 3.0, 5.0]) {
|
||||
const steps = stepsFromTread(IDEAL_TREAD, run);
|
||||
const tread = run / steps;
|
||||
expect(tread).toBeGreaterThanOrEqual(0.24 - 1e-6);
|
||||
}
|
||||
});
|
||||
});
|
||||
+175
-22
@@ -4,9 +4,12 @@
|
||||
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
|
||||
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
|
||||
//
|
||||
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
|
||||
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
|
||||
// zeigt die Tritt-Linien + die Lauflinie.
|
||||
// Optionen (togglebar, IMMER sichtbar): Grundform gerade/L/Wendel, Auf-/Ab-
|
||||
// Umschalter, Referenz (links/mitte/rechts), Trittmass-Modus (mit/ohne).
|
||||
// Tab-Felder in BEIDEN Phasen: Breite (idle + run). Im Laufschritt zusätzlich:
|
||||
// • Trittmass-Modus „ohne": Stufen + Steighöhe (heutiges Verhalten).
|
||||
// • Trittmass-Modus „mit" : Auftritt (Soll-Trittmass); Stufenzahl wird daraus
|
||||
// abgeleitet: steps = clamp(round(runLength / auftritt), MIN_STEPS, …).
|
||||
//
|
||||
// Herkunft der Treppen-Felder (CommandContext):
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
@@ -17,7 +20,7 @@
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||
|
||||
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
|
||||
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
|
||||
import { stairGeometry, defaultStepCount, IDEAL_TREAD, MIN_STEPS } from "../../geometry/stair";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
@@ -38,6 +41,35 @@ const STAIR_CATEGORY = "40";
|
||||
const DEF_WIDTH = 1.0;
|
||||
const EPS = 1e-6;
|
||||
|
||||
// ── Trittmass-Modus ──────────────────────────────────────────────────────────
|
||||
/** Trittmass-Modus: „with" = Soll-Auftritt vorgegeben, Stufen abgeleitet;
|
||||
* „without" = heutiges Verhalten (freies Stufen-Feld). */
|
||||
type TreadMode = "with" | "without";
|
||||
|
||||
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
|
||||
/** Letzte Nutzer-Einstellungen für Treppen; wird von jeder neuen Instanz gelesen
|
||||
* und nach jedem Commit aktualisiert. */
|
||||
const stairDefaults: {
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
referenz: "links" | "mitte" | "rechts";
|
||||
treadMode: TreadMode;
|
||||
auftritt: number; // Soll-Trittmass (Meter)
|
||||
steps: number | null;
|
||||
rise: number | null;
|
||||
} = {
|
||||
shape: "straight",
|
||||
up: true,
|
||||
width: DEF_WIDTH,
|
||||
referenz: "mitte",
|
||||
treadMode: "without",
|
||||
auftritt: IDEAL_TREAD,
|
||||
steps: null,
|
||||
rise: null,
|
||||
};
|
||||
|
||||
// ── Vektor-Helfer ─────────────────────────────────────────────────────────────
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const normv = (a: Vec2): Vec2 => {
|
||||
@@ -65,21 +97,45 @@ function floorRise(ctx: CommandContext): number {
|
||||
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
|
||||
}
|
||||
|
||||
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
|
||||
const RUN_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "steps", labelKey: "cmd.stair.stepsField" },
|
||||
{ id: "rise", labelKey: "cmd.stair.riseField" },
|
||||
];
|
||||
// ── Trittmass-Ableitung ───────────────────────────────────────────────────────
|
||||
/**
|
||||
* Leitet die Stufenzahl aus Auftritt (Soll-Trittmass) + Lauflänge ab.
|
||||
* Klemmt auf [MIN_STEPS, maxByRun] (mind. 0.24 m Auftrittstiefe).
|
||||
* Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler).
|
||||
*/
|
||||
export function stepsFromTread(auftritt: number, runLength: number): number {
|
||||
const a = Math.max(0.05, auftritt);
|
||||
const raw = Math.round(runLength / a);
|
||||
// Auftrittstiefe aus Stufenzahl darf nicht unter 0.24 m sinken.
|
||||
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : raw;
|
||||
return Math.max(MIN_STEPS, Math.min(raw, maxByRun));
|
||||
}
|
||||
|
||||
// ── Felder (Tab-Zyklus) ───────────────────────────────────────────────────────
|
||||
/** Tab-Feld: Breite (immer, ab idle). */
|
||||
const FIELD_WIDTH: CommandField = { id: "width", labelKey: "cmd.field.width" };
|
||||
/** Tab-Feld: Stufenanzahl (run-Phase, nur Trittmass-Modus „ohne"). */
|
||||
const FIELD_STEPS: CommandField = { id: "steps", labelKey: "cmd.stair.stepsField" };
|
||||
/** Tab-Feld: Steighöhe (run-Phase, nur Trittmass-Modus „ohne"). */
|
||||
const FIELD_RISE: CommandField = { id: "rise", labelKey: "cmd.stair.riseField" };
|
||||
/** Tab-Feld: Auftritt (run-Phase, nur Trittmass-Modus „mit"). */
|
||||
const FIELD_AUFTRITT: CommandField = { id: "auftritt", labelKey: "cmd.stair.auftrittField" };
|
||||
|
||||
// ── Optionen (immer sichtbar) ─────────────────────────────────────────────────
|
||||
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
|
||||
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
|
||||
const REFERENZ: CmdOption = { id: "referenz", labelKey: "cmd.stair.referenz", value: "mitte" };
|
||||
const TREAD_MODE: CmdOption = { id: "treadMode", labelKey: "cmd.stair.treadMode", value: "without" };
|
||||
|
||||
// ── Zustands-Typen ────────────────────────────────────────────────────────────
|
||||
interface StairIdle extends CommandState {
|
||||
phase: "start";
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
referenz: "links" | "mitte" | "rechts";
|
||||
treadMode: TreadMode;
|
||||
auftritt: number;
|
||||
steps: number | null; // null = automatisch
|
||||
rise: number | null; // null = Geschosshöhe
|
||||
}
|
||||
@@ -88,6 +144,9 @@ interface StairRun extends CommandState {
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
referenz: "links" | "mitte" | "rechts";
|
||||
treadMode: TreadMode;
|
||||
auftritt: number;
|
||||
steps: number | null;
|
||||
rise: number | null;
|
||||
start: Vec2;
|
||||
@@ -95,14 +154,30 @@ interface StairRun extends CommandState {
|
||||
}
|
||||
type StairCmdState = StairIdle | StairRun;
|
||||
|
||||
function initState(shape: StairShape = "straight"): StairIdle {
|
||||
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
|
||||
/** Neuer Ruhezustand aus den modulweiten Defaults (oder überschrieben). */
|
||||
function initState(override?: Partial<typeof stairDefaults>): StairIdle {
|
||||
const d = { ...stairDefaults, ...override };
|
||||
return {
|
||||
phase: "start",
|
||||
lastPoint: null,
|
||||
shape: d.shape,
|
||||
up: d.up,
|
||||
width: d.width,
|
||||
referenz: d.referenz,
|
||||
treadMode: d.treadMode,
|
||||
auftritt: d.auftritt,
|
||||
steps: d.steps,
|
||||
rise: d.rise,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
|
||||
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
|
||||
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
|
||||
*
|
||||
* Im Trittmass-Modus „mit" wird die Stufenzahl aus dem Soll-Auftritt + der
|
||||
* aktuellen Lauflänge abgeleitet (stepsFromTread), kein freies steps-Feld.
|
||||
*/
|
||||
function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
const rise = s.rise ?? floorRise(ctx);
|
||||
@@ -110,7 +185,15 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
const dist = Math.max(0.1, lenOf(d));
|
||||
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
|
||||
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
|
||||
const steps = s.steps ?? defaultStepCount(rise, runLength);
|
||||
|
||||
// Stufenzahl je Trittmass-Modus:
|
||||
// „mit Trittmass" → aus Auftritt + Lauflänge ableiten (Soll-Schrittmass).
|
||||
// „ohne Trittmass" → manuell oder defaultStepCount (heutiges Verhalten).
|
||||
const steps =
|
||||
s.treadMode === "with"
|
||||
? stepsFromTread(s.auftritt, runLength)
|
||||
: (s.steps ?? defaultStepCount(rise, runLength));
|
||||
|
||||
const base: Stair = {
|
||||
id: uniqueId("ST"),
|
||||
type: "stair",
|
||||
@@ -121,6 +204,7 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
dir,
|
||||
runLength,
|
||||
width: s.width,
|
||||
referenz: s.referenz,
|
||||
totalRise: rise,
|
||||
stepCount: steps,
|
||||
up: s.up,
|
||||
@@ -156,18 +240,42 @@ function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Treppe ans Projekt (immutabel). */
|
||||
/** Hängt eine Treppe ans Projekt (immutabel) und aktualisiert die Defaults. */
|
||||
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
|
||||
const stair = buildStair(s, ctx);
|
||||
// Zuletzt genutzte Einstellungen für die nächste Treppe merken.
|
||||
stairDefaults.shape = s.shape;
|
||||
stairDefaults.up = s.up;
|
||||
stairDefaults.width = s.width;
|
||||
stairDefaults.referenz = s.referenz;
|
||||
stairDefaults.treadMode = s.treadMode;
|
||||
stairDefaults.auftritt = s.auftritt;
|
||||
stairDefaults.steps = s.steps;
|
||||
stairDefaults.rise = s.rise;
|
||||
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
|
||||
return { ...p, stairs };
|
||||
}
|
||||
|
||||
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
|
||||
initState(from?.shape ?? "straight"),
|
||||
initState(from ? {
|
||||
shape: from.shape,
|
||||
up: from.up,
|
||||
width: from.width,
|
||||
referenz: from.referenz,
|
||||
treadMode: from.treadMode,
|
||||
auftritt: from.auftritt,
|
||||
steps: from.steps,
|
||||
rise: from.rise,
|
||||
} : undefined),
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
// ── Referenz-Toggle-Zyklus ────────────────────────────────────────────────────
|
||||
function nextReferenz(r: "links" | "mitte" | "rechts"): "links" | "mitte" | "rechts" {
|
||||
return r === "links" ? "mitte" : r === "mitte" ? "rechts" : "links";
|
||||
}
|
||||
|
||||
// ── Command-Export ────────────────────────────────────────────────────────────
|
||||
export const stairCommand: Command = {
|
||||
name: "stair",
|
||||
labelKey: "cmd.stair.label",
|
||||
@@ -177,14 +285,20 @@ export const stairCommand: Command = {
|
||||
(s as StairCmdState).phase === "run"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
|
||||
/** Optionen werden IMMER angezeigt (idle + run): Grundform, Auf/Ab, Referenz,
|
||||
* Trittmass-Modus. */
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as StairCmdState;
|
||||
return [
|
||||
{ ...SHAPE, value: st.shape },
|
||||
{ ...UPDOWN, value: st.up ? "up" : "down" },
|
||||
{ ...REFERENZ, value: st.referenz },
|
||||
{ ...TREAD_MODE, value: st.treadMode },
|
||||
];
|
||||
},
|
||||
init: (): StairIdle => initState("straight"),
|
||||
|
||||
init: (): StairIdle => initState(),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
@@ -195,6 +309,12 @@ export const stairCommand: Command = {
|
||||
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
|
||||
} else if (input.id === "updown") {
|
||||
next.up = !s.up;
|
||||
} else if (input.id === "referenz") {
|
||||
next.referenz = nextReferenz(s.referenz);
|
||||
} else if (input.id === "treadMode") {
|
||||
next.treadMode = s.treadMode === "with" ? "without" : "with";
|
||||
// Im „mit"-Modus freie Stufenzahl zurücksetzen (wird abgeleitet).
|
||||
if (next.treadMode === "with") next.steps = null;
|
||||
}
|
||||
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
|
||||
}
|
||||
@@ -211,6 +331,9 @@ export const stairCommand: Command = {
|
||||
shape: s.shape,
|
||||
up: s.up,
|
||||
width: s.width,
|
||||
referenz: s.referenz,
|
||||
treadMode: s.treadMode,
|
||||
auftritt: s.auftritt,
|
||||
steps: s.steps,
|
||||
rise: s.rise,
|
||||
start: pt,
|
||||
@@ -249,20 +372,50 @@ export const stairCommand: Command = {
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
|
||||
|
||||
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
|
||||
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||
/**
|
||||
* Tab-Felder:
|
||||
* • idle-Phase: [Breite] (Konfiguration VOR dem ersten Punkt).
|
||||
* • run-Phase, Trittmass-Modus „ohne": [Breite, Stufen, Steighöhe].
|
||||
* • run-Phase, Trittmass-Modus „mit" : [Breite, Auftritt].
|
||||
*/
|
||||
fields: (state): CommandField[] => {
|
||||
const s = state as StairCmdState;
|
||||
if (s.phase === "start") {
|
||||
return [FIELD_WIDTH];
|
||||
}
|
||||
// run-Phase
|
||||
if (s.treadMode === "with") {
|
||||
return [FIELD_WIDTH, FIELD_AUFTRITT];
|
||||
}
|
||||
return [FIELD_WIDTH, FIELD_STEPS, FIELD_RISE];
|
||||
},
|
||||
|
||||
/** Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt. */
|
||||
pointFromFields: (state, locks) => {
|
||||
const s = state as StairCmdState;
|
||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
|
||||
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||
if (s.phase === "run") {
|
||||
if ("auftritt" in locks && locks.auftritt > 0) s.auftritt = locks.auftritt;
|
||||
// Stufen + Steighöhe nur im „ohne"-Modus als freie Felder.
|
||||
if (s.treadMode === "without") {
|
||||
if ("steps" in locks && locks.steps >= MIN_STEPS) s.steps = Math.round(locks.steps);
|
||||
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
fieldValues: (state, _locks, _cursor): Record<string, number> => {
|
||||
const s = state as StairCmdState;
|
||||
const out: Record<string, number> = { width: s.width };
|
||||
if (s.steps != null) out.steps = s.steps;
|
||||
if (s.rise != null) out.rise = s.rise;
|
||||
if (s.phase === "run") {
|
||||
if (s.treadMode === "with") {
|
||||
out.auftritt = s.auftritt;
|
||||
} else {
|
||||
if (s.steps != null) out.steps = s.steps;
|
||||
if (s.rise != null) out.rise = s.rise;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,980 @@
|
||||
// Differential-Paritaet: Rust-WASM-Kernel (`src-tauri/kernel2d`, Feature "web")
|
||||
// gegen die TS-Referenz `kernel2d.ts` — auf identischen Eingaben muessen beide
|
||||
// bis auf ein funktionsspezifisches Epsilon dasselbe liefern (siehe PORT_PLAN §5).
|
||||
// Kern der Migration: beweist, dass der Port die Semantik bitnah erhaelt.
|
||||
//
|
||||
// VORAUSSETZUNG: `npm run build:kernel2d` muss vorher gelaufen sein (das Paket
|
||||
// `src/engine/pkgKernel2d` ist git-ignoriert). Init synchron via `initSync` mit
|
||||
// den WASM-Bytes aus `readFileSync` (kein fetch im Node-Lauf).
|
||||
//
|
||||
// Zwei Testklassen, NIE gemischt: Zufalls-Paritaet (naiv==naiv) und Golden
|
||||
// (explizite Grenzfaelle). Robuste Praedikate sind hier NICHT im Spiel (v1).
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
import {
|
||||
circleCircleIntersect,
|
||||
closestPointOnSegment,
|
||||
extendSegment,
|
||||
filletCorner,
|
||||
isCCW,
|
||||
joinChains,
|
||||
lineCircleIntersect,
|
||||
lineSegmentIntersect,
|
||||
offsetPolyline,
|
||||
offsetSegment,
|
||||
pointSegmentDistance,
|
||||
projectParam,
|
||||
removeSegment,
|
||||
segmentCircleIntersect,
|
||||
segmentIntersect,
|
||||
segmentPolylineHits,
|
||||
signedArea,
|
||||
splitAtIntersections,
|
||||
splitClosedByChord,
|
||||
splitPolylineAtParam,
|
||||
splitSegmentByCutters,
|
||||
trimPolyline,
|
||||
trimSegment,
|
||||
type Fillet,
|
||||
type Hit,
|
||||
} from "./kernel2d";
|
||||
import {
|
||||
ceilingArea,
|
||||
isValidOutline,
|
||||
normalizeOutline,
|
||||
outlineBBox,
|
||||
outlineCentroid,
|
||||
pointInOutline,
|
||||
} from "./ceiling";
|
||||
import { centroid, perimeter, polygonArea } from "./roomArea";
|
||||
import {
|
||||
defaultStepCount,
|
||||
pointHitsStair,
|
||||
pointInPolygon as stairPointInPolygon,
|
||||
stairBBox,
|
||||
stairCut,
|
||||
stairGeometry,
|
||||
type StairGeometry as TSStairGeometry,
|
||||
} from "./stair";
|
||||
import type { Stair } from "../model/types";
|
||||
import {
|
||||
detectRooms,
|
||||
pointInPolygon as rbPointInPolygon,
|
||||
roomFromPointInside,
|
||||
roomFromPointInsideFaces,
|
||||
type WallSegment,
|
||||
type WallFace,
|
||||
} from "./roomBoundary";
|
||||
|
||||
type Poly = { pts: Vec2[]; closed: boolean };
|
||||
|
||||
// Das WASM-Paket ist git-ignoriert und wird nur von `npm run build:kernel2d`
|
||||
// erzeugt. Fehlt es, wird die Suite SAUBER uebersprungen (statt Collection-Fehler),
|
||||
// damit `vitest run` ohne vorherigen WASM-Build gruen bleibt. Darum dynamischer
|
||||
// Import erst in `beforeAll` (kein statischer Top-Level-Import des Pakets).
|
||||
type Batch = (json: string) => string;
|
||||
const wasmPath = fileURLToPath(
|
||||
new URL("../engine/pkgKernel2d/kernel2d_bg.wasm", import.meta.url),
|
||||
);
|
||||
const built = existsSync(wasmPath);
|
||||
let K: Record<string, Batch>;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!built) return;
|
||||
const m = await import("../engine/pkgKernel2d/kernel2d.js");
|
||||
(m as { initSync: (o: { module: Buffer }) => unknown }).initSync({
|
||||
module: readFileSync(wasmPath),
|
||||
});
|
||||
K = m as unknown as Record<string, Batch>;
|
||||
});
|
||||
|
||||
if (!built) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[kernel2d.parity] pkgKernel2d fehlt — `npm run build:kernel2d` fuer den Diff-Test noetig. Uebersprungen.",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Seed-basierter RNG (mulberry32), deterministisch ─────────────────────────
|
||||
function mulberry32(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Koordinate in [-100,100] m, mit Clustern nahe 0 und im 1e-6..1e-3-Bereich. */
|
||||
function coord(rng: () => number): number {
|
||||
const r = rng();
|
||||
if (r < 0.15) return (rng() * 2 - 1) * 1e-3; // nahe 0
|
||||
if (r < 0.25) return (rng() * 2 - 1) * 1e-6 + Math.round(rng() * 4 - 2); // Schwellen
|
||||
return (rng() * 2 - 1) * 100;
|
||||
}
|
||||
const v = (rng: () => number): Vec2 => ({ x: coord(rng), y: coord(rng) });
|
||||
|
||||
/** Zwei sich garantiert schneidende Strecken um ein Zentrum c. */
|
||||
function crossingSegs(rng: () => number) {
|
||||
const c = v(rng);
|
||||
const ang1 = rng() * Math.PI;
|
||||
const ang2 = ang1 + 0.2 + rng() * (Math.PI - 0.4); // nicht (fast) parallel
|
||||
const d1 = { x: Math.cos(ang1), y: Math.sin(ang1) };
|
||||
const d2 = { x: Math.cos(ang2), y: Math.sin(ang2) };
|
||||
const ext = () => 0.1 + rng() * 3;
|
||||
return {
|
||||
a1: { x: c.x - d1.x * ext(), y: c.y - d1.y * ext() },
|
||||
a2: { x: c.x + d1.x * ext(), y: c.y + d1.y * ext() },
|
||||
b1: { x: c.x - d2.x * ext(), y: c.y - d2.y * ext() },
|
||||
b2: { x: c.x + d2.x * ext(), y: c.y + d2.y * ext() },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Vergleichs-Helfer (Struktur zuerst, dann Werte mit op-Epsilon) ───────────
|
||||
/** Abs-ODER-relative Toleranz: eng bei ~1, skaliert bei grossen Werten. */
|
||||
function closeNum(a: number, b: number, rel = 1e-9): boolean {
|
||||
return Math.abs(a - b) <= rel * Math.max(1, Math.abs(a), Math.abs(b));
|
||||
}
|
||||
function closeVec(a: Vec2, b: Vec2, rel = 1e-9): boolean {
|
||||
return closeNum(a.x, b.x, rel) && closeNum(a.y, b.y, rel);
|
||||
}
|
||||
function closeHit(a: Hit, b: Hit): boolean {
|
||||
return closeVec(a.point, b.point) && closeNum(a.t, b.t) && closeNum(a.s, b.s);
|
||||
}
|
||||
function eqPts(w: Vec2[], t: Vec2[]): boolean {
|
||||
return w.length === t.length && t.every((p, i) => closeVec(w[i], p));
|
||||
}
|
||||
function eqPtsLists(w: Vec2[][], t: Vec2[][]): boolean {
|
||||
return w.length === t.length && t.every((s, i) => eqPts(w[i], s));
|
||||
}
|
||||
function eqPolyList(w: Poly[], t: Poly[]): boolean {
|
||||
return w.length === t.length && t.every((pl, i) => w[i].closed === pl.closed && eqPts(w[i].pts, pl.pts));
|
||||
}
|
||||
function eqPairs(w: [Vec2, Vec2][], t: [Vec2, Vec2][]): boolean {
|
||||
return w.length === t.length && t.every((pr, i) => closeVec(w[i][0], pr[0]) && closeVec(w[i][1], pr[1]));
|
||||
}
|
||||
/** Ein paar lange Schneider-Segmente quer durch die BBox (als offene Cutter). */
|
||||
function genCutters(rng: () => number): Poly[] {
|
||||
const k = 1 + Math.floor(rng() * 3);
|
||||
return Array.from({ length: k }, () => ({
|
||||
pts: [
|
||||
{ x: -80, y: (rng() * 2 - 1) * 60 },
|
||||
{ x: 80, y: (rng() * 2 - 1) * 60 },
|
||||
],
|
||||
closed: false,
|
||||
}));
|
||||
}
|
||||
function genPolyline(rng: () => number, minN: number, maxN: number): Vec2[] {
|
||||
const n = minN + Math.floor(rng() * (maxN - minN + 1));
|
||||
return Array.from({ length: n }, () => ({ x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 }));
|
||||
}
|
||||
|
||||
const N = 300;
|
||||
|
||||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Zufall", () => {
|
||||
it("projectParam / closestPointOnSegment / pointSegmentDistance", () => {
|
||||
const rng = mulberry32(1);
|
||||
const qs = Array.from({ length: N }, () => ({ p: v(rng), a: v(rng), b: v(rng) }));
|
||||
|
||||
const wProj = JSON.parse(K.project_param_batch_json(JSON.stringify(qs))) as number[];
|
||||
const wClose = JSON.parse(K.closest_point_batch_json(JSON.stringify(qs))) as Vec2[];
|
||||
const wDist = JSON.parse(K.point_segment_distance_batch_json(JSON.stringify(qs))) as number[];
|
||||
|
||||
qs.forEach((q, i) => {
|
||||
expect(closeNum(wProj[i], projectParam(q.p, q.a, q.b)), `proj#${i}`).toBe(true);
|
||||
expect(closeVec(wClose[i], closestPointOnSegment(q.p, q.a, q.b)), `close#${i}`).toBe(true);
|
||||
expect(closeNum(wDist[i], pointSegmentDistance(q.p, q.a, q.b)), `dist#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("segmentIntersect / lineSegmentIntersect (Zufall + garantierte Kreuzungen)", () => {
|
||||
const rng = mulberry32(2);
|
||||
const qs = Array.from({ length: N }, (_, i) =>
|
||||
i % 2 === 0
|
||||
? { a1: v(rng), a2: v(rng), b1: v(rng), b2: v(rng) }
|
||||
: crossingSegs(rng),
|
||||
);
|
||||
const wSeg = JSON.parse(K.segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
|
||||
const wLine = JSON.parse(K.line_segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
|
||||
|
||||
let hits = 0;
|
||||
qs.forEach((q, i) => {
|
||||
const t = segmentIntersect(q.a1, q.a2, q.b1, q.b2);
|
||||
// Struktur exakt: null ⇔ null.
|
||||
expect(wSeg[i] === null, `seg-null#${i}`).toBe(t === null);
|
||||
if (t && wSeg[i]) {
|
||||
expect(closeHit(wSeg[i]!, t), `seg#${i}`).toBe(true);
|
||||
hits++;
|
||||
}
|
||||
const tl = lineSegmentIntersect(q.a1, q.a2, q.b1, q.b2);
|
||||
expect(wLine[i] === null, `line-null#${i}`).toBe(tl === null);
|
||||
if (tl && wLine[i]) expect(closeHit(wLine[i]!, tl), `line#${i}`).toBe(true);
|
||||
});
|
||||
expect(hits, "keine einzige Kreuzung getroffen — Test waere aussagelos").toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("segmentPolylineHits (Struktur exakt + Werte)", () => {
|
||||
const rng = mulberry32(3);
|
||||
const qs = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 18);
|
||||
const pts = Array.from({ length: n }, () => v(rng));
|
||||
// Langer Schneider quer durch die BBox.
|
||||
return { a1: { x: -120, y: coord(rng) }, a2: { x: 120, y: coord(rng) }, pts, closed: rng() < 0.5 };
|
||||
});
|
||||
const wHits = JSON.parse(K.segment_polyline_hits_batch_json(JSON.stringify(qs))) as Hit[][];
|
||||
qs.forEach((q, i) => {
|
||||
const t = segmentPolylineHits(q.a1, q.a2, q.pts, q.closed);
|
||||
expect(wHits[i].length, `hits-len#${i}`).toBe(t.length);
|
||||
t.forEach((h, j) => expect(closeHit(wHits[i][j], h), `hit#${i}.${j}`).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
it("lineCircleIntersect / segmentCircleIntersect (Struktur + Werte)", () => {
|
||||
const rng = mulberry32(4);
|
||||
const qs = Array.from({ length: N }, () => ({
|
||||
a: v(rng),
|
||||
b: v(rng),
|
||||
center: v(rng),
|
||||
r: 0.01 + rng() * 50,
|
||||
}));
|
||||
const wLine = JSON.parse(K.line_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
|
||||
const wSeg = JSON.parse(K.segment_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
|
||||
qs.forEach((q, i) => {
|
||||
const tl = lineCircleIntersect(q.a, q.b, q.center, q.r);
|
||||
expect(wLine[i].length, `lc-len#${i}`).toBe(tl.length);
|
||||
tl.forEach((p, j) => expect(closeVec(wLine[i][j], p), `lc#${i}.${j}`).toBe(true));
|
||||
const ts = segmentCircleIntersect(q.a, q.b, q.center, q.r);
|
||||
expect(wSeg[i].length, `sc-len#${i}`).toBe(ts.length);
|
||||
ts.forEach((p, j) => expect(closeVec(wSeg[i][j], p), `sc#${i}.${j}`).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
it("circleCircleIntersect (Struktur + Werte)", () => {
|
||||
const rng = mulberry32(5);
|
||||
const qs = Array.from({ length: N }, () => ({
|
||||
c1: v(rng),
|
||||
r1: 0.01 + rng() * 50,
|
||||
c2: v(rng),
|
||||
r2: 0.01 + rng() * 50,
|
||||
}));
|
||||
const w = JSON.parse(K.circle_circle_intersect_batch_json(JSON.stringify(qs))) as Vec2[][];
|
||||
qs.forEach((q, i) => {
|
||||
const t = circleCircleIntersect(q.c1, q.r1, q.c2, q.r2);
|
||||
expect(w[i].length, `cc-len#${i}`).toBe(t.length);
|
||||
t.forEach((p, j) => expect(closeVec(w[i][j], p), `cc#${i}.${j}`).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
it("signedArea (rel 1e-9) / isCCW (Struktur exakt)", () => {
|
||||
const rng = mulberry32(6);
|
||||
const polys = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 8);
|
||||
return Array.from({ length: n }, () => v(rng));
|
||||
});
|
||||
const wArea = JSON.parse(K.signed_area_batch_json(JSON.stringify(polys))) as number[];
|
||||
const wCcw = JSON.parse(K.is_ccw_batch_json(JSON.stringify(polys))) as boolean[];
|
||||
polys.forEach((p, i) => {
|
||||
const a = signedArea(p);
|
||||
expect(Math.abs(wArea[i] - a) <= 1e-9 * Math.max(1, Math.abs(a)), `area#${i}`).toBe(true);
|
||||
expect(wCcw[i], `ccw#${i}`).toBe(isCCW(p));
|
||||
});
|
||||
});
|
||||
|
||||
it("offsetSegment / offsetPolyline (Miter, Struktur + Werte rel 1e-9)", () => {
|
||||
const rng = mulberry32(7);
|
||||
const segs = Array.from({ length: N }, () => ({
|
||||
a: v(rng),
|
||||
b: v(rng),
|
||||
d: (rng() * 2 - 1) * 5,
|
||||
}));
|
||||
const wSeg = JSON.parse(K.offset_segment_batch_json(JSON.stringify(segs))) as [Vec2, Vec2][];
|
||||
segs.forEach((q, i) => {
|
||||
const [a, b] = offsetSegment(q.a, q.b, q.d);
|
||||
expect(closeVec(wSeg[i][0], a) && closeVec(wSeg[i][1], b), `offSeg#${i}`).toBe(true);
|
||||
});
|
||||
|
||||
const polys = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 10);
|
||||
const pts = Array.from({ length: n }, () => v(rng));
|
||||
return { pts, d: (rng() * 2 - 1) * 5, closed: rng() < 0.5 };
|
||||
});
|
||||
const wPoly = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(polys))) as Vec2[][];
|
||||
polys.forEach((q, i) => {
|
||||
const t = offsetPolyline(q.pts, q.d, q.closed);
|
||||
expect(wPoly[i].length, `offPoly-len#${i}`).toBe(t.length);
|
||||
t.forEach((p, j) => expect(closeVec(wPoly[i][j], p), `offPoly#${i}.${j}`).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
it("filletCorner (Struktur exakt + Werte, Winkel abs 1e-7)", () => {
|
||||
const rng = mulberry32(8);
|
||||
// Halb kontrolliert (garantiert gueltige Verrundung), halb Zufall (None-Paritaet).
|
||||
const qs = Array.from({ length: N }, (_, i) => {
|
||||
if (i % 2 === 0) {
|
||||
const corner = v(rng);
|
||||
const a0 = rng() * Math.PI * 2;
|
||||
const half = 0.3 + rng() * 0.9; // theta = 2*half ∈ [0.6, 2.4] rad (weg von 0/π)
|
||||
const legLen = 3 + rng() * 7;
|
||||
const rMax = legLen * Math.tan(half);
|
||||
const r = 0.1 + rng() * 0.8 * rMax;
|
||||
const dir = (ang: number) => ({ x: Math.cos(ang), y: Math.sin(ang) });
|
||||
const d1 = dir(a0 + half);
|
||||
const d2 = dir(a0 - half);
|
||||
return {
|
||||
corner,
|
||||
p1: { x: corner.x + d1.x * legLen, y: corner.y + d1.y * legLen },
|
||||
p2: { x: corner.x + d2.x * legLen, y: corner.y + d2.y * legLen },
|
||||
r,
|
||||
};
|
||||
}
|
||||
return { corner: v(rng), p1: v(rng), p2: v(rng), r: 0.1 + rng() * 5 };
|
||||
});
|
||||
const w = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(qs))) as (Fillet | null)[];
|
||||
let valid = 0;
|
||||
qs.forEach((q, i) => {
|
||||
const t = filletCorner(q.corner, q.p1, q.p2, q.r);
|
||||
expect(w[i] === null, `fil-null#${i}`).toBe(t === null);
|
||||
if (t && w[i]) {
|
||||
const f = w[i]!;
|
||||
expect(closeVec(f.center, t.center), `fil-center#${i}`).toBe(true);
|
||||
expect(closeVec(f.tangentA, t.tangentA), `fil-tA#${i}`).toBe(true);
|
||||
expect(closeVec(f.tangentB, t.tangentB), `fil-tB#${i}`).toBe(true);
|
||||
expect(closeNum(f.radius, t.radius), `fil-r#${i}`).toBe(true);
|
||||
expect(Math.abs(f.startAngle - t.startAngle) <= 1e-7, `fil-sa#${i}`).toBe(true);
|
||||
expect(Math.abs(f.endAngle - t.endAngle) <= 1e-7, `fil-ea#${i}`).toBe(true);
|
||||
valid++;
|
||||
}
|
||||
});
|
||||
expect(valid, "keine gueltige Verrundung — Test waere aussagelos").toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("splitSegmentByCutters / trimSegment (Struktur + Werte)", () => {
|
||||
const rng = mulberry32(9);
|
||||
const qs = Array.from({ length: N }, () => ({
|
||||
a1: { x: -60, y: (rng() * 2 - 1) * 40 },
|
||||
a2: { x: 60, y: (rng() * 2 - 1) * 40 },
|
||||
cutters: genCutters(rng),
|
||||
pick: { x: (rng() * 2 - 1) * 60, y: (rng() * 2 - 1) * 40 },
|
||||
}));
|
||||
const wSplit = JSON.parse(K.split_segment_by_cutters_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][];
|
||||
const wTrim = JSON.parse(K.trim_segment_batch_json(JSON.stringify(qs))) as [Vec2, Vec2][][];
|
||||
qs.forEach((q, i) => {
|
||||
expect(eqPairs(wSplit[i], splitSegmentByCutters(q.a1, q.a2, q.cutters)), `split#${i}`).toBe(true);
|
||||
expect(eqPairs(wTrim[i], trimSegment(q.a1, q.a2, q.cutters, q.pick)), `trim#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("trimPolyline / splitAtIntersections (offen+geschlossen, Struktur exakt)", () => {
|
||||
const rng = mulberry32(10);
|
||||
const qs = Array.from({ length: N }, () => ({
|
||||
pts: genPolyline(rng, 4, 9),
|
||||
closed: rng() < 0.5,
|
||||
cutters: genCutters(rng),
|
||||
pick: { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 },
|
||||
others: genCutters(rng).map((c) => c.pts),
|
||||
}));
|
||||
const wTrim = JSON.parse(K.trim_polyline_batch_json(JSON.stringify(qs))) as Poly[][];
|
||||
const wSplit = JSON.parse(
|
||||
K.split_at_intersections_batch_json(
|
||||
JSON.stringify(qs.map((q) => ({ targetPts: q.pts, closed: q.closed, others: q.others }))),
|
||||
),
|
||||
) as Vec2[][][];
|
||||
qs.forEach((q, i) => {
|
||||
expect(eqPolyList(wTrim[i], trimPolyline(q.pts, q.closed, q.cutters, q.pick)), `trimPoly#${i}`).toBe(true);
|
||||
expect(eqPtsLists(wSplit[i], splitAtIntersections(q.pts, q.closed, q.others)), `splitAt#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("splitPolylineAtParam / splitClosedByChord / removeSegment", () => {
|
||||
const rng = mulberry32(11);
|
||||
const qs = Array.from({ length: N }, () => {
|
||||
const pts = genPolyline(rng, 4, 9);
|
||||
const n = pts.length;
|
||||
const closed = rng() < 0.5;
|
||||
return { pts, closed, edgeIndex: Math.floor(rng() * (closed ? n : n - 1)), t: rng() };
|
||||
});
|
||||
const wSap = JSON.parse(K.split_polyline_at_param_batch_json(JSON.stringify(qs))) as Vec2[][][];
|
||||
const wRem = JSON.parse(
|
||||
K.remove_segment_batch_json(
|
||||
JSON.stringify(qs.map((q) => ({ pts: q.pts, closed: q.closed, edgeIndex: q.edgeIndex }))),
|
||||
),
|
||||
) as Poly[];
|
||||
const chords = Array.from({ length: N }, () => {
|
||||
const pts = genPolyline(rng, 4, 8);
|
||||
const n = pts.length;
|
||||
const i = Math.floor(rng() * n);
|
||||
let j = Math.floor(rng() * n);
|
||||
if (j === i) j = (j + 1) % n;
|
||||
return { pts, i, ti: rng(), j, tj: rng() };
|
||||
});
|
||||
const wChord = JSON.parse(K.split_closed_by_chord_batch_json(JSON.stringify(chords))) as ([Vec2[], Vec2[]] | null)[];
|
||||
qs.forEach((q, i) => {
|
||||
expect(eqPtsLists(wSap[i], splitPolylineAtParam(q.pts, q.closed, q.edgeIndex, q.t)), `sap#${i}`).toBe(true);
|
||||
const tr = removeSegment(q.pts, q.closed, q.edgeIndex);
|
||||
expect(wRem[i].closed === tr.closed && eqPts(wRem[i].pts, tr.pts), `rem#${i}`).toBe(true);
|
||||
});
|
||||
chords.forEach((q, i) => {
|
||||
const t = splitClosedByChord(q.pts, q.i, q.ti, q.j, q.tj);
|
||||
expect((wChord[i] === null) === (t === null), `chord-null#${i}`).toBe(true);
|
||||
if (t && wChord[i]) {
|
||||
expect(eqPts(wChord[i]![0], t[0]) && eqPts(wChord[i]![1], t[1]), `chord#${i}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("extendSegment (start/end, null-Paritaet + Werte)", () => {
|
||||
const rng = mulberry32(12);
|
||||
const qs = Array.from({ length: N }, (_, k) => {
|
||||
const c = { x: (rng() * 2 - 1) * 30, y: (rng() * 2 - 1) * 30 };
|
||||
return {
|
||||
a1: { x: c.x - 1, y: c.y },
|
||||
a2: { x: c.x + 1, y: c.y },
|
||||
end: (k % 2 === 0 ? "end" : "start") as "start" | "end",
|
||||
cutters: genCutters(rng).concat([
|
||||
{ pts: [{ x: c.x + 5, y: -50 }, { x: c.x + 5, y: 50 }], closed: false },
|
||||
]),
|
||||
};
|
||||
});
|
||||
const w = JSON.parse(K.extend_segment_batch_json(JSON.stringify(qs))) as ([Vec2, Vec2] | null)[];
|
||||
qs.forEach((q, i) => {
|
||||
const t = extendSegment(q.a1, q.a2, q.end, q.cutters);
|
||||
expect((w[i] === null) === (t === null), `ext-null#${i}`).toBe(true);
|
||||
if (t && w[i]) expect(closeVec(w[i]![0], t[0]) && closeVec(w[i]![1], t[1]), `ext#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("joinChains (mergeable + Zufall, Struktur exakt)", () => {
|
||||
const rng = mulberry32(13);
|
||||
const groups = Array.from({ length: N }, () => {
|
||||
const base = genPolyline(rng, 4, 7);
|
||||
const chains: Poly[] = [];
|
||||
for (let k = 0; k < base.length - 1; k++) {
|
||||
const seg = [base[k], base[k + 1]];
|
||||
chains.push({ pts: rng() < 0.5 ? seg : [seg[1], seg[0]], closed: false });
|
||||
}
|
||||
if (rng() < 0.5) chains.push({ pts: genPolyline(rng, 2, 3), closed: false });
|
||||
for (let k = chains.length - 1; k > 0; k--) {
|
||||
const j = Math.floor(rng() * (k + 1));
|
||||
[chains[k], chains[j]] = [chains[j], chains[k]];
|
||||
}
|
||||
return chains;
|
||||
});
|
||||
const w = JSON.parse(K.join_chains_batch_json(JSON.stringify(groups))) as Poly[][];
|
||||
groups.forEach((g, i) => {
|
||||
expect(eqPolyList(w[i], joinChains(g)), `join#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Slice 1: roomArea / ceiling ───────────────────────────────────────────────
|
||||
|
||||
/** Zufaelliges konvexes Polygon mit n=3..10 Ecken in [-30,30]×[-30,30]. */
|
||||
function genConvexPoly(rng: () => number, n: number): Vec2[] {
|
||||
// Einfache Methode: Einheitswinkel sortieren + skalieren.
|
||||
const angles = Array.from({ length: n }, () => rng() * 2 * Math.PI);
|
||||
angles.sort((a, b) => a - b);
|
||||
const rx = 1 + rng() * 20;
|
||||
const ry = 1 + rng() * 20;
|
||||
const cx = (rng() * 2 - 1) * 10;
|
||||
const cy = (rng() * 2 - 1) * 10;
|
||||
return angles.map((a) => ({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }));
|
||||
}
|
||||
|
||||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 1: roomArea + ceiling", () => {
|
||||
it("polygonArea / perimeter / centroid (Zufallspolygone 3–10 Ecken, rel 1e-9)", () => {
|
||||
const rng = mulberry32(20);
|
||||
const polys = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 8);
|
||||
return genConvexPoly(rng, n);
|
||||
});
|
||||
const wArea = JSON.parse(K.polygon_area_batch_json(JSON.stringify(polys))) as number[];
|
||||
const wPerim = JSON.parse(K.perimeter_batch_json(JSON.stringify(polys))) as number[];
|
||||
const wCent = JSON.parse(K.centroid_batch_json(JSON.stringify(polys))) as Vec2[];
|
||||
|
||||
polys.forEach((p, i) => {
|
||||
const a = polygonArea(p);
|
||||
const pe = perimeter(p);
|
||||
const c = centroid(p);
|
||||
expect(closeNum(wArea[i], a), `area#${i}`).toBe(true);
|
||||
expect(closeNum(wPerim[i], pe), `perim#${i}`).toBe(true);
|
||||
expect(closeVec(wCent[i], c), `centroid#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizeOutline: Struktur exakt (null↔null) + Werte + None-Paritaet", () => {
|
||||
const rng = mulberry32(21);
|
||||
// Haelfte: gueltige Polygone; Haelfte: degenerierte (< 3 Punkte oder kollinear).
|
||||
const inputs = Array.from({ length: N }, (_, i) => {
|
||||
if (i % 4 < 3) {
|
||||
const n = 3 + Math.floor(rng() * 8);
|
||||
return genConvexPoly(rng, n);
|
||||
}
|
||||
// Degeneriert: 0, 1 oder 2 Punkte.
|
||||
const k = Math.floor(rng() * 3);
|
||||
return Array.from({ length: k }, () => ({ x: rng() * 10, y: rng() * 10 }));
|
||||
});
|
||||
const w = JSON.parse(K.normalize_outline_batch_json(JSON.stringify(inputs))) as (Vec2[] | null)[];
|
||||
inputs.forEach((p, i) => {
|
||||
const t = normalizeOutline(p);
|
||||
expect(w[i] === null, `norm-null#${i}`).toBe(t === null);
|
||||
if (t !== null && w[i] !== null) {
|
||||
const wp = w[i]!;
|
||||
expect(wp.length, `norm-len#${i}`).toBe(t.length);
|
||||
t.forEach((pt, j) => expect(closeVec(wp[j], pt), `norm-pt#${i}.${j}`).toBe(true));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("isValidOutline / ceilingArea (Struktur exakt + Werte)", () => {
|
||||
const rng = mulberry32(22);
|
||||
const polys = Array.from({ length: N }, (_, i) => {
|
||||
if (i % 5 < 4) return genConvexPoly(rng, 3 + Math.floor(rng() * 8));
|
||||
return Array.from({ length: Math.floor(rng() * 3) }, () => ({ x: rng() * 5, y: rng() * 5 }));
|
||||
});
|
||||
const wValid = JSON.parse(K.is_valid_outline_batch_json(JSON.stringify(polys))) as boolean[];
|
||||
const wArea = JSON.parse(K.ceiling_area_batch_json(JSON.stringify(polys))) as number[];
|
||||
|
||||
polys.forEach((p, i) => {
|
||||
expect(wValid[i], `valid#${i}`).toBe(isValidOutline(p));
|
||||
expect(closeNum(wArea[i], ceilingArea(p)), `ceilArea#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("outlineBBox / outlineCentroid (Werte rel 1e-9)", () => {
|
||||
const rng = mulberry32(23);
|
||||
const polys = Array.from({ length: N }, () => genConvexPoly(rng, 3 + Math.floor(rng() * 8)));
|
||||
const wBBox = JSON.parse(K.outline_bbox_batch_json(JSON.stringify(polys))) as {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}[];
|
||||
const wCent = JSON.parse(K.outline_centroid_batch_json(JSON.stringify(polys))) as Vec2[];
|
||||
|
||||
polys.forEach((p, i) => {
|
||||
const bb = outlineBBox(p);
|
||||
const c = outlineCentroid(p);
|
||||
expect(closeNum(wBBox[i].minX, bb.minX), `bbox-minX#${i}`).toBe(true);
|
||||
expect(closeNum(wBBox[i].minY, bb.minY), `bbox-minY#${i}`).toBe(true);
|
||||
expect(closeNum(wBBox[i].maxX, bb.maxX), `bbox-maxX#${i}`).toBe(true);
|
||||
expect(closeNum(wBBox[i].maxY, bb.maxY), `bbox-maxY#${i}`).toBe(true);
|
||||
expect(closeVec(wCent[i], c), `outCentroid#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("pointInOutline (Struktur exakt: true↔true, Zufallspunkte innen+aussen)", () => {
|
||||
const rng = mulberry32(24);
|
||||
const qs = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 8);
|
||||
const outline = genConvexPoly(rng, n);
|
||||
// Schwerpunkt des Polygons als garantiert innerer Punkt.
|
||||
const cx = outline.reduce((s, p) => s + p.x, 0) / outline.length;
|
||||
const cy = outline.reduce((s, p) => s + p.y, 0) / outline.length;
|
||||
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
|
||||
return { p, outline };
|
||||
});
|
||||
const w = JSON.parse(K.point_in_outline_batch_json(JSON.stringify(qs))) as boolean[];
|
||||
qs.forEach((q, i) => {
|
||||
expect(w[i], `pio#${i}`).toBe(pointInOutline(q.p, q.outline));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Slice 2: stair ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Erzeugt einen einfachen Stair-Stub mit geometrisch relevanten Feldern. */
|
||||
function genStair(
|
||||
rng: () => number,
|
||||
shape: "straight" | "L" | "spiral",
|
||||
): Stair & { totalRise: number } {
|
||||
const start = { x: (rng() * 2 - 1) * 10, y: (rng() * 2 - 1) * 10 };
|
||||
const angle = rng() * Math.PI * 2;
|
||||
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
const runLength = 1 + rng() * 5;
|
||||
const width = 0.8 + rng() * 1.5;
|
||||
const stepCount = 3 + Math.floor(rng() * 12);
|
||||
const totalRise = 0.5 + rng() * 3;
|
||||
const up = rng() < 0.5 ? true : false;
|
||||
const base: Stair = {
|
||||
id: "test",
|
||||
type: "stair",
|
||||
floorId: "f1",
|
||||
categoryCode: "40",
|
||||
shape,
|
||||
start,
|
||||
dir,
|
||||
runLength,
|
||||
width,
|
||||
stepCount,
|
||||
up,
|
||||
};
|
||||
if (shape === "L") {
|
||||
return { ...base, run2Length: 1 + rng() * 4, turn: rng() < 0.5 ? 1 : -1, totalRise };
|
||||
}
|
||||
if (shape === "spiral") {
|
||||
return {
|
||||
...base,
|
||||
center: { x: start.x + rng() * 2, y: start.y + rng() * 2 },
|
||||
radius: 0.5 + rng() * 2,
|
||||
sweep: 90 + rng() * 270,
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
return { ...base, totalRise };
|
||||
}
|
||||
|
||||
/** Prueft Tritt-Listen-Gleichheit: Laenge, dann je Tritt index/baseRise/topRise/pts. */
|
||||
function eqTreads(
|
||||
w: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[],
|
||||
t: TSStairGeometry["treads"],
|
||||
): boolean {
|
||||
if (w.length !== t.length) return false;
|
||||
return t.every((tr, i) => {
|
||||
const wtr = w[i];
|
||||
if (wtr.index !== tr.index) return false;
|
||||
if (!closeNum(wtr.topRise, tr.topRise)) return false;
|
||||
if (!closeNum(wtr.baseRise, tr.baseRise)) return false;
|
||||
if (!eqPts(wtr.pts, tr.pts)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 2: stair", () => {
|
||||
it("defaultStepCount (Struktur exakt + Werte)", () => {
|
||||
const rng = mulberry32(30);
|
||||
const qs = Array.from({ length: N }, () => ({
|
||||
totalRise: 0.1 + rng() * 5,
|
||||
runLength: rng() * 8,
|
||||
}));
|
||||
const w = JSON.parse(K.default_step_count_batch_json(JSON.stringify(qs))) as number[];
|
||||
qs.forEach((q, i) => {
|
||||
expect(w[i], `dsc#${i}`).toBe(defaultStepCount(q.totalRise, q.runLength));
|
||||
});
|
||||
});
|
||||
|
||||
it("stairGeometry gerade: Tritte + Werte (Struktur exakt, rel 1e-9)", () => {
|
||||
const rng = mulberry32(31);
|
||||
const qs = Array.from({ length: 100 }, () => {
|
||||
const s = genStair(rng, "straight");
|
||||
return { stair: s, totalRise: s.totalRise };
|
||||
});
|
||||
const wGeos = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(qs))) as {
|
||||
treads: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[];
|
||||
landing: Vec2[] | null;
|
||||
riserHeight: number;
|
||||
treadDepth: number;
|
||||
runLine: Vec2[];
|
||||
totalRise: number;
|
||||
}[];
|
||||
qs.forEach((q, i) => {
|
||||
const t = stairGeometry(q.stair, q.totalRise);
|
||||
const w = wGeos[i];
|
||||
expect(eqTreads(w.treads, t.treads), `treads#${i}`).toBe(true);
|
||||
expect(w.landing === null, `landing-null#${i}`).toBe(t.landing === null);
|
||||
expect(closeNum(w.riserHeight, t.riserHeight), `riser#${i}`).toBe(true);
|
||||
expect(closeNum(w.treadDepth, t.treadDepth), `depth#${i}`).toBe(true);
|
||||
expect(eqPts(w.runLine, t.runLine), `runLine#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("stairGeometry L + spiral: Struktur exakt + Werte", () => {
|
||||
const rng = mulberry32(32);
|
||||
const lqs = Array.from({ length: 50 }, () => {
|
||||
const s = genStair(rng, "L");
|
||||
return { stair: s, totalRise: s.totalRise };
|
||||
});
|
||||
const spiralqs = Array.from({ length: 50 }, () => {
|
||||
const s = genStair(rng, "spiral");
|
||||
return { stair: s, totalRise: s.totalRise };
|
||||
});
|
||||
const wL = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(lqs))) as {
|
||||
treads: { pts: Vec2[]; index: number; topRise: number; baseRise: number }[];
|
||||
landing: Vec2[] | null;
|
||||
riserHeight: number;
|
||||
treadDepth: number;
|
||||
runLine: Vec2[];
|
||||
}[];
|
||||
lqs.forEach((q, i) => {
|
||||
const t = stairGeometry(q.stair, q.totalRise);
|
||||
const w = wL[i];
|
||||
expect(eqTreads(w.treads, t.treads), `L-treads#${i}`).toBe(true);
|
||||
expect(w.landing !== null, `L-landing#${i}`).toBe(t.landing !== null);
|
||||
if (t.landing && w.landing) expect(eqPts(w.landing, t.landing), `L-landingPts#${i}`).toBe(true);
|
||||
expect(eqPts(w.runLine, t.runLine), `L-runLine#${i}`).toBe(true);
|
||||
});
|
||||
const wSp = JSON.parse(K.stair_geometry_batch_json(JSON.stringify(spiralqs))) as typeof wL;
|
||||
spiralqs.forEach((q, i) => {
|
||||
const t = stairGeometry(q.stair, q.totalRise);
|
||||
const w = wSp[i];
|
||||
expect(eqTreads(w.treads, t.treads), `sp-treads#${i}`).toBe(true);
|
||||
expect(eqPts(w.runLine, t.runLine), `sp-runLine#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("stairCut (Struktur exakt: Indizes + breakLine-null)", () => {
|
||||
const rng = mulberry32(33);
|
||||
const qs = Array.from({ length: 100 }, () => {
|
||||
const s = genStair(rng, "straight");
|
||||
const geo = stairGeometry(s, s.totalRise);
|
||||
const cutRise = rng() * s.totalRise * 1.1;
|
||||
return { geo, cutRise };
|
||||
});
|
||||
const w = JSON.parse(K.stair_cut_batch_json(JSON.stringify(qs))) as {
|
||||
belowIndices: number[];
|
||||
aboveIndices: number[];
|
||||
breakLine: [Vec2, Vec2][] | null;
|
||||
}[];
|
||||
qs.forEach((q, i) => {
|
||||
const t = stairCut(q.geo, q.cutRise);
|
||||
expect(w[i].belowIndices.length, `cut-below-len#${i}`).toBe(t.belowIndices.length);
|
||||
expect(w[i].aboveIndices.length, `cut-above-len#${i}`).toBe(t.aboveIndices.length);
|
||||
expect(w[i].belowIndices, `cut-below#${i}`).toEqual(t.belowIndices);
|
||||
expect(w[i].aboveIndices, `cut-above#${i}`).toEqual(t.aboveIndices);
|
||||
expect(w[i].breakLine === null, `cut-break-null#${i}`).toBe(t.breakLine === null);
|
||||
});
|
||||
});
|
||||
|
||||
it("stairBBox (Werte rel 1e-9)", () => {
|
||||
const rng = mulberry32(34);
|
||||
const geos = Array.from({ length: 100 }, () => {
|
||||
const s = genStair(rng, "straight");
|
||||
return stairGeometry(s, s.totalRise);
|
||||
});
|
||||
const w = JSON.parse(K.stair_bbox_batch_json(JSON.stringify(geos))) as {
|
||||
minX: number; minY: number; maxX: number; maxY: number;
|
||||
}[];
|
||||
geos.forEach((g, i) => {
|
||||
const t = stairBBox(g);
|
||||
expect(closeNum(w[i].minX, t.minX), `bbox-minX#${i}`).toBe(true);
|
||||
expect(closeNum(w[i].minY, t.minY), `bbox-minY#${i}`).toBe(true);
|
||||
expect(closeNum(w[i].maxX, t.maxX), `bbox-maxX#${i}`).toBe(true);
|
||||
expect(closeNum(w[i].maxY, t.maxY), `bbox-maxY#${i}`).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("pointInPolygon / pointHitsStair (Struktur exakt)", () => {
|
||||
const rng = mulberry32(35);
|
||||
const pipQs = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 8);
|
||||
const poly = genConvexPoly(rng, n);
|
||||
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
|
||||
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
|
||||
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
|
||||
return { p, poly };
|
||||
});
|
||||
const wPip = JSON.parse(K.point_in_polygon_batch_json(JSON.stringify(pipQs))) as boolean[];
|
||||
pipQs.forEach((q, i) => {
|
||||
expect(wPip[i], `pip#${i}`).toBe(stairPointInPolygon(q.p, q.poly));
|
||||
});
|
||||
|
||||
const phsQs = Array.from({ length: 50 }, () => {
|
||||
const s = genStair(rng, "straight");
|
||||
const geo = stairGeometry(s, s.totalRise);
|
||||
const p = { x: s.start.x + rng() * 6 - 1, y: s.start.y + (rng() * 2 - 1) * 2 };
|
||||
return { p, geo };
|
||||
});
|
||||
const wPhs = JSON.parse(K.point_hits_stair_batch_json(JSON.stringify(phsQs))) as boolean[];
|
||||
phsQs.forEach((q, i) => {
|
||||
expect(wPhs[i], `phs#${i}`).toBe(pointHitsStair(q.p, q.geo));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Slice 3: roomBoundary ─────────────────────────────────────────────────────
|
||||
|
||||
/** Erzeugt eine Menge rechtwinkliger Wandsegmente, die ein einfaches Rechteck
|
||||
* (oder L-Form) bilden. */
|
||||
function genRectWalls(rng: () => number): WallSegment[] {
|
||||
const x0 = (rng() * 2 - 1) * 5;
|
||||
const y0 = (rng() * 2 - 1) * 5;
|
||||
const w = 2 + rng() * 5;
|
||||
const h = 2 + rng() * 5;
|
||||
const t = 0.1 + rng() * 0.3;
|
||||
return [
|
||||
{ a: { x: x0, y: y0 }, b: { x: x0 + w, y: y0 }, thickness: t },
|
||||
{ a: { x: x0 + w, y: y0 }, b: { x: x0 + w, y: y0 + h }, thickness: t },
|
||||
{ a: { x: x0 + w, y: y0 + h }, b: { x: x0, y: y0 + h }, thickness: t },
|
||||
{ a: { x: x0, y: y0 + h }, b: { x: x0, y: y0 }, thickness: t },
|
||||
];
|
||||
}
|
||||
|
||||
/** Erzeugt WallFace-Liste aus einem geschlossenen Polygon. */
|
||||
function polyToFaces(pts: Vec2[]): WallFace[] {
|
||||
const n = pts.length;
|
||||
return Array.from({ length: n }, (_, i) => ({ a: pts[i], b: pts[(i + 1) % n] }));
|
||||
}
|
||||
|
||||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBoundary", () => {
|
||||
it("rbPointInPolygon (roomBoundary-Variante, kein Nenner-Guard, Struktur exakt)", () => {
|
||||
const rng = mulberry32(40);
|
||||
const qs = Array.from({ length: N }, () => {
|
||||
const n = 3 + Math.floor(rng() * 8);
|
||||
const poly = genConvexPoly(rng, n);
|
||||
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
|
||||
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
|
||||
const p = rng() < 0.5 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 50, y: (rng() * 2 - 1) * 50 };
|
||||
return { p, poly };
|
||||
});
|
||||
const w = JSON.parse(K.rb_point_in_polygon_batch_json(JSON.stringify(qs))) as boolean[];
|
||||
qs.forEach((q, i) => {
|
||||
expect(w[i], `rbpip#${i}`).toBe(rbPointInPolygon(q.p, q.poly));
|
||||
});
|
||||
});
|
||||
|
||||
it("detectRooms — Rechteck-Grundriss: gleiche Anzahl + Reihenfolge Raeume", () => {
|
||||
const rng = mulberry32(41);
|
||||
const qs = Array.from({ length: 50 }, () => ({
|
||||
walls: genRectWalls(rng),
|
||||
gapTol: 0.05,
|
||||
minArea: 0.05,
|
||||
offsetToInner: true,
|
||||
}));
|
||||
const w = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(qs))) as Vec2[][][];
|
||||
qs.forEach((q, i) => {
|
||||
const t = detectRooms(q.walls, { gapTol: q.gapTol, minArea: q.minArea, offsetToInner: q.offsetToInner });
|
||||
// Gleiche Anzahl Raeume.
|
||||
expect(w[i].length, `dr-count#${i}`).toBe(t.length);
|
||||
// Je Raum: gleiche Punktanzahl (Reihenfolge-abhaengig, aber deterministisch).
|
||||
t.forEach((room, j) => {
|
||||
expect(w[i][j]?.length, `dr-roomLen#${i}.${j}`).toBe(room.length);
|
||||
room.forEach((pt, k) => expect(closeVec(w[i][j][k], pt), `dr-pt#${i}.${j}.${k}`).toBe(true));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("detectRooms ohne Offset + degeneriert (< 3 Waende → leer)", () => {
|
||||
const rng = mulberry32(42);
|
||||
const qs = Array.from({ length: 30 }, () => ({
|
||||
walls: genRectWalls(rng),
|
||||
gapTol: 0.05,
|
||||
minArea: 0.05,
|
||||
offsetToInner: false,
|
||||
}));
|
||||
const w = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(qs))) as Vec2[][][];
|
||||
qs.forEach((q, i) => {
|
||||
const t = detectRooms(q.walls, { gapTol: q.gapTol, minArea: q.minArea, offsetToInner: q.offsetToInner });
|
||||
expect(w[i].length, `drno-count#${i}`).toBe(t.length);
|
||||
});
|
||||
// Degeneriert: < 3 Waende → leer.
|
||||
const deg = [{ walls: [{ a: { x: 0, y: 0 }, b: { x: 1, y: 0 }, thickness: 0.2 }], gapTol: 0.05, minArea: 0.05, offsetToInner: true }];
|
||||
const wd = JSON.parse(K.detect_rooms_batch_json(JSON.stringify(deg))) as Vec2[][][];
|
||||
expect(wd[0].length).toBe(detectRooms(deg[0].walls, {}).length);
|
||||
expect(wd[0].length).toBe(0);
|
||||
});
|
||||
|
||||
it("roomFromPointInside — Schwerpunkt als Saatpunkt findet den Raum (nicht null)", () => {
|
||||
const rng = mulberry32(43);
|
||||
const qs = Array.from({ length: 50 }, () => {
|
||||
const walls = genRectWalls(rng);
|
||||
// Mittelpunkt des Rechtecks als Saatpunkt.
|
||||
const xs = walls.flatMap((w) => [w.a.x, w.b.x]);
|
||||
const ys = walls.flatMap((w) => [w.a.y, w.b.y]);
|
||||
const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
|
||||
return { point: { x: cx, y: cy }, walls, gapTol: 0.05, offsetToInner: true };
|
||||
});
|
||||
const w = JSON.parse(K.room_from_point_inside_batch_json(JSON.stringify(qs))) as (Vec2[] | null)[];
|
||||
qs.forEach((q, i) => {
|
||||
const t = roomFromPointInside(q.point, q.walls, { gapTol: q.gapTol, offsetToInner: q.offsetToInner });
|
||||
expect(w[i] === null, `rfpi-null#${i}`).toBe(t === null);
|
||||
if (t !== null && w[i] !== null) {
|
||||
expect(w[i]!.length, `rfpi-len#${i}`).toBe(t.length);
|
||||
t.forEach((pt, j) => expect(closeVec(w[i]![j], pt), `rfpi-pt#${i}.${j}`).toBe(true));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("roomFromPointInsideFaces — Struktur exakt (null↔null, Laenge, Punkte)", () => {
|
||||
const rng = mulberry32(44);
|
||||
const qs = Array.from({ length: 30 }, () => {
|
||||
const n = 4 + Math.floor(rng() * 4);
|
||||
const poly = genConvexPoly(rng, n);
|
||||
const faces = polyToFaces(poly);
|
||||
const cx = poly.reduce((s, p) => s + p.x, 0) / poly.length;
|
||||
const cy = poly.reduce((s, p) => s + p.y, 0) / poly.length;
|
||||
const inOrOut = rng() < 0.7 ? { x: cx, y: cy } : { x: (rng() * 2 - 1) * 40, y: (rng() * 2 - 1) * 40 };
|
||||
return { point: inOrOut, wallFaces: faces, gapTol: 0.05 };
|
||||
});
|
||||
const w = JSON.parse(K.room_from_point_inside_faces_batch_json(JSON.stringify(qs))) as (Vec2[] | null)[];
|
||||
qs.forEach((q, i) => {
|
||||
const t = roomFromPointInsideFaces(q.point, q.wallFaces, q.gapTol);
|
||||
expect(w[i] === null, `rfpif-null#${i}`).toBe(t === null);
|
||||
if (t !== null && w[i] !== null) {
|
||||
expect(w[i]!.length, `rfpif-len#${i}`).toBe(t.length);
|
||||
t.forEach((pt, j) => expect(closeVec(w[i]![j], pt), `rfpif-pt#${i}.${j}`).toBe(true));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
|
||||
it("parallele/kollineare Strecken → null (beide)", () => {
|
||||
const qs = [
|
||||
{ a1: { x: 0, y: 0 }, a2: { x: 2, y: 0 }, b1: { x: 0, y: 1 }, b2: { x: 2, y: 1 } }, // parallel
|
||||
{ a1: { x: 0, y: 0 }, a2: { x: 4, y: 0 }, b1: { x: 1, y: 0 }, b2: { x: 3, y: 0 } }, // kollinear
|
||||
{ a1: { x: 0, y: 0 }, a2: { x: 0, y: 0 }, b1: { x: 1, y: 1 }, b2: { x: 2, y: 2 } }, // Null-Laenge
|
||||
];
|
||||
const w = JSON.parse(K.segment_intersect_batch_json(JSON.stringify(qs))) as (Hit | null)[];
|
||||
qs.forEach((q, i) => {
|
||||
const t = segmentIntersect(q.a1, q.a2, q.b1, q.b2);
|
||||
expect(w[i] === null, `#${i}`).toBe(t === null);
|
||||
if (t && w[i]) expect(closeHit(w[i]!, t)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("tangentiale Gerade → genau 1 Punkt; konzentrische/getrennte Kreise → leer", () => {
|
||||
const lc = [{ a: { x: -1, y: 1 }, b: { x: 1, y: 1 }, center: { x: 0, y: 0 }, r: 1 }]; // Tangente
|
||||
const wlc = JSON.parse(K.line_circle_intersect_batch_json(JSON.stringify(lc))) as Vec2[][];
|
||||
expect(wlc[0].length).toBe(lineCircleIntersect(lc[0].a, lc[0].b, lc[0].center, lc[0].r).length);
|
||||
expect(wlc[0].length).toBe(1);
|
||||
|
||||
const cc = [
|
||||
{ c1: { x: 0, y: 0 }, r1: 1, c2: { x: 0, y: 0 }, r2: 2 }, // konzentrisch → leer
|
||||
{ c1: { x: 0, y: 0 }, r1: 1, c2: { x: 5, y: 0 }, r2: 1 }, // getrennt → leer
|
||||
{ c1: { x: 0, y: 0 }, r1: 2, c2: { x: 1, y: 0 }, r2: 1 }, // innen tangential → 1
|
||||
];
|
||||
const wcc = JSON.parse(K.circle_circle_intersect_batch_json(JSON.stringify(cc))) as Vec2[][];
|
||||
cc.forEach((q, i) => {
|
||||
const t = circleCircleIntersect(q.c1, q.r1, q.c2, q.r2);
|
||||
expect(wcc[i].length, `cc#${i}`).toBe(t.length);
|
||||
t.forEach((p, j) => expect(closeVec(wcc[i][j], p)).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
it("degeneriertes Null-Flaeche-Polygon (kollinear) → Flaeche 0, nicht CCW", () => {
|
||||
const polys = [[{ x: 0, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 2 }]];
|
||||
const wArea = JSON.parse(K.signed_area_batch_json(JSON.stringify(polys))) as number[];
|
||||
const wCcw = JSON.parse(K.is_ccw_batch_json(JSON.stringify(polys))) as boolean[];
|
||||
expect(closeNum(wArea[0], signedArea(polys[0]))).toBe(true);
|
||||
expect(Math.abs(wArea[0])).toBeLessThan(1e-12);
|
||||
expect(wCcw[0]).toBe(isCCW(polys[0]));
|
||||
});
|
||||
|
||||
it("Offset L-Ecke (Gehrung) und Fillet rechter Winkel / kollinear→null / zu gross→null", () => {
|
||||
const off = [{ pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }], d: 0.5, closed: false }];
|
||||
const wOff = JSON.parse(K.offset_polyline_batch_json(JSON.stringify(off))) as Vec2[][];
|
||||
const tOff = offsetPolyline(off[0].pts, off[0].d, off[0].closed);
|
||||
expect(wOff[0].length).toBe(tOff.length);
|
||||
tOff.forEach((p, j) => expect(closeVec(wOff[0][j], p)).toBe(true));
|
||||
|
||||
const fil = [
|
||||
{ corner: { x: 0, y: 0 }, p1: { x: 5, y: 0 }, p2: { x: 0, y: 5 }, r: 1 }, // rechter Winkel
|
||||
{ corner: { x: 0, y: 0 }, p1: { x: 1, y: 0 }, p2: { x: -1, y: 0 }, r: 0.5 }, // kollinear → null
|
||||
{ corner: { x: 0, y: 0 }, p1: { x: 0.1, y: 0 }, p2: { x: 0, y: 0.1 }, r: 10 }, // zu gross → null
|
||||
];
|
||||
const wFil = JSON.parse(K.fillet_corner_batch_json(JSON.stringify(fil))) as (Fillet | null)[];
|
||||
fil.forEach((q, i) => {
|
||||
const t = filletCorner(q.corner, q.p1, q.p2, q.r);
|
||||
expect(wFil[i] === null, `golden-fil#${i}`).toBe(t === null);
|
||||
if (t && wFil[i]) {
|
||||
expect(closeVec(wFil[i]!.center, t.center)).toBe(true);
|
||||
expect(closeVec(wFil[i]!.tangentA, t.tangentA)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
-2
@@ -170,12 +170,21 @@ export interface WindowSymbol {
|
||||
frame: Vec2[];
|
||||
/** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */
|
||||
glassLines: [Vec2, Vec2][];
|
||||
/**
|
||||
* Flügel-Mittelpfosten (`wingCount − 1` Querlinien), jeweils von einer
|
||||
* Laibungsseite zur anderen (parallel zu den Laibungslinien, quer zur
|
||||
* Öffnungsrichtung), gleichmäßig zwischen den beiden Pfosten verteilt. Bei
|
||||
* `wingCount ≤ 1` leer. Entspricht den Mittelpfosten in `_make_oeffnung_pieces`
|
||||
* des Rhino-Plugins (`oeff_fluegel`).
|
||||
*/
|
||||
mullionLines: [Vec2, Vec2][];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* `count` steuert die Anzahl je Detailgrad).
|
||||
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
|
||||
* (`wingCount − 1` Querlinien).
|
||||
*/
|
||||
export function windowSymbol(
|
||||
project: Project,
|
||||
@@ -205,5 +214,21 @@ export function windowSymbol(
|
||||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||||
}
|
||||
return { frame, glassLines };
|
||||
// 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 wings = Math.max(1, Math.min(4, Math.round(o.wingCount ?? 1)));
|
||||
if (wings > 1) {
|
||||
const postDepth = total * 0.6;
|
||||
const pOuter = refOff + postDepth / 2;
|
||||
const pInner = refOff - postDepth / 2;
|
||||
for (let i = 1; i < wings; i++) {
|
||||
const t = i / wings; // gleichmäßige Teilung entlang der Achse
|
||||
const onAxis = add(jambStart, scale(sub(jambEnd, jambStart), t));
|
||||
mullionLines.push([add(onAxis, scale(n, pOuter)), add(onAxis, scale(n, pInner))]);
|
||||
}
|
||||
}
|
||||
return { frame, glassLines, mullionLines };
|
||||
}
|
||||
|
||||
+177
-5
@@ -27,6 +27,32 @@ const norm = (a: Vec2): Vec2 => {
|
||||
/** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */
|
||||
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
|
||||
|
||||
/**
|
||||
* Perp-Versatz von der Referenzkante zur visuellen Treppen-MITTE, entlang der
|
||||
* linken Normale `n = leftN(u)`. Entspricht `mid_off` aus `_treppe_2d_side_offsets`
|
||||
* im Rhino-Plugin (Konvention: `perp` = CCW-Normale = linke Normale):
|
||||
* • referenz "links" → Achse ist die LINKE Kante; die Mitte liegt eine
|
||||
* halbe Breite in −n-Richtung (rechts) → mid_off = −width/2.
|
||||
* • referenz "rechts" → Achse ist die RECHTE Kante; die Mitte liegt eine
|
||||
* halbe Breite in +n-Richtung (links) → mid_off = +width/2.
|
||||
* • referenz "mitte"/undefined → Achse ist bereits die Mitte → 0.
|
||||
*
|
||||
* ANNAHME (selbstkritisch): „links" heisst „linke Kante in Laufrichtung", und die
|
||||
* linke Kante liegt in +n (n = leftN(u), 90° links der Laufrichtung). Von der
|
||||
* linken Kante zur Mitte geht es also nach −n, daher mid_off = −width/2. Das
|
||||
* deckt sich mit `_treppe_2d_side_offsets` (referenz="links" → off_a=0, off_b=−b,
|
||||
* Mitte = −b/2·perp). Wird die Richtung im UI als seitenverkehrt empfunden, ist
|
||||
* hier das Vorzeichen zu drehen — die Geometrie bleibt in sich konsistent.
|
||||
*/
|
||||
function referenzMidShift(stair: Stair, u: Vec2): Vec2 {
|
||||
const ref = stair.referenz ?? "mitte";
|
||||
if (ref === "mitte") return { x: 0, y: 0 };
|
||||
const n = leftN(u);
|
||||
const halfW = Math.max(0.05, stair.width / 2);
|
||||
const midOff = ref === "links" ? -halfW : halfW;
|
||||
return scale(n, midOff);
|
||||
}
|
||||
|
||||
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
|
||||
export const IDEAL_RISER = 0.17;
|
||||
export const IDEAL_TREAD = 0.29;
|
||||
@@ -105,13 +131,22 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
|
||||
const u = norm(stair.dir);
|
||||
const n = leftN(u);
|
||||
|
||||
if (stair.shape === "spiral") {
|
||||
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
|
||||
// Referenz (links/mitte/rechts): die gespeicherte Achse wird auf die visuelle
|
||||
// Treppen-MITTE versetzt, danach greift die bestehende, um die Achse zentrierte
|
||||
// Tritt-/Lauflinien-Logik unverändert. So bleibt die Lauflinie samt Pfeil stets
|
||||
// auf der Mitte (nicht auf der Referenzkante). "mitte"/undefined ⇒ Nullversatz.
|
||||
const c = referenzMidShift(stair, u);
|
||||
const s = stair.referenz && stair.referenz !== "mitte"
|
||||
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
|
||||
: stair;
|
||||
|
||||
if (s.shape === "spiral") {
|
||||
return spiralGeometry(s, totalRise, steps, riserHeight, halfW);
|
||||
}
|
||||
if (stair.shape === "L") {
|
||||
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||
if (s.shape === "L") {
|
||||
return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
|
||||
}
|
||||
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||
return straightGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
|
||||
}
|
||||
|
||||
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
|
||||
@@ -409,3 +444,140 @@ export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
|
||||
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Treppen-Aussenlinie (Plan-Outline) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Schliessendes Umriss-Polygon einer geraden Treppe im Grundriss (4 Ecken CCW).
|
||||
* Entspricht `_aussen_gerade` im Rhino-Plugin (Rechteck über Laufbreite + Lauflänge).
|
||||
* Liefert null bei entarteter Geometrie.
|
||||
*/
|
||||
function straightOutlinePoints(stair: Stair, halfW: number): Vec2[] | null {
|
||||
const u = norm({ x: stair.dir.x, y: stair.dir.y });
|
||||
const n = leftN(u);
|
||||
const L = Math.max(1e-4, stair.runLength);
|
||||
const s = stair.start;
|
||||
// Vier Ecken: Start-links → Start-rechts → End-rechts → End-links (CCW).
|
||||
return [
|
||||
add(s, scale(n, halfW)),
|
||||
add(s, scale(n, -halfW)),
|
||||
add(add(s, scale(u, L)), scale(n, -halfW)),
|
||||
add(add(s, scale(u, L)), scale(n, halfW)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D-Linien-Schnittpunkt. Liefert null bei Parallelität (det < ε).
|
||||
* Entspricht `_line_intersect_xy` im Rhino-Plugin.
|
||||
*/
|
||||
function lineIntersect2d(p1: Vec2, d1: Vec2, p2: Vec2, d2: Vec2): Vec2 | null {
|
||||
const det = d1.x * (-d2.y) - d1.y * (-d2.x);
|
||||
if (Math.abs(det) < 1e-9) return null;
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const t = (dx * (-d2.y) - dy * (-d2.x)) / det;
|
||||
return { x: p1.x + d1.x * t, y: p1.y + d1.y * t };
|
||||
}
|
||||
|
||||
/**
|
||||
* Umriss-Polygon einer L-Treppe im Grundriss. Entspricht `_aussen_l_polygon`
|
||||
* im Rhino-Plugin: Linien-Schnittpunkte liefern die Ecken des L-Polygons.
|
||||
* Liefert null bei entarteter Geometrie.
|
||||
*/
|
||||
function lOutlinePoints(stair: Stair, halfW: number): Vec2[] | null {
|
||||
const u1 = norm(stair.dir);
|
||||
const n1 = leftN(u1);
|
||||
const L1 = Math.max(1e-4, stair.runLength);
|
||||
const turn = stair.turn ?? 1;
|
||||
const u2: Vec2 = turn > 0 ? n1 : scale(n1, -1);
|
||||
const n2 = leftN(u2);
|
||||
const L2 = Math.max(1e-4, stair.run2Length ?? stair.runLength);
|
||||
|
||||
// Podest-Zentrum am Ende des ersten Laufs (Mitte = halfW hinter dem Lauf-Ende).
|
||||
const corner = add(stair.start, scale(u1, L1 + halfW));
|
||||
const run2Start = add(corner, scale(u2, halfW));
|
||||
|
||||
// Lauf-1-Anfang je Seite
|
||||
const p0l = add(stair.start, scale(n1, halfW));
|
||||
const p0r = add(stair.start, scale(n1, -halfW));
|
||||
// Lauf-2-Ende je Seite
|
||||
const endRun2 = add(run2Start, scale(u2, L2));
|
||||
const p3l = add(endRun2, scale(n2, halfW));
|
||||
const p3r = add(endRun2, scale(n2, -halfW));
|
||||
// Innere Eck-Punkte: Schnittpunkt der parallelen Seitenlinien je Seite.
|
||||
const cornerL = lineIntersect2d(p0l, u1, p3l, scale(u2, -1)) ?? add(corner, scale(n1, halfW));
|
||||
const cornerR = lineIntersect2d(p0r, u1, p3r, scale(u2, -1)) ?? add(corner, scale(n1, -halfW));
|
||||
// CCW-Polygon: p0l → p0r → cornerR → p3r → p3l → cornerL
|
||||
return [p0l, p0r, cornerR, p3r, p3l, cornerL];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiral-Outline: Innenbogen + Aussenbogen + zwei radiale Schliesslinien.
|
||||
* Entspricht `_aussen_wendel` im Rhino-Plugin (immer vollständig, ohne Cut).
|
||||
* Die Bögen werden als Kreisbogen-Parameter geliefert (cx/cy/r/a0/a1), die
|
||||
* radialen Schliesslinien als Segment-Paare.
|
||||
*/
|
||||
export interface SpiralOutlineSegments {
|
||||
/** Radiale Abschlusslinien (jeweils Innenpunkt → Aussenpunkt). */
|
||||
lines: [Vec2, Vec2][];
|
||||
/** Kreisbogen-Parameter: Innen- und Aussenkreis. */
|
||||
arcs: { cx: number; cy: number; r: number; a0: number; a1: number }[];
|
||||
}
|
||||
|
||||
function spiralOutline(stair: Stair, halfW: number): SpiralOutlineSegments | null {
|
||||
const center = stair.center ?? stair.start;
|
||||
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
|
||||
const sweep = (stair.sweep ?? 270) * (Math.PI / 180);
|
||||
const startVec = sub(stair.start, center);
|
||||
const a0 = Math.atan2(startVec.y, startVec.x);
|
||||
const a1 = a0 + sweep;
|
||||
const rIn = Math.max(0.02, radius - halfW);
|
||||
const rOut = radius + halfW;
|
||||
|
||||
const ptAt = (r: number, a: number): Vec2 => ({
|
||||
x: center.x + r * Math.cos(a),
|
||||
y: center.y + r * Math.sin(a),
|
||||
});
|
||||
|
||||
return {
|
||||
lines: [
|
||||
[ptAt(rIn, a0), ptAt(rOut, a0)],
|
||||
[ptAt(rIn, a1), ptAt(rOut, a1)],
|
||||
],
|
||||
arcs: [
|
||||
{ cx: center.x, cy: center.y, r: rIn, a0, a1 },
|
||||
{ cx: center.x, cy: center.y, r: rOut, a0, a1 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aussenlinie einer Treppe im Grundriss je nach Form:
|
||||
* • gerade → geschlossenes Rechteck als Polygon-Punkte.
|
||||
* • L → L-Polygon via Linien-Schnittpunkten.
|
||||
* • Wendel → `SpiralOutlineSegments` (Bögen + radiale Linien).
|
||||
*
|
||||
* Genau einer von `polygon` / `spiral` ist nicht null.
|
||||
*/
|
||||
export function stairOutline(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
): { polygon: Vec2[] | null; spiral: SpiralOutlineSegments | null } {
|
||||
void totalRise; // Signatur-Konsistenz mit stairGeometry; derzeit ungenutzt
|
||||
const halfW = Math.max(0.05, stair.width / 2);
|
||||
// Referenz (links/mitte/rechts): die gespeicherte Achse zuerst auf die
|
||||
// visuelle Treppen-Mitte versetzen, danach die um die Mitte zentrierte
|
||||
// Aussenlinien-Logik unverändert anwenden (identisch zu `stairGeometry`).
|
||||
const u = norm(stair.dir);
|
||||
const c = referenzMidShift(stair, u);
|
||||
const s = stair.referenz && stair.referenz !== "mitte"
|
||||
? { ...stair, start: add(stair.start, c), center: stair.center ? add(stair.center, c) : stair.center }
|
||||
: stair;
|
||||
if (s.shape === "spiral") {
|
||||
return { polygon: null, spiral: spiralOutline(s, halfW) };
|
||||
}
|
||||
if (s.shape === "L") {
|
||||
return { polygon: lOutlinePoints(s, halfW), spiral: null };
|
||||
}
|
||||
return { polygon: straightOutlinePoints(s, halfW), spiral: null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Unit-Tests für `stairOutline` (Plan-Aussenlinie).
|
||||
*
|
||||
* Prüft:
|
||||
* • Gerade Treppe → 4-Punkte-Rechteck (polygon, keine spiral).
|
||||
* • L-Treppe → 6-Punkte-L-Polygon via Linien-Schnittpunkten.
|
||||
* • Wendeltreppe → SpiralOutlineSegments (2 Bögen, 2 radiale Linien, kein polygon).
|
||||
*
|
||||
* Die genauen Koordinaten-Werte werden nur grob geprüft (Vorzeichen, Länge,
|
||||
* Schliessen); die Geometrie-Logik wird via Abstand-Invarianten verifiziert.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stairOutline, stairGeometry } from "./stair";
|
||||
import type { Stair, Vec2 } from "../model/types";
|
||||
|
||||
/** Einfache Distanz-Funktion. */
|
||||
const dist = (a: Vec2, b: Vec2) => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
|
||||
/** Minimale gerade Treppe nach rechts (+X). */
|
||||
const geradeStair: Stair = {
|
||||
id: "g1",
|
||||
type: "stair",
|
||||
floorId: "eg",
|
||||
categoryCode: "40",
|
||||
shape: "straight",
|
||||
start: { x: 0, y: 0 },
|
||||
dir: { x: 1, y: 0 },
|
||||
runLength: 3.0,
|
||||
width: 1.2,
|
||||
stepCount: 16,
|
||||
};
|
||||
|
||||
/** L-Treppe: erster Lauf +X, zweiter Lauf +Y (turn = +1). */
|
||||
const lStair: Stair = {
|
||||
id: "l1",
|
||||
type: "stair",
|
||||
floorId: "eg",
|
||||
categoryCode: "40",
|
||||
shape: "L",
|
||||
start: { x: 0, y: 0 },
|
||||
dir: { x: 1, y: 0 },
|
||||
runLength: 2.0,
|
||||
run2Length: 2.0,
|
||||
turn: 1,
|
||||
width: 1.2,
|
||||
stepCount: 14,
|
||||
};
|
||||
|
||||
/** Wendeltreppe: 270° im Gegenuhrzeigersinn, Radius 1.5 m. */
|
||||
const wendelStair: Stair = {
|
||||
id: "w1",
|
||||
type: "stair",
|
||||
floorId: "eg",
|
||||
categoryCode: "40",
|
||||
shape: "spiral",
|
||||
start: { x: 1.5, y: 0 },
|
||||
dir: { x: 1, y: 0 },
|
||||
runLength: 3.0, // bei Wendel: Pflichtfeld, inhaltlich nicht genutzt
|
||||
center: { x: 0, y: 0 },
|
||||
radius: 1.5,
|
||||
sweep: 270,
|
||||
width: 1.2,
|
||||
stepCount: 12,
|
||||
};
|
||||
|
||||
describe("stairOutline — gerade Treppe", () => {
|
||||
const totalRise = 3.0;
|
||||
const result = stairOutline(geradeStair, totalRise);
|
||||
|
||||
it("liefert polygon, keine spiral", () => {
|
||||
expect(result.polygon).not.toBeNull();
|
||||
expect(result.spiral).toBeNull();
|
||||
});
|
||||
|
||||
it("polygon hat genau 4 Ecken", () => {
|
||||
expect(result.polygon!.length).toBe(4);
|
||||
});
|
||||
|
||||
it("Lauflaenge entspricht runLength", () => {
|
||||
// Start-Kante (x=0) vs End-Kante (x=3): Abstand der gleichseitigen Kanten = 3.0 m.
|
||||
const pts = result.polygon!;
|
||||
// pts[0] und pts[1] liegen bei x≈0, pts[2] und pts[3] bei x≈runLength.
|
||||
const startX = (pts[0].x + pts[1].x) / 2;
|
||||
const endX = (pts[2].x + pts[3].x) / 2;
|
||||
expect(Math.abs(endX - startX)).toBeCloseTo(geradeStair.runLength, 5);
|
||||
});
|
||||
|
||||
it("Breite entspricht stair.width", () => {
|
||||
const pts = result.polygon!;
|
||||
// Seitenkante 0→3 und 1→2 haben die Breite = stair.width.
|
||||
const w = dist(pts[0], pts[1]);
|
||||
expect(w).toBeCloseTo(geradeStair.width, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stairOutline — L-Treppe", () => {
|
||||
const totalRise = 3.0;
|
||||
const result = stairOutline(lStair, totalRise);
|
||||
|
||||
it("liefert polygon, keine spiral", () => {
|
||||
expect(result.polygon).not.toBeNull();
|
||||
expect(result.spiral).toBeNull();
|
||||
});
|
||||
|
||||
it("polygon hat 6 Ecken (L-Form)", () => {
|
||||
// 4 Aussen-Eck + 2 Innen-Eck via Linien-Schnittpunkten = 6 Punkte.
|
||||
expect(result.polygon!.length).toBe(6);
|
||||
});
|
||||
|
||||
it("keine NaN-Koordinaten", () => {
|
||||
for (const p of result.polygon!) {
|
||||
expect(isFinite(p.x)).toBe(true);
|
||||
expect(isFinite(p.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stairOutline — Wendeltreppe", () => {
|
||||
const totalRise = 3.0;
|
||||
const result = stairOutline(wendelStair, totalRise);
|
||||
|
||||
it("liefert spiral, kein polygon", () => {
|
||||
expect(result.spiral).not.toBeNull();
|
||||
expect(result.polygon).toBeNull();
|
||||
});
|
||||
|
||||
it("zwei radiale Schliesslinien (Start + Ende)", () => {
|
||||
expect(result.spiral!.lines.length).toBe(2);
|
||||
});
|
||||
|
||||
it("zwei Kreisboegen (Innen + Aussen)", () => {
|
||||
expect(result.spiral!.arcs.length).toBe(2);
|
||||
});
|
||||
|
||||
it("Innenradius < Aussenradius", () => {
|
||||
const [arcIn, arcOut] = result.spiral!.arcs;
|
||||
expect(arcIn.r).toBeLessThan(arcOut.r);
|
||||
});
|
||||
|
||||
it("Startlinie Innen-Punkt liegt auf Innenkreis", () => {
|
||||
const arcIn = result.spiral!.arcs[0];
|
||||
const [lineStart] = result.spiral!.lines;
|
||||
const [innerPt] = lineStart;
|
||||
const d = dist({ x: arcIn.cx, y: arcIn.cy }, innerPt);
|
||||
expect(d).toBeCloseTo(arcIn.r, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stairOutline — Referenz links/mitte/rechts (Item 4)", () => {
|
||||
const totalRise = 3.0;
|
||||
// Gerade Treppe entlang +X, Breite 1.2 m. Perp (linke Normale) = (0, +1).
|
||||
const base: Stair = { ...geradeStair, referenz: "mitte" };
|
||||
const mitte = stairOutline(base, totalRise).polygon!;
|
||||
const links = stairOutline({ ...base, referenz: "links" }, totalRise).polygon!;
|
||||
const rechts = stairOutline({ ...base, referenz: "rechts" }, totalRise).polygon!;
|
||||
|
||||
/** Mittlere Y-Höhe eines Rechteck-Polygons. */
|
||||
const midY = (pts: Vec2[]) => pts.reduce((s, p) => s + p.y, 0) / pts.length;
|
||||
|
||||
it("Breite bleibt bei allen Referenzen gleich", () => {
|
||||
for (const poly of [mitte, links, rechts]) {
|
||||
expect(dist(poly[0], poly[1])).toBeCloseTo(geradeStair.width, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("referenz=links: Treppe liegt RECHTS der Achse (−n) → Mitte um width/2 nach −Y", () => {
|
||||
// Achse (start/dir) = linke Kante; die Fläche versetzt sich in −n = (0,−1).
|
||||
expect(midY(links) - midY(mitte)).toBeCloseTo(-geradeStair.width / 2, 5);
|
||||
});
|
||||
|
||||
it("referenz=rechts: Treppe liegt LINKS der Achse (+n) → Mitte um width/2 nach +Y", () => {
|
||||
expect(midY(rechts) - midY(mitte)).toBeCloseTo(+geradeStair.width / 2, 5);
|
||||
});
|
||||
|
||||
it("bei referenz=links liegt die Achse (y=0) auf der linken Kante der Treppe", () => {
|
||||
// Linke Kante = max-Y-Kante der Fläche (n = +Y). Sie soll bei y=0 liegen.
|
||||
const maxY = Math.max(...links.map((p) => p.y));
|
||||
expect(maxY).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("mitte entspricht dem Default (kein referenz-Feld gesetzt)", () => {
|
||||
const noRef = stairOutline({ ...geradeStair }, totalRise).polygon!;
|
||||
for (let i = 0; i < noRef.length; i++) {
|
||||
expect(noRef[i].x).toBeCloseTo(mitte[i].x, 6);
|
||||
expect(noRef[i].y).toBeCloseTo(mitte[i].y, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("stairGeometry — Lauflinie bleibt visuell zentriert (Item 4)", () => {
|
||||
const totalRise = 3.0;
|
||||
const links = stairGeometry({ ...geradeStair, referenz: "links" }, totalRise);
|
||||
const mitte = stairGeometry({ ...geradeStair, referenz: "mitte" }, totalRise);
|
||||
|
||||
it("Lauflinie liegt auf der Treppen-Mitte, nicht auf der Referenzkante (y=0)", () => {
|
||||
// Bei referenz=links liegt die Mitte bei y = −width/2 = −0.6.
|
||||
for (const p of links.runLine) {
|
||||
expect(p.y).toBeCloseTo(-geradeStair.width / 2, 5);
|
||||
}
|
||||
// Bei mitte liegt die Lauflinie auf y=0 (Achse = Mitte).
|
||||
for (const p of mitte.runLine) {
|
||||
expect(p.y).toBeCloseTo(0, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("Tritte sind bei referenz=links um −width/2 versetzt", () => {
|
||||
const dy = links.treads[0].pts[0].y - mitte.treads[0].pts[0].y;
|
||||
expect(dy).toBeCloseTo(-geradeStair.width / 2, 5);
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,16 @@ export const de = {
|
||||
"objinfo.opening.dir.out": "Außen",
|
||||
"objinfo.opening.bottom": "Unterkante (UK)",
|
||||
"objinfo.opening.top": "Oberkante (OK)",
|
||||
// Neue Öffnungs-Felder
|
||||
"objinfo.opening.wingCount": "Flügel",
|
||||
"objinfo.opening.doorType": "Typ",
|
||||
"objinfo.opening.doorType.normal": "Normal",
|
||||
"objinfo.opening.doorType.wandoeffnung": "Wandöffnung",
|
||||
"objinfo.opening.lintelLines": "Sturzlinien",
|
||||
"objinfo.opening.lintelLines.keine": "Keine",
|
||||
"objinfo.opening.lintelLines.innen": "Innen",
|
||||
"objinfo.opening.lintelLines.aussen": "Außen",
|
||||
"objinfo.opening.lintelLines.beide": "Beide",
|
||||
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
|
||||
"objinfo.stair.section": "Treppe",
|
||||
"objinfo.stair.shape": "Grundform",
|
||||
@@ -318,6 +328,11 @@ export const de = {
|
||||
"objinfo.stair.refFloor": "Referenzgeschoss",
|
||||
"objinfo.stair.bottom": "Unterkante (UK)",
|
||||
"objinfo.stair.top": "Oberkante (OK)",
|
||||
// Neues Treppen-Feld
|
||||
"objinfo.stair.referenz": "Referenz",
|
||||
"objinfo.stair.referenz.links": "Links",
|
||||
"objinfo.stair.referenz.mitte": "Mitte",
|
||||
"objinfo.stair.referenz.rechts": "Rechts",
|
||||
// ── Raum-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||
"objinfo.room.section": "Raum",
|
||||
"objinfo.room.name": "Name",
|
||||
@@ -669,6 +684,9 @@ export const de = {
|
||||
"cmd.stair.updown": "Auf/Ab",
|
||||
"cmd.stair.stepsField": "Stufen",
|
||||
"cmd.stair.riseField": "Steighöhe",
|
||||
"cmd.stair.referenz": "Referenz",
|
||||
"cmd.stair.treadMode": "Trittmass",
|
||||
"cmd.stair.auftrittField": "Auftritt",
|
||||
"cmd.line.label": "Linie",
|
||||
"cmd.line.start": "Startpunkt der Linie:",
|
||||
"cmd.line.end": "Endpunkt der Linie:",
|
||||
|
||||
@@ -301,6 +301,15 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.opening.dir.out": "Out",
|
||||
"objinfo.opening.bottom": "Bottom (UK)",
|
||||
"objinfo.opening.top": "Top (OK)",
|
||||
"objinfo.opening.wingCount": "Wings",
|
||||
"objinfo.opening.doorType": "Type",
|
||||
"objinfo.opening.doorType.normal": "Normal",
|
||||
"objinfo.opening.doorType.wandoeffnung": "Wall opening",
|
||||
"objinfo.opening.lintelLines": "Lintel lines",
|
||||
"objinfo.opening.lintelLines.keine": "None",
|
||||
"objinfo.opening.lintelLines.innen": "Inside",
|
||||
"objinfo.opening.lintelLines.aussen": "Outside",
|
||||
"objinfo.opening.lintelLines.beide": "Both",
|
||||
"objinfo.stair.section": "Stair",
|
||||
"objinfo.stair.shape": "Shape",
|
||||
"objinfo.stair.shape.straight": "Straight",
|
||||
@@ -317,6 +326,10 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.stair.refFloor": "Reference floor",
|
||||
"objinfo.stair.bottom": "Bottom (UK)",
|
||||
"objinfo.stair.top": "Top (OK)",
|
||||
"objinfo.stair.referenz": "Reference",
|
||||
"objinfo.stair.referenz.links": "Left",
|
||||
"objinfo.stair.referenz.mitte": "Centre",
|
||||
"objinfo.stair.referenz.rechts": "Right",
|
||||
// ── Room attributes (object info panel) ──────────────────────────────────
|
||||
"objinfo.room.section": "Room",
|
||||
"objinfo.room.name": "Name",
|
||||
@@ -661,6 +674,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.stair.updown": "Up/Down",
|
||||
"cmd.stair.stepsField": "Steps",
|
||||
"cmd.stair.riseField": "Rise",
|
||||
"cmd.stair.referenz": "Reference",
|
||||
"cmd.stair.treadMode": "Tread",
|
||||
"cmd.stair.auftrittField": "Tread depth",
|
||||
"cmd.line.label": "Line",
|
||||
"cmd.line.start": "Start of line:",
|
||||
"cmd.line.end": "End of line:",
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
// Tests für den DXF-Import der Kurven-Entities ARC / CIRCLE / ELLIPSE:
|
||||
// belegt Tessellierung, Winkeleinheit (Radiant), Schließung und Z-Höhe.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseDxf } from "./dxfParser";
|
||||
import { contoursToDrawings } from "./dxfToDrawings";
|
||||
import type { Contour, ContourSet, Vec2 } from "../model/types";
|
||||
|
||||
/** Minimales DXF aus Gruppencode/Wert-Paaren; nur eine ENTITIES-Sektion. */
|
||||
function dxf(...entities: string[][]): string {
|
||||
const lines = ["0", "SECTION", "2", "ENTITIES"];
|
||||
for (const pairs of entities) lines.push(...pairs);
|
||||
lines.push("0", "ENDSEC", "0", "EOF");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Ein CIRCLE-Entity: Zentrum (cx,cy,cz), Radius r. */
|
||||
function circle(cx: number, cy: number, cz: number, r: number): string[] {
|
||||
return ["0", "CIRCLE", "8", "0", "10", `${cx}`, "20", `${cy}`, "30", `${cz}`, "40", `${r}`];
|
||||
}
|
||||
|
||||
/** Ein ARC-Entity: Zentrum, Radius, Start/End in GRAD (DXF-Konvention). */
|
||||
function arc(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string[] {
|
||||
return [
|
||||
"0", "ARC", "8", "0",
|
||||
"10", `${cx}`, "20", `${cy}`, "30", "0",
|
||||
"40", `${r}`, "50", `${startDeg}`, "51", `${endDeg}`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Ein ELLIPSE-Entity: Zentrum, Hauptachsen-Endpunkt (rel.), Verhältnis, Start/End (Radiant). */
|
||||
function ellipse(
|
||||
cx: number, cy: number, majX: number, majY: number, ratio: number, start: number, end: number,
|
||||
): string[] {
|
||||
return [
|
||||
"0", "ELLIPSE", "8", "0",
|
||||
"10", `${cx}`, "20", `${cy}`, "30", "0",
|
||||
"11", `${majX}`, "21", `${majY}`, "31", "0",
|
||||
"40", `${ratio}`, "41", `${start}`, "42", `${end}`,
|
||||
];
|
||||
}
|
||||
|
||||
const dist = (p: Vec2, cx: number, cy: number) => Math.hypot(p.x - cx, p.y - cy);
|
||||
|
||||
/** Einzige Kontur des Imports (Test-Bequemlichkeit). */
|
||||
function onlyContour(text: string): Contour {
|
||||
const res = parseDxf(text);
|
||||
expect(res.contours).toHaveLength(1);
|
||||
expect(res.contours[0].contours).toHaveLength(1);
|
||||
return res.contours[0].contours[0];
|
||||
}
|
||||
|
||||
describe("parseDxf — CIRCLE", () => {
|
||||
it("erzeugt einen geschlossenen, tessellierten Ring auf konstantem Radius", () => {
|
||||
const c = onlyContour(dxf(circle(10, 20, 5, 4)));
|
||||
expect(c.closed).toBe(true);
|
||||
expect(c.z).toBe(5);
|
||||
// Voller Kreis ohne Schluss-Duplikat: 2π / (π/32) = 64 Segmente → 64 Punkte.
|
||||
expect(c.pts).toHaveLength(64);
|
||||
for (const p of c.pts) expect(dist(p, 10, 20)).toBeCloseTo(4, 9);
|
||||
// Erster Punkt bei Winkel 0: (cx+r, cy).
|
||||
expect(c.pts[0].x).toBeCloseTo(14, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(20, 9);
|
||||
// Kein Duplikat des Startpunkts am Ende.
|
||||
expect(dist(c.pts[c.pts.length - 1], 14, 20)).toBeGreaterThan(0.01);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDxf — ARC", () => {
|
||||
it("tesselliert einen Viertelbogen 0°→90° CCW (offen)", () => {
|
||||
const c = onlyContour(dxf(arc(0, 0, 10, 0, 90)));
|
||||
expect(c.closed).toBe(false);
|
||||
// Spanne π/2 → 16 Segmente → 17 Punkte.
|
||||
expect(c.pts).toHaveLength(17);
|
||||
expect(c.pts[0].x).toBeCloseTo(10, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 9);
|
||||
const end = c.pts[c.pts.length - 1];
|
||||
expect(end.x).toBeCloseTo(0, 9);
|
||||
expect(end.y).toBeCloseTo(10, 9);
|
||||
for (const p of c.pts) expect(dist(p, 0, 0)).toBeCloseTo(10, 9);
|
||||
});
|
||||
|
||||
it("ergänzt eine umlaufende Spanne (270°→90°) um 2π statt negativ", () => {
|
||||
const c = onlyContour(dxf(arc(0, 0, 5, 270, 90)));
|
||||
// Spanne 180° = π → 32 Segmente → 33 Punkte, CCW von unten (−y) nach oben (+y).
|
||||
expect(c.pts).toHaveLength(33);
|
||||
expect(c.pts[0].y).toBeCloseTo(-5, 9);
|
||||
expect(c.pts[c.pts.length - 1].y).toBeCloseTo(5, 9);
|
||||
// Mittelpunkt der Spanne (bei 0°) liegt bei (+r, 0), nicht (−r, 0).
|
||||
expect(c.pts[16].x).toBeCloseTo(5, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDxf — ELLIPSE", () => {
|
||||
it("tesselliert einen vollen Umlauf (geschlossen) mit korrekten Halbachsen", () => {
|
||||
const c = onlyContour(ellipse2Dxf());
|
||||
expect(c.closed).toBe(true);
|
||||
// Voller Umlauf → Schluss-Duplikat weggelassen.
|
||||
expect(dist(c.pts[c.pts.length - 1], c.pts[0].x, c.pts[0].y)).toBeGreaterThan(0.01);
|
||||
// Hauptachse 10 entlang x, Nebenachse 5 entlang y (ratio 0.5).
|
||||
const maxX = Math.max(...c.pts.map((p) => p.x));
|
||||
const maxY = Math.max(...c.pts.map((p) => p.y));
|
||||
expect(maxX).toBeCloseTo(10, 6);
|
||||
expect(maxY).toBeCloseTo(5, 6);
|
||||
// Parameter t=0 → Center + Hauptachse = (10, 0).
|
||||
expect(c.pts[0].x).toBeCloseTo(10, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 9);
|
||||
});
|
||||
});
|
||||
|
||||
/** Volle Ellipse: Center (0,0), Hauptachse (10,0), ratio 0.5, 0..2π. */
|
||||
function ellipse2Dxf(): string {
|
||||
return dxf(ellipse(0, 0, 10, 0, 0.5, 0, Math.PI * 2));
|
||||
}
|
||||
|
||||
describe("parseDxf — gemischt", () => {
|
||||
it("liest mehrere Kurven-Entities in EINEN Konturensatz", () => {
|
||||
const res = parseDxf(dxf(circle(0, 0, 0, 1), arc(0, 0, 2, 0, 90)));
|
||||
expect(res.contours).toHaveLength(1);
|
||||
expect(res.contours[0].contours).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── SPLINE ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** LINE-Entity (10/20/30 Start, 11/21/31 Ende). */
|
||||
function line(x1: number, y1: number, x2: number, y2: number): string[] {
|
||||
return ["0", "LINE", "8", "0", "10", `${x1}`, "20", `${y1}`, "30", "0", "11", `${x2}`, "21", `${y2}`, "31", "0"];
|
||||
}
|
||||
|
||||
/** SPLINE mit Kontrollpunkten + Knoten (71 Grad, 40 Knoten, 10/20/30 CPs). */
|
||||
function spline(degree: number, knots: number[], cps: Array<[number, number]>): string[] {
|
||||
const g = ["0", "SPLINE", "8", "0", "71", `${degree}`];
|
||||
for (const k of knots) g.push("40", `${k}`);
|
||||
for (const [x, y] of cps) g.push("10", `${x}`, "20", `${y}`, "30", "0");
|
||||
return g;
|
||||
}
|
||||
|
||||
/** SPLINE nur mit Stützpunkten (11/21/31 fitPoints), ohne gültige Knoten. */
|
||||
function splineFit(fps: Array<[number, number]>): string[] {
|
||||
const g = ["0", "SPLINE", "8", "0", "71", "3"];
|
||||
for (const [x, y] of fps) g.push("11", `${x}`, "21", `${y}`, "31", "0");
|
||||
return g;
|
||||
}
|
||||
|
||||
describe("parseDxf — SPLINE", () => {
|
||||
it("Grad-1-Spline zeichnet exakt das Kontrollpolygon nach", () => {
|
||||
// Clamped-Knoten für 2 CPs, Grad 1: |U| = 2+1+1 = 4, Domain [0,1].
|
||||
const c = onlyContour(dxf(spline(1, [0, 0, 1, 1], [[0, 0], [10, 0]])));
|
||||
expect(c.closed).toBe(false);
|
||||
expect(c.pts[0].x).toBeCloseTo(0, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 9);
|
||||
const last = c.pts[c.pts.length - 1];
|
||||
expect(last.x).toBeCloseTo(10, 9);
|
||||
expect(last.y).toBeCloseTo(0, 9);
|
||||
// Linear: alle Punkte auf y=0, x monoton steigend.
|
||||
for (let i = 1; i < c.pts.length; i++) {
|
||||
expect(c.pts[i].y).toBeCloseTo(0, 9);
|
||||
expect(c.pts[i].x).toBeGreaterThanOrEqual(c.pts[i - 1].x - 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
it("Grad-2-Spline bleibt in der konvexen Hülle und endet auf den Rand-CPs", () => {
|
||||
// 3 CPs, Grad 2, clamped: |U| = 3+2+1 = 6 → [0,0,0,1,1,1], Domain [0,1].
|
||||
const c = onlyContour(
|
||||
dxf(spline(2, [0, 0, 0, 1, 1, 1], [[0, 0], [5, 10], [10, 0]])),
|
||||
);
|
||||
// Endpunkte = erster/letzter Kontrollpunkt (clamped).
|
||||
expect(c.pts[0].x).toBeCloseTo(0, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 9);
|
||||
const last = c.pts[c.pts.length - 1];
|
||||
expect(last.x).toBeCloseTo(10, 9);
|
||||
expect(last.y).toBeCloseTo(0, 9);
|
||||
// Konvexe Hülle: y nie über 10, x in [0,10]; Scheitel bei x≈5 unter 10.
|
||||
for (const p of c.pts) {
|
||||
expect(p.y).toBeLessThanOrEqual(10 + 1e-9);
|
||||
expect(p.y).toBeGreaterThanOrEqual(-1e-9);
|
||||
expect(p.x).toBeGreaterThanOrEqual(-1e-9);
|
||||
expect(p.x).toBeLessThanOrEqual(10 + 1e-9);
|
||||
}
|
||||
// Mittelpunkt (t=0.5) einer quadratischen Bézier: 0.25·P0+0.5·P1+0.25·P2 = (5,5).
|
||||
const mid = c.pts[Math.floor(c.pts.length / 2)];
|
||||
expect(mid.x).toBeCloseTo(5, 6);
|
||||
expect(mid.y).toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
it("fällt ohne gültige Knoten auf die Stützpunkte zurück", () => {
|
||||
const c = onlyContour(dxf(splineFit([[0, 0], [1, 2], [3, 4]])));
|
||||
expect(c.pts).toHaveLength(3);
|
||||
expect(c.pts[1].x).toBeCloseTo(1, 9);
|
||||
expect(c.pts[1].y).toBeCloseTo(2, 9);
|
||||
});
|
||||
});
|
||||
|
||||
// ── INSERT (Block-Referenzen) ─────────────────────────────────────────────────
|
||||
|
||||
/** BLOCK-Definition: Name, Basispunkt, enthaltene Entities. */
|
||||
function block(name: string, bx: number, by: number, ...ents: string[][]): string[] {
|
||||
const g = ["0", "BLOCK", "8", "0", "2", name, "10", `${bx}`, "20", `${by}`, "30", "0", "70", "0"];
|
||||
for (const e of ents) g.push(...e);
|
||||
g.push("0", "ENDBLK");
|
||||
return g;
|
||||
}
|
||||
|
||||
interface InsOpts {
|
||||
rot?: number; sx?: number; sy?: number; nc?: number; nr?: number; dc?: number; dr?: number;
|
||||
}
|
||||
/** INSERT-Referenz auf einen Block. */
|
||||
function insert(name: string, x: number, y: number, o: InsOpts = {}): string[] {
|
||||
const g = ["0", "INSERT", "2", name, "10", `${x}`, "20", `${y}`, "30", "0"];
|
||||
if (o.sx !== undefined) g.push("41", `${o.sx}`);
|
||||
if (o.sy !== undefined) g.push("42", `${o.sy}`);
|
||||
if (o.rot !== undefined) g.push("50", `${o.rot}`);
|
||||
if (o.nc !== undefined) g.push("70", `${o.nc}`);
|
||||
if (o.nr !== undefined) g.push("71", `${o.nr}`);
|
||||
if (o.dc !== undefined) g.push("44", `${o.dc}`);
|
||||
if (o.dr !== undefined) g.push("45", `${o.dr}`);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** DXF mit BLOCKS- und ENTITIES-Sektion. */
|
||||
function dxfFull(blocks: string[][], entities: string[][]): string {
|
||||
const lines: string[] = [];
|
||||
if (blocks.length) {
|
||||
lines.push("0", "SECTION", "2", "BLOCKS");
|
||||
for (const b of blocks) lines.push(...b);
|
||||
lines.push("0", "ENDSEC");
|
||||
}
|
||||
lines.push("0", "SECTION", "2", "ENTITIES");
|
||||
for (const e of entities) lines.push(...e);
|
||||
lines.push("0", "ENDSEC", "0", "EOF");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("parseDxf — INSERT", () => {
|
||||
it("verschiebt eine Block-Linie an den Einfügepunkt", () => {
|
||||
const c = onlyContour(
|
||||
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 5, 5)]),
|
||||
);
|
||||
expect(c.pts[0].x).toBeCloseTo(5, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(5, 9);
|
||||
expect(c.pts[1].x).toBeCloseTo(6, 9);
|
||||
expect(c.pts[1].y).toBeCloseTo(5, 9);
|
||||
});
|
||||
|
||||
it("rotiert um 90° und skaliert", () => {
|
||||
const rot = onlyContour(
|
||||
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { rot: 90 })]),
|
||||
);
|
||||
// (1,0) um 90° CCW → (0,1).
|
||||
expect(rot.pts[1].x).toBeCloseTo(0, 6);
|
||||
expect(rot.pts[1].y).toBeCloseTo(1, 6);
|
||||
|
||||
const scl = onlyContour(
|
||||
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { sx: 2, sy: 3 })]),
|
||||
);
|
||||
expect(scl.pts[1].x).toBeCloseTo(2, 9);
|
||||
expect(scl.pts[1].y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("expandiert ein 2×1-Array zu zwei Konturen", () => {
|
||||
const res = parseDxf(
|
||||
dxfFull([block("seg", 0, 0, line(0, 0, 1, 0))], [insert("seg", 0, 0, { nc: 2, dc: 10 })]),
|
||||
);
|
||||
const cs = res.contours[0].contours;
|
||||
expect(cs).toHaveLength(2);
|
||||
// Zweite Spalte um columnSpacing 10 versetzt.
|
||||
const xs = cs.map((c) => c.pts[0].x).sort((a, b) => a - b);
|
||||
expect(xs[0]).toBeCloseTo(0, 9);
|
||||
expect(xs[1]).toBeCloseTo(10, 9);
|
||||
});
|
||||
|
||||
it("löst verschachtelte Blockreferenzen auf (Transform-Komposition)", () => {
|
||||
const c = onlyContour(
|
||||
dxfFull(
|
||||
[
|
||||
block("inner", 0, 0, line(0, 0, 1, 0)),
|
||||
block("outer", 0, 0, insert("inner", 2, 0)),
|
||||
],
|
||||
[insert("outer", 0, 3)],
|
||||
),
|
||||
);
|
||||
// inner (0,0)-(1,0) → +(2,0) durch outer → +(0,3) durch top = (2,3)-(3,3).
|
||||
expect(c.pts[0].x).toBeCloseTo(2, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(3, 9);
|
||||
expect(c.pts[1].x).toBeCloseTo(3, 9);
|
||||
expect(c.pts[1].y).toBeCloseTo(3, 9);
|
||||
});
|
||||
});
|
||||
|
||||
// ── HATCH (gefüllte Flächen) ──────────────────────────────────────────────────
|
||||
|
||||
/** HATCH mit Polyline-Randpfad (Flag 3 = external+polyline). */
|
||||
function hatchPoly(verts: Array<[number, number]>): string[] {
|
||||
const g = ["0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "3", "72", "0", "73", "1", "93", `${verts.length}`];
|
||||
for (const [x, y] of verts) g.push("10", `${x}`, "20", `${y}`);
|
||||
g.push("97", "0");
|
||||
return g;
|
||||
}
|
||||
|
||||
/** HATCH mit Kanten-Randpfad aus Linienkanten (Flag 1 = external, kein Polyline-Bit). */
|
||||
function hatchLineEdges(verts: Array<[number, number]>): string[] {
|
||||
const g = ["0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "1", "92", "1", "93", `${verts.length}`];
|
||||
for (let k = 0; k < verts.length; k++) {
|
||||
const a = verts[k];
|
||||
const b = verts[(k + 1) % verts.length];
|
||||
g.push("72", "1", "10", `${a[0]}`, "20", `${a[1]}`, "11", `${b[0]}`, "21", `${b[1]}`);
|
||||
}
|
||||
g.push("97", "0");
|
||||
return g;
|
||||
}
|
||||
|
||||
/** HATCH mit einer Bogenkante (Kantentyp 2), Winkel in Grad. */
|
||||
function hatchArc(cx: number, cy: number, r: number, s: number, e: number): string[] {
|
||||
return [
|
||||
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1",
|
||||
"91", "1", "92", "1", "93", "1",
|
||||
"72", "2", "10", `${cx}`, "20", `${cy}`, "40", `${r}`, "50", `${s}`, "51", `${e}`, "73", "1",
|
||||
"97", "0",
|
||||
];
|
||||
}
|
||||
|
||||
describe("parseDxf — HATCH", () => {
|
||||
it("Polyline-Rand → geschlossene gefüllte Kontur", () => {
|
||||
const c = onlyContour(dxf(hatchPoly([[0, 0], [10, 0], [10, 10], [0, 10]])));
|
||||
expect(c.closed).toBe(true);
|
||||
expect(c.filled).toBe(true);
|
||||
expect(c.pts).toHaveLength(4);
|
||||
expect(c.pts[1].x).toBeCloseTo(10, 9);
|
||||
expect(c.pts[1].y).toBeCloseTo(0, 9);
|
||||
expect(c.pts[2].x).toBeCloseTo(10, 9);
|
||||
expect(c.pts[2].y).toBeCloseTo(10, 9);
|
||||
});
|
||||
|
||||
it("Linienkanten-Rand → geschlossene gefüllte Kontur (Eckpunkte)", () => {
|
||||
const c = onlyContour(dxf(hatchLineEdges([[0, 0], [4, 0], [4, 4], [0, 4]])));
|
||||
expect(c.closed).toBe(true);
|
||||
expect(c.filled).toBe(true);
|
||||
expect(c.pts).toHaveLength(4);
|
||||
const xs = c.pts.map((p) => p.x).sort((a, b) => a - b);
|
||||
expect(xs[0]).toBeCloseTo(0, 9);
|
||||
expect(xs[3]).toBeCloseTo(4, 9);
|
||||
});
|
||||
|
||||
it("Bogenkante → tessellierter Halbkreis auf Radius", () => {
|
||||
const c = onlyContour(dxf(hatchArc(0, 0, 5, 0, 180)));
|
||||
expect(c.filled).toBe(true);
|
||||
for (const p of c.pts) expect(Math.hypot(p.x, p.y)).toBeCloseTo(5, 6);
|
||||
expect(c.pts[0].x).toBeCloseTo(5, 6);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 6);
|
||||
const last = c.pts[c.pts.length - 1];
|
||||
expect(last.x).toBeCloseTo(-5, 6);
|
||||
expect(last.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("Ellipsenkante → tessellierter Halbumlauf mit korrekten Halbachsen", () => {
|
||||
// Center (0,0), Hauptachse (10,0), Verhältnis 0.5, 0°→180° ccw.
|
||||
const g = [
|
||||
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1",
|
||||
"91", "1", "92", "1", "93", "1",
|
||||
"72", "3", "10", "0", "20", "0", "11", "10", "21", "0", "40", "0.5", "50", "0", "51", "180", "73", "1",
|
||||
"97", "0",
|
||||
];
|
||||
const c = onlyContour(dxf(g));
|
||||
expect(c.filled).toBe(true);
|
||||
// t=0 → Center + Hauptachse = (10,0).
|
||||
expect(c.pts[0].x).toBeCloseTo(10, 6);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 6);
|
||||
// Scheitel bei t=90° → Nebenachse (0,5).
|
||||
const maxY = Math.max(...c.pts.map((p) => p.y));
|
||||
expect(maxY).toBeCloseTo(5, 6);
|
||||
const last = c.pts[c.pts.length - 1];
|
||||
expect(last.x).toBeCloseTo(-10, 6);
|
||||
expect(last.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it("Spline-Kante (Grad 1) → Kontrollpolygon nachgezeichnet", () => {
|
||||
// 2 CPs (0,0)-(10,0), Grad 1, clamped-Knoten [0,0,1,1].
|
||||
const g = [
|
||||
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1",
|
||||
"91", "1", "92", "1", "93", "1",
|
||||
"72", "4", "94", "1", "95", "4", "96", "2",
|
||||
"40", "0", "40", "0", "40", "1", "40", "1",
|
||||
"10", "0", "20", "0", "10", "10", "20", "0",
|
||||
"97", "0",
|
||||
];
|
||||
const c = onlyContour(dxf(g));
|
||||
expect(c.filled).toBe(true);
|
||||
expect(c.pts[0].x).toBeCloseTo(0, 9);
|
||||
expect(c.pts[0].y).toBeCloseTo(0, 9);
|
||||
const last = c.pts[c.pts.length - 1];
|
||||
expect(last.x).toBeCloseTo(10, 9);
|
||||
expect(last.y).toBeCloseTo(0, 9);
|
||||
for (const p of c.pts) expect(p.y).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("mehrere Randpfade → mehrere Loops (Insel als eigener Ring)", () => {
|
||||
// Zwei Polyline-Pfade in EINER HATCH: numPaths = 2.
|
||||
const g = [
|
||||
"0", "HATCH", "8", "0", "2", "SOLID", "70", "1", "91", "2",
|
||||
"92", "3", "72", "0", "73", "1", "93", "4",
|
||||
"10", "0", "20", "0", "10", "10", "20", "0", "10", "10", "20", "10", "10", "0", "20", "10",
|
||||
"97", "0",
|
||||
"92", "3", "72", "0", "73", "1", "93", "4",
|
||||
"10", "3", "20", "3", "10", "7", "20", "3", "10", "7", "20", "7", "10", "3", "20", "7",
|
||||
"97", "0",
|
||||
];
|
||||
const res = parseDxf(dxf(g));
|
||||
expect(res.contours[0].contours).toHaveLength(2);
|
||||
expect(res.contours[0].contours.every((c) => c.filled && c.closed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contoursToDrawings — HATCH-Füllung", () => {
|
||||
it("gefüllte Kontur → Drawing2D mit fillColor und polyline-Form", () => {
|
||||
const set: ContourSet = {
|
||||
id: "s", type: "contourSet", name: "h",
|
||||
contours: [{ z: 0, closed: true, filled: true, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }] }],
|
||||
};
|
||||
const [d] = contoursToDrawings([set], "lvl", "active", "CODE", (s) => s);
|
||||
expect(d.geom.shape).toBe("polyline");
|
||||
expect(d.fillColor).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ungefüllte Kontur → Drawing2D ohne fillColor", () => {
|
||||
const set: ContourSet = {
|
||||
id: "s", type: "contourSet", name: "h",
|
||||
contours: [{ z: 0, closed: true, pts: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }] }],
|
||||
};
|
||||
const [d] = contoursToDrawings([set], "lvl", "active", "CODE", (s) => s);
|
||||
expect(d.fillColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+648
-29
@@ -17,6 +17,13 @@
|
||||
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
|
||||
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
|
||||
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
||||
// • ARC → Kontur (offener Bogen, tesselliert).
|
||||
// • CIRCLE → Kontur (geschlossener Kreis, tesselliert).
|
||||
// • ELLIPSE → Kontur (Ellipsenbogen/-umlauf, tesselliert).
|
||||
// • SPLINE → Kontur (B-Spline via De Boor; Fallback fitPoints).
|
||||
// • INSERT → Block-Konturen, transformiert (Scale/Rot/Array, verschachtelt).
|
||||
// • HATCH → gefüllte Kontur(en) je Randpfad (Custom-Handler,
|
||||
// Polyline- + Linien-/Bogen-/Ellipsen-/Spline-Kanten; Fill via `Contour.filled`).
|
||||
|
||||
import DxfParser from "dxf-parser";
|
||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||
@@ -61,10 +68,51 @@ interface DxfEntity {
|
||||
faces?: number[][];
|
||||
isPolyfaceMesh?: boolean;
|
||||
is3dPolygonMesh?: boolean;
|
||||
// Kurven-Entities (ARC/CIRCLE/ELLIPSE). Winkel liefert dxf-parser in RADIANT
|
||||
// (ARC/CIRCLE Grad→rad umgerechnet; ELLIPSE-Parameterwinkel roh in Radiant).
|
||||
center?: DxfVertex;
|
||||
radius?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
/** ELLIPSE: Hauptachsen-Endpunkt RELATIV zum Center. */
|
||||
majorAxisEndPoint?: DxfVertex;
|
||||
/** ELLIPSE: Verhältnis Neben-/Hauptachse (b/a). */
|
||||
axisRatio?: number;
|
||||
// SPLINE-Felder. Winkel/Parameter roh; Knoten/Grad definieren die B-Spline.
|
||||
controlPoints?: DxfVertex[];
|
||||
fitPoints?: DxfVertex[];
|
||||
knotValues?: number[];
|
||||
degreeOfSplineCurve?: number;
|
||||
closed?: boolean;
|
||||
// INSERT-Felder (Block-Referenz). `position` (Einfügepunkt) wird tolerant
|
||||
// gelesen (das Feld ist oben als MESH-Vertexliste getippt) — daher hier NICHT
|
||||
// erneut deklariert; `rotation` ist in GRAD (anders als ARC/CIRCLE).
|
||||
name?: string;
|
||||
xScale?: number;
|
||||
yScale?: number;
|
||||
rotation?: number;
|
||||
columnCount?: number;
|
||||
rowCount?: number;
|
||||
columnSpacing?: number;
|
||||
rowSpacing?: number;
|
||||
// HATCH: rohe Gruppencodes (vom Custom-Handler gesammelt, siehe HatchHandler).
|
||||
rawCodes?: RawCode[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
/** Ein rohes DXF-Gruppencode/Wert-Paar (HATCH-Randpfad-Auswertung). */
|
||||
interface RawCode {
|
||||
code: number;
|
||||
value: number | string;
|
||||
}
|
||||
/** Ein Block (BLOCKS-Tabelle): Basispunkt `position` + eigene Entity-Liste. */
|
||||
interface DxfBlock {
|
||||
name?: string;
|
||||
position?: DxfVertex;
|
||||
entities?: DxfEntity[];
|
||||
}
|
||||
interface DxfDocument {
|
||||
entities?: DxfEntity[];
|
||||
blocks?: Record<string, DxfBlock>;
|
||||
}
|
||||
|
||||
let importSeq = 0;
|
||||
@@ -78,9 +126,16 @@ const nextId = (prefix: string) =>
|
||||
*/
|
||||
export function parseDxf(text: string): DxfImportResult {
|
||||
const parser = new DxfParser();
|
||||
// dxf-parser bringt keinen HATCH-Handler mit (verwirft HATCH sonst stumm).
|
||||
// Eigenen registrieren, der die rohen Gruppencodes einsammelt; die Auswertung
|
||||
// der Randpfade läuft separat und testbar in `hatchContours`.
|
||||
(parser as unknown as {
|
||||
registerEntityHandler: (h: new () => unknown) => void;
|
||||
}).registerEntityHandler(HatchHandler);
|
||||
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
|
||||
const doc = parser.parseSync(text) as unknown as DxfDocument;
|
||||
const entities = doc?.entities ?? [];
|
||||
const blocks = doc?.blocks ?? {};
|
||||
|
||||
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
||||
const meshIndices: number[] = [];
|
||||
@@ -88,36 +143,22 @@ export function parseDxf(text: string): DxfImportResult {
|
||||
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
switch (type) {
|
||||
case "3DFACE":
|
||||
addFace(meshTriangles, meshIndices, e);
|
||||
break;
|
||||
case "MESH":
|
||||
addMesh(meshTriangles, meshIndices, e);
|
||||
break;
|
||||
case "POLYLINE":
|
||||
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
|
||||
if (isMeshPolyline(e)) {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
} else {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
}
|
||||
break;
|
||||
case "LWPOLYLINE": {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const ct = lineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
// Mesh-Entities in die Dreiecks-Puffer; POLYLINE nur als Mesh-Variante.
|
||||
if (type === "3DFACE") {
|
||||
addFace(meshTriangles, meshIndices, e);
|
||||
continue;
|
||||
}
|
||||
if (type === "MESH") {
|
||||
addMesh(meshTriangles, meshIndices, e);
|
||||
continue;
|
||||
}
|
||||
if (type === "POLYLINE" && isMeshPolyline(e)) {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
continue;
|
||||
}
|
||||
// Alle übrigen Kontur-Entities (inkl. SPLINE + INSERT-Block-Expansion) über
|
||||
// den gemeinsamen Sammler — dieselbe Logik nutzt die INSERT-Rekursion.
|
||||
collectContours(e, blocks, contours, 0);
|
||||
}
|
||||
|
||||
const meshes: ImportedMesh[] = [];
|
||||
@@ -308,3 +349,581 @@ function lineContour(e: DxfEntity): Contour | null {
|
||||
layer: e.layer,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Kurven-Entities (ARC/CIRCLE/ELLIPSE) → tessellierte Konturen ──────────────
|
||||
|
||||
/** Winkelauflösung der Tessellierung (~5.6° pro Segment). */
|
||||
const CURVE_STEP = Math.PI / 32;
|
||||
/** Obergrenze der Segmentzahl (Schutz gegen entartete Eingaben). */
|
||||
const CURVE_MAX_SEG = 256;
|
||||
|
||||
/** Segmentzahl für eine Winkelspanne (Radiant): mind. 2, gedeckelt. */
|
||||
function segmentsFor(sweep: number): number {
|
||||
return Math.max(2, Math.min(CURVE_MAX_SEG, Math.ceil(Math.abs(sweep) / CURVE_STEP)));
|
||||
}
|
||||
|
||||
/**
|
||||
* ARC → offener Bogen-Linienzug. `startAngle`/`endAngle` in Radiant (dxf-parser
|
||||
* rechnet Grad→rad). Bögen laufen CCW; eine nicht-positive Spanne wird um 2π
|
||||
* ergänzt (voller-Kreis-Fall bleibt 2π).
|
||||
*/
|
||||
function arcContour(e: DxfEntity): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
typeof r !== "number" ||
|
||||
!Number.isFinite(r) ||
|
||||
r <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2;
|
||||
let sweep = end - start;
|
||||
if (sweep <= 0) sweep += Math.PI * 2;
|
||||
const cx = c.x ?? 0;
|
||||
const cy = c.y ?? 0;
|
||||
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
const segs = segmentsFor(sweep);
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i <= segs; i++) {
|
||||
const t = start + (sweep * i) / segs;
|
||||
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
||||
}
|
||||
return { z, pts, closed: false, layer: e.layer };
|
||||
}
|
||||
|
||||
/** CIRCLE → geschlossener Kreis-Linienzug (voller Umlauf, letzter Punkt weggelassen). */
|
||||
function circleContour(e: DxfEntity): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
typeof r !== "number" ||
|
||||
!Number.isFinite(r) ||
|
||||
r <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const cx = c.x ?? 0;
|
||||
const cy = c.y ?? 0;
|
||||
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
const segs = segmentsFor(Math.PI * 2);
|
||||
const pts: Vec2[] = [];
|
||||
// 0..2π ohne Schluss-Duplikat (closed schließt den Ring).
|
||||
for (let i = 0; i < segs; i++) {
|
||||
const t = (Math.PI * 2 * i) / segs;
|
||||
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
||||
}
|
||||
return { z, pts, closed: true, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* ELLIPSE → Linienzug. Hauptachse = `majorAxisEndPoint` (Vektor relativ zum
|
||||
* Center), Nebenachse = ⟂ dazu · `axisRatio`. `startAngle`/`endAngle` sind
|
||||
* PARAMETERwinkel in Radiant; ein voller Umlauf (Spanne ≈ 2π) wird geschlossen.
|
||||
* Punkt(t) = Center + cos t · Haupt + sin t · Neben.
|
||||
*/
|
||||
function ellipseContour(e: DxfEntity): Contour | null {
|
||||
const c = e.center;
|
||||
const maj = e.majorAxisEndPoint;
|
||||
const ratio = e.axisRatio;
|
||||
if (
|
||||
!c ||
|
||||
!maj ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(maj.x) ||
|
||||
!Number.isFinite(maj.y) ||
|
||||
typeof ratio !== "number" ||
|
||||
!Number.isFinite(ratio)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const cx = c.x ?? 0;
|
||||
const cy = c.y ?? 0;
|
||||
const ax = maj.x ?? 0; // Hauptachsen-Vektor (relativ Center)
|
||||
const ay = maj.y ?? 0;
|
||||
const bx = -ay * ratio; // Nebenachse = Linksnormale der Hauptachse · Verhältnis
|
||||
const by = ax * ratio;
|
||||
const start = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
const end = Number.isFinite(e.endAngle) ? (e.endAngle as number) : Math.PI * 2;
|
||||
let sweep = end - start;
|
||||
if (sweep <= 0) sweep += Math.PI * 2;
|
||||
const full = Math.abs(sweep - Math.PI * 2) < 1e-9;
|
||||
const z = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
const segs = segmentsFor(sweep);
|
||||
const pts: Vec2[] = [];
|
||||
// Bei vollem Umlauf Schluss-Duplikat weglassen (closed schließt den Ring).
|
||||
const last = full ? segs - 1 : segs;
|
||||
for (let i = 0; i <= last; i++) {
|
||||
const t = start + (sweep * i) / segs;
|
||||
const ct = Math.cos(t);
|
||||
const st = Math.sin(t);
|
||||
pts.push({ x: cx + ax * ct + bx * st, y: cy + ay * ct + by * st });
|
||||
}
|
||||
return { z, pts, closed: full, layer: e.layer };
|
||||
}
|
||||
|
||||
// ── SPLINE (B-Spline via De Boor) ────────────────────────────────────────────
|
||||
|
||||
/** DxfVertex-Liste → finite Vec2-Stützpunkte. */
|
||||
function toVec2s(vs: DxfVertex[] | undefined): Vec2[] {
|
||||
const out: Vec2[] = [];
|
||||
for (const v of vs ?? []) {
|
||||
if (Number.isFinite(v.x) && Number.isFinite(v.y)) {
|
||||
out.push({ x: v.x ?? 0, y: v.y ?? 0 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Erster finiter Z-Wert einer DxfVertex-Liste (sonst 0). */
|
||||
function firstZ(vs: DxfVertex[] | undefined): number {
|
||||
for (const v of vs ?? []) if (Number.isFinite(v.z)) return v.z as number;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Knoten-Span-Index (The NURBS Book, A2.1): grösstes k mit U[k] ≤ t < U[k+1],
|
||||
* geklemmt auf [p, n]. `n` = letzter Kontrollpunkt-Index.
|
||||
*/
|
||||
function findSpan(n: number, p: number, t: number, U: number[]): number {
|
||||
if (t >= U[n + 1]) return n;
|
||||
if (t <= U[p]) return p;
|
||||
let low = p;
|
||||
let high = n + 1;
|
||||
let mid = Math.floor((low + high) / 2);
|
||||
while (t < U[mid] || t >= U[mid + 1]) {
|
||||
if (t < U[mid]) high = mid;
|
||||
else low = mid;
|
||||
mid = Math.floor((low + high) / 2);
|
||||
}
|
||||
return mid;
|
||||
}
|
||||
|
||||
/** Punkt einer (nicht-rationalen) B-Spline bei Parameter t (De Boor, A2.4). */
|
||||
function deBoor(cps: Vec2[], U: number[], p: number, t: number): Vec2 {
|
||||
const n = cps.length - 1;
|
||||
const span = findSpan(n, p, t, U);
|
||||
const d: Vec2[] = [];
|
||||
for (let j = 0; j <= p; j++) d[j] = { ...cps[span - p + j] };
|
||||
for (let r = 1; r <= p; r++) {
|
||||
for (let j = p; j >= r; j--) {
|
||||
const i = span - p + j;
|
||||
const denom = U[i + p - r + 1] - U[i];
|
||||
const a = denom === 0 ? 0 : (t - U[i]) / denom;
|
||||
d[j] = {
|
||||
x: (1 - a) * d[j - 1].x + a * d[j].x,
|
||||
y: (1 - a) * d[j - 1].y + a * d[j].y,
|
||||
};
|
||||
}
|
||||
}
|
||||
return d[p];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nicht-rationale B-Spline über die Domäne [U[p], U[n+1]] abtasten (De Boor),
|
||||
* wenn Kontrollpunkte/Knoten/Grad konsistent sind (|U| = |P| + Grad + 1). Sonst
|
||||
* null. ~8 Abtastpunkte je Kontrollpunkt, gedeckelt.
|
||||
*/
|
||||
function sampleBSpline(cps: Vec2[], knots: number[], degree: number): Vec2[] | null {
|
||||
if (cps.length < 2 || degree < 1 || knots.length !== cps.length + degree + 1) {
|
||||
return null;
|
||||
}
|
||||
const samples = Math.max(16, Math.min(CURVE_MAX_SEG, cps.length * 8));
|
||||
const n = cps.length - 1;
|
||||
const t0 = knots[degree];
|
||||
const t1 = knots[n + 1];
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const t = i === samples ? t1 : t0 + ((t1 - t0) * i) / samples;
|
||||
pts.push(deBoor(cps, knots, degree, t));
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* SPLINE → Linienzug. Primär echte B-Spline-Auswertung (Kontrollpunkte + Knoten
|
||||
* + Grad, wenn der Knotenvektor konsistent ist: |U| = |P| + Grad + 1). Sonst
|
||||
* Rückfall auf Stützpunkte (fitPoints) bzw. — grob — das Kontrollpolygon.
|
||||
* Rationale Gewichte werden ignoriert (selten; dxf-parser liefert sie nicht).
|
||||
*/
|
||||
function splineContour(e: DxfEntity): Contour | null {
|
||||
const cps = toVec2s(e.controlPoints);
|
||||
const fps = toVec2s(e.fitPoints);
|
||||
const knots = (e.knotValues ?? []).filter((k) => Number.isFinite(k));
|
||||
const degree = Number.isFinite(e.degreeOfSplineCurve)
|
||||
? (e.degreeOfSplineCurve as number)
|
||||
: 3;
|
||||
const closed = e.closed === true;
|
||||
const z = cps.length > 0 ? firstZ(e.controlPoints) : firstZ(e.fitPoints);
|
||||
|
||||
const sampled = sampleBSpline(cps, knots, degree);
|
||||
if (sampled) return { z, pts: sampled, closed, layer: e.layer };
|
||||
if (fps.length >= 2) return { z, pts: fps, closed, layer: e.layer };
|
||||
if (cps.length >= 2) return { z, pts: cps, closed, layer: e.layer };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── INSERT (Block-Referenz → transformierte Konturen) ─────────────────────────
|
||||
|
||||
/** Tolerantes Zahlenfeld: endliche Zahl oder Default. */
|
||||
function numOr(v: unknown, d: number): number {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : d;
|
||||
}
|
||||
|
||||
/** Tolerante Koordinate aus einem (unbekannt getippten) Punktobjekt. */
|
||||
function coord(v: unknown, key: "x" | "y"): number {
|
||||
if (v && typeof v === "object") {
|
||||
const n = (v as Record<string, unknown>)[key];
|
||||
if (typeof n === "number" && Number.isFinite(n)) return n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Schutz gegen zyklische/tief verschachtelte Blockreferenzen. */
|
||||
const MAX_INSERT_DEPTH = 8;
|
||||
|
||||
/**
|
||||
* INSERT expandieren: Block-Entities rekursiv zu LOKALEN Konturen sammeln, dann
|
||||
* je Array-Zelle mit der 2D-Transform der Referenz nach Welt abbilden.
|
||||
* Welt(p, off) = Einfügepunkt + Rot(θ) · ( Scale(p − Basispunkt) + off ).
|
||||
* `off` = (Spalte·Spaltenabstand, Zeile·Zeilenabstand) im rotierten Blockraster.
|
||||
*/
|
||||
function expandInsert(
|
||||
e: DxfEntity,
|
||||
blocks: Record<string, DxfBlock>,
|
||||
out: Contour[],
|
||||
depth: number,
|
||||
): void {
|
||||
if (depth >= MAX_INSERT_DEPTH) return;
|
||||
const name = typeof e.name === "string" ? e.name : null;
|
||||
if (!name) return;
|
||||
const block = blocks[name];
|
||||
if (!block || !Array.isArray(block.entities)) return;
|
||||
|
||||
const sx = numOr(e.xScale, 1);
|
||||
const sy = numOr(e.yScale, 1);
|
||||
const rot = (numOr(e.rotation, 0) * Math.PI) / 180; // INSERT-Rotation in GRAD
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
const ix = coord(e.position, "x"); // Einfügepunkt (tolerant gelesen)
|
||||
const iy = coord(e.position, "y");
|
||||
const bx = coord(block.position, "x"); // Block-Basispunkt
|
||||
const by = coord(block.position, "y");
|
||||
const nc = Math.max(1, Math.floor(numOr(e.columnCount, 1)));
|
||||
const nr = Math.max(1, Math.floor(numOr(e.rowCount, 1)));
|
||||
const dc = numOr(e.columnSpacing, 0);
|
||||
const dr = numOr(e.rowSpacing, 0);
|
||||
|
||||
// Block-Inhalt EINMAL lokal sammeln (rekursiv), dann je Zelle transformieren.
|
||||
const local: Contour[] = [];
|
||||
for (const be of block.entities) collectContours(be, blocks, local, depth + 1);
|
||||
if (local.length === 0) return;
|
||||
|
||||
for (let row = 0; row < nr; row++) {
|
||||
for (let col = 0; col < nc; col++) {
|
||||
const offx = col * dc;
|
||||
const offy = row * dr;
|
||||
for (const lc of local) {
|
||||
const pts = lc.pts.map((p) => {
|
||||
const vx = (p.x - bx) * sx + offx;
|
||||
const vy = (p.y - by) * sy + offy;
|
||||
return { x: ix + (vx * cos - vy * sin), y: iy + (vx * sin + vy * cos) };
|
||||
});
|
||||
out.push({ z: lc.z, pts, closed: lc.closed, layer: e.layer ?? lc.layer });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Kontur-Dispatch für eine Entity (Top-Level UND Block-Inhalt).
|
||||
* Mesh-Entities werden hier NICHT behandelt (nur der Top-Level-Pfad erzeugt
|
||||
* Meshes; Block-interne Meshes sind im 2D-Import selten und bleiben aussen vor).
|
||||
*/
|
||||
function collectContours(
|
||||
e: DxfEntity,
|
||||
blocks: Record<string, DxfBlock>,
|
||||
out: Contour[],
|
||||
depth: number,
|
||||
): void {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
switch (type) {
|
||||
case "LWPOLYLINE": {
|
||||
const c = polylineContour(e);
|
||||
if (c) out.push(c);
|
||||
break;
|
||||
}
|
||||
case "POLYLINE": {
|
||||
if (!isMeshPolyline(e)) {
|
||||
const c = polylineContour(e);
|
||||
if (c) out.push(c);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const c = lineContour(e);
|
||||
if (c) out.push(c);
|
||||
break;
|
||||
}
|
||||
case "ARC": {
|
||||
const c = arcContour(e);
|
||||
if (c) out.push(c);
|
||||
break;
|
||||
}
|
||||
case "CIRCLE": {
|
||||
const c = circleContour(e);
|
||||
if (c) out.push(c);
|
||||
break;
|
||||
}
|
||||
case "ELLIPSE": {
|
||||
const c = ellipseContour(e);
|
||||
if (c) out.push(c);
|
||||
break;
|
||||
}
|
||||
case "SPLINE": {
|
||||
const c = splineContour(e);
|
||||
if (c) out.push(c);
|
||||
break;
|
||||
}
|
||||
case "HATCH":
|
||||
for (const c of hatchContours(e)) out.push(c);
|
||||
break;
|
||||
case "INSERT":
|
||||
expandInsert(e, blocks, out, depth);
|
||||
break;
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HATCH (Custom-Handler + Randpfad-Auswertung → gefüllte Konturen) ──────────
|
||||
|
||||
/**
|
||||
* Custom dxf-parser-Handler für HATCH: sammelt die rohen Gruppencodes der Entity
|
||||
* (bis zum nächsten Code 0), OHNE zu interpretieren. Muster wie die eingebauten
|
||||
* Handler (Schleife bricht bei Code 0; `lastReadGroup` bleibt darauf stehen).
|
||||
* Die eigentliche Randpfad-Logik liegt testbar in `hatchContours`.
|
||||
*/
|
||||
class HatchHandler {
|
||||
ForEntityName = "HATCH";
|
||||
parseEntity(
|
||||
scanner: {
|
||||
next: () => { code: number; value: number | string };
|
||||
isEOF: () => boolean;
|
||||
},
|
||||
curr: { code: number; value: number | string },
|
||||
): DxfEntity {
|
||||
const raw: RawCode[] = [];
|
||||
const entity: DxfEntity = { type: String(curr.value), rawCodes: raw };
|
||||
curr = scanner.next();
|
||||
while (!scanner.isEOF()) {
|
||||
if (curr.code === 0) break;
|
||||
if (curr.code === 8) entity.layer = String(curr.value);
|
||||
raw.push({ code: curr.code, value: curr.value });
|
||||
curr = scanner.next();
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zahlenwert eines Gruppencodes (tolerant). */
|
||||
function rc(v: number | string | undefined): number {
|
||||
if (typeof v === "number") return Number.isFinite(v) ? v : 0;
|
||||
const n = typeof v === "string" ? Number.parseFloat(v) : NaN;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** Erster Wert eines Codes aus der Kanten-Code-Sammlung. */
|
||||
function firstOf(m: Map<number, number[]>, code: number): number | undefined {
|
||||
const a = m.get(code);
|
||||
return a && a.length > 0 ? a[0] : undefined;
|
||||
}
|
||||
|
||||
/** Bogenkante (Kantentyp 2): Zentrum/Radius, Winkel in GRAD, ggf. im Uhrzeigersinn. */
|
||||
function pushArcEdge(pts: Vec2[], m: Map<number, number[]>): void {
|
||||
const cx = firstOf(m, 10);
|
||||
const cy = firstOf(m, 20);
|
||||
const r = firstOf(m, 40);
|
||||
if (cx === undefined || cy === undefined || r === undefined || r <= 0) return;
|
||||
const s = (firstOf(m, 50) ?? 0) * (Math.PI / 180);
|
||||
const e = (firstOf(m, 51) ?? 360) * (Math.PI / 180);
|
||||
const ccw = (firstOf(m, 73) ?? 1) !== 0;
|
||||
// Spanne in Laufrichtung auf (0, 2π] normieren.
|
||||
let sweep = ccw ? e - s : -(e - s);
|
||||
sweep = ((sweep % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||||
if (sweep === 0) sweep = Math.PI * 2;
|
||||
const dir = ccw ? 1 : -1;
|
||||
const segs = segmentsFor(sweep);
|
||||
// Startpunkt weglassen, wenn er den vorherigen Kanten-Endpunkt dupliziert.
|
||||
for (let i = 0; i <= segs; i++) {
|
||||
const t = s + dir * (sweep * i) / segs;
|
||||
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellipsenkante (Kantentyp 3): Zentrum (10/20), Hauptachsen-Endpunkt relativ
|
||||
* (11/21), Achsverhältnis (40), Start/End-PARAMETERwinkel in GRAD (50/51), ggf.
|
||||
* im Uhrzeigersinn (73). Punkt(t) = Center + cos t·Haupt + sin t·Neben.
|
||||
*/
|
||||
function pushEllipseEdge(pts: Vec2[], m: Map<number, number[]>): void {
|
||||
const cx = firstOf(m, 10);
|
||||
const cy = firstOf(m, 20);
|
||||
const mx = firstOf(m, 11);
|
||||
const my = firstOf(m, 21);
|
||||
const ratio = firstOf(m, 40);
|
||||
if (cx === undefined || cy === undefined || mx === undefined || my === undefined || ratio === undefined) {
|
||||
return;
|
||||
}
|
||||
const bx = -my * ratio; // Nebenachse = Linksnormale der Hauptachse · Verhältnis
|
||||
const by = mx * ratio;
|
||||
const s = (firstOf(m, 50) ?? 0) * (Math.PI / 180);
|
||||
const e = (firstOf(m, 51) ?? 360) * (Math.PI / 180);
|
||||
const ccw = (firstOf(m, 73) ?? 1) !== 0;
|
||||
let sweep = ccw ? e - s : -(e - s);
|
||||
sweep = ((sweep % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||||
if (sweep === 0) sweep = Math.PI * 2;
|
||||
const dir = ccw ? 1 : -1;
|
||||
const segs = segmentsFor(sweep);
|
||||
for (let i = 0; i <= segs; i++) {
|
||||
const t = s + (dir * (sweep * i)) / segs;
|
||||
const ct = Math.cos(t);
|
||||
const st = Math.sin(t);
|
||||
pts.push({ x: cx + mx * ct + bx * st, y: cy + my * ct + by * st });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spline-Kante (Kantentyp 4): Grad (94), Knoten (40×), Kontrollpunkte (10/20×).
|
||||
* Über De Boor abgetastet (rationale Gewichte 42 ignoriert). Fallback: rohe
|
||||
* Kontrollpunkte als Polygonzug.
|
||||
*/
|
||||
function pushSplineEdge(pts: Vec2[], m: Map<number, number[]>): void {
|
||||
const xs = m.get(10) ?? [];
|
||||
const ys = m.get(20) ?? [];
|
||||
const cps: Vec2[] = [];
|
||||
for (let i = 0; i < Math.min(xs.length, ys.length); i++) cps.push({ x: xs[i], y: ys[i] });
|
||||
const knots = m.get(40) ?? [];
|
||||
const degree = firstOf(m, 94) ?? 3;
|
||||
const sampled = sampleBSpline(cps, knots, degree);
|
||||
if (sampled) {
|
||||
for (const p of sampled) pts.push(p);
|
||||
} else {
|
||||
for (const p of cps) pts.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
/** Punkte einer HATCH-Randkante an die Loop-Punkte anhängen (Typ 1/2/3/4). */
|
||||
function appendEdge(pts: Vec2[], edgeType: number, m: Map<number, number[]>): void {
|
||||
if (edgeType === 1) {
|
||||
// Linie: Start (10/20) — der Endpunkt ist der Start der nächsten Kante.
|
||||
const x = firstOf(m, 10);
|
||||
const y = firstOf(m, 20);
|
||||
if (x !== undefined && y !== undefined) pts.push({ x, y });
|
||||
// Bei der LETZTEN Kante fehlt sonst der Endpunkt; der closed-Ring schließt ihn.
|
||||
return;
|
||||
}
|
||||
if (edgeType === 2) pushArcEdge(pts, m);
|
||||
else if (edgeType === 3) pushEllipseEdge(pts, m);
|
||||
else if (edgeType === 4) pushSplineEdge(pts, m);
|
||||
}
|
||||
|
||||
/**
|
||||
* HATCH-Entity (rohe Gruppencodes) → gefüllte, geschlossene Konturen (ein Loop
|
||||
* je Randpfad). Unterstützt Polyline-Randpfade (Flag-Bit 2) sowie Kanten-Pfade
|
||||
* mit Linien-, Bogen-, Ellipsen- und Spline-Kanten (alle tesselliert). Bulges an
|
||||
* Polyline-Rändern werden ignoriert (als Sehne); Insel-Loops entstehen als eigene
|
||||
* geschlossene Ringe (keine echten Löcher).
|
||||
*/
|
||||
function hatchContours(e: DxfEntity): Contour[] {
|
||||
const raw = e.rawCodes;
|
||||
if (!raw || raw.length === 0) return [];
|
||||
const n = raw.length;
|
||||
const layer = e.layer;
|
||||
|
||||
// Zum ersten Code 91 (Anzahl Randpfade).
|
||||
let i = 0;
|
||||
while (i < n && raw[i].code !== 91) i++;
|
||||
if (i >= n) return [];
|
||||
const numPaths = rc(raw[i].value);
|
||||
i++;
|
||||
|
||||
const loops: Contour[] = [];
|
||||
for (let p = 0; p < numPaths && i < n; p++) {
|
||||
// Zum Pfadtyp-Flag (92) des nächsten Pfads.
|
||||
while (i < n && raw[i].code !== 92) i++;
|
||||
if (i >= n) break;
|
||||
const flag = rc(raw[i].value);
|
||||
i++;
|
||||
const isPolyline = (flag & 2) !== 0;
|
||||
const pts: Vec2[] = [];
|
||||
|
||||
if (isPolyline) {
|
||||
// Optionale 72/73 überspringen, bis 93 (Vertex-Anzahl).
|
||||
while (i < n && raw[i].code !== 93) i++;
|
||||
if (i >= n) break;
|
||||
const numVerts = rc(raw[i].value);
|
||||
i++;
|
||||
let cx: number | null = null;
|
||||
let got = 0;
|
||||
while (i < n && got < numVerts) {
|
||||
const c = raw[i];
|
||||
if (c.code === 91 || c.code === 92) break; // Sicherheitsnetz
|
||||
if (c.code === 10) {
|
||||
cx = rc(c.value);
|
||||
} else if (c.code === 20 && cx !== null) {
|
||||
pts.push({ x: cx, y: rc(c.value) });
|
||||
cx = null;
|
||||
got++;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
// Kanten-Pfad: 93 = Anzahl Kanten.
|
||||
while (i < n && raw[i].code !== 93) i++;
|
||||
if (i >= n) break;
|
||||
const numEdges = rc(raw[i].value);
|
||||
i++;
|
||||
for (let ed = 0; ed < numEdges && i < n; ed++) {
|
||||
// Zum Kantentyp (72), aber nicht über den Pfad/Kanten-Block hinaus.
|
||||
while (
|
||||
i < n &&
|
||||
raw[i].code !== 72 &&
|
||||
raw[i].code !== 97 &&
|
||||
raw[i].code !== 92 &&
|
||||
raw[i].code !== 91
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
if (i >= n || raw[i].code !== 72) break;
|
||||
const edgeType = rc(raw[i].value);
|
||||
i++;
|
||||
// Kanten-Codes bis zur nächsten Kante/Blockgrenze sammeln.
|
||||
const m = new Map<number, number[]>();
|
||||
while (i < n) {
|
||||
const c = raw[i];
|
||||
if (c.code === 72 || c.code === 97 || c.code === 92 || c.code === 91) break;
|
||||
const arr = m.get(c.code) ?? [];
|
||||
arr.push(rc(c.value));
|
||||
m.set(c.code, arr);
|
||||
i++;
|
||||
}
|
||||
appendEdge(pts, edgeType, m);
|
||||
}
|
||||
}
|
||||
|
||||
if (pts.length >= 2) {
|
||||
loops.push({ z: 0, pts, closed: true, layer, filled: true });
|
||||
}
|
||||
}
|
||||
return loops;
|
||||
}
|
||||
|
||||
+11
-2
@@ -23,6 +23,9 @@ export type CategoryMode = "active" | "byLayer";
|
||||
let drawingSeq = 0;
|
||||
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
|
||||
|
||||
/** Default-Füllfarbe für aus HATCH importierte Flächen (neutral, restylebar). */
|
||||
const DXF_HATCH_FILL = "#c8c8c8";
|
||||
|
||||
/**
|
||||
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
|
||||
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
|
||||
@@ -45,13 +48,19 @@ export function contoursToDrawings(
|
||||
categoryMode === "byLayer" && layerName
|
||||
? layerToCode(layerName)
|
||||
: fallbackCode;
|
||||
out.push({
|
||||
const drawing: Drawing2D = {
|
||||
id: nextId(),
|
||||
type: "drawing2d",
|
||||
levelId,
|
||||
categoryCode,
|
||||
geom,
|
||||
});
|
||||
};
|
||||
// Aus einer HATCH stammende Kontur → geschlossene, gefüllte Form. Vollton-
|
||||
// Füllung als restylebarer Default (neutrales Grau); Umriss bleibt Kategorie.
|
||||
if (contour.filled && geom.shape === "polyline") {
|
||||
drawing.fillColor = DXF_HATCH_FILL;
|
||||
}
|
||||
out.push(drawing);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -267,7 +267,7 @@ function iwWall(id: string, start: Vec2, end: Vec2): Wall {
|
||||
}
|
||||
|
||||
describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
||||
it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs sticht bis zur Rückgrat-Nahfläche durch, Putz wird getrimmt + L-Seitenlinie an derselben Fläche", () => {
|
||||
it("W9-artiger T-Knoten: Backstein-Kern des Abzweigs sticht bis zur Rückgrat-Nahfläche durch, Putz wird getrimmt — materialgleicher Nah-Putz ⇒ KEINE L-Trennnaht", () => {
|
||||
// Durchgangswand WA1 (0,0)→(5,0) / WA2 (5,0)→(10,0), Abzweig WB (5,0)→(5,3),
|
||||
// alle vom selben Wandtyp "iw" (Innenputz/Backstein/Innenputz, T=0.15).
|
||||
const proj = layeredWallProject(
|
||||
@@ -303,13 +303,13 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
||||
expect(lc.start[2]).not.toBeNull();
|
||||
expect(distToLine(lc.start[0]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.start[2]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||
// ... UND bekommen die zusätzliche L-Seitenlinie an der Rückgrat-Nahfläche
|
||||
// (y = 0.06, parallel zur Durchgangsachse) — dort endet der durchstechende
|
||||
// Kern.
|
||||
expect(lc.startSide[0]).not.toBeNull();
|
||||
expect(lc.startSide[2]).not.toBeNull();
|
||||
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6);
|
||||
expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6);
|
||||
// ... aber KEINE L-Seitenlinie: der Nah-Putz der Durchgangswand ist
|
||||
// materialgleicher Innenputz und füllt den getrimmten Abzweig-Putz an den
|
||||
// Flanken durchgehend (der spanCutout schneidet nur die Kernbreite aus).
|
||||
// Es entsteht ein durchgehendes Putz-L ohne Trennnaht. Nur bei material-
|
||||
// FREMDEM Nah-Putz stünde hier eine L-Linie.
|
||||
expect(lc.startSide[0]).toBeNull();
|
||||
expect(lc.startSide[2]).toBeNull();
|
||||
|
||||
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
|
||||
// unangetastet (keine Pro-Schicht-Cuts für sie).
|
||||
|
||||
+13
-1
@@ -421,11 +421,23 @@ function applyLayerCuts(
|
||||
// Nachbarschicht kappt ihre Breite.
|
||||
const sideLine: Line = mergeCut;
|
||||
|
||||
// Materialien des durchgehenden Nah-Putzes der Durchgangswand (dieselbe
|
||||
// sign-Seite wie die nearPlaster-Summe oben). Ist die getrimmte Abzweig-Schicht
|
||||
// materialgleich mit einer davon, füllt der Nah-Putz den Abzweig-Putz an dieser
|
||||
// Stelle durchgehend (der spanCutout schneidet nur die KERN-Breite aus, nicht
|
||||
// die schmalen Putzflanken) → Putz-L OHNE Trennnaht, also KEINE L-Seitenlinie.
|
||||
const nearPlasterComps = new Set<string>();
|
||||
for (let i = 0; i < throughWt.layers.length; i++) {
|
||||
if (sign > 0 ? i > backboneIdx : i < backboneIdx)
|
||||
nearPlasterComps.add(throughWt.layers[i].componentId);
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
faceArr[i] = merged[i] ? mergeCut : faceCut;
|
||||
const adjMerged =
|
||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
|
||||
sideArr[i] = adjMerged ? sideLine : null;
|
||||
const mergesWithNearPlaster = nearPlasterComps.has(branchWt.layers[i].componentId);
|
||||
sideArr[i] = adjMerged && !mergesWithNearPlaster ? sideLine : null;
|
||||
}
|
||||
|
||||
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
|
||||
|
||||
@@ -731,6 +731,22 @@ export interface Opening {
|
||||
height: number;
|
||||
/** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */
|
||||
sillHeight: number;
|
||||
/**
|
||||
* Nur Fenster: Anzahl der Flügel (1–4). Bei > 1 werden im Plan `wingCount − 1`
|
||||
* Mittelpfosten (Querlinien quer zur Öffnungsrichtung) gleichmäßig innerhalb des
|
||||
* Rahmens gezeichnet — analog den Mittelpfosten in `_make_oeffnung_pieces` des
|
||||
* Rhino-Plugins (`oeff_fluegel`). Fehlt es, gilt 1 (= heutiges Verhalten: keine
|
||||
* Pfosten).
|
||||
*/
|
||||
wingCount?: number;
|
||||
/**
|
||||
* Nur Tür: Tür-Typ (analog `oeff_tuer_typ` im Rhino-Plugin).
|
||||
* • "normal" — Türblatt + Schwenkbogen (Default = heutiges Verhalten).
|
||||
* • "wandoeffnung" — reiner Wanddurchbruch: KEIN Türblatt, KEIN Schwenkbogen;
|
||||
* nur die Öffnung/Laibung (Wandlücke + ggf. Sturz-/Anschlaglinien) bleibt.
|
||||
* Fehlt es, gilt "normal".
|
||||
*/
|
||||
doorType?: "normal" | "wandoeffnung";
|
||||
/** Nur Tür: an welchem Pfosten das Scharnier sitzt. */
|
||||
hinge?: "start" | "end";
|
||||
/** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */
|
||||
@@ -741,6 +757,15 @@ export interface Opening {
|
||||
openingDir?: "in" | "out";
|
||||
/** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */
|
||||
frameThickness?: number;
|
||||
/**
|
||||
* Nur Tür: Sturzlinien (SIA, gestrichelt) quer über die Öffnung an der Wand-
|
||||
* Innen- und/oder Aussenkante. Zeigt die Überkopf-Projektion des Sturzes.
|
||||
* "keine" → keine Sturzlinien
|
||||
* "innen" → eine Linie an der Wand-Innenkante
|
||||
* "aussen"→ eine Linie an der Wand-Aussenkante
|
||||
* "beide" → beide Linien (Default wenn nicht gesetzt)
|
||||
*/
|
||||
lintelLines?: "keine" | "innen" | "aussen" | "beide";
|
||||
/**
|
||||
* Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
|
||||
* Kategorie-Farbe.
|
||||
@@ -805,6 +830,19 @@ export interface Stair {
|
||||
sweep?: number;
|
||||
/** Laufbreite in Metern (quer zur Laufrichtung). */
|
||||
width: number;
|
||||
/**
|
||||
* Lage der gespeicherten Achse (`start`/`dir` bzw. Wendel-Bogen) über die
|
||||
* Laufbreite — analog `treppe_referenz` im Rhino-Plugin. Bestimmt, ob die Achse
|
||||
* die LINKE, MITTLERE oder RECHTE Kante der Treppe repräsentiert (in
|
||||
* Laufrichtung gesehen). Fehlt sie, gilt "mitte" (= heutiges Verhalten: Achse =
|
||||
* Treppen-Mitte).
|
||||
* • "links" — Achse = linke Kante; die Treppe liegt rechts der Achse.
|
||||
* • "mitte" — Achse = Mitte (Default).
|
||||
* • "rechts" — Achse = rechte Kante; die Treppe liegt links der Achse.
|
||||
* Die Lauflinie samt Pfeil bleibt IMMER auf der visuellen Treppen-Mitte
|
||||
* (nicht auf der Referenzkante) — siehe `stairGeometry`.
|
||||
*/
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/**
|
||||
* Gesamt-Steighöhe in Metern (OKFF → OKFF nächstes Geschoss). Fehlt sie, gilt
|
||||
* beim Auflösen die Geschosshöhe des zugehörigen Geschosses.
|
||||
@@ -1055,6 +1093,11 @@ export interface Contour {
|
||||
closed: boolean;
|
||||
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
||||
layer?: string;
|
||||
/**
|
||||
* Gefüllte Fläche (aus einer DXF-HATCH). Beim Drawing-Import wird daraus eine
|
||||
* geschlossene, gefüllte 2D-Form (Vollton-Füllung); sonst nur ein Umriss.
|
||||
*/
|
||||
filled?: boolean;
|
||||
}
|
||||
|
||||
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
|
||||
|
||||
@@ -297,6 +297,22 @@ function StairSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Referenzpunkt der Laufbreite (links/mitte/rechts). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.referenz")}</span>
|
||||
<Dropdown
|
||||
value={stair.referenz ?? "mitte"}
|
||||
onChange={(v) => host.onSetStairReferenz(v as "links" | "mitte" | "rechts")}
|
||||
options={[
|
||||
{ value: "links", label: t("objinfo.stair.referenz.links") },
|
||||
{ value: "mitte", label: t("objinfo.stair.referenz.mitte") },
|
||||
{ value: "rechts", label: t("objinfo.stair.referenz.rechts") },
|
||||
]}
|
||||
title={t("objinfo.stair.referenz")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Laufrichtung (aufwärts/abwärts). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
|
||||
@@ -426,16 +442,55 @@ function OpeningSection({
|
||||
onCommit={(v) => host.onSetOpeningHeight(v)}
|
||||
/>
|
||||
{!isDoor && (
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.sill")}
|
||||
value={opening.sillHeight}
|
||||
onCommit={(v) => host.onSetOpeningSill(v)}
|
||||
/>
|
||||
<>
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.sill")}
|
||||
value={opening.sillHeight}
|
||||
onCommit={(v) => host.onSetOpeningSill(v)}
|
||||
/>
|
||||
{/* Fenster-spezifisch: Flügelanzahl (1–4). */}
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.wingCount")}
|
||||
value={opening.wingCount ?? 1}
|
||||
min={1}
|
||||
onCommit={(v) => host.onSetOpeningWingCount(Math.max(1, Math.min(4, Math.round(v))))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
||||
{/* Tür-spezifisch: Typ / Sturzlinien / Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
||||
{isDoor && (
|
||||
<>
|
||||
{/* Tür-Typ (normal / Wandöffnung). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.doorType")}</span>
|
||||
<Dropdown
|
||||
value={opening.doorType ?? "normal"}
|
||||
onChange={(v) => host.onSetOpeningDoorType(v as "normal" | "wandoeffnung")}
|
||||
options={[
|
||||
{ value: "normal", label: t("objinfo.opening.doorType.normal") },
|
||||
{ value: "wandoeffnung", label: t("objinfo.opening.doorType.wandoeffnung") },
|
||||
]}
|
||||
title={t("objinfo.opening.doorType")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
{/* Sturzlinien-Darstellung (keine/innen/aussen/beide). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.lintelLines")}</span>
|
||||
<Dropdown
|
||||
value={opening.lintelLines ?? "beide"}
|
||||
onChange={(v) => host.onSetOpeningLintelLines(v as "keine" | "innen" | "aussen" | "beide")}
|
||||
options={[
|
||||
{ value: "keine", label: t("objinfo.opening.lintelLines.keine") },
|
||||
{ value: "innen", label: t("objinfo.opening.lintelLines.innen") },
|
||||
{ value: "aussen", label: t("objinfo.opening.lintelLines.aussen") },
|
||||
{ value: "beide", label: t("objinfo.opening.lintelLines.beide") },
|
||||
]}
|
||||
title={t("objinfo.opening.lintelLines")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
|
||||
<Dropdown
|
||||
|
||||
@@ -236,6 +236,12 @@ export interface PanelHostValue {
|
||||
onSetOpeningSwing: (swing: "left" | "right") => void;
|
||||
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
|
||||
onSetOpeningDir: (dir: "in" | "out") => void;
|
||||
/** Setzt die Flügelanzahl des Fensters (1–4). */
|
||||
onSetOpeningWingCount: (wingCount: number) => void;
|
||||
/** Setzt den Tür-Typ (normal / Wandöffnung). */
|
||||
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
|
||||
/** Setzt die Sturzlinien-Darstellung der Tür. */
|
||||
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
|
||||
|
||||
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||
@@ -248,6 +254,8 @@ export interface PanelHostValue {
|
||||
onSetStairRise: (totalRise: number) => void;
|
||||
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
||||
onSetStairUp: (up: boolean) => void;
|
||||
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
|
||||
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
|
||||
|
||||
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
|
||||
/** Setzt den Raum-Namen. */
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Unit-Tests für die Öffnungs-Symbole im Grundriss (generatePlan):
|
||||
* • Item 5 — Fenster Flügel-Mittelpfosten: bei wingCount=3 werden genau 2
|
||||
* Pfosten-Linien (Querlinien) erzeugt; wingCount=1/undefined ⇒ keine.
|
||||
* • Item 6 — Tür-Typ „wandoeffnung": kein Türblatt, kein Schwenkbogen (arc);
|
||||
* „normal" (Default) zeichnet Blatt + Bogen wie bisher.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generatePlan } from "./generatePlan";
|
||||
import type { Opening, Project, Wall } from "../model/types";
|
||||
|
||||
/** Minimalprojekt mit einer Wand + einer Öffnung, konfigurierbar. */
|
||||
function projectWithOpening(opening: Opening): Project {
|
||||
const wall: Wall = {
|
||||
id: "W1",
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start: { x: 0, y: 0 },
|
||||
end: { x: 4, y: 0 },
|
||||
wallTypeId: "aw",
|
||||
height: 2.6,
|
||||
};
|
||||
return {
|
||||
id: "t",
|
||||
name: "T",
|
||||
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
|
||||
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
|
||||
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
|
||||
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }],
|
||||
drawingLevels: [
|
||||
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||
],
|
||||
layers: [
|
||||
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
|
||||
{ code: "21", name: "Öffnungen", color: "#0a0a0a", lw: 0.3, visible: true, locked: false },
|
||||
],
|
||||
walls: [wall],
|
||||
doors: [],
|
||||
openings: [opening],
|
||||
ceilings: [],
|
||||
stairs: [],
|
||||
rooms: [],
|
||||
drawings2d: [],
|
||||
context: [],
|
||||
};
|
||||
}
|
||||
|
||||
const visible = new Set(["20", "21"]);
|
||||
|
||||
/** Basis-Fenster (Brüstung 0, damit keine Sill-Linie mitzählt). */
|
||||
const baseWindow: Opening = {
|
||||
id: "F1",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 1.0,
|
||||
width: 1.6,
|
||||
height: 1.2,
|
||||
sillHeight: 0,
|
||||
};
|
||||
|
||||
/** Basis-Tür. */
|
||||
const baseDoor: Opening = {
|
||||
id: "T1",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "door",
|
||||
position: 1.0,
|
||||
width: 0.9,
|
||||
height: 2.0,
|
||||
sillHeight: 0,
|
||||
swing: "left",
|
||||
hinge: "start",
|
||||
};
|
||||
|
||||
/** Zählt Mittelpfosten-Linien (cls "window-mullion") im Plan. */
|
||||
const mullionCount = (p: Project) =>
|
||||
generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter(
|
||||
(pr) => pr.kind === "line" && pr.cls === "window-mullion",
|
||||
).length;
|
||||
|
||||
describe("generatePlan — Fenster Flügel-Mittelpfosten (Item 5)", () => {
|
||||
it("wingCount=3 erzeugt genau 2 Pfosten-Linien", () => {
|
||||
expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 3 }))).toBe(2);
|
||||
});
|
||||
|
||||
it("wingCount=1 erzeugt keine Pfosten", () => {
|
||||
expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 1 }))).toBe(0);
|
||||
});
|
||||
|
||||
it("ohne wingCount (Default) keine Pfosten — rückwärtskompatibel", () => {
|
||||
expect(mullionCount(projectWithOpening({ ...baseWindow }))).toBe(0);
|
||||
});
|
||||
|
||||
it("wingCount=4 erzeugt 3 Pfosten", () => {
|
||||
expect(mullionCount(projectWithOpening({ ...baseWindow, wingCount: 4 }))).toBe(3);
|
||||
});
|
||||
|
||||
it("Pfosten sind reine Linien ohne Strichmuster (kein dash)", () => {
|
||||
const plan = generatePlan(projectWithOpening({ ...baseWindow, wingCount: 3 }), "eg", visible, undefined, "mittel");
|
||||
const mullions = plan.primitives.filter(
|
||||
(pr) => pr.kind === "line" && pr.cls === "window-mullion",
|
||||
);
|
||||
for (const m of mullions) {
|
||||
expect((m as { dash?: number[] | null }).dash ?? null).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("bei grob keine Pfosten (nur mittel/fein)", () => {
|
||||
const plan = generatePlan(projectWithOpening({ ...baseWindow, wingCount: 3 }), "eg", visible, undefined, "grob");
|
||||
const n = plan.primitives.filter((pr) => pr.kind === "line" && pr.cls === "window-mullion").length;
|
||||
expect(n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePlan — Tür-Typ wandoeffnung (Item 6)", () => {
|
||||
/** Zählt Bögen (arc-Primitive) im Plan. */
|
||||
const arcCount = (p: Project, detail: "grob" | "mittel" | "fein" = "mittel") =>
|
||||
generatePlan(p, "eg", visible, undefined, detail).primitives.filter((pr) => pr.kind === "arc").length;
|
||||
/** Zählt Türblatt-Linien (cls "door-leaf"). */
|
||||
const leafCount = (p: Project) =>
|
||||
generatePlan(p, "eg", visible, undefined, "mittel").primitives.filter(
|
||||
(pr) => pr.kind === "line" && pr.cls === "door-leaf",
|
||||
).length;
|
||||
|
||||
it("wandoeffnung erzeugt KEINE Bögen (arc)", () => {
|
||||
expect(arcCount(projectWithOpening({ ...baseDoor, doorType: "wandoeffnung" }))).toBe(0);
|
||||
});
|
||||
|
||||
it("wandoeffnung erzeugt KEIN Türblatt", () => {
|
||||
expect(leafCount(projectWithOpening({ ...baseDoor, doorType: "wandoeffnung" }))).toBe(0);
|
||||
});
|
||||
|
||||
it("normal (Default) erzeugt Türblatt + Schwenkbogen", () => {
|
||||
const normal = projectWithOpening({ ...baseDoor, doorType: "normal" });
|
||||
expect(leafCount(normal)).toBe(1);
|
||||
expect(arcCount(normal)).toBe(1);
|
||||
});
|
||||
|
||||
it("ohne doorType (Default) verhält sich wie normal — rückwärtskompatibel", () => {
|
||||
const def = projectWithOpening({ ...baseDoor });
|
||||
expect(leafCount(def)).toBe(1);
|
||||
expect(arcCount(def)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,9 @@
|
||||
* • Der Backstein-Kern des Abzweigs sticht durch den Nah-Putz der Durchgangs-
|
||||
* wand und stoppt an deren Kern-Nahfläche (kein Durchlaufen bis zur Achse,
|
||||
* kein Überlappen) — "durchstechender Kern".
|
||||
* • Die Innenputz-Schichten des Abzweigs enden weiterhin an der Nahfläche
|
||||
* UND bekommen eine zusätzliche seitliche L-Linie (cls "layer-joint-l").
|
||||
* • Die Innenputz-Schichten des Abzweigs enden an der Nahfläche. Ist der
|
||||
* Nah-Putz der Durchgangswand materialgleich, füllt er die Flanken durchgehend
|
||||
* → durchgehendes Putz-L OHNE Trennnaht (keine "layer-joint-l"-Linie).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -96,22 +97,17 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
|
||||
expect(brick.pts[3].y).toBeCloseTo(0.06, 6);
|
||||
});
|
||||
|
||||
it("zeichnet die L-Seitenlinie nur für die getrimmten Putzschichten, nicht für den durchlaufenden Kern", () => {
|
||||
it("zeichnet KEINE L-Seitenlinie, wenn der getrimmte Putz materialgleich mit dem durchgehenden Nah-Putz der Durchgangswand ist (durchgehendes Putz-L ohne Naht)", () => {
|
||||
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
|
||||
const lLines = plan.primitives.filter(
|
||||
(p): p is Extract<typeof p, { kind: "line" }> => p.kind === "line" && p.cls === "layer-joint-l",
|
||||
);
|
||||
// Genau zwei L-Linien (eine je Innenputz-Schicht); der Backstein-Kern
|
||||
// bekommt keine (er verschmilzt, kein Sprung im Material).
|
||||
expect(lLines.length).toBe(2);
|
||||
|
||||
// Jede L-Linie verläuft an der Rückgrat-Nahfläche (y=0.06, parallel zur
|
||||
// x-Achse) und spannt genau die Breite EINER Putzschicht (0.015 m) auf.
|
||||
for (const l of lLines) {
|
||||
expect(l.a.y).toBeCloseTo(0.06, 6);
|
||||
expect(l.b.y).toBeCloseTo(0.06, 6);
|
||||
expect(Math.abs(l.a.x - l.b.x)).toBeCloseTo(0.015, 6);
|
||||
}
|
||||
// Alle drei Wände sind vom selben iw-Typ: der Innenputz des Abzweigs trifft
|
||||
// an der Flanke auf den materialgleichen Innenputz der Durchgangswand (der
|
||||
// spanCutout schneidet nur die Kernbreite aus, nicht die Putzflanken). Der
|
||||
// Putz läuft dort durchgehend ums Eck (L) — eine Trennnaht würde eine falsche
|
||||
// Materialgrenze suggerieren. Daher KEINE L-Linie.
|
||||
expect(lLines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
|
||||
|
||||
+299
-34
@@ -38,12 +38,20 @@ import { docFromText } from "../text/richText";
|
||||
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
|
||||
import type { RoomStampLine } from "../model/roomStamp";
|
||||
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
|
||||
import { stairGeometry, stairCut } from "../geometry/stair";
|
||||
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
|
||||
import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
|
||||
import type { SpiralOutlineSegments } from "../geometry/stair";
|
||||
import {
|
||||
doorSymbol,
|
||||
openingInterval,
|
||||
openingJambs,
|
||||
wallAxisFrame,
|
||||
windowSymbol,
|
||||
} from "../geometry/opening";
|
||||
import {
|
||||
add,
|
||||
along,
|
||||
clippedBand,
|
||||
dot,
|
||||
leftNormal,
|
||||
lineIntersect,
|
||||
normalize,
|
||||
@@ -260,6 +268,8 @@ export type Primitive =
|
||||
dash?: number[] | null;
|
||||
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
|
||||
openingId?: string;
|
||||
/** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
|
||||
stairId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
@@ -574,6 +584,11 @@ export function generatePlan(
|
||||
visibleCodes.has(c.categoryCode) &&
|
||||
categoryDisplay(c.categoryCode).render,
|
||||
);
|
||||
// Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
|
||||
// die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
|
||||
// wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
|
||||
// wenn es überhaupt Decken gibt.
|
||||
const wallFootprints = ceilings.length ? buildWallFootprints(project, walls) : [];
|
||||
for (const ceiling of ceilings) {
|
||||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||||
const category = catByCode.get(ceiling.categoryCode);
|
||||
@@ -583,7 +598,7 @@ export function generatePlan(
|
||||
category,
|
||||
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
|
||||
);
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category);
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category, wallFootprints);
|
||||
}
|
||||
|
||||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||||
@@ -1077,6 +1092,88 @@ function subtractIntervals(
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wand-Footprint als orientiertes Rechteck (OBB) für das Verdecken der
|
||||
* darunterliegenden Decke im Grundriss: Achs-Startpunkt + Einheits-Richtung `u`
|
||||
* und -Normale `m`, Halbdicke, Referenz-Querversatz (Wandachse↔Bandmitte) und
|
||||
* Achslänge. Die u-Ausdehnung wird beim Test an beiden Enden um die Halbdicke
|
||||
* verlängert, damit auch die Eck-Überlappe (Gehrungen) sicher abgedeckt sind.
|
||||
*/
|
||||
interface WallFootprint {
|
||||
a: Vec2;
|
||||
u: Vec2;
|
||||
m: Vec2;
|
||||
len: number;
|
||||
half: number;
|
||||
refOff: number;
|
||||
}
|
||||
|
||||
/** Baut die Wand-Footprints (OBBs) aus den gerenderten Wänden. Nicht auflösbarer
|
||||
* Wandtyp ⇒ Default-Dicke 0.2 m (wie der Vollkörper-Fallback im 3D-Emitter). */
|
||||
function buildWallFootprints(project: Project, walls: Wall[]): WallFootprint[] {
|
||||
const out: WallFootprint[] = [];
|
||||
for (const w of walls) {
|
||||
let t = 0.2;
|
||||
try {
|
||||
t = wallTypeThickness(getWallType(project, w));
|
||||
} catch {
|
||||
// Default-Dicke behalten.
|
||||
}
|
||||
const u = normalize(sub(w.end, w.start));
|
||||
out.push({
|
||||
a: w.start,
|
||||
u,
|
||||
m: leftNormal(u),
|
||||
len: Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y),
|
||||
half: t / 2,
|
||||
refOff: wallReferenceOffset(w, t),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametrisches Intervall [t0,t1] ⊆ [0,1], über das die Strecke `pa→pb`
|
||||
* INNERHALB des Wand-Footprints liegt (also von der Wand verdeckt). `null`, wenn
|
||||
* die Strecke den Footprint nicht schneidet. OBB-Test über zwei Slabs: Projektion
|
||||
* auf die Achse `u` (Bereich [−half, len+half]) und auf die Normale `m` (Bereich
|
||||
* [refOff−half, refOff+half]); das Schnitt-Intervall beider Slabs ∩ [0,1] ist die
|
||||
* verdeckte Strecke.
|
||||
*/
|
||||
function segmentInsideWallBand(
|
||||
pa: Vec2,
|
||||
pb: Vec2,
|
||||
fp: WallFootprint,
|
||||
): [number, number] | null {
|
||||
const d = sub(pb, pa);
|
||||
const rel = sub(pa, fp.a);
|
||||
const clipSlab = (
|
||||
f0: number,
|
||||
fd: number,
|
||||
lo: number,
|
||||
hi: number,
|
||||
): [number, number] | null => {
|
||||
if (Math.abs(fd) < 1e-12)
|
||||
return f0 >= lo - CUTOUT_EPS && f0 <= hi + CUTOUT_EPS ? [0, 1] : null;
|
||||
let ta = (lo - f0) / fd;
|
||||
let tb = (hi - f0) / fd;
|
||||
if (ta > tb) [ta, tb] = [tb, ta];
|
||||
return [ta, tb];
|
||||
};
|
||||
const su = clipSlab(dot(rel, fp.u), dot(d, fp.u), -fp.half, fp.len + fp.half);
|
||||
if (!su) return null;
|
||||
const sm = clipSlab(
|
||||
dot(rel, fp.m),
|
||||
dot(d, fp.m),
|
||||
fp.refOff - fp.half,
|
||||
fp.refOff + fp.half,
|
||||
);
|
||||
if (!sm) return null;
|
||||
const t0 = Math.max(0, su[0], sm[0]);
|
||||
const t1 = Math.min(1, su[1], sm[1]);
|
||||
return t1 > t0 + CUTOUT_EPS ? [t0, t1] : null;
|
||||
}
|
||||
|
||||
function addWallPoche(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
@@ -1491,29 +1588,36 @@ function addOpeningSymbol(
|
||||
if (o.kind === "door") {
|
||||
const sym = doorSymbol(wall, o);
|
||||
if (!sym) return;
|
||||
// Türblatt (alle Stufen).
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: sym.hinge,
|
||||
b: sym.openEnd,
|
||||
cls: "door-leaf",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// mittel + fein: Schwenkbogen von geschlossen → offen.
|
||||
if (detail !== "grob") {
|
||||
// Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN
|
||||
// Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
|
||||
// ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
|
||||
// Entspricht `oeff_tuer_typ == "wandoeffnung"` im Rhino-Plugin.
|
||||
const isWandoeffnung = (o.doorType ?? "normal") === "wandoeffnung";
|
||||
if (!isWandoeffnung) {
|
||||
// Türblatt (alle Stufen).
|
||||
out.push({
|
||||
kind: "arc",
|
||||
center: sym.hinge,
|
||||
from: sym.closedEnd,
|
||||
to: sym.openEnd,
|
||||
r: sym.radius,
|
||||
cls: "door-swing",
|
||||
kind: "line",
|
||||
a: sym.hinge,
|
||||
b: sym.openEnd,
|
||||
cls: "door-leaf",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// mittel + fein: Schwenkbogen von geschlossen → offen.
|
||||
if (detail !== "grob") {
|
||||
out.push({
|
||||
kind: "arc",
|
||||
center: sym.hinge,
|
||||
from: sym.closedEnd,
|
||||
to: sym.openEnd,
|
||||
r: sym.radius,
|
||||
cls: "door-swing",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
|
||||
if (detail === "fein") {
|
||||
@@ -1530,6 +1634,57 @@ function addOpeningSymbol(
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sturzlinien (SIA, gestrichelt) — Überkopf-Projektion des Sturzes quer über
|
||||
// die Öffnung an der Wand-Innen-/Aussenkante. Entspricht `_make_tuer_sturz_curves`
|
||||
// im Rhino-Plugin. Default „beide" wenn nicht explizit gesetzt.
|
||||
const lintelMode = o.lintelLines ?? "beide";
|
||||
if (lintelMode !== "keine") {
|
||||
const jambs = openingJambs(wall, o);
|
||||
if (jambs) {
|
||||
const { n } = wallAxisFrame(wall);
|
||||
const total = wallTypeThickness(getWallType(project, wall));
|
||||
const refOff = wallReferenceOffset(wall, total);
|
||||
const halfD = total / 2;
|
||||
const outerOff = refOff + halfD;
|
||||
const innerOff = refOff - halfD;
|
||||
// Strichmuster: gestrichelt (Überkopf-Projektion, SIA-Konvention).
|
||||
const dash: number[] = [0.18, 0.09];
|
||||
const strokeColor = o.color ?? POCHE_STROKE;
|
||||
/** Linie quer über die Öffnung am gegebenen Perp-Offset. */
|
||||
const lintelLine = (perpOff: number) => ({
|
||||
a: add(jambs.jambStart, scale(n, perpOff)),
|
||||
b: add(jambs.jambEnd, scale(n, perpOff)),
|
||||
});
|
||||
if (lintelMode === "aussen" || lintelMode === "beide") {
|
||||
const seg = lintelLine(outerOff);
|
||||
out.push({
|
||||
kind: "line" as const,
|
||||
a: seg.a,
|
||||
b: seg.b,
|
||||
cls: "door-lintel",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
dash,
|
||||
color: strokeColor,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
if (lintelMode === "innen" || lintelMode === "beide") {
|
||||
const seg = lintelLine(innerOff);
|
||||
out.push({
|
||||
kind: "line" as const,
|
||||
a: seg.a,
|
||||
b: seg.b,
|
||||
cls: "door-lintel",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
dash,
|
||||
color: strokeColor,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1549,6 +1704,20 @@ function addOpeningSymbol(
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// Flügel-Mittelpfosten (wingCount − 1) — reine Querlinien innerhalb des
|
||||
// Rahmens (kein Strichmuster). Nur mittel/fein; entfällt bei grob/schema.
|
||||
for (const [a, b] of sym.mullionLines) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a,
|
||||
b,
|
||||
cls: "window-mullion",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
color: o.color ?? POCHE_STROKE,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [a, b] of sym.glassLines) {
|
||||
out.push({
|
||||
@@ -1561,6 +1730,26 @@ function addOpeningSymbol(
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
// Brüstungslinie (gepunktet) — zeigt die Brüstung im Grundriss auf der Wandachse.
|
||||
// Nur bei Fenstern mit Brüstungshöhe > 0. Entspricht der Sill-Linie in
|
||||
// `_make_oeffnung_preview` und dem Brüstungs-Plansymbol im Rhino-Plugin.
|
||||
if (o.sillHeight > 0) {
|
||||
const jambs = openingJambs(wall, o);
|
||||
if (jambs) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: jambs.jambStart,
|
||||
b: jambs.jambEnd,
|
||||
cls: "window-sill",
|
||||
weightMm: SYMBOL_HAIRLINE_MM,
|
||||
// Gepunktet (dot-dash) wie in _make_oeffnung_preview DrawDottedLine.
|
||||
dash: [0.04, 0.08],
|
||||
color: o.color ?? POCHE_STROKE,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
|
||||
@@ -1614,6 +1803,7 @@ function addCeilingPoche(
|
||||
detail: DetailLevel,
|
||||
lwMm: number,
|
||||
category: LayerCategory | undefined,
|
||||
wallFootprints: WallFootprint[],
|
||||
): void {
|
||||
const pts = ceiling.outline;
|
||||
if (pts.length < 3) return;
|
||||
@@ -1646,27 +1836,47 @@ function addCeilingPoche(
|
||||
resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
|
||||
)
|
||||
: NO_HATCH;
|
||||
// Gefüllte Fläche OHNE eigenen Umriss (stroke:"none"): die Decke liegt unter
|
||||
// der Wand-Poché; ihre Kante wird separat als verdeckungs-geclippte Linie
|
||||
// gezeichnet (s. u.), damit sie nicht durch die darüberstehenden Wände schlägt.
|
||||
// Trägt weiterhin die `ceilingId` für die Links-Klick-Auswahl der Fläche.
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: comp ? pocheFill(hatch.pattern) : "none",
|
||||
stroke,
|
||||
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||
stroke: "none",
|
||||
strokeWidthMm: 0,
|
||||
hatch,
|
||||
greyed,
|
||||
ceilingId,
|
||||
});
|
||||
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: outlineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
ceilingId,
|
||||
});
|
||||
// Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
|
||||
// Umriss-Kante gegen die Wand-Footprints clippen und die freien Teilstücke als
|
||||
// Linien ausgeben. Liegt die Decke deckungsgleich unter den Wänden (Regelfall:
|
||||
// Deckenumriss = Gebäude-/Wand-Mittellinien), entfällt der Umriss ganz; nur
|
||||
// Überstände (Vordach/Balkon jenseits der Wand) bleiben sichtbar.
|
||||
const n = pts.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
const holes: Array<[number, number]> = [];
|
||||
for (const fp of wallFootprints) {
|
||||
const iv = segmentInsideWallBand(a, b, fp);
|
||||
if (iv) holes.push(iv);
|
||||
}
|
||||
const d = sub(b, a);
|
||||
for (const [t0, t1] of subtractIntervals(0, 1, holes)) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: add(a, scale(d, t0)),
|
||||
b: add(a, scale(d, t1)),
|
||||
cls: "ceiling-outline",
|
||||
weightMm: outlineMm,
|
||||
color: stroke,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1756,6 +1966,40 @@ function addRoomArea(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet die Wendel-Aussenlinie: Innen-/Aussenbogen als Arc-Primitive plus zwei
|
||||
* radiale Schliesslinien. Wird von `addStairSymbol` aufgerufen wenn `shape === "spiral"`.
|
||||
*/
|
||||
function _addSpiralOutline(
|
||||
out: Primitive[],
|
||||
seg: SpiralOutlineSegments,
|
||||
stroke: string,
|
||||
lwMm: number,
|
||||
greyed: boolean,
|
||||
stairId: string,
|
||||
): void {
|
||||
// Radiale Schliesslinien (Startwinkel + Endwinkel).
|
||||
for (const [a, b] of seg.lines) {
|
||||
out.push({ kind: "line", a, b, cls: "stair-outline", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
}
|
||||
// Innen- und Aussenbogen.
|
||||
for (const arc of seg.arcs) {
|
||||
const from: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a0), y: arc.cy + arc.r * Math.sin(arc.a0) };
|
||||
const to: Vec2 = { x: arc.cx + arc.r * Math.cos(arc.a1), y: arc.cy + arc.r * Math.sin(arc.a1) };
|
||||
out.push({
|
||||
kind: "arc",
|
||||
center: { x: arc.cx, y: arc.cy },
|
||||
from,
|
||||
to,
|
||||
r: arc.r,
|
||||
cls: "stair-outline",
|
||||
weightMm: lwMm,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
|
||||
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
|
||||
@@ -1860,6 +2104,27 @@ function addStairSymbol(
|
||||
const [h1, tip, h2] = geo.arrow.head;
|
||||
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
|
||||
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
|
||||
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
|
||||
// Entspricht `_aussen_gerade` / `_aussen_l_polygon` / `_aussen_wendel` im
|
||||
// Rhino-Plugin. Stets durchgezogen, volle Strichstärke.
|
||||
const outline = stairOutline(stair, totalRise);
|
||||
if (outline.polygon && outline.polygon.length >= 3) {
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: outline.polygon,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: lwMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
if (outline.spiral) {
|
||||
_addSpiralOutline(out, outline.spiral, stroke, lwMm, greyed, stairId);
|
||||
}
|
||||
}
|
||||
|
||||
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
|
||||
|
||||
@@ -137,6 +137,12 @@ export interface OpeningInfo {
|
||||
swing?: "left" | "right";
|
||||
swingAngle?: number;
|
||||
openingDir?: "in" | "out";
|
||||
/** Nur Fenster: Anzahl der Flügel (1–4). Fehlt → 1 (Default). */
|
||||
wingCount?: number;
|
||||
/** Nur Tür: Tür-Typ (normal / Wandöffnung). Fehlt → "normal". */
|
||||
doorType?: "normal" | "wandoeffnung";
|
||||
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
|
||||
lintelLines?: "keine" | "innen" | "aussen" | "beide";
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
@@ -160,6 +166,8 @@ export interface StairInfo {
|
||||
treadDepth: number;
|
||||
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
||||
up: boolean;
|
||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
||||
referenz?: "links" | "mitte" | "rechts";
|
||||
/** Geschoss, dem die Treppe zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
@@ -478,6 +486,9 @@ function openingSelection(project: Project, o: Opening): Selection {
|
||||
swing: o.swing,
|
||||
swingAngle: o.swingAngle,
|
||||
openingDir: o.openingDir,
|
||||
wingCount: o.wingCount,
|
||||
doorType: o.doorType,
|
||||
lintelLines: o.lintelLines,
|
||||
zBottom: ext.zBottom,
|
||||
zTop: ext.zTop,
|
||||
};
|
||||
@@ -513,6 +524,7 @@ function stairSelection(project: Project, s: Stair): Selection {
|
||||
riserHeight: geo.riserHeight,
|
||||
treadDepth: geo.treadDepth,
|
||||
up: s.up !== false,
|
||||
referenz: s.referenz,
|
||||
floorId: s.floorId,
|
||||
floorName: floor?.name ?? s.floorId,
|
||||
zBottom,
|
||||
|
||||
@@ -39,11 +39,12 @@ export const ACCENT_SWATCHES: AccentSwatch[] = [
|
||||
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
|
||||
|
||||
/**
|
||||
* Snap-/Endpunkt-Default: Akzent „Sora" (vorläufig — die offene Rückfrage
|
||||
* „aki" im HANDOVER-Backlog ist noch nicht geklärt). Bewusst ein kühler,
|
||||
* heller Ton: hebt sich klar vom warmen Yuyake-Auswahlrahmen UND von den
|
||||
* meist neutral-dunklen Wand-/Linienfarben ab, bleibt aber (anders als das
|
||||
* sehr blasse Kori) auch auf hellem Papier-Hintergrund gut sichtbar.
|
||||
* Snap-/Endpunkt-Default: Akzent „Sora" (#5FA1C9). Vom Nutzer als endgültiger
|
||||
* Default festgelegt (2026-07-04) — die frühere „aki"-Rückfrage ist damit
|
||||
* geschlossen. Bewusst ein kühler, heller Ton: hebt sich klar vom warmen
|
||||
* Yuyake-Auswahlrahmen UND von den meist neutral-dunklen Wand-/Linienfarben ab,
|
||||
* bleibt aber (anders als das sehr blasse Kori) auch auf hellem
|
||||
* Papier-Hintergrund gut sichtbar. Per Picker im Einstellungs-Fenster änderbar.
|
||||
*/
|
||||
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user