Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db6f3ae0a0 | |||
| 1407c68232 | |||
| dd9984d3c4 | |||
| c5a344d3f2 | |||
| cb8fae5cb4 | |||
| 03f0c402b5 | |||
| 82d354f5e7 | |||
| afd03f1808 | |||
| bf97d501be | |||
| c36a7e0d54 | |||
| 6c473558ef | |||
| 5ed3549c66 | |||
| da39f0c8d5 | |||
| fc0a76feea | |||
| e4b8df6ae8 | |||
| 32497bcfab | |||
| 18557170aa | |||
| d74dda1ab5 | |||
| 0ddf99de08 | |||
| e1698b7c3d | |||
| f2b21d1f69 | |||
| 79d1187af7 | |||
| 7dd324d55e | |||
| e42c336478 | |||
| de7a26b89e | |||
| 8bc472a272 | |||
| 2dc40cd788 | |||
| 02bd2616fc | |||
| fabf97a402 | |||
| ac0d9efc84 | |||
| 6ef57b6b6b | |||
| 85a721f03b | |||
| 7c02ea0498 | |||
| 77a5940616 | |||
| 0f0cf83a4b | |||
| 3c3746d529 | |||
| d9937e5afa | |||
| 3d3bbdf64c | |||
| 576e6ba91b | |||
| fd890a0d74 | |||
| 368a2ffcdb | |||
| 7b58745855 | |||
| dcb6ed5d31 | |||
| 3b90c4c072 | |||
| c9cdb58275 | |||
| 339202b355 | |||
| d02781e8d2 | |||
| 9695748fbc | |||
| 23d81bee7b | |||
| 7569524d81 | |||
| f22970f321 | |||
| ebed3f5311 | |||
| ae17900cb2 | |||
| 25cebbd98c | |||
| 3d1fa40402 | |||
| b4dd396768 | |||
| f0e777efc5 | |||
| 0613bb596b | |||
| f5923d302d | |||
| 87ebd91ba2 | |||
| bf3e8bf9fc | |||
| 7b65f3bce6 | |||
| a33f124931 | |||
| 989e61c261 | |||
| 5d99701024 | |||
| 940c38eb33 | |||
| a2c8fcd6de | |||
| 0c82d01cd9 | |||
| 56bbcec2a8 | |||
| b6c1a9a3c6 | |||
| b83fd81399 | |||
| 633480252e | |||
| 5b70742efa | |||
| b0db956013 | |||
| d788cbb32b | |||
| b1e7fdf8fc | |||
| 3aee2e05ee | |||
| 88cc9df0b6 | |||
| e173978135 | |||
| 6b6c18b43d | |||
| 17523d422c | |||
| 7bcf69c16c | |||
| b6d14ca2b2 | |||
| bd80eb1c59 | |||
| 3a0652a337 | |||
| 4211633e3e | |||
| ac55233c1a | |||
| 5496f8c007 | |||
| 694e666160 | |||
| 47b9fa5aa7 | |||
| f2d36632cb | |||
| bd0e7195bb | |||
| 96628c5aaa | |||
| fc0cab9ec7 | |||
| b353ed34ae | |||
| b6e902a809 | |||
| 7d2c9803d6 | |||
| 7cf867a1a0 | |||
| 0e1c69e316 | |||
| 5a10ea5404 | |||
| fd862c4a8b | |||
| 056ca2c288 | |||
| 3402e6ac96 | |||
| b4c4a2cc6b | |||
| 382771b2e7 | |||
| a8e491e534 | |||
| 76029dbf68 | |||
| 4b02e40238 | |||
| ce6bd26ff7 | |||
| 54211b1443 | |||
| c8a4188618 | |||
| 7ca5197b6b | |||
| a010584d9b | |||
| e6d061cc91 | |||
| 0161b0231d | |||
| 34317e53f4 | |||
| 27e41077b1 | |||
| a37b1c0cc9 | |||
| 4eb635cc89 | |||
| 4734c04ec0 | |||
| 2f971a54c0 | |||
| 8cfe6c2521 | |||
| 665cbce1f9 | |||
| 6eead6d493 | |||
| 7b9f72e79f | |||
| cd0f976b4f | |||
| 919bc67bfa | |||
| ec5cbf6fa0 | |||
| f1b802299f | |||
| 0337422ae6 | |||
| d39244989b | |||
| 01424c1e22 | |||
| 1811e82005 | |||
| 0a1aaedef8 | |||
| 483ad3f278 | |||
| d27622d581 | |||
| f315c80748 | |||
| bda256d3a4 | |||
| c93b168f5c | |||
| a971cd610b | |||
| 48bbcaed1e | |||
| 6d4b4e66c1 | |||
| 27bb5a42ec | |||
| 8fd8987b70 | |||
| 7b3b597abc | |||
| 4a08161e4b | |||
| 39236c65c7 | |||
| b9dc1838c7 |
+1
-1
@@ -57,7 +57,7 @@ Rendern angewandt, nie in die Geometrie eingebacken.
|
|||||||
|
|
||||||
## Arbeitsweise (für Beiträge)
|
## Arbeitsweise (für Beiträge)
|
||||||
|
|
||||||
- Substanzielle, mehrstufige Arbeit schrittweise in isolierten Schritten angehen.
|
- Substanzielle, mehrstufige Arbeit an **Subagenten** delegieren, wo möglich.
|
||||||
- Änderungen verifizieren: `npx tsc -b`, `npm run build`, und Screenshot via
|
- Ä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
|
`node scripts/probe.mjs` (schreibt `scripts/probe.png`) bzw. `probe-ff*.mjs` für
|
||||||
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
|
Firefox-Fälle. Screenshot ansehen und Geometrie visuell prüfen.
|
||||||
|
|||||||
+11
-10
@@ -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.
|
> **➡️ 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 2026-07-04 abends — Unterbruch, 3 Slices verloren
|
## ⚠️ NACHTRAG Session 3b (2026-07-04 abends) — Session hart abgebrochen (Spend-Limit), 3 Slices mitten in der Arbeit 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. 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.**
|
> **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.**
|
||||||
>
|
>
|
||||||
> **⚠️ 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.
|
> **⚠️ 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.
|
> Der folgende Block bleibt als historischer Kontext/Vorlage für den offenen Slice 3 stehen.
|
||||||
|
|
||||||
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":
|
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":
|
||||||
|
|
||||||
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.
|
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. 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.
|
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.
|
||||||
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.
|
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 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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**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` (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.
|
**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.
|
||||||
|
|
||||||
### Empfohlene Reihenfolge zum Wiederaufsetzen
|
### 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.
|
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,9 +75,10 @@ Die Arbeit wurde unerwartet unterbrochen — mehrere Tasks liefen parallel. Dies
|
|||||||
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.
|
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)
|
## Konventionen (ZWINGEND — zuerst lesen)
|
||||||
- **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.
|
- **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.
|
||||||
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
|
- **NICHT ANFASSEN (fremde WIP):** `.gitignore`, `public/assets/materials/manifest.json`, `src/materials/library.ts`, `scripts/fetch-materials.mjs` — bleiben dauerhaft dirty, nie stagen.
|
||||||
- **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).
|
- **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.
|
||||||
- **Engine = „Nordstern"** (render2d/render3d/WASM). Referenz DOSSIER-Rhino: `https://git.openbureau.ch/karim/dossier` (Alias git.kgva.ch), geklont `/tmp/dossier-ref`.
|
- **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)
|
## In dieser Session gelandet (Auswahl Commits)
|
||||||
@@ -85,7 +86,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`).
|
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`
|
## 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.
|
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 ❓).
|
||||||
|
|
||||||
## Verifikation
|
## Verifikation
|
||||||
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
|
`npx tsc --noEmit` + `npx vitest run` (zuletzt 152 grün) + trace-scan vor jedem Commit.
|
||||||
|
|||||||
+13
-39
@@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
## Arbeitsprotokoll (ZWINGEND)
|
## Arbeitsprotokoll (ZWINGEND)
|
||||||
|
|
||||||
**Bearbeiter:**
|
**Worker-Instanz** (führt aus):
|
||||||
1. **Zuerst diese Datei lesen**, dann `CONVENTIONS.md` + verlinkte Detailquellen des Items.
|
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).
|
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**.
|
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.
|
4. **Kein Arbeitsschritt endet ohne aktualisierte Liste.** Status hier ist immer aktuell, sonst driftet es wieder.
|
||||||
5. Bearbeiter **committet nicht selbst** und delegiert Unteraufgaben nicht weiter.
|
5. Worker **committet nicht selbst** und delegiert nicht weiter (Agent/Task verboten) — das macht die Hauptinstanz. (Konvention HANDOVER.)
|
||||||
|
|
||||||
**Planer** (mit dem Nutzer, kuratiert):
|
**Planungs-Instanz** (mit dem Nutzer, kuratiert):
|
||||||
- Nimmt Nutzer-Feedback, pflegt daraus die Liste (rein/umpriorisieren/aufspalten). Führt selbst keine Queue-Items aus.
|
- 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.
|
- Prüft **❓**-Zeilen mit dem Nutzer, wandelt sie in konkrete Items.
|
||||||
|
|
||||||
@@ -20,52 +20,31 @@
|
|||||||
|
|
||||||
## ⛔ Blocker (zuerst klären)
|
## ⛔ Blocker (zuerst klären)
|
||||||
|
|
||||||
- [x] ~~**Toolchain prüfen**~~ — **ERLEDIGT 2026-07-04 (macOS-Gerät):** `node`/`npm`/`cargo` (1.96.0)/`wasm-pack` (0.15.0) alle vorhanden. `npm install` + `npm approve-scripts esbuild wasm-pack …` (npm gated Install-Scripts → `allowScripts`-Block in `package.json`, ungetrackt gelassen), `rustup target add wasm32-unknown-unknown`, beide WASM-Engines gebaut, Frontend gebaut, **`npm run tauri:build` → Mac-App (`cad.app` + `cad_0.1.0_aarch64.dmg`, arm64) läuft.** Verifikations-Baseline grün: `tsc --noEmit` sauber, `vitest run` 230/230, `cargo test render3d` 56/56. **Kein Blocker mehr für Engine-Slices auf diesem Gerät.**
|
- [ ] **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
|
||||||
|
|
||||||
## 🔧 In Arbeit
|
## 🔧 In Arbeit
|
||||||
|
|
||||||
- [ ] **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:
|
_(leer — Worker trägt hier ein, was er gerade anpackt)_
|
||||||
- [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)
|
## ⚠️ Zu prüfen (evtl. schon erledigt / Status unklar)
|
||||||
|
|
||||||
- [x] ~~**Snap an Wand-Schichttrennlinien**~~ — **gelandet `339202b`** (`wallLayerBoundarySegments` in `src/tools/snapping.ts`), verifiziert vorhanden.
|
- [ ] **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
|
||||||
|
|
||||||
## ⏭️ Als Nächstes
|
## ⏭️ Als Nächstes
|
||||||
|
|
||||||
- [ ] **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.
|
_(Öffnungen als Boolean-Löcher ist jetzt in 🔧 In Arbeit)_
|
||||||
|
|
||||||
## 📋 Backlog (Priorität grob absteigend)
|
## 📋 Backlog (Priorität grob absteigend)
|
||||||
|
|
||||||
- [ ] **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).
|
- [ ] **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.
|
||||||
- [ ] **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):
|
- [ ] **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).
|
- Reale Höhen + **Projekt-MüM** (EG-Referenzhöhe): Terrain georeferenziert bei realem z relativ dazu, Gebäude auf Terrain drapiert (heute alles z=0).
|
||||||
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
- **Luftbild/SWISSIMAGE**-Orthofoto als Textur aufs Terrain-Mesh.
|
||||||
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
|
- Importierte Geo-Elemente auf **aktives Geschoss** (`viewSlice.activeLevelId`) + Gelände-Ebene.
|
||||||
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
|
- **Nordstern-Geo-Rendering:** importierte Meshes (heute nur three.js `importedMesh`/`terrainMesh`) auch in `projectToModel3d` einspeisen.
|
||||||
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
|
- **3D-Mesh-DXF/DWG-Import** (heute DXF nur 2D); Building-Draping; höhere DTM-Auflösung.
|
||||||
- [x] ~~**ResourceManager Bauteile-Tab** auf Master-Detail~~ — **bereits Master-Detail** (`ComponentsTab`/`ComponentDetail` in `src/ui/ResourceManager.tsx`, Liste links `res-md-list` / Detail rechts). Verifiziert vorhanden.
|
- [ ] **ResourceManager Bauteile-Tab** auf Master-Detail (wie Wandstile) — BEVOR Bauteil-Logik angefasst wird.
|
||||||
- [ ] **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.
|
- [ ] **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.
|
||||||
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
- [ ] **Bildschraffur:** ambientCG/CGI-Colorfiles als Quelle (Material-Lib WIP — erst nach Freigabe); Bild-Filter Sättigung/Helligkeit/Kontrast/SW (`image.filters`).
|
||||||
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
- [ ] **E2b Schraffur-Kachel-Motiv** (MotifEditor für HatchStyle-Tile wiederverwenden).
|
||||||
- [ ] **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
|
- [ ] **Linienstile aufräumen** (Weight-only-Stile → „Volllinie", lineStyleId-Referenzen remappen).
|
||||||
@@ -74,14 +53,14 @@
|
|||||||
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
- [ ] Elemente im Schnitt anwählbar; render3d 2D-Schraffur auf 3D-Flächen.
|
||||||
|
|
||||||
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
|
### 3D-REST (engine-schwer, bewusst NICHT blind — mit Nutzer angehen)
|
||||||
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern). **Erster Durchstich als Spike terminiert → [SPIKE_TEXTUR_render3d.md](SPIKE_TEXTUR_render3d.md) (steht unter ⏭️ Als Nächstes).**
|
- [ ] **Textur-/PBR-Pipeline** (Sampler/Bindings/UV in wgpu; dann `textured`-Style + `Component.texture3d`/Material echt rendern).
|
||||||
- [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.
|
- [ ] **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).
|
||||||
- [ ] **Ortho-Ray-Picking** (aktuell perspektivischer Pick-Strahl; Front/Top/Side-Presets vor erster Navigation leicht ungenau).
|
- [ ] **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).
|
- [ ] **3D-Griffe/Editieren** (Wand-Endpunkte im 3D ziehen etc. — bisher nur Auswahl + Panel-Edit).
|
||||||
|
|
||||||
## ❓ Offene Rückfragen (an den Nutzer)
|
## ❓ Offene Rückfragen (an den Nutzer)
|
||||||
|
|
||||||
- [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.
|
- [ ] **„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)
|
||||||
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
|
- [ ] Floating-ResourceManager „headless" = ganz ohne Titelleiste? (aktuell MIT)
|
||||||
- [ ] Geo-Block: Projekt-MüM zuerst oder Nordstern-Geo-Rendering?
|
- [ ] 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?
|
- [ ] 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?
|
||||||
@@ -92,11 +71,6 @@
|
|||||||
|
|
||||||
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
_Nur jüngste Session; ältere Historie siehe `git log` und HANDOVER-Narrative._
|
||||||
|
|
||||||
- [x] 2026-07-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 **Ö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 **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`
|
- [x] 2026-07-04 **Locked-Iso-Fix** freie Kamera bleibt nach Ortho-Preset orthografisch — `cb8fae5`
|
||||||
|
|||||||
-124
@@ -1,124 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# 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 @@
|
|||||||
# ARCHITEKTUR-BRIEFING: Tauri + wgpu (Korrigiert)
|
# HAUPTINSTANZ-BRIEFING: Tauri + wgpu (Korrigiert)
|
||||||
|
|
||||||
**Stand:** 2026-07-01 — **KORREKTUR** (vorherige Dokumente waren unvollständig)
|
**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
|
## Nächste Schritte (für Hauptinstanz)
|
||||||
|
|
||||||
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
|
1. **Lesen:** `docs/design/tauri-migration-plan.md` (technisch, konkret)
|
||||||
2. **Spawn:** vier Agents (parallel, unabhängig)
|
2. **Spawn:** vier Agents (parallel, unabhängig)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# DOSSIER-Feature-Audit (A1–A6, B1–B4, C1–C3, D1–D3, E)
|
# 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
|
Ausführliche Fassung des Audits, auf das HANDOVER.md Backlog-Punkt 9 nur noch mit
|
||||||
Einzeilern verweist. Abgleich DOSSIER-Rhino-Referenzrepo (`/tmp/dossier-ref`) gegen
|
Einzeilern verweist. Ursprünglich von einem Opus-Subagent erarbeitet, der das
|
||||||
den aktuellen Browser-Port. Belege (Datei:Zeile) beziehen sich auf `/tmp/dossier-ref/rhino/*.py`.
|
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`.
|
||||||
|
|
||||||
Status-Symbole: ❌ nicht übernommen · 🟡 teilweise übernommen.
|
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)
|
## 7. Gate (Pflicht)
|
||||||
|
|
||||||
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
|
`rm -f tsconfig.tsbuildinfo && npx tsc -b` grün, `npm run build` grün, keine
|
||||||
Trace-Scan (grep auf Co-Authored/Generated), Boot-Probe
|
AI-Spuren (grep auf Claude/Anthropic/AI/Generated/Co-Authored), Boot-Probe
|
||||||
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
|
(`node scripts/probe.mjs`) ohne Konsolenfehler, Screenshot der Oberleiste.
|
||||||
KEIN Commit.
|
KEIN Commit.
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
|
# Briefing — Nativer wgpu-2D-Renderer (parallele Instanz)
|
||||||
|
|
||||||
> Für den Entwickler, der den nativen GPU-Renderer baut. Isoliert vom
|
> Für die Instanz/den Agenten, der den nativen GPU-Renderer baut. Isoliert vom
|
||||||
> Web-Renderer; koordiniert über dieses Dokument.
|
> Web-Renderer; koordiniert über dieses Dokument.
|
||||||
|
|
||||||
## COMMIT-REGEL (verbindlich)
|
## COMMIT-REGEL (verbindlich)
|
||||||
Dieses Repo darf **keinerlei Fremd-Tool-Hinweise** enthalten — nicht im Code,
|
Dieses Repo darf **keinerlei Hinweise auf KI-Werkzeuge** enthalten — nicht im Code,
|
||||||
in Kommentaren oder in der Git-Historie. Kommentare deutsch, Identifier englisch.
|
in Kommentaren oder in der Git-Historie. Keine `Co-Authored-By`-Trailer, keine
|
||||||
**Kein Commit ohne Absprache.**
|
„Generated with"-Zeilen, keine Modell-/Agenten-Namen. Kommentare deutsch,
|
||||||
|
Identifier englisch. **Kein Commit ohne Ansage der Hauptinstanz.**
|
||||||
|
|
||||||
## Warum
|
## Warum
|
||||||
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
|
Der 2D-Plan wird aktuell in einem WebGL-Canvas gerendert (Web-Stack). In Chromium
|
||||||
@@ -71,9 +72,9 @@ per Matrix-Uniform macht. Fenster-Integration in Tauri ist der zweite, separate
|
|||||||
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
|
- **Eigener Branch** (z. B. `feature/wgpu-renderer`), NICHT auf `master`/
|
||||||
`feature/parametric-walls` committen. Isolierter Worktree.
|
`feature/parametric-walls` committen. Isolierter Worktree.
|
||||||
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
|
- **Nur** `src-tauri/` + neue Rust-Module + `docs/`. **NICHT** `src/App.tsx`,
|
||||||
`src/plan/*`, `types.ts` anfassen (dort laufen parallel Features).
|
`src/plan/*`, `types.ts` anfassen (die Hauptinstanz arbeitet dort an Features).
|
||||||
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
|
- Der Web-WebGL-Renderer bleibt bestehen (Browser-Lauffähigkeit + Referenz).
|
||||||
- Ergebnisse/Diffs zurückliefern; Projektleitung committet.
|
- Ergebnisse/Diffs zurückliefern; die Hauptinstanz committet und pflegt das Memory.
|
||||||
|
|
||||||
## Gates
|
## Gates
|
||||||
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
|
`cargo check`/`cargo test`/`cargo build` grün. Trace-Scan sauber (COMMIT-REGEL).
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
|
> Port-Plan) und M1 (entkoppelter Standalone-Spike). Noch NICHT die Migration.
|
||||||
|
|
||||||
## Commit-/Spuren-Regel
|
## Commit-/Spuren-Regel
|
||||||
Wie im ganzen Repo: keine Fremd-Tool-Hinweise im Code, in Kommentaren oder der Historie. Kommentare deutsch, Identifier englisch. Kein Commit ohne Absprache.
|
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.
|
||||||
|
|
||||||
## Warum
|
## Warum
|
||||||
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
|
Der 3D-View laeuft heute als three.js/WebGL im Tauri-Webview (WebKitGTK). Wie beim
|
||||||
|
|||||||
+1
-4
@@ -16,10 +16,7 @@
|
|||||||
"shell": "scripts/chromium-shell.sh",
|
"shell": "scripts/chromium-shell.sh",
|
||||||
"electron": "scripts/electron-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: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": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
|
|||||||
Generated
-2
@@ -1488,10 +1488,8 @@ dependencies = [
|
|||||||
name = "geometry"
|
name = "geometry"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+6
-10
@@ -1,14 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["."]
|
members = [".", "geometry"]
|
||||||
# render2d/render3d/geometry sind eigenstaendige Crates, damit sie headless ohne
|
# render2d/render3d sind eigenstaendige Crates (jeweils eigener [workspace]), damit
|
||||||
# Tauri-Toolchain UND per wasm-pack (Feature "web") zu WASM baubar bleiben. Sie
|
# sie headless ohne Tauri-Toolchain baubar/testbar bleiben. Sie liegen zwar im
|
||||||
# liegen zwar im src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace —
|
# src-tauri-Baum, gehoeren aber NICHT zum cad-tauri-Workspace — hier ausschliessen,
|
||||||
# hier ausschliessen, damit `cad-tauri` sie dennoch per Pfad als Abhaengigkeit
|
# damit `cad-tauri` sie dennoch per Pfad als (optionale) Abhaengigkeit nutzen kann.
|
||||||
# nutzen kann. geometry: neu ausgeschlossen (war Member), seit es zu WASM gebaut
|
exclude = ["render2d", "render3d"]
|
||||||
# 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]
|
[package]
|
||||||
name = "cad-tauri"
|
name = "cad-tauri"
|
||||||
|
|||||||
Generated
-578
@@ -1,578 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# 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 }
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
// 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,28 +2,9 @@
|
|||||||
name = "geometry"
|
name = "geometry"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
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]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
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.
|
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -9,20 +9,6 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Copy)]
|
||||||
pub struct Vec2 {
|
pub struct Vec2 {
|
||||||
pub x: f64,
|
pub x: f64,
|
||||||
@@ -242,18 +228,6 @@ fn apply_layer_cuts(
|
|||||||
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die
|
// Rueckgrat-Nahflaeche ab (nicht an der Wandachse). In einem Block, damit die
|
||||||
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
|
// mutable Ausleihe von `result[branch_wall_idx]` vor dem Zugriff auf die
|
||||||
// Durchgangswand unten endet.
|
// 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 side_line = merge_cut;
|
||||||
let (face_arr, side_arr) = match branch_end {
|
let (face_arr, side_arr) = match branch_end {
|
||||||
@@ -264,13 +238,7 @@ fn apply_layer_cuts(
|
|||||||
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
|
face_arr[i] = if merged[i] { Some(merge_cut) } else { Some(face_cut) };
|
||||||
let adj_merged =
|
let adj_merged =
|
||||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
|
!merged[i] && ((i > 0 && merged[i - 1]) || (i + 1 < n && merged[i + 1]));
|
||||||
let merges_with_near_plaster =
|
side_arr[i] = if adj_merged { Some(side_line) } else { None };
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,8 +936,8 @@ mod tests {
|
|||||||
// Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom
|
// Wie t_junction_branch_gets_face_cut, aber alle drei Waende vom
|
||||||
// W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den
|
// W9-artigen Wandtyp: der Backstein-Kern (Index 1) sticht durch den
|
||||||
// Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden
|
// Nah-Putz und stoppt an der Kern-Nahflaeche (y=0.06), die beiden
|
||||||
// Innenputz-Schichten (Index 0/2) werden getrimmt. Da der Nah-Putz der
|
// Innenputz-Schichten (Index 0/2) werden getrimmt und bekommen
|
||||||
// Durchgangswand materialgleich ist, entsteht KEINE L-Trennnaht.
|
// zusaetzlich die seitliche L-Linie an derselben Flaeche.
|
||||||
let input = JoinInput {
|
let input = JoinInput {
|
||||||
walls: vec![
|
walls: vec![
|
||||||
w_iw("A", 0.0, 0.0, 5.0, 0.0),
|
w_iw("A", 0.0, 0.0, 5.0, 0.0),
|
||||||
@@ -1004,12 +972,13 @@ mod tests {
|
|||||||
let l2 = lc.start[2].expect("Schicht 2 getrimmt");
|
let l2 = lc.start[2].expect("Schicht 2 getrimmt");
|
||||||
assert!((l0.point.y - 0.075).abs() < 1e-9);
|
assert!((l0.point.y - 0.075).abs() < 1e-9);
|
||||||
assert!((l2.point.y - 0.075).abs() < 1e-9);
|
assert!((l2.point.y - 0.075).abs() < 1e-9);
|
||||||
// ... aber KEINE seitliche L-Linie: der Innenputz der Durchgangswand ist
|
// ... UND die seitliche L-Linie an der Rueckgrat-Nahflaeche (y=0.06,
|
||||||
// materialgleich und fuellt den getrimmten Abzweig-Putz durchgehend
|
// parallel zur x-Achse).
|
||||||
// (Putz-L ohne Trennnaht). Nur bei materialFREMDEM Nah-Putz stuende hier
|
let s0 = lc.start_side[0].expect("Schicht 0 hat L-Seitenlinie");
|
||||||
// eine L-Linie.
|
let s2 = lc.start_side[2].expect("Schicht 2 hat L-Seitenlinie");
|
||||||
assert!(lc.start_side[0].is_none());
|
assert!(dist(s0, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||||
assert!(lc.start_side[2].is_none());
|
assert!(dist(s0, Vec2 { x: 0.0, y: 0.06 }) < 1e-9);
|
||||||
|
assert!(dist(s2, Vec2 { x: 5.0, y: 0.06 }) < 1e-9);
|
||||||
|
|
||||||
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
|
// Die kollineare Durchgangswand bleibt bei den layerCuts unangetastet.
|
||||||
let ca = find(&out, "A");
|
let ca = find(&out, "A");
|
||||||
|
|||||||
Generated
-195
@@ -1,195 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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,14 +13,13 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use render3d::gpu::{RenderStyle, Renderer};
|
use render3d::gpu::Renderer;
|
||||||
use render3d::math::orbit_eye;
|
use render3d::math::orbit_eye;
|
||||||
use render3d::types::{Camera, Projection, WallInput};
|
use render3d::types::{Camera, Projection, WallInput};
|
||||||
|
|
||||||
use winit::application::ApplicationHandler;
|
use winit::application::ApplicationHandler;
|
||||||
use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
|
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
|
||||||
use winit::window::{Window, WindowId};
|
use winit::window::{Window, WindowId};
|
||||||
|
|
||||||
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
|
/// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen
|
||||||
@@ -197,9 +196,6 @@ struct App {
|
|||||||
orbit: Orbit,
|
orbit: Orbit,
|
||||||
dragging: bool,
|
dragging: bool,
|
||||||
last_cursor: (f64, f64),
|
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 {
|
impl ApplicationHandler for App {
|
||||||
@@ -227,27 +223,6 @@ impl ApplicationHandler for App {
|
|||||||
state.resize(size.width, size.height);
|
state.resize(size.width, size.height);
|
||||||
state.window.request_redraw();
|
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, .. } => {
|
WindowEvent::MouseInput { state: s, button, .. } => {
|
||||||
if button == MouseButton::Left {
|
if button == MouseButton::Left {
|
||||||
self.dragging = s == ElementState::Pressed;
|
self.dragging = s == ElementState::Pressed;
|
||||||
|
|||||||
@@ -15,13 +15,11 @@ use wgpu::util::DeviceExt;
|
|||||||
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX};
|
||||||
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX};
|
||||||
use crate::math::{view_projection, Mat4};
|
use crate::math::{view_projection, Mat4};
|
||||||
use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh};
|
use crate::mesh::{build_scene_mesh, build_walls_mesh};
|
||||||
use crate::section::SectionPlane;
|
use crate::section::SectionPlane;
|
||||||
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
|
use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX};
|
||||||
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL};
|
use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL};
|
||||||
use crate::types::{
|
use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX};
|
||||||
Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
/// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/
|
||||||
/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und
|
/// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und
|
||||||
@@ -32,9 +30,7 @@ pub enum RenderStyle {
|
|||||||
Shaded,
|
Shaded,
|
||||||
/// Clay-Look: einheitlich helles Material, beleuchtet.
|
/// Clay-Look: einheitlich helles Material, beleuchtet.
|
||||||
White,
|
White,
|
||||||
/// Echte Bild-Textur (prozedurales Schachbrett) auf den Wandflaechen,
|
/// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake).
|
||||||
/// beleuchtet wie `Shaded` (eigene Pipeline + Textur-Bind-Group group 1,
|
|
||||||
/// siehe `textured_pipeline`).
|
|
||||||
Textured,
|
Textured,
|
||||||
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
|
/// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen.
|
||||||
Wireframe,
|
Wireframe,
|
||||||
@@ -136,55 +132,6 @@ struct MeshBuffers {
|
|||||||
index_count: u32,
|
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
|
/// GPU-seitiger Vertex-Buffer einer LineList (kein Indexpuffer). Format
|
||||||
/// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten
|
/// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten
|
||||||
/// (identisches Layout/Shader, siehe grid.rs / edges.rs).
|
/// (identisches Layout/Shader, siehe grid.rs / edges.rs).
|
||||||
@@ -225,29 +172,9 @@ pub struct Renderer {
|
|||||||
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
/// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene
|
||||||
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
/// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None).
|
||||||
cap_pipeline: wgpu::RenderPipeline,
|
cap_pipeline: wgpu::RenderPipeline,
|
||||||
/// 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,
|
bind_group: wgpu::BindGroup,
|
||||||
uniform: wgpu::Buffer,
|
uniform: wgpu::Buffer,
|
||||||
mesh: Option<MeshBuffers>,
|
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
|
/// Modell-Kanten (Feature-Edges, edges.rs) als LineList. Einmalig bei
|
||||||
/// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in
|
/// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in
|
||||||
/// den Stilen wireframe/hidden.
|
/// den Stilen wireframe/hidden.
|
||||||
@@ -535,144 +462,6 @@ impl Renderer {
|
|||||||
cache: None,
|
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 {
|
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("globals.buffer"),
|
label: Some("globals.buffer"),
|
||||||
size: std::mem::size_of::<Globals>() as u64,
|
size: std::mem::size_of::<Globals>() as u64,
|
||||||
@@ -694,14 +483,9 @@ impl Renderer {
|
|||||||
grid_pipeline,
|
grid_pipeline,
|
||||||
highlight_pipeline,
|
highlight_pipeline,
|
||||||
cap_pipeline,
|
cap_pipeline,
|
||||||
textured_pipeline,
|
|
||||||
texture_bind_group,
|
|
||||||
texture,
|
|
||||||
texture_uploaded: false,
|
|
||||||
bind_group,
|
bind_group,
|
||||||
uniform,
|
uniform,
|
||||||
mesh: None,
|
mesh: None,
|
||||||
textured_mesh: None,
|
|
||||||
edges: None,
|
edges: None,
|
||||||
grid: None,
|
grid: None,
|
||||||
highlight: None,
|
highlight: None,
|
||||||
@@ -745,7 +529,6 @@ impl Renderer {
|
|||||||
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) {
|
||||||
if mesh.indices.is_empty() {
|
if mesh.indices.is_empty() {
|
||||||
self.mesh = None;
|
self.mesh = None;
|
||||||
self.textured_mesh = None;
|
|
||||||
self.edges = None;
|
self.edges = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -781,27 +564,6 @@ impl Renderer {
|
|||||||
ibo,
|
ibo,
|
||||||
index_count: mesh.indices.len() as u32,
|
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
|
/// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische
|
||||||
@@ -1021,33 +783,6 @@ impl Renderer {
|
|||||||
self.globals.mode[0] = self.style.mesh_mode();
|
self.globals.mode[0] = self.style.mesh_mode();
|
||||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals));
|
||||||
|
|
||||||
// Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine
|
|
||||||
// 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_depth(device, w, h);
|
||||||
self.ensure_msaa(device, w, h);
|
self.ensure_msaa(device, w, h);
|
||||||
let depth_view = &self.depth.as_ref().unwrap().view;
|
let depth_view = &self.depth.as_ref().unwrap().view;
|
||||||
@@ -1090,20 +825,7 @@ impl Renderer {
|
|||||||
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
// Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit
|
||||||
// die folgenden Kanten sauber obenauf liegen.
|
// die folgenden Kanten sauber obenauf liegen.
|
||||||
if self.style.draws_faces() {
|
if self.style.draws_faces() {
|
||||||
if self.style == RenderStyle::Textured {
|
if let Some(m) = &self.mesh {
|
||||||
// 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 {
|
let face_pipeline = if self.style == RenderStyle::Hidden {
|
||||||
&self.hidden_face_pipeline
|
&self.hidden_face_pipeline
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -38,27 +38,23 @@ pub use math::{
|
|||||||
view_matrix, view_projection, Mat4,
|
view_matrix, view_projection, Mat4,
|
||||||
};
|
};
|
||||||
pub use mesh::{
|
pub use mesh::{
|
||||||
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh,
|
append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab,
|
||||||
build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate,
|
extrude_wall, triangulate,
|
||||||
};
|
};
|
||||||
pub use section::{
|
pub use section::{
|
||||||
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
|
cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane,
|
||||||
};
|
};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput,
|
Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput,
|
||||||
TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
FLOATS_PER_VERTEX,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
|
// --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) -------------------
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::mesh::{
|
use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX};
|
||||||
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};
|
||||||
};
|
|
||||||
use super::types::{
|
|
||||||
Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
/// Bequemer Bau einer achsparallelen Wand entlang +X.
|
||||||
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
|
fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput {
|
||||||
@@ -84,67 +80,6 @@ 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]
|
#[test]
|
||||||
fn eine_wand_hat_quader_zaehlung() {
|
fn eine_wand_hat_quader_zaehlung() {
|
||||||
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
|
// Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke).
|
||||||
@@ -992,14 +927,5 @@ mod tests {
|
|||||||
Validator::new(ValidationFlags::all(), Capabilities::all())
|
Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||||
.validate(&cap_module)
|
.validate(&cap_module)
|
||||||
.unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}"));
|
.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::openings::{split_range_by_voids, MIN_SPAN};
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer,
|
Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer,
|
||||||
FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX,
|
FLOATS_PER_VERTEX,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
|
/// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2
|
||||||
@@ -917,72 +917,6 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh {
|
|||||||
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
|
/// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten
|
||||||
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
/// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt
|
||||||
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
/// vorne (deterministische Zaehlung fuer die Wand-Tests).
|
||||||
|
|||||||
@@ -125,89 +125,6 @@ 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:
|
/// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline:
|
||||||
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
/// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion
|
||||||
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
/// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline.
|
||||||
|
|||||||
@@ -333,30 +333,6 @@ pub struct Mesh {
|
|||||||
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
|
/// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe.
|
||||||
pub const FLOATS_PER_VERTEX: usize = 9;
|
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 {
|
impl Mesh {
|
||||||
/// Anzahl Vertices im Puffer.
|
/// Anzahl Vertices im Puffer.
|
||||||
pub fn vertex_count(&self) -> usize {
|
pub fn vertex_count(&self) -> usize {
|
||||||
|
|||||||
-13
@@ -2337,16 +2337,6 @@ export default function App() {
|
|||||||
onSetOpeningDir: (openingDir) => {
|
onSetOpeningDir: (openingDir) => {
|
||||||
if (selection?.kind === "opening") updateOpening(selection.id, { 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) ────────────────────
|
// ── Treppen-Attribute (nur bei selektierter Treppe) ────────────────────
|
||||||
onSetStairShape: (shape) => {
|
onSetStairShape: (shape) => {
|
||||||
if (selection?.kind === "stair") updateStair(selection.id, { shape });
|
if (selection?.kind === "stair") updateStair(selection.id, { shape });
|
||||||
@@ -2366,9 +2356,6 @@ export default function App() {
|
|||||||
onSetStairUp: (up) => {
|
onSetStairUp: (up) => {
|
||||||
if (selection?.kind === "stair") updateStair(selection.id, { 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) ─────────────────────────
|
// ── Raum-Attribute (nur bei selektiertem Raum) ─────────────────────────
|
||||||
onSetRoomName: (name) => {
|
onSetRoomName: (name) => {
|
||||||
if (selection?.kind === "room") updateRoom(selection.id, { name });
|
if (selection?.kind === "room") updateRoom(selection.id, { name });
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+19
-172
@@ -4,12 +4,9 @@
|
|||||||
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
|
// 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.
|
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
|
||||||
//
|
//
|
||||||
// Optionen (togglebar, IMMER sichtbar): Grundform gerade/L/Wendel, Auf-/Ab-
|
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
|
||||||
// Umschalter, Referenz (links/mitte/rechts), Trittmass-Modus (mit/ohne).
|
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
|
||||||
// Tab-Felder in BEIDEN Phasen: Breite (idle + run). Im Laufschritt zusätzlich:
|
// zeigt die Tritt-Linien + die Lauflinie.
|
||||||
// • 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):
|
// Herkunft der Treppen-Felder (CommandContext):
|
||||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||||
@@ -20,7 +17,7 @@
|
|||||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||||
|
|
||||||
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
|
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
|
||||||
import { stairGeometry, defaultStepCount, IDEAL_TREAD, MIN_STEPS } from "../../geometry/stair";
|
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
@@ -41,35 +38,6 @@ const STAIR_CATEGORY = "40";
|
|||||||
const DEF_WIDTH = 1.0;
|
const DEF_WIDTH = 1.0;
|
||||||
const EPS = 1e-6;
|
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 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 lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||||
const normv = (a: Vec2): Vec2 => {
|
const normv = (a: Vec2): Vec2 => {
|
||||||
@@ -97,45 +65,21 @@ function floorRise(ctx: CommandContext): number {
|
|||||||
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
|
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Trittmass-Ableitung ───────────────────────────────────────────────────────
|
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
|
||||||
/**
|
const RUN_FIELDS: CommandField[] = [
|
||||||
* Leitet die Stufenzahl aus Auftritt (Soll-Trittmass) + Lauflänge ab.
|
{ id: "width", labelKey: "cmd.field.width" },
|
||||||
* Klemmt auf [MIN_STEPS, maxByRun] (mind. 0.24 m Auftrittstiefe).
|
{ id: "steps", labelKey: "cmd.stair.stepsField" },
|
||||||
* Entspricht dem `target_a`-Modus im Rhino-Plugin (_make_treppe_preview_handler).
|
{ id: "rise", labelKey: "cmd.stair.riseField" },
|
||||||
*/
|
];
|
||||||
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 SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
|
||||||
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
|
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 {
|
interface StairIdle extends CommandState {
|
||||||
phase: "start";
|
phase: "start";
|
||||||
shape: StairShape;
|
shape: StairShape;
|
||||||
up: boolean;
|
up: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
referenz: "links" | "mitte" | "rechts";
|
|
||||||
treadMode: TreadMode;
|
|
||||||
auftritt: number;
|
|
||||||
steps: number | null; // null = automatisch
|
steps: number | null; // null = automatisch
|
||||||
rise: number | null; // null = Geschosshöhe
|
rise: number | null; // null = Geschosshöhe
|
||||||
}
|
}
|
||||||
@@ -144,9 +88,6 @@ interface StairRun extends CommandState {
|
|||||||
shape: StairShape;
|
shape: StairShape;
|
||||||
up: boolean;
|
up: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
referenz: "links" | "mitte" | "rechts";
|
|
||||||
treadMode: TreadMode;
|
|
||||||
auftritt: number;
|
|
||||||
steps: number | null;
|
steps: number | null;
|
||||||
rise: number | null;
|
rise: number | null;
|
||||||
start: Vec2;
|
start: Vec2;
|
||||||
@@ -154,30 +95,14 @@ interface StairRun extends CommandState {
|
|||||||
}
|
}
|
||||||
type StairCmdState = StairIdle | StairRun;
|
type StairCmdState = StairIdle | StairRun;
|
||||||
|
|
||||||
/** Neuer Ruhezustand aus den modulweiten Defaults (oder überschrieben). */
|
function initState(shape: StairShape = "straight"): StairIdle {
|
||||||
function initState(override?: Partial<typeof stairDefaults>): StairIdle {
|
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
|
||||||
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
|
* 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°
|
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
|
||||||
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
|
* (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 {
|
function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||||
const rise = s.rise ?? floorRise(ctx);
|
const rise = s.rise ?? floorRise(ctx);
|
||||||
@@ -185,15 +110,7 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
|||||||
const dist = Math.max(0.1, lenOf(d));
|
const dist = Math.max(0.1, lenOf(d));
|
||||||
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
|
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
|
||||||
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
|
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 = {
|
const base: Stair = {
|
||||||
id: uniqueId("ST"),
|
id: uniqueId("ST"),
|
||||||
type: "stair",
|
type: "stair",
|
||||||
@@ -204,7 +121,6 @@ function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
|||||||
dir,
|
dir,
|
||||||
runLength,
|
runLength,
|
||||||
width: s.width,
|
width: s.width,
|
||||||
referenz: s.referenz,
|
|
||||||
totalRise: rise,
|
totalRise: rise,
|
||||||
stepCount: steps,
|
stepCount: steps,
|
||||||
up: s.up,
|
up: s.up,
|
||||||
@@ -240,42 +156,18 @@ function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
|
|||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hängt eine Treppe ans Projekt (immutabel) und aktualisiert die Defaults. */
|
/** Hängt eine Treppe ans Projekt (immutabel). */
|
||||||
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
|
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
|
||||||
const stair = buildStair(s, ctx);
|
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];
|
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
|
||||||
return { ...p, stairs };
|
return { ...p, stairs };
|
||||||
}
|
}
|
||||||
|
|
||||||
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
|
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
|
||||||
initState(from ? {
|
initState(from?.shape ?? "straight"),
|
||||||
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 },
|
{ 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 = {
|
export const stairCommand: Command = {
|
||||||
name: "stair",
|
name: "stair",
|
||||||
labelKey: "cmd.stair.label",
|
labelKey: "cmd.stair.label",
|
||||||
@@ -285,20 +177,14 @@ export const stairCommand: Command = {
|
|||||||
(s as StairCmdState).phase === "run"
|
(s as StairCmdState).phase === "run"
|
||||||
? ["point", "number", "option"]
|
? ["point", "number", "option"]
|
||||||
: ["point", "option"],
|
: ["point", "option"],
|
||||||
|
|
||||||
/** Optionen werden IMMER angezeigt (idle + run): Grundform, Auf/Ab, Referenz,
|
|
||||||
* Trittmass-Modus. */
|
|
||||||
options: (s): CmdOption[] => {
|
options: (s): CmdOption[] => {
|
||||||
const st = s as StairCmdState;
|
const st = s as StairCmdState;
|
||||||
return [
|
return [
|
||||||
{ ...SHAPE, value: st.shape },
|
{ ...SHAPE, value: st.shape },
|
||||||
{ ...UPDOWN, value: st.up ? "up" : "down" },
|
{ ...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] => {
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
const s = state as StairCmdState;
|
const s = state as StairCmdState;
|
||||||
@@ -309,12 +195,6 @@ export const stairCommand: Command = {
|
|||||||
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
|
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
|
||||||
} else if (input.id === "updown") {
|
} else if (input.id === "updown") {
|
||||||
next.up = !s.up;
|
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 }];
|
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
|
||||||
}
|
}
|
||||||
@@ -331,9 +211,6 @@ export const stairCommand: Command = {
|
|||||||
shape: s.shape,
|
shape: s.shape,
|
||||||
up: s.up,
|
up: s.up,
|
||||||
width: s.width,
|
width: s.width,
|
||||||
referenz: s.referenz,
|
|
||||||
treadMode: s.treadMode,
|
|
||||||
auftritt: s.auftritt,
|
|
||||||
steps: s.steps,
|
steps: s.steps,
|
||||||
rise: s.rise,
|
rise: s.rise,
|
||||||
start: pt,
|
start: pt,
|
||||||
@@ -372,50 +249,20 @@ export const stairCommand: Command = {
|
|||||||
},
|
},
|
||||||
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
|
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
|
||||||
|
|
||||||
/**
|
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
|
||||||
* Tab-Felder:
|
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||||
* • 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) => {
|
pointFromFields: (state, locks) => {
|
||||||
const s = state as StairCmdState;
|
const s = state as StairCmdState;
|
||||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||||
if (s.phase === "run") {
|
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
|
||||||
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;
|
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
fieldValues: (state, _locks, _cursor): Record<string, number> => {
|
fieldValues: (state, _locks, _cursor): Record<string, number> => {
|
||||||
const s = state as StairCmdState;
|
const s = state as StairCmdState;
|
||||||
const out: Record<string, number> = { width: s.width };
|
const out: Record<string, number> = { width: s.width };
|
||||||
if (s.phase === "run") {
|
|
||||||
if (s.treadMode === "with") {
|
|
||||||
out.auftritt = s.auftritt;
|
|
||||||
} else {
|
|
||||||
if (s.steps != null) out.steps = s.steps;
|
if (s.steps != null) out.steps = s.steps;
|
||||||
if (s.rise != null) out.rise = s.rise;
|
if (s.rise != null) out.rise = s.rise;
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,980 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-27
@@ -170,21 +170,12 @@ export interface WindowSymbol {
|
|||||||
frame: Vec2[];
|
frame: Vec2[];
|
||||||
/** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */
|
/** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */
|
||||||
glassLines: [Vec2, Vec2][];
|
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
|
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
|
||||||
* Wanddicke) plus 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
* Wanddicke) plus 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
||||||
* `count` steuert die Anzahl je Detailgrad) plus optionale Flügel-Mittelpfosten
|
* `count` steuert die Anzahl je Detailgrad).
|
||||||
* (`wingCount − 1` Querlinien).
|
|
||||||
*/
|
*/
|
||||||
export function windowSymbol(
|
export function windowSymbol(
|
||||||
project: Project,
|
project: Project,
|
||||||
@@ -214,21 +205,5 @@ export function windowSymbol(
|
|||||||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||||||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||||||
}
|
}
|
||||||
// Flügel-Mittelpfosten: (wingCount − 1) Querlinien gleichmäßig zwischen den
|
return { frame, glassLines };
|
||||||
// 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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-177
@@ -27,32 +27,6 @@ const norm = (a: Vec2): Vec2 => {
|
|||||||
/** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */
|
/** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */
|
||||||
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
|
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. */
|
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
|
||||||
export const IDEAL_RISER = 0.17;
|
export const IDEAL_RISER = 0.17;
|
||||||
export const IDEAL_TREAD = 0.29;
|
export const IDEAL_TREAD = 0.29;
|
||||||
@@ -131,22 +105,13 @@ export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
|
|||||||
const u = norm(stair.dir);
|
const u = norm(stair.dir);
|
||||||
const n = leftN(u);
|
const n = leftN(u);
|
||||||
|
|
||||||
// Referenz (links/mitte/rechts): die gespeicherte Achse wird auf die visuelle
|
if (stair.shape === "spiral") {
|
||||||
// Treppen-MITTE versetzt, danach greift die bestehende, um die Achse zentrierte
|
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
|
||||||
// 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 (s.shape === "L") {
|
if (stair.shape === "L") {
|
||||||
return lGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
|
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||||
}
|
}
|
||||||
return straightGeometry(s, totalRise, steps, riserHeight, halfW, u, n);
|
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
|
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
|
||||||
@@ -444,140 +409,3 @@ export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
|
|||||||
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
|
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
|
||||||
return false;
|
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,16 +301,6 @@ export const de = {
|
|||||||
"objinfo.opening.dir.out": "Außen",
|
"objinfo.opening.dir.out": "Außen",
|
||||||
"objinfo.opening.bottom": "Unterkante (UK)",
|
"objinfo.opening.bottom": "Unterkante (UK)",
|
||||||
"objinfo.opening.top": "Oberkante (OK)",
|
"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) ───────────────────────────────
|
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
|
||||||
"objinfo.stair.section": "Treppe",
|
"objinfo.stair.section": "Treppe",
|
||||||
"objinfo.stair.shape": "Grundform",
|
"objinfo.stair.shape": "Grundform",
|
||||||
@@ -328,11 +318,6 @@ export const de = {
|
|||||||
"objinfo.stair.refFloor": "Referenzgeschoss",
|
"objinfo.stair.refFloor": "Referenzgeschoss",
|
||||||
"objinfo.stair.bottom": "Unterkante (UK)",
|
"objinfo.stair.bottom": "Unterkante (UK)",
|
||||||
"objinfo.stair.top": "Oberkante (OK)",
|
"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) ──────────────────────────────────
|
// ── Raum-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||||
"objinfo.room.section": "Raum",
|
"objinfo.room.section": "Raum",
|
||||||
"objinfo.room.name": "Name",
|
"objinfo.room.name": "Name",
|
||||||
@@ -684,9 +669,6 @@ export const de = {
|
|||||||
"cmd.stair.updown": "Auf/Ab",
|
"cmd.stair.updown": "Auf/Ab",
|
||||||
"cmd.stair.stepsField": "Stufen",
|
"cmd.stair.stepsField": "Stufen",
|
||||||
"cmd.stair.riseField": "Steighöhe",
|
"cmd.stair.riseField": "Steighöhe",
|
||||||
"cmd.stair.referenz": "Referenz",
|
|
||||||
"cmd.stair.treadMode": "Trittmass",
|
|
||||||
"cmd.stair.auftrittField": "Auftritt",
|
|
||||||
"cmd.line.label": "Linie",
|
"cmd.line.label": "Linie",
|
||||||
"cmd.line.start": "Startpunkt der Linie:",
|
"cmd.line.start": "Startpunkt der Linie:",
|
||||||
"cmd.line.end": "Endpunkt der Linie:",
|
"cmd.line.end": "Endpunkt der Linie:",
|
||||||
|
|||||||
@@ -301,15 +301,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.opening.dir.out": "Out",
|
"objinfo.opening.dir.out": "Out",
|
||||||
"objinfo.opening.bottom": "Bottom (UK)",
|
"objinfo.opening.bottom": "Bottom (UK)",
|
||||||
"objinfo.opening.top": "Top (OK)",
|
"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.section": "Stair",
|
||||||
"objinfo.stair.shape": "Shape",
|
"objinfo.stair.shape": "Shape",
|
||||||
"objinfo.stair.shape.straight": "Straight",
|
"objinfo.stair.shape.straight": "Straight",
|
||||||
@@ -326,10 +317,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"objinfo.stair.refFloor": "Reference floor",
|
"objinfo.stair.refFloor": "Reference floor",
|
||||||
"objinfo.stair.bottom": "Bottom (UK)",
|
"objinfo.stair.bottom": "Bottom (UK)",
|
||||||
"objinfo.stair.top": "Top (OK)",
|
"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) ──────────────────────────────────
|
// ── Room attributes (object info panel) ──────────────────────────────────
|
||||||
"objinfo.room.section": "Room",
|
"objinfo.room.section": "Room",
|
||||||
"objinfo.room.name": "Name",
|
"objinfo.room.name": "Name",
|
||||||
@@ -674,9 +661,6 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"cmd.stair.updown": "Up/Down",
|
"cmd.stair.updown": "Up/Down",
|
||||||
"cmd.stair.stepsField": "Steps",
|
"cmd.stair.stepsField": "Steps",
|
||||||
"cmd.stair.riseField": "Rise",
|
"cmd.stair.riseField": "Rise",
|
||||||
"cmd.stair.referenz": "Reference",
|
|
||||||
"cmd.stair.treadMode": "Tread",
|
|
||||||
"cmd.stair.auftrittField": "Tread depth",
|
|
||||||
"cmd.line.label": "Line",
|
"cmd.line.label": "Line",
|
||||||
"cmd.line.start": "Start of line:",
|
"cmd.line.start": "Start of line:",
|
||||||
"cmd.line.end": "End of line:",
|
"cmd.line.end": "End of line:",
|
||||||
|
|||||||
@@ -1,433 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+26
-645
@@ -17,13 +17,6 @@
|
|||||||
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
|
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
|
||||||
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
|
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
|
||||||
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
// • 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 DxfParser from "dxf-parser";
|
||||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||||
@@ -68,51 +61,10 @@ interface DxfEntity {
|
|||||||
faces?: number[][];
|
faces?: number[][];
|
||||||
isPolyfaceMesh?: boolean;
|
isPolyfaceMesh?: boolean;
|
||||||
is3dPolygonMesh?: 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;
|
[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 {
|
interface DxfDocument {
|
||||||
entities?: DxfEntity[];
|
entities?: DxfEntity[];
|
||||||
blocks?: Record<string, DxfBlock>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let importSeq = 0;
|
let importSeq = 0;
|
||||||
@@ -126,16 +78,9 @@ const nextId = (prefix: string) =>
|
|||||||
*/
|
*/
|
||||||
export function parseDxf(text: string): DxfImportResult {
|
export function parseDxf(text: string): DxfImportResult {
|
||||||
const parser = new DxfParser();
|
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.
|
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
|
||||||
const doc = parser.parseSync(text) as unknown as DxfDocument;
|
const doc = parser.parseSync(text) as unknown as DxfDocument;
|
||||||
const entities = doc?.entities ?? [];
|
const entities = doc?.entities ?? [];
|
||||||
const blocks = doc?.blocks ?? {};
|
|
||||||
|
|
||||||
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
|
||||||
const meshIndices: number[] = [];
|
const meshIndices: number[] = [];
|
||||||
@@ -143,22 +88,36 @@ export function parseDxf(text: string): DxfImportResult {
|
|||||||
|
|
||||||
for (const e of entities) {
|
for (const e of entities) {
|
||||||
const type = (e.type ?? "").toUpperCase();
|
const type = (e.type ?? "").toUpperCase();
|
||||||
// Mesh-Entities in die Dreiecks-Puffer; POLYLINE nur als Mesh-Variante.
|
switch (type) {
|
||||||
if (type === "3DFACE") {
|
case "3DFACE":
|
||||||
addFace(meshTriangles, meshIndices, e);
|
addFace(meshTriangles, meshIndices, e);
|
||||||
continue;
|
break;
|
||||||
}
|
case "MESH":
|
||||||
if (type === "MESH") {
|
|
||||||
addMesh(meshTriangles, meshIndices, e);
|
addMesh(meshTriangles, meshIndices, e);
|
||||||
continue;
|
break;
|
||||||
}
|
case "POLYLINE":
|
||||||
if (type === "POLYLINE" && isMeshPolyline(e)) {
|
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
|
||||||
|
if (isMeshPolyline(e)) {
|
||||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||||
continue;
|
} 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;
|
||||||
}
|
}
|
||||||
// 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[] = [];
|
const meshes: ImportedMesh[] = [];
|
||||||
@@ -349,581 +308,3 @@ function lineContour(e: DxfEntity): Contour | null {
|
|||||||
layer: e.layer,
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-11
@@ -23,9 +23,6 @@ export type CategoryMode = "active" | "byLayer";
|
|||||||
let drawingSeq = 0;
|
let drawingSeq = 0;
|
||||||
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
|
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
|
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
|
||||||
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
|
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
|
||||||
@@ -48,19 +45,13 @@ export function contoursToDrawings(
|
|||||||
categoryMode === "byLayer" && layerName
|
categoryMode === "byLayer" && layerName
|
||||||
? layerToCode(layerName)
|
? layerToCode(layerName)
|
||||||
: fallbackCode;
|
: fallbackCode;
|
||||||
const drawing: Drawing2D = {
|
out.push({
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
type: "drawing2d",
|
type: "drawing2d",
|
||||||
levelId,
|
levelId,
|
||||||
categoryCode,
|
categoryCode,
|
||||||
geom,
|
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;
|
return out;
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ function iwWall(id: string, start: Vec2, end: Vec2): Wall {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
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 — materialgleicher Nah-Putz ⇒ KEINE L-Trennnaht", () => {
|
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", () => {
|
||||||
// Durchgangswand WA1 (0,0)→(5,0) / WA2 (5,0)→(10,0), Abzweig WB (5,0)→(5,3),
|
// 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).
|
// alle vom selben Wandtyp "iw" (Innenputz/Backstein/Innenputz, T=0.15).
|
||||||
const proj = layeredWallProject(
|
const proj = layeredWallProject(
|
||||||
@@ -303,13 +303,13 @@ describe("computeJoins — materialbewusster T-Stoss (layerCuts)", () => {
|
|||||||
expect(lc.start[2]).not.toBeNull();
|
expect(lc.start[2]).not.toBeNull();
|
||||||
expect(distToLine(lc.start[0]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
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);
|
expect(distToLine(lc.start[2]!, { x: 5, y: 0.075 })).toBeCloseTo(0, 6);
|
||||||
// ... aber KEINE L-Seitenlinie: der Nah-Putz der Durchgangswand ist
|
// ... UND bekommen die zusätzliche L-Seitenlinie an der Rückgrat-Nahfläche
|
||||||
// materialgleicher Innenputz und füllt den getrimmten Abzweig-Putz an den
|
// (y = 0.06, parallel zur Durchgangsachse) — dort endet der durchstechende
|
||||||
// Flanken durchgehend (der spanCutout schneidet nur die Kernbreite aus).
|
// Kern.
|
||||||
// Es entsteht ein durchgehendes Putz-L ohne Trennnaht. Nur bei material-
|
expect(lc.startSide[0]).not.toBeNull();
|
||||||
// FREMDEM Nah-Putz stünde hier eine L-Linie.
|
expect(lc.startSide[2]).not.toBeNull();
|
||||||
expect(lc.startSide[0]).toBeNull();
|
expect(distToLine(lc.startSide[0]!, { x: 5, y: 0.06 })).toBeCloseTo(0, 6);
|
||||||
expect(lc.startSide[2]).toBeNull();
|
expect(distToLine(lc.startSide[0]!, { x: 0, y: 0.06 })).toBeCloseTo(0, 6);
|
||||||
|
|
||||||
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
|
// Die kollinearen Hälften der Durchgangswand bleiben bei den layerCuts
|
||||||
// unangetastet (keine Pro-Schicht-Cuts für sie).
|
// unangetastet (keine Pro-Schicht-Cuts für sie).
|
||||||
|
|||||||
+1
-13
@@ -421,23 +421,11 @@ function applyLayerCuts(
|
|||||||
// Nachbarschicht kappt ihre Breite.
|
// Nachbarschicht kappt ihre Breite.
|
||||||
const sideLine: Line = mergeCut;
|
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++) {
|
for (let i = 0; i < n; i++) {
|
||||||
faceArr[i] = merged[i] ? mergeCut : faceCut;
|
faceArr[i] = merged[i] ? mergeCut : faceCut;
|
||||||
const adjMerged =
|
const adjMerged =
|
||||||
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
|
!merged[i] && ((i > 0 && merged[i - 1]) || (i < n - 1 && merged[i + 1]));
|
||||||
const mergesWithNearPlaster = nearPlasterComps.has(branchWt.layers[i].componentId);
|
sideArr[i] = adjMerged ? sideLine : null;
|
||||||
sideArr[i] = adjMerged && !mergesWithNearPlaster ? sideLine : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
|
// Durchgangswand-Aussparung (Phase 1c): den Nah-Putz der Durchgangswand über
|
||||||
|
|||||||
@@ -731,22 +731,6 @@ export interface Opening {
|
|||||||
height: number;
|
height: number;
|
||||||
/** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */
|
/** Brüstungshöhe (Unterkante der Öffnung) über der Wand-UK; 0 bei Türen. */
|
||||||
sillHeight: number;
|
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. */
|
/** Nur Tür: an welchem Pfosten das Scharnier sitzt. */
|
||||||
hinge?: "start" | "end";
|
hinge?: "start" | "end";
|
||||||
/** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */
|
/** Nur Tür: auf welche Seite der Wandachse das Blatt aufschlägt. */
|
||||||
@@ -757,15 +741,6 @@ export interface Opening {
|
|||||||
openingDir?: "in" | "out";
|
openingDir?: "in" | "out";
|
||||||
/** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */
|
/** Optionale Rahmenstärke (quer zur Wand) in Metern für die 3D-Darstellung. */
|
||||||
frameThickness?: number;
|
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
|
* Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
|
||||||
* Kategorie-Farbe.
|
* Kategorie-Farbe.
|
||||||
@@ -830,19 +805,6 @@ export interface Stair {
|
|||||||
sweep?: number;
|
sweep?: number;
|
||||||
/** Laufbreite in Metern (quer zur Laufrichtung). */
|
/** Laufbreite in Metern (quer zur Laufrichtung). */
|
||||||
width: number;
|
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
|
* Gesamt-Steighöhe in Metern (OKFF → OKFF nächstes Geschoss). Fehlt sie, gilt
|
||||||
* beim Auflösen die Geschosshöhe des zugehörigen Geschosses.
|
* beim Auflösen die Geschosshöhe des zugehörigen Geschosses.
|
||||||
@@ -1093,11 +1055,6 @@ export interface Contour {
|
|||||||
closed: boolean;
|
closed: boolean;
|
||||||
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
||||||
layer?: string;
|
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). */
|
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
|
||||||
|
|||||||
@@ -297,22 +297,6 @@ function StairSection({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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). */}
|
{/* Laufrichtung (aufwärts/abwärts). */}
|
||||||
<div className="objinfo-field">
|
<div className="objinfo-field">
|
||||||
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
|
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
|
||||||
@@ -442,55 +426,16 @@ function OpeningSection({
|
|||||||
onCommit={(v) => host.onSetOpeningHeight(v)}
|
onCommit={(v) => host.onSetOpeningHeight(v)}
|
||||||
/>
|
/>
|
||||||
{!isDoor && (
|
{!isDoor && (
|
||||||
<>
|
|
||||||
<DimensionField
|
<DimensionField
|
||||||
label={t("objinfo.opening.sill")}
|
label={t("objinfo.opening.sill")}
|
||||||
value={opening.sillHeight}
|
value={opening.sillHeight}
|
||||||
onCommit={(v) => host.onSetOpeningSill(v)}
|
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: Typ / Sturzlinien / Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
||||||
{isDoor && (
|
{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">
|
<div className="objinfo-field">
|
||||||
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
|
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -236,12 +236,6 @@ export interface PanelHostValue {
|
|||||||
onSetOpeningSwing: (swing: "left" | "right") => void;
|
onSetOpeningSwing: (swing: "left" | "right") => void;
|
||||||
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
|
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
|
||||||
onSetOpeningDir: (dir: "in" | "out") => void;
|
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) ───
|
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
||||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||||
@@ -254,8 +248,6 @@ export interface PanelHostValue {
|
|||||||
onSetStairRise: (totalRise: number) => void;
|
onSetStairRise: (totalRise: number) => void;
|
||||||
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
||||||
onSetStairUp: (up: boolean) => void;
|
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) ────────
|
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
|
||||||
/** Setzt den Raum-Namen. */
|
/** Setzt den Raum-Namen. */
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,9 +6,8 @@
|
|||||||
* • Der Backstein-Kern des Abzweigs sticht durch den Nah-Putz der Durchgangs-
|
* • 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,
|
* wand und stoppt an deren Kern-Nahfläche (kein Durchlaufen bis zur Achse,
|
||||||
* kein Überlappen) — "durchstechender Kern".
|
* kein Überlappen) — "durchstechender Kern".
|
||||||
* • Die Innenputz-Schichten des Abzweigs enden an der Nahfläche. Ist der
|
* • Die Innenputz-Schichten des Abzweigs enden weiterhin an der Nahfläche
|
||||||
* Nah-Putz der Durchgangswand materialgleich, füllt er die Flanken durchgehend
|
* UND bekommen eine zusätzliche seitliche L-Linie (cls "layer-joint-l").
|
||||||
* → durchgehendes Putz-L OHNE Trennnaht (keine "layer-joint-l"-Linie).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
@@ -97,17 +96,22 @@ describe("generatePlan — materialbewusster T-Stoss (Poché-Bänder)", () => {
|
|||||||
expect(brick.pts[3].y).toBeCloseTo(0.06, 6);
|
expect(brick.pts[3].y).toBeCloseTo(0.06, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zeichnet KEINE L-Seitenlinie, wenn der getrimmte Putz materialgleich mit dem durchgehenden Nah-Putz der Durchgangswand ist (durchgehendes Putz-L ohne Naht)", () => {
|
it("zeichnet die L-Seitenlinie nur für die getrimmten Putzschichten, nicht für den durchlaufenden Kern", () => {
|
||||||
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
|
const plan = generatePlan(tJunctionProject(), "eg", visible, undefined, "mittel");
|
||||||
const lLines = plan.primitives.filter(
|
const lLines = plan.primitives.filter(
|
||||||
(p): p is Extract<typeof p, { kind: "line" }> => p.kind === "line" && p.cls === "layer-joint-l",
|
(p): p is Extract<typeof p, { kind: "line" }> => p.kind === "line" && p.cls === "layer-joint-l",
|
||||||
);
|
);
|
||||||
// Alle drei Wände sind vom selben iw-Typ: der Innenputz des Abzweigs trifft
|
// Genau zwei L-Linien (eine je Innenputz-Schicht); der Backstein-Kern
|
||||||
// an der Flanke auf den materialgleichen Innenputz der Durchgangswand (der
|
// bekommt keine (er verschmilzt, kein Sprung im Material).
|
||||||
// spanCutout schneidet nur die Kernbreite aus, nicht die Putzflanken). Der
|
expect(lLines.length).toBe(2);
|
||||||
// Putz läuft dort durchgehend ums Eck (L) — eine Trennnaht würde eine falsche
|
|
||||||
// Materialgrenze suggerieren. Daher KEINE L-Linie.
|
// Jede L-Linie verläuft an der Rückgrat-Nahfläche (y=0.06, parallel zur
|
||||||
expect(lLines.length).toBe(0);
|
// 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
|
it("bricht die Nah-Putz-Schicht der Durchgangswand am Durchstoss auf (Phase 1c)", () => {
|
||||||
|
|||||||
+13
-278
@@ -38,20 +38,12 @@ import { docFromText } from "../text/richText";
|
|||||||
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
|
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
|
||||||
import type { RoomStampLine } from "../model/roomStamp";
|
import type { RoomStampLine } from "../model/roomStamp";
|
||||||
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
|
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
|
||||||
import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
|
import { stairGeometry, stairCut } from "../geometry/stair";
|
||||||
import type { SpiralOutlineSegments } from "../geometry/stair";
|
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
|
||||||
import {
|
|
||||||
doorSymbol,
|
|
||||||
openingInterval,
|
|
||||||
openingJambs,
|
|
||||||
wallAxisFrame,
|
|
||||||
windowSymbol,
|
|
||||||
} from "../geometry/opening";
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
along,
|
along,
|
||||||
clippedBand,
|
clippedBand,
|
||||||
dot,
|
|
||||||
leftNormal,
|
leftNormal,
|
||||||
lineIntersect,
|
lineIntersect,
|
||||||
normalize,
|
normalize,
|
||||||
@@ -268,8 +260,6 @@ export type Primitive =
|
|||||||
dash?: number[] | null;
|
dash?: number[] | null;
|
||||||
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
|
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
|
||||||
openingId?: string;
|
openingId?: string;
|
||||||
/** ID der Treppe (für Links-Klick-Auswahl von Wendel-Outline-Bögen). */
|
|
||||||
stairId?: string;
|
|
||||||
greyed?: boolean;
|
greyed?: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -584,11 +574,6 @@ export function generatePlan(
|
|||||||
visibleCodes.has(c.categoryCode) &&
|
visibleCodes.has(c.categoryCode) &&
|
||||||
categoryDisplay(c.categoryCode).render,
|
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) {
|
for (const ceiling of ceilings) {
|
||||||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||||||
const category = catByCode.get(ceiling.categoryCode);
|
const category = catByCode.get(ceiling.categoryCode);
|
||||||
@@ -598,7 +583,7 @@ export function generatePlan(
|
|||||||
category,
|
category,
|
||||||
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
|
lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM,
|
||||||
);
|
);
|
||||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category, wallFootprints);
|
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||||||
@@ -1092,88 +1077,6 @@ function subtractIntervals(
|
|||||||
return parts;
|
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(
|
function addWallPoche(
|
||||||
out: Primitive[],
|
out: Primitive[],
|
||||||
project: Project,
|
project: Project,
|
||||||
@@ -1588,12 +1491,6 @@ function addOpeningSymbol(
|
|||||||
if (o.kind === "door") {
|
if (o.kind === "door") {
|
||||||
const sym = doorSymbol(wall, o);
|
const sym = doorSymbol(wall, o);
|
||||||
if (!sym) return;
|
if (!sym) return;
|
||||||
// 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).
|
// Türblatt (alle Stufen).
|
||||||
out.push({
|
out.push({
|
||||||
kind: "line",
|
kind: "line",
|
||||||
@@ -1618,7 +1515,6 @@ function addOpeningSymbol(
|
|||||||
openingId: o.id,
|
openingId: o.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
|
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
|
||||||
if (detail === "fein") {
|
if (detail === "fein") {
|
||||||
const reveal = 0.06;
|
const reveal = 0.06;
|
||||||
@@ -1634,57 +1530,6 @@ 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1704,20 +1549,6 @@ function addOpeningSymbol(
|
|||||||
greyed,
|
greyed,
|
||||||
openingId: o.id,
|
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) {
|
for (const [a, b] of sym.glassLines) {
|
||||||
out.push({
|
out.push({
|
||||||
@@ -1730,26 +1561,6 @@ function addOpeningSymbol(
|
|||||||
openingId: o.id,
|
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). */
|
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
|
||||||
@@ -1803,7 +1614,6 @@ function addCeilingPoche(
|
|||||||
detail: DetailLevel,
|
detail: DetailLevel,
|
||||||
lwMm: number,
|
lwMm: number,
|
||||||
category: LayerCategory | undefined,
|
category: LayerCategory | undefined,
|
||||||
wallFootprints: WallFootprint[],
|
|
||||||
): void {
|
): void {
|
||||||
const pts = ceiling.outline;
|
const pts = ceiling.outline;
|
||||||
if (pts.length < 3) return;
|
if (pts.length < 3) return;
|
||||||
@@ -1836,47 +1646,27 @@ function addCeilingPoche(
|
|||||||
resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
|
resolveForeground(comp, ceiling.foreground, category, ceiling.foregroundSource),
|
||||||
)
|
)
|
||||||
: NO_HATCH;
|
: 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({
|
out.push({
|
||||||
kind: "polygon",
|
kind: "polygon",
|
||||||
pts,
|
pts,
|
||||||
fill: comp ? pocheFill(hatch.pattern) : "none",
|
fill: comp ? pocheFill(hatch.pattern) : "none",
|
||||||
stroke: "none",
|
stroke,
|
||||||
strokeWidthMm: 0,
|
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||||
hatch,
|
hatch,
|
||||||
greyed,
|
greyed,
|
||||||
ceilingId,
|
ceilingId,
|
||||||
});
|
});
|
||||||
// Kräftige Umrisslinie NUR dort, wo keine Wand die Decke verdeckt: jede
|
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
|
||||||
// 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({
|
out.push({
|
||||||
kind: "line",
|
kind: "polygon",
|
||||||
a: add(a, scale(d, t0)),
|
pts,
|
||||||
b: add(a, scale(d, t1)),
|
fill: "none",
|
||||||
cls: "ceiling-outline",
|
stroke,
|
||||||
weightMm: outlineMm,
|
strokeWidthMm: outlineMm,
|
||||||
color: stroke,
|
hatch: NO_HATCH,
|
||||||
greyed,
|
greyed,
|
||||||
|
ceilingId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1966,40 +1756,6 @@ 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):
|
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
|
||||||
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
|
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
|
||||||
@@ -2104,27 +1860,6 @@ function addStairSymbol(
|
|||||||
const [h1, tip, h2] = geo.arrow.head;
|
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: 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 });
|
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);
|
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
|
||||||
|
|||||||
@@ -137,12 +137,6 @@ export interface OpeningInfo {
|
|||||||
swing?: "left" | "right";
|
swing?: "left" | "right";
|
||||||
swingAngle?: number;
|
swingAngle?: number;
|
||||||
openingDir?: "in" | "out";
|
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). */
|
/** Aufgelöste absolute UK/OK (Meter). */
|
||||||
zBottom: number;
|
zBottom: number;
|
||||||
zTop: number;
|
zTop: number;
|
||||||
@@ -166,8 +160,6 @@ export interface StairInfo {
|
|||||||
treadDepth: number;
|
treadDepth: number;
|
||||||
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
||||||
up: boolean;
|
up: boolean;
|
||||||
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
|
|
||||||
referenz?: "links" | "mitte" | "rechts";
|
|
||||||
/** Geschoss, dem die Treppe zugeordnet ist. */
|
/** Geschoss, dem die Treppe zugeordnet ist. */
|
||||||
floorId: string;
|
floorId: string;
|
||||||
floorName: string;
|
floorName: string;
|
||||||
@@ -486,9 +478,6 @@ function openingSelection(project: Project, o: Opening): Selection {
|
|||||||
swing: o.swing,
|
swing: o.swing,
|
||||||
swingAngle: o.swingAngle,
|
swingAngle: o.swingAngle,
|
||||||
openingDir: o.openingDir,
|
openingDir: o.openingDir,
|
||||||
wingCount: o.wingCount,
|
|
||||||
doorType: o.doorType,
|
|
||||||
lintelLines: o.lintelLines,
|
|
||||||
zBottom: ext.zBottom,
|
zBottom: ext.zBottom,
|
||||||
zTop: ext.zTop,
|
zTop: ext.zTop,
|
||||||
};
|
};
|
||||||
@@ -524,7 +513,6 @@ function stairSelection(project: Project, s: Stair): Selection {
|
|||||||
riserHeight: geo.riserHeight,
|
riserHeight: geo.riserHeight,
|
||||||
treadDepth: geo.treadDepth,
|
treadDepth: geo.treadDepth,
|
||||||
up: s.up !== false,
|
up: s.up !== false,
|
||||||
referenz: s.referenz,
|
|
||||||
floorId: s.floorId,
|
floorId: s.floorId,
|
||||||
floorName: floor?.name ?? s.floorId,
|
floorName: floor?.name ?? s.floorId,
|
||||||
zBottom,
|
zBottom,
|
||||||
|
|||||||
@@ -39,12 +39,11 @@ export const ACCENT_SWATCHES: AccentSwatch[] = [
|
|||||||
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
|
export const DEFAULT_MARQUEE_COLOR = "#CEB188";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snap-/Endpunkt-Default: Akzent „Sora" (#5FA1C9). Vom Nutzer als endgültiger
|
* Snap-/Endpunkt-Default: Akzent „Sora" (vorläufig — die offene Rückfrage
|
||||||
* Default festgelegt (2026-07-04) — die frühere „aki"-Rückfrage ist damit
|
* „aki" im HANDOVER-Backlog ist noch nicht geklärt). Bewusst ein kühler,
|
||||||
* geschlossen. Bewusst ein kühler, heller Ton: hebt sich klar vom warmen
|
* heller Ton: hebt sich klar vom warmen Yuyake-Auswahlrahmen UND von den
|
||||||
* Yuyake-Auswahlrahmen UND von den meist neutral-dunklen Wand-/Linienfarben ab,
|
* meist neutral-dunklen Wand-/Linienfarben ab, bleibt aber (anders als das
|
||||||
* bleibt aber (anders als das sehr blasse Kori) auch auf hellem
|
* sehr blasse Kori) auch auf hellem Papier-Hintergrund gut sichtbar.
|
||||||
* Papier-Hintergrund gut sichtbar. Per Picker im Einstellungs-Fenster änderbar.
|
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
|
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user